From 0f2f21c5dcca10e451e8e4106678c2701483c680 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 20 Mar 2026 16:06:28 +0100 Subject: [PATCH 1/2] Persisted Documents --- .changeset/negative_cache_single_flight.md | 10 + .changeset/persisted_documents.md | 35 + .github/workflows/ci.yaml | 13 +- Cargo.lock | 90 +- Cargo.toml | 1 + bench/configs/persisted-documents.config.yaml | 15 + bench/k6.js | 14 +- bench/persisted-documents.json | 3 + bin/router/Cargo.toml | 6 +- .../persisted_documents_matcher_benches.rs | 218 +++++ bin/router/src/error.rs | 2 + bin/router/src/lib.rs | 28 + bin/router/src/pipeline/error.rs | 23 +- bin/router/src/pipeline/execution_request.rs | 834 +++++++++++++++--- bin/router/src/pipeline/mod.rs | 62 +- .../persisted_documents/extract/core.rs | 292 ++++++ .../extract/extractors/apollo.rs | 16 + .../extract/extractors/document_id.rs | 13 + .../extract/extractors/json_path.rs | 118 +++ .../extract/extractors/mod.rs | 5 + .../extract/extractors/url_path_param.rs | 63 ++ .../extract/extractors/url_query_param.rs | 232 +++++ .../persisted_documents/extract/mod.rs | 7 + .../src/pipeline/persisted_documents/mod.rs | 82 ++ .../persisted_documents/resolve/file.rs | 326 +++++++ .../persisted_documents/resolve/hive.rs | 341 +++++++ .../persisted_documents/resolve/mod.rs | 62 ++ .../src/pipeline/persisted_documents/types.rs | 72 ++ bin/router/src/shared_state.rs | 7 + docs/README.md | 93 +- .../persisted-documents/apollo-client.md | 108 +++ docs/design/persisted-documents/main.md | 352 ++++++++ .../persisted-documents/relay-client.md | 101 +++ e2e/src/lib.rs | 2 + e2e/src/persisted_documents/defaults.rs | 54 ++ .../persisted_documents/extractor_apollo.rs | 134 +++ .../extractor_document_id.rs | 81 ++ .../extractor_json_path.rs | 137 +++ .../extractor_precedence.rs | 44 + .../extractor_url_path_param.rs | 224 +++++ .../extractor_url_query_param.rs | 210 +++++ e2e/src/persisted_documents/method_get.rs | 221 +++++ e2e/src/persisted_documents/mod.rs | 12 + e2e/src/persisted_documents/policy.rs | 75 ++ e2e/src/persisted_documents/shared.rs | 43 + e2e/src/persisted_documents/storage_file.rs | 63 ++ e2e/src/persisted_documents/storage_hive.rs | 395 +++++++++ e2e/src/telemetry/metrics.rs | 93 +- e2e/src/testkit/mod.rs | 28 +- .../src/plugins/hooks/on_graphql_params.rs | 44 +- .../src/persisted_documents.rs | 131 ++- lib/internal/Cargo.toml | 1 + lib/internal/src/json.rs | 24 + lib/internal/src/lib.rs | 1 + lib/internal/src/telemetry/metrics/catalog.rs | 6 + lib/internal/src/telemetry/metrics/mod.rs | 4 + .../metrics/persisted_documents_metrics.rs | 51 ++ lib/router-config/src/lib.rs | 5 + lib/router-config/src/persisted_documents.rs | 452 ++++++++++ lib/router-config/src/primitives/file_path.rs | 13 +- 60 files changed, 5959 insertions(+), 233 deletions(-) create mode 100644 .changeset/negative_cache_single_flight.md create mode 100644 .changeset/persisted_documents.md create mode 100644 bench/configs/persisted-documents.config.yaml create mode 100644 bench/persisted-documents.json create mode 100644 bin/router/benches/persisted_documents_matcher_benches.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/core.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/resolve/file.rs create mode 100644 bin/router/src/pipeline/persisted_documents/resolve/hive.rs create mode 100644 bin/router/src/pipeline/persisted_documents/resolve/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/types.rs create mode 100644 docs/design/persisted-documents/apollo-client.md create mode 100644 docs/design/persisted-documents/main.md create mode 100644 docs/design/persisted-documents/relay-client.md create mode 100644 e2e/src/persisted_documents/defaults.rs create mode 100644 e2e/src/persisted_documents/extractor_apollo.rs create mode 100644 e2e/src/persisted_documents/extractor_document_id.rs create mode 100644 e2e/src/persisted_documents/extractor_json_path.rs create mode 100644 e2e/src/persisted_documents/extractor_precedence.rs create mode 100644 e2e/src/persisted_documents/extractor_url_path_param.rs create mode 100644 e2e/src/persisted_documents/extractor_url_query_param.rs create mode 100644 e2e/src/persisted_documents/method_get.rs create mode 100644 e2e/src/persisted_documents/mod.rs create mode 100644 e2e/src/persisted_documents/policy.rs create mode 100644 e2e/src/persisted_documents/shared.rs create mode 100644 e2e/src/persisted_documents/storage_file.rs create mode 100644 e2e/src/persisted_documents/storage_hive.rs create mode 100644 lib/internal/src/json.rs create mode 100644 lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs create mode 100644 lib/router-config/src/persisted_documents.rs diff --git a/.changeset/negative_cache_single_flight.md b/.changeset/negative_cache_single_flight.md new file mode 100644 index 000000000..57331098b --- /dev/null +++ b/.changeset/negative_cache_single_flight.md @@ -0,0 +1,10 @@ +--- +hive-console-sdk: minor +hive-router: patch +--- + +# Negative Cache and Single-Flight + +Introduced single-flight resolution of documents in the SDK. + +Added a negative cache to store non 2XX requests for 5s (configurable, but in SDK it's disabled by default). It's meant to not keep repeating the same requests that eventually give errors or 404s. diff --git a/.changeset/persisted_documents.md b/.changeset/persisted_documents.md new file mode 100644 index 000000000..f8c4e311b --- /dev/null +++ b/.changeset/persisted_documents.md @@ -0,0 +1,35 @@ +--- +hive-router-plan-executor: minor +hive-router-config: minor +hive-router: minor +hive-router-internal: minor +hive-console-sdk: minor +--- + +# Persisted Documents + +Introduces persisted documents support in Hive Router with configurable extraction and storage backends. + +Supports extracting persisted document IDs from: +- `documentId` in request body (default) +- `documentId` in URL query params (default) +- Apollo-style `extensions.persistedQuery.sha256Hash` (default) +- custom `json_path` (for example `doc_id` or `extensions.anything.id`) +- custom `url_query_param` (for example `?doc_id=123`) +- custom `url_path_param` (for example `/graphql/:id`) + +Order is configurable and evaluated top-to-bottom. + +Supports persisted document resolution from: +- file manifests (Apollo and Relay KV styles) +- Hive CDN (via `hive-console-sdk`) + +File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. +Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. + +Adds persisted-documents metrics: + +- `hive.router.persisted_documents.extract.missing_id_total` +- `hive.router.persisted_documents.storage.failures_total` + +These help track migration progress and resolution failures in production diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 57bd1073f..5dd556fe9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -188,6 +188,12 @@ jobs: binary: "hive_router_with_plugin" config: "bench/configs/plugins.config.yaml" compare_with_default: true + - name: "persisted-documents" + args: "" + package: "hive-router" + binary: "hive_router" + config: "bench/configs/persisted-documents.config.yaml" + compare_with_default: true name: benchmark / router / ${{ matrix.name }} runs-on: ubuntu-latest env: @@ -220,7 +226,12 @@ jobs: ROUTER_CONFIG_FILE_PATH: ${{matrix.config}} - name: Run k6 benchmark for ${{ github.ref }} if: github.event_name == 'pull_request' - run: k6 run -e SUMMARY_PATH=./bench/results/pr bench/k6.js + run: | + if [ "${{ matrix.name }}" = "persisted-documents" ]; then + k6 run -e SUMMARY_PATH=./bench/results/pr -e BENCH_PERSISTED_MODE=true -e BENCH_DOCUMENT_ID=bench_test_query bench/k6.js + else + k6 run -e SUMMARY_PATH=./bench/results/pr bench/k6.js + fi - name: Checkout main branch in a separate directory if: github.event_name == 'pull_request' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/Cargo.lock b/Cargo.lock index 1a875dab2..6d71b83e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,7 +443,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -1983,6 +1983,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -2387,11 +2396,15 @@ dependencies = [ "jsonwebtoken", "lasso2", "lazy_static", + "matchit 0.9.1", "md5", "mediatype", + "memchr", "mimalloc", "moka", + "notify", "ntex", + "percent-encoding", "prometheus", "rand 0.10.1", "regex-automata", @@ -2465,6 +2478,7 @@ dependencies = [ "opentelemetry-zipkin", "opentelemetry_sdk", "prometheus", + "serde", "sonic-rs", "strum 0.28.0", "thiserror 2.0.18", @@ -2945,6 +2959,26 @@ dependencies = [ "snafu 0.7.5", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3132,6 +3166,26 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lalrpop" version = "0.22.2" @@ -3282,6 +3336,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matchit" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" + [[package]] name = "md-5" version = "0.10.6" @@ -3358,6 +3418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3601,6 +3662,33 @@ dependencies = [ "serde", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "ntex" version = "3.7.2" diff --git a/Cargo.toml b/Cargo.toml index 05b8cd21a..6caa85f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ strum = { version = "0.28.0", features = ["derive"] } mockito = "1.7.0" futures-util = "0.3.31" axum = "0.8.4" +notify = "8.2.0" # Telemetry opentelemetry = "0.31.0" diff --git a/bench/configs/persisted-documents.config.yaml b/bench/configs/persisted-documents.config.yaml new file mode 100644 index 000000000..79c9b81a4 --- /dev/null +++ b/bench/configs/persisted-documents.config.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql + +persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: ../persisted-documents.json + watch: false + +log: + level: info diff --git a/bench/k6.js b/bench/k6.js index 600a24ce9..3c5da60bd 100644 --- a/bench/k6.js +++ b/bench/k6.js @@ -5,6 +5,8 @@ import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.1/index.js"; const endpoint = __ENV.ROUTER_ENDPOINT || "http://0.0.0.0:4000/graphql"; const vus = __ENV.BENCH_VUS ? parseInt(__ENV.BENCH_VUS) : 50; const duration = __ENV.BENCH_OVER_TIME || "30s"; +const persistedMode = __ENV.BENCH_PERSISTED_MODE === "true"; +const documentId = __ENV.BENCH_DOCUMENT_ID || "bench_test_query"; export const options = { vus, @@ -42,9 +44,7 @@ function runOnce(identifier, cb) { return cb(); } -const graphqlRequest = { - payload: JSON.stringify({ - query: `fragment User on User { +const graphqlQuery = `fragment User on User { id username name @@ -101,8 +101,12 @@ const graphqlRequest = { } } } - }`, - }), + }`; + +const graphqlRequest = { + payload: JSON.stringify( + persistedMode ? { documentId } : { query: graphqlQuery }, + ), params: { headers: { "Content-Type": "application/json", diff --git a/bench/persisted-documents.json b/bench/persisted-documents.json new file mode 100644 index 000000000..39fc1d000 --- /dev/null +++ b/bench/persisted-documents.json @@ -0,0 +1,3 @@ +{ + "bench_test_query": "fragment User on User { id username name } fragment Review on Review { id body } fragment Product on Product { inStock name price shippingEstimate upc weight } query TestQuery { users { ...User reviews { ...Review product { ...Product reviews { ...Review author { ...User reviews { ...Review product { ...Product } } } } } } } topProducts { ...Product reviews { ...Review author { ...User reviews { ...Review product { ...Product } } } } } }" +} diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index a9df18ee1..79d2ab2a0 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -58,6 +58,10 @@ ahash = { workspace = true } rustls = { workspace = true, features = ["aws-lc-rs"] } hyper-rustls = { workspace = true, features = ["aws-lc-rs"]} dashmap = { workspace = true } +notify = { workspace = true } +memchr = "2.8.0" +percent-encoding = "2.3.2" +matchit = "0.9.1" moka = { workspace = true } ulid = "1.2.1" @@ -85,5 +89,5 @@ criterion = { workspace = true } insta = { workspace = true } [[bench]] -name = "router_benches" +name = "persisted_documents_matcher_benches" harness = false diff --git a/bin/router/benches/persisted_documents_matcher_benches.rs b/bin/router/benches/persisted_documents_matcher_benches.rs new file mode 100644 index 000000000..9a2725a1e --- /dev/null +++ b/bin/router/benches/persisted_documents_matcher_benches.rs @@ -0,0 +1,218 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use hive_router::pipeline::persisted_documents::extract::{ + DocumentIdResolver, DocumentIdResolverInput, HttpRequestContext, +}; +use hive_router_config::persisted_documents::PersistedDocumentsConfig; +use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; +use std::hint::black_box; + +struct PathCase { + name: &'static str, + template: &'static str, + hit_path: &'static str, + miss_path: &'static str, +} + +const GRAPHQL_ENDPOINT: &str = "/graphql"; + +fn build_resolver(template: &str) -> DocumentIdResolver { + let manifest_path = std::env::temp_dir().join("persisted-docs-bench.json"); + std::fs::write(&manifest_path, "{}").expect("bench manifest should be writable"); + + let raw = format!( + r#"{{ + "enabled": true, + "storage": {{ + "type": "file", + "path": "{}", + "watch": false + }}, + "selectors": [ + {{ "type": "url_path_param", "template": "{template}" }} + ] +}}"#, + manifest_path.display() + ); + + let config: PersistedDocumentsConfig = + serde_json::from_str(&raw).expect("bench config should parse"); + DocumentIdResolver::from_config(&config, GRAPHQL_ENDPOINT) + .expect("resolver config should compile") +} + +fn build_query_param_resolver(name: &str) -> DocumentIdResolver { + let manifest_path = std::env::temp_dir().join("persisted-docs-bench.json"); + std::fs::write(&manifest_path, "{}").expect("bench manifest should be writable"); + + let raw = format!( + r#"{{ + "enabled": true, + "storage": {{ + "type": "file", + "path": "{}", + "watch": false + }}, + "selectors": [ + {{ "type": "url_query_param", "name": "{name}" }} + ] +}}"#, + manifest_path.display() + ); + + let config: PersistedDocumentsConfig = + serde_json::from_str(&raw).expect("bench config should parse"); + DocumentIdResolver::from_config(&config, GRAPHQL_ENDPOINT) + .expect("resolver config should compile") +} + +fn persisted_documents_matcher_benchmark(c: &mut Criterion) { + let cases = [ + PathCase { + name: "simple_id", + template: "/p/:id", + hit_path: "/graphql/p/abc-123", + miss_path: "/graphql/p", + }, + PathCase { + name: "single_wildcard", + template: "/v1/*/:id/details", + hit_path: "/graphql/v1/mobile/abc-123/details", + miss_path: "/graphql/v1/mobile/abc-123", + }, + ]; + + let graphql_params = GraphQLParams::default(); + + for case in cases { + let resolver = build_resolver(case.template); + + let hit_context = HttpRequestContext::from_parts(case.hit_path, None); + let miss_context = HttpRequestContext::from_parts(case.miss_path, None); + + let mut group = c.benchmark_group(format!("persisted_docs_path_match/{}", case.name)); + + group.bench_with_input( + BenchmarkId::new("current", "hit"), + &hit_context, + |b, ctx| { + b.iter(|| { + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("current", "miss"), + &miss_context, + |b, ctx| { + b.iter(|| { + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.finish(); + } +} + +fn persisted_documents_query_param_benchmark(c: &mut Criterion) { + let resolver = build_query_param_resolver("documentId"); + let graphql_params = GraphQLParams::default(); + + let hit_query = "documentId=sha256:abc"; + let miss_query = "foo=bar"; + let long_miss_query = + "a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10&k=11&l=12&m=13&n=14&o=15&p=16&q=17&r=18&s=19&t=20"; + let encoded_hit_query = "documentId=sha256%3Aabc"; + + let mut group = c.benchmark_group("persisted_docs_query_param/current"); + + group.bench_with_input( + BenchmarkId::new("lookup", "hit_plain"), + &hit_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("lookup", "miss_plain"), + &miss_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("lookup", "miss_long"), + &long_miss_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("lookup", "hit_encoded"), + &encoded_hit_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.finish(); +} + +criterion_group!( + benches, + persisted_documents_matcher_benchmark, + persisted_documents_query_param_benchmark, +); +criterion_main!(benches); diff --git a/bin/router/src/error.rs b/bin/router/src/error.rs index d083ccdce..c2390cd60 100644 --- a/bin/router/src/error.rs +++ b/bin/router/src/error.rs @@ -28,6 +28,8 @@ pub enum RouterInitError { TelemetryInitError(#[from] TelemetryInitError), #[error(transparent)] PluginRegistryError(#[from] PluginRegistryError), + #[error("Persisted documents endpoint incompatible: {0}")] + PersistedDocumentsEndpointIncompatible(String), #[error("Endpoints of '{endpoint_name_one}' and '{endpoint_name_two}' cannot both use the same endpoint: {endpoint}")] EndpointConflict { endpoint_name_one: String, diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index 06bf96266..41222ef79 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -28,6 +28,7 @@ use crate::{ header::ResponseMode, http_callback::handler, long_lived_client_limit::LongLivedClientLimitService, + persisted_documents::PersistedDocumentsRuntime, request_extensions::{ read_graphql_operation_metric_identity, read_graphql_response_metric_status, read_request_body_size, write_graphql_response_metric_status, @@ -353,8 +354,28 @@ pub async fn configure_app_from_config( config: max_aliases_config.clone(), })); } + let persisted_documents_runtime = PersistedDocumentsRuntime::init( + &router_config_arc.persisted_documents, + &router_config_arc.http.graphql_endpoint, + bg_tasks_manager, + ) + .await + .map_err(|err| crate::shared_state::SharedStateError::PersistedDocuments(Box::new(err)))?; + + if !persisted_documents_runtime + .supports_graphql_endpoint(&router_config_arc.http.graphql_endpoint) + { + // url_path_param extractor depends on path segments relative to graphql endpoint. + // Root endpoint would make all routes ambiguous for persisted-document extraction. + // Even /health could be treated as a graphql request with document id == "health". + return Err(RouterInitError::PersistedDocumentsEndpointIncompatible( + "http.graphql_endpoint='/' is not allowed when persisted_documents.selectors contains type=url_path_param. Use a non-root endpoint like '/graphql'.".to_string(), + )); + } + let shared_state = Arc::new(RouterSharedState::new( router_config_arc, + persisted_documents_runtime, jwt_runtime, hive_usage_agent, validation_plan, @@ -474,6 +495,13 @@ pub fn configure_ntex_app( }), ); } + + // Enables /graphql/sha256:12345 cases for persisted documents + if paths.graphql != "/" { + cfg.service( + web::scope(paths.graphql.as_str()).default_service(web::to(graphql_endpoint_handler)), + ); + } } /// Initializes the rustls cryptographic provider for the entire process. diff --git a/bin/router/src/pipeline/error.rs b/bin/router/src/pipeline/error.rs index e5f53bd01..5a30b56d0 100644 --- a/bin/router/src/pipeline/error.rs +++ b/bin/router/src/pipeline/error.rs @@ -53,9 +53,6 @@ pub enum PipelineError { UnsupportedContentType, // GET Specific pipeline errors - #[error("Failed to deserialize query parameters")] - #[strum(serialize = "INVALID_QUERY_PARAMS")] - GetInvalidQueryParams, #[error("Missing query parameter: {0}")] #[strum(serialize = "MISSING_QUERY_PARAM")] GetMissingQueryParam(&'static str), @@ -79,6 +76,18 @@ pub enum PipelineError { #[error("Failed to parse GraphQL operation: {0}")] #[strum(serialize = "GRAPHQL_PARSE_FAILED")] FailedToParseOperation(#[from] Arc), + #[error("Persisted document not found: {0}")] + #[strum(serialize = "PERSISTED_DOCUMENT_NOT_FOUND")] + PersistedDocumentNotFound(String), + #[error("Persisted document id is required")] + #[strum(serialize = "PERSISTED_DOCUMENT_ID_REQUIRED")] + PersistedDocumentIdRequired, + #[error("{0}")] + #[strum(serialize = "PERSISTED_DOCUMENT_EXTRACTION_FAILED")] + PersistedDocumentExtraction(String), + #[error("{0}")] + #[strum(serialize = "PERSISTED_DOCUMENT_RESOLUTION_FAILED")] + PersistedDocumentResolution(String), #[error("Failed to minify parsed GraphQL operation: {0}")] #[strum(serialize = "GRAPHQL_PARSE_MINIFY_FAILED")] FailedToMinifyParsedOperation(String), @@ -204,11 +213,17 @@ impl PipelineError { (Self::UnsupportedHttpMethod(_), _) => StatusCode::METHOD_NOT_ALLOWED, (Self::InvalidHeaderValue(_), _) => StatusCode::BAD_REQUEST, (Self::GetUnprocessableQueryParams(_), _) => StatusCode::BAD_REQUEST, - (Self::GetInvalidQueryParams, _) => StatusCode::BAD_REQUEST, (Self::GetMissingQueryParam(_), _) => StatusCode::BAD_REQUEST, (Self::FailedToParseBody(_), _) => StatusCode::BAD_REQUEST, (Self::FailedToParseVariables(_), _) => StatusCode::BAD_REQUEST, (Self::FailedToParseExtensions(_), _) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentNotFound(_), false) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentNotFound(_), true) => StatusCode::OK, + (Self::PersistedDocumentIdRequired, false) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentIdRequired, true) => StatusCode::OK, + (Self::PersistedDocumentExtraction(_), false) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentExtraction(_), true) => StatusCode::OK, + (Self::PersistedDocumentResolution(_), _) => StatusCode::INTERNAL_SERVER_ERROR, (Self::FailedToParseOperation(_), false) => StatusCode::BAD_REQUEST, (Self::FailedToParseOperation(_), true) => StatusCode::OK, (Self::FailedToMinifyParsedOperation(_), false) => StatusCode::BAD_REQUEST, diff --git a/bin/router/src/pipeline/execution_request.rs b/bin/router/src/pipeline/execution_request.rs index 343b6c191..fb9ed27a0 100644 --- a/bin/router/src/pipeline/execution_request.rs +++ b/bin/router/src/pipeline/execution_request.rs @@ -1,5 +1,9 @@ +use std::borrow::Cow; use std::collections::HashMap; +use std::fmt; +use hive_router_internal::json::MapAccessSerdeExt; +use hive_router_internal::telemetry::metrics::Metrics; use hive_router_plan_executor::hooks::on_graphql_params::{ GraphQLParams, OnGraphQLParamsEndHookPayload, OnGraphQLParamsStartHookPayload, }; @@ -9,21 +13,240 @@ use http::{header::CONTENT_TYPE, Method}; use ntex::util::Bytes; use ntex::web::types::Query; use ntex::web::HttpRequest; -use tracing::{trace, warn}; +use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, Visitor}; +use std::sync::Arc; +use tracing::{info, trace, warn}; use crate::pipeline::error::PipelineError; use crate::pipeline::header::SingleContentType; +use crate::pipeline::persisted_documents::extract::{ + DocumentIdResolver, DocumentIdResolverInput, HttpRequestContext, DOCUMENT_ID_FIELD, +}; +use crate::pipeline::persisted_documents::resolve::PersistedDocumentResolveInput; +use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; +use crate::pipeline::persisted_documents::PersistedDocumentsRuntime; +use crate::shared_state::RouterSharedState; #[derive(serde::Deserialize, Debug)] -struct GETQueryParams { +struct GraphQLGetInput { pub query: Option, - #[serde(rename = "camelCase")] + #[serde(rename = "operationName")] pub operation_name: Option, + #[serde(rename = "documentId")] + pub document_id: Option, pub variables: Option, pub extensions: Option, } -impl TryInto for GETQueryParams { +impl GraphQLGetInput { + pub fn empty() -> Self { + Self { + query: None, + operation_name: None, + document_id: None, + variables: None, + extensions: None, + } + } +} + +#[derive(Debug, Default)] +struct GraphQLPostInput { + query: Option, + operation_name: Option, + variables: HashMap, + extensions: Option>, + document_id: Option, + nonstandard_json_fields: Option>, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum GraphQLDocumentIdValue { + String(String), + U64(u64), +} + +impl GraphQLDocumentIdValue { + #[inline] + fn into_string(self) -> String { + match self { + Self::String(value) => value, + Self::U64(value) => value.to_string(), + } + } +} + +#[derive(Debug)] +pub struct PreparedOperation { + pub graphql_params: GraphQLParams, + /// Represents the resolved document ID, if one was found, + /// according to the document ID resolver plan. + pub resolved_document_id: Option, +} + +impl PreparedOperation { + #[inline] + fn from_get( + get_input: GraphQLGetInput, + document_id_resolver: &DocumentIdResolver, + request_context: HttpRequestContext<'_>, + ) -> Result { + let document_id = get_input.document_id.clone(); + Ok(Self::from_graphql_params( + get_input.try_into()?, + document_id_resolver, + request_context, + document_id.as_deref(), + None, + )) + } + + #[inline] + fn from_post( + post_input: GraphQLPostInput, + document_id_resolver: &DocumentIdResolver, + request_context: HttpRequestContext<'_>, + ) -> Self { + let GraphQLPostInput { + query, + operation_name, + variables, + extensions, + document_id, + nonstandard_json_fields, + } = post_input; + + Self::from_graphql_params( + GraphQLParams { + query, + operation_name, + variables, + extensions, + }, + document_id_resolver, + request_context, + document_id.as_deref(), + nonstandard_json_fields.as_ref(), + ) + } + + #[inline] + fn from_graphql_params( + graphql_params: GraphQLParams, + document_id_resolver: &DocumentIdResolver, + request_context: HttpRequestContext<'_>, + document_id: Option<&str>, + nonstandard_json_fields: Option<&HashMap>, + ) -> Self { + let persisted_document_id = if document_id_resolver.is_enabled() { + document_id_resolver.resolve_document_id(DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id, + nonstandard_json_fields, + request_context: &request_context, + }) + } else { + None + }; + + Self { + graphql_params, + resolved_document_id: persisted_document_id, + } + } +} + +struct GraphQLPostBodySeed<'a> { + document_id_resolver: &'a DocumentIdResolver, +} + +impl<'a> GraphQLPostBodySeed<'a> { + #[inline] + fn new(document_id_resolver: &'a DocumentIdResolver) -> Self { + Self { + document_id_resolver, + } + } +} + +struct GraphQLPostBodyVisitor { + // wether to capture extra fields from the POST body + // besides the query, operation name, variables, extensions and documentId. + // We only need it when the document ID resolver requires something else than: + // - documentId + // - extensions.* + capture_nonstandard_json_fields: bool, +} + +impl<'de> DeserializeSeed<'de> for GraphQLPostBodySeed<'_> { + type Value = GraphQLPostInput; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(GraphQLPostBodyVisitor { + capture_nonstandard_json_fields: self + .document_id_resolver + .requires_nonstandard_json_fields(), + }) + } +} + +impl<'de> Visitor<'de> for GraphQLPostBodyVisitor { + type Value = GraphQLPostInput; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a GraphQL POST JSON object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut query: Option = None; + let mut operation_name: Option = None; + let mut variables: Option> = None; + let mut extensions: Option> = None; + let mut document_id: Option = None; + let mut nonstandard_json_fields: Option> = + self.capture_nonstandard_json_fields.then(HashMap::new); + + while let Some(key) = map.next_key::>()? { + match key.as_ref() { + "query" => map.deserialize_once_into_option(&mut query, "query")?, + "operationName" => { + map.deserialize_once_into_option(&mut operation_name, "operationName")? + } + "variables" => map.deserialize_once_into_option(&mut variables, "variables")?, + "extensions" => map.deserialize_once_into_option(&mut extensions, "extensions")?, + DOCUMENT_ID_FIELD => { + map.deserialize_once_into_option(&mut document_id, DOCUMENT_ID_FIELD)? + } + _ => { + if let Some(nonstandard_json_fields) = nonstandard_json_fields.as_mut() { + let value = map.next_value::()?; + nonstandard_json_fields.insert(key.into_owned(), value); + } else { + let _ = map.next_value::()?; + } + } + } + } + + Ok(GraphQLPostInput { + query, + operation_name, + variables: variables.unwrap_or_default(), + extensions, + document_id: document_id.map(GraphQLDocumentIdValue::into_string), + nonstandard_json_fields, + }) + } +} + +impl TryInto for GraphQLGetInput { type Error = PipelineError; fn try_into(self) -> Result { @@ -70,124 +293,537 @@ impl GetQueryStr for GraphQLParams { } } -pub enum DeserializationResult { +pub enum OperationPreparationResult { EarlyResponse(ntex::web::HttpResponse), - GraphQLParams(GraphQLParams), + Operation(PreparedOperation), } -#[inline] -pub async fn deserialize_graphql_params( - req: &HttpRequest, +pub struct OperationPreparation<'a> { + req: &'a HttpRequest, + persisted_documents_runtime: &'a PersistedDocumentsRuntime, + plugin_req_state: &'a Option>, body: Bytes, - plugin_req_state: &Option>, -) -> Result { - /* Handle on_deserialize hook in the plugins - START */ - let mut deserialization_end_callbacks = vec![]; - - let mut graphql_params = None; - let mut body = body; - if let Some(plugin_req_state) = plugin_req_state.as_ref() { - let mut deserialization_payload: OnGraphQLParamsStartHookPayload = - OnGraphQLParamsStartHookPayload { - router_http_request: &plugin_req_state.router_http_request, + require_id: bool, + persisted_documents_enabled: bool, + log_missing_id_requests: bool, + client_identity: ClientIdentity<'a>, + metrics: Arc, +} + +impl<'a> OperationPreparation<'a> { + #[inline] + pub async fn prepare( + req: &'a HttpRequest, + shared_state: &'a Arc, + plugin_req_state: &'a Option>, + body: Bytes, + client_name: Option<&'a str>, + client_version: Option<&'a str>, + ) -> Result { + Self { + req, + persisted_documents_runtime: &shared_state.persisted_documents_runtime, + plugin_req_state, + body, + require_id: shared_state.router_config.persisted_documents.require_id, + persisted_documents_enabled: shared_state.router_config.persisted_documents.enabled, + log_missing_id_requests: shared_state + .router_config + .persisted_documents + .log_missing_id, + client_identity: ClientIdentity { + name: client_name, + version: client_version, + }, + metrics: shared_state.telemetry_context.metrics.clone(), + } + .extract_and_resolve() + .await + } + + async fn extract_and_resolve(mut self) -> Result { + let mut graphql_params_from_plugins = None; + let mut graphql_params_end_callbacks = Vec::new(); + + if let Some(plugin_req_state) = self.plugin_req_state.as_ref() { + let mut deserialization_payload: OnGraphQLParamsStartHookPayload = + OnGraphQLParamsStartHookPayload { + router_http_request: &plugin_req_state.router_http_request, + context: &plugin_req_state.context, + body: self.body.clone(), + graphql_params: None, + }; + + for plugin in plugin_req_state.plugins.as_ref() { + let result = plugin.on_graphql_params(deserialization_payload).await; + deserialization_payload = result.payload; + match result.control_flow { + StartControlFlow::Proceed => {} + StartControlFlow::EndWithResponse(response) => { + return Ok(OperationPreparationResult::EarlyResponse(response)); + } + StartControlFlow::OnEnd(callback) => { + graphql_params_end_callbacks.push(callback); + } + } + } + + graphql_params_from_plugins = deserialization_payload.graphql_params; + self.body = deserialization_payload.body; + } + + let mut operation = self.decode_or_use_plugin_override(graphql_params_from_plugins)?; + + if self.persisted_documents_enabled && operation.resolved_document_id.is_none() { + self.metrics.persisted_documents.record_missing_id(); + } + + if self.persisted_documents_enabled + && self.log_missing_id_requests + && operation.resolved_document_id.is_none() + { + info!( + event = "persisted_documents.missing_id_request", + method = %self.req.method(), + path = %self.req.uri().path(), + require_id = self.require_id, + operation_name = operation.graphql_params.operation_name.as_deref().unwrap_or(""), + operation_body = operation.graphql_params.query.as_deref().unwrap_or(""), + client_name = self.client_identity.name.unwrap_or(""), + client_version = self.client_identity.version.unwrap_or(""), + "request without document id" + ); + } + + self.enforce_require_id_policy(&mut operation)?; + + // Apollo's APQ requests may include both `query` and a persisted id/hash in `extensions`. + // The require-id policy above normalizes query/id precedence before this branch. + if self.persisted_documents_enabled && operation.graphql_params.query.is_none() { + self.resolve_query_from_document_id(&mut operation).await?; + } + + if let Some(plugin_req_state) = self.plugin_req_state.as_ref() { + let mut payload = OnGraphQLParamsEndHookPayload { + graphql_params: operation.graphql_params, context: &plugin_req_state.context, - body, - graphql_params: None, }; - for plugin in plugin_req_state.plugins.as_ref() { - let result = plugin.on_graphql_params(deserialization_payload).await; - deserialization_payload = result.payload; - match result.control_flow { - StartControlFlow::Proceed => { /* continue to next plugin */ } - StartControlFlow::EndWithResponse(response) => { - return Ok(DeserializationResult::EarlyResponse(response)); - } - StartControlFlow::OnEnd(callback) => { - deserialization_end_callbacks.push(callback); + + for callback in graphql_params_end_callbacks { + let result = callback(payload); + payload = result.payload; + match result.control_flow { + EndControlFlow::Proceed => {} + EndControlFlow::EndWithResponse(response) => { + return Ok(OperationPreparationResult::EarlyResponse(response)); + } } } + + operation.graphql_params = payload.graphql_params; } - // Give the ownership back to variables - graphql_params = deserialization_payload.graphql_params; - body = deserialization_payload.body; + + Ok(OperationPreparationResult::Operation(operation)) } - let mut graphql_params = match graphql_params { - Some(params) => params, - None => { - let http_method = req.method(); - match *http_method { - Method::GET => { - trace!("processing GET GraphQL operation"); - let query_params_str = req - .uri() - .query() - .ok_or_else(|| PipelineError::GetInvalidQueryParams)?; - let query_params = Query::::from_query(query_params_str)?.0; + #[inline] + fn decode_or_use_plugin_override( + &self, + graphql_params_override: Option, + ) -> Result { + if let Some(graphql_params) = graphql_params_override { + return Ok(PreparedOperation::from_graphql_params( + graphql_params, + &self.persisted_documents_runtime.document_id_resolver, + self.req.into(), + None, + None, + )); + } - trace!("parsed GET query params: {:?}", query_params); + match *self.req.method() { + Method::GET => self.decode_get(), + Method::POST => self.decode_post(), + _ => { + warn!("unsupported HTTP method: {}", self.req.method()); + Err(PipelineError::UnsupportedHttpMethod( + self.req.method().to_owned(), + )) + } + } + } - query_params.try_into()? - } - Method::POST => { - trace!("Processing POST GraphQL request"); - - match req.headers().get(CONTENT_TYPE) { - Some(value) => { - let content_type_str = value - .to_str() - .map_err(|_| PipelineError::InvalidHeaderValue(CONTENT_TYPE))?; - if !content_type_str.contains(SingleContentType::JSON.as_ref()) { - warn!( - "Invalid content type on a POST request: {}", - content_type_str - ); - return Err(PipelineError::UnsupportedContentType); - } - } - None => { - trace!("POST without content type detected"); - return Err(PipelineError::MissingContentTypeHeader); - } - } + #[inline] + fn decode_get(&self) -> Result { + let query_params_str = self.req.uri().query(); + let query_params = if let Some(q) = query_params_str { + Query::::from_query(q)?.0 + } else { + // We need it to be able to use Persisted Documents in `GET /graphql/:id` format + GraphQLGetInput::empty() + }; - let execution_request = unsafe { - sonic_rs::from_slice_unchecked::(&body).map_err(|e| { - warn!("Failed to parse body: {}", e); - PipelineError::FailedToParseBody(e) - })? - }; + PreparedOperation::from_get( + query_params, + &self.persisted_documents_runtime.document_id_resolver, + self.req.into(), + ) + } - execution_request + #[inline] + fn decode_post(&self) -> Result { + match self.req.headers().get(CONTENT_TYPE) { + Some(value) => { + let content_type_str = value + .to_str() + .map_err(|_| PipelineError::InvalidHeaderValue(CONTENT_TYPE))?; + if !content_type_str.contains(SingleContentType::JSON.as_ref()) { + warn!( + "Invalid content type on a POST request: {}", + content_type_str + ); + return Err(PipelineError::UnsupportedContentType); } - _ => { - warn!("unsupported HTTP method: {}", http_method); + } + None => { + trace!("POST without content type detected"); + return Err(PipelineError::MissingContentTypeHeader); + } + } - return Err(PipelineError::UnsupportedHttpMethod(http_method.to_owned())); - } + let mut deserializer = sonic_rs::Deserializer::from_slice(&self.body); + + let post_input = + GraphQLPostBodySeed::new(&self.persisted_documents_runtime.document_id_resolver) + .deserialize(&mut deserializer) + .map_err(PipelineError::FailedToParseBody)?; + + // Calling end() is important to ensure there is no trailing garbage after the JSON payload. + // Without calling it, this might be accepted: + // {"query":"{ me { id } }"} garbage + // or even: + // {"query":"{ me { id } }"}{"another":"object"} + deserializer + .end() + .map_err(PipelineError::FailedToParseBody)?; + + Ok(PreparedOperation::from_post( + post_input, + &self.persisted_documents_runtime.document_id_resolver, + self.req.into(), + )) + } + + #[inline] + fn enforce_require_id_policy( + &self, + prepared_operation: &mut PreparedOperation, + ) -> Result<(), PipelineError> { + if !self.persisted_documents_enabled { + // If persisted documents are disabled, clear the resolved document ID, + // as it's not meant to be used in that case. + prepared_operation.resolved_document_id = None; + return Ok(()); + } + + if self.require_id { + // If require_id is set, clear the query to make the document ID-based resolution mandatory. + prepared_operation.graphql_params.query = None; + if prepared_operation.resolved_document_id.is_none() { + return Err(PipelineError::PersistedDocumentIdRequired); } + return Ok(()); } + + if prepared_operation.graphql_params.query.is_some() { + // if a query is present, clear the resolved document ID, + // as the query takes precedence over the document ID. + prepared_operation.resolved_document_id = None; + } + + Ok(()) + } + + #[inline] + async fn resolve_query_from_document_id( + &self, + prepared_operation: &mut PreparedOperation, + ) -> Result<(), PipelineError> { + if let Some(document_id) = prepared_operation.resolved_document_id.as_ref() { + let resolver = self + .persisted_documents_runtime + .persisted_document_resolver + .as_ref() + .ok_or_else(|| { + PipelineError::PersistedDocumentResolution( + "Persisted documents storage is not configured".to_string(), + ) + })?; + + let resolved = resolver + .resolve(PersistedDocumentResolveInput { + persisted_document_id: document_id, + client_identity: self.client_identity, + }) + .await + .map_err(|error| { + self.metrics.persisted_documents.record_resolution_failure(); + PipelineError::from(error) + })?; + + prepared_operation.graphql_params.query = Some(resolved.text.to_string()); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use async_trait::async_trait; + use hive_router_config::persisted_documents::PersistedDocumentsConfig; + use hive_router_internal::telemetry::metrics::Metrics; + use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; + use hive_router_plan_executor::plugin_context::PluginRequestState; + use ntex::util::Bytes; + use ntex::web::test::TestRequest; + use ntex::web::HttpRequest; + + use super::{OperationPreparation, PreparedOperation}; + use crate::pipeline::error::PipelineError; + use crate::pipeline::persisted_documents::extract::DocumentIdResolver; + use crate::pipeline::persisted_documents::resolve::{ + PersistedDocumentResolveInput, PersistedDocumentResolver, PersistedDocumentResolverError, + ResolvedDocument, }; + use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; + use crate::pipeline::persisted_documents::PersistedDocumentsRuntime; - if let Some(plugin_req_state) = &plugin_req_state { - let mut payload = OnGraphQLParamsEndHookPayload { - graphql_params, - context: &plugin_req_state.context, - }; - for deserialization_end_callback in deserialization_end_callbacks { - let result = deserialization_end_callback(payload); - payload = result.payload; - match result.control_flow { - EndControlFlow::Proceed => { /* continue to next plugin */ } - EndControlFlow::EndWithResponse(response) => { - return Ok(DeserializationResult::EarlyResponse(response)); - } - } + struct StaticResolver { + document: Arc, + } + + #[async_trait] + impl PersistedDocumentResolver for StaticResolver { + async fn resolve( + &self, + _input: PersistedDocumentResolveInput<'_>, + ) -> Result { + Ok(ResolvedDocument { + text: Arc::clone(&self.document), + }) + } + } + + fn document_id_resolver() -> DocumentIdResolver { + DocumentIdResolver::from_config(&PersistedDocumentsConfig::default(), "/graphql") + .expect("resolver config should compile") + } + + fn request() -> HttpRequest { + TestRequest::with_uri("/graphql").to_http_request() + } + + fn operation(query: Option<&str>, persisted_id: Option<&str>) -> PreparedOperation { + PreparedOperation { + graphql_params: GraphQLParams { + query: query.map(ToString::to_string), + operation_name: None, + variables: HashMap::new(), + extensions: None, + }, + resolved_document_id: PersistedDocumentId::from_option(persisted_id), } - graphql_params = payload.graphql_params; } - /* Handle on_deserialize hook in the plugins - END */ + #[ntex::test] + async fn resolves_query_from_persisted_document_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_resolver: Arc = Arc::new(StaticResolver { + document: Arc::::from("query { me { id } }"), + }); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: Some(persisted_resolver.clone()), + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: false, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = PreparedOperation { + graphql_params: GraphQLParams { + query: None, + operation_name: None, + variables: HashMap::new(), + extensions: None, + }, + resolved_document_id: Some(PersistedDocumentId::try_from("sha256:abc").unwrap()), + }; + + prep.resolve_query_from_document_id(&mut op) + .await + .expect("query should resolve"); + + assert_eq!( + op.graphql_params.query.as_deref(), + Some("query { me { id } }") + ); + } + + #[test] + fn require_id_enabled_drops_query_and_keeps_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: true, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("require_id policy should pass"); + + assert!(op.graphql_params.query.is_none()); + assert!(op.resolved_document_id.is_some()); + } + + #[test] + fn require_id_enabled_without_id_returns_required_error() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: true, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), None); + + let err = prep + .enforce_require_id_policy(&mut op) + .expect_err("missing id should fail"); + + assert!(matches!(err, PipelineError::PersistedDocumentIdRequired)); + } + + #[test] + fn require_id_disabled_query_wins_and_drops_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: false, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("policy should pass"); - Ok(DeserializationResult::GraphQLParams(graphql_params)) + assert!(op.graphql_params.query.is_some()); + assert!(op.resolved_document_id.is_none()); + } + + #[test] + fn persisted_documents_disabled_always_drops_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: true, + persisted_documents_enabled: false, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("policy should pass"); + + assert!(op.graphql_params.query.is_some()); + assert!(op.resolved_document_id.is_none()); + } + + #[test] + fn query_missing_with_require_id_disabled_keeps_persisted_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: false, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(None, Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("policy should pass"); + + assert!(op.graphql_params.query.is_none()); + assert!(op.resolved_document_id.is_some()); + } } diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index 352365da0..58a18745a 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -38,7 +38,7 @@ use crate::{ csrf_prevention::perform_csrf_prevention, error::PipelineError, execution::{execute_plan, PlannedRequest}, - execution_request::{deserialize_graphql_params, DeserializationResult, GetQueryStr}, + execution_request::{GetQueryStr, OperationPreparation, OperationPreparationResult}, header::{RequestAccepts, ResponseMode, TEXT_HTML_MIME}, introspection_policy::handle_introspection_policy, normalize::{normalize_request_with_cache, GraphQLNormalizationPayload}, @@ -77,6 +77,7 @@ pub mod long_lived_client_limit; pub mod multipart_subscribe; pub mod normalize; pub mod parser; +pub mod persisted_documents; pub mod progressive_override; pub mod query_plan; pub mod request_extensions; @@ -138,31 +139,6 @@ pub async fn graphql_request_handler( write_request_body_size(req, body_bytes.len() as u64); http_server_request_span.record_body_size(body_bytes.len()); - let mut plugin_req_state = None; - - if let (Some(plugins), Some(plugin_context)) = ( - shared_state.plugins.as_ref(), - req.extensions().get::>(), - ) { - plugin_req_state = Some(PluginRequestState { - plugins: plugins.clone(), - router_http_request: req.into(), - context: plugin_context.clone(), - }); - } - - let deserialization_result = - deserialize_graphql_params(req, body_bytes, &plugin_req_state).await?; - - let graphql_params = match deserialization_result { - DeserializationResult::GraphQLParams(params) => params, - DeserializationResult::EarlyResponse(response) => { - return Ok(response); - } - }; - - write_graphql_operation_metric_identity(req, graphql_params.operation_name.clone(), None); - let client_name = req .headers() .get( @@ -184,6 +160,40 @@ pub async fn graphql_request_handler( ) .and_then(|v| v.to_str().ok()); + let mut plugin_req_state = None; + + if let (Some(plugins), Some(plugin_context)) = ( + shared_state.plugins.as_ref(), + req.extensions().get::>(), + ) { + plugin_req_state = Some(PluginRequestState { + plugins: plugins.clone(), + router_http_request: req.into(), + context: plugin_context.clone(), + }); + } + + let operation_preparation_result = OperationPreparation::prepare( + req, + shared_state, + &plugin_req_state, + body_bytes, + client_name, + client_version, + ) + .await?; + + let prepared_operation = match operation_preparation_result { + OperationPreparationResult::Operation(prepared_operation) => prepared_operation, + OperationPreparationResult::EarlyResponse(response) => { + return Ok(response); + } + }; + + let graphql_params = prepared_operation.graphql_params; + + write_graphql_operation_metric_identity(req, graphql_params.operation_name.clone(), None); + let parser_result = parse_operation_with_cache(shared_state, &graphql_params, &plugin_req_state).await?; diff --git a/bin/router/src/pipeline/persisted_documents/extract/core.rs b/bin/router/src/pipeline/persisted_documents/extract/core.rs new file mode 100644 index 000000000..b38fd4cb1 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/core.rs @@ -0,0 +1,292 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::ops::Deref; + +use hive_router_config::persisted_documents::{ + PersistedDocumentExtractorConfig, PersistedDocumentUrlTemplate, PersistedDocumentsConfig, +}; +use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; +use ntex::web::HttpRequest; +use sonic_rs::OwnedLazyValue; +use thiserror::Error; + +use crate::pipeline::persisted_documents::extract::extractors::apollo::{ + ApolloExtractor, APOLLO_HASH_PATH, +}; +use crate::pipeline::persisted_documents::extract::extractors::document_id::{ + DocumentIdExtractor, DOCUMENT_ID_FIELD, +}; +use crate::pipeline::persisted_documents::extract::extractors::json_path::JsonPathExtractor; +use crate::pipeline::persisted_documents::extract::extractors::url_path_param::UrlPathParamExtractor; +use crate::pipeline::persisted_documents::extract::extractors::url_query_param::{ + QueryParams, UrlQueryParamExtractor, +}; + +use super::super::types::PersistedDocumentId; + +pub struct HttpRequestContext<'a> { + pub(crate) path: &'a str, + pub(crate) query: Option>, +} + +pub struct DocumentIdResolverInput<'a> { + pub graphql_params: &'a GraphQLParams, + pub document_id: Option<&'a str>, + pub nonstandard_json_fields: Option<&'a HashMap>, + pub request_context: &'a HttpRequestContext<'a>, +} + +impl<'a> From<&'a HttpRequest> for HttpRequestContext<'a> { + fn from(req: &'a HttpRequest) -> Self { + Self::from_parts(req.uri().path(), req.uri().query()) + } +} + +impl<'a> HttpRequestContext<'a> { + pub fn from_parts(path: &'a str, query: Option<&'a str>) -> Self { + Self { + path, + query: query.map(QueryParams::new), + } + } +} + +pub struct DocumentIdResolver { + graphql_endpoint: GraphQLEndpointPath, + state: ResolverState, +} + +#[derive(Debug, Error)] +pub enum PersistedDocumentExtractError { + #[error("url_path_param.template must contain ':id' segment: {template}")] + MissingIdParam { template: String }, + #[error("failed to compile url_path_param.template: {0}")] + MatcherCompile(String), +} + +enum ResolverState { + Disabled, + Enabled(ActivePlan), +} + +struct ActivePlan { + selectors: Vec>, + requires_nonstandard_json_fields: bool, + depends_on_graphql_path: bool, +} + +pub(super) trait DocumentIdSourceExtractor: Send + Sync { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option; +} + +#[derive(Debug)] +struct GraphQLEndpointPath(String); + +impl Deref for GraphQLEndpointPath { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for GraphQLEndpointPath { + fn as_ref(&self) -> &str { + &self.0 + } +} + +pub(super) struct ExtractionContext<'a> { + pub(crate) graphql_params: &'a GraphQLParams, + document_id: Option<&'a str>, + pub(crate) nonstandard_json_fields: Option<&'a HashMap>, + relative_path: Option<&'a str>, + pub(crate) request_context: &'a HttpRequestContext<'a>, +} + +impl<'a> ExtractionContext<'a> { + fn new(input: DocumentIdResolverInput<'a>, graphql_endpoint: &GraphQLEndpointPath) -> Self { + Self { + graphql_params: input.graphql_params, + document_id: input.document_id, + nonstandard_json_fields: input.nonstandard_json_fields, + relative_path: graphql_endpoint.relative_path(input.request_context.path()), + request_context: input.request_context, + } + } + + pub(super) fn document_id(&self) -> Option<&'a str> { + self.document_id + } + + pub(super) fn relative_path(&self) -> Option<&'a str> { + self.relative_path + } + + pub(super) fn query_param(&self, name: &str) -> Option> { + self.request_context.query_param(name) + } +} + +impl DocumentIdResolver { + pub fn from_config( + config: &PersistedDocumentsConfig, + graphql_endpoint: &str, + ) -> Result { + let graphql_endpoint = GraphQLEndpointPath::from(graphql_endpoint); + + if !config.enabled { + return Ok(Self { + graphql_endpoint, + state: ResolverState::Disabled, + }); + } + + let configured_selectors = match config.selectors.as_ref() { + Some(selectors) => selectors.clone(), + None => PersistedDocumentsConfig::default_selectors(), + }; + + let mut selectors = Vec::with_capacity(configured_selectors.len()); + let mut requires_nonstandard_json_fields = false; + let mut depends_on_graphql_path = false; + + for selector_config in &configured_selectors { + let (extractor, requires_nonstandard_fields, depends_on_url_path) = + build_extractor(selector_config)?; + requires_nonstandard_json_fields |= requires_nonstandard_fields; + depends_on_graphql_path |= depends_on_url_path; + selectors.push(extractor); + } + + Ok(Self { + graphql_endpoint, + state: ResolverState::Enabled(ActivePlan { + selectors, + requires_nonstandard_json_fields, + depends_on_graphql_path, + }), + }) + } + + #[inline] + pub fn is_enabled(&self) -> bool { + matches!(self.state, ResolverState::Enabled(_)) + } + + #[inline] + pub fn requires_nonstandard_json_fields(&self) -> bool { + match &self.state { + ResolverState::Disabled => false, + ResolverState::Enabled(active_plan) => active_plan.requires_nonstandard_json_fields, + } + } + + pub fn depends_on_graphql_path(&self) -> bool { + match &self.state { + ResolverState::Disabled => false, + ResolverState::Enabled(active_plan) => active_plan.depends_on_graphql_path, + } + } + + pub fn resolve_document_id( + &self, + input: DocumentIdResolverInput<'_>, + ) -> Option { + let active_plan = match &self.state { + ResolverState::Disabled => return None, + ResolverState::Enabled(active_plan) => active_plan, + }; + + let ctx = ExtractionContext::new(input, &self.graphql_endpoint); + + for selector in &active_plan.selectors { + if let Some(persisted_document_id) = selector.extract(&ctx) { + return Some(persisted_document_id); + } + } + + None + } +} + +fn build_extractor( + extractor_config: &PersistedDocumentExtractorConfig, +) -> Result<(Box, bool, bool), PersistedDocumentExtractError> { + match extractor_config { + PersistedDocumentExtractorConfig::JsonPath { path } => { + if path.as_str() == DOCUMENT_ID_FIELD { + return Ok((Box::new(DocumentIdExtractor), false, false)); + } + + if path.as_str() == APOLLO_HASH_PATH { + return Ok((Box::new(ApolloExtractor), false, false)); + } + + let segments = path + .as_str() + .split('.') + .map(|s| s.to_string()) + .collect::>(); + + let requires_extra = JsonPathExtractor::requires_nonstandard_json_fields(&segments); + + Ok(( + Box::new(JsonPathExtractor { segments }), + requires_extra, + false, + )) + } + PersistedDocumentExtractorConfig::UrlQueryParam { name } => Ok(( + Box::new(UrlQueryParamExtractor { + name: name.as_str().to_string(), + }), + false, + false, + )), + PersistedDocumentExtractorConfig::UrlPathParam { template } => { + let extractor: UrlPathParamExtractor = template.try_into()?; + Ok((Box::new(extractor), false, true)) + } + } +} + +impl From<&str> for GraphQLEndpointPath { + fn from(endpoint: &str) -> Self { + if endpoint.is_empty() || endpoint == "/" { + return Self("/".to_string()); + } + + let with_leading_slash = if endpoint.starts_with('/') { + endpoint.to_string() + } else { + format!("/{endpoint}") + }; + + Self(with_leading_slash.trim_end_matches('/').to_string()) + } +} + +impl GraphQLEndpointPath { + fn relative_path<'a>(&self, request_path: &'a str) -> Option<&'a str> { + let suffix = if self.as_ref() == "/" { + request_path + } else { + let suffix = request_path.strip_prefix(self.as_ref())?; + if !suffix.is_empty() && !suffix.starts_with('/') { + return None; + } + suffix + }; + + Some(suffix) + } +} + +impl TryFrom<&PersistedDocumentUrlTemplate> for UrlPathParamExtractor { + type Error = PersistedDocumentExtractError; + + fn try_from(template: &PersistedDocumentUrlTemplate) -> Result { + UrlPathParamExtractor::try_from_template(template) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs new file mode 100644 index 000000000..fc7057caf --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs @@ -0,0 +1,16 @@ +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +pub(crate) const APOLLO_HASH_PATH: &str = "extensions.persistedQuery.sha256Hash"; +pub(crate) const APOLLO_HASH_PATH_SEGMENTS: &[&str; 3] = + &["extensions", "persistedQuery", "sha256Hash"]; + +/// Extracts "$.extensions.persistedQuery.sha256Hash" from the GraphQL request body. +pub(crate) struct ApolloExtractor; + +impl DocumentIdSourceExtractor for ApolloExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + ctx.json_path(APOLLO_HASH_PATH_SEGMENTS) + .and_then(|value| PersistedDocumentId::try_from(value.as_ref()).ok()) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs new file mode 100644 index 000000000..246009972 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs @@ -0,0 +1,13 @@ +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +pub(crate) const DOCUMENT_ID_FIELD: &str = "documentId"; + +/// Extracts "$.documentId" from the GraphQL request body. +pub(crate) struct DocumentIdExtractor; + +impl DocumentIdSourceExtractor for DocumentIdExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + PersistedDocumentId::from_option(ctx.document_id()) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs new file mode 100644 index 000000000..a2799f9fd --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs @@ -0,0 +1,118 @@ +use std::borrow::Cow; +use std::collections::HashMap; + +use sonic_rs::{JsonValueTrait, OwnedLazyValue, Value}; + +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +/// Extracts "$.x.y.z" from the GraphQL request body. +pub(crate) struct JsonPathExtractor { + // TODO: Add e2e coverage for JSON-path extraction edge cases + pub(crate) segments: Vec, +} + +impl DocumentIdSourceExtractor for JsonPathExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + ctx.json_path(&self.segments) + .and_then(|value| PersistedDocumentId::try_from(value.as_ref()).ok()) + } +} + +impl JsonPathExtractor { + pub(crate) fn requires_nonstandard_json_fields(segments: &[String]) -> bool { + // We only skip capturing unknown top-level fields when extraction can be + // satisfied by extensions.* + // Everything else requires captured nonstandard JSON fields. + if segments.is_empty() { + // Config validation rejects empty json_path + return false; + } + + segments[0] != "extensions" + } +} + +impl<'a> ExtractionContext<'a> { + pub(super) fn json_path>(&self, segments: &[T]) -> Option> { + let (first, rest) = segments.split_first()?; + let first = first.as_ref(); + + match first { + // We don't support JSON paths for these fields + "query" | "operationName" | "variables" => None, + "extensions" => self + .graphql_params + .extensions + .as_ref() + .and_then(|obj| Self::extract_from_object_map(obj, rest)), + other => self + .nonstandard_json_fields + .and_then(|map| map.get(other)) + .and_then(|value| extract_from_json_path(value, rest)), + } + } + + fn extract_from_object_map<'b, T: AsRef>( + obj: &'b HashMap, + segments: &[T], + ) -> Option> { + let (first, rest) = segments.split_first()?; + let value = obj.get(first.as_ref())?; + extract_from_json_path(value, rest) + } +} + +/// The trait exist to reuse `extract_from_json_path` logic across different types +pub(crate) trait JsonPathNode { + fn get_child(&self, key: &str) -> Option<&Self>; + fn as_document_id_value(&self) -> Option>; +} + +/// It's for extraction of document id from `extensions.*` +impl JsonPathNode for Value { + #[inline] + fn get_child(&self, key: &str) -> Option<&Self> { + self.get(key) + } + + #[inline] + fn as_document_id_value(&self) -> Option> { + if let Some(value) = self.as_str() { + return Some(Cow::Borrowed(value)); + } + + self.as_u64().map(|value| Cow::Owned(value.to_string())) + } +} + +/// It's for extraction of document id from non-standard fields +impl JsonPathNode for OwnedLazyValue { + #[inline] + fn get_child(&self, key: &str) -> Option<&Self> { + self.get(key) + } + + #[inline] + fn as_document_id_value(&self) -> Option> { + if let Some(value) = self.as_str() { + return Some(Cow::Borrowed(value)); + } + + self.as_u64().map(|value| Cow::Owned(value.to_string())) + } +} + +#[inline] +pub(crate) fn extract_from_json_path<'a, N, T>(value: &'a N, segments: &[T]) -> Option> +where + N: JsonPathNode, + T: AsRef, +{ + let mut current = value; + for segment in segments { + current = current.get_child(segment.as_ref())?; + } + + current.as_document_id_value() +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs new file mode 100644 index 000000000..087beaabf --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod apollo; +pub(crate) mod document_id; +pub(crate) mod json_path; +pub(crate) mod url_path_param; +pub(crate) mod url_query_param; diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs new file mode 100644 index 000000000..e3666cacc --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs @@ -0,0 +1,63 @@ +use hive_router_config::persisted_documents::PersistedDocumentUrlTemplate; +use matchit::Router; + +use crate::pipeline::persisted_documents::extract::HttpRequestContext; + +use super::super::super::types::PersistedDocumentId; +use super::super::core::{ + DocumentIdSourceExtractor, ExtractionContext, PersistedDocumentExtractError, +}; + +/// Extracts a value from the URL path using a template. +pub(crate) struct UrlPathParamExtractor { + pub(crate) router: Router<()>, +} + +impl DocumentIdSourceExtractor for UrlPathParamExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + let relative_path = ctx.relative_path()?; + let matched = self.router.at(relative_path).ok()?; + PersistedDocumentId::from_option(matched.params.get("id")) + } +} + +impl UrlPathParamExtractor { + pub(crate) fn try_from_template( + template: &PersistedDocumentUrlTemplate, + ) -> Result { + // Templates are validated to start with '/', so the first split segment is always empty. + let raw_segments: Vec<&str> = template.as_str().split('/').skip(1).collect(); + if !raw_segments.contains(&":id") { + return Err(PersistedDocumentExtractError::MissingIdParam { + template: template.as_str().to_string(), + }); + } + + let mut wildcard_index = 0; + // Converts our template syntax to `matchit` crate's syntax. + // We do it to not rely on `matchit` and be able to change the implementation later. + let route_segments = raw_segments.into_iter().map(|segment| match segment { + ":id" => "{id}".to_string(), + "*" => { + let route_param = format!("{{_w{wildcard_index}}}"); + wildcard_index += 1; + route_param + } + literal => literal.to_string(), + }); + let matchit_template = format!("/{}", route_segments.collect::>().join("/")); + + let mut router = Router::new(); + router + .insert(matchit_template, ()) + .map_err(|error| PersistedDocumentExtractError::MatcherCompile(error.to_string()))?; + + Ok(Self { router }) + } +} + +impl<'a> HttpRequestContext<'a> { + pub fn path(&self) -> &str { + self.path + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs new file mode 100644 index 000000000..7332df990 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs @@ -0,0 +1,232 @@ +use std::borrow::Cow; + +use crate::pipeline::persisted_documents::extract::HttpRequestContext; + +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +/// Extracts a value from the URL query string. +pub(crate) struct UrlQueryParamExtractor { + pub(crate) name: String, +} + +impl DocumentIdSourceExtractor for UrlQueryParamExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + ctx.query_param(&self.name) + .and_then(|value| PersistedDocumentId::try_from(value.as_ref()).ok()) + } +} + +impl<'a> HttpRequestContext<'a> { + pub fn query_param(&self, name: &str) -> Option> { + self.query.as_ref()?.get(name) + } +} + +pub(crate) struct QueryParams<'a> { + raw: &'a str, +} + +/// I tried to use different for url decoding, and query params parsing, +/// but they all either allocated entire HashMaps or were slow. +/// The difference sometimes was 10ns vs 400ns, +/// Especially on large set of query params, where the key was not found. +/// I decided to implement a custom query params parser that does not allocate, +/// and use url decoding crate, to perform safer decoding. +impl<'a> QueryParams<'a> { + pub(crate) fn new(raw: &'a str) -> Self { + Self { raw } + } + + pub(crate) fn get(&self, name: &str) -> Option> { + let value = Self::find_first_value(self.raw, name)?; + Self::decode_if_needed(value) + } + + #[inline] + fn find_first_value<'b>(query: &'b str, name: &str) -> Option<&'b str> { + let bytes = query.as_bytes(); + let name_bytes = name.as_bytes(); + + if name_bytes.is_empty() { + return None; + } + + // First-match semantics: + // - first `name=value` returns `Some(value)` + // - first `name` or `name=` returns `None` + // - once the first `name` is seen, later duplicates are ignored + for idx in memchr::memchr_iter(name_bytes[0], bytes) { + // If it is not preceded by a '&' or it's not the first character, skip it. + // Example: + // - /graphql?bar=1&foo=2 + // - /graphql?foo=3 + // - /graphql?foo=3&bar=1 + // - /graphql?xfoo=3 [continue] + // - /graphql?bar=1foo [continue] + if !Self::is_pair_boundary(bytes, idx) { + continue; + } + + // Confirm full key match at this boundary. + let Some(key_end) = Self::match_key_at(bytes, idx, name_bytes) else { + continue; + }; + + // Bare key at end (`...&name`) is treated as empty, + // so we return None to indicate the key is present but has no value. + if key_end == bytes.len() { + return None; + } + + // Separator after key: + // - `name&` + // - '=' => key-value pair, continue parsing + // - other => prefix match like `names=...`, keep scanning + let separator = bytes[key_end]; + // `name&` means the key is present but has no value, so we return None. + if separator == b'&' { + return None; + } + + // `names` is a prefix match, so we keep scanning. + if separator != b'=' { + continue; + } + + // `name=` and `name=&...` are treated as no value, so we return None. + let value_start = key_end + 1; + if value_start >= bytes.len() || bytes[value_start] == b'&' { + return None; + } + + let suffix = &bytes[value_start..]; + // Find the next `&` or end of string, if any. + let value_end = if let Some(offset) = memchr::memchr(b'&', suffix) { + value_start + offset + } else { + query.len() + }; + + // Value is present, return it. + if value_start < value_end { + return Some(&query[value_start..value_end]); + } + } + + None + } + + #[inline] + /// Returns `true` if the byte at `idx` is the start of a key-value pair boundary. + /// It is either the start of the query string or the previous character is `&`. + fn is_pair_boundary(bytes: &[u8], idx: usize) -> bool { + idx == 0 || bytes[idx - 1] == b'&' + } + + #[inline] + fn match_key_at(bytes: &[u8], idx: usize, name_bytes: &[u8]) -> Option { + let key_end = idx + name_bytes.len(); + // Key end is beyond the end of the query string, skip it. + if key_end > bytes.len() { + return None; + } + + // Key does not match, skip it. + if &bytes[idx..key_end] != name_bytes { + return None; + } + + Some(key_end) + } + + /// Decode url encoded value if necessary. + fn decode_if_needed<'b>(value: &'b str) -> Option> { + let value_bytes = value.as_bytes(); + let percent_at = memchr::memchr(b'%', value_bytes); + let plus_at = memchr::memchr(b'+', value_bytes); + + if percent_at.is_none() && plus_at.is_none() { + // No need to decode, return as is. + return Some(Cow::Borrowed(value)); + } + + let Some(plus_at) = plus_at else { + return percent_encoding::percent_decode(value_bytes) + .decode_utf8() + .ok(); + }; + + // Special case we need to handle. + // `+` is a space character in url encoding, so we replace it with a space. + // I tried to use form_urlencoded crate but it was 4x slower than this. + // The percent_encoding does not handle `+` as a space character, so we replace it first. + // That's why we use Cow::Owned here, and Cow in general to avoid allocations. + let replaced = Self::replace_plus(value_bytes, plus_at); + + let decoded = percent_encoding::percent_decode(&replaced) + .decode_utf8() + .ok()?; + Some(Cow::Owned(decoded.into_owned())) + } + + fn replace_plus(input: &[u8], first_position: usize) -> Cow<'_, [u8]> { + let mut replaced = input.to_owned(); + replaced[first_position] = b' '; + for byte in &mut replaced[first_position + 1..] { + if *byte == b'+' { + *byte = b' '; + } + } + Cow::Owned(replaced) + } +} + +#[cfg(test)] +mod tests { + use super::QueryParams; + + fn query_param(raw_query: &str, name: &str) -> Option { + QueryParams::new(raw_query) + .get(name) + .map(|value| value.into_owned()) + } + + #[test] + fn query_params_lookup_rules() { + let cases = [ + ("key=first&key=second", "key", Some("first")), + ("key=&key=second", "key", None), + ("key&key=second", "key", None), + ("keys=1&key=value", "key", Some("value")), + ("xkey=1&key=value", "key", Some("value")), + ("foo=bar", "key", None), + ("", "key", None), + ("key=value", "", None), + ]; + + for (query, name, expected) in cases { + let actual = query_param(query, name); + assert_eq!( + actual.as_deref(), + expected, + "query='{query}', name='{name}'" + ); + } + } + + #[test] + fn query_params_decoding_rules() { + let cases = [ + ("key=a+b", Some("a b")), + ("key=a%2Bb", Some("a+b")), + ("key=sha256%3Aabc", Some("sha256:abc")), + ("key=abc%ZZ", Some("abc%ZZ")), + ]; + + for (query, expected) in cases { + let actual = query_param(query, "key"); + assert_eq!(actual.as_deref(), expected, "query='{query}'"); + } + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/mod.rs b/bin/router/src/pipeline/persisted_documents/extract/mod.rs new file mode 100644 index 000000000..a44e5a8e5 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/mod.rs @@ -0,0 +1,7 @@ +mod core; +mod extractors; + +pub use core::{ + DocumentIdResolver, DocumentIdResolverInput, HttpRequestContext, PersistedDocumentExtractError, +}; +pub(crate) use extractors::document_id::DOCUMENT_ID_FIELD; diff --git a/bin/router/src/pipeline/persisted_documents/mod.rs b/bin/router/src/pipeline/persisted_documents/mod.rs new file mode 100644 index 000000000..6bb578aa8 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/mod.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use hive_router_config::persisted_documents::{ + PersistedDocumentsConfig, PersistedDocumentsStorageConfig, +}; +use hive_router_internal::background_tasks::BackgroundTasksManager; + +use crate::pipeline::persisted_documents::extract::DocumentIdResolver; +use crate::pipeline::persisted_documents::resolve::{ + FileManifestReloadTask, FileManifestResolver, HiveCDNResolver, PersistedDocumentResolver, + PersistedDocumentResolverError, +}; + +pub mod extract; +pub mod resolve; +pub mod types; + +pub struct PersistedDocumentsRuntime { + pub document_id_resolver: Arc, + pub persisted_document_resolver: Option>, +} + +impl PersistedDocumentsRuntime { + pub async fn init( + config: &PersistedDocumentsConfig, + graphql_endpoint: &str, + background_tasks_mgr: &mut BackgroundTasksManager, + ) -> Result { + let document_id_resolver = Arc::new( + DocumentIdResolver::from_config(config, graphql_endpoint).map_err(|error| { + PersistedDocumentResolverError::Configuration(format!( + "failed to build persisted document extraction plan: {error}" + )) + })?, + ); + + let persisted_document_resolver = if config.enabled { + let storage = config + .storage + .as_ref() + .ok_or(PersistedDocumentResolverError::StorageNotConfigured)?; + match storage { + PersistedDocumentsStorageConfig::File { config } => { + let resolver = + Arc::new(FileManifestResolver::from_storage_config(config).await?); + if resolver.has_watcher() { + background_tasks_mgr + .register_task(FileManifestReloadTask(resolver.clone())); + } + Some(resolver as Arc) + } + PersistedDocumentsStorageConfig::Hive { config } => { + let resolver = Arc::new(HiveCDNResolver::from_storage_config(config)?); + Some(resolver as Arc) + } + } + } else { + None + }; + + Ok(Self { + document_id_resolver, + persisted_document_resolver, + }) + } + + pub fn supports_graphql_endpoint(&self, graphql_endpoint: &str) -> bool { + if !self.document_id_resolver.is_enabled() { + return true; + } + + if !self.document_id_resolver.depends_on_graphql_path() { + return true; + } + + let is_root_endpoint = graphql_endpoint.trim_end_matches('/').is_empty(); + + // `/` can't be used as it would conflict with the path param extractor. + // The `/:id` would match `/health` endpoint for example. + !is_root_endpoint + } +} diff --git a/bin/router/src/pipeline/persisted_documents/resolve/file.rs b/bin/router/src/pipeline/persisted_documents/resolve/file.rs new file mode 100644 index 000000000..49eb8e498 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/resolve/file.rs @@ -0,0 +1,326 @@ +use arc_swap::ArcSwap; +use async_trait::async_trait; +use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Deserialize; +use std::borrow::Cow; +use std::collections::HashMap; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use thiserror::Error; +use tokio::sync::{Mutex, Notify}; +use tracing::{info, warn}; + +use hive_router_config::persisted_documents::PersistedDocumentsFileStorageConfig; +use hive_router_internal::background_tasks::{BackgroundTask, CancellationToken}; + +use super::{ + PersistedDocumentResolveInput, PersistedDocumentResolver, PersistedDocumentResolverError, + ResolvedDocument, +}; + +const RELOAD_EVENT_DEBOUNCE: Duration = Duration::from_millis(150); + +// In-memory map used by the file manifest resolver. +// Values are Arc-backed so lookups only clone cheap references. +struct DocumentsById(HashMap>); + +impl Deref for DocumentsById { + type Target = HashMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait] +impl PersistedDocumentResolver for FileManifestResolver { + async fn resolve( + &self, + input: PersistedDocumentResolveInput<'_>, + ) -> Result { + // File manifests are keyed only by persisted document id. + // Client identity is ignored for this source type. + let text = self + .documents + .load() + .get(input.persisted_document_id.as_ref()) + .cloned() + .ok_or_else(|| { + PersistedDocumentResolverError::NotFound(input.persisted_document_id.to_string()) + })?; + + Ok(ResolvedDocument { text }) + } +} + +pub struct FileManifestResolver { + manifest_path: String, + // Snapshot of currently active documents for lock-free reads. + documents: ArcSwap, + // Signals a potential file change + dirty: Arc, + // Ensures at-most-one reload in flight so watcher events do not race + // and publish snapshots out of order. + reload_guard: Mutex<()>, + // Notification channel from watcher callback to background reload task. + reload_signal: Arc, + watcher: Option, +} + +// Background task wrapper registered in the shared task manager. +pub struct FileManifestReloadTask(pub Arc); + +impl Deref for FileManifestReloadTask { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Deserialize)] +struct ApolloPersistedQueryManifest<'a> { + #[serde(borrow)] + format: Cow<'a, str>, + version: u8, + #[serde(borrow)] + operations: Vec>, +} + +#[derive(Deserialize)] +struct ApolloPersistedQueryOperation<'a> { + #[serde(borrow)] + id: Cow<'a, str>, + #[serde(borrow)] + body: Cow<'a, str>, +} + +type KeyValueManifest<'a> = HashMap, Cow<'a, str>>; + +#[derive(Deserialize)] +#[serde(untagged)] +#[serde(bound(deserialize = "'de: 'a"))] +enum PersistedDocumentsManifest<'a> { + Apollo(ApolloPersistedQueryManifest<'a>), + KeyValue(KeyValueManifest<'a>), +} + +#[derive(Debug, Error)] +pub enum FileResolverError { + #[error("failed to read persisted documents manifest at '{path}': {message}")] + ReadManifest { path: String, message: String }, + #[error("failed to parse persisted documents manifest at '{path}': {message}")] + ParseManifest { path: String, message: String }, + #[error("unsupported apollo manifest format. Expected 'apollo-persisted-query-manifest', received '{format}'")] + UnsupportedApolloManifestFormat { format: String }, + #[error("unsupported apollo manifest version. Expected '1', received '{version}'")] + UnsupportedApolloManifestVersion { version: u8 }, + #[error("failed to initialize persisted documents file watcher for '{path}': {message}")] + WatcherInit { path: String, message: String }, + #[error("failed to watch persisted documents path '{path}': {message}")] + WatcherWatchPath { path: String, message: String }, +} + +impl FileManifestResolver { + pub async fn from_storage_config( + config: &PersistedDocumentsFileStorageConfig, + ) -> Result { + let manifest_path = config.path.absolute.clone(); + let documents = Self::read_manifest_documents(&manifest_path).await?; + let dirty = Arc::new(AtomicBool::new(false)); + let reload_signal = Arc::new(Notify::new()); + let watcher = if config.watch { + Some(Self::create_watcher( + &manifest_path, + Arc::clone(&dirty), + Arc::clone(&reload_signal), + )?) + } else { + None + }; + + Ok(Self { + manifest_path, + documents: ArcSwap::from_pointee(documents), + dirty, + reload_guard: Mutex::new(()), + reload_signal, + watcher, + }) + } + + pub(crate) fn has_watcher(&self) -> bool { + self.watcher.is_some() + } + + fn create_watcher( + manifest_path: &str, + dirty: Arc, + reload_signal: Arc, + ) -> Result { + let path = Path::new(manifest_path); + let manifest_path_buf = PathBuf::from(manifest_path); + // Watch the parent directory so replace/rename save patterns are observed. + let watch_target = path.parent().unwrap_or(path); + + let mut watcher = match RecommendedWatcher::new( + move |result: notify::Result| { + let should_signal_reload = match result { + Ok(event) => { + let is_relevant_kind = matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ); + let touches_manifest = + event.paths.iter().any(|path| path == &manifest_path_buf); + is_relevant_kind && touches_manifest + } + Err(err) => { + warn!("persisted documents watcher event failed: {err}"); + true + } + }; + + if should_signal_reload { + dirty.store(true, Ordering::Relaxed); + reload_signal.notify_one(); + } + }, + NotifyConfig::default(), + ) { + Ok(watcher) => watcher, + Err(err) => { + return Err(FileResolverError::WatcherInit { + path: manifest_path.to_string(), + message: err.to_string(), + } + .into()); + } + }; + + if let Err(err) = watcher.watch(watch_target, RecursiveMode::NonRecursive) { + return Err(FileResolverError::WatcherWatchPath { + path: manifest_path.to_string(), + message: err.to_string(), + } + .into()); + } + + Ok(watcher) + } + + // Keeps last known good snapshot active when reload fails + pub(crate) async fn reload_if_needed(&self) -> Result<(), PersistedDocumentResolverError> { + let _reload_guard = self.reload_guard.lock().await; + + if !self.dirty.swap(false, Ordering::Relaxed) { + return Ok(()); + } + + let documents = Self::read_manifest_documents(&self.manifest_path).await?; + self.documents.store(Arc::new(documents)); + info!( + "reloaded persisted documents manifest from '{}'", + self.manifest_path + ); + Ok(()) + } + + async fn read_manifest_documents( + manifest_path: &str, + ) -> Result { + tokio::fs::read(manifest_path) + .await + .map_err(|err| { + PersistedDocumentResolverError::from(FileResolverError::ReadManifest { + path: manifest_path.to_string(), + message: err.to_string(), + }) + }) + .and_then(|raw| { + let manifest: PersistedDocumentsManifest<'_> = + sonic_rs::from_slice(&raw).map_err(|err| FileResolverError::ParseManifest { + path: manifest_path.to_string(), + message: err.to_string(), + })?; + + manifest.try_into() + }) + } +} + +#[async_trait] +impl BackgroundTask for FileManifestReloadTask { + fn id(&self) -> &str { + "persisted-documents-file-reloader" + } + + async fn run(&self, token: CancellationToken) { + // Watcher events are debounced to reduce noisy save/update actions + while token + .run_until_cancelled(async { + self.reload_signal.notified().await; + tokio::time::sleep(RELOAD_EVENT_DEBOUNCE).await; + }) + .await + .is_some() + { + if let Err(err) = self.reload_if_needed().await { + warn!("persisted documents background reload failed: {err}"); + } + } + } +} + +impl<'a> TryFrom> for DocumentsById { + type Error = PersistedDocumentResolverError; + + fn try_from(value: PersistedDocumentsManifest<'a>) -> Result { + match value { + PersistedDocumentsManifest::Apollo(manifest) => manifest.try_into(), + PersistedDocumentsManifest::KeyValue(manifest) => Ok(manifest.into()), + } + } +} + +impl<'a> TryFrom> for DocumentsById { + type Error = PersistedDocumentResolverError; + + fn try_from(manifest: ApolloPersistedQueryManifest<'a>) -> Result { + if manifest.format != "apollo-persisted-query-manifest" { + return Err(FileResolverError::UnsupportedApolloManifestFormat { + format: manifest.format.into_owned(), + } + .into()); + } + + if manifest.version != 1 { + return Err(FileResolverError::UnsupportedApolloManifestVersion { + version: manifest.version, + } + .into()); + } + + Ok(DocumentsById( + manifest + .operations + .into_iter() + .map(|op| (op.id.into_owned(), Arc::::from(op.body))) + .collect::>(), + )) + } +} + +impl<'a> From> for DocumentsById { + fn from(manifest: KeyValueManifest<'a>) -> Self { + DocumentsById( + manifest + .into_iter() + .map(|(id, text)| (id.into_owned(), Arc::::from(text))) + .collect(), + ) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/resolve/hive.rs b/bin/router/src/pipeline/persisted_documents/resolve/hive.rs new file mode 100644 index 000000000..d2a7219f6 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/resolve/hive.rs @@ -0,0 +1,341 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use async_trait::async_trait; +use hive_console_sdk::circuit_breaker::CircuitBreakerBuilder; +use hive_console_sdk::persisted_documents::{PersistedDocumentsError, PersistedDocumentsManager}; +use hive_router_config::persisted_documents::PersistedDocumentsHiveStorageConfig; +use thiserror::Error; + +use crate::consts::ROUTER_VERSION; + +use super::{ + PersistedDocumentResolveInput, PersistedDocumentResolver, PersistedDocumentResolverError, + ResolvedDocument, +}; + +pub struct HiveCDNResolver { + manager: PersistedDocumentsManager, +} + +static CLIENT_INSTRUCTIONS: &str = "Provide both client name and version headers, or send persisted document id in 'appName~appVersion~documentId' format"; + +#[derive(Debug, Error)] +pub enum HiveResolverError { + #[error("persisted_documents.storage.hive.endpoint is not configured")] + MissingEndpoint, + #[error("persisted_documents.storage.hive.key is not configured")] + MissingKey, + #[error("Document id format is invalid. Either 'appName~appVersion~documentId' or 'documentId' is accepted, received: {0}")] + InvalidDocumentIdFormat(String), + #[error("Client identity is missing. {CLIENT_INSTRUCTIONS}")] + ClientIdentityMissing, + #[error("Client identity is partial. {CLIENT_INSTRUCTIONS}")] + ClientIdentityPartial, + #[error("Initialization failed: {0}")] + ManagerInit(String), + #[error("SDK error: {0}")] + SDKError(String), +} + +struct AppDocumentId<'a>(Cow<'a, str>); + +enum DocumentIdSyntax<'a> { + App(&'a str), + Plain(&'a str), +} + +impl<'a> TryFrom<&'a str> for DocumentIdSyntax<'a> { + type Error = HiveResolverError; + + // It uses memchr. I performed a benchmark with a lot of solutions, even byte by byte scanning. + // It was the best bang for the buck option. + fn try_from(value: &'a str) -> Result { + let bytes = value.as_bytes(); + + // First '~' separates app name from app version. + let Some(first) = memchr::memchr(b'~', bytes) else { + // If there is no '~', the entire value is a plain document id + return Ok(Self::Plain(value)); + }; + + // We found "~...." - empty app name segment + if first == 0 { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Second '~' separates app version from document id + let Some(second_relative) = memchr::memchr(b'~', &bytes[first + 1..]) else { + // We found "appName~documentId", so it lacks an app version segment + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + }; + // If the relative position of the second '~' is 0, it means it's right after the first '~'. + // Found "appName~~documentId", so it has the app version segment, but it's empty. + if second_relative == 0 { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Compute the absolute position of the second '~' + let second = first + 1 + second_relative; + + // Check if it's not the last character of the string. + // If it is, we found an empty document id segment. + if second + 1 >= bytes.len() { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Syntax with more than 2 separators is invalid. + if memchr::memchr(b'~', &bytes[second + 1..]).is_some() { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Syntax is valid, return appName~appVersion~documentId. + Ok(Self::App(value)) + } +} + +impl<'a> TryFrom> for AppDocumentId<'a> { + type Error = HiveResolverError; + + fn try_from(input: PersistedDocumentResolveInput<'a>) -> Result { + let persisted_document_id = input.persisted_document_id.as_ref(); + + match DocumentIdSyntax::try_from(persisted_document_id)? { + DocumentIdSyntax::App(app_document_id) => { + // Raw app-included id takes precedence over client identity headers. + Ok(Self(Cow::Borrowed(app_document_id))) + } + DocumentIdSyntax::Plain(document_id) => { + match (input.client_identity.name, input.client_identity.version) { + (Some(name), Some(version)) => { + Ok(Self(Cow::Owned(format!("{name}~{version}~{document_id}")))) + } + (Some(_), None) | (None, Some(_)) => { + Err(HiveResolverError::ClientIdentityPartial) + } + (None, None) => Err(HiveResolverError::ClientIdentityMissing), + } + } + } + } +} + +impl AsRef for AppDocumentId<'_> { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl HiveCDNResolver { + pub fn from_storage_config( + config: &PersistedDocumentsHiveStorageConfig, + ) -> Result { + let endpoints: Vec = config + .endpoint + .clone() + .ok_or(HiveResolverError::MissingEndpoint)? + .into(); + let key = config.key.clone().ok_or(HiveResolverError::MissingKey)?; + + let circuit_breaker = CircuitBreakerBuilder::default() + .error_threshold(config.circuit_breaker.error_threshold) + .volume_threshold(config.circuit_breaker.volume_threshold) + .reset_timeout(config.circuit_breaker.reset_timeout); + + let mut builder = PersistedDocumentsManager::builder() + .key(key) + .accept_invalid_certs(config.accept_invalid_certs) + .connect_timeout(config.connect_timeout) + .request_timeout(config.request_timeout) + .max_retries(config.retry_policy.max_retries) + .cache_size(config.cache_size) + .circuit_breaker(circuit_breaker) + .user_agent(format!("hive-router/{ROUTER_VERSION}")); + + if let Some(negative_cache) = config.negative_cache.enabled_config() { + builder = builder.negative_cache_ttl(negative_cache.ttl); + } + + for endpoint in endpoints { + builder = builder.add_endpoint(endpoint); + } + + let manager = builder + .build() + .map_err(|err| HiveResolverError::ManagerInit(err.to_string()))?; + Ok(Self { manager }) + } +} + +#[async_trait] +impl PersistedDocumentResolver for HiveCDNResolver { + // TODO: Consider implementing stale-while-revalidate (SWR). + // + // Requirements: + // - We should not spawn a task/thread per request when an entry becomes stale. + // - Revalidation must be bounded (queue + capped worker concurrency) to avoid overload. + // - Requests should keep serving stale entries during the grace window while refresh runs. + // - Refreshes should be de-duplicated per document id (avoid N concurrent refreshes for same key). + // - Queue overflow and cancellation/shutdown behavior must be defined explicitly. + // - Interaction with SDK cache and negative-cache semantics needs careful handling. + // + // Suggested: + // - Add a background task (similar to file resolver) with "notify" + bounded queue. + // - Keep per-entry freshness metadata: fresh until / stale until. + // - On stale hit: return stale + enqueue refresh + // - On miss/expired: fetch + // - Add observability counters for stale-served, refresh-enqueued, refresh-failed, queue-dropped. + async fn resolve( + &self, + input: PersistedDocumentResolveInput<'_>, + ) -> Result { + let app_document_id = AppDocumentId::try_from(input)?; + let text = self + .manager + .resolve_document(app_document_id.as_ref()) + .await + .map_err(|err| match err { + PersistedDocumentsError::DocumentNotFound => { + PersistedDocumentResolverError::NotFound(app_document_id.as_ref().to_string()) + } + other => HiveResolverError::SDKError(other.to_string()).into(), + })?; + + Ok(ResolvedDocument { + text: Arc::::from(text), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{AppDocumentId, PersistedDocumentResolveInput}; + use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; + + struct Case { + raw_id: &'static str, + client_name: Option<&'static str>, + client_version: Option<&'static str>, + expected: Result<&'static str, &'static str>, + } + + #[test] + fn app_document_id_conversion_matrix() { + let cases = [ + Case { + raw_id: "documentId", + client_name: Some("app"), + client_version: Some("1.0.0"), + expected: Ok("app~1.0.0~documentId"), + }, + Case { + raw_id: "app~1.0.0~documentId", + client_name: None, + client_version: None, + expected: Ok("app~1.0.0~documentId"), + }, + Case { + raw_id: "app~1.0.0~documentId", + client_name: Some("app"), + client_version: Some("1.2.3"), + expected: Ok("app~1.0.0~documentId"), + }, + Case { + raw_id: "documentId", + client_name: None, + client_version: None, + expected: Err("missing"), + }, + Case { + raw_id: "documentId", + client_name: Some("app"), + client_version: None, + expected: Err("partial"), + }, + Case { + raw_id: "documentId", + client_name: None, + client_version: Some("1.0.0"), + expected: Err("partial"), + }, + Case { + raw_id: "app~documentId", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "app~~documentId", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "~1.0.0~documentId", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "app~1.0.0~", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "a~b~c~d", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + ]; + + for (idx, case) in cases.into_iter().enumerate() { + let persisted_document_id = + PersistedDocumentId::try_from(case.raw_id).expect("fixture id should parse"); + let input = PersistedDocumentResolveInput { + persisted_document_id: &persisted_document_id, + client_identity: ClientIdentity { + name: case.client_name, + version: case.client_version, + }, + }; + + match (AppDocumentId::try_from(input), case.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual.as_ref(), expected, "case_index={idx}") + } + (Err(err), Err(expected)) => { + assert!( + err.to_string().contains(expected), + "case_index={}, err={}", + idx, + err + ); + } + (Ok(actual), Err(expected)) => panic!( + "case_index={} expected err containing '{}' but got Ok({})", + idx, + expected, + actual.as_ref() + ), + (Err(err), Ok(expected)) => { + panic!( + "case_index={} expected Ok({}) but got Err({})", + idx, expected, err + ) + } + } + } + } +} diff --git a/bin/router/src/pipeline/persisted_documents/resolve/mod.rs b/bin/router/src/pipeline/persisted_documents/resolve/mod.rs new file mode 100644 index 000000000..ba1b8ac57 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/resolve/mod.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use std::sync::Arc; + +use crate::pipeline::error::PipelineError; +use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; +use file::FileResolverError; +use hive::HiveResolverError; + +pub mod file; +pub mod hive; + +pub use file::{FileManifestReloadTask, FileManifestResolver}; +pub use hive::HiveCDNResolver; + +#[derive(Debug, Clone, Copy)] +pub struct PersistedDocumentResolveInput<'a> { + pub persisted_document_id: &'a PersistedDocumentId, + pub client_identity: ClientIdentity<'a>, +} + +#[derive(Debug, thiserror::Error)] +pub enum PersistedDocumentResolverError { + #[error("Persisted document not found: {0}")] + NotFound(String), + #[error("Persisted documents configuration error: {0}")] + Configuration(String), + #[error("Persisted documents storage is not configured")] + StorageNotConfigured, + #[error("Hive Storage: {0}")] + Hive(#[from] HiveResolverError), + #[error("File Storage: {0}")] + File(#[from] FileResolverError), +} + +impl From for PipelineError { + fn from(value: PersistedDocumentResolverError) -> Self { + match value { + PersistedDocumentResolverError::NotFound(document_id) => { + PipelineError::PersistedDocumentNotFound(document_id) + } + PersistedDocumentResolverError::Hive(HiveResolverError::InvalidDocumentIdFormat(_)) + | PersistedDocumentResolverError::Hive(HiveResolverError::ClientIdentityMissing) + | PersistedDocumentResolverError::Hive(HiveResolverError::ClientIdentityPartial) => { + PipelineError::PersistedDocumentExtraction(value.to_string()) + } + other => PipelineError::PersistedDocumentResolution(other.to_string()), + } + } +} + +#[derive(Debug)] +pub struct ResolvedDocument { + pub text: Arc, +} + +#[async_trait] +pub trait PersistedDocumentResolver: Send + Sync { + async fn resolve( + &self, + input: PersistedDocumentResolveInput<'_>, + ) -> Result; +} diff --git a/bin/router/src/pipeline/persisted_documents/types.rs b/bin/router/src/pipeline/persisted_documents/types.rs new file mode 100644 index 000000000..030d35dfc --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/types.rs @@ -0,0 +1,72 @@ +use std::fmt; +use std::ops::Deref; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedDocumentId(String); + +impl PersistedDocumentId { + #[inline] + pub fn new(id: String) -> Self { + Self(id) + } + + #[inline] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + #[inline] + pub fn from_option(raw: Option<&str>) -> Option { + raw.and_then(|raw| raw.try_into().ok()) + } +} + +impl TryFrom<&str> for PersistedDocumentId { + type Error = (); + + fn try_from(raw: &str) -> Result { + if raw.is_empty() { + return Err(()); + } + + // Keep IDs exactly as provided (including algorithm prefixes like + // "sha256:...") so extraction and storage use the same key. + Ok(Self::new(raw.to_string())) + } +} + +impl Deref for PersistedDocumentId { + type Target = str; + + #[inline] + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl AsRef for PersistedDocumentId { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for PersistedDocumentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ClientIdentity<'a> { + // Optional client name and version provided by request identification. + // Not all persisted-document sources need these fields. + pub name: Option<&'a str>, + pub version: Option<&'a str>, +} + +impl ClientIdentity<'_> { + pub fn is_empty(&self) -> bool { + self.name.is_none() && self.version.is_none() + } +} diff --git a/bin/router/src/shared_state.rs b/bin/router/src/shared_state.rs index de96706d3..eb259c3d4 100644 --- a/bin/router/src/shared_state.rs +++ b/bin/router/src/shared_state.rs @@ -36,6 +36,8 @@ use crate::pipeline::multipart_subscribe::{ self, APOLLO_MULTIPART_HTTP_CONTENT_TYPE, INCREMENTAL_DELIVERY_CONTENT_TYPE, }; use crate::pipeline::parser::ParseCacheEntry; +use crate::pipeline::persisted_documents::resolve::PersistedDocumentResolverError; +use crate::pipeline::persisted_documents::PersistedDocumentsRuntime; use crate::pipeline::progressive_override::{OverrideLabelsCompileError, OverrideLabelsEvaluator}; use crate::pipeline::sse; @@ -269,6 +271,7 @@ impl Expiry> for JwtClaimsExpiry { pub struct RouterSharedState { pub validation_plan: Arc, pub parse_cache: Cache, + pub persisted_documents_runtime: PersistedDocumentsRuntime, pub router_config: Arc, pub headers_plan: Arc, pub override_labels_evaluator: OverrideLabelsEvaluator, @@ -295,6 +298,7 @@ impl RouterSharedState { #[allow(clippy::too_many_arguments)] pub fn new( router_config: Arc, + persisted_documents_runtime: PersistedDocumentsRuntime, jwt_auth_runtime: Option, hive_usage_agent: Option, validation_plan: ValidationPlan, @@ -308,6 +312,7 @@ impl RouterSharedState { validation_plan: Arc::new(validation_plan), headers_plan: Arc::new(compile_headers_plan(&router_config.headers).map_err(Box::new)?), parse_cache, + persisted_documents_runtime, cors_runtime: Cors::from_config(&router_config.cors).map_err(Box::new)?, jwt_claims_cache: Cache::builder() // High capacity due to potentially high token diversity. @@ -349,6 +354,8 @@ pub enum SharedStateError { OverrideLabelsCompile(#[from] Box), #[error("error creating hive usage agent: {0}")] UsageAgent(#[from] Box), + #[error("invalid persisted documents config: {0}")] + PersistedDocuments(#[from] Box), #[error("invalid introspection config: {0}")] IntrospectionPolicyCompile(#[from] Box), } diff --git a/docs/README.md b/docs/README.md index e4bf7fa64..38eee5ba9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ |[**log**](#log)|`object`|The router logger configuration.
Default: `{"filter":null,"format":"json","level":"info"}`
|| |[**override\_labels**](#override_labels)|`object`|Configuration for overriding labels.
|| |[**override\_subgraph\_urls**](#override_subgraph_urls)|`object`|Configuration for overriding subgraph URLs.
Default: `{}`
|| +|[**persisted\_documents**](#persisted_documents)|`object`|Configuration for persisted documents extraction and resolution.
Default: `{"enabled":false,"log_missing_id":false,"require_id":false,"selectors":null,"storage":null}`
|| |[**plugins**](#plugins)|`object`|Configuration for custom plugins
|| |[**query\_planner**](#query_planner)|`object`|Query planning configuration.
Default: `{"allow_expose":false,"timeout":"10s"}`
|| |[**subscriptions**](#subscriptions)|`object`|Configuration for subscriptions.
Default: `{"broadcast_capacity":0,"enabled":false}`
|| @@ -117,6 +118,12 @@ override_subgraph_urls: .default } +persisted_documents: + enabled: false + log_missing_id: false + require_id: false + selectors: null + storage: null plugins: {} query_planner: allow_expose: false @@ -598,7 +605,6 @@ propagate: {} ``` -  **Option 2 (alternative):** Remove headers before sending the request to a subgraph. @@ -621,7 +627,6 @@ remove: {} ``` -  **Option 3 (alternative):** Add or overwrite a header with a static value. @@ -754,7 +759,6 @@ Static value provided in the config. |**value**|`string`||yes| -  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -821,7 +825,6 @@ propagate: {} ``` -  **Option 2 (alternative):** Remove headers before sending the response to the client. @@ -841,7 +844,6 @@ remove: {} ``` -  **Option 3 (alternative):** Add or overwrite a header in the response to the client. @@ -976,7 +978,6 @@ Static value provided in the config. |**value**|`string`||yes| -  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -1073,7 +1074,6 @@ propagate: {} ``` -  **Option 2 (alternative):** Remove headers before sending the request to a subgraph. @@ -1096,7 +1096,6 @@ remove: {} ``` -  **Option 3 (alternative):** Add or overwrite a header with a static value. @@ -1229,7 +1228,6 @@ Static value provided in the config. |**value**|`string`||yes| -  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -1296,7 +1294,6 @@ propagate: {} ``` -  **Option 2 (alternative):** Remove headers before sending the response to the client. @@ -1316,7 +1313,6 @@ remove: {} ``` -  **Option 3 (alternative):** Add or overwrite a header in the response to the client. @@ -1451,7 +1447,6 @@ Static value provided in the config. |**value**|`string`||yes| -  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -1641,7 +1636,6 @@ A local file on the file-system. This file will be read once on startup and cach |**source**|`string`|Constant Value: `"file"`
|yes| -  **Option 2 (alternative):** A remote JWKS provider. The JWKS will be fetched via HTTP/HTTPS and cached. @@ -1683,7 +1677,6 @@ The first one that is found will be used. |**source**|`string`|Constant Value: `"header"`
|yes| -  **Option 2 (alternative):** **Properties** @@ -1886,6 +1879,73 @@ products: |----|----|-----------|--------| |**url**||Overrides for the URL of the subgraph.

For convenience, a plain string in your configuration will be treated as a static URL.

### Static URL Example
```yaml
url: "https://api.example.com/graphql"
```

### Dynamic Expression Example

The expression has access to the following variables:
- `request`: The incoming HTTP request, including headers and other metadata.
- `default`: The original URL of the subgraph (from supergraph sdl).

```yaml
url:
expression: \|
if .request.headers."x-region" == "us-east" {
"https://products-us-east.example.com/graphql"
} else if .request.headers."x-region" == "eu-west" {
"https://products-eu-west.example.com/graphql"
} else {
.default
}
|yes| +
+## persisted\_documents: object + +Configuration for persisted documents extraction and resolution. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**enabled**|`boolean`|Default: `false`
|| +|**log\_missing\_id**|`boolean`|Default: `false`
|| +|**require\_id**|`boolean`|Default: `false`
|| +|[**selectors**](#persisted_documentsselectors)|`array`||| +|**storage**|||| + +**Example** + +```yaml +enabled: false +log_missing_id: false +require_id: false +selectors: null +storage: null + +``` + + +### persisted\_documents\.selectors\[\]: array,null + +**Items** + +  +**Option 1 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**path**|`string`||yes| +|**type**|`string`|Constant Value: `"json_path"`
|yes| + + +**Option 2 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**template**|`string`||yes| +|**type**|`string`|Constant Value: `"url_path_param"`
|yes| + + +**Option 3 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**name**|`string`||yes| +|**type**|`string`|Constant Value: `"url_query_param"`
|yes| + + +**Example** + +```yaml +{} + +``` + ## plugins: object @@ -2061,7 +2121,6 @@ Configuration for the Federation supergraph source. By default, the router will Each source has a different set of configuration, depending on the source type. -  **Option 1 (alternative):** Loads a supergraph from the filesystem. The path can be either absolute or relative to the router's working directory. @@ -2084,7 +2143,6 @@ poll_interval: null ``` -  **Option 2 (alternative):** Loads a supergraph from Hive Console CDN. @@ -2467,7 +2525,6 @@ temporality: cumulative ``` -  **Option 2 (alternative):** **Properties** @@ -2821,7 +2878,6 @@ http: null ``` -  **Option 2 (alternative):** **Properties** @@ -3204,4 +3260,3 @@ source: connection ``` - diff --git a/docs/design/persisted-documents/apollo-client.md b/docs/design/persisted-documents/apollo-client.md new file mode 100644 index 000000000..e04cb4f47 --- /dev/null +++ b/docs/design/persisted-documents/apollo-client.md @@ -0,0 +1,108 @@ +# Persisted Documents - Apollo ClientRepository with an example + +Example available here: https://github.com/kamilkisiela/graphql-persisted-operations-example/tree/main/apps/apollo + +Here’s what Apollo recommends: https://www.apollographql.com/docs/react/data/persisted-queries + +## Manifest + +Manifest is generated with https://www.npmjs.com/package/@apollo/generate-persisted-query-manifest + +```bash +$ npx generate-persisted-query-manifest +``` + +A new file is created ./persisted-query-manifest.json with this content: + +```json +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87", + "name": "ApolloCountries", + "type": "query", + "body": "query ApolloCountries {\n countries {\n code\n name\n emoji\n __typename\n }\n}" + } + ] +} +``` + +## Client setup + +Here’s the recommended (by Apollo docs) Apollo Client setup: + +```js +import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import { generatePersistedQueryIdsFromManifest } from "@apollo/persisted-query-lists"; +import { PersistedQueryLink } from "@apollo/client/link/persisted-queries"; + +const persistedQueryLink = new PersistedQueryLink( + generatePersistedQueryIdsFromManifest({ + loadManifest: () => import("../persisted-query-manifest.json"), + }), +); + +const httpLink = new HttpLink({ + uri: "http://localhost:4000/graphql", +}); + +export const apolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: persistedQueryLink.concat(httpLink), + clientAwareness: { + name: "example", + version: "1.0.0", + }, +}); +`` + +Manifest is consumed by Apollo Client with https://www.npmjs.com/package/@apollo/persisted-query-lists. +We pass client’s name and version the way it’s intended in Apollo Client. + +### Http Request + +What Apollo Client sends to the server. + +Body: +```json +{ + "operationName":"ApolloCountries", + "variables":{}, + "extensions":{ + "clientLibrary":{ + "name":"@apollo/client", + "version":"4.1.6" + }, + "persistedQuery":{ + "version":1, + "sha256Hash":"9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87" + } + } +} +``` + +Headers: +``` +apollographql-client-name: example +apollographql-client-version: 1.0.0 +``` + +## Gateway + +What the gateway knows: + +* it’s Apollo format (use of extensions with persistedQuery field) +* document’s id +* operation’s name +* operation’s variables +* client library (name + version) +* app’s name and version + + +With this knowledge the gateway is capable of resolving a document from Hive CDN: + +``` +example~1.0.0~9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87 +``` diff --git a/docs/design/persisted-documents/main.md b/docs/design/persisted-documents/main.md new file mode 100644 index 000000000..8dfdcc245 --- /dev/null +++ b/docs/design/persisted-documents/main.md @@ -0,0 +1,352 @@ +# Persisted Documents in Hive Router + +I’m planning to implement this feature in Hive Router. + +## HTTP Request (Input) + +There is only one piece of data (Hive CDN is an exception here) that Hive Router needs from the graphql client, it’s the identity of the document. I will call it “document id”, but in reality it could be anything: a hash, custom string, combination of both. +This document id can be included in the HTTP Request in many ways: + +* URL + * `/graphql/` + * `/graphql?id=` +* Header + * `graphql-document-id: ` +* Body + * `{ "document_id": }` + * `{ "extensions": { "whatever": { "doc_id": } } }` + * you get the idea... + +The point I’m trying to make here, and you will see it when I’ll cover different GraphQL clients, is that there is no standard, there are many, and there could be new standards soon. +That’s why Hive Router needs to be flexible enough to support all kinds of kinky shit. + +We do care about performance, so relying on VRL expression for the extraction of the document id, on every request, is not really an option, at least not the one we should do and call it a day :) + +### Apollo Client + +[Persisted Documents in Apollo Client](./apollo-client.md) + +This is what Apollo Client sends by default (when you configure it the way it is intended by Apollo team - according to docs) + +```json +{ + "operationName":"ApolloCountries", + "variables":{}, + "extensions":{ + "clientLibrary":{ + "name":"@apollo/client", + "version":"4.1.6" + }, + "persistedQuery":{ + "version":1, + "sha256Hash":"9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87" + } + } +} +``` + +When you configure the clientAwarness feature (as I described in the linked canvas, you also get these headers + +``` +apollographql-client-name: example +apollographql-client-version: 1.0.0 +``` + +### Relay Client + +[Persisted Documents in Relay Client](./relay-client.md) + +Relay case is interesting as it’s not enforcing any patterns. You can do whatever as you control the network layer. +What it showcases though, in the documentation and what is de facto a standard: + +```graphql +{ + "doc_id":"0ebf7938810e26eb3938a5362307cf95", + "operationName":"AppCountriesQuery", + "variables":{} +} +``` + +### GraphQL HTTP Specification + +Persisted Documents: GraphQL HTTP Specification does not really specify anything... +The draft or whatever the status of it is... accepts anything that may or may not contain : character. +When it does not you treat everything is the document id, but when it includes the semicolon, you get the xyz:. + +``` +: +sha256:7dba4bd717b41f10434822356a93c32b1fb4907b983e854300ad839f84cdcd6e + + +7dba4bd717b41f10434822356a93c32b1fb4907b983e854300ad839f84cdcd6e + +x-: +x-hive:7dba4bd717b41f10434822356a93c32b1fb4907b983e854300ad839f84cdcd6e +``` + +## Storage + +It’s not only where we store but also what we store. + +### Storage Format + +Both GraphQL Codegen’s Client Preset and Relay’s Compiler produce a similar manifest file: + +```json +{ "": "" } +``` + +Apollo Client on the other hand, produces something more complex and different: + +```json +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "", + "name": "", + "type": "query", + "body": "query { ... }" + } + ] +} +``` + +An alternative approach would be to store a single document per file, where the name of the file contains the document id. This is what Hive CDN does. + +### Storage Space + +Imo these are 4 popular ways of storing manifests or document-per-file files. + +#### HTTP endpoint + +I can imagine people writing their own registries (link to my talk) or doing some weird proxies. In order to support them we should give them a nice API. +Instead of creating some weird specification of how to fetch documents based on IDs, that is generic and easy to implement, and have ways to invalidate the cache etc, we should just point them to Plugin System and either expose a clean and easy to use API or rely on what the plugin system offers today. +A must here, is to create an example, at least in docs... + +#### S3 compatible + +Most of the time people will host the documents or manifests on S3/GCS/R1, basically S3 compatible storages. +I think we should natively support it in Hive Router and not point them to the plugin system. +There are many problems here: + +* how to provide auth credentials given there are many different vendors +* different bucket names +* what’s stored may be different (document or manifest with documents?) + +#### File + +Mostly for development, as I can’t imagine people persisting a file next to the Router binary at scale... + +* watch mode / polling is a must +* I say we support one manifest file instead of a persisted/*.json globs or whole directory pointers ./persisted - one we have a need, we can add it. Let’s not overcomplicate it from day one as 99.9999% of cases it’s a single manifest file. +* support different manifest formats (should the config explicitly say what format the file is? Not sure as it has a DX cost and could be auto detected) + +#### Hive CDN + +What we should recommend and polish really well. + +``` +GET https://cdn.graphql-hive.com/artifacts/v1/:targetId/apps/:appName/:appVersion/:documentId +``` + +This is a bit tricky, because we not only rely on document id, but also app’s name and version. +The app’s name and version could be provided in many ways, but we should limit that to 2 options: + +* client identification (request header for name, request header for version) +* hardcoded in document id (name~version~id) + +Providing app’s name and version in headers gives a much better UX: + +* Apollo Client has clientAwareness feature in which users provide name and version (apollographql-client-name/version headers) +* document id generated by “document extraction + persisting” tools is always a hash, so it’s natural to pass it as is to the http payload (Relay has params.id that could be used as {"doc_id": params.id } +* Aligns better with Usage Reporting, tracing, metrics and logging + +### Cache invalidation + +What should happen when a document was resolved from the source? +When documents should be invalidated? When schema changes (naive but safe...). + +Invalidation strategies per storage space: + +* File - invalidate when file changes + * to increase the cache-hits ratio: + * we could produce a checksum of the document text (minified) + * store that checksum + * compare the checksum and invalidate if gone or different +* HTTP - up to the plugin implementor to decide +* S3 - reuse cache headers sent back by the S3 storage or give an option to specify the TTL +* Hive - same rules as for S3 really + + +We need to not only cache the happy path (OK 200 with the file), but failures too. +The 404s should be cached for some time as well. +All configured with sensible defaults. + +When it’s time to check whether the document is still active or not, we should serve the old one, but fetch the new one in the background, to swap later on. Basically the stale-while-revalidate pattern. + +## Request Acceptance + +I guess we could have a few level of strictness + +* allow to execute only persisted documents +* allow to execute both persisted documents and regular requests + +Additional logs for the migration period (from regular to persisted): + +* ability to info log requests that are not persisted (full document body was sent) +* useful to detect rejected operations due to lack of document id + +Apollo offers safelisting based on document’s string, not only based on the id, with ability to opt-in to require the id and reject non-id requests. + + +## Pipeline + +When an http request with the document id hits the server: + +1. document id is extracted from the request (Extraction) +2. document is resolved (Resolution) +3. document is injected into the graphql request +4. the graphql request continues to flow through the rest of the pipeline + 1. parse + 2. validate + 3. normalize + 4. plan + 5. execute + +This adds latency to the first request (and identical-id requests that will be accepted during the time). + +### Extraction + +Gets info from HTTP request. +I think we should support these built-in extractors: + +* URL path segment +* URL query param +* header +* JSON body field (path to get the id) +* Relay doc_id +* Apollo’s extensions.persistedQuery.id + +and optional custom extractor via plugin or VRL only as fallback. +These extractors could be defined in Hive Router as a list to configure precedence. + +Dumb example code to what I mean: + +```rust +struct DocumentRef<'a> { + raw: &'a str, + kind: DocumentRefKind, +} + +enum DocumentRefKind<'a> { + Opaque, + Hash { algorithm: HashAlgorithm }, + Custom { prefix: &'a str }, +} + +struct ResolvedDocument<'a> { + source: ResolvedDocumentSource, + id: &'a str, + text: Arc, + operation_name_hint: Option<&'a str>, + metadata: DocumentMetadata, +} + +struct ClientIdentity<'a> { + name: Option<&'a str>, + version: Option<&'a str>, +} + +trait DocumentRefExtractor { + fn extract<'a>(&self, request: &'a HttpRequestParts, body: Option<&'a [u8]>) -> ExtractionResult<'a>; +} + +struct ExtractionResult<'a> { + document_ref: Option>, + client_identity: ClientIdentity<'a>, + metadata: ExtractionMetadata, +} +``` + +At the extraction level, we should enforce Request Acceptance. + +### Resolution + +Uses extracted info to load document text. +It should include a caching layer that resolves: + +* found (doc text + metadata) +* not found +* error (reason) + +The 404 cases should be treated differently than other errors. 5XX for example, should be retried, have shorter TTL. +Bult-in resolution impls: + +* File manifest (format autodetected) +* S3 manifest (format autodetected) +* S3 object +* generic http (maybe?) +* Hive CDN + +Dumb example code to what I mean: + +```rust +trait PersistedDocumentResolver { + async fn resolve<'a>( + &self, + reference: &DocumentRef<'a>, + client_identity: &ClientIdentity<'a>, + ctx: &ResolveContext, + ) -> Result, ResolveError>; +} +``` + +### Prewarming the caches + +This is relatively cheap, because we multiplex the parsing, validation, normalization and planning to identical documents, happening at the same time. We do the work once. +We also don’t know all the persisted documents in advance (maybe we should?). We only know about those executed in the past. +When a new schema is loaded, the caches are busted. +This gives us an opportunity to avoid a spike in latencies of future requests! +We could prewarm the caches for recently used persisted operations, so next time the operation happens, it’s reusing the caches already. +It should be either opt-in or opt-out - to be decided. + +If we knew all documents in advance, we could have prewarmed them all (or some, based on some factors like popularity), both on startup and on schema reload. + +## Potential performance bottlenecks + +* outbound HTTP request to Hive CDN for every fresh app name + app version + hash combination request +* big impact on latency on schema reloads (caches are nuked) +* big impact on latency on startup (fresh requests are uncached) +* http request to Hive CDN may take forever or be retired forever, when network issues occur (let’s have a sensible and configurable timeout) +* lots of http request resolved concurrently - we would have to put a limit on document resolver + +## Observability + +### Tracing + +We should at least add the document id as the attribute. The client’s name and version is already attached to spans. + +### Logs + +We should at least add the document id as the attribute. We should also include client’s name and version. +Depending on Request Acceptance we should also inform user about rejections. + +### Metrics + +We should observe the two stages: + +* document id extraction +* document resolution + +Observe the duration, observe the hit/miss/error rates, when it makes sense. +Depending on Request Acceptance we should also inform user about rejections. + + +## Random stuff + +* I think we should have a bypass behavior - like a header or something +* Research: the error codes and http status codes - basically how graphql clients handle the failures +* Ensure: when deserializing the request body, we don’t fail on extra/unknown fields +* Think: when allowing /graphql/1234, should it be treated as a persisted operation only or fallback to /graphql on invalid ids or something diff --git a/docs/design/persisted-documents/relay-client.md b/docs/design/persisted-documents/relay-client.md new file mode 100644 index 000000000..045db3307 --- /dev/null +++ b/docs/design/persisted-documents/relay-client.md @@ -0,0 +1,101 @@ +# Persisted Documents in Relay Client + +Repository with an example: https://github.com/kamilkisiela/graphql-persisted-operations-example/tree/main/apps/relay + +Here’s what Relay recommends: https://relay.dev/docs/guides/persisted-queries/ + +## Manifest + +The manifest is generated with relay-compiler that is capable of watching code files and generating a new manifest on every file change. + +An example `package.json`: + +```json +{ + "scripts": { + "persisted": "relay-compiler", + "persisted:watch": "relay-compiler --watch" + }, + "relay": { + "src": "./src", + "schema": "./schema.graphql", + "language": "javascript", + "artifactDirectory": "./src/__generated__", + "persistConfig": { + "file": "./persisted-queries.json", + "algorithm": "MD5" + } + } +} +``` + +When `$ relay-compiler` runs it generates code in `src/__generated__`. That’s not unusual, that’s the regular workflow when using Relay. +The only difference is that the generated queries contain a unique id. +The compiler writes also a persisted-queries.json file with the manifest (mapping between ids and document texts). + +## Client setup + +It’s really up the the user, but the documentation says: + +```js +import { Environment, Network, RecordSource, Store } from "relay-runtime" + +async function fetchGraphQL(params, variables) { + const response = await fetch("http://localhost:4000/graphql", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + doc_id: params.id, + operationName: params.name, + variables, + }), + }) + + return response.json() +} + +export const relayEnvironment = new Environment({ + network: Network.create(fetchGraphQL), + store: new Store(new RecordSource()), +}) +```` + +The relay’s compiler make sure to add id to every query in code, that’s why it’s available in params. No need to refer to the json file + +### Http Request + +What Relay Client sends to the server. + +Body: +```json +{ + "doc_id":"0ebf7938810e26eb3938a5362307cf95", + "operationName":"AppCountriesQuery", + "variables":{} +} +``` + +Headers: +``` +graphql-client-name: example +graphql-client-version: v1.0.0 +``` + +## Gateway + +What the gateway knows: + +* it’s Relay format (doc_id) +* document’s id +* operation’s name +* operation’s variables +* app’s name and version + + +With this knowledge the gateway is capable of resolving a document from Hive CDN: + +``` +example~1.0.0~0ebf7938810e26eb3938a5362307cf95 +``` diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index f42fcfd76..2daf0e7bc 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -39,6 +39,8 @@ mod max_tokens; #[cfg(test)] mod override_subgraph_urls; #[cfg(test)] +mod persisted_documents; +#[cfg(test)] mod probes; #[cfg(test)] mod router_timeout; diff --git a/e2e/src/persisted_documents/defaults.rs b/e2e/src/persisted_documents/defaults.rs new file mode 100644 index 000000000..b6b1b5fd4 --- /dev/null +++ b/e2e/src/persisted_documents/defaults.rs @@ -0,0 +1,54 @@ +use sonic_rs::json; + +use super::shared::{assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure Hive Router accepts by default selectors: +// - json: documentId +// - json: extensions.persistedQuery.sha256 +async fn default_selectors() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": DOC_ID }), None) + .await; + + assert_resolves_successfully(response).await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "extensions": { + "persistedQuery": { + "sha256Hash": DOC_ID + } + } + }), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/extractor_apollo.rs b/e2e/src/persisted_documents/extractor_apollo.rs new file mode 100644 index 000000000..288f79c61 --- /dev/null +++ b/e2e/src/persisted_documents/extractor_apollo.rs @@ -0,0 +1,134 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure apollo's PQ format works +async fn extracts_sha256_hash_from_extensions() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.persistedQuery.sha256Hash + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "persistedQuery": { + "sha256Hash": DOC_ID + } + } + })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Make sure documentId does not collide with apollo's hash +async fn returns_none_when_hash_missing() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.persistedQuery.sha256Hash + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "documentId": "1ab2", + "extensions": { + "persistedQuery": {} + } + })) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Make sure non-string values are accepted by the apollo extractor +async fn accepts_non_string_value() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "persistedQuery": { + "sha256Hash": 123 + } + } + })) + .await + .expect("failed to send graphql request"); + + // If Hive Router does not support u64, + // the error code would be PERSISTED_DOCUMENT_ID_REQUIRED + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; +} diff --git a/e2e/src/persisted_documents/extractor_document_id.rs b/e2e/src/persisted_documents/extractor_document_id.rs new file mode 100644 index 000000000..ce60ba90d --- /dev/null +++ b/e2e/src/persisted_documents/extractor_document_id.rs @@ -0,0 +1,81 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Empty documentId is treated as missing +async fn empty_id_is_ignored() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ "documentId": "" })) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Make sure non-string values are accepted by the document id extractor +async fn accepts_non_string_value() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "documentId": 123 + })) + .await + .expect("failed to send graphql request"); + + // If Hive Router does not support u64, + // the error code would be PERSISTED_DOCUMENT_ID_REQUIRED + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; +} diff --git a/e2e/src/persisted_documents/extractor_json_path.rs b/e2e/src/persisted_documents/extractor_json_path.rs new file mode 100644 index 000000000..19305ae1c --- /dev/null +++ b/e2e/src/persisted_documents/extractor_json_path.rs @@ -0,0 +1,137 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure json_path extractor extracts from extensions nested path +async fn extracts_from_extensions_nested_path() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.custom.document.id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "custom": { + "document": { + "id": DOC_ID + } + } + } + })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Make sure json_path extractor extracts from non-standard root field +async fn extracts_from_nonstandard_root_field() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: custom.document.id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "custom": { + "document": { + "id": DOC_ID + } + } + })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Make sure json_path extractor does not error when path is missing, +// but returns none instead +async fn returns_none_when_path_missing() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.custom.document.id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "custom": {} + } + })) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} diff --git a/e2e/src/persisted_documents/extractor_precedence.rs b/e2e/src/persisted_documents/extractor_precedence.rs new file mode 100644 index 000000000..f67b3f0be --- /dev/null +++ b/e2e/src/persisted_documents/extractor_precedence.rs @@ -0,0 +1,44 @@ +use sonic_rs::json; + +use super::shared::{assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Extractors are applied in order, and the first match wins. +async fn uses_first_match() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: documentId + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql?documentId=notfound") + .send_json(&json!({ "documentId": DOC_ID })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/extractor_url_path_param.rs b/e2e/src/persisted_documents/extractor_url_path_param.rs new file mode 100644 index 000000000..6b4ef3fcb --- /dev/null +++ b/e2e/src/persisted_documents/extractor_url_path_param.rs @@ -0,0 +1,224 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, PATH_DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn extracts_id_from_path() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request(&format!("/graphql/docs/{PATH_DOC_ID}"), json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn mismatch() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql/other/abc-123", json!({}), None) + .await; + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +async fn matches_wildcard_template() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /v1/*/:id/details + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + &format!("/graphql/v1/anything/{PATH_DOC_ID}/details"), + json!({}), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn works_with_custom_graphql_endpoint() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + http: + graphql_endpoint: /custom + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request(&format!("/custom/docs/{PATH_DOC_ID}"), json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn uses_first_match_with_other_selectors() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + &format!("/graphql/docs/{PATH_DOC_ID}?documentId=sha256%3Anotfound"), + json!({}), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies queryless GET path requests can resolve persisted document id from url_path_param extractor. +async fn resolves_id_from_queryless_get_path() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get(&format!("/graphql/docs/{PATH_DOC_ID}")) + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/extractor_url_query_param.rs b/e2e/src/persisted_documents/extractor_url_query_param.rs new file mode 100644 index 000000000..b9e4f7352 --- /dev/null +++ b/e2e/src/persisted_documents/extractor_url_query_param.rs @@ -0,0 +1,210 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure url_query_param extractor does not error on missing query string, +// but returns none instead +async fn missing_query_string_returns_none() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({})) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Make sure url_query_param extractor decodes percent-encoded values correctly +async fn decodes_percent_encoded_value() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql?documentId=sha256%3Aabc123", json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// url_query_param extractor uses first match +async fn uses_first_value_for_duplicate_keys() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + // first: correct, second: incorrect + "/graphql?documentId=sha256%3Aabc123&documentId=sha256%3Aother", + json!({}), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// url_query_param extractor matches first param, even if it's empty +async fn first_empty_match() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + // documentId=& + "/graphql?documentId=&documentId=sha256%3Aabc123", + json!({}), + None, + ) + .await; + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; + + let response = router + .send_post_request( + // documentId& + "/graphql?documentId&documentId=sha256%3Aabc123", + json!({}), + None, + ) + .await; + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// url_query_param matches exactly the param name, not a prefix/suffix +async fn ignores_prefix_matches_and_continues() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: key + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql?keys=1&key=sha256%3Aabc123", json!({}), None) + .await; + + assert_resolves_successfully(response).await; + + let response = router + .send_post_request("/graphql?skey=1&key=sha256%3Aabc123", json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/method_get.rs b/e2e/src/persisted_documents/method_get.rs new file mode 100644 index 000000000..f391bd01a --- /dev/null +++ b/e2e/src/persisted_documents/method_get.rs @@ -0,0 +1,221 @@ +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Verifies GET requests can resolve persisted documents via the default documentId query parameter. +async fn resolves_from_document_id_query_param() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?documentId=sha256%3Aabc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies GET requests can resolve persisted documents through a configured custom query parameter. +async fn resolves_from_custom_query_param_extractor() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: pid + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?pid=sha256:abc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies GET requests with an empty query and no persisted document id do not resolve a document +async fn requires_id_when_query_is_empty() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?query=") + .send() + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Verifies queryless GET request is possible +async fn requires_id_when_queryless_get_has_no_id() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql") + .send() + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Verifies percent-encoded values in the configured custom query parameter are decoded before lookup +async fn decodes_percent_encoded_custom_param() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: pid + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?pid=sha256%3Aabc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies a configured custom query parameter extractor does not fall back to default `documentId` +async fn requires_id_when_custom_param_is_missing() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: pid + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?documentId=sha256%3Aabc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} diff --git a/e2e/src/persisted_documents/mod.rs b/e2e/src/persisted_documents/mod.rs new file mode 100644 index 000000000..2e088db7d --- /dev/null +++ b/e2e/src/persisted_documents/mod.rs @@ -0,0 +1,12 @@ +mod defaults; +mod extractor_apollo; +mod extractor_document_id; +mod extractor_json_path; +mod extractor_precedence; +mod extractor_url_path_param; +mod extractor_url_query_param; +mod method_get; +mod policy; +mod shared; +mod storage_file; +mod storage_hive; diff --git a/e2e/src/persisted_documents/policy.rs b/e2e/src/persisted_documents/policy.rs new file mode 100644 index 000000000..f5a163c4f --- /dev/null +++ b/e2e/src/persisted_documents/policy.rs @@ -0,0 +1,75 @@ +use sonic_rs::json; + +use super::shared::{assert_resolves_successfully, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn query_wins_when_require_id_is_false() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: false + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "query": "{ topProducts { name } }", + "documentId": "sha256:notfound" + }), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn disabled_mode_ignores_extracted_id() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: false + require_id: true + "#, + ) + .build() + .start() + .await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "query": "{ topProducts { name } }", + "documentId": "sha256:notfound" + }), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/shared.rs b/e2e/src/persisted_documents/shared.rs new file mode 100644 index 000000000..8520b3e5a --- /dev/null +++ b/e2e/src/persisted_documents/shared.rs @@ -0,0 +1,43 @@ +use sonic_rs::{json, JsonValueTrait}; +use tempfile::NamedTempFile; + +use crate::testkit::ClientResponseExt; + +pub(super) const DOC_ID: &str = "sha256:abc123"; +pub(super) const PATH_DOC_ID: &str = "abc-123"; +pub(super) const DOC_QUERY: &str = "{ topProducts { name } }"; + +pub(super) fn write_manifest() -> NamedTempFile { + let file = NamedTempFile::new().expect("failed to create temp persisted document manifest"); + std::fs::write( + file.path(), + sonic_rs::to_string(&json!({ + DOC_ID: DOC_QUERY, + PATH_DOC_ID: DOC_QUERY, + })) + .expect("failed to serialize persisted document manifest"), + ) + .expect("failed to write persisted document manifest"); + file +} + +pub(super) async fn assert_resolves_successfully(response: ntex::client::ClientResponse) { + assert!(response.status().is_success(), "expected 2xx response"); + let body = response.json_body().await; + assert!( + body["errors"].is_null(), + "unexpected graphql errors: {body}" + ); + assert!( + body["data"]["topProducts"].is_array(), + "expected resolved persisted query data: {body}" + ); +} + +pub(super) async fn assert_error_code(response: ntex::client::ClientResponse, code: &str) { + let body = response.json_body().await; + let got = body["errors"][0]["extensions"]["code"] + .as_str() + .expect("expected graphql error code string"); + assert_eq!(got, code, "unexpected response body: {body}"); +} diff --git a/e2e/src/persisted_documents/storage_file.rs b/e2e/src/persisted_documents/storage_file.rs new file mode 100644 index 000000000..19e0937df --- /dev/null +++ b/e2e/src/persisted_documents/storage_file.rs @@ -0,0 +1,63 @@ +use std::time::Duration; + +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure the file watch works as expected and updates the manifest. +async fn file_watch_works() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": DOC_ID }), None) + .await; + + // We expect the first request to resolve successfully, + // because the DOC_ID is present in the manifest. + assert_resolves_successfully(response).await; + + // Now we replace the manifest with new content, + // that lacks the DOC_ID. + std::fs::write(manifest.path(), r#"{"foo":"{__typename}"}"#) + .expect("failed to update manifest"); + + // Debounce of 150ms is configured for file watch events, + // so let's wait a double the time before making the request. + tokio::time::sleep(Duration::from_millis(300)).await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "documentId": DOC_ID + }), + None, + ) + .await; + + // We expect the request to fail, + // because the DOC_ID is no longer present in the manifest. + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; +} diff --git a/e2e/src/persisted_documents/storage_hive.rs b/e2e/src/persisted_documents/storage_hive.rs new file mode 100644 index 000000000..e8fb29225 --- /dev/null +++ b/e2e/src/persisted_documents/storage_hive.rs @@ -0,0 +1,395 @@ +use std::time::Duration; + +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully}; +use crate::testkit::{some_header_map, TestRouter, TestSubgraphs}; + +mod negative_cache { + use super::*; + + const MISSING_DOC_ID: &str = "app~1.0.0~missing-doc"; + const MISSING_DOC_CDN_PATH: &str = "/apps/app/1.0.0/missing-doc"; + + #[ntex::test] + // Verifies that negative cache is enabled by default for Hive storage. + // Expects that the second request for same missing id within default TTL avoids a second CDN fetch. + async fn default_skips_second_miss_within_ttl() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } + + #[ntex::test] + // Verifies that explicitly disabling negative cache forces misses to hit CDN each time. + // Expects repeated requests for same missing id trigger a CDN fetch each time. + async fn disabled_retries_each_miss() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(2) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + negative_cache: false + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } + + #[ntex::test] + // Verifies that negative cache entries expire after configured TTL. + // Expects that the same missing id triggers CDN fetch again after TTL has elapsed. + async fn expires_and_refetches_after_ttl() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(2) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + negative_cache: + ttl: 100ms + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + tokio::time::sleep(Duration::from_millis(250)).await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } + + #[ntex::test] + // Verifies that enabling negative cache with boolean true uses default cache configuration. + // Expects the second request for same missing id within default TTL to avoid a second CDN fetch. + async fn enabled_uses_default_ttl() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + negative_cache: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } +} + +#[ntex::test] +// Verifies successful Hive lookups are cached in memory by default. +// Expects that two router requests for the same document id produce only one CDN fetch. +async fn reuses_cached_document_on_second_request() { + let doc_id: &str = "app~1.0.0~found-doc"; + let cdn_path: &str = "/apps/app/1.0.0/found-doc"; + + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let hit = server + .mock("GET", cdn_path) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(200) + .with_header("content-type", "text/plain") + .with_body("{ topProducts { name } }") + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": doc_id }), None) + .await; + assert_resolves_successfully(response).await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": doc_id }), None) + .await; + assert_resolves_successfully(response).await; + + hit.assert(); +} + +#[ntex::test] +// Verifies app-qualified and header-qualified forms of the same document id share one cache key. +// Expects the second router request to reuse the first fetch and avoid a second CDN hit. +async fn caches_documents_by_id() { + let app_doc_id: &str = "app~1.0.0~found-doc"; + let plain_doc_id: &str = "found-doc"; + let cdn_path: &str = "/apps/app/1.0.0/found-doc"; + + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let hit = server + .mock("GET", cdn_path) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(200) + .with_header("content-type", "text/plain") + .with_body("{ topProducts { name } }") + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": app_doc_id }), None) + .await; + assert_resolves_successfully(response).await; + + let response = router + .send_post_request( + "/graphql", + json!({ "documentId": plain_doc_id }), + some_header_map!( + ::http::header::HeaderName::from_static("graphql-client-name") => "app", + ::http::header::HeaderName::from_static("graphql-client-version") => "1.0.0", + ), + ) + .await; + assert_resolves_successfully(response).await; + + hit.assert(); +} + +#[ntex::test] +// Verifies concurrent requests for the same logical document id reuse a single CDN fetch. +// Expects one request to Hive CDN when one app-qualified and one header-qualified request hit router concurrently. +async fn concurrent_requests_share_one_cdn_fetch() { + let app_doc_id: &str = "app~1.0.0~found-doc"; + let plain_doc_id: &str = "found-doc"; + let cdn_path: &str = "/apps/app/1.0.0/found-doc"; + + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let hit = server + .mock("GET", cdn_path) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(200) + .with_header("content-type", "text/plain") + .with_chunked_body(|writer| { + std::thread::sleep(Duration::from_millis(150)); + writer.write_all(b"{ topProducts { name } }") + }) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let app_id_request = + router.send_post_request("/graphql", json!({ "documentId": app_doc_id }), None); + let plain_id_request = router.send_post_request( + "/graphql", + json!({ "documentId": plain_doc_id }), + some_header_map!( + ::http::header::HeaderName::from_static("graphql-client-name") => "app", + ::http::header::HeaderName::from_static("graphql-client-version") => "1.0.0", + ), + ); + + let (app_id_response, plain_id_response) = tokio::join!(app_id_request, plain_id_request); + + assert_resolves_successfully(app_id_response).await; + assert_resolves_successfully(plain_id_response).await; + + hit.assert(); +} diff --git a/e2e/src/telemetry/metrics.rs b/e2e/src/telemetry/metrics.rs index ea03997ca..a1ee47250 100644 --- a/e2e/src/telemetry/metrics.rs +++ b/e2e/src/telemetry/metrics.rs @@ -12,6 +12,7 @@ use hive_router::{ plugins::hooks::on_plugin_init::OnPluginInitResult, plugins::plugin_trait::RouterPlugin, }; use hive_router_internal::telemetry::metrics::catalog::{labels, labels_for, names, values}; +use tempfile::NamedTempFile; async fn wait_for_metrics_export() { tokio::time::sleep(Duration::from_millis(500)).await; @@ -136,7 +137,93 @@ async fn test_otlp_http_metrics_export_with_graphql_request() { assert_histogram_count(&metrics, names::PLAN_CACHE_DURATION, &no_attrs, 2); } -/// Verify cache size metrics are exported as gauges +#[ntex::test] +async fn test_otlp_persisted_documents_failure_and_missing_id_counters() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + + let manifest = NamedTempFile::new().expect("failed to create temp persisted manifest"); + std::fs::write( + manifest.path(), + sonic_rs::to_string(&sonic_rs::json!({ + "sha256:known": "{ topProducts { name } }" + })) + .expect("failed to serialize manifest"), + ) + .expect("failed to write manifest"); + + let otlp_collector = OtlpCollector::start() + .await + .expect("Failed to start OTLP collector"); + let otlp_endpoint = otlp_collector.http_metrics_endpoint(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {} + + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + + telemetry: + metrics: + exporters: + - kind: otlp + endpoint: {} + protocol: http + interval: 30ms + max_export_timeout: 50ms + "#, + supergraph_path.to_str().unwrap(), + manifest.path().display(), + otlp_endpoint + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + // Missing id request. + let _ = router + .send_post_request("/graphql", sonic_rs::json!({}), None) + .await; + + // Resolve failure request. + let _ = router + .send_post_request( + "/graphql", + sonic_rs::json!({ "documentId": "sha256:not-found" }), + None, + ) + .await; + + wait_for_metrics_export().await; + + let metrics = otlp_collector.metrics_view().await; + let no_attrs: [(&str, &str); 0] = []; + + assert_counter_eq( + &metrics, + names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, + &no_attrs, + 1.0, + ); + assert_counter_eq( + &metrics, + names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, + &no_attrs, + 1.0, + ); +} + #[ntex::test] async fn test_otlp_cache_size_metrics_exported_as_gauges() { let supergraph_path = @@ -435,6 +522,8 @@ async fn test_otlp_all_metrics_path_attribute_names() { (names::PLAN_CACHE_REQUESTS_TOTAL, &[][..]), (names::PLAN_CACHE_DURATION, &[][..]), (names::PLAN_CACHE_SIZE, &[][..]), + (names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, &[][..]), + (names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, &[][..]), ] { assert_metric_has_attrs(&metrics, name, ignore); } @@ -533,6 +622,8 @@ async fn test_otlp_all_metrics_happy_path_attribute_names() { (names::PLAN_CACHE_REQUESTS_TOTAL, &[][..]), (names::PLAN_CACHE_DURATION, &[][..]), (names::PLAN_CACHE_SIZE, &[][..]), + (names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, &[][..]), + (names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, &[][..]), ] { assert_metric_has_attrs(&metrics, name, ignore); } diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index af2b06ac4..475ca1721 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -942,10 +942,27 @@ impl TestRouter { query: &str, variables: Option, headers: Option, + ) -> ClientResponse { + self.send_post_request( + self.graphql_path(), + json!({ + "query": query, + "variables": variables, + }), + headers, + ) + .await + } + + pub async fn send_post_request( + &self, + path: &str, + payload: sonic_rs::Value, + headers: Option, ) -> ClientResponse { let mut req = self .serv() - .post(self.graphql_path()) + .post(path) .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/graphql-response+json"); @@ -955,12 +972,9 @@ impl TestRouter { } } - req.send_json(&json!({ - "query": query, - "variables": variables, - })) - .await - .expect("Failed to send graphql request") + req.send_json(&payload) + .await + .expect("Failed to send graphql request") } pub async fn ws(&self) -> WsConnection { diff --git a/lib/executor/src/plugins/hooks/on_graphql_params.rs b/lib/executor/src/plugins/hooks/on_graphql_params.rs index 99482abac..c04639915 100644 --- a/lib/executor/src/plugins/hooks/on_graphql_params.rs +++ b/lib/executor/src/plugins/hooks/on_graphql_params.rs @@ -1,10 +1,14 @@ -use core::fmt; - +use std::borrow::Cow; use std::collections::HashMap; +use std::fmt; +use hive_router_internal::json::MapAccessSerdeExt; use ntex::util::Bytes; +use serde::de; +use serde::de::IgnoredAny; +use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; -use serde::{de, Deserialize, Deserializer}; use sonic_rs::Value; use crate::plugin_context::PluginContext; @@ -63,37 +67,21 @@ impl<'de> Deserialize<'de> for GraphQLParams { let mut operation_name = None; let mut variables: Option> = None; let mut extensions: Option> = None; - let mut extra_params = HashMap::new(); - - while let Some(key) = map.next_key::()? { - match key.as_str() { - "query" => { - if query.is_some() { - return Err(de::Error::duplicate_field("query")); - } - query = map.next_value::>()?; - } + + while let Some(key) = map.next_key::>()? { + match key.as_ref() { + "query" => map.deserialize_once_into_option(&mut query, "query")?, "operationName" => { - if operation_name.is_some() { - return Err(de::Error::duplicate_field("operationName")); - } - operation_name = map.next_value::>()?; + map.deserialize_once_into_option(&mut operation_name, "operationName")? } "variables" => { - if variables.is_some() { - return Err(de::Error::duplicate_field("variables")); - } - variables = map.next_value::>>()?; + map.deserialize_once_into_option(&mut variables, "variables")? } "extensions" => { - if extensions.is_some() { - return Err(de::Error::duplicate_field("extensions")); - } - extensions = map.next_value::>>()?; + map.deserialize_once_into_option(&mut extensions, "extensions")? } - other => { - let value: Value = map.next_value()?; - extra_params.insert(other.to_string(), value); + _ => { + let _ = map.next_value::()?; } } } diff --git a/lib/hive-console-sdk/src/persisted_documents.rs b/lib/hive-console-sdk/src/persisted_documents.rs index 4a5aab95c..f7f38c281 100644 --- a/lib/hive-console-sdk/src/persisted_documents.rs +++ b/lib/hive-console-sdk/src/persisted_documents.rs @@ -2,6 +2,7 @@ use std::time::Duration; use crate::agent::usage_agent::non_empty_string; use crate::circuit_breaker::CircuitBreakerBuilder; +use crate::circuit_breaker::CircuitBreakerError; use moka::future::Cache; use recloser::AsyncRecloser; use reqwest::header::HeaderMap; @@ -16,23 +17,24 @@ use tracing::{debug, info, warn}; pub struct PersistedDocumentsManager { client: ClientWithMiddleware, cache: Cache, + negative_cache: Option>, endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, Clone)] pub enum PersistedDocumentsError { #[error("Failed to read body: {0}")] FailedToReadBody(String), #[error("Failed to parse body: {0}")] - FailedToParseBody(serde_json::Error), + FailedToParseBody(String), #[error("Persisted document not found.")] DocumentNotFound, #[error("Failed to locate the persisted document key in request.")] KeyNotFound, #[error("Failed to validate persisted document")] - FailedToFetchFromCDN(reqwest_middleware::Error), + FailedToFetchFromCDN(String), #[error("Failed to read CDN response body")] - FailedToReadCDNResponse(reqwest::Error), + FailedToReadCDNResponse(String), #[error("No persisted document provided, or document id cannot be resolved.")] PersistedDocumentRequired, #[error("Missing required configuration option: {0}")] @@ -40,15 +42,33 @@ pub enum PersistedDocumentsError { #[error("Invalid CDN key {0}")] InvalidCDNKey(String), #[error("Failed to create HTTP client: {0}")] - HTTPClientCreationError(reqwest::Error), + HTTPClientCreationError(String), #[error("unable to create circuit breaker: {0}")] - CircuitBreakerCreationError(#[from] crate::circuit_breaker::CircuitBreakerError), + CircuitBreakerCreationError(String), #[error("rejected by the circuit breaker")] CircuitBreakerRejected, #[error("unknown error")] Unknown, } +impl From for PersistedDocumentsError { + fn from(err: reqwest_middleware::Error) -> Self { + PersistedDocumentsError::FailedToFetchFromCDN(err.to_string()) + } +} + +impl From for PersistedDocumentsError { + fn from(err: serde_json::Error) -> Self { + PersistedDocumentsError::FailedToParseBody(err.to_string()) + } +} + +impl From for PersistedDocumentsError { + fn from(err: CircuitBreakerError) -> Self { + PersistedDocumentsError::CircuitBreakerCreationError(err.to_string()) + } +} + impl PersistedDocumentsError { pub fn message(&self) -> String { self.to_string() @@ -105,7 +125,7 @@ impl PersistedDocumentsManager { .call(response_fut) .await .map_err(|e| match e { - recloser::Error::Inner(e) => PersistedDocumentsError::FailedToFetchFromCDN(e), + recloser::Error::Inner(e) => PersistedDocumentsError::from(e), recloser::Error::Rejected => PersistedDocumentsError::CircuitBreakerRejected, })?; @@ -113,21 +133,17 @@ impl PersistedDocumentsManager { let document = response .text() .await - .map_err(PersistedDocumentsError::FailedToReadCDNResponse)?; - debug!( - "Document fetched from CDN: {}, storing in local cache", - document - ); - self.cache - .insert(document_id.into(), document.clone()) - .await; + .map_err(|e| PersistedDocumentsError::FailedToReadCDNResponse(e.to_string()))?; + debug!("Document fetched from CDN: {}", document); return Ok(document); } + let status = response.status(); + warn!( "Document fetch from CDN failed: HTTP {}, Body: {:?}", - response.status(), + status, response .text() .await @@ -136,42 +152,57 @@ impl PersistedDocumentsManager { Err(PersistedDocumentsError::DocumentNotFound) } + /// Resolves the document from the cache, or from the CDN pub async fn resolve_document( &self, document_id: &str, ) -> Result { - let cached_record = self.cache.get(document_id).await; + if let Some(negative_cache) = &self.negative_cache { + if negative_cache.get(document_id).await.is_some() { + debug!( + "Document {} found in negative cache, skipping CDN fetch", + document_id + ); + return Err(PersistedDocumentsError::DocumentNotFound); + } + } - match cached_record { - Some(document) => { - debug!("Document {} found in cache: {}", document_id, document); + if let Some(cached_document) = self.cache.get(document_id).await { + return Ok(cached_document); + } - Ok(document) - } - None => { + let result = self + .cache + .try_get_with_by_ref(document_id, async { debug!( "Document {} not found in cache. Fetching from CDN", document_id ); + let mut last_error: Option = None; - for (endpoint, circuit_breaker) in &self.endpoints_with_circuit_breakers { - let result = self + for (endpoint, circuit_breaker) in self.endpoints_with_circuit_breakers.iter() { + match self .resolve_from_endpoint(endpoint, document_id, circuit_breaker) - .await; - match result { + .await + { Ok(document) => return Ok(document), - Err(e) => { - last_error = Some(e); - } + Err(error) => last_error = Some(error), } } - match last_error { - Some(e) => Err(e), - None => Err(PersistedDocumentsError::Unknown), - } + + Err(last_error.unwrap_or(PersistedDocumentsError::Unknown)) + }) + .await + .map_err(|error| error.as_ref().clone()); + + if matches!(&result, Err(PersistedDocumentsError::DocumentNotFound)) { + if let Some(negative_cache) = &self.negative_cache { + negative_cache.insert(document_id.to_string(), ()).await; } } + + result } } @@ -183,6 +214,7 @@ pub struct PersistedDocumentsManagerBuilder { request_timeout: Duration, retry_policy: ExponentialBackoff, cache_size: u64, + negative_cache_ttl: Option, user_agent: Option, circuit_breaker: CircuitBreakerBuilder, } @@ -197,6 +229,7 @@ impl Default for PersistedDocumentsManagerBuilder { request_timeout: Duration::from_secs(15), retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), cache_size: 10_000, + negative_cache_ttl: None, user_agent: None, circuit_breaker: CircuitBreakerBuilder::default(), } @@ -260,6 +293,21 @@ impl PersistedDocumentsManagerBuilder { self } + /// TTL for negative cache entries (failed lookups / not found responses). + /// + /// When set, repeated misses for the same document id are served from in-memory cache + /// until the TTL expires. + pub fn negative_cache_ttl(mut self, ttl: Duration) -> Self { + self.negative_cache_ttl = Some(ttl); + self + } + + /// Circuit breaker configuration for persisted document CDN requests. + pub fn circuit_breaker(mut self, circuit_breaker: CircuitBreakerBuilder) -> Self { + self.circuit_breaker = circuit_breaker; + self + } + /// User-Agent header to be sent with each request pub fn user_agent(mut self, user_agent: String) -> Self { self.user_agent = non_empty_string(Some(user_agent)); @@ -293,12 +341,18 @@ impl PersistedDocumentsManagerBuilder { let reqwest_agent = reqwest_agent .build() - .map_err(PersistedDocumentsError::HTTPClientCreationError)?; + .map_err(|e| PersistedDocumentsError::HTTPClientCreationError(e.to_string()))?; let client = ClientBuilder::new(reqwest_agent) .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) .build(); let cache = Cache::::new(self.cache_size); + let negative_cache = self.negative_cache_ttl.map(|ttl| { + Cache::builder() + .max_capacity(self.cache_size) + .time_to_live(ttl) + .build() + }); if self.endpoints.is_empty() { return Err(PersistedDocumentsError::MissingConfigurationOption( @@ -309,15 +363,12 @@ impl PersistedDocumentsManagerBuilder { Ok(PersistedDocumentsManager { client, cache, + negative_cache, endpoints_with_circuit_breakers: self .endpoints .into_iter() .map(move |endpoint| { - let circuit_breaker = self - .circuit_breaker - .clone() - .build_async() - .map_err(PersistedDocumentsError::CircuitBreakerCreationError)?; + let circuit_breaker = self.circuit_breaker.clone().build_async()?; Ok((endpoint, circuit_breaker)) }) .collect::, PersistedDocumentsError>>()?, diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 58a5bad25..0a885bf59 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -35,6 +35,7 @@ thiserror = { workspace = true } ahash = { workspace = true } dashmap = { workspace = true } tokio-stream = "0.1.18" +serde = { workspace = true } # telemetry opentelemetry = { workspace = true, features = ["trace"] } diff --git a/lib/internal/src/json.rs b/lib/internal/src/json.rs new file mode 100644 index 000000000..d048f826a --- /dev/null +++ b/lib/internal/src/json.rs @@ -0,0 +1,24 @@ +use serde::de; + +pub trait MapAccessSerdeExt<'de>: de::MapAccess<'de> { + #[inline] + /// Deserializes an optional field value from the current map entry into `slot`. + /// Returns a duplicate-field error when the same field appears more than once. + fn deserialize_once_into_option( + &mut self, + slot: &mut Option, + field_name: &'static str, + ) -> Result<(), Self::Error> + where + T: serde::Deserialize<'de>, + { + if slot.is_some() { + return Err(de::Error::duplicate_field(field_name)); + } + + *slot = self.next_value::>()?; + Ok(()) + } +} + +impl<'de, A> MapAccessSerdeExt<'de> for A where A: de::MapAccess<'de> {} diff --git a/lib/internal/src/lib.rs b/lib/internal/src/lib.rs index 7b6d626d0..1a6e55040 100644 --- a/lib/internal/src/lib.rs +++ b/lib/internal/src/lib.rs @@ -4,6 +4,7 @@ pub mod expressions; pub mod graphql; pub mod http; pub mod inflight; +pub mod json; pub mod telemetry; pub type BoxError = Box; diff --git a/lib/internal/src/telemetry/metrics/catalog.rs b/lib/internal/src/telemetry/metrics/catalog.rs index 1d26044e0..6b6fd5230 100644 --- a/lib/internal/src/telemetry/metrics/catalog.rs +++ b/lib/internal/src/telemetry/metrics/catalog.rs @@ -107,6 +107,10 @@ pub mod names { pub const PLAN_CACHE_REQUESTS_TOTAL: &str = "hive.router.plan_cache.requests_total"; pub const PLAN_CACHE_DURATION: &str = "hive.router.plan_cache.duration"; pub const PLAN_CACHE_SIZE: &str = "hive.router.plan_cache.size"; + pub const PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL: &str = + "hive.router.persisted_documents.storage.failures_total"; + pub const PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL: &str = + "hive.router.persisted_documents.extract.missing_id_total"; } pub(crate) const METRIC_SPECS: &[(&str, &[&str])] = &[ @@ -234,6 +238,8 @@ pub(crate) const METRIC_SPECS: &[(&str, &[&str])] = &[ (names::PLAN_CACHE_REQUESTS_TOTAL, &[labels::RESULT]), (names::PLAN_CACHE_DURATION, &[labels::RESULT]), (names::PLAN_CACHE_SIZE, &[]), + (names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, &[]), + (names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, &[]), ]; pub fn labels_for(metric_name: &str) -> Option<&'static [&'static str]> { diff --git a/lib/internal/src/telemetry/metrics/mod.rs b/lib/internal/src/telemetry/metrics/mod.rs index 499403224..15643faae 100644 --- a/lib/internal/src/telemetry/metrics/mod.rs +++ b/lib/internal/src/telemetry/metrics/mod.rs @@ -4,6 +4,7 @@ pub mod catalog; pub mod graphql_metrics; pub mod http_client_metrics; pub mod http_server_metrics; +pub mod persisted_documents_metrics; pub mod setup; pub mod supergraph_metrics; @@ -16,6 +17,7 @@ use crate::telemetry::metrics::cache_metrics::CacheMetrics; use crate::telemetry::metrics::graphql_metrics::GraphQLMetrics; use crate::telemetry::metrics::http_client_metrics::HttpClientMetrics; use crate::telemetry::metrics::http_server_metrics::HttpServerMetrics; +use crate::telemetry::metrics::persisted_documents_metrics::PersistedDocumentsMetrics; use crate::telemetry::metrics::supergraph_metrics::SupergraphMetrics; pub struct Metrics { @@ -24,6 +26,7 @@ pub struct Metrics { pub graphql: GraphQLMetrics, pub supergraph: SupergraphMetrics, pub cache: CacheMetrics, + pub persisted_documents: PersistedDocumentsMetrics, } impl Metrics { @@ -34,6 +37,7 @@ impl Metrics { graphql: GraphQLMetrics::new(meter), supergraph: SupergraphMetrics::new(meter), cache: CacheMetrics::new(meter), + persisted_documents: PersistedDocumentsMetrics::new(meter), } } } diff --git a/lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs b/lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs new file mode 100644 index 000000000..5ee813d16 --- /dev/null +++ b/lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs @@ -0,0 +1,51 @@ +use opentelemetry::metrics::{Counter, Meter}; + +use crate::telemetry::metrics::catalog::names; + +struct PersistedDocumentsInstruments { + storage_failures_total: Option>, + extract_missing_id_total: Option>, +} + +pub struct PersistedDocumentsMetrics { + instruments: PersistedDocumentsInstruments, +} + +impl PersistedDocumentsMetrics { + pub fn new(meter: Option<&Meter>) -> Self { + let storage_failures_total = meter.map(|meter| { + meter + .u64_counter(names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL) + .with_unit("{failure}") + .with_description("Total number of failed persisted document resolutions") + .build() + }); + + let extract_missing_id_total = meter.map(|meter| { + meter + .u64_counter(names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL) + .with_unit("{request}") + .with_description("Total number of requests without persisted document id") + .build() + }); + + Self { + instruments: PersistedDocumentsInstruments { + storage_failures_total, + extract_missing_id_total, + }, + } + } + + pub fn record_resolution_failure(&self) { + if let Some(counter) = &self.instruments.storage_failures_total { + counter.add(1, &[]); + } + } + + pub fn record_missing_id(&self) { + if let Some(counter) = &self.instruments.extract_missing_id_total { + counter.add(1, &[]); + } + } +} diff --git a/lib/router-config/src/lib.rs b/lib/router-config/src/lib.rs index afa00d4d2..bd74267b3 100644 --- a/lib/router-config/src/lib.rs +++ b/lib/router-config/src/lib.rs @@ -11,6 +11,7 @@ pub mod limits; pub mod log; pub mod override_labels; pub mod override_subgraph_urls; +pub mod persisted_documents; pub mod primitives; pub mod query_planner; pub mod subscriptions; @@ -127,6 +128,10 @@ pub struct HiveRouterConfig { /// Configuration of router's WebSocket server. #[serde(default)] pub websocket: websocket::WebSocketConfig, + + /// Configuration for persisted documents extraction and resolution. + #[serde(default)] + pub persisted_documents: persisted_documents::PersistedDocumentsConfig, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] diff --git a/lib/router-config/src/persisted_documents.rs b/lib/router-config/src/persisted_documents.rs new file mode 100644 index 000000000..11c271d40 --- /dev/null +++ b/lib/router-config/src/persisted_documents.rs @@ -0,0 +1,452 @@ +use schemars::JsonSchema; +use serde::{de::Error as _, Deserialize, Serialize}; +use std::collections::HashSet; +use std::time::Duration; + +use crate::primitives::file_path::FilePath; +use crate::primitives::retry_policy::RetryPolicyConfig; +use crate::primitives::single_or_multiple::SingleOrMultiple; +use crate::primitives::toggle::ToggleWith; + +#[derive(Debug, Serialize, JsonSchema, Clone, Default)] +pub struct PersistedDocumentsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub require_id: bool, + #[serde(default)] + pub log_missing_id: bool, + #[serde(default)] + pub storage: Option, + #[serde(default)] + pub selectors: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPersistedDocumentsConfig { + #[serde(default)] + enabled: bool, + #[serde(default)] + require_id: bool, + #[serde(default)] + log_missing_id: bool, + #[serde(default)] + storage: Option, + #[serde(default)] + selectors: Option>, +} + +impl<'de> Deserialize<'de> for PersistedDocumentsConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawPersistedDocumentsConfig::deserialize(deserializer)?; + + if raw.enabled && matches!(raw.selectors.as_ref(), Some(selectors) if selectors.is_empty()) + { + return Err(D::Error::custom( + "persisted_documents.selectors must not be an explicit empty list when persisted_documents.enabled=true", + )); + } + + if raw.enabled && raw.storage.is_none() { + return Err(D::Error::custom( + "persisted_documents.storage is required when persisted_documents.enabled=true", + )); + } + + if let Some(selectors) = raw.selectors.as_ref() { + let mut seen = HashSet::new(); + for selector in selectors { + if !seen.insert(selector.clone()) { + return Err(D::Error::custom(format!( + "persisted_documents.selectors contains a duplicate entry: {selector:?}" + ))); + } + } + } + + Ok(Self { + enabled: raw.enabled, + require_id: raw.require_id, + log_missing_id: raw.log_missing_id, + storage: raw.storage, + selectors: raw.selectors, + }) + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")] +pub enum PersistedDocumentsStorageConfig { + File { + #[serde(flatten)] + config: PersistedDocumentsFileStorageConfig, + }, + Hive { + #[serde(flatten)] + config: PersistedDocumentsHiveStorageConfig, + }, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsFileStorageConfig { + pub path: FilePath, + #[serde(default = "default_watch")] + pub watch: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsHiveStorageConfig { + /// The CDN endpoint from Hive Console target. + /// Can also be set using the `HIVE_CDN_ENDPOINT` environment variable. + pub endpoint: Option>, + /// The CDN Access Token with from the Hive Console target. + /// Can also be set using the `HIVE_CDN_KEY` environment variable. + pub key: Option, + #[serde(default = "default_hive_accept_invalid_certs")] + pub accept_invalid_certs: bool, + #[serde( + default = "default_hive_connect_timeout", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub connect_timeout: Duration, + #[serde( + default = "default_hive_request_timeout", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub request_timeout: Duration, + #[serde(default = "default_hive_retry_policy")] + pub retry_policy: RetryPolicyConfig, + #[serde(default = "default_hive_cache_size")] + pub cache_size: u64, + #[serde(default)] + pub circuit_breaker: PersistedDocumentsHiveCircuitBreakerConfig, + #[serde(default = "default_hive_negative_cache")] + pub negative_cache: ToggleWith, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsHiveNegativeCacheConfig { + #[serde( + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub ttl: Duration, +} + +impl Default for PersistedDocumentsHiveNegativeCacheConfig { + fn default() -> Self { + Self { + ttl: Duration::from_secs(5), + } + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsHiveCircuitBreakerConfig { + #[serde(default = "default_circuit_breaker_error_threshold")] + pub error_threshold: f32, + #[serde(default = "default_circuit_breaker_volume_threshold")] + pub volume_threshold: usize, + #[serde( + default = "default_circuit_breaker_reset_timeout", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub reset_timeout: Duration, +} + +impl Default for PersistedDocumentsHiveCircuitBreakerConfig { + fn default() -> Self { + Self { + error_threshold: default_circuit_breaker_error_threshold(), + volume_threshold: default_circuit_breaker_volume_threshold(), + reset_timeout: default_circuit_breaker_reset_timeout(), + } + } +} + +fn default_hive_accept_invalid_certs() -> bool { + false +} + +fn default_hive_connect_timeout() -> Duration { + Duration::from_secs(5) +} + +fn default_hive_request_timeout() -> Duration { + Duration::from_secs(15) +} + +fn default_hive_retry_policy() -> RetryPolicyConfig { + RetryPolicyConfig { max_retries: 3 } +} + +fn default_hive_cache_size() -> u64 { + 10_000 +} + +fn default_hive_negative_cache() -> ToggleWith { + ToggleWith::Enabled(PersistedDocumentsHiveNegativeCacheConfig::default()) +} + +fn default_circuit_breaker_error_threshold() -> f32 { + 0.5 +} + +fn default_circuit_breaker_volume_threshold() -> usize { + 5 +} + +fn default_circuit_breaker_reset_timeout() -> Duration { + Duration::from_secs(10) +} + +const fn default_watch() -> bool { + true +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PersistedDocumentExtractorConfig { + JsonPath { + path: PersistedDocumentJsonPath, + }, + UrlPathParam { + template: PersistedDocumentUrlTemplate, + }, + UrlQueryParam { + name: PersistedDocumentQueryParamName, + }, +} + +#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct PersistedDocumentJsonPath(String); + +impl PersistedDocumentJsonPath { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PersistedDocumentJsonPath { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // TODO: add more validations (like " char etc) + let path = String::deserialize(deserializer)?; + if path.is_empty() { + return Err(D::Error::custom("json_path cannot be empty")); + } + if path.chars().any(char::is_whitespace) { + return Err(D::Error::custom("json_path cannot include whitespace")); + } + if path.contains('[') || path.contains(']') { + return Err(D::Error::custom("json_path cannot include array syntax")); + } + if path.contains('*') { + return Err(D::Error::custom("json_path cannot include wildcard syntax")); + } + if path.split('.').any(str::is_empty) { + return Err(D::Error::custom( + "json_path cannot include empty segments (e.g. '..')", + )); + } + + if matches!( + path.split('.').next(), + Some("query" | "operationName" | "variables") + ) { + return Err(D::Error::custom( + "json_path cannot access root GraphQL fields: query, operationName, variables", + )); + } + + Ok(Self(path)) + } +} + +#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct PersistedDocumentUrlTemplate(String); + +impl PersistedDocumentUrlTemplate { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PersistedDocumentUrlTemplate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let template = String::deserialize(deserializer)?; + + validate_url_path_template(&template).map_err(D::Error::custom)?; + + Ok(Self(template)) + } +} + +fn validate_url_path_template(template: &str) -> Result<(), String> { + if template.is_empty() { + return Err("url_path_param.template cannot be empty".to_string()); + } + if !template.starts_with('/') { + return Err("url_path_param.template must start with '/'".to_string()); + } + if template.contains('?') || template.contains('#') { + return Err("url_path_param.template cannot include query string or fragment".to_string()); + } + + let raw_segments: Vec<&str> = template.split('/').skip(1).collect(); + if raw_segments.iter().any(|segment| segment.is_empty()) { + return Err("url_path_param.template cannot include empty segments".to_string()); + } + + let mut id_count = 0; + for (index, segment) in raw_segments.iter().enumerate() { + match *segment { + ":id" => id_count += 1, + "*" => {} + "**" => { + return Err("url_path_param.template does not support '**' segments".to_string()); + } + literal if literal.starts_with(':') => { + return Err(format!( + "url_path_param.template has unsupported parameter segment '{literal}' at index {index}; only ':id' is allowed" + )); + } + _ => {} + } + } + + if id_count != 1 { + return Err("url_path_param.template must include exactly one ':id' segment".to_string()); + } + + Ok(()) +} + +#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct PersistedDocumentQueryParamName(String); + +impl PersistedDocumentQueryParamName { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PersistedDocumentQueryParamName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let name = String::deserialize(deserializer)?; + // TODO: improve it + if name.trim().is_empty() { + return Err(D::Error::custom("url_query_param.name cannot be empty")); + } + Ok(Self(name)) + } +} + +impl PersistedDocumentsConfig { + pub fn default_selectors() -> Vec { + vec![ + PersistedDocumentExtractorConfig::JsonPath { + path: PersistedDocumentJsonPath("documentId".to_string()), + }, + PersistedDocumentExtractorConfig::JsonPath { + path: PersistedDocumentJsonPath("extensions.persistedQuery.sha256Hash".to_string()), + }, + ] + } +} + +#[cfg(test)] +mod tests { + use super::{ + PersistedDocumentJsonPath, PersistedDocumentUrlTemplate, PersistedDocumentsConfig, + }; + + #[test] + fn rejects_root_graphql_fields_for_json_path() { + for path in ["query", "operationName", "variables", "query.foo"] { + let raw = format!("\"{path}\""); + let parsed = serde_json::from_str::(&raw); + assert!(parsed.is_err(), "expected path '{path}' to be rejected"); + } + } + + #[test] + fn allows_non_root_graphql_fields_for_json_path() { + for path in [ + "documentId", + "extensions.persistedQuery.sha256Hash", + "foo.query", + ] { + let raw = format!("\"{path}\""); + let parsed = serde_json::from_str::(&raw); + assert!(parsed.is_ok(), "expected path '{path}' to be allowed"); + } + } + + #[test] + fn enabled_persisted_documents_require_storage() { + let parsed = serde_json::from_str::( + r#"{ + "enabled": true + }"#, + ); + + assert!( + parsed.is_err(), + "expected storage to be required when enabled" + ); + } + + #[test] + fn url_template_rejects_unknown_parameter_segment() { + let parsed = serde_json::from_str::(r#""/p/:docId""#); + assert!(parsed.is_err(), "expected unknown parameter to be rejected"); + } + + #[test] + fn url_template_accepts_supported_segment_types() { + for template in ["/v1/p/:id", "/v1/*/:id", "/v1/*/:id/details"] { + let raw = format!("\"{template}\""); + let parsed = serde_json::from_str::(&raw); + assert!(parsed.is_ok(), "expected template '{template}' to be valid"); + } + } + + #[test] + fn url_template_rejects_globstar_segment() { + for template in ["/v1/**/:id", "/:id/**/v2"] { + let raw = format!("\"{template}\""); + let parsed = serde_json::from_str::(&raw); + assert!( + parsed.is_err(), + "expected template '{template}' to be rejected" + ); + } + } +} diff --git a/lib/router-config/src/primitives/file_path.rs b/lib/router-config/src/primitives/file_path.rs index f8140562a..b184b1e03 100644 --- a/lib/router-config/src/primitives/file_path.rs +++ b/lib/router-config/src/primitives/file_path.rs @@ -61,13 +61,24 @@ impl<'de> Visitor<'de> for FilePathVisitor { type Value = FilePath; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a string representing a relative file path") + formatter.write_str("a string representing a file path") } fn visit_str(self, v: &str) -> Result where E: de::Error, { + let path = Path::new(v); + if path.is_absolute() { + let canonical_path = fs::canonicalize(path) + .map_err(|err| E::custom(format!("Failed to canonicalize path: {}", err)))?; + + return Ok(FilePath { + relative: v.to_string(), + absolute: canonical_path.to_string_lossy().to_string(), + }); + } + CONTEXT_START_PATH.with(|ctx| { if let Some(start_path) = ctx.borrow().as_ref() { match FilePath::resolve_relative(start_path, v, true) { From 52656e4114ca30326ae7d8df03b028af20876b9b Mon Sep 17 00:00:00 2001 From: theguild-bot Date: Fri, 17 Apr 2026 08:47:27 +0000 Subject: [PATCH 2/2] docs: update documentation --- docs/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/README.md b/docs/README.md index 38eee5ba9..a51ad8d32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -605,6 +605,7 @@ propagate: {} ``` +  **Option 2 (alternative):** Remove headers before sending the request to a subgraph. @@ -627,6 +628,7 @@ remove: {} ``` +  **Option 3 (alternative):** Add or overwrite a header with a static value. @@ -759,6 +761,7 @@ Static value provided in the config. |**value**|`string`||yes| +  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -825,6 +828,7 @@ propagate: {} ``` +  **Option 2 (alternative):** Remove headers before sending the response to the client. @@ -844,6 +848,7 @@ remove: {} ``` +  **Option 3 (alternative):** Add or overwrite a header in the response to the client. @@ -978,6 +983,7 @@ Static value provided in the config. |**value**|`string`||yes| +  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -1074,6 +1080,7 @@ propagate: {} ``` +  **Option 2 (alternative):** Remove headers before sending the request to a subgraph. @@ -1096,6 +1103,7 @@ remove: {} ``` +  **Option 3 (alternative):** Add or overwrite a header with a static value. @@ -1228,6 +1236,7 @@ Static value provided in the config. |**value**|`string`||yes| +  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -1294,6 +1303,7 @@ propagate: {} ``` +  **Option 2 (alternative):** Remove headers before sending the response to the client. @@ -1313,6 +1323,7 @@ remove: {} ``` +  **Option 3 (alternative):** Add or overwrite a header in the response to the client. @@ -1447,6 +1458,7 @@ Static value provided in the config. |**value**|`string`||yes| +  **Option 2 (optional):** A dynamic value computed by a VRL expression. @@ -1636,6 +1648,7 @@ A local file on the file-system. This file will be read once on startup and cach |**source**|`string`|Constant Value: `"file"`
|yes| +  **Option 2 (alternative):** A remote JWKS provider. The JWKS will be fetched via HTTP/HTTPS and cached. @@ -1677,6 +1690,7 @@ The first one that is found will be used. |**source**|`string`|Constant Value: `"header"`
|yes| +  **Option 2 (alternative):** **Properties** @@ -1921,6 +1935,7 @@ storage: null |**type**|`string`|Constant Value: `"json_path"`
|yes| +  **Option 2 (alternative):** **Properties** @@ -1930,6 +1945,7 @@ storage: null |**type**|`string`|Constant Value: `"url_path_param"`
|yes| +  **Option 3 (alternative):** **Properties** @@ -2121,6 +2137,7 @@ Configuration for the Federation supergraph source. By default, the router will Each source has a different set of configuration, depending on the source type. +  **Option 1 (alternative):** Loads a supergraph from the filesystem. The path can be either absolute or relative to the router's working directory. @@ -2143,6 +2160,7 @@ poll_interval: null ``` +  **Option 2 (alternative):** Loads a supergraph from Hive Console CDN. @@ -2525,6 +2543,7 @@ temporality: cumulative ``` +  **Option 2 (alternative):** **Properties** @@ -2878,6 +2897,7 @@ http: null ``` +  **Option 2 (alternative):** **Properties** @@ -3260,3 +3280,4 @@ source: connection ``` +