diff --git a/.appveyor.yml b/.appveyor.yml index ed791722..44166968 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -11,7 +11,7 @@ init: install: - ps: Set-Service wuauserv -StartupType Manual - - cinst -y php --version=8.0 + - cinst -y php --version=8.2 - cinst -y composer - refreshenv - cd c:\tools\php80 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a3b8886..16e25711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,6 @@ jobs: fail-fast: false matrix: php-version: - - '8.0' - - '8.1' - '8.2' symfony-version: - '5.4.*' @@ -30,17 +28,12 @@ jobs: - 'highest' remove-dependencies: [ '' ] coverage: [ 'none' ] - exclude: - - php-version: '8.0' - symfony-version: '6.2.*' - - php-version: '8.0' - symfony-version: '6.4.*' include: - - php-version: '8.0' + - php-version: '8.2' symfony-version: '5.4.*' dependencies: 'lowest' remove-dependencies: '--dev symfony/validator doctrine/orm doctrine/annotations' - - php-version: '8.1' + - php-version: '8.2' symfony-version: '5.4.*' dependencies: 'lowest' coverage: "pcov" @@ -92,7 +85,7 @@ jobs: uses: "shivammathur/setup-php@v2" with: tools: flex - php-version: "8.0" + php-version: "8.2" - name: "Install dependencies" uses: ramsey/composer-install@1.3.0 @@ -100,6 +93,28 @@ jobs: - name: "Check coding standard" run: composer check-cs + generated-protobuf: + runs-on: ubuntu-22.04 + name: Generated Protobuf + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + + # Pinned: protoc's PHP generator changes its output between releases, so an + # unpinned compiler would report drift that isn't there. Keep this version in + # step with the one recorded in proto/reports.proto. + - name: "Install protoc 35.1" + run: | + curl -sSLo /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protoc-35.1-linux-x86_64.zip + sudo unzip -o -q /tmp/protoc.zip -d /usr/local + protoc --version + + - name: "Regenerate and check for drift" + run: | + composer proto + git diff --exit-code proto/gen \ + || (echo "::error::proto/gen is out of date with proto/reports.proto -- run 'composer proto' and commit the result" && exit 1) + static-analysis: runs-on: ubuntu-22.04 name: "Static analysis" @@ -110,7 +125,7 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@v2" with: - php-version: "8.0" + php-version: "8.2" - name: "Install dependencies" uses: ramsey/composer-install@1.3.0 diff --git a/composer.json b/composer.json index 9215fc24..8f9e1ba1 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ ], "autoload": { "psr-4": { - "Redeye\\GraphQLBundle\\": "src/" + "Redeye\\GraphQLBundle\\": "src/", + "Redeye\\GraphQLBundle\\Federation\\Tracing\\Proto\\": "proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/" } }, "autoload-dev": { @@ -28,8 +29,9 @@ } }, "require": { - "php": "^8.0", + "php": "^8.2", "ext-json": "*", + "google/protobuf": "^5.35.1", "murtukov/php-code-generator": "^0.1.5", "phpdocumentor/reflection-docblock": "^5.2", "phpdocumentor/type-resolver": ">1.4.0,<1.6.0", @@ -46,6 +48,8 @@ "webonyx/graphql-php": "^14.5" }, "suggest": { + "ext-protobuf": "For much faster serialization of Apollo Federation inline traces (ftv1)", + "sentry/sentry": "To trace individual field resolution as Sentry spans.", "nelmio/cors-bundle": "For more flexibility when using CORS prefight", "react/promise": "To use ReactPHP promise adapter", "symfony/translation": "If you want validation error messages to be translated." @@ -88,6 +92,7 @@ }, "scripts": { "test": "bin/phpunit --color=always --coverage-clover=build/logs/clover.xml", + "proto": "protoc -I proto --php_out=proto/gen proto/reports.proto", "static-analysis": [ "phpstan analyse --ansi --memory-limit=1G" ], diff --git a/docs/definitions/cache-control.md b/docs/definitions/cache-control.md new file mode 100644 index 00000000..4d944756 --- /dev/null +++ b/docs/definitions/cache-control.md @@ -0,0 +1,163 @@ +# HTTP caching with `@cacheControl` and `@cacheTag` + +This bundle can turn cache hints written in your schema into a `Cache-Control` response header, +with the same semantics as Apollo Server, and pass `@cacheTag` through to the SDL your subgraph +publishes so an Apollo Router can use it for invalidation. + +The two directives are handled by different parties: + +| Directive | Interpreted by | Effect | +| --- | --- | --- | +| `@cacheControl` | this subgraph | produces the `Cache-Control` response header | +| `@cacheTag` | the router | tags data for targeted invalidation; the subgraph only publishes it | + +## Enabling it + +The feature is **off by default**, because switching it on gives a `Cache-Control` header to +responses that previously had none — including `no-store` for anything unannotated. + +```yaml +redeye_graphql: + cache_control: + enabled: true + default_max_age: 0 + calculate_http_headers: always # always | if-cacheable | never +``` + +- **`default_max_age`** — the `maxAge` given to root fields and to fields returning a composite + type when they carry no hint. `0`, as in Apollo, means "uncacheable unless you say otherwise". +- **`calculate_http_headers`** — `always` writes `no-store` when the response is not cacheable; + `if-cacheable` leaves the header untouched in that case; `never` never writes the header at all. + `true` and `false` are accepted as aliases for `always` and `never`. + +## Writing the directives + +In the GraphQL schema language: + +```graphql +type Query { + posts: [Post!]! @cacheControl(maxAge: 60) @cacheTag(format: "posts") + me: User @cacheControl(maxAge: 30, scope: PRIVATE) +} + +type Post @cacheControl(maxAge: 120) @cacheTag(format: "post-{$key.id}") @cacheTag(format: "post") { + id: ID! + title: String! + author: Author @cacheControl(inheritMaxAge: true) +} +``` + +Or in YAML: + +```yaml +Post: + type: object + config: + cacheControl: { maxAge: 120 } + cacheTags: ['post-{$key.id}', 'post'] + fields: + title: + type: "String!" + author: + type: Author + cacheControl: { inheritMaxAge: true } +``` + +`@cacheControl` is valid on a field, an object, an interface and a union. `@cacheTag` is valid on a +field and an object, and is repeatable. Neither is available through PHP attributes or annotations. + +> You do not need to declare `directive @cacheControl(...)` or `enum CacheControlScope` yourself. +> The bundle supplies both. Declaring the enum in a mapped `.graphql` file would create an ordinary +> type of that name and collide with the one the bundle emits. + +## How the policy is computed + +For each field that actually gets resolved: + +1. If the field's declared return type is an object, interface or union, its `@cacheControl` is + read first. This uses the **declared** type, so an interface- or union-typed field takes the + hint from the interface or union rather than from whichever concrete type is returned. +2. The field's own `@cacheControl` is applied on top, overriding only the arguments it actually + sets. `@cacheControl(scope: PRIVATE)` on a field keeps the `maxAge` declared on its return type. +3. If `maxAge` is still undecided and the field is either a root field or returns a composite type, + it becomes `default_max_age`. + +The response's `maxAge` is the smallest of every field that decided one, and its scope is `private` +if any of them said so. A field that never decides a `maxAge` contributes nothing — which is +precisely how inheritance works: non-root scalar fields stay undecided, so the value their parent +decided still stands. + +`inheritMaxAge: true` extends that to a field or type that would otherwise be defaulted. It cannot +be combined with an explicit `maxAge`; there would be nothing left to inherit. + +Root fields are always defaulted, `inheritMaxAge` or not — there is no parent to inherit from. + +### When no header is written + +- A response carrying **errors** is always `no-store`, even if every field it did resolve was + cacheable. +- A **mutation** needs no special handling: its root fields are defaulted like any other, so they + come out uncacheable unless annotated. + +### The `_entities` field + +`Query._entities` is a root field, so the rules above would pin every federated entity fetch at +`maxAge: 0` and no entity response could ever be cached. It is therefore exempt from the root +default, and instead takes the hint from the **concrete type** of each representation it is asked +for — matching Apollo. Annotate the entity type itself: + +```graphql +type Post @key(fields: "id") @cacheControl(maxAge: 120) { ... } +``` + +If you replace the `_entities` resolver with your own, that lookup no longer happens and `_entities` +contributes no hint at all, so those responses come out uncacheable rather than wrongly cacheable. + +## Batched requests + +The `/batch` endpoint runs several operations in one HTTP response, which can carry only one header, +so the aggregate is the most restrictive of them: the smallest `maxAge`, `private` if any operation +was, and `no-store` if any of them errored. This is the same rule that already applies across the +fields of a single query. + +Apollo Server instead writes no header at all for a batched response. The difference does not arise +behind a router, which never batches to its subgraphs. + +## What lands in the header + +``` +Cache-Control: max-age=60, public +Cache-Control: max-age=30, private +Cache-Control: no-store, private +``` + +Symfony appends `, private` to any `Cache-Control` naming neither `public`, `private` nor +`s-maxage`, so the uncacheable case reads `no-store, private` rather than Apollo's bare `no-store`. +This is not reachable through Symfony's `Response` API and means the same thing to every cache. + +## What the published SDL looks like + +With `federation: true`, `_service { sdl }` and `redeye:graphql:dump-federated` emit: + +```graphql +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.12", + import: ["@key", "@provides", "@requires", "@external", "@shareable", "@override", "@cacheTag"]) + +enum CacheControlScope { + PUBLIC + PRIVATE +} + +directive @cacheControl(maxAge: Int, scope: CacheControlScope, inheritMaxAge: Boolean) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + +type Post @key(fields: "id") @cacheControl(maxAge: 120) @cacheTag(format: "post-{$key.id}") { ... } +``` + +`@cacheTag` arrives through the `@link` import — it is part of the federation spec from v2.12 — so +it gets no local definition. `@cacheControl` is not a federation directive, so it is declared +locally; composition drops it from the supergraph, which is correct, because the router learns the +TTL from the `Cache-Control` header rather than from the schema. + +Composing this SDL emits one hint, that `CacheControlScope` is defined but unused. That is expected: +the enum exists only as an argument type of a directive the supergraph does not keep. diff --git a/docs/definitions/index.md b/docs/definitions/index.md index 73fb44c7..7c2de39f 100644 --- a/docs/definitions/index.md +++ b/docs/definitions/index.md @@ -17,6 +17,7 @@ - [Debug](debug/index.md) - [GraphiQL](graphiql/index.md) - [Upload files](upload-files.md) +- [HTTP caching (`@cacheControl` / `@cacheTag`)](cache-control.md) - [Annotations](annotations/index.md) Next step [Data fetching](../data-fetching/index.md). diff --git a/docs/federation/inline-trace.md b/docs/federation/inline-trace.md new file mode 100644 index 00000000..5f77ad6d --- /dev/null +++ b/docs/federation/inline-trace.md @@ -0,0 +1,237 @@ +Apollo Federation inline tracing (FTV1) +======================================= + +Apollo's gateway/router collects per-field performance data from subgraphs by sending the HTTP +header `apollo-federation-include-trace: ftv1`. A compliant subgraph answers with an +`extensions.ftv1` entry containing a base64-encoded protobuf `Trace` message. Apollo Server +does this via `ApolloServerPluginInlineTrace`, installed by default on every subgraph; +`graphql-java` does the same via `FederatedTracingInstrumentation`. + +This bundle implements the same behaviour. + +Configuration +------------- + +```yaml +redeye_graphql: + federation: true + # inline_trace is enabled by default when federation is true +``` + +Explicit forms: + +```yaml +redeye_graphql: + inline_trace: false # disable + inline_trace: true # enable, regardless of `federation` + inline_trace: + include_errors: masked # masked (default) | unmodified + transformer_service: App\GraphQL\MyTraceErrorTransformer +``` + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | inherits `redeye_graphql.federation` | Whether to emit traces at all. | +| `include_errors` | `masked` | Error reporting policy — see [Errors](#errors). | +| `transformer_service` | `null` | Service id implementing `Redeye\GraphQLBundle\Federation\Tracing\TraceErrorTransformerInterface`. Overrides `include_errors`. | + +Note that setting only `include_errors` or `transformer_service` does **not** enable the +feature — `enabled` still inherits `federation`. Configuring options should not silently turn +tracing on for a non-federated schema. + +### Security + +With inline tracing enabled, **any** client that sends `apollo-federation-include-trace: ftv1` +receives field-level timings for your server, and — in `unmodified` mode — error detail. +Subgraphs are not meant to be publicly reachable. Apollo Server carries the same exposure and +the same caveat. + +Specification +------------- + +There is no published specification for FTV1. What follows is derived from Apollo's +`reports.proto` and the two reference implementations: + +- [`apollo-server` / `plugin/inlineTrace/index.ts`](https://github.com/apollographql/apollo-server/blob/main/packages/server/src/plugin/inlineTrace/index.ts) +- [`apollo-server` / `plugin/traceTreeBuilder.ts`](https://github.com/apollographql/apollo-server/blob/main/packages/server/src/plugin/traceTreeBuilder.ts) +- [`apollo-server` / `reports.proto`](https://github.com/apollographql/apollo-server/blob/main/packages/usage-reporting-protobuf/src/reports.proto) +- [`federation-jvm` / `FederatedTracingInstrumentation.java`](https://github.com/apollographql/federation-jvm/blob/main/graphql-java-support/src/main/java/com/apollographql/federation/graphqljava/tracing/FederatedTracingInstrumentation.java) +- [Apollo docs: Inline trace plugin](https://www.apollographql.com/docs/apollo-server/api/plugin/inline-trace) + +### Trigger + +A request carrying the HTTP header `apollo-federation-include-trace` with the exact value +`ftv1` requests a trace. Any other value, or absence, means no trace. Header-name matching is +case-insensitive (HTTP); the value comparison is strict — Apollo only ever sends `ftv1`. + +### Response + +The response `extensions` object gains a key `ftv1` whose value is +`base64(Trace.serializeToString())`. Nothing else about the response changes. If the request did +not ask for a trace, `extensions.ftv1` is absent. + +### Wire format + +Subset of Apollo's `reports.proto`. **Field numbers are normative and must match upstream +exactly.** The vendored copy lives at [`proto/reports.proto`](../../proto/reports.proto). + +```proto +message Trace { + google.protobuf.Timestamp end_time = 3; // wallclock, trace end + google.protobuf.Timestamp start_time = 4; // wallclock, trace start + uint64 duration_ns = 11; // monotonic; may != end-start + Node root = 14; + double field_execution_weight = 31; // always 1.0 + + message Node { + oneof id { + string response_name = 1; // response key (alias-aware) for field nodes + uint32 index = 2; // list position for list-element nodes + } + string type = 3; // field return type, e.g. "String!" + string parent_type = 13; // owning type name, e.g. "User" + uint64 start_time = 8; // ns since Trace.start_time + uint64 end_time = 9; // ns since Trace.start_time + repeated Error error = 11; + repeated Node child = 12; + string original_field_name = 14; // set only when response_name is an alias + } + + message Error { + string message = 1; + repeated Location location = 2; + uint64 time_ns = 3; // not set + string json = 4; + } + + message Location { + uint32 line = 1; + uint32 column = 2; + } +} +``` + +`field_execution_weight` is set to `1.0`, matching Apollo Server. Studio scales field-usage +statistics by it. + +Fields deliberately **not** emitted (gateway / usage-reporting concerns): `signature`, +`details`, `client_name`, `client_version`, `http`, `cache_policy`, `query_plan`, +`full_query_cache_hit`, `persisted_query_*`, `registered_operation`, `forbidden_operation`, +`is_incomplete`, and `Node.cache_policy`. + +### Node tree + +The tree mirrors the shape of the GraphQL **response**, not resolver invocation order. + +- `Trace.root` has no `id`, no `type` and no timings. It exists only as parent of the + operation's top-level selections. +- Each resolved field contributes one node keyed by its **response name** (the alias if + aliased, otherwise the field name). When the response name differs from the schema field + name, `original_field_name` carries the schema field name. +- Each element of a list contributes an intermediate node carrying only `index`. Index nodes + have no `type`, `parent_type` or timings — they are structural, and no resolver ever fires + for them. +- Ancestors are created on demand: inserting `["users", 0, "name"]` creates `users`, `0` and + `name` as needed. Insertion is order-independent. +- `start_time`/`end_time` are nanoseconds **relative to `Trace.start_time`**, from a monotonic + clock. + +### Timing boundaries + +`Trace.start_time` is taken before parsing and validation, so parse and validation cost is +inside `duration_ns`. `end_time`/`duration_ns` are taken after execution completes. +`duration_ns` uses `hrtime(true)` (monotonic); `start_time`/`end_time` use wallclock +(`microtime(true)`) converted to `google.protobuf.Timestamp`. + +A trace is emitted even when the operation fails to parse or validate — the tree is then just +the root node carrying the errors. + +### Errors + +Errors attach to the node matching the error's `path`; errors with no path (parse, validation, +variable coercion) go on the root node. If no node exists for a path, it is created along with +its ancestors. + +Each `Trace.Error` carries `message` (after the policy below), `location` (1-based +line/column), and `json` (the standard `{message, locations, path, extensions}` serialization +of the policy-applied error). `time_ns` is not set — neither reference implementation sets it. + +| `include_errors` | Behaviour | +|---|---| +| `masked` (default) | `message` becomes the literal ``; `extensions` becomes `{"maskedBy": "RedeyeGraphQLBundleInlineTrace"}`. `locations` and `path` are preserved. | +| `unmodified` | The **client-visible** message and extensions are reported. | +| `transformer_service` | The service receives each error and returns a replacement or `null`; `null` drops the error from the trace. Only `message` and `extensions` may change — `locations` and `path` always come from the original so the error stays on the right node. | + +Masking is the default because a trace crosses a trust boundary: it leaves the subgraph and is +stored by Apollo Studio, and subgraph errors routinely carry internal detail. + +> **`unmodified` does not mean "raw".** This bundle's error handler never rewrites the message +> on the `GraphQL\Error\Error` object — masking happens later, inside the response formatter. +> So `$error->getMessage()` is still the raw PHP exception text (SQL, DSNs, file paths) while +> the trace is being built. `unmodified` therefore reports what the *client* would see, via +> `FormattedError::createFromException($error, DebugFlag::NONE, $internalErrorMessage)`, not the +> raw exception message. + +A custom transformer: + +```php +namespace App\GraphQL; + +use GraphQL\Error\Error; +use Redeye\GraphQLBundle\Federation\Tracing\TraceErrorTransformerInterface; + +final class MyTraceErrorTransformer implements TraceErrorTransformerInterface +{ + public function transform(Error $error): ?Error + { + // Drop anything that might carry customer data. + if (str_contains($error->getMessage(), '@')) { + return null; + } + + return $error; + } +} +``` + +Implementation notes +-------------------- + +The trace is built at three points, mirroring Apollo Server's plugin lifecycle: + +| Apollo Server | This bundle | +|---|---| +| `requestDidStart` (header check, `startTiming`) | `InlineTraceListener::onPreExecutor` on `graphql.pre_executor` | +| `willResolveField` | `TracingReferenceExecutor::resolveFieldValueOrError()` | +| `didEncounterErrors` + `willSendResponse` | `InlineTraceListener::onPostExecutor` on `graphql.post_executor`, priority 100 | + +The in-progress trace is carried in the per-execution `ArrayObject` context under the key +`Redeye\GraphQLBundle\Federation\Tracing\InlineTraceListener::CONTEXT_KEY`. It is visible to +resolvers as `$context[...]` but is **internal** — do not rely on it. Keeping it there (rather +than on the listener service) is what makes batched requests correct: `Request\Executor` builds +a fresh context per query, so each query in a batch gets its own independent trace. + +Per-field timings are collected by `TracingReferenceExecutor`, which this bundle installs +process-wide via `GraphQL\Executor\Executor::setImplementationFactory()`. **If your application +calls `setImplementationFactory()` itself, field nodes will silently disappear** while the trace +envelope is still emitted. + +Very large responses produce very large traces. The builder caps the node count (25 000) and +stops recording beyond it; a truncated trace is preferable to exhausting memory. + +Verifying by hand +----------------- + +```bash +curl -s localhost:8000/graphql \ + -H 'content-type: application/json' \ + -H 'apollo-federation-include-trace: ftv1' \ + -d '{"query":"{ me { name } }"}' | jq -r .extensions.ftv1 | base64 -d | protoc --decode_raw +``` + +`protoc --decode_raw` needs no schema and prints the field-number tree directly, so it +independently confirms field numbering against the table above. + +Apollo's [`apollo-federation-subgraph-compatibility`](https://github.com/apollographql/apollo-federation-subgraph-compatibility) +harness asserts that a subgraph returns `extensions.ftv1` for a header-carrying request, and is +the strongest available end-to-end check. diff --git a/docs/index.md b/docs/index.md index 2979d4a9..7f65f50e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ Versions requirements | Version | PHP | Symfony | Support | | --------------------------------------------------------------: | ---------: | ---------------: | -------------: | -| [`1.0`](https://github.com/redeye/GraphQLBundle/tree/master) | `>= 8.0` | `>= 5.3.7` | DEV | +| [`1.0`](https://github.com/redeye/GraphQLBundle/tree/master) | `>= 8.2` | `>= 5.3.7` | DEV | | [`0.14`](https://github.com/redeye/GraphQLBundle/tree/0.14) | `>= 7.4` | `>= 4.4.30` | Active support | | [`0.13`](https://github.com/redeye/GraphQLBundle/tree/0.13) | `>= 7.2` | `>= 4.3` | Active support | | [`0.12`](https://github.com/redeye/GraphQLBundle/tree/0.12) | `>= 7.1` | `>= 3.4, <4.4` | Security | @@ -24,6 +24,8 @@ After installation Define your [GraphQL schema](definitions/index.md). +Serving an Apollo Federation subgraph? See [inline tracing (FTV1)](federation/inline-trace.md). + Symfony Flex installation ------------ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4e9ec211..a9087f1b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -25,6 +25,9 @@ + + diff --git a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Metadata/Reports.php b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Metadata/Reports.php new file mode 100644 index 00000000..024f4507 --- /dev/null +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Metadata/Reports.php @@ -0,0 +1,26 @@ +internalAddGeneratedFile( + "\x0A\xD4\x05\x0A\x0Dreports.proto\"\xCF\x04\x0A\x05Trace\x12,\x0A\x08end_time\x18\x03 \x01(\x0B2\x1A.google.protobuf.Timestamp\x12.\x0A\x0Astart_time\x18\x04 \x01(\x0B2\x1A.google.protobuf.Timestamp\x12\x13\x0A\x0Bduration_ns\x18\x0B \x01(\x04\x12\x19\x0A\x04root\x18\x0E \x01(\x0B2\x0B.Trace.Node\x12\x1E\x0A\x16field_execution_weight\x18\x1F \x01(\x01\x1AZ\x0A\x05Error\x12\x0F\x0A\x07message\x18\x01 \x01(\x09\x12!\x0A\x08location\x18\x02 \x03(\x0B2\x0F.Trace.Location\x12\x0F\x0A\x07time_ns\x18\x03 \x01(\x04\x12\x0C\x0A\x04json\x18\x04 \x01(\x09\x1A(\x0A\x08Location\x12\x0C\x0A\x04line\x18\x01 \x01(\x0D\x12\x0E\x0A\x06column\x18\x02 \x01(\x0D\x1A\xE1\x01\x0A\x04Node\x12\x17\x0A\x0Dresponse_name\x18\x01 \x01(\x09H\x00\x12\x0F\x0A\x05index\x18\x02 \x01(\x0DH\x00\x12\x0C\x0A\x04type\x18\x03 \x01(\x09\x12\x12\x0A\x0Astart_time\x18\x08 \x01(\x04\x12\x10\x0A\x08end_time\x18\x09 \x01(\x04\x12\x1B\x0A\x05error\x18\x0B \x03(\x0B2\x0C.Trace.Error\x12\x1A\x0A\x05child\x18\x0C \x03(\x0B2\x0B.Trace.Node\x12\x13\x0A\x0Bparent_type\x18\x0D \x01(\x09\x12\x1B\x0A\x13original_field_name\x18\x0E \x01(\x09B\x04\x0A\x02idJ\x04\x08\x04\x10\x08J\x04\x08\x0A\x10\x0BJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03J\x04\x08\x05\x10\x0BJ\x04\x08\x0C\x10\x0DJ\x04\x08\x0D\x10\x0EJ\x04\x08\x0F\x10\x1FJ\x04\x08 \x10!J\x04\x08!\x10\"Bi\xCA\x02-Redeye\\GraphQLBundle\\Federation\\Tracing\\Proto\xE2\x026Redeye\\GraphQLBundle\\Federation\\Tracing\\Proto\\Metadatab\x06proto3" + , true); + + static::$is_initialized = true; + } +} + diff --git a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php new file mode 100644 index 00000000..26a09ead --- /dev/null +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php @@ -0,0 +1,240 @@ +Trace + */ +class Trace extends \Google\Protobuf\Internal\Message +{ + /** + * Wallclock time when the trace ended. + * + * Generated from protobuf field .google.protobuf.Timestamp end_time = 3; + */ + protected $end_time = null; + /** + * Wallclock time when the trace began. + * + * Generated from protobuf field .google.protobuf.Timestamp start_time = 4; + */ + protected $start_time = null; + /** + * High precision duration of the trace; may not equal end_time - start_time + * (e.g. if the machine's clock changed during the trace). + * + * Generated from protobuf field uint64 duration_ns = 11; + */ + protected $duration_ns = 0; + /** + * A tree containing information about all resolvers run by this service, including errors. + * + * Generated from protobuf field .Trace.Node root = 14; + */ + protected $root = null; + /** + * Some servers don't do field-level instrumentation for every request and assign each + * instrumented request a "weight". Apollo Server always sends 1 for inline traces; Studio + * scales estimated_execution_count by it. 0 is treated as 1 for backwards compatibility. + * + * Generated from protobuf field double field_execution_weight = 31; + */ + protected $field_execution_weight = 0.0; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Google\Protobuf\Timestamp $end_time + * Wallclock time when the trace ended. + * @type \Google\Protobuf\Timestamp $start_time + * Wallclock time when the trace began. + * @type int|string $duration_ns + * High precision duration of the trace; may not equal end_time - start_time + * (e.g. if the machine's clock changed during the trace). + * @type \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node $root + * A tree containing information about all resolvers run by this service, including errors. + * @type float $field_execution_weight + * Some servers don't do field-level instrumentation for every request and assign each + * instrumented request a "weight". Apollo Server always sends 1 for inline traces; Studio + * scales estimated_execution_count by it. 0 is treated as 1 for backwards compatibility. + * } + */ + public function __construct($data = NULL) { + \Redeye\GraphQLBundle\Federation\Tracing\Proto\Metadata\Reports::initOnce(); + parent::__construct($data); + } + + /** + * Wallclock time when the trace ended. + * + * Generated from protobuf field .google.protobuf.Timestamp end_time = 3; + * @return \Google\Protobuf\Timestamp|null + */ + public function getEndTime() + { + return $this->end_time; + } + + public function hasEndTime() + { + return isset($this->end_time); + } + + public function clearEndTime() + { + unset($this->end_time); + } + + /** + * Wallclock time when the trace ended. + * + * Generated from protobuf field .google.protobuf.Timestamp end_time = 3; + * @param \Google\Protobuf\Timestamp $var + * @return $this + */ + public function setEndTime(\Google\Protobuf\Timestamp|null $var) + { + $this->end_time = $var; + + return $this; + } + + /** + * Wallclock time when the trace began. + * + * Generated from protobuf field .google.protobuf.Timestamp start_time = 4; + * @return \Google\Protobuf\Timestamp|null + */ + public function getStartTime() + { + return $this->start_time; + } + + public function hasStartTime() + { + return isset($this->start_time); + } + + public function clearStartTime() + { + unset($this->start_time); + } + + /** + * Wallclock time when the trace began. + * + * Generated from protobuf field .google.protobuf.Timestamp start_time = 4; + * @param \Google\Protobuf\Timestamp $var + * @return $this + */ + public function setStartTime(\Google\Protobuf\Timestamp|null $var) + { + $this->start_time = $var; + + return $this; + } + + /** + * High precision duration of the trace; may not equal end_time - start_time + * (e.g. if the machine's clock changed during the trace). + * + * Generated from protobuf field uint64 duration_ns = 11; + * @return int|string + */ + public function getDurationNs() + { + return $this->duration_ns; + } + + /** + * High precision duration of the trace; may not equal end_time - start_time + * (e.g. if the machine's clock changed during the trace). + * + * Generated from protobuf field uint64 duration_ns = 11; + * @param int|string $var + * @return $this + */ + public function setDurationNs(int|string $var) + { + GPBUtil::checkUint64($var); + $this->duration_ns = $var; + + return $this; + } + + /** + * A tree containing information about all resolvers run by this service, including errors. + * + * Generated from protobuf field .Trace.Node root = 14; + * @return \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node|null + */ + public function getRoot() + { + return $this->root; + } + + public function hasRoot() + { + return isset($this->root); + } + + public function clearRoot() + { + unset($this->root); + } + + /** + * A tree containing information about all resolvers run by this service, including errors. + * + * Generated from protobuf field .Trace.Node root = 14; + * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node $var + * @return $this + */ + public function setRoot(\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node|null $var) + { + $this->root = $var; + + return $this; + } + + /** + * Some servers don't do field-level instrumentation for every request and assign each + * instrumented request a "weight". Apollo Server always sends 1 for inline traces; Studio + * scales estimated_execution_count by it. 0 is treated as 1 for backwards compatibility. + * + * Generated from protobuf field double field_execution_weight = 31; + * @return float + */ + public function getFieldExecutionWeight() + { + return $this->field_execution_weight; + } + + /** + * Some servers don't do field-level instrumentation for every request and assign each + * instrumented request a "weight". Apollo Server always sends 1 for inline traces; Studio + * scales estimated_execution_count by it. 0 is treated as 1 for backwards compatibility. + * + * Generated from protobuf field double field_execution_weight = 31; + * @param float $var + * @return $this + */ + public function setFieldExecutionWeight(float $var) + { + $this->field_execution_weight = $var; + + return $this; + } + +} + diff --git a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Error.php b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Error.php new file mode 100644 index 00000000..72f16e19 --- /dev/null +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Error.php @@ -0,0 +1,140 @@ +Trace.Error + */ +class Error extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field string message = 1; + */ + protected $message = ''; + /** + * Generated from protobuf field repeated .Trace.Location location = 2; + */ + private $location; + /** + * Generated from protobuf field uint64 time_ns = 3; + */ + protected $time_ns = 0; + /** + * Generated from protobuf field string json = 4; + */ + protected $json = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $message + * @type \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location[] $location + * @type int|string $time_ns + * @type string $json + * } + */ + public function __construct($data = NULL) { + \Redeye\GraphQLBundle\Federation\Tracing\Proto\Metadata\Reports::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string message = 1; + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Generated from protobuf field string message = 1; + * @param string $var + * @return $this + */ + public function setMessage(string $var) + { + GPBUtil::checkString($var, true); + $this->message = $var; + + return $this; + } + + /** + * Generated from protobuf field repeated .Trace.Location location = 2; + * @return RepeatedField<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location> + */ + public function getLocation() + { + return $this->location; + } + + /** + * Generated from protobuf field repeated .Trace.Location location = 2; + * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location[] $var + * @return $this + */ + public function setLocation(array|RepeatedField $var) + { + $arr = GPBUtil::checkRepeatedField($var, \Google\Protobuf\Internal\GPBType::MESSAGE, \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location::class); + $this->location = $arr; + + return $this; + } + + /** + * Generated from protobuf field uint64 time_ns = 3; + * @return int|string + */ + public function getTimeNs() + { + return $this->time_ns; + } + + /** + * Generated from protobuf field uint64 time_ns = 3; + * @param int|string $var + * @return $this + */ + public function setTimeNs(int|string $var) + { + GPBUtil::checkUint64($var); + $this->time_ns = $var; + + return $this; + } + + /** + * Generated from protobuf field string json = 4; + * @return string + */ + public function getJson() + { + return $this->json; + } + + /** + * Generated from protobuf field string json = 4; + * @param string $var + * @return $this + */ + public function setJson(string $var) + { + GPBUtil::checkString($var, true); + $this->json = $var; + + return $this; + } + +} + diff --git a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Location.php b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Location.php new file mode 100644 index 00000000..d7cccd05 --- /dev/null +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Location.php @@ -0,0 +1,86 @@ +Trace.Location + */ +class Location extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field uint32 line = 1; + */ + protected $line = 0; + /** + * Generated from protobuf field uint32 column = 2; + */ + protected $column = 0; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type int $line + * @type int $column + * } + */ + public function __construct($data = NULL) { + \Redeye\GraphQLBundle\Federation\Tracing\Proto\Metadata\Reports::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field uint32 line = 1; + * @return int + */ + public function getLine() + { + return $this->line; + } + + /** + * Generated from protobuf field uint32 line = 1; + * @param int $var + * @return $this + */ + public function setLine(int $var) + { + GPBUtil::checkUint32($var); + $this->line = $var; + + return $this; + } + + /** + * Generated from protobuf field uint32 column = 2; + * @return int + */ + public function getColumn() + { + return $this->column; + } + + /** + * Generated from protobuf field uint32 column = 2; + * @param int $var + * @return $this + */ + public function setColumn(int $var) + { + GPBUtil::checkUint32($var); + $this->column = $var; + + return $this; + } + +} + diff --git a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php new file mode 100644 index 00000000..2dbd5e1c --- /dev/null +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php @@ -0,0 +1,319 @@ +Trace.Node + */ +class Node extends \Google\Protobuf\Internal\Message +{ + /** + * The field's return type; e.g. "String!" for User.email: String! + * + * Generated from protobuf field string type = 3; + */ + protected $type = ''; + /** + * relative to the trace's start_time, in ns + * + * Generated from protobuf field uint64 start_time = 8; + */ + protected $start_time = 0; + /** + * relative to the trace's start_time, in ns + * + * Generated from protobuf field uint64 end_time = 9; + */ + protected $end_time = 0; + /** + * Generated from protobuf field repeated .Trace.Error error = 11; + */ + private $error; + /** + * Generated from protobuf field repeated .Trace.Node child = 12; + */ + private $child; + /** + * The field's parent type; e.g. "User" for User.email: String! + * + * Generated from protobuf field string parent_type = 13; + */ + protected $parent_type = ''; + /** + * Generated from protobuf field string original_field_name = 14; + */ + protected $original_field_name = ''; + protected $id; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $response_name + * @type int $index + * @type string $type + * The field's return type; e.g. "String!" for User.email: String! + * @type int|string $start_time + * relative to the trace's start_time, in ns + * @type int|string $end_time + * relative to the trace's start_time, in ns + * @type \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error[] $error + * @type \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node[] $child + * @type string $parent_type + * The field's parent type; e.g. "User" for User.email: String! + * @type string $original_field_name + * } + */ + public function __construct($data = NULL) { + \Redeye\GraphQLBundle\Federation\Tracing\Proto\Metadata\Reports::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string response_name = 1; + * @return string + */ + public function getResponseName() + { + return $this->readOneof(1); + } + + public function hasResponseName() + { + return $this->hasOneof(1); + } + + /** + * Generated from protobuf field string response_name = 1; + * @param string $var + * @return $this + */ + public function setResponseName(string $var) + { + GPBUtil::checkString($var, true); + $this->writeOneof(1, $var); + + return $this; + } + + /** + * Generated from protobuf field uint32 index = 2; + * @return int + */ + public function getIndex() + { + return $this->readOneof(2); + } + + public function hasIndex() + { + return $this->hasOneof(2); + } + + /** + * Generated from protobuf field uint32 index = 2; + * @param int $var + * @return $this + */ + public function setIndex(int $var) + { + GPBUtil::checkUint32($var); + $this->writeOneof(2, $var); + + return $this; + } + + /** + * The field's return type; e.g. "String!" for User.email: String! + * + * Generated from protobuf field string type = 3; + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * The field's return type; e.g. "String!" for User.email: String! + * + * Generated from protobuf field string type = 3; + * @param string $var + * @return $this + */ + public function setType(string $var) + { + GPBUtil::checkString($var, true); + $this->type = $var; + + return $this; + } + + /** + * relative to the trace's start_time, in ns + * + * Generated from protobuf field uint64 start_time = 8; + * @return int|string + */ + public function getStartTime() + { + return $this->start_time; + } + + /** + * relative to the trace's start_time, in ns + * + * Generated from protobuf field uint64 start_time = 8; + * @param int|string $var + * @return $this + */ + public function setStartTime(int|string $var) + { + GPBUtil::checkUint64($var); + $this->start_time = $var; + + return $this; + } + + /** + * relative to the trace's start_time, in ns + * + * Generated from protobuf field uint64 end_time = 9; + * @return int|string + */ + public function getEndTime() + { + return $this->end_time; + } + + /** + * relative to the trace's start_time, in ns + * + * Generated from protobuf field uint64 end_time = 9; + * @param int|string $var + * @return $this + */ + public function setEndTime(int|string $var) + { + GPBUtil::checkUint64($var); + $this->end_time = $var; + + return $this; + } + + /** + * Generated from protobuf field repeated .Trace.Error error = 11; + * @return RepeatedField<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error> + */ + public function getError() + { + return $this->error; + } + + /** + * Generated from protobuf field repeated .Trace.Error error = 11; + * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error[] $var + * @return $this + */ + public function setError(array|RepeatedField $var) + { + $arr = GPBUtil::checkRepeatedField($var, \Google\Protobuf\Internal\GPBType::MESSAGE, \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error::class); + $this->error = $arr; + + return $this; + } + + /** + * Generated from protobuf field repeated .Trace.Node child = 12; + * @return RepeatedField<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node> + */ + public function getChild() + { + return $this->child; + } + + /** + * Generated from protobuf field repeated .Trace.Node child = 12; + * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node[] $var + * @return $this + */ + public function setChild(array|RepeatedField $var) + { + $arr = GPBUtil::checkRepeatedField($var, \Google\Protobuf\Internal\GPBType::MESSAGE, \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node::class); + $this->child = $arr; + + return $this; + } + + /** + * The field's parent type; e.g. "User" for User.email: String! + * + * Generated from protobuf field string parent_type = 13; + * @return string + */ + public function getParentType() + { + return $this->parent_type; + } + + /** + * The field's parent type; e.g. "User" for User.email: String! + * + * Generated from protobuf field string parent_type = 13; + * @param string $var + * @return $this + */ + public function setParentType(string $var) + { + GPBUtil::checkString($var, true); + $this->parent_type = $var; + + return $this; + } + + /** + * Generated from protobuf field string original_field_name = 14; + * @return string + */ + public function getOriginalFieldName() + { + return $this->original_field_name; + } + + /** + * Generated from protobuf field string original_field_name = 14; + * @param string $var + * @return $this + */ + public function setOriginalFieldName(string $var) + { + GPBUtil::checkString($var, true); + $this->original_field_name = $var; + + return $this; + } + + /** + * @return string + */ + public function getId() + { + return $this->whichOneof("id"); + } + +} + diff --git a/proto/reports.proto b/proto/reports.proto new file mode 100644 index 00000000..91ad62a2 --- /dev/null +++ b/proto/reports.proto @@ -0,0 +1,95 @@ +syntax = "proto3"; + +// Trimmed subset of Apollo's reports.proto, sufficient to emit FTV1 inline traces +// from a federated subgraph. +// +// Upstream: https://github.com/apollographql/apollo-server +// packages/usage-reporting-protobuf/src/reports.proto +// Vendored from commit 021460e95c34ce921dc1c8caa3e5ded3463487ee (2023-03-02). +// +// FIELD NUMBERS MUST MATCH UPSTREAM EXACTLY -- they are the wire format. +// `reserved` guards the numbers of upstream fields we deliberately omit, so they +// can never be reused here by mistake. See docs/federation/inline-trace.md. +// +// Upstream declares no `package`, which would make protoc emit into the PHP global +// namespace (\Trace). The two `option php_*_namespace` lines below are this file's +// only intentional divergence from upstream. +// +// Regenerate with: composer proto +// +// Generated with protoc 35.1. protoc's PHP backend changes its output between +// releases, so use that version (CI pins it) or expect a large, spurious diff. + +import "google/protobuf/timestamp.proto"; + +option php_namespace = "Redeye\\GraphQLBundle\\Federation\\Tracing\\Proto"; +option php_metadata_namespace = "Redeye\\GraphQLBundle\\Federation\\Tracing\\Proto\\Metadata"; + +message Trace { + message Error { + string message = 1; + repeated Location location = 2; + uint64 time_ns = 3; + string json = 4; + } + + message Location { + uint32 line = 1; + uint32 column = 2; + } + + // We store information on each resolver execution as a Node on a tree. The structure + // of the tree corresponds to the structure of the GraphQL response; it does not + // indicate the order in which resolvers were invoked. Nodes representing list indices + // (and the root node) don't carry types or times. + message Node { + // The response name of the field -- i.e. the alias, when the query used one. In that + // case original_field_name holds the actual field name from the schema. Paths are + // always built from response_name. + oneof id { + string response_name = 1; + uint32 index = 2; + } + + // The field's return type; e.g. "String!" for User.email: String! + string type = 3; + + // relative to the trace's start_time, in ns + uint64 start_time = 8; + // relative to the trace's start_time, in ns + uint64 end_time = 9; + + repeated Error error = 11; + repeated Node child = 12; + + // The field's parent type; e.g. "User" for User.email: String! + string parent_type = 13; + + string original_field_name = 14; + + // Omitted upstream fields: cache_policy (5). 4 is reserved upstream too. + reserved 4 to 7, 10; + } + + // Wallclock time when the trace ended. + google.protobuf.Timestamp end_time = 3; + // Wallclock time when the trace began. + google.protobuf.Timestamp start_time = 4; + // High precision duration of the trace; may not equal end_time - start_time + // (e.g. if the machine's clock changed during the trace). + uint64 duration_ns = 11; + // A tree containing information about all resolvers run by this service, including errors. + Node root = 14; + + // Some servers don't do field-level instrumentation for every request and assign each + // instrumented request a "weight". Apollo Server always sends 1 for inline traces; Studio + // scales estimated_execution_count by it. 0 is treated as 1 for backwards compatibility. + double field_execution_weight = 31; + + // Everything else in the upstream Trace message is a gateway / usage-reporting concern + // and is never present in an inline trace: signature, details, client_name, + // client_version, http, cache_policy, query_plan, full_query_cache_hit, + // persisted_query_*, registered_operation, forbidden_operation, is_incomplete, + // unexecutedOperation*. + reserved 1, 2, 5 to 10, 12, 13, 15 to 30, 32, 33; +} diff --git a/src/CacheControl/CacheControlAccumulator.php b/src/CacheControl/CacheControlAccumulator.php new file mode 100644 index 00000000..0f9191a5 --- /dev/null +++ b/src/CacheControl/CacheControlAccumulator.php @@ -0,0 +1,144 @@ +hintResolver = $hintResolver; + $this->enabled = $enabled; + $this->defaultMaxAge = $defaultMaxAge; + $this->headerMode = $headerMode; + $this->overall = new CachePolicy(); + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Starts a fresh recorder for one operation. The caller puts it in the GraphQL context value. + */ + public function startOperation(): CacheControlRecorder + { + return $this->current = new CacheControlRecorder($this->hintResolver, $this->defaultMaxAge); + } + + /** + * Merges the finished operation into the request-wide policy. + * + * An operation that produced errors makes the whole response uncacheable, and stays that way + * however many further operations succeed. + */ + public function finishOperation(bool $hasErrors): void + { + if ($hasErrors) { + $this->uncacheable = true; + } elseif (null !== $this->current) { + $this->overall->restrictBy($this->current->getPolicy()); + } + + $this->current = null; + } + + /** + * Under php-fpm the container is rebuilt per request, but under a worker runtime it is not. + */ + public function onKernelRequest(RequestEvent $event): void + { + if ($event->isMainRequest()) { + $this->reset(); + } + } + + /** + * Clears the accumulated policy. The hint resolver's memo is deliberately untouched — it is + * derived from the schema, not from the request. + */ + public function reset(): void + { + $this->overall->reset(); + $this->uncacheable = false; + $this->current = null; + } + + /** + * Writes the aggregated policy onto the response. + * + * Symfony normalises `Cache-Control` on the way out: a value carrying neither `public`, + * `private` nor `s-maxage` gets `, private` appended, so the uncacheable case is emitted as + * `no-store, private` rather than Apollo's bare `no-store`. That is not reachable through + * Symfony's Response API — `setCache()` goes through the same code — and it is semantically + * identical, since no cache may store the response either way. + * + * In the `if-cacheable` mode nothing is written at all for an uncacheable response, which + * leaves Symfony's own default of `no-cache, private` in place. + */ + public function applyTo(Response $response): void + { + if (!$this->enabled || self::HEADERS_NEVER === $this->headerMode) { + return; + } + + if (!$this->uncacheable && $this->overall->isCacheable()) { + $response->headers->set(self::HEADER, sprintf( + 'max-age=%d, %s', + $this->overall->getMaxAge(), + CacheScope::toHeaderToken($this->overall->getScope()) + )); + + return; + } + + if (self::HEADERS_IF_CACHEABLE === $this->headerMode) { + return; + } + + $response->headers->set(self::HEADER, self::UNCACHEABLE); + } + + public function getPolicy(): CachePolicy + { + return $this->overall; + } + + public function isUncacheable(): bool + { + return $this->uncacheable; + } +} diff --git a/src/CacheControl/CacheControlRecorder.php b/src/CacheControl/CacheControlRecorder.php new file mode 100644 index 00000000..98738b13 --- /dev/null +++ b/src/CacheControl/CacheControlRecorder.php @@ -0,0 +1,106 @@ +hintResolver = $hintResolver; + $this->defaultMaxAge = $defaultMaxAge; + $this->policy = new CachePolicy(); + } + + public function recordField(ResolveInfo $info): void + { + if ($this->sealed) { + return; + } + + $fieldDefinition = $info->fieldDefinition; + + if (!empty($fieldDefinition->config[self::SELF_HINTED_CONFIG_KEY])) { + return; + } + + $hint = $this->hintResolver->forField($fieldDefinition); + $maxAge = $hint->getMaxAge(); + + // Root fields, and fields returning a composite type, are uncacheable unless they say + // otherwise. Everything else — notably non-root scalars — stays undecided and therefore + // inherits whatever its ancestors decided. + if (null === $maxAge && (($hint->returnsCompositeType() && !$hint->shouldInheritMaxAge()) || $this->isRootField($info))) { + $maxAge = $this->defaultMaxAge; + } + + $this->restrict($maxAge, $hint->getScope()); + } + + /** + * Applies the hint of a concrete type, for fields that resolve their type at runtime. + */ + public function recordType(Type $type): void + { + if ($this->sealed) { + return; + } + + $hint = $this->hintResolver->forType($type); + + $this->restrict($hint->getMaxAge() ?? $this->defaultMaxAge, $hint->getScope()); + } + + public function getPolicy(): CachePolicy + { + return $this->policy; + } + + private function restrict(?int $maxAge, ?string $scope): void + { + $this->policy->restrict($maxAge, $scope); + + if ($this->policy->isFullyRestricted()) { + $this->sealed = true; + } + } + + private function isRootField(ResolveInfo $info): bool + { + return 1 === count($info->path); + } +} diff --git a/src/CacheControl/CacheHint.php b/src/CacheControl/CacheHint.php new file mode 100644 index 00000000..b33f4fbe --- /dev/null +++ b/src/CacheControl/CacheHint.php @@ -0,0 +1,64 @@ +maxAge = $maxAge; + $this->scope = $scope; + $this->inheritMaxAge = $inheritMaxAge; + } + + /** + * Builds a hint from the `cacheControl` entry of a field or type config array. + * + * @param array{maxAge?: int|null, scope?: string|null, inheritMaxAge?: bool|null}|null $config + */ + public static function fromConfig(?array $config): self + { + if (null === $config) { + return new self(); + } + + return new self( + isset($config['maxAge']) ? (int) $config['maxAge'] : null, + $config['scope'] ?? null, + (bool) ($config['inheritMaxAge'] ?? false) + ); + } + + public function getMaxAge(): ?int + { + return $this->maxAge; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function shouldInheritMaxAge(): bool + { + return $this->inheritMaxAge; + } + + public function isEmpty(): bool + { + return null === $this->maxAge && null === $this->scope && !$this->inheritMaxAge; + } +} diff --git a/src/CacheControl/CacheHintResolver.php b/src/CacheControl/CacheHintResolver.php new file mode 100644 index 00000000..754da0c5 --- /dev/null +++ b/src/CacheControl/CacheHintResolver.php @@ -0,0 +1,102 @@ + */ + private WeakMap $fieldHints; + + public function __construct() + { + $this->fieldHints = new WeakMap(); + } + + public function forField(FieldDefinition $fieldDefinition): FieldCacheHint + { + return $this->fieldHints[$fieldDefinition] ??= $this->computeFieldHint($fieldDefinition); + } + + /** + * The `@cacheControl` annotation written directly on a type. + */ + public function forType(Type $type): CacheHint + { + $config = $type->config ?? null; + + if (!is_array($config) || !isset($config['cacheControl']) || !is_array($config['cacheControl'])) { + return new CacheHint(); + } + + return CacheHint::fromConfig($config['cacheControl']); + } + + private function computeFieldHint(FieldDefinition $fieldDefinition): FieldCacheHint + { + $namedReturnType = Type::getNamedType($fieldDefinition->getType()); + $composite = null !== $namedReturnType && Type::isCompositeType($namedReturnType); + + $maxAge = null; + $scope = null; + $inheritMaxAge = false; + + // The annotation on the declared return type comes first. Note this is the *declared* type, + // so an interface- or union-typed field reads the interface/union annotation rather than + // whichever concrete type happens to be returned at runtime. + if ($composite) { + /** @var Type $namedReturnType */ + $typeHint = $this->forType($namedReturnType); + $inheritMaxAge = $typeHint->shouldInheritMaxAge(); + $maxAge = $typeHint->getMaxAge(); + $scope = $typeHint->getScope(); + } + + $fieldHint = $this->annotationOn($fieldDefinition); + + // The field's own annotation replaces only the keys it actually sets, so + // `@cacheControl(scope: PRIVATE)` on a field keeps the maxAge declared on its return type. + if ($fieldHint->shouldInheritMaxAge() && null === $maxAge) { + $inheritMaxAge = true; + + if (null !== $fieldHint->getScope()) { + $scope = $fieldHint->getScope(); + } + } else { + if (null !== $fieldHint->getMaxAge()) { + $maxAge = $fieldHint->getMaxAge(); + } + + if (null !== $fieldHint->getScope()) { + $scope = $fieldHint->getScope(); + } + } + + return new FieldCacheHint($maxAge, $scope, $inheritMaxAge, $composite); + } + + private function annotationOn(FieldDefinition $fieldDefinition): CacheHint + { + $config = $fieldDefinition->config; + + if (!isset($config['cacheControl']) || !is_array($config['cacheControl'])) { + return new CacheHint(); + } + + return CacheHint::fromConfig($config['cacheControl']); + } +} diff --git a/src/CacheControl/CachePolicy.php b/src/CacheControl/CachePolicy.php new file mode 100644 index 00000000..76644868 --- /dev/null +++ b/src/CacheControl/CachePolicy.php @@ -0,0 +1,68 @@ +maxAge || $maxAge < $this->maxAge)) { + $this->maxAge = $maxAge; + } + + $this->scope = CacheScope::restrict($this->scope, $scope); + } + + public function restrictBy(self $other): void + { + $this->restrict($other->maxAge, $other->scope); + } + + public function getMaxAge(): ?int + { + return $this->maxAge; + } + + public function getScope(): ?string + { + return $this->scope; + } + + /** + * A policy nobody has restricted is *not* cacheable: with no hints at all we have no reason to + * believe the response can be stored. + */ + public function isCacheable(): bool + { + return null !== $this->maxAge && $this->maxAge > 0; + } + + /** + * True once no further hint could possibly change the outcome. + * + * Requires both conditions: a policy at maxAge 0 can still degrade from PUBLIC to PRIVATE. + */ + public function isFullyRestricted(): bool + { + return 0 === $this->maxAge && CacheScope::PRIVATE === $this->scope; + } + + public function reset(): void + { + $this->maxAge = null; + $this->scope = null; + } +} diff --git a/src/CacheControl/CacheScope.php b/src/CacheControl/CacheScope.php new file mode 100644 index 00000000..9de48579 --- /dev/null +++ b/src/CacheControl/CacheScope.php @@ -0,0 +1,47 @@ +maxAge = $maxAge; + $this->scope = $scope; + $this->inheritMaxAge = $inheritMaxAge; + $this->composite = $composite; + } + + public function getMaxAge(): ?int + { + return $this->maxAge; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function shouldInheritMaxAge(): bool + { + return $this->inheritMaxAge; + } + + /** + * Whether the field's named return type is an object, interface or union. + */ + public function returnsCompositeType(): bool + { + return $this->composite; + } +} diff --git a/src/Config/EntityObjectDefinition.php b/src/Config/EntityObjectDefinition.php index b88620bb..9ba9947b 100644 --- a/src/Config/EntityObjectDefinition.php +++ b/src/Config/EntityObjectDefinition.php @@ -23,6 +23,8 @@ public function getDefinition(): ArrayNodeDefinition ->append($this->outputFieldsSection()) ->append($this->fieldsBuilderSection()) ->append($this->descriptionSection()) + ->append($this->cacheControlSection()) + ->append($this->cacheTagsSection()) ->booleanNode('shareable')->defaultFalse()->end() ->booleanNode('external')->defaultFalse()->end() ->arrayNode('keyFields') @@ -47,6 +49,8 @@ public function getDefinition(): ArrayNodeDefinition $this->treatFieldsDefaultAccess($node); $this->treatFieldsDefaultPublic($node); + $node->validate()->always(fn ($v) => self::unsetEmptyCacheTags($v))->end(); + return $node; } diff --git a/src/Config/EntityRefObjectDefinition.php b/src/Config/EntityRefObjectDefinition.php index 35e67a32..8b7850e4 100644 --- a/src/Config/EntityRefObjectDefinition.php +++ b/src/Config/EntityRefObjectDefinition.php @@ -23,6 +23,8 @@ public function getDefinition(): ArrayNodeDefinition ->append($this->outputFieldsSection()) ->append($this->fieldsBuilderSection()) ->append($this->descriptionSection()) + ->append($this->cacheControlSection()) + ->append($this->cacheTagsSection()) ->booleanNode('shareable')->defaultFalse()->end() ->booleanNode('external')->defaultFalse()->end() ->arrayNode('keyFields') @@ -46,6 +48,8 @@ public function getDefinition(): ArrayNodeDefinition $this->treatFieldsDefaultAccess($node); $this->treatFieldsDefaultPublic($node); + $node->validate()->always(fn ($v) => self::unsetEmptyCacheTags($v))->end(); + return $node; } diff --git a/src/Config/InterfaceTypeDefinition.php b/src/Config/InterfaceTypeDefinition.php index 65768d4d..e9ea9e93 100644 --- a/src/Config/InterfaceTypeDefinition.php +++ b/src/Config/InterfaceTypeDefinition.php @@ -20,6 +20,7 @@ public function getDefinition(): ArrayNodeDefinition ->append($this->outputFieldsSection()) ->append($this->resolveTypeSection()) ->append($this->descriptionSection()) + ->append($this->cacheControlSection()) ->arrayNode('interfaces') ->prototype('scalar')->info('One of internal or custom interface types.')->end() ->end() diff --git a/src/Config/ObjectTypeDefinition.php b/src/Config/ObjectTypeDefinition.php index 053de50f..f9e197bc 100644 --- a/src/Config/ObjectTypeDefinition.php +++ b/src/Config/ObjectTypeDefinition.php @@ -25,6 +25,8 @@ public function getDefinition(): ArrayNodeDefinition ->append($this->outputFieldsSection()) ->append($this->fieldsBuilderSection()) ->append($this->descriptionSection()) + ->append($this->cacheControlSection()) + ->append($this->cacheTagsSection()) ->booleanNode('shareable')->defaultFalse()->end() ->booleanNode('external')->defaultFalse()->end() ->arrayNode('interfaces') @@ -43,6 +45,8 @@ public function getDefinition(): ArrayNodeDefinition $this->treatFieldsDefaultAccess($node); $this->treatFieldsDefaultPublic($node); + $node->validate()->always(fn ($v) => self::unsetEmptyCacheTags($v))->end(); + return $node; } diff --git a/src/Config/Parser/GraphQL/ASTConverter/CacheDirectivesNode.php b/src/Config/Parser/GraphQL/ASTConverter/CacheDirectivesNode.php new file mode 100644 index 00000000..d035e302 --- /dev/null +++ b/src/Config/Parser/GraphQL/ASTConverter/CacheDirectivesNode.php @@ -0,0 +1,121 @@ +, cacheTags?: string[]} + */ + public static function toConfig(Node $node): array + { + $config = []; + + $cacheControl = self::cacheControl($node); + if ([] !== $cacheControl) { + $config[self::CACHE_CONTROL] = $cacheControl; + } + + $cacheTags = self::cacheTags($node); + if ([] !== $cacheTags) { + $config[self::CACHE_TAGS] = $cacheTags; + } + + return $config; + } + + /** + * @return array + */ + private static function cacheControl(Node $node): array + { + $directives = DirectiveArguments::named($node, self::CACHE_CONTROL_DIRECTIVE); + + if ([] === $directives) { + return []; + } + + if (count($directives) > 1) { + throw new RuntimeException(sprintf('@%s is not repeatable.', self::CACHE_CONTROL_DIRECTIVE)); + } + + $directive = $directives[0]; + + $config = []; + + $maxAge = DirectiveArguments::intArg($directive, 'maxAge'); + if (null !== $maxAge) { + $config['maxAge'] = $maxAge; + } + + $scope = DirectiveArguments::enumArg($directive, 'scope', CacheScope::ALL); + if (null !== $scope) { + $config['scope'] = $scope; + } + + $inheritMaxAge = DirectiveArguments::boolArg($directive, 'inheritMaxAge'); + if (null !== $inheritMaxAge) { + $config['inheritMaxAge'] = $inheritMaxAge; + } + + // There is nothing to inherit if the annotation already decided a maxAge. + if (isset($config['maxAge']) && !empty($config['inheritMaxAge'])) { + throw new RuntimeException(sprintf( + '@%s cannot set both "maxAge" and "inheritMaxAge".', + self::CACHE_CONTROL_DIRECTIVE + )); + } + + return $config; + } + + /** + * @return string[] + */ + private static function cacheTags(Node $node): array + { + $tags = []; + + foreach (DirectiveArguments::named($node, self::CACHE_TAG_DIRECTIVE) as $directive) { + $format = DirectiveArguments::stringArg($directive, 'format'); + + if (null === $format) { + throw new RuntimeException(sprintf( + 'Argument "format" is required on the @%s directive.', + self::CACHE_TAG_DIRECTIVE + )); + } + + $tags[] = $format; + } + + return $tags; + } +} diff --git a/src/Config/Parser/GraphQL/ASTConverter/DirectiveArguments.php b/src/Config/Parser/GraphQL/ASTConverter/DirectiveArguments.php new file mode 100644 index 00000000..ddcaedeb --- /dev/null +++ b/src/Config/Parser/GraphQL/ASTConverter/DirectiveArguments.php @@ -0,0 +1,174 @@ +arguments[0]->value->value`, which works only for a + * directive with a single string argument. Anything with named arguments of mixed types, or a + * directive that may be repeated, needs these instead. + */ +final class DirectiveArguments +{ + private function __construct() + { + } + + /** + * All applications of a directive on a node. More than one is possible for repeatable + * directives such as `@cacheTag`. + * + * @return ASTDirectiveNode[] + */ + public static function named(Node $node, string $directiveName): array + { + $directives = []; + + // Node itself declares no $directives; only the definition subclasses that can carry them + // do, and graphql-php v14 has no common interface for those. + /** @phpstan-ignore-next-line */ + foreach ($node->directives ?? [] as $directive) { + if ($directive->name->value === $directiveName) { + $directives[] = $directive; + } + } + + return $directives; + } + + public static function intArg(ASTDirectiveNode $directive, string $name): ?int + { + $value = self::valueNode($directive, $name); + + if (null === $value) { + return null; + } + + if (!$value instanceof IntValueNode) { + throw self::wrongType($directive, $name, 'an Int', $value); + } + + // IntValueNode holds its value as a string. + return (int) $value->value; + } + + /** + * @param string[] $allowed + */ + public static function enumArg(ASTDirectiveNode $directive, string $name, array $allowed): ?string + { + $value = self::valueNode($directive, $name); + + if (null === $value) { + return null; + } + + // Tooling emits enum arguments both bare and quoted, so accept either. + if (!$value instanceof EnumValueNode && !$value instanceof StringValueNode) { + throw self::wrongType($directive, $name, 'an enum value', $value); + } + + if (!in_array($value->value, $allowed, true)) { + throw new RuntimeException(sprintf( + 'Argument "%s" of @%s must be one of %s, got "%s".', + $name, + $directive->name->value, + implode(', ', $allowed), + $value->value + )); + } + + return $value->value; + } + + public static function boolArg(ASTDirectiveNode $directive, string $name): ?bool + { + $value = self::valueNode($directive, $name); + + if (null === $value) { + return null; + } + + if (!$value instanceof BooleanValueNode) { + throw self::wrongType($directive, $name, 'a Boolean', $value); + } + + return $value->value; + } + + public static function stringArg(ASTDirectiveNode $directive, string $name): ?string + { + $value = self::valueNode($directive, $name); + + if (null === $value) { + return null; + } + + if (!$value instanceof StringValueNode) { + throw self::wrongType($directive, $name, 'a String', $value); + } + + return $value->value; + } + + /** + * Reads a named argument as a raw value node, or null when the argument is absent. + * + * @phpstan-template T of Node + * + * @phpstan-param class-string $valueNodeClass + * + * @phpstan-return T|null + */ + public static function typedArg(ASTDirectiveNode $directive, string $name, string $valueNodeClass): ?Node + { + $value = self::valueNode($directive, $name); + + if (null === $value) { + return null; + } + + if (!$value instanceof $valueNodeClass) { + throw new RuntimeException(sprintf('Expected value type to be %s, but was %s', $valueNodeClass, get_class($value))); + } + + return $value; + } + + private static function valueNode(ASTDirectiveNode $directive, string $name): ?Node + { + foreach ($directive->arguments as $argument) { + if ($argument->name->value === $name) { + return $argument->value; + } + } + + return null; + } + + private static function wrongType(ASTDirectiveNode $directive, string $name, string $expected, Node $actual): RuntimeException + { + return new RuntimeException(sprintf( + 'Argument "%s" of @%s must be %s, got %s.', + $name, + $directive->name->value, + $expected, + $actual->kind + )); + } +} diff --git a/src/Config/Parser/GraphQL/ASTConverter/DirectiveNode.php b/src/Config/Parser/GraphQL/ASTConverter/DirectiveNode.php index 5b4b340f..7580bf5e 100644 --- a/src/Config/Parser/GraphQL/ASTConverter/DirectiveNode.php +++ b/src/Config/Parser/GraphQL/ASTConverter/DirectiveNode.php @@ -19,7 +19,10 @@ public static function toConfig(Node $node): array $directiveDef->arguments[0]->value->value : Directive::DEFAULT_DEPRECATION_REASON; $config['deprecationReason'] = $reason; - break; + + // Not `break`: a node may carry further directives after @deprecated, and + // abandoning the loop here silently drops every one of them. + continue; } if ('resolve' === $directiveDef->name->value) { @@ -51,6 +54,6 @@ public static function toConfig(Node $node): array } } - return $config; + return $config + CacheDirectivesNode::toConfig($node); } } diff --git a/src/Config/Parser/GraphQL/ASTConverter/FieldsNode.php b/src/Config/Parser/GraphQL/ASTConverter/FieldsNode.php index 7436881f..fbd1bc1d 100644 --- a/src/Config/Parser/GraphQL/ASTConverter/FieldsNode.php +++ b/src/Config/Parser/GraphQL/ASTConverter/FieldsNode.php @@ -57,6 +57,12 @@ public static function toConfig(Node $node, string $property = 'fields'): array $fieldConfig['access'] = $directiveConfig['access']; } + foreach ([CacheDirectivesNode::CACHE_CONTROL, CacheDirectivesNode::CACHE_TAGS] as $cacheKey) { + if (isset($directiveConfig[$cacheKey])) { + $fieldConfig[$cacheKey] = $directiveConfig[$cacheKey]; + } + } + $config[$definition->name->value] = $fieldConfig; } } diff --git a/src/Config/Parser/GraphQL/ASTConverter/ObjectNode.php b/src/Config/Parser/GraphQL/ASTConverter/ObjectNode.php index c4105d47..74758833 100644 --- a/src/Config/Parser/GraphQL/ASTConverter/ObjectNode.php +++ b/src/Config/Parser/GraphQL/ASTConverter/ObjectNode.php @@ -76,6 +76,8 @@ public static function toConfig(Node $node): array $config['resolveReference'] = $expr->value; } + $config += CacheDirectivesNode::toConfig($node); + return [ 'type' => self::typeName($hasKeyDirective, $isResolvable), 'config' => $config, @@ -96,14 +98,7 @@ private static function typeName(bool $hasKeyDirective, bool $isResolvable): str */ private static function directivesNamed(Node $node, string $directiveName): array { - $directives = []; - foreach ($node->directives as $directive) { - if ($directive->name->value === $directiveName) { - $directives[] = $directive; - } - } - - return $directives; + return DirectiveArguments::named($node, $directiveName); } /** @@ -113,16 +108,6 @@ private static function directivesNamed(Node $node, string $directiveName): arra */ private static function directiveArgumentValue(ASTDirectiveNode $directive, string $argumentName, string $valueNodeClass): ?Node { - foreach ($directive->arguments as $argument) { - if ($argument->name->value === $argumentName) { - if ($argument->value instanceof $valueNodeClass) { - return $argument->value; - } else { - throw new \RuntimeException("Expected value type to be $valueNodeClass, but was ".get_class($argument->value)); - } - } - } - - return null; + return DirectiveArguments::typedArg($directive, $argumentName, $valueNodeClass); } } diff --git a/src/Config/Parser/GraphQL/ASTConverter/UnionNode.php b/src/Config/Parser/GraphQL/ASTConverter/UnionNode.php index aa6eca0d..2117a14a 100644 --- a/src/Config/Parser/GraphQL/ASTConverter/UnionNode.php +++ b/src/Config/Parser/GraphQL/ASTConverter/UnionNode.php @@ -20,6 +20,8 @@ public static function toConfig(Node $node): array $config['types'] = $types; } + $config += CacheDirectivesNode::toConfig($node); + return [ 'type' => 'union', 'config' => $config, diff --git a/src/Config/TypeDefinition.php b/src/Config/TypeDefinition.php index e37434ea..25ba2568 100644 --- a/src/Config/TypeDefinition.php +++ b/src/Config/TypeDefinition.php @@ -4,6 +4,7 @@ namespace Redeye\GraphQLBundle\Config; +use Redeye\GraphQLBundle\CacheControl\CacheScope; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\ScalarNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; @@ -142,6 +143,82 @@ protected function deprecationReasonSection(): ScalarNodeDefinition return $node; } + /** + * The `@cacheControl` directive, usable on a field or on an object, interface or union type. + * + * Deliberately has neither `addDefaultsIfNotSet()` nor per-child defaults: the caching + * algorithm distinguishes "this annotation did not mention maxAge" from "maxAge is 0", and + * defaults here would silently annotate every type in the schema. + */ + protected function cacheControlSection(): ArrayNodeDefinition + { + /** @var ArrayNodeDefinition $node */ + $node = self::createNode('cacheControl'); + + /** @phpstan-ignore-next-line */ + $node + ->info('HTTP cache hint for this field or type.') + ->children() + ->integerNode('maxAge') + ->min(0) + ->info('How long, in seconds, a response containing this may be cached.') + ->end() + ->enumNode('scope') + ->values(CacheScope::ALL) + ->info('PRIVATE marks the value as specific to a single user.') + ->end() + ->booleanNode('inheritMaxAge') + ->info('Inherit the parent\'s maxAge instead of defaulting to uncacheable.') + ->end() + ->end() + ->validate() + ->ifTrue(fn ($value) => isset($value['maxAge']) && !empty($value['inheritMaxAge'])) + ->thenInvalid('"cacheControl" cannot set both "maxAge" and "inheritMaxAge".') + ->end(); + + return $node; + } + + /** + * The `@cacheTag` directive. Repeatable, so this is a list of format strings. + */ + protected function cacheTagsSection(): ArrayNodeDefinition + { + /** @var ArrayNodeDefinition $node */ + $node = self::createNode('cacheTags'); + + /** @phpstan-ignore-next-line */ + $node + ->info('Cache tags, passed through to the federated SDL for the router to interpret.') + ->beforeNormalization() + ->castToArray() + ->end() + ->scalarPrototype()->end(); + + return $node; + } + + /** + * Drops an empty `cacheTags` entry from a config array. + * + * A prototyped array node always reports a default of `[]`, and Symfony applies defaults + * without running the node's own validators, so the key has to be removed from the parent — + * the same way this class's `validationGroups` and `args` are handled. Left in place it would + * add a meaningless empty array to every field and type in every schema. + * + * @param array $config + * + * @return array + */ + protected static function unsetEmptyCacheTags(array $config): array + { + if (empty($config['cacheTags'])) { + unset($config['cacheTags']); + } + + return $config; + } + protected function typeSection(bool $isRequired = false): ScalarNodeDefinition { /** @var ScalarNodeDefinition $node */ diff --git a/src/Config/TypeWithOutputFieldsDefinition.php b/src/Config/TypeWithOutputFieldsDefinition.php index 4d2c31b6..2f55c66c 100644 --- a/src/Config/TypeWithOutputFieldsDefinition.php +++ b/src/Config/TypeWithOutputFieldsDefinition.php @@ -37,7 +37,7 @@ protected function outputFieldsSection(): NodeDefinition unset($value['args']); } - return $value; + return self::unsetEmptyCacheTags($value); }) ->end() ->children() @@ -76,6 +76,8 @@ protected function outputFieldsSection(): NodeDefinition ->scalarNode('requires')->end() ->scalarNode('provides')->end() ->booleanNode('external')->defaultFalse()->end() + ->append($this->cacheControlSection()) + ->append($this->cacheTagsSection()) ->append($this->descriptionSection()) ->append($this->deprecationReasonSection()) ->variableNode('access') diff --git a/src/Config/UnionTypeDefinition.php b/src/Config/UnionTypeDefinition.php index 32e61d7e..93ae524e 100644 --- a/src/Config/UnionTypeDefinition.php +++ b/src/Config/UnionTypeDefinition.php @@ -26,6 +26,7 @@ public function getDefinition(): ArrayNodeDefinition ->end() ->append($this->resolveTypeSection()) ->append($this->descriptionSection()) + ->append($this->cacheControlSection()) ->end(); return $node; diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php index 40a44cba..40a1b109 100644 --- a/src/Controller/GraphController.php +++ b/src/Controller/GraphController.php @@ -4,6 +4,7 @@ namespace Redeye\GraphQLBundle\Controller; +use Redeye\GraphQLBundle\CacheControl\CacheControlAccumulator; use Redeye\GraphQLBundle\Request\BatchParser; use Redeye\GraphQLBundle\Request\Executor; use Redeye\GraphQLBundle\Request\Parser; @@ -19,19 +20,22 @@ class GraphController private Parser $requestParser; private bool $shouldHandleCORS; private bool $useApolloBatchingMethod; + private CacheControlAccumulator $cacheControl; public function __construct( BatchParser $batchParser, Executor $requestExecutor, Parser $requestParser, bool $shouldHandleCORS, - string $graphQLBatchingMethod + string $graphQLBatchingMethod, + CacheControlAccumulator $cacheControl ) { $this->batchParser = $batchParser; $this->requestExecutor = $requestExecutor; $this->requestParser = $requestParser; $this->shouldHandleCORS = $shouldHandleCORS; $this->useApolloBatchingMethod = 'apollo' === $graphQLBatchingMethod; + $this->cacheControl = $cacheControl; } /** @@ -61,8 +65,14 @@ private function createResponse(Request $request, ?string $schemaName, bool $bat if (!in_array($request->getMethod(), ['POST', 'GET'])) { return new JsonResponse('', 405); } + // Every operation of this request accumulates into one policy, so start from a clean + // slate. Only observable under a worker runtime, but cheap either way. + $this->cacheControl->reset(); + $payload = $this->processQuery($request, $schemaName, $batched); $response = new JsonResponse($payload, 200); + + $this->cacheControl->applyTo($response); } $this->addCORSHeadersIfNeeded($response, $request); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 12cea852..be1bf38c 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -6,6 +6,7 @@ use GraphQL\Validator\Rules\QueryComplexity; use GraphQL\Validator\Rules\QueryDepth; +use Redeye\GraphQLBundle\CacheControl\CacheControlAccumulator; use Redeye\GraphQLBundle\DataLoader\Promise\Adapter\Webonyx\GraphQL\SyncPromiseAdapter; use Redeye\GraphQLBundle\Definition\Argument; use Redeye\GraphQLBundle\DependencyInjection\Compiler\ConfigParserPass; @@ -13,6 +14,8 @@ use Redeye\GraphQLBundle\EventListener\ErrorLoggerListener; use Redeye\GraphQLBundle\Executor\Executor; use Redeye\GraphQLBundle\ExpressionLanguage\ExpressionLanguage; +use Redeye\GraphQLBundle\Federation\Tracing\TraceErrorFilter; +use Redeye\GraphQLBundle\Federation\Tracing\TraceErrorTransformerInterface; use Redeye\GraphQLBundle\Resolver\FieldResolver; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\EnumNodeDefinition; @@ -22,6 +25,7 @@ use Symfony\Component\HttpKernel\Kernel; use function array_keys; use function is_array; +use function is_bool; use function is_int; use function is_numeric; use function is_string; @@ -49,10 +53,24 @@ public function getConfigTreeBuilder(): TreeBuilder // @phpstan-ignore-next-line $rootNode + ->validate() + ->always(function (array $v): array { + // `inline_trace.enabled: null` means "inherit from the `federation` sibling". + // This has to happen here rather than in a beforeNormalization: Symfony + // normalizes each config fragment on its own and only merges afterwards, so + // this is the first point at which both keys are known. + if (null === $v['inline_trace']['enabled']) { + $v['inline_trace']['enabled'] = $v['federation']; + } + + return $v; + }) + ->end() ->children() ->booleanNode('federation') ->defaultFalse() ->end() + ->append($this->inlineTraceSection()) ->append($this->batchingMethodSection()) ->append($this->definitionsSection()) ->append($this->errorsHandlerSection()) @@ -60,11 +78,88 @@ public function getConfigTreeBuilder(): TreeBuilder ->append($this->securitySection()) ->append($this->doctrineSection()) ->append($this->profilerSection()) + ->append($this->cacheControlSection()) ->end(); return $treeBuilder; } + private function cacheControlSection(): ArrayNodeDefinition + { + $builder = new TreeBuilder('cache_control'); + + /** @var ArrayNodeDefinition $node */ + $node = $builder->getRootNode(); + + // @phpstan-ignore-next-line + $node + ->info('Turns @cacheControl annotations into a Cache-Control response header.') + ->treatFalseLike(['enabled' => false]) + ->treatTrueLike(['enabled' => true]) + ->treatNullLike(['enabled' => true]) + ->addDefaultsIfNotSet() + ->children() + // Opt-in: switching this on gives a Cache-Control header to responses that + // previously had none, including no-store for anything unannotated. + ->booleanNode('enabled')->defaultFalse()->end() + ->integerNode('default_max_age') + ->min(0) + ->defaultValue(0) + ->info('maxAge applied to root fields and composite-returning fields that carry no hint.') + ->end() + ->enumNode('calculate_http_headers') + ->values([ + CacheControlAccumulator::HEADERS_ALWAYS, + CacheControlAccumulator::HEADERS_IF_CACHEABLE, + CacheControlAccumulator::HEADERS_NEVER, + ]) + ->defaultValue(CacheControlAccumulator::HEADERS_ALWAYS) + ->beforeNormalization() + ->ifTrue(fn ($v) => is_bool($v)) + ->then(fn ($v) => $v ? CacheControlAccumulator::HEADERS_ALWAYS : CacheControlAccumulator::HEADERS_NEVER) + ->end() + ->info('"if-cacheable" leaves the header untouched instead of emitting no-store.') + ->end() + ->end(); + + return $node; + } + + private function inlineTraceSection(): ArrayNodeDefinition + { + $builder = new TreeBuilder('inline_trace'); + + /** @var ArrayNodeDefinition $node */ + $node = $builder->getRootNode(); + + // @phpstan-ignore-next-line + $node + ->info('Apollo Federation inline tracing (ftv1). See docs/federation/inline-trace.md.') + ->treatFalseLike(['enabled' => false]) + ->treatTrueLike(['enabled' => true]) + ->treatNullLike(['enabled' => true]) + ->addDefaultsIfNotSet() + ->children() + // Tri-state rather than canBeEnabled(): that hard-codes defaultFalse(), which + // would make an explicit `false` indistinguishable from the default. + ->booleanNode('enabled') + ->info('Defaults to the value of "federation".') + ->defaultNull() + ->end() + ->enumNode('include_errors') + ->info('"masked" replaces error messages with "" (the Apollo Server default); "unmodified" reports the client-visible message.') + ->values([TraceErrorFilter::MASKED, TraceErrorFilter::UNMODIFIED]) + ->defaultValue(TraceErrorFilter::MASKED) + ->end() + ->scalarNode('transformer_service') + ->info('Service id implementing '.TraceErrorTransformerInterface::class.'. Overrides include_errors.') + ->defaultNull() + ->end() + ->end(); + + return $node; + } + private function batchingMethodSection(): EnumNodeDefinition { $builder = new TreeBuilder('batching_method', 'enum'); diff --git a/src/DependencyInjection/RedeyeGraphQLExtension.php b/src/DependencyInjection/RedeyeGraphQLExtension.php index f4639e1e..f6c1c4a2 100644 --- a/src/DependencyInjection/RedeyeGraphQLExtension.php +++ b/src/DependencyInjection/RedeyeGraphQLExtension.php @@ -22,7 +22,9 @@ use Redeye\GraphQLBundle\EventListener\DebugListener; use Redeye\GraphQLBundle\EventListener\ErrorHandlerListener; use Redeye\GraphQLBundle\EventListener\ErrorLoggerListener; +use Redeye\GraphQLBundle\EventListener\InlineTraceListener; use Redeye\GraphQLBundle\Federation\FederatedSchemaBuilder; +use Redeye\GraphQLBundle\Federation\Tracing\TraceErrorFilter; use Redeye\GraphQLBundle\Request\Executor; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -54,6 +56,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->setSchemaBuilderArguments($config, $container); $this->setSchemaArguments($config, $container); $this->setErrorHandler($config, $container); + $this->setInlineTrace($config, $container); $this->setSecurity($config, $container); $this->setConfigBuilders($config, $container); $this->setDebugListener($config, $container); @@ -63,6 +66,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->setCompilerCacheWarmer($config, $container); $this->registerForAutoconfiguration($container); $this->setDefaultFieldResolver($config, $container); + $this->setCacheControlParameters($config, $container); $container->setParameter($this->getAlias().'.config', $config); $container->setParameter($this->getAlias().'.resources_dir', realpath(__DIR__.'/../Resources')); @@ -158,6 +162,13 @@ private function setBatchingMethod(array $config, ContainerBuilder $container): $container->setParameter($this->getAlias().'.batching_method', $config['batching_method']); } + private function setCacheControlParameters(array $config, ContainerBuilder $container): void + { + foreach ($config['cache_control'] as $key => $value) { + $container->setParameter(sprintf('%s.cache_control.%s', $this->getAlias(), $key), $value); + } + } + private function setDebugListener(array $config, ContainerBuilder $container): void { if ($config['definitions']['show_debug_info']) { @@ -167,6 +178,43 @@ private function setDebugListener(array $config, ContainerBuilder $container): v } } + /** + * Wires Apollo Federation inline tracing (ftv1). + * + * Must run after setErrorHandler(): the `unmodified` error policy reports the client-visible + * message, which means it needs the configured internal error message. + * + * @see docs/federation/inline-trace.md + */ + private function setInlineTrace(array $config, ContainerBuilder $container): void + { + $container->setParameter($this->getAlias().'.inline_trace', $config['inline_trace']['enabled']); + + if (!$config['inline_trace']['enabled']) { + return; + } + + $transformer = $config['inline_trace']['transformer_service']; + + $container->register(TraceErrorFilter::class) + ->setArguments([ + $config['inline_trace']['include_errors'], + $config['errors_handler']['internal_error_message'], + null === $transformer ? null : new Reference($transformer), + ]); + + $container->register(InlineTraceListener::class) + ->setArguments([ + new Reference('request_stack'), + new Reference(TraceErrorFilter::class), + ]) + ->addTag('kernel.event_listener', ['event' => Events::PRE_EXECUTOR, 'method' => 'onPreExecutor']) + // Ahead of ErrorHandlerListener, DebugListener and GraphQLCollector, which all sit at + // the default priority of 0. ErrorHandler rebuilds errors and drops their extensions, + // so the trace has to see them first. + ->addTag('kernel.event_listener', ['event' => Events::POST_EXECUTOR, 'method' => 'onPostExecutor', 'priority' => 100]); + } + private function setConfigBuilders(array $config, ContainerBuilder $container): void { foreach (BuilderProcessor::BUILDER_TYPES as $type) { diff --git a/src/EventListener/CacheControlListener.php b/src/EventListener/CacheControlListener.php new file mode 100644 index 00000000..2dd68953 --- /dev/null +++ b/src/EventListener/CacheControlListener.php @@ -0,0 +1,49 @@ +accumulator = $accumulator; + } + + public function onExecutorContext(ExecutorContextEvent $event): void + { + if (!$this->accumulator->isEnabled()) { + return; + } + + $context = $event->getExecutorContext(); + $context[CacheControlRecorder::CONTEXT_KEY] = $this->accumulator->startOperation(); + } + + public function onPostExecutor(ExecutorResultEvent $event): void + { + if (!$this->accumulator->isEnabled()) { + return; + } + + // Apollo makes any response carrying errors uncacheable. A request that failed to parse or + // validate never reached the executor, so its recorder is empty and this is the only thing + // that marks it — which is the correct outcome either way. + $this->accumulator->finishOperation(!empty($event->getResult()->errors)); + } +} diff --git a/src/EventListener/InlineTraceListener.php b/src/EventListener/InlineTraceListener.php new file mode 100644 index 00000000..f6c2c587 --- /dev/null +++ b/src/EventListener/InlineTraceListener.php @@ -0,0 +1,89 @@ +requestStack = $requestStack; + $this->errorFilter = $errorFilter; + } + + public function onPreExecutor(ExecutorArgumentsEvent $event): void + { + $request = $this->requestStack->getCurrentRequest(); + + // No request at all under bin/console, and none in in-process test helpers. + if (!$request instanceof Request || self::HEADER_VALUE !== $request->headers->get(self::HEADER)) { + return; + } + + $builder = new TraceTreeBuilder(); + $builder->startTiming(); + + $event->getContextValue()[self::CONTEXT_KEY] = $builder; + } + + public function onPostExecutor(ExecutorResultEvent $event): void + { + $builder = $event->getExecutorArguments()->getContextValue()[self::CONTEXT_KEY] ?? null; + + if (!$builder instanceof TraceTreeBuilder) { + return; + } + + $result = $event->getResult(); + + // Runs before ErrorHandlerListener (priority 100 vs. 0), so these are still the raw + // errors from execution, with their paths, nodes and extensions intact. + foreach ($result->errors ?? [] as $error) { + $traceError = $this->errorFilter->filter($error); + + if (null !== $traceError) { + $builder->addError($error->path, $traceError); + } + } + + $builder->stopTiming(); + + $result->extensions[self::EXTENSION_KEY] = base64_encode($builder->toProto()->serializeToString()); + } +} diff --git a/src/Executor/TracingReferenceExecutor.php b/src/Executor/TracingReferenceExecutor.php index a2d68a23..c7cf1ec8 100644 --- a/src/Executor/TracingReferenceExecutor.php +++ b/src/Executor/TracingReferenceExecutor.php @@ -1,8 +1,11 @@ Printer::doPrint($documentNode), 'operationName' => $operationName, ]; + $referenceExecutor->traceBuilder = self::findTraceBuilder($contextValue); $referenceExecutor->configureContext(); return $referenceExecutor; @@ -40,8 +57,31 @@ public static function create(PromiseAdapter $promiseAdapter, Schema $schema, Do return $referenceExecutor; } + /** + * @param mixed $contextValue + */ + private static function findTraceBuilder($contextValue): ?TraceTreeBuilder + { + if (!$contextValue instanceof ArrayAccess && !is_array($contextValue)) { + return null; + } + + $builder = $contextValue[InlineTraceListener::CONTEXT_KEY] ?? null; + + return $builder instanceof TraceTreeBuilder ? $builder : null; + } + + /** + * @param mixed $rootValue + * + * @return mixed the resolver's return value, or the Throwable it raised + */ protected function resolveFieldValueOrError(FieldDefinition $fieldDef, FieldNode $fieldNode, callable $resolveFn, $rootValue, ResolveInfo $info) { + // Deliberately first: this is the equivalent of Apollo's willResolveField, and it must not + // depend on the Sentry block below having worked. + $this->recordCacheControl($info); + $argumentNodes = $fieldNode->arguments ?? []; foreach ($argumentNodes as $argumentNode) { if (!$argumentNode->value instanceof VariableNode) { @@ -65,9 +105,7 @@ protected function resolveFieldValueOrError(FieldDefinition $fieldDef, FieldNode $span = null; $parent = null; - if (class_exists('Sentry\SentrySdk') ) { - $parent = SentrySdk::getCurrentHub()->getSpan(); - + if (class_exists('Sentry\SentrySdk') && null !== $parent = SentrySdk::getCurrentHub()->getSpan()) { $context = new SpanContext(); $context->setOp('graphql.resolve_field'); $context->setDescription(implode('.', $info->path)); @@ -77,8 +115,19 @@ protected function resolveFieldValueOrError(FieldDefinition $fieldDef, FieldNode SentrySdk::getCurrentHub()->setSpan($span); } + // Last before the call, unlike recordCacheControl() above: this one starts a clock, so the + // Sentry setup should not be counted against the field's own duration. + $endTrace = null !== $this->traceBuilder ? $this->traceBuilder->willResolveField($info) : null; + try { - return parent::resolveFieldValueOrError($fieldDef, $fieldNode, $resolveFn, $rootValue, $info); + $result = parent::resolveFieldValueOrError($fieldDef, $fieldNode, $resolveFn, $rootValue, $info); + } catch (Throwable $e) { + // The parent catches everything and returns it, so this is belt and braces. + if (null !== $endTrace) { + $endTrace(); + } + + throw $e; } finally { // We only have a span if we started a span earlier if (null !== $span) { @@ -88,6 +137,72 @@ protected function resolveFieldValueOrError(FieldDefinition $fieldDef, FieldNode SentrySdk::getCurrentHub()->setSpan($parent); } } + + if (null !== $endTrace) { + $this->recordFieldEnd($result, $endTrace); + } + + return $result; + } + + /** + * Stops the clock on a field: immediately for a plain value, or when its promise settles. + * + * getPromise() rather than the promise adapter directly -- DataLoader hands back a + * GraphQL\Executor\Promise\Promise, which SyncPromiseAdapter::isThenable() rejects, so + * convertThenable() would throw on the most common async case in this bundle. + * + * The derived promise is deliberately discarded: this is a side branch, and the original + * value must be returned unchanged so that completeValueCatchingError() still sees it. + * Because resolveFieldValueOrError() runs before completeValueCatchingError(), this handler + * is registered first and so records the end time before any child field is resolved. + * + * @param mixed $result + */ + private function recordFieldEnd($result, callable $endTrace): void + { + $promise = $this->getPromise($result); + + if (null === $promise) { + $endTrace(); + + return; + } + + $settle = static function () use ($endTrace): void { + try { + $endTrace(); + } catch (Throwable $e) { + // Tracing must never break execution. + } + }; + + // Both handlers return void and never rethrow: rethrowing would leave an unhandled + // rejection on this orphan branch, which Guzzle's adapter reports from a destructor. + $promise->then($settle, $settle); + } + + /** + * Feeds the field into the recorder for the operation being executed, if there is one. + * + * The recorder travels in the GraphQL context value rather than being injected, because this + * class is built by a static factory inside graphql-php and so has no access to the container. + * A schema executed outside this bundle's request executor simply has no recorder and records + * nothing. + */ + private function recordCacheControl(ResolveInfo $info): void + { + $context = $this->exeContext->contextValue; + + if (!$context instanceof ArrayObject && !is_array($context)) { + return; + } + + $recorder = $context[CacheControlRecorder::CONTEXT_KEY] ?? null; + + if ($recorder instanceof CacheControlRecorder) { + $recorder->recordField($info); + } } private function findFieldArgumentNamed(FieldDefinition $def, string $name): ?FieldArgument @@ -107,9 +222,9 @@ private function shouldTraceFieldArgument(FieldArgument $arg): bool return false; } - private function configureContext() + private function configureContext(): void { - if (class_exists('Sentry\State\Scope') ) { + if (class_exists('Sentry\State\Scope')) { \Sentry\configureScope(function (Scope $scope): void { $scope->setContext('graphql_query', $this->sentryContext); }); diff --git a/src/Federation/Directives.php b/src/Federation/Directives.php index e9f25a55..52c7d018 100644 --- a/src/Federation/Directives.php +++ b/src/Federation/Directives.php @@ -4,6 +4,8 @@ namespace Redeye\GraphQLBundle\Federation; +use Redeye\GraphQLBundle\Federation\Directives\CacheControlDirective; +use Redeye\GraphQLBundle\Federation\Directives\CacheTagDirective; use Redeye\GraphQLBundle\Federation\Directives\KeyDirective; use Redeye\GraphQLBundle\Federation\Directives\ExternalDirective; use Redeye\GraphQLBundle\Federation\Directives\OverrideDirective; @@ -67,6 +69,22 @@ public static function override(): OverrideDirective return self::getDirectives()['override']; } + /** + * Gets the @cacheControl directive + */ + public static function cacheControl(): CacheControlDirective + { + return self::getDirectives()['cacheControl']; + } + + /** + * Gets the @cacheTag directive + */ + public static function cacheTag(): CacheTagDirective + { + return self::getDirectives()['cacheTag']; + } + /** * Gets the directives that can be used on federated entity types */ @@ -80,6 +98,8 @@ public static function getDirectives(): array 'provides' => new ProvidesDirective(), 'shareable' => new ShareableDirective(), 'override' => new OverrideDirective(), + 'cacheControl' => new CacheControlDirective(), + 'cacheTag' => new CacheTagDirective(), ]; } diff --git a/src/Federation/Directives/CacheControlDirective.php b/src/Federation/Directives/CacheControlDirective.php new file mode 100644 index 00000000..1d37778b --- /dev/null +++ b/src/Federation/Directives/CacheControlDirective.php @@ -0,0 +1,47 @@ + 'cacheControl', + 'locations' => [ + DirectiveLocation::FIELD_DEFINITION, + DirectiveLocation::OBJECT, + DirectiveLocation::IFACE, + DirectiveLocation::UNION, + ], + 'args' => [ + new FieldArgument([ + 'name' => 'maxAge', + 'type' => Type::int(), + ]), + new FieldArgument([ + 'name' => 'scope', + 'type' => new CacheControlScopeType(), + ]), + new FieldArgument([ + 'name' => 'inheritMaxAge', + 'type' => Type::boolean(), + ]), + ], + ]); + } +} diff --git a/src/Federation/Directives/CacheTagDirective.php b/src/Federation/Directives/CacheTagDirective.php new file mode 100644 index 00000000..ef6661b3 --- /dev/null +++ b/src/Federation/Directives/CacheTagDirective.php @@ -0,0 +1,37 @@ + 'cacheTag', + 'isRepeatable' => true, + 'locations' => [ + DirectiveLocation::FIELD_DEFINITION, + DirectiveLocation::OBJECT, + ], + 'args' => [ + new FieldArgument([ + 'name' => 'format', + 'type' => Type::nonNull(Type::string()), + ]), + ], + ]); + } +} diff --git a/src/Federation/FederatedSchema.php b/src/Federation/FederatedSchema.php index eaed905a..5b534b32 100644 --- a/src/Federation/FederatedSchema.php +++ b/src/Federation/FederatedSchema.php @@ -16,6 +16,9 @@ use Redeye\GraphQLBundle\Definition\Type\ExtensibleSchema; use Redeye\GraphQLBundle\Federation\EntityTypeResolver\EntityTypeResolverInterface; use Redeye\GraphQLBundle\Federation\Types\EntityUnionType; +use Redeye\GraphQLBundle\CacheControl\CacheControlRecorder; +use ArrayObject; +use function is_array; /** * A federated GraphQL schema definition (see [related docs](https://www.apollographql.com/docs/apollo-server/federation/introduction)) @@ -188,6 +191,10 @@ private function getQueryTypeEntitiesFieldConfig(?array $config): array return [ '_entities' => [ 'type' => Type::listOf($this->entityUnionType), + // _entities is a root field, so the usual defaulting would pin it at maxAge 0 and + // no entity fetch could ever be cached. Its hint comes from the concrete type of + // each representation instead, applied in self::resolve(). + CacheControlRecorder::SELF_HINTED_CONFIG_KEY => true, 'args' => [ 'representations' => [ 'type' => Type::nonNull(Type::listOf(Type::nonNull($this->anyType))) @@ -212,6 +219,8 @@ private function resolve($root, $args, $context, $info) $typeName = $ref['__typename']; $type = $info->schema->getType($typeName); + self::recordCacheControl($context, $type); + Utils::invariant( $type && $type instanceof EntityObjectType, sprintf( @@ -229,6 +238,29 @@ private function resolve($root, $args, $context, $info) }, $args['representations']); } + /** + * Applies the cache hint of a concrete entity type to the operation being executed. + * + * The `_entities` field is declared as returning the `_Entity` union, so nothing in the schema + * statically connects it to the type actually being fetched. Apollo resolves this the same way: + * the concrete type is looked up while resolving and its hint applied in place of the default. + * + * @param mixed $context + * @param mixed $type + */ + private static function recordCacheControl($context, $type): void + { + if (!$type instanceof Type || (!$context instanceof ArrayObject && !is_array($context))) { + return; + } + + $recorder = $context[CacheControlRecorder::CONTEXT_KEY] ?? null; + + if ($recorder instanceof CacheControlRecorder) { + $recorder->recordType($type); + } + } + /** * @param array $config * diff --git a/src/Federation/Tracing/TraceError.php b/src/Federation/Tracing/TraceError.php new file mode 100644 index 00000000..d3903762 --- /dev/null +++ b/src/Federation/Tracing/TraceError.php @@ -0,0 +1,28 @@ + $locations 1-based source locations + * @param string $json the `{message, locations, path, extensions}` + * serialization of the policy-applied error + */ + public function __construct( + public string $message, + public array $locations, + public string $json + ) { + } +} diff --git a/src/Federation/Tracing/TraceErrorFilter.php b/src/Federation/Tracing/TraceErrorFilter.php new file mode 100644 index 00000000..c55b16d0 --- /dev/null +++ b/src/Federation/Tracing/TraceErrorFilter.php @@ -0,0 +1,103 @@ +'; + public const MASKED_BY = 'RedeyeGraphQLBundleInlineTrace'; + + private string $includeErrors; + private string $internalErrorMessage; + private ?TraceErrorTransformerInterface $transformer; + + public function __construct( + string $includeErrors, + string $internalErrorMessage, + ?TraceErrorTransformerInterface $transformer = null + ) { + $this->includeErrors = $includeErrors; + $this->internalErrorMessage = $internalErrorMessage; + $this->transformer = $transformer; + } + + /** + * @return TraceError|null null when the policy says to omit this error from the trace + */ + public function filter(Error $error): ?TraceError + { + $reported = $this->applyPolicy($error); + + if (null === $reported) { + return null; + } + + $locations = []; + foreach ($error->getLocations() as $location) { + $locations[] = ['line' => $location->line, 'column' => $location->column]; + } + + return new TraceError($reported->getMessage(), $locations, (string) json_encode($reported)); + } + + /** + * Rebuilds the error with a policy-approved message and extensions, always keeping the + * original's nodes, source, positions and path so that it stays attached to the same trace + * node and reports the same locations. + */ + private function applyPolicy(Error $error): ?Error + { + if (null !== $this->transformer) { + $transformed = $this->transformer->transform($error); + + if (null === $transformed) { + return null; + } + + return $this->rebuild($error, $transformed->getMessage(), $transformed->getExtensions() ?? []); + } + + if (self::UNMODIFIED === $this->includeErrors) { + // NOT $error->getMessage(): the bundle's ErrorHandler masks at response-format time, + // so the raw exception text (SQL, DSNs, file paths) is still on the Error object + // here. Report what the client would see instead. + $formatted = FormattedError::createFromException($error, DebugFlag::NONE, $this->internalErrorMessage); + + return $this->rebuild($error, (string) $formatted['message'], $formatted['extensions'] ?? []); + } + + return $this->rebuild($error, self::MASKED_MESSAGE, ['maskedBy' => self::MASKED_BY]); + } + + /** + * @param array $extensions + */ + private function rebuild(Error $error, string $message, array $extensions): Error + { + return new Error( + $message, + $error->nodes, + $error->getSource(), + $error->getPositions(), + $error->path, + null, + $extensions + ); + } +} diff --git a/src/Federation/Tracing/TraceErrorTransformerInterface.php b/src/Federation/Tracing/TraceErrorTransformerInterface.php new file mode 100644 index 00000000..0164dcdd --- /dev/null +++ b/src/Federation/Tracing/TraceErrorTransformerInterface.php @@ -0,0 +1,28 @@ +responseName = $responseName; + + return $node; + } + + public static function forIndex(int $index): self + { + $node = new self(); + $node->index = $index; + + return $node; + } +} diff --git a/src/Federation/Tracing/TraceTreeBuilder.php b/src/Federation/Tracing/TraceTreeBuilder.php new file mode 100644 index 00000000..90c18c0c --- /dev/null +++ b/src/Federation/Tracing/TraceTreeBuilder.php @@ -0,0 +1,261 @@ + + */ + private array $nodes; + + private int $nodeCount = 0; + private int $maxNodes; + + private ?int $startHrTime = null; + private ?float $startWallTime = null; + private ?float $endWallTime = null; + private int $durationNs = 0; + private bool $stopped = false; + + public function __construct(int $maxNodes = self::DEFAULT_MAX_NODES) + { + $this->maxNodes = $maxNodes; + $this->root = new TraceNode(); + $this->nodes = ['' => $this->root]; + } + + public function startTiming(): void + { + $this->startWallTime = microtime(true); + $this->startHrTime = hrtime(true); + } + + public function getRoot(): TraceNode + { + return $this->root; + } + + /** + * Records the start of a field resolution and returns the callback that records its end. + * + * The callback captures the node, never the ResolveInfo or the path: graphql-php reuses a + * single ResolveInfo across list items and mutates its `path` in completeListValue(), so a + * callback that re-derived the path when the field settles would write into the wrong node. + */ + public function willResolveField(ResolveInfo $info): callable + { + if ($this->stopped || $this->nodeCount >= $this->maxNodes) { + return static function (): void { + }; + } + + $node = $this->ensureNode($info->path); + $node->type = $info->returnType->toString(); + $node->parentType = $info->parentType->name; + $node->startTime = $this->elapsedNs(); + + $responseName = end($info->path); + if (is_string($responseName) && $responseName !== $info->fieldName) { + $node->originalFieldName = $info->fieldName; + } + + return function () use ($node): void { + $node->endTime = $this->elapsedNs(); + }; + } + + /** + * @param array|null $path null for parse, validation and other whole-operation + * errors, which belong on the root node + */ + public function addError(?array $path, TraceError $error): void + { + $node = null === $path || [] === $path ? $this->root : $this->ensureNode($path); + $node->errors[] = $error; + } + + public function stopTiming(): void + { + if ($this->stopped) { + return; + } + + $this->durationNs = $this->elapsedNs(); + $this->endWallTime = microtime(true); + $this->stopped = true; + + $this->backfillEndTimes($this->root); + } + + public function toProto(): Trace + { + $trace = new Trace(); + $trace->setDurationNs($this->durationNs); + // Apollo Server always sends 1; Studio scales field usage statistics by it. + $trace->setFieldExecutionWeight(1.0); + $trace->setRoot($this->toProtoNode($this->root)); + + if (null !== $this->startWallTime) { + $trace->setStartTime(self::toTimestamp($this->startWallTime)); + } + + if (null !== $this->endWallTime) { + $trace->setEndTime(self::toTimestamp($this->endWallTime)); + } + + return $trace; + } + + /** + * Returns the node for a response path, creating it and any missing ancestors. + * + * No resolver ever fires for a list index, so those nodes only ever come into existence + * here. Ancestors are walked up to the nearest known node and then created downwards, which + * makes insertion order irrelevant -- deferred list items can settle in any order. + * + * @param array $path + */ + private function ensureNode(array $path): TraceNode + { + $key = implode('.', $path); + if (isset($this->nodes[$key])) { + return $this->nodes[$key]; + } + + $parent = $this->ensureNode(array_slice($path, 0, -1)); + + $segment = end($path); + $node = is_int($segment) ? TraceNode::forIndex($segment) : TraceNode::forResponseName((string) $segment); + + $parent->children[] = $node; + $this->nodes[$key] = $node; + ++$this->nodeCount; + + return $node; + } + + /** + * A resolver whose promise never settles -- an undispatched DataLoader, say -- would leave + * end_time at 0, which Studio reads as a negative duration. Structural nodes, which never + * carry timings at all, are left alone. + */ + private function backfillEndTimes(TraceNode $node): void + { + if ($node->startTime > 0 && 0 === $node->endTime) { + $node->endTime = $this->durationNs; + } + + foreach ($node->children as $child) { + $this->backfillEndTimes($child); + } + } + + private function toProtoNode(TraceNode $node): ProtoNode + { + $proto = new ProtoNode(); + + if (null !== $node->responseName) { + $proto->setResponseName($node->responseName); + } elseif (null !== $node->index) { + $proto->setIndex($node->index); + } + + if ('' !== $node->type) { + $proto->setType($node->type); + } + + if ('' !== $node->parentType) { + $proto->setParentType($node->parentType); + } + + if ('' !== $node->originalFieldName) { + $proto->setOriginalFieldName($node->originalFieldName); + } + + $proto->setStartTime($node->startTime); + $proto->setEndTime($node->endTime); + + if ([] !== $node->errors) { + $proto->setError(array_map([self::class, 'toProtoError'], $node->errors)); + } + + if ([] !== $node->children) { + $proto->setChild(array_map([$this, 'toProtoNode'], $node->children)); + } + + return $proto; + } + + private static function toProtoError(TraceError $error): ProtoError + { + $proto = new ProtoError(); + $proto->setMessage($error->message); + $proto->setJson($error->json); + + if ([] !== $error->locations) { + $proto->setLocation(array_map( + static fn (array $location): ProtoLocation => (new ProtoLocation()) + ->setLine($location['line']) + ->setColumn($location['column']), + $error->locations + )); + } + + return $proto; + } + + private function elapsedNs(): int + { + if (null === $this->startHrTime) { + return 0; + } + + return hrtime(true) - $this->startHrTime; + } + + private static function toTimestamp(float $wallTime): Timestamp + { + $seconds = (int) floor($wallTime); + + return new Timestamp([ + 'seconds' => $seconds, + 'nanos' => (int) round(($wallTime - $seconds) * 1_000_000_000), + ]); + } +} diff --git a/src/Federation/Types/CacheControlScopeType.php b/src/Federation/Types/CacheControlScopeType.php new file mode 100644 index 00000000..beead1cf --- /dev/null +++ b/src/Federation/Types/CacheControlScopeType.php @@ -0,0 +1,33 @@ + self::getTypeName(), + 'values' => [ + CacheScope::PUBLIC => ['value' => CacheScope::PUBLIC], + CacheScope::PRIVATE => [ + 'value' => CacheScope::PRIVATE, + 'description' => 'The value is specific to a single user.', + ], + ], + ]); + } + + public static function getTypeName(): string + { + return 'CacheControlScope'; + } +} diff --git a/src/Federation/Utils/FederatedSchemaPrinter.php b/src/Federation/Utils/FederatedSchemaPrinter.php index 70a71696..9129b0fd 100644 --- a/src/Federation/Utils/FederatedSchemaPrinter.php +++ b/src/Federation/Utils/FederatedSchemaPrinter.php @@ -36,6 +36,7 @@ use GraphQL\Language\Printer; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; +use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; @@ -47,6 +48,7 @@ use GraphQL\Utils\AST; use GraphQL\Utils\Utils; +use Redeye\GraphQLBundle\Federation\Types\CacheControlScopeType; use Redeye\GraphQLBundle\Federation\Types\EntityObjectType; use Redeye\GraphQLBundle\Federation\Types\EntityRefObjectType; @@ -89,15 +91,25 @@ static function ($type) { return !Directive::isSpecifiedDirective($type) && !self::isFederatedDirective($type); }, static function ($type) { - return !Type::isBuiltInType($type); + // CacheControlScope reaches the type map through the @cacheControl argument, but + // the prelude already declares it; printing it again is a duplicate definition. + return !Type::isBuiltInType($type) && CacheControlScopeType::getTypeName() !== $type->name; }, $options ); } + /** + * Directives whose definition must not be printed into the SDL. + * + * The federation ones arrive through the `@link` import, and printing a definition alongside + * the import is a composition error. `@cacheControl` is in here for a different reason: it is + * declared by hand in the prelude, so that its `CacheControlScope` argument type is guaranteed + * to be declared too. + */ public static function isFederatedDirective($type): bool { - return in_array($type->name, ['key', 'provides', 'requires', 'external', 'shareable', 'override']); + return in_array($type->name, ['key', 'provides', 'requires', 'external', 'shareable', 'override', 'cacheTag', 'cacheControl']); } /** @@ -113,10 +125,21 @@ private static function printFilteredSchema(Schema $schema, $directiveFilter, $t ksort($types); $types = array_filter($types, $typeFilter); + // @cacheTag is a federation-spec directive as of v2.12, so it arrives through the @link + // import and must not be given a local definition. @cacheControl is not part of the spec — + // the router only ever reads the Cache-Control header for it — so it is declared here, + // together with the enum its `scope` argument refers to. $federationVersion = <<name === 'Query' || $type->name === 'Mutation' ? 'extend ' : ''; return self::printDescription($options, $type) . @@ -428,6 +453,8 @@ private static function printEntityObject(EntityObjectType $type, array $options $federationDirectives .= ' @external'; } + $federationDirectives .= self::spaced(self::printCacheDirectives($type)); + $isEntityRef = false; //$type instanceof EntityRefObjectType; $extends = $isEntityRef ? 'extend ' : ''; @@ -512,16 +539,72 @@ private static function printFieldFederatedDirectives($field) array_push($directives, sprintf('@override(from: "%s")', $field->config['override'])); } + $cacheDirectives = self::printCacheDirectives($field); + if ('' !== $cacheDirectives) { + array_push($directives, $cacheDirectives); + } + return implode(' ', $directives); } + /** + * Prints the `@cacheControl` and `@cacheTag` applications on a field or a type. + * + * @param FieldDefinition|Type $definition + */ + private static function printCacheDirectives($definition): string + { + $directives = []; + + if (isset($definition->config['cacheControl'])) { + $args = []; + $cacheControl = $definition->config['cacheControl']; + + if (isset($cacheControl['maxAge'])) { + $args[] = sprintf('maxAge: %d', $cacheControl['maxAge']); + } + + if (isset($cacheControl['scope'])) { + // An enum value, so it is printed bare rather than quoted. + $args[] = sprintf('scope: %s', $cacheControl['scope']); + } + + if (isset($cacheControl['inheritMaxAge'])) { + $args[] = sprintf('inheritMaxAge: %s', $cacheControl['inheritMaxAge'] ? 'true' : 'false'); + } + + if (!empty($args)) { + $directives[] = sprintf('@cacheControl(%s)', implode(', ', $args)); + } + } + + foreach ($definition->config['cacheTags'] ?? [] as $format) { + $directives[] = sprintf('@cacheTag(format: %s)', Printer::doPrint(AST::astFromValue($format, Type::string()))); + } + + return implode(' ', $directives); + } + + /** + * Prefixes a directive list with a space so it can be appended to a type name. + */ + private static function spaced(string $directives): string + { + return '' === $directives ? '' : ' '.$directives; + } + /** * @param bool[] $options */ private static function printInterface(InterfaceType $type, array $options): string { return self::printDescription($options, $type) . - sprintf("interface %s {\n%s\n}", $type->name, self::printFields($options, $type)); + sprintf( + "interface %s%s {\n%s\n}", + $type->name, + self::spaced(self::printCacheDirectives($type)), + self::printFields($options, $type) + ); } /** @@ -529,8 +612,14 @@ private static function printInterface(InterfaceType $type, array $options): str */ private static function printUnion(UnionType $type, array $options): string { + // Directives on a union go before the `=`. return self::printDescription($options, $type) . - sprintf('union %s = %s', $type->name, implode(' | ', $type->getTypes())); + sprintf( + 'union %s%s = %s', + $type->name, + self::spaced(self::printCacheDirectives($type)), + implode(' | ', $type->getTypes()) + ); } /** diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index d58c655d..9c165bee 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -108,6 +108,8 @@ public function __construct(ExpressionConverter $expressionConverter, string $na * serialize?: callable, * parseValue?: callable, * parseLiteral?: callable, + * cacheControl?: array, + * cacheTags?: array, * } $config * * @throws GeneratorException @@ -328,6 +330,14 @@ protected function buildConfig(array $config): Collection $configLoader->addItem('__resolveReference', $this->buildResolveReference($c->resolveReference)); } + if (!empty($c->cacheControl)) { + $configLoader->addItem('cacheControl', $c->cacheControl); + } + + if (!empty($c->cacheTags)) { + $configLoader->addItem('cacheTags', $c->cacheTags); + } + // only by object, input-object and interface types if (!empty($c->fields)) { $configLoader->addItem('fields', ArrowFunction::new( @@ -733,6 +743,8 @@ protected function buildConstraints(array $constraints = [], bool $inClosure = t * complexity?: string, * deprecatedReason?: string, * validation?: array, + * cacheControl?: array, + * cacheTags?: array, * } $fieldConfig * * @internal @@ -814,6 +826,14 @@ public function buildField(array $fieldConfig, string $fieldname) $field->addItem('requires', $c->requires); } + if (!empty($c->cacheControl)) { + $field->addItem('cacheControl', $c->cacheControl); + } + + if (!empty($c->cacheTags)) { + $field->addItem('cacheTags', $c->cacheTags); + } + if ('input-object' === $this->type) { if (property_exists($c, 'defaultValue')) { $field->addItem('defaultValue', $c->defaultValue); diff --git a/src/Resources/config/listeners.yaml b/src/Resources/config/listeners.yaml index 1f06d44f..89ba48fc 100644 --- a/src/Resources/config/listeners.yaml +++ b/src/Resources/config/listeners.yaml @@ -11,6 +11,14 @@ services: tags: - { name: kernel.event_listener, event: graphql.type_loaded, method: onTypeLoaded } + Redeye\GraphQLBundle\EventListener\CacheControlListener: + arguments: + - '@Redeye\GraphQLBundle\CacheControl\CacheControlAccumulator' + tags: + - { name: kernel.event_listener, event: graphql.executor.context, method: onExecutorContext } + # Runs after the error-handling listeners so it sees the final error list. + - { name: kernel.event_listener, event: graphql.post_executor, method: onPostExecutor, priority: -256 } + Redeye\GraphQLBundle\EventListener\ValidationErrorsListener: tags: - name: kernel.event_listener diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 7cfa80b0..2d509288 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -111,6 +111,19 @@ services: - '@Redeye\GraphQLBundle\Request\Parser' - "%redeye_graphql.handle_cors%" - "%redeye_graphql.batching_method%" + - '@Redeye\GraphQLBundle\CacheControl\CacheControlAccumulator' + + Redeye\GraphQLBundle\CacheControl\CacheHintResolver: ~ + + Redeye\GraphQLBundle\CacheControl\CacheControlAccumulator: + arguments: + - '@Redeye\GraphQLBundle\CacheControl\CacheHintResolver' + - "%redeye_graphql.cache_control.enabled%" + - "%redeye_graphql.cache_control.default_max_age%" + - "%redeye_graphql.cache_control.calculate_http_headers%" + tags: + # Only matters under a worker runtime, where the container outlives the request. + - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 512 } Redeye\GraphQLBundle\Definition\ConfigProcessor: arguments: diff --git a/tests/CacheControl/CacheControlAccumulatorTest.php b/tests/CacheControl/CacheControlAccumulatorTest.php new file mode 100644 index 00000000..e978fc62 --- /dev/null +++ b/tests/CacheControl/CacheControlAccumulatorTest.php @@ -0,0 +1,179 @@ +accumulator(false); + $this->runOperation($accumulator, ['maxAge' => 60]); + + $this->assertSame(self::UNTOUCHED, $this->header($accumulator)); + } + + public function testCacheableResponse(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 60]); + + $this->assertSame('max-age=60, public', $this->header($accumulator)); + } + + public function testPrivateScope(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 60, 'scope' => CacheScope::PRIVATE]); + + $this->assertSame('max-age=60, private', $this->header($accumulator)); + } + + public function testZeroMaxAgeIsUncacheable(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 0]); + + $this->assertSame(self::NO_STORE, $this->header($accumulator)); + } + + public function testNoOperationAtAllIsUncacheable(): void + { + $this->assertSame(self::NO_STORE, $this->header($this->accumulator())); + } + + public function testErrorsMakeTheResponseUncacheable(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 60], true); + + $this->assertSame(self::NO_STORE, $this->header($accumulator)); + } + + public function testErrorsInOneBatchedOperationPoisonTheWholeResponse(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 60], true); + $this->runOperation($accumulator, ['maxAge' => 60]); + + $this->assertSame(self::NO_STORE, $this->header($accumulator)); + } + + public function testBatchTakesTheMostRestrictiveMaxAge(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 60]); + $this->runOperation($accumulator, ['maxAge' => 30]); + + $this->assertSame('max-age=30, public', $this->header($accumulator)); + } + + public function testBatchTakesTheMostRestrictiveScope(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 60]); + $this->runOperation($accumulator, ['maxAge' => 60, 'scope' => CacheScope::PRIVATE]); + + $this->assertSame('max-age=60, private', $this->header($accumulator)); + } + + public function testIfCacheableLeavesTheHeaderAloneInsteadOfNoStore(): void + { + $accumulator = $this->accumulator(true, 0, CacheControlAccumulator::HEADERS_IF_CACHEABLE); + $this->runOperation($accumulator, ['maxAge' => 0]); + + $this->assertSame(self::UNTOUCHED, $this->header($accumulator)); + } + + public function testIfCacheableStillEmitsACacheableHeader(): void + { + $accumulator = $this->accumulator(true, 0, CacheControlAccumulator::HEADERS_IF_CACHEABLE); + $this->runOperation($accumulator, ['maxAge' => 60]); + + $this->assertSame('max-age=60, public', $this->header($accumulator)); + } + + public function testNeverLeavesTheHeaderAloneEvenWhenCacheable(): void + { + $accumulator = $this->accumulator(true, 0, CacheControlAccumulator::HEADERS_NEVER); + $this->runOperation($accumulator, ['maxAge' => 60]); + + $this->assertSame(self::UNTOUCHED, $this->header($accumulator)); + } + + public function testResetClearsThePolicyAndTheErrorFlag(): void + { + $accumulator = $this->accumulator(); + $this->runOperation($accumulator, ['maxAge' => 60], true); + + $accumulator->reset(); + $this->runOperation($accumulator, ['maxAge' => 30]); + + $this->assertSame('max-age=30, public', $this->header($accumulator)); + } + + public function testDefaultMaxAgeIsUsedForUnannotatedTypes(): void + { + $accumulator = $this->accumulator(true, 45); + $this->runOperation($accumulator, null); + + $this->assertSame('max-age=45, public', $this->header($accumulator)); + } + + private function accumulator( + bool $enabled = true, + int $defaultMaxAge = 0, + string $mode = CacheControlAccumulator::HEADERS_ALWAYS + ): CacheControlAccumulator { + return new CacheControlAccumulator(new CacheHintResolver(), $enabled, $defaultMaxAge, $mode); + } + + private function runOperation(CacheControlAccumulator $accumulator, ?array $cacheControl, bool $hasErrors = false): void + { + $accumulator->startOperation()->recordType($this->objectType($cacheControl)); + $accumulator->finishOperation($hasErrors); + } + + private function objectType(?array $cacheControl): ObjectType + { + $config = [ + 'name' => 'Node'.(++$this->typeCounter), + 'fields' => ['id' => Type::string()], + ]; + + if (null !== $cacheControl) { + $config['cacheControl'] = $cacheControl; + } + + return new ObjectType($config); + } + + private function header(CacheControlAccumulator $accumulator): ?string + { + $response = new Response(); + $accumulator->applyTo($response); + + return $response->headers->get('Cache-Control'); + } +} diff --git a/tests/CacheControl/CacheControlRecorderTest.php b/tests/CacheControl/CacheControlRecorderTest.php new file mode 100644 index 00000000..1b2e789f --- /dev/null +++ b/tests/CacheControl/CacheControlRecorderTest.php @@ -0,0 +1,248 @@ + [0]; + yield 'defaultMaxAge 60' => [60]; + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testUnannotatedNonRootScalarContributesNothing(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field(Type::string()), false)); + + $this->assertNull($recorder->getPolicy()->getMaxAge()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testUnannotatedRootScalarGetsTheDefault(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field(Type::string()), true)); + + $this->assertSame($default, $recorder->getPolicy()->getMaxAge()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testUnannotatedNonRootCompositeGetsTheDefault(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field($this->objectType()), false)); + + $this->assertSame($default, $recorder->getPolicy()->getMaxAge()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testCompositeWithInheritMaxAgeOnTheTypeContributesNothing(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field($this->objectType(['inheritMaxAge' => true])), false)); + + $this->assertNull($recorder->getPolicy()->getMaxAge()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testRootFieldGetsTheDefaultEvenWithInheritMaxAge(int $default): void + { + // Verified against Apollo's source: the root-field condition is an `||`, so a root field is + // defaulted regardless of inheritMaxAge. There is no parent to inherit from. + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field($this->objectType(['inheritMaxAge' => true])), true)); + + $this->assertSame($default, $recorder->getPolicy()->getMaxAge()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testExplicitMaxAgeWins(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field($this->objectType(), ['maxAge' => 30]), false)); + + $this->assertSame(30, $recorder->getPolicy()->getMaxAge()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testExplicitZeroIsNotUnset(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field($this->objectType(), ['maxAge' => 0]), false)); + + $this->assertSame(0, $recorder->getPolicy()->getMaxAge()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testScopeOnlyOnANonRootScalarLeavesMaxAgeUndecided(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordField($this->info($this->field(Type::string(), ['scope' => CacheScope::PRIVATE]), false)); + + $this->assertNull($recorder->getPolicy()->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $recorder->getPolicy()->getScope()); + } + + public function testFieldInheritMaxAgeContributesNothing(): void + { + $recorder = $this->recorder(60); + $recorder->recordField($this->info( + $this->field($this->objectType(), ['inheritMaxAge' => true]), + false + )); + + $this->assertNull($recorder->getPolicy()->getMaxAge()); + } + + public function testPolicyIsTheMinimumAcrossFields(): void + { + $recorder = $this->recorder(0); + $recorder->recordField($this->info($this->field($this->objectType(), ['maxAge' => 90]), true)); + $recorder->recordField($this->info($this->field($this->objectType(), ['maxAge' => 30]), false)); + $recorder->recordField($this->info($this->field($this->objectType(), ['maxAge' => 60]), false)); + + $this->assertSame(30, $recorder->getPolicy()->getMaxAge()); + } + + public function testRecordingStopsOnceFullyRestricted(): void + { + $recorder = $this->recorder(0); + + // maxAge 0 + PRIVATE cannot be restricted further. + $recorder->recordField($this->info( + $this->field($this->objectType(), ['maxAge' => 0, 'scope' => CacheScope::PRIVATE]), + true + )); + + $this->assertTrue($recorder->getPolicy()->isFullyRestricted()); + + // Anything after this is ignored, which is what makes the hot path cheap. + $recorder->recordType($this->objectType(['maxAge' => 500, 'scope' => CacheScope::PUBLIC])); + + $this->assertSame(0, $recorder->getPolicy()->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $recorder->getPolicy()->getScope()); + } + + public function testSelfHintedFieldsAreSkipped(): void + { + // `_entities` is a root field, so without this exemption it would pin every federated + // entity fetch at maxAge 0 and no entity response could ever be cached. + $field = $this->field($this->objectType(), null, [CacheControlRecorder::SELF_HINTED_CONFIG_KEY => true]); + + $recorder = $this->recorder(0); + $recorder->recordField($this->info($field, true)); + + $this->assertNull($recorder->getPolicy()->getMaxAge()); + } + + public function testRecordTypeAppliesTheConcreteTypeHint(): void + { + $recorder = $this->recorder(0); + $recorder->recordType($this->objectType(['maxAge' => 120, 'scope' => CacheScope::PRIVATE])); + + $this->assertSame(120, $recorder->getPolicy()->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $recorder->getPolicy()->getScope()); + } + + /** + * @dataProvider defaultMaxAgeProvider + */ + public function testRecordTypeFallsBackToTheDefault(int $default): void + { + $recorder = $this->recorder($default); + $recorder->recordType($this->objectType()); + + $this->assertSame($default, $recorder->getPolicy()->getMaxAge()); + } + + private function recorder(int $defaultMaxAge): CacheControlRecorder + { + return new CacheControlRecorder(new CacheHintResolver(), $defaultMaxAge); + } + + private function objectType(?array $cacheControl = null): ObjectType + { + $config = [ + 'name' => 'Node'.(++$this->typeCounter), + 'fields' => ['id' => Type::string()], + ]; + + if (null !== $cacheControl) { + $config['cacheControl'] = $cacheControl; + } + + return new ObjectType($config); + } + + private function field(Type $type, ?array $cacheControl = null, array $extraConfig = []): FieldDefinition + { + $fieldConfig = ['type' => $type] + $extraConfig; + + if (null !== $cacheControl) { + $fieldConfig['cacheControl'] = $cacheControl; + } + + $parent = new ObjectType([ + 'name' => 'Parent'.(++$this->typeCounter), + 'fields' => ['subject' => $fieldConfig], + ]); + + return $parent->getField('subject'); + } + + private function info(FieldDefinition $field, bool $root): ResolveInfo + { + $queryType = new ObjectType([ + 'name' => 'Query'.(++$this->typeCounter), + 'fields' => ['dummy' => Type::string()], + ]); + + return new ResolveInfo( + $field, + [], + $queryType, + $root ? ['subject'] : ['parent', 'subject'], + new Schema(['query' => $queryType]), + [], + null, + null, + [] + ); + } +} diff --git a/tests/CacheControl/CacheHintResolverTest.php b/tests/CacheControl/CacheHintResolverTest.php new file mode 100644 index 00000000..77399418 --- /dev/null +++ b/tests/CacheControl/CacheHintResolverTest.php @@ -0,0 +1,249 @@ +resolver = new CacheHintResolver(); + } + + public function testUnannotatedScalarField(): void + { + $hint = $this->resolver->forField($this->fieldReturning(Type::string())); + + $this->assertNull($hint->getMaxAge()); + $this->assertNull($hint->getScope()); + $this->assertFalse($hint->shouldInheritMaxAge()); + $this->assertFalse($hint->returnsCompositeType()); + } + + public function testCompositeReturnTypeIsDetected(): void + { + $hint = $this->resolver->forField($this->fieldReturning($this->objectType())); + + $this->assertTrue($hint->returnsCompositeType()); + } + + public function testAnnotationOnTheReturnTypeIsUsed(): void + { + $type = $this->objectType(['maxAge' => 60, 'scope' => CacheScope::PRIVATE]); + + $hint = $this->resolver->forField($this->fieldReturning($type)); + + $this->assertSame(60, $hint->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $hint->getScope()); + } + + public function testAnnotationOnAScalarReturnTypeIsIrrelevant(): void + { + // Only composite return types carry a type-level annotation, so a scalar field is decided + // purely by its own annotation. + $hint = $this->resolver->forField($this->fieldReturning(Type::string())); + + $this->assertNull($hint->getMaxAge()); + } + + public function testFieldAnnotationOverridesTheTypeAnnotation(): void + { + $type = $this->objectType(['maxAge' => 60]); + + $hint = $this->resolver->forField($this->fieldReturning($type, ['maxAge' => 30])); + + $this->assertSame(30, $hint->getMaxAge()); + } + + public function testFieldAnnotationReplacesOnlyTheKeysItSets(): void + { + // A field saying only `maxAge` must keep the scope declared on its return type. Treating + // the field annotation as a wholesale replacement silently loses PRIVATE here. + $type = $this->objectType(['maxAge' => 60, 'scope' => CacheScope::PRIVATE]); + + $hint = $this->resolver->forField($this->fieldReturning($type, ['maxAge' => 30])); + + $this->assertSame(30, $hint->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $hint->getScope()); + } + + public function testFieldScopeOnlyKeepsTheTypeMaxAge(): void + { + $type = $this->objectType(['maxAge' => 60]); + + $hint = $this->resolver->forField($this->fieldReturning($type, ['scope' => CacheScope::PRIVATE])); + + $this->assertSame(60, $hint->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $hint->getScope()); + } + + public function testInheritMaxAgeOnTheType(): void + { + $type = $this->objectType(['inheritMaxAge' => true]); + + $hint = $this->resolver->forField($this->fieldReturning($type)); + + $this->assertTrue($hint->shouldInheritMaxAge()); + $this->assertNull($hint->getMaxAge()); + } + + public function testInheritMaxAgeOnTheField(): void + { + $hint = $this->resolver->forField( + $this->fieldReturning($this->objectType(), ['inheritMaxAge' => true]) + ); + + $this->assertTrue($hint->shouldInheritMaxAge()); + $this->assertNull($hint->getMaxAge()); + } + + public function testInheritMaxAgeOnTheFieldStillAppliesItsScope(): void + { + $hint = $this->resolver->forField($this->fieldReturning( + $this->objectType(), + ['inheritMaxAge' => true, 'scope' => CacheScope::PRIVATE] + )); + + $this->assertTrue($hint->shouldInheritMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $hint->getScope()); + } + + public function testInheritMaxAgeOnTheFieldIsIgnoredWhenTheTypeSetsMaxAge(): void + { + // The type already decided a maxAge, so there is nothing left to inherit. + $type = $this->objectType(['maxAge' => 60]); + + $hint = $this->resolver->forField($this->fieldReturning($type, ['inheritMaxAge' => true])); + + $this->assertFalse($hint->shouldInheritMaxAge()); + $this->assertSame(60, $hint->getMaxAge()); + } + + public function testExplicitZeroIsNotTreatedAsUnset(): void + { + $type = $this->objectType(['maxAge' => 60]); + + $hint = $this->resolver->forField($this->fieldReturning($type, ['maxAge' => 0])); + + $this->assertSame(0, $hint->getMaxAge()); + } + + /** + * @dataProvider wrappedTypeProvider + */ + public function testListAndNonNullWrappersAreUnwrapped(callable $wrap): void + { + $type = $this->objectType(['maxAge' => 60]); + + $hint = $this->resolver->forField($this->fieldReturning($wrap($type))); + + $this->assertSame(60, $hint->getMaxAge()); + $this->assertTrue($hint->returnsCompositeType()); + } + + public function wrappedTypeProvider(): iterable + { + yield 'non-null' => [fn (Type $t) => Type::nonNull($t)]; + yield 'list' => [fn (Type $t) => Type::listOf($t)]; + yield 'non-null list of non-null' => [fn (Type $t) => Type::nonNull(Type::listOf(Type::nonNull($t)))]; + yield 'list of list' => [fn (Type $t) => Type::listOf(Type::listOf($t))]; + } + + public function testInterfaceTypedFieldReadsTheInterfaceAnnotation(): void + { + $interface = new InterfaceType([ + 'name' => 'Node', + 'fields' => ['id' => Type::string()], + 'cacheControl' => ['maxAge' => 45, 'scope' => CacheScope::PRIVATE], + ]); + + $hint = $this->resolver->forField($this->fieldReturning($interface)); + + $this->assertSame(45, $hint->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $hint->getScope()); + $this->assertTrue($hint->returnsCompositeType()); + } + + public function testUnionTypedFieldReadsTheUnionAnnotation(): void + { + $union = new UnionType([ + 'name' => 'SearchResult', + 'types' => [$this->objectType(['maxAge' => 5])], + 'cacheControl' => ['maxAge' => 90], + ]); + + $hint = $this->resolver->forField($this->fieldReturning($union)); + + // The declared union's annotation wins; the member type's is not consulted statically. + $this->assertSame(90, $hint->getMaxAge()); + $this->assertTrue($hint->returnsCompositeType()); + } + + public function testHintsAreMemoizedPerFieldDefinition(): void + { + $field = $this->fieldReturning($this->objectType(['maxAge' => 60])); + + $this->assertSame($this->resolver->forField($field), $this->resolver->forField($field)); + } + + public function testForTypeReadsTheAnnotationDirectly(): void + { + $hint = $this->resolver->forType($this->objectType(['maxAge' => 15, 'scope' => CacheScope::PUBLIC])); + + $this->assertSame(15, $hint->getMaxAge()); + $this->assertSame(CacheScope::PUBLIC, $hint->getScope()); + } + + public function testForTypeOnAnUnannotatedType(): void + { + $this->assertTrue($this->resolver->forType($this->objectType())->isEmpty()); + } + + private function objectType(?array $cacheControl = null): ObjectType + { + $config = [ + 'name' => 'Post'.(++$this->typeCounter), + 'fields' => ['id' => Type::string()], + ]; + + if (null !== $cacheControl) { + $config['cacheControl'] = $cacheControl; + } + + return new ObjectType($config); + } + + private function fieldReturning(Type $type, ?array $cacheControl = null): FieldDefinition + { + $fieldConfig = ['type' => $type]; + + if (null !== $cacheControl) { + $fieldConfig['cacheControl'] = $cacheControl; + } + + $parent = new ObjectType([ + 'name' => 'Parent'.(++$this->typeCounter), + 'fields' => ['subject' => $fieldConfig], + ]); + + return $parent->getField('subject'); + } +} diff --git a/tests/CacheControl/CachePolicyTest.php b/tests/CacheControl/CachePolicyTest.php new file mode 100644 index 00000000..cd33c41f --- /dev/null +++ b/tests/CacheControl/CachePolicyTest.php @@ -0,0 +1,119 @@ +assertNull($policy->getMaxAge()); + $this->assertNull($policy->getScope()); + $this->assertFalse($policy->isCacheable()); + } + + public function testKeepsTheSmallestMaxAge(): void + { + $policy = new CachePolicy(); + $policy->restrict(60, null); + $policy->restrict(30, null); + $policy->restrict(90, null); + + $this->assertSame(30, $policy->getMaxAge()); + } + + public function testANullMaxAgeContributesNothing(): void + { + // This is how "inherit the parent's maxAge" works: an undecided field must not lower the + // policy, so whatever its ancestors decided still stands. + $policy = new CachePolicy(); + $policy->restrict(60, null); + $policy->restrict(null, null); + + $this->assertSame(60, $policy->getMaxAge()); + } + + public function testPrivateWinsAndIsSticky(): void + { + $policy = new CachePolicy(); + $policy->restrict(null, CacheScope::PUBLIC); + $policy->restrict(null, CacheScope::PRIVATE); + $policy->restrict(null, CacheScope::PUBLIC); + + $this->assertSame(CacheScope::PRIVATE, $policy->getScope()); + } + + public function testANullScopeContributesNothing(): void + { + $policy = new CachePolicy(); + $policy->restrict(null, CacheScope::PRIVATE); + $policy->restrict(null, null); + + $this->assertSame(CacheScope::PRIVATE, $policy->getScope()); + } + + public function testZeroMaxAgeIsNotCacheable(): void + { + $policy = new CachePolicy(); + $policy->restrict(0, null); + + $this->assertSame(0, $policy->getMaxAge()); + $this->assertFalse($policy->isCacheable()); + } + + public function testIsCacheableOnlyAboveZero(): void + { + $policy = new CachePolicy(); + $policy->restrict(1, null); + + $this->assertTrue($policy->isCacheable()); + } + + public function testIsFullyRestrictedNeedsBothMaxAgeZeroAndPrivate(): void + { + $policy = new CachePolicy(); + $policy->restrict(0, null); + $this->assertFalse($policy->isFullyRestricted(), 'scope can still degrade to PRIVATE'); + + $policy->restrict(null, CacheScope::PRIVATE); + $this->assertTrue($policy->isFullyRestricted()); + } + + public function testRestrictByAnotherPolicy(): void + { + $a = new CachePolicy(); + $a->restrict(60, CacheScope::PUBLIC); + + $b = new CachePolicy(); + $b->restrict(30, CacheScope::PRIVATE); + + $a->restrictBy($b); + + $this->assertSame(30, $a->getMaxAge()); + $this->assertSame(CacheScope::PRIVATE, $a->getScope()); + } + + public function testReset(): void + { + $policy = new CachePolicy(); + $policy->restrict(30, CacheScope::PRIVATE); + $policy->reset(); + + $this->assertNull($policy->getMaxAge()); + $this->assertNull($policy->getScope()); + } + + public function testUnsetScopeRendersAsPublic(): void + { + $this->assertSame('public', CacheScope::toHeaderToken(null)); + $this->assertSame('public', CacheScope::toHeaderToken(CacheScope::PUBLIC)); + $this->assertSame('private', CacheScope::toHeaderToken(CacheScope::PRIVATE)); + } +} diff --git a/tests/Config/Parser/GraphQLParserCacheControlTest.php b/tests/Config/Parser/GraphQLParserCacheControlTest.php new file mode 100644 index 00000000..464a8257 --- /dev/null +++ b/tests/Config/Parser/GraphQLParserCacheControlTest.php @@ -0,0 +1,90 @@ +assertContainerAddFileToResources($fileName); + $config = GraphQLParser::parse(new SplFileInfo($fileName), $this->containerBuilder); + + $this->assertSame($expected, self::cleanConfig($config)); + } + + /** + * The directive definitions in the fixture must be accepted and then ignored — the bundle + * supplies the canonical ones itself. + */ + public function testDirectiveDefinitionsAreNotTurnedIntoTypes(): void + { + $fileName = __DIR__.'/fixtures/graphql/cache-control.graphql'; + $config = GraphQLParser::parse(new SplFileInfo($fileName), $this->containerBuilder); + + $this->assertArrayNotHasKey('cacheControl', $config); + $this->assertArrayNotHasKey('cacheTag', $config); + } + + /** + * @dataProvider invalidUsageProvider + */ + public function testInvalidUsageIsRejected(string $directives, string $expectedMessage): void + { + $node = Parser::parse(sprintf('type Foo { bar: String %s }', $directives))->definitions[0]->fields[0]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($expectedMessage); + + CacheDirectivesNode::toConfig($node); + } + + public function invalidUsageProvider(): iterable + { + yield 'maxAge as a string' => [ + '@cacheControl(maxAge: "60")', + 'Argument "maxAge" of @cacheControl must be an Int', + ]; + + yield 'unknown scope' => [ + '@cacheControl(scope: MAYBE)', + 'Argument "scope" of @cacheControl must be one of PUBLIC, PRIVATE, got "MAYBE".', + ]; + + yield 'inheritMaxAge as a string' => [ + '@cacheControl(inheritMaxAge: "yes")', + 'Argument "inheritMaxAge" of @cacheControl must be a Boolean', + ]; + + yield 'maxAge together with inheritMaxAge' => [ + '@cacheControl(maxAge: 5, inheritMaxAge: true)', + '@cacheControl cannot set both "maxAge" and "inheritMaxAge".', + ]; + + yield 'repeated cacheControl' => [ + '@cacheControl(maxAge: 5) @cacheControl(maxAge: 10)', + '@cacheControl is not repeatable.', + ]; + + yield 'cacheTag without a format' => [ + '@cacheTag', + 'Argument "format" is required on the @cacheTag directive.', + ]; + + yield 'cacheTag format as an enum' => [ + '@cacheTag(format: POSTS)', + 'Argument "format" of @cacheTag must be a String', + ]; + } +} diff --git a/tests/Config/Parser/fixtures/graphql/cache-control.graphql b/tests/Config/Parser/fixtures/graphql/cache-control.graphql new file mode 100644 index 00000000..5492a5a2 --- /dev/null +++ b/tests/Config/Parser/fixtures/graphql/cache-control.graphql @@ -0,0 +1,27 @@ +directive @cacheControl(maxAge: Int, scope: CacheControlScope, inheritMaxAge: Boolean) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + +directive @cacheTag(format: String!) repeatable on FIELD_DEFINITION | OBJECT + +type Query { + posts: [Post!]! @cacheControl(maxAge: 60) @cacheTag(format: "posts") + me: User @cacheControl(scope: PRIVATE, maxAge: 10) + legacy: String @deprecated(reason: "Use posts instead") @cacheControl(maxAge: 5) + quoted: String @cacheControl(scope: "PRIVATE") +} + +type Post @cacheControl(maxAge: 120) @cacheTag(format: "post-{$key.id}") @cacheTag(format: "post") { + id: ID! + title: String! + author: User @cacheControl(inheritMaxAge: true) +} + +type User @cacheControl(inheritMaxAge: true, scope: PRIVATE) { + id: ID! + name: String! +} + +interface Node @cacheControl(maxAge: 30) { + id: ID! +} + +union SearchResult @cacheControl(maxAge: 15) = Post | User diff --git a/tests/Config/Parser/fixtures/graphql/cache-control.php b/tests/Config/Parser/fixtures/graphql/cache-control.php new file mode 100644 index 00000000..c84e963a --- /dev/null +++ b/tests/Config/Parser/fixtures/graphql/cache-control.php @@ -0,0 +1,101 @@ + [ + 'type' => 'object', + 'config' => [ + 'description' => null, + 'fields' => [ + 'posts' => [ + 'type' => '[Post!]!', + 'description' => null, + 'cacheControl' => ['maxAge' => 60], + 'cacheTags' => ['posts'], + ], + // Arguments are matched by name, so declaration order is irrelevant. + 'me' => [ + 'type' => 'User', + 'description' => null, + 'cacheControl' => ['maxAge' => 10, 'scope' => 'PRIVATE'], + ], + // Directives after @deprecated must survive. + 'legacy' => [ + 'type' => 'String', + 'description' => null, + 'deprecationReason' => 'Use posts instead', + 'cacheControl' => ['maxAge' => 5], + ], + // A quoted enum value is accepted, since tooling emits it both ways. + 'quoted' => [ + 'type' => 'String', + 'description' => null, + 'cacheControl' => ['scope' => 'PRIVATE'], + ], + ], + ], + ], + 'Post' => [ + 'type' => 'object', + 'config' => [ + 'description' => null, + 'fields' => [ + 'id' => [ + 'type' => 'ID!', + 'description' => null, + ], + 'title' => [ + 'type' => 'String!', + 'description' => null, + ], + 'author' => [ + 'type' => 'User', + 'description' => null, + 'cacheControl' => ['inheritMaxAge' => true], + ], + ], + 'cacheControl' => ['maxAge' => 120], + // @cacheTag is repeatable. + 'cacheTags' => ['post-{$key.id}', 'post'], + ], + ], + 'User' => [ + 'type' => 'object', + 'config' => [ + 'description' => null, + 'fields' => [ + 'id' => [ + 'type' => 'ID!', + 'description' => null, + ], + 'name' => [ + 'type' => 'String!', + 'description' => null, + ], + ], + 'cacheControl' => ['scope' => 'PRIVATE', 'inheritMaxAge' => true], + ], + ], + 'Node' => [ + 'type' => 'interface', + 'config' => [ + 'description' => null, + 'fields' => [ + 'id' => [ + 'type' => 'ID!', + 'description' => null, + ], + ], + 'cacheControl' => ['maxAge' => 30], + ], + ], + 'SearchResult' => [ + 'type' => 'union', + 'config' => [ + 'description' => null, + 'types' => ['Post', 'User'], + 'cacheControl' => ['maxAge' => 15], + ], + ], +]; diff --git a/tests/DependencyInjection/CacheControlTypesConfigurationTest.php b/tests/DependencyInjection/CacheControlTypesConfigurationTest.php new file mode 100644 index 00000000..0fd91410 --- /dev/null +++ b/tests/DependencyInjection/CacheControlTypesConfigurationTest.php @@ -0,0 +1,142 @@ +process([ + 'Query' => [ + 'type' => 'object', + 'config' => [ + 'fields' => [ + 'posts' => [ + 'type' => '[Post]', + 'cacheControl' => ['maxAge' => 60, 'scope' => 'PRIVATE'], + 'cacheTags' => ['posts'], + ], + ], + ], + ], + ]); + + $field = $config['Query']['config']['fields']['posts']; + + $this->assertSame(['maxAge' => 60, 'scope' => 'PRIVATE'], $field['cacheControl']); + $this->assertSame(['posts'], $field['cacheTags']); + } + + public function testTypeLevelCacheControl(): void + { + $config = $this->process([ + 'Post' => [ + 'type' => 'object', + 'config' => [ + 'cacheControl' => ['inheritMaxAge' => true], + 'cacheTags' => 'post', + 'fields' => ['id' => ['type' => 'ID']], + ], + ], + ]); + + $this->assertSame(['inheritMaxAge' => true], $config['Post']['config']['cacheControl']); + // A single tag may be written unwrapped. + $this->assertSame(['post'], $config['Post']['config']['cacheTags']); + } + + public function testUnionCacheControl(): void + { + $config = $this->process([ + 'Content' => [ + 'type' => 'union', + 'config' => [ + 'types' => ['Post'], + 'cacheControl' => ['maxAge' => 15], + ], + ], + ]); + + $this->assertSame(['maxAge' => 15], $config['Content']['config']['cacheControl']); + } + + /** + * Unset must stay distinguishable from 0 / PUBLIC / false, or every type in the schema would + * look annotated and inheritance would break. + */ + public function testAbsentCacheControlIsNotMaterialised(): void + { + $config = $this->process([ + 'Post' => [ + 'type' => 'object', + 'config' => ['fields' => ['id' => ['type' => 'ID']]], + ], + ]); + + $this->assertArrayNotHasKey('cacheControl', $config['Post']['config']); + $this->assertArrayNotHasKey('cacheTags', $config['Post']['config']); + $this->assertArrayNotHasKey('cacheTags', $config['Post']['config']['fields']['id']); + } + + public function testMaxAgeAndInheritMaxAgeAreMutuallyExclusive(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('"cacheControl" cannot set both "maxAge" and "inheritMaxAge".'); + + $this->process([ + 'Post' => [ + 'type' => 'object', + 'config' => [ + 'cacheControl' => ['maxAge' => 5, 'inheritMaxAge' => true], + 'fields' => ['id' => ['type' => 'ID']], + ], + ], + ]); + } + + public function testUnknownScopeIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([ + 'Post' => [ + 'type' => 'object', + 'config' => [ + 'cacheControl' => ['scope' => 'MAYBE'], + 'fields' => ['id' => ['type' => 'ID']], + ], + ], + ]); + } + + public function testNegativeMaxAgeIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([ + 'Post' => [ + 'type' => 'object', + 'config' => [ + 'cacheControl' => ['maxAge' => -1], + 'fields' => ['id' => ['type' => 'ID']], + ], + ], + ]); + } + + private function process(array $config): array + { + return (new Processor())->processConfiguration(new TypesConfiguration(), [$config]); + } +} diff --git a/tests/DependencyInjection/InlineTraceConfigurationTest.php b/tests/DependencyInjection/InlineTraceConfigurationTest.php new file mode 100644 index 00000000..173c9c4d --- /dev/null +++ b/tests/DependencyInjection/InlineTraceConfigurationTest.php @@ -0,0 +1,114 @@ +> $configs + * + * @return array + */ + private function process(array $configs): array + { + return (new Processor())->processConfiguration(new Configuration(false), $configs); + } + + public function testDisabledByDefault(): void + { + $config = $this->process([[]]); + + $this->assertFalse($config['inline_trace']['enabled']); + } + + public function testEnabledByDefaultForAFederatedSchema(): void + { + $config = $this->process([['federation' => true]]); + + $this->assertTrue($config['inline_trace']['enabled']); + } + + public function testCanBeDisabledOnAFederatedSchema(): void + { + $config = $this->process([['federation' => true, 'inline_trace' => false]]); + + $this->assertFalse($config['inline_trace']['enabled']); + } + + public function testCanBeEnabledOnANonFederatedSchema(): void + { + $config = $this->process([['inline_trace' => true]]); + + $this->assertTrue($config['inline_trace']['enabled']); + } + + /** + * Configuring options must not be a back door to switching the feature on. + */ + public function testSettingOptionsAloneDoesNotEnableItOnANonFederatedSchema(): void + { + $config = $this->process([['inline_trace' => ['include_errors' => 'unmodified']]]); + + $this->assertFalse($config['inline_trace']['enabled']); + $this->assertSame('unmodified', $config['inline_trace']['include_errors']); + } + + /** + * Symfony normalizes each config fragment on its own and only then merges them, so the + * federation default has to be resolved after the merge. A `beforeNormalization` on the root + * node would see these two fragments separately and get this wrong. + */ + public function testInheritsFederationAcrossSeparateConfigFragments(): void + { + $config = $this->process([ + ['federation' => true], + ['inline_trace' => ['include_errors' => 'unmodified']], + ]); + + $this->assertTrue($config['inline_trace']['enabled']); + $this->assertSame('unmodified', $config['inline_trace']['include_errors']); + } + + public function testDefaultsToMaskedErrors(): void + { + $config = $this->process([['federation' => true]]); + + $this->assertSame(TraceErrorFilter::MASKED, $config['inline_trace']['include_errors']); + } + + public function testDefaultsToNoTransformerService(): void + { + $config = $this->process([['federation' => true]]); + + $this->assertNull($config['inline_trace']['transformer_service']); + } + + public function testAcceptsATransformerService(): void + { + $config = $this->process([['inline_trace' => ['transformer_service' => 'App\\MyTransformer']]]); + + $this->assertSame('App\\MyTransformer', $config['inline_trace']['transformer_service']); + } + + public function testNullEnablesIt(): void + { + $config = $this->process([['inline_trace' => null]]); + + $this->assertTrue($config['inline_trace']['enabled']); + } + + public function testRejectsAnUnknownIncludeErrorsMode(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([['inline_trace' => ['include_errors' => 'bogus']]]); + } +} diff --git a/tests/EventListener/InlineTraceListenerTest.php b/tests/EventListener/InlineTraceListenerTest.php new file mode 100644 index 00000000..21000b67 --- /dev/null +++ b/tests/EventListener/InlineTraceListenerTest.php @@ -0,0 +1,250 @@ +push($request); + } + + return new InlineTraceListener( + $stack, + $filter ?? new TraceErrorFilter(TraceErrorFilter::MASKED, 'Internal server Error') + ); + } + + private function requestWithHeader(?string $value): Request + { + $request = new Request(); + if (null !== $value) { + $request->headers->set('apollo-federation-include-trace', $value); + } + + return $request; + } + + private function argumentsEvent(): ExecutorArgumentsEvent + { + $schema = new ExtensibleSchema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => ['me' => ['type' => Type::string()]], + ]), + ]); + + return ExecutorArgumentsEvent::create('default', $schema, '{ me }', new ArrayObject()); + } + + private function decode(ExecutionResult $result): Trace + { + $this->assertArrayHasKey('ftv1', $result->extensions); + + $trace = new Trace(); + $trace->mergeFromString((string) base64_decode($result->extensions['ftv1'], true)); + + return $trace; + } + + public function testStoresABuilderInTheContextWhenTheHeaderRequestsATrace(): void + { + $event = $this->argumentsEvent(); + + $this->listener($this->requestWithHeader('ftv1'))->onPreExecutor($event); + + $this->assertInstanceOf( + TraceTreeBuilder::class, + $event->getContextValue()[InlineTraceListener::CONTEXT_KEY] ?? null + ); + } + + public function testIgnoresARequestWithoutTheHeader(): void + { + $event = $this->argumentsEvent(); + + $this->listener($this->requestWithHeader(null))->onPreExecutor($event); + + $this->assertArrayNotHasKey(InlineTraceListener::CONTEXT_KEY, $event->getContextValue()); + } + + /** + * @dataProvider nonMatchingHeaderProvider + */ + public function testIgnoresAHeaderWithAnotherValue(string $value): void + { + $event = $this->argumentsEvent(); + + $this->listener($this->requestWithHeader($value))->onPreExecutor($event); + + $this->assertArrayNotHasKey(InlineTraceListener::CONTEXT_KEY, $event->getContextValue()); + } + + public function nonMatchingHeaderProvider(): iterable + { + yield 'empty' => ['']; + yield 'v1' => ['v1']; + yield 'ftv2' => ['ftv2']; + yield 'padded' => [' ftv1']; + } + + /** + * RequestStack is empty under bin/console and in in-process test helpers. + */ + public function testIgnoresExecutionWithNoCurrentRequest(): void + { + $event = $this->argumentsEvent(); + + $this->listener(null)->onPreExecutor($event); + + $this->assertArrayNotHasKey(InlineTraceListener::CONTEXT_KEY, $event->getContextValue()); + } + + public function testAddsNoExtensionWhenNoTraceWasStarted(): void + { + $arguments = $this->argumentsEvent(); + $listener = $this->listener($this->requestWithHeader(null)); + $listener->onPreExecutor($arguments); + + $result = new ExecutionResult(['me' => 'Ada']); + $listener->onPostExecutor(new ExecutorResultEvent($result, $arguments)); + + $this->assertSame([], $result->extensions); + } + + public function testEmitsABase64EncodedTraceOnTheResponse(): void + { + $arguments = $this->argumentsEvent(); + $listener = $this->listener($this->requestWithHeader('ftv1')); + $listener->onPreExecutor($arguments); + + $result = new ExecutionResult(['me' => 'Ada']); + $listener->onPostExecutor(new ExecutorResultEvent($result, $arguments)); + + $trace = $this->decode($result); + $this->assertGreaterThan(0, (int) $trace->getDurationNs()); + $this->assertNotNull($trace->getStartTime()); + $this->assertNotNull($trace->getEndTime()); + $this->assertSame(1.0, $trace->getFieldExecutionWeight()); + } + + public function testAttachesAPathedErrorToItsNode(): void + { + $arguments = $this->argumentsEvent(); + $listener = $this->listener($this->requestWithHeader('ftv1')); + $listener->onPreExecutor($arguments); + + $result = new ExecutionResult(null, [ + new Error('boom', null, new Source('{ me }'), [3], ['me'], new RuntimeException('boom')), + ]); + $listener->onPostExecutor(new ExecutorResultEvent($result, $arguments)); + + $root = $this->decode($result)->getRoot(); + $this->assertCount(0, $root->getError()); + + $me = $root->getChild()[0]; + $this->assertSame('me', $me->getResponseName()); + $this->assertSame('', $me->getError()[0]->getMessage()); + } + + /** + * A syntax or validation failure never resolves a field, but must still produce a trace. + */ + public function testEmitsATraceForAnOperationThatNeverExecuted(): void + { + $arguments = $this->argumentsEvent(); + $listener = $this->listener($this->requestWithHeader('ftv1')); + $listener->onPreExecutor($arguments); + + $result = new ExecutionResult(null, [new Error('Syntax Error: Unexpected ')]); + $listener->onPostExecutor(new ExecutorResultEvent($result, $arguments)); + + $root = $this->decode($result)->getRoot(); + $this->assertCount(1, $root->getError()); + $this->assertCount(0, $root->getChild()); + } + + public function testOmitsErrorsTheFilterDrops(): void + { + $dropEverything = new class() implements TraceErrorTransformerInterface { + public function transform(Error $error): ?Error + { + return null; + } + }; + $filter = new TraceErrorFilter(TraceErrorFilter::MASKED, 'Internal server Error', $dropEverything); + + $arguments = $this->argumentsEvent(); + $listener = $this->listener($this->requestWithHeader('ftv1'), $filter); + $listener->onPreExecutor($arguments); + + $result = new ExecutionResult(null, [new Error('boom')]); + $listener->onPostExecutor(new ExecutorResultEvent($result, $arguments)); + + $this->assertCount(0, $this->decode($result)->getRoot()->getError()); + } + + /** + * Batched requests share one listener service but get one context each, so their traces must + * not bleed into one another. + */ + public function testEachExecutionGetsItsOwnTrace(): void + { + $listener = $this->listener($this->requestWithHeader('ftv1')); + + $first = $this->argumentsEvent(); + $listener->onPreExecutor($first); + $second = $this->argumentsEvent(); + $listener->onPreExecutor($second); + + $this->assertNotSame( + $first->getContextValue()[InlineTraceListener::CONTEXT_KEY], + $second->getContextValue()[InlineTraceListener::CONTEXT_KEY] + ); + + $firstResult = new ExecutionResult(null, [new Error('only in the first')]); + $listener->onPostExecutor(new ExecutorResultEvent($firstResult, $first)); + $secondResult = new ExecutionResult(['me' => 'Ada']); + $listener->onPostExecutor(new ExecutorResultEvent($secondResult, $second)); + + $this->assertCount(1, $this->decode($firstResult)->getRoot()->getError()); + $this->assertCount(0, $this->decode($secondResult)->getRoot()->getError()); + } + + public function testLeavesExistingExtensionsAlone(): void + { + $arguments = $this->argumentsEvent(); + $listener = $this->listener($this->requestWithHeader('ftv1')); + $listener->onPreExecutor($arguments); + + $result = new ExecutionResult(['me' => 'Ada']); + $result->extensions['debug'] = ['executionTime' => '1 ms']; + $listener->onPostExecutor(new ExecutorResultEvent($result, $arguments)); + + $this->assertSame(['executionTime' => '1 ms'], $result->extensions['debug']); + $this->assertArrayHasKey('ftv1', $result->extensions); + } +} diff --git a/tests/Executor/TracingReferenceExecutorTest.php b/tests/Executor/TracingReferenceExecutorTest.php new file mode 100644 index 00000000..f13fbdbe --- /dev/null +++ b/tests/Executor/TracingReferenceExecutorTest.php @@ -0,0 +1,214 @@ +previousFactory = Executor::getImplementationFactory(); + Executor::setImplementationFactory([TracingReferenceExecutor::class, 'create']); + } + + protected function tearDown(): void + { + Executor::setImplementationFactory($this->previousFactory); + } + + private function schema(): Schema + { + $user = new ObjectType([ + 'name' => 'User', + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'resolve' => static fn (array $user): string => $user['name'], + ], + 'slowName' => [ + 'type' => Type::string(), + // A deferred field: the value is not available when the resolver returns. + 'resolve' => static fn (array $user): Deferred => new Deferred(static function () use ($user): string { + usleep(3000); + + return $user['name']; + }), + ], + 'broken' => [ + 'type' => Type::string(), + 'resolve' => static function (): string { + throw new RuntimeException('resolver exploded'); + }, + ], + ], + ]); + + return new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'me' => [ + 'type' => Type::nonNull($user), + 'resolve' => static fn (): array => ['name' => 'Ada'], + ], + 'users' => [ + 'type' => Type::listOf($user), + 'resolve' => static fn (): array => [['name' => 'Ada'], ['name' => 'Grace']], + ], + ], + ]), + ]); + } + + /** + * @return array{0: TraceTreeBuilder, 1: \GraphQL\Executor\ExecutionResult} + */ + private function execute(string $query, bool $withTracing = true): array + { + $builder = new TraceTreeBuilder(); + $builder->startTiming(); + + $context = new ArrayObject(); + if ($withTracing) { + $context[InlineTraceListener::CONTEXT_KEY] = $builder; + } + + $result = GraphQL::executeQuery($this->schema(), $query, null, $context); + + return [$builder, $result]; + } + + private function childNamed(TraceNode $node, string $responseName): TraceNode + { + foreach ($node->children as $child) { + if ($responseName === $child->responseName) { + return $child; + } + } + + self::fail(sprintf('no child named "%s"', $responseName)); + } + + public function testRecordsANodePerResolvedField(): void + { + [$builder, $result] = $this->execute('{ me { name } }'); + + $this->assertSame(['me' => ['name' => 'Ada']], $result->data); + + $me = $this->childNamed($builder->getRoot(), 'me'); + $this->assertSame('User!', $me->type); + $this->assertSame('Query', $me->parentType); + $this->assertGreaterThan(0, $me->endTime); + + $name = $this->childNamed($me, 'name'); + $this->assertSame('String', $name->type); + $this->assertSame('User', $name->parentType); + } + + public function testRecordsIndexNodesForListItems(): void + { + [$builder] = $this->execute('{ users { name } }'); + + $users = $this->childNamed($builder->getRoot(), 'users'); + $this->assertCount(2, $users->children); + + $this->assertSame(0, $users->children[0]->index); + $this->assertSame(1, $users->children[1]->index); + $this->assertSame('name', $users->children[0]->children[0]->responseName); + $this->assertSame('User', $users->children[0]->children[0]->parentType); + } + + public function testRecordsTheSchemaFieldNameForAnAliasedField(): void + { + [$builder] = $this->execute('{ currentUser: me { name } }'); + + $node = $this->childNamed($builder->getRoot(), 'currentUser'); + $this->assertSame('me', $node->originalFieldName); + } + + public function testRecordsTypenameFields(): void + { + [$builder] = $this->execute('{ me { __typename } }'); + + $me = $this->childNamed($builder->getRoot(), 'me'); + $this->assertSame('__typename', $this->childNamed($me, '__typename')->responseName); + } + + /** + * The end time of a deferred field must be recorded when its value settles, not when the + * resolver returns the promise. Recording synchronously would give a duration of very nearly + * zero; the resolver sleeps 3ms, so the gap is unmistakable. + */ + public function testRecordsTheEndTimeOfADeferredFieldWhenItSettles(): void + { + [$builder] = $this->execute('{ me { slowName } }'); + + $slowName = $this->childNamed($this->childNamed($builder->getRoot(), 'me'), 'slowName'); + + $this->assertGreaterThan( + 2_000_000, + $slowName->endTime - $slowName->startTime, + 'a deferred field was timed synchronously instead of on settle' + ); + } + + public function testDeferredFieldsStillProduceOrderedTimings(): void + { + [$builder] = $this->execute('{ me { slowName name } }'); + + $me = $this->childNamed($builder->getRoot(), 'me'); + foreach ($me->children as $child) { + $this->assertGreaterThanOrEqual($child->startTime, $child->endTime, $child->responseName); + $this->assertGreaterThan(0, $child->endTime, $child->responseName); + } + } + + public function testRecordsAFieldWhoseResolverThrows(): void + { + [$builder, $result] = $this->execute('{ me { broken } }'); + + $this->assertCount(1, $result->errors); + + $broken = $this->childNamed($this->childNamed($builder->getRoot(), 'me'), 'broken'); + $this->assertSame('String', $broken->type); + $this->assertGreaterThan(0, $broken->endTime, 'a throwing resolver must still be timed'); + } + + public function testExecutesNormallyWithNoTraceInTheContext(): void + { + [$builder, $result] = $this->execute('{ me { name } }', false); + + $this->assertSame(['me' => ['name' => 'Ada']], $result->data); + $this->assertSame([], $builder->getRoot()->children); + } + + public function testReturnsTheOriginalResolverValueForDeferredFields(): void + { + [, $result] = $this->execute('{ me { slowName } }'); + + $this->assertSame(['me' => ['slowName' => 'Ada']], $result->data); + $this->assertSame([], $result->errors); + } +} diff --git a/tests/Federation/Tracing/TraceErrorFilterTest.php b/tests/Federation/Tracing/TraceErrorFilterTest.php new file mode 100644 index 00000000..5f37a601 --- /dev/null +++ b/tests/Federation/Tracing/TraceErrorFilterTest.php @@ -0,0 +1,238 @@ +source(), + [13], + ['me', 'name'], + new RuntimeException($rawMessage) + ); + } + + private function clientSafeError(string $message = 'You may not do that'): Error + { + return new Error( + $message, + null, + $this->source(), + [13], + ['me', 'name'], + new UserError($message) + ); + } + + public function testMaskedReplacesTheMessage(): void + { + $filter = new TraceErrorFilter(TraceErrorFilter::MASKED, self::INTERNAL_MESSAGE); + + $traced = $filter->filter($this->internalError()); + + $this->assertNotNull($traced); + $this->assertSame('', $traced->message); + } + + public function testMaskedPreservesLocations(): void + { + $filter = new TraceErrorFilter(TraceErrorFilter::MASKED, self::INTERNAL_MESSAGE); + + $traced = $filter->filter($this->internalError()); + + $this->assertNotNull($traced); + $this->assertSame([['line' => 3, 'column' => 5]], $traced->locations); + } + + public function testMaskedJsonCarriesMaskedByAndPathButNotTheRawMessage(): void + { + $filter = new TraceErrorFilter(TraceErrorFilter::MASKED, self::INTERNAL_MESSAGE); + + $traced = $filter->filter($this->internalError()); + + $this->assertNotNull($traced); + $decoded = json_decode($traced->json, true); + + $this->assertSame('', $decoded['message']); + $this->assertSame(['me', 'name'], $decoded['path']); + $this->assertSame(TraceErrorFilter::MASKED_BY, $decoded['extensions']['maskedBy']); + $this->assertStringNotContainsString('SQLSTATE', $traced->json); + } + + public function testMaskedDropsOriginalExtensions(): void + { + $error = new Error('boom', null, $this->source(), [13], ['me'], null, ['secret' => 'do-not-leak']); + + $filter = new TraceErrorFilter(TraceErrorFilter::MASKED, self::INTERNAL_MESSAGE); + $traced = $filter->filter($error); + + $this->assertNotNull($traced); + $this->assertStringNotContainsString('do-not-leak', $traced->json); + } + + /** + * The bundle's ErrorHandler masks at response-format time, not on the Error object, so the + * raw exception text is still present while the trace is being built. `unmodified` must + * report what the client sees -- never the raw message. + */ + public function testUnmodifiedReportsTheClientVisibleMessageNotTheRawException(): void + { + $filter = new TraceErrorFilter(TraceErrorFilter::UNMODIFIED, self::INTERNAL_MESSAGE); + + $traced = $filter->filter($this->internalError()); + + $this->assertNotNull($traced); + $this->assertSame(self::INTERNAL_MESSAGE, $traced->message); + $this->assertStringNotContainsString('SQLSTATE', $traced->json); + } + + public function testUnmodifiedReportsTheRealMessageForClientSafeErrors(): void + { + $filter = new TraceErrorFilter(TraceErrorFilter::UNMODIFIED, self::INTERNAL_MESSAGE); + + $traced = $filter->filter($this->clientSafeError()); + + $this->assertNotNull($traced); + $this->assertSame('You may not do that', $traced->message); + } + + public function testUnmodifiedPreservesLocationsAndPath(): void + { + $filter = new TraceErrorFilter(TraceErrorFilter::UNMODIFIED, self::INTERNAL_MESSAGE); + + $traced = $filter->filter($this->clientSafeError()); + + $this->assertNotNull($traced); + $this->assertSame([['line' => 3, 'column' => 5]], $traced->locations); + $this->assertSame(['me', 'name'], json_decode($traced->json, true)['path']); + } + + public function testTransformerReturningNullDropsTheError(): void + { + $filter = new TraceErrorFilter( + TraceErrorFilter::MASKED, + self::INTERNAL_MESSAGE, + $this->transformer(static fn (Error $e): ?Error => null) + ); + + $this->assertNull($filter->filter($this->internalError())); + } + + public function testTransformerOverridesTheIncludeErrorsMode(): void + { + $filter = new TraceErrorFilter( + TraceErrorFilter::MASKED, + self::INTERNAL_MESSAGE, + $this->transformer(static fn (Error $e): ?Error => new Error('rewritten')) + ); + + $traced = $filter->filter($this->internalError()); + + $this->assertNotNull($traced); + $this->assertSame('rewritten', $traced->message, 'the transformer must win over `masked`'); + } + + /** + * A transformer may only change the message and extensions. Locations and path always come + * from the original error, so the error still lands on the right trace node. + */ + public function testTransformerCannotMoveTheErrorToAnotherNode(): void + { + $filter = new TraceErrorFilter( + TraceErrorFilter::MASKED, + self::INTERNAL_MESSAGE, + $this->transformer(static fn (Error $e): ?Error => new Error('rewritten', null, null, [], ['somewhere', 'else'])) + ); + + $traced = $filter->filter($this->internalError()); + + $this->assertNotNull($traced); + $this->assertSame(['me', 'name'], json_decode($traced->json, true)['path']); + $this->assertSame([['line' => 3, 'column' => 5]], $traced->locations); + } + + public function testTransformerCanSetExtensions(): void + { + $filter = new TraceErrorFilter( + TraceErrorFilter::MASKED, + self::INTERNAL_MESSAGE, + $this->transformer(static fn (Error $e): ?Error => new Error('rewritten', null, null, [], null, null, ['code' => 'E17'])) + ); + + $traced = $filter->filter($this->internalError()); + + $this->assertNotNull($traced); + $this->assertSame('E17', json_decode($traced->json, true)['extensions']['code']); + } + + public function testTransformerReceivesTheOriginalError(): void + { + $seen = null; + $filter = new TraceErrorFilter( + TraceErrorFilter::MASKED, + self::INTERNAL_MESSAGE, + $this->transformer(static function (Error $e) use (&$seen): ?Error { + $seen = $e->getMessage(); + + return $e; + }) + ); + + $filter->filter($this->internalError('the raw one')); + + $this->assertSame('the raw one', $seen); + } + + public function testErrorWithoutLocationsProducesNoLocations(): void + { + $filter = new TraceErrorFilter(TraceErrorFilter::MASKED, self::INTERNAL_MESSAGE); + + $traced = $filter->filter(new Error('boom')); + + $this->assertNotNull($traced); + $this->assertSame([], $traced->locations); + } + + private function transformer(callable $fn): TraceErrorTransformerInterface + { + return new class($fn) implements TraceErrorTransformerInterface { + /** @var callable */ + private $fn; + + public function __construct(callable $fn) + { + $this->fn = $fn; + } + + public function transform(Error $error): ?Error + { + return ($this->fn)($error); + } + }; + } +} diff --git a/tests/Federation/Tracing/TraceSerializationTest.php b/tests/Federation/Tracing/TraceSerializationTest.php new file mode 100644 index 00000000..8954e8d7 --- /dev/null +++ b/tests/Federation/Tracing/TraceSerializationTest.php @@ -0,0 +1,220 @@ +setResponseName('currentUser') + ->setOriginalFieldName('me') + ->setType('User!') + ->setParentType('Query') + ->setStartTime(1000) + ->setEndTime(2000); + + $root = new TraceNode(); + $root->setChild([$child]); + + $trace = (new Trace()) + ->setStartTime(new Timestamp(['seconds' => 1660000000, 'nanos' => 123000000])) + ->setEndTime(new Timestamp(['seconds' => 1660000001, 'nanos' => 456000000])) + ->setDurationNs(1333000000) + ->setFieldExecutionWeight(1.0) + ->setRoot($root); + + $decoded = new Trace(); + $decoded->mergeFromString($trace->serializeToString()); + + $this->assertSame(1660000000, (int) $decoded->getStartTime()->getSeconds()); + $this->assertSame(123000000, $decoded->getStartTime()->getNanos()); + $this->assertSame(1660000001, (int) $decoded->getEndTime()->getSeconds()); + $this->assertSame(456000000, $decoded->getEndTime()->getNanos()); + $this->assertSame(1333000000, (int) $decoded->getDurationNs()); + $this->assertSame(1.0, $decoded->getFieldExecutionWeight()); + + $decodedChild = $decoded->getRoot()->getChild()[0]; + $this->assertSame('currentUser', $decodedChild->getResponseName()); + $this->assertSame('me', $decodedChild->getOriginalFieldName()); + $this->assertSame('User!', $decodedChild->getType()); + $this->assertSame('Query', $decodedChild->getParentType()); + $this->assertSame(1000, (int) $decodedChild->getStartTime()); + $this->assertSame(2000, (int) $decodedChild->getEndTime()); + } + + public function testIndexNodeRoundTrips(): void + { + $root = new TraceNode(); + $root->setChild([(new TraceNode())->setIndex(3)]); + + $decoded = new Trace(); + $decoded->mergeFromString((new Trace())->setRoot($root)->serializeToString()); + + $node = $decoded->getRoot()->getChild()[0]; + $this->assertSame(3, $node->getIndex()); + $this->assertSame('index', $node->getId(), 'the `id` oneof should report `index` as the set field'); + } + + public function testErrorRoundTrips(): void + { + $error = (new TraceError()) + ->setMessage('') + ->setJson('{"message":""}'); + $error->setLocation([(new TraceLocation())->setLine(2)->setColumn(7)]); + + $root = new TraceNode(); + $root->setError([$error]); + + $decoded = new Trace(); + $decoded->mergeFromString((new Trace())->setRoot($root)->serializeToString()); + + $decodedError = $decoded->getRoot()->getError()[0]; + $this->assertSame('', $decodedError->getMessage()); + $this->assertSame('{"message":""}', $decodedError->getJson()); + $this->assertSame(2, $decodedError->getLocation()[0]->getLine()); + $this->assertSame(7, $decodedError->getLocation()[0]->getColumn()); + } + + /** + * A round-trip cannot catch a transposed field number -- it would encode and decode with the + * same wrong tag. Asserting the raw tag bytes pins the numbering to Apollo's reports.proto. + * + * Tag byte = (field_number << 3) | wire_type. + * + * @dataProvider tagByteProvider + */ + public function testFieldNumbersMatchApolloReportsProto(string $description, Trace $trace, int $expectedTagByte): void + { + $bytes = $trace->serializeToString(); + + $this->assertSame( + $expectedTagByte, + ord($bytes[0]), + sprintf('%s: expected leading tag byte 0x%02X, got 0x%02X', $description, $expectedTagByte, ord($bytes[0])) + ); + } + + public function tagByteProvider(): iterable + { + // duration_ns = field 11, varint (wire type 0) => (11 << 3) | 0 = 88 = 0x58 + yield 'Trace.duration_ns is field 11' => [ + 'Trace.duration_ns', + (new Trace())->setDurationNs(42), + 0x58, + ]; + + // root = field 14, length-delimited (wire type 2) => (14 << 3) | 2 = 114 = 0x72 + yield 'Trace.root is field 14' => [ + 'Trace.root', + (new Trace())->setRoot(new TraceNode()), + 0x72, + ]; + + // end_time = field 3, length-delimited => (3 << 3) | 2 = 26 = 0x1A + yield 'Trace.end_time is field 3' => [ + 'Trace.end_time', + (new Trace())->setEndTime(new Timestamp(['seconds' => 1])), + 0x1A, + ]; + + // start_time = field 4, length-delimited => (4 << 3) | 2 = 34 = 0x22 + yield 'Trace.start_time is field 4' => [ + 'Trace.start_time', + (new Trace())->setStartTime(new Timestamp(['seconds' => 1])), + 0x22, + ]; + + // field_execution_weight = field 31, 64-bit (wire type 1) => (31 << 3) | 1 = 249 = 0xF9 + yield 'Trace.field_execution_weight is field 31' => [ + 'Trace.field_execution_weight', + (new Trace())->setFieldExecutionWeight(1.0), + 0xF9, + ]; + } + + /** + * @dataProvider nodeTagByteProvider + */ + public function testNodeFieldNumbersMatchApolloReportsProto(string $description, TraceNode $node, int $expectedTagByte): void + { + $bytes = $node->serializeToString(); + + $this->assertSame( + $expectedTagByte, + ord($bytes[0]), + sprintf('%s: expected leading tag byte 0x%02X, got 0x%02X', $description, $expectedTagByte, ord($bytes[0])) + ); + } + + public function nodeTagByteProvider(): iterable + { + // response_name = 1, length-delimited => (1 << 3) | 2 = 10 = 0x0A + yield 'Node.response_name is field 1' => ['Node.response_name', (new TraceNode())->setResponseName('a'), 0x0A]; + // index = 2, varint => (2 << 3) | 0 = 16 = 0x10 + yield 'Node.index is field 2' => ['Node.index', (new TraceNode())->setIndex(1), 0x10]; + // type = 3, length-delimited => (3 << 3) | 2 = 26 = 0x1A + yield 'Node.type is field 3' => ['Node.type', (new TraceNode())->setType('a'), 0x1A]; + // start_time = 8, varint => (8 << 3) | 0 = 64 = 0x40 + yield 'Node.start_time is field 8' => ['Node.start_time', (new TraceNode())->setStartTime(1), 0x40]; + // end_time = 9, varint => (9 << 3) | 0 = 72 = 0x48 + yield 'Node.end_time is field 9' => ['Node.end_time', (new TraceNode())->setEndTime(1), 0x48]; + // parent_type = 13, length-delimited => (13 << 3) | 2 = 106 = 0x6A + yield 'Node.parent_type is field 13' => ['Node.parent_type', (new TraceNode())->setParentType('a'), 0x6A]; + // original_field_name = 14, length-delimited => (14 << 3) | 2 = 114 = 0x72 + yield 'Node.original_field_name is field 14' => ['Node.original_field_name', (new TraceNode())->setOriginalFieldName('a'), 0x72]; + } + + /** + * The only test that proves interop: a real ftv1 payload emitted by Apollo Server, parsed + * with *our* generated classes. If our field numbers or wire types diverge from upstream, + * the decoded values come out wrong or missing. + * + * @see tests/Federation/Tracing/fixtures/README.md for how the fixture was produced. + */ + public function testDecodesGoldenApolloPayload(): void + { + $base64 = trim((string) file_get_contents(__DIR__.'/fixtures/apollo-ftv1.b64')); + + $trace = new Trace(); + $trace->mergeFromString((string) base64_decode($base64, true)); + + $this->assertGreaterThan(0, (int) $trace->getDurationNs(), 'duration_ns did not decode'); + $this->assertNotNull($trace->getStartTime(), 'start_time did not decode'); + $this->assertNotNull($trace->getEndTime(), 'end_time did not decode'); + $this->assertNotNull($trace->getRoot(), 'root did not decode'); + + $children = iterator_to_array($trace->getRoot()->getChild()); + $this->assertCount(1, $children); + + $me = $children[0]; + $this->assertSame('me', $me->getResponseName()); + $this->assertSame('User!', $me->getType()); + $this->assertSame('Query', $me->getParentType()); + $this->assertGreaterThan(0, (int) $me->getEndTime()); + + $name = iterator_to_array($me->getChild())[0]; + $this->assertSame('name', $name->getResponseName()); + $this->assertSame('String', $name->getType()); + $this->assertSame('User', $name->getParentType()); + } +} diff --git a/tests/Federation/Tracing/TraceTreeBuilderTest.php b/tests/Federation/Tracing/TraceTreeBuilderTest.php new file mode 100644 index 00000000..b7e84c5b --- /dev/null +++ b/tests/Federation/Tracing/TraceTreeBuilderTest.php @@ -0,0 +1,324 @@ +user = new ObjectType([ + 'name' => 'User', + 'fields' => [ + 'name' => ['type' => Type::string()], + ], + ]); + + $this->query = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'me' => ['type' => Type::nonNull($this->user)], + 'users' => ['type' => Type::listOf($this->user)], + ], + ]); + + $this->schema = new Schema(['query' => $this->query]); + } + + /** + * @param list $path + */ + private function resolveInfo(ObjectType $parentType, string $fieldName, array $path): ResolveInfo + { + return new ResolveInfo( + $parentType->getField($fieldName), + [], + $parentType, + $path, + $this->schema, + [], + null, + new OperationDefinitionNode([ + 'name' => new NameNode(['value' => 'Test']), + 'operation' => 'query', + 'selectionSet' => new SelectionSetNode(['selections' => []]), + 'variableDefinitions' => [], + 'directives' => [], + ]), + [] + ); + } + + private function startedBuilder(): TraceTreeBuilder + { + $builder = new TraceTreeBuilder(); + $builder->startTiming(); + + return $builder; + } + + public function testRootNodeHasNoIdentityAndNoTimings(): void + { + $root = $this->startedBuilder()->getRoot(); + + $this->assertNull($root->responseName); + $this->assertNull($root->index); + $this->assertSame('', $root->type); + $this->assertSame('', $root->parentType); + $this->assertSame(0, $root->startTime); + $this->assertSame(0, $root->endTime); + } + + public function testResolvedFieldBecomesChildOfRootWithTypeAndParentType(): void + { + $builder = $this->startedBuilder(); + + $builder->willResolveField($this->resolveInfo($this->query, 'me', ['me'])); + + $children = $builder->getRoot()->children; + $this->assertCount(1, $children); + $this->assertSame('me', $children[0]->responseName); + $this->assertSame('User!', $children[0]->type); + $this->assertSame('Query', $children[0]->parentType); + } + + public function testEndCallbackRecordsEndTimeAfterStartTime(): void + { + $builder = $this->startedBuilder(); + + $end = $builder->willResolveField($this->resolveInfo($this->query, 'me', ['me'])); + $end(); + + $node = $builder->getRoot()->children[0]; + $this->assertGreaterThan(0, $node->endTime); + $this->assertGreaterThanOrEqual($node->startTime, $node->endTime); + } + + public function testAliasedFieldRecordsOriginalFieldName(): void + { + $builder = $this->startedBuilder(); + + // `{ currentUser: me { ... } }` -- path carries the alias, fieldName the schema name. + $builder->willResolveField($this->resolveInfo($this->query, 'me', ['currentUser'])); + + $node = $builder->getRoot()->children[0]; + $this->assertSame('currentUser', $node->responseName); + $this->assertSame('me', $node->originalFieldName); + } + + public function testUnaliasedFieldLeavesOriginalFieldNameEmpty(): void + { + $builder = $this->startedBuilder(); + + $builder->willResolveField($this->resolveInfo($this->query, 'me', ['me'])); + + $this->assertSame('', $builder->getRoot()->children[0]->originalFieldName); + } + + /** + * No resolver ever fires for a list index, so the builder has to synthesise the + * intermediate node when a descendant is resolved. + */ + public function testListIndexAncestorIsSynthesised(): void + { + $builder = $this->startedBuilder(); + + $builder->willResolveField($this->resolveInfo($this->user, 'name', ['users', 0, 'name'])); + + $users = $builder->getRoot()->children[0]; + $this->assertSame('users', $users->responseName); + $this->assertSame('', $users->type, 'a synthesised ancestor has no type'); + + $index = $users->children[0]; + $this->assertNull($index->responseName); + $this->assertSame(0, $index->index); + $this->assertSame('', $index->type, 'an index node is structural only'); + $this->assertSame(0, $index->startTime); + + $name = $index->children[0]; + $this->assertSame('name', $name->responseName); + $this->assertSame('String', $name->type); + $this->assertSame('User', $name->parentType); + } + + public function testSiblingListItemsShareOneParentNode(): void + { + $builder = $this->startedBuilder(); + + $builder->willResolveField($this->resolveInfo($this->user, 'name', ['users', 0, 'name'])); + $builder->willResolveField($this->resolveInfo($this->user, 'name', ['users', 1, 'name'])); + + $users = $builder->getRoot()->children; + $this->assertCount(1, $users, 'both items must hang off a single `users` node'); + $this->assertCount(2, $users[0]->children); + $this->assertSame(0, $users[0]->children[0]->index); + $this->assertSame(1, $users[0]->children[1]->index); + } + + public function testAncestorSynthesisIsOrderIndependent(): void + { + $builder = $this->startedBuilder(); + + // Deferred items can settle out of order. + $builder->willResolveField($this->resolveInfo($this->user, 'name', ['users', 2, 'name'])); + $builder->willResolveField($this->resolveInfo($this->user, 'name', ['users', 0, 'name'])); + + $indices = array_map( + static fn (TraceNode $n): ?int => $n->index, + $builder->getRoot()->children[0]->children + ); + $this->assertSame([2, 0], $indices); + } + + /** + * graphql-php reuses one ResolveInfo across list items and mutates its `path` in + * completeListValue(). An end callback that re-derives the path at settle time would + * therefore write into the wrong node -- it must capture the node itself. + */ + public function testEndCallbackIsImmuneToResolveInfoPathMutation(): void + { + $builder = $this->startedBuilder(); + + $info = $this->resolveInfo($this->user, 'name', ['users', 0, 'name']); + $end = $builder->willResolveField($info); + + $info->path = ['users', 1, 'name']; + $end(); + + $indexZero = $builder->getRoot()->children[0]->children[0]; + $this->assertSame(0, $indexZero->index); + $this->assertGreaterThan(0, $indexZero->children[0]->endTime, 'end time landed on the wrong node'); + } + + public function testErrorWithMatchingPathAttachesToThatNode(): void + { + $builder = $this->startedBuilder(); + $builder->willResolveField($this->resolveInfo($this->query, 'me', ['me'])); + + $builder->addError(['me'], new TraceError('boom', [], '{}')); + + $this->assertCount(0, $builder->getRoot()->errors); + $this->assertCount(1, $builder->getRoot()->children[0]->errors); + $this->assertSame('boom', $builder->getRoot()->children[0]->errors[0]->message); + } + + public function testErrorWithoutPathAttachesToRoot(): void + { + $builder = $this->startedBuilder(); + + $builder->addError(null, new TraceError('Syntax Error', [], '{}')); + + $this->assertCount(1, $builder->getRoot()->errors); + $this->assertSame('Syntax Error', $builder->getRoot()->errors[0]->message); + } + + public function testErrorWithUnresolvedPathCreatesTheNode(): void + { + $builder = $this->startedBuilder(); + + $builder->addError(['users', 0, 'name'], new TraceError('boom', [], '{}')); + + $name = $builder->getRoot()->children[0]->children[0]->children[0]; + $this->assertSame('name', $name->responseName); + $this->assertCount(1, $name->errors); + } + + public function testStopTimingSetsDurationAndFieldExecutionWeight(): void + { + $builder = $this->startedBuilder(); + $builder->stopTiming(); + + $trace = $builder->toProto(); + $this->assertGreaterThan(0, (int) $trace->getDurationNs()); + $this->assertSame(1.0, $trace->getFieldExecutionWeight()); + $this->assertGreaterThan(0, (int) $trace->getStartTime()->getSeconds()); + $this->assertGreaterThan(0, (int) $trace->getEndTime()->getSeconds()); + } + + /** + * A resolver that returns a promise which never settles (e.g. an undispatched DataLoader) + * would otherwise leave end_time at 0, which reads as a negative duration in Studio. + */ + public function testStopTimingBackfillsMissingEndTimes(): void + { + $builder = $this->startedBuilder(); + $builder->willResolveField($this->resolveInfo($this->query, 'me', ['me'])); + + $builder->stopTiming(); + + $node = $builder->getRoot()->children[0]; + $this->assertSame((int) $builder->toProto()->getDurationNs(), $node->endTime); + } + + public function testStopTimingLeavesStructuralNodesUntouched(): void + { + $builder = $this->startedBuilder(); + $builder->willResolveField($this->resolveInfo($this->user, 'name', ['users', 0, 'name'])); + + $builder->stopTiming(); + + $index = $builder->getRoot()->children[0]->children[0]; + $this->assertSame(0, $index->startTime); + $this->assertSame(0, $index->endTime, 'index nodes never carry timings'); + } + + public function testStopsRecordingOnceTheNodeCapIsReached(): void + { + $builder = new TraceTreeBuilder(3); + $builder->startTiming(); + + for ($i = 0; $i < 10; ++$i) { + $end = $builder->willResolveField($this->resolveInfo($this->query, 'me', ['me'.$i])); + $end(); + } + + $this->assertLessThanOrEqual(3, count($builder->getRoot()->children)); + } + + public function testProtoMirrorsTheBuiltTree(): void + { + $builder = $this->startedBuilder(); + $end = $builder->willResolveField($this->resolveInfo($this->user, 'name', ['users', 0, 'name'])); + $end(); + $builder->addError(['users', 0, 'name'], new TraceError('boom', [['line' => 2, 'column' => 5]], '{"m":1}')); + $builder->stopTiming(); + + $root = $builder->toProto()->getRoot(); + + $users = $root->getChild()[0]; + $this->assertSame('users', $users->getResponseName()); + + $index = $users->getChild()[0]; + $this->assertSame(0, $index->getIndex()); + + $name = $index->getChild()[0]; + $this->assertSame('name', $name->getResponseName()); + $this->assertSame('String', $name->getType()); + $this->assertSame('User', $name->getParentType()); + $this->assertGreaterThan(0, (int) $name->getEndTime()); + + $error = $name->getError()[0]; + $this->assertSame('boom', $error->getMessage()); + $this->assertSame('{"m":1}', $error->getJson()); + $this->assertSame(2, $error->getLocation()[0]->getLine()); + $this->assertSame(5, $error->getLocation()[0]->getColumn()); + } +} diff --git a/tests/Federation/Tracing/fixtures/README.md b/tests/Federation/Tracing/fixtures/README.md new file mode 100644 index 00000000..3f09bbaa --- /dev/null +++ b/tests/Federation/Tracing/fixtures/README.md @@ -0,0 +1,94 @@ +`apollo-ftv1.b64` +================= + +A real `extensions.ftv1` payload, emitted by a real Apollo Server subgraph running its own +`ApolloServerPluginInlineTrace`. `TraceSerializationTest::testDecodesGoldenApolloPayload()` +parses it with *our* generated protobuf classes; if our field numbers or wire types ever drift +from Apollo's `reports.proto`, that test fails. + +This is the only test in the suite that proves interoperability rather than self-consistency. + +How it was produced +------------------- + +```bash +npm install @apollo/server @apollo/subgraph graphql graphql-tag +``` + +```js +// gen.mjs +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import gql from 'graphql-tag'; + +const typeDefs = gql` + type Query { me: User! } + type User @key(fields: "id") { id: ID! name: String } +`; + +const resolvers = { + Query: { + me: async () => { await new Promise(r => setTimeout(r, 5)); return { id: '1', name: 'Ada' }; }, + }, + User: { + name: async (u) => { await new Promise(r => setTimeout(r, 3)); return u.name; }, + }, +}; + +const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }) }); +const { url } = await startStandaloneServer(server, { listen: { port: 4999 } }); + +const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + body: JSON.stringify({ query: '{ me { name } }' }), +}); +console.log((await res.json()).extensions.ftv1); +await server.stop(); +process.exit(0); +``` + +```bash +node gen.mjs | grep -E '^[A-Za-z0-9+/]+=*$' > apollo-ftv1.b64 +``` + +(The `grep` drops Apollo's "Enabling inline tracing for this subgraph" log line, which it writes +to stdout alongside the payload.) + +The resolvers sleep so that the recorded `start_time`/`end_time` are non-zero and ordered, which +is what the test asserts on. + +Inspecting it +------------- + +```bash +base64 -d < apollo-ftv1.b64 | protoc --decode_raw +``` + +`--decode_raw` needs no schema, so it shows the raw field numbers independently of anything in +this repository: + +``` +3 { 1: 1787060485 2: 614000000 } # end_time (google.protobuf.Timestamp) +4 { 1: 1787060485 2: 600000000 } # start_time (google.protobuf.Timestamp) +11: 13847833 # duration_ns +14 { # root + 12 { # child + 1: "me" # response_name + 3: "User!" # type + 8: 3513708 # start_time + 9: 10048833 # end_time + 12 { 1: "name" 3: "String" 8: 10184875 9: 13630958 13: "User" } + 13: "Query" # parent_type + } +} +31: 0x3ff0000000000000 # field_execution_weight == 1.0 +``` + +Note that `--decode_raw` renders `type: "User!"` as a nested message `3 { 10: 0x21726573 }`, +because those five bytes also parse as a valid submessage. That is an ambiguity in `--decode_raw` +(which has no schema to disambiguate with), not a problem with the payload. diff --git a/tests/Federation/Tracing/fixtures/apollo-ftv1.b64 b/tests/Federation/Tracing/fixtures/apollo-ftv1.b64 new file mode 100644 index 00000000..9dc16918 --- /dev/null +++ b/tests/Federation/Tracing/fixtures/apollo-ftv1.b64 @@ -0,0 +1 @@ +GgwIhcKR1AYQgMvjpAIiDAiFwpHUBhCAjI2eAliZms0Gcj5iPAoCbWUaBVVzZXIhQOy61gFIwarlBGIeCgRuYW1lGgZTdHJpbmdAq9HtBEju+78GagRVc2VyagVRdWVyefkBAAAAAAAA8D8= diff --git a/tests/Functional/App/Resolver/CacheControlEntityTypeResolver.php b/tests/Functional/App/Resolver/CacheControlEntityTypeResolver.php new file mode 100644 index 00000000..bb679bca --- /dev/null +++ b/tests/Functional/App/Resolver/CacheControlEntityTypeResolver.php @@ -0,0 +1,20 @@ + [ + 'posts' => fn () => array_values(CacheControlReferenceResolver::POSTS), + 'uncached' => fn () => 'anything', + 'content' => fn () => null, + 'timestamped' => fn () => null, + ], + 'Content' => [ + self::RESOLVE_TYPE => fn ($value) => isset($value['title']) ? 'Post' : 'Author', + ], + 'Timestamped' => [ + self::RESOLVE_TYPE => fn () => 'Post', + ], + ]; + } +} diff --git a/tests/Functional/App/Resolver/CacheControlQueryResolverMap.php b/tests/Functional/App/Resolver/CacheControlQueryResolverMap.php new file mode 100644 index 00000000..405dd350 --- /dev/null +++ b/tests/Functional/App/Resolver/CacheControlQueryResolverMap.php @@ -0,0 +1,32 @@ + '1', 'title' => 'First'], + ['id' => '2', 'title' => 'Second'], + ]; + + protected function map(): array + { + return [ + 'Query' => [ + 'posts' => fn () => self::POSTS, + 'featured' => fn () => self::POSTS[0], + 'me' => fn () => ['id' => '42', 'email' => 'ryan@example.com'], + 'shortLived' => fn () => 'ephemeral', + 'uncached' => fn () => 'anything', + 'boom' => function (): void { + throw new UserError('Kaboom.'); + }, + ], + ]; + } +} diff --git a/tests/Functional/App/Resolver/CacheControlReferenceResolver.php b/tests/Functional/App/Resolver/CacheControlReferenceResolver.php new file mode 100644 index 00000000..3ae99956 --- /dev/null +++ b/tests/Functional/App/Resolver/CacheControlReferenceResolver.php @@ -0,0 +1,21 @@ + ['id' => '1', 'title' => 'First', 'updatedAt' => '2026-01-01'], + '2' => ['id' => '2', 'title' => 'Second', 'updatedAt' => '2026-01-02'], + ]; + + /** + * @param array{id: string} $reference + */ + public function resolve(array $reference): ?array + { + return self::POSTS[$reference['id']] ?? null; + } +} diff --git a/tests/Functional/App/Resolver/InlineTraceResolver.php b/tests/Functional/App/Resolver/InlineTraceResolver.php new file mode 100644 index 00000000..a3fc25bc --- /dev/null +++ b/tests/Functional/App/Resolver/InlineTraceResolver.php @@ -0,0 +1,45 @@ + 'Ada']; + } + + /** + * @return array + */ + public function resolveUsers(): array + { + return [['name' => 'Ada'], ['name' => 'Grace']]; + } + + /** + * Resolves through a promise, so its end time can only be recorded once the value settles. + */ + public function resolveSlowName(array $user): Deferred + { + return new Deferred(static function () use ($user): string { + usleep(3000); + + return $user['name']; + }); + } + + public function resolveBroken(): string + { + throw new RuntimeException('a secret internal detail'); + } +} diff --git a/tests/Functional/App/config/cacheControl/config.yml b/tests/Functional/App/config/cacheControl/config.yml new file mode 100644 index 00000000..b75f6a66 --- /dev/null +++ b/tests/Functional/App/config/cacheControl/config.yml @@ -0,0 +1,23 @@ +imports: + - { resource: ../config.yml } + +redeye_graphql: + cache_control: + enabled: true + default_max_age: 0 + definitions: + class_namespace: "Redeye\\GraphQLBundle\\CacheControlApp\\__DEFINITIONS__" + schema: + default: + query: Query + mappings: + types: + - + type: graphql + dir: "%kernel.project_dir%/config/cacheControl/mapping" + suffix: ~ + +services: + Redeye\GraphQLBundle\Tests\Functional\App\Resolver\CacheControlQueryResolverMap: + tags: + - { name: redeye_graphql.resolver_map, schema: default } diff --git a/tests/Functional/App/config/cacheControl/mapping/query.graphql b/tests/Functional/App/config/cacheControl/mapping/query.graphql new file mode 100644 index 00000000..b8bdfc32 --- /dev/null +++ b/tests/Functional/App/config/cacheControl/mapping/query.graphql @@ -0,0 +1,28 @@ +type Query { + "Field hint overrides the maxAge declared on Post." + posts: [Post!]! @cacheControl(maxAge: 60) + + "No field hint, so the hint on Post applies." + featured: Post + + "Field hint replaces both maxAge and scope." + me: User @cacheControl(maxAge: 30, scope: PRIVATE) + + shortLived: String @cacheControl(maxAge: 30) + + "Unannotated root field: uncacheable by default." + uncached: String + + "Cacheable on paper, but its resolver throws." + boom: String @cacheControl(maxAge: 60) +} + +type Post @cacheControl(maxAge: 120) { + id: ID! + title: String! +} + +type User @cacheControl(maxAge: 120) { + id: ID! + email: String! +} diff --git a/tests/Functional/App/config/cacheControlDisabled/config.yml b/tests/Functional/App/config/cacheControlDisabled/config.yml new file mode 100644 index 00000000..02f7da56 --- /dev/null +++ b/tests/Functional/App/config/cacheControlDisabled/config.yml @@ -0,0 +1,8 @@ +imports: + - { resource: ../cacheControl/config.yml } + +redeye_graphql: + cache_control: + enabled: false + definitions: + class_namespace: "Redeye\\GraphQLBundle\\CacheControlDisabledApp\\__DEFINITIONS__" diff --git a/tests/Functional/App/config/cacheControlFederation/config.yml b/tests/Functional/App/config/cacheControlFederation/config.yml new file mode 100644 index 00000000..d2e63cfd --- /dev/null +++ b/tests/Functional/App/config/cacheControlFederation/config.yml @@ -0,0 +1,37 @@ +imports: + - { resource: ../config.yml } + +redeye_graphql: + federation: true + # Surface real exceptions rather than hiding them behind "Internal server Error". + errors_handler: + debug: true + cache_control: + enabled: true + default_max_age: 0 + definitions: + class_namespace: "Redeye\\GraphQLBundle\\CacheControlFederationApp\\__DEFINITIONS__" + schema: + default: + query: Query + mappings: + types: + - + type: graphql + dir: "%kernel.project_dir%/config/cacheControlFederation/mapping" + suffix: ~ + +services: + # Aliased to a plain id: a namespaced class name inside an expression-language string would + # have its backslashes mangled. + test.cache_control_reference_resolver: + class: Redeye\GraphQLBundle\Tests\Functional\App\Resolver\CacheControlReferenceResolver + public: true + + Redeye\GraphQLBundle\Tests\Functional\App\Resolver\CacheControlFederationResolverMap: + tags: + - { name: redeye_graphql.resolver_map, schema: default } + + Redeye\GraphQLBundle\Tests\Functional\App\Resolver\CacheControlEntityTypeResolver: + tags: + - { name: redeye_graphql.entity_type_resolver } diff --git a/tests/Functional/App/config/cacheControlFederation/mapping/query.graphql b/tests/Functional/App/config/cacheControlFederation/mapping/query.graphql new file mode 100644 index 00000000..99cbd795 --- /dev/null +++ b/tests/Functional/App/config/cacheControlFederation/mapping/query.graphql @@ -0,0 +1,30 @@ +type Query { + posts: [Post!]! @cacheControl(maxAge: 60) @cacheTag(format: "posts") + uncached: String + content: Content + timestamped: Timestamped +} + +type Post implements Timestamped + @key(fields: "id") + @resolveReference(expression: "@=service('test.cache_control_reference_resolver').resolve(ref)") + @cacheControl(maxAge: 120) + @cacheTag(format: "post-{$key.id}") + @cacheTag(format: "post") +{ + id: ID! + title: String! + updatedAt: String! + author: Author @cacheControl(inheritMaxAge: true) +} + +type Author @cacheControl(maxAge: 300, scope: PRIVATE) { + id: ID! + name: String! +} + +interface Timestamped @cacheControl(maxAge: 45) { + updatedAt: String! +} + +union Content @cacheControl(maxAge: 15) = Post | Author diff --git a/tests/Functional/App/config/cacheControlIfCacheable/config.yml b/tests/Functional/App/config/cacheControlIfCacheable/config.yml new file mode 100644 index 00000000..49a8553f --- /dev/null +++ b/tests/Functional/App/config/cacheControlIfCacheable/config.yml @@ -0,0 +1,8 @@ +imports: + - { resource: ../cacheControl/config.yml } + +redeye_graphql: + cache_control: + calculate_http_headers: if-cacheable + definitions: + class_namespace: "Redeye\\GraphQLBundle\\CacheControlIfCacheableApp\\__DEFINITIONS__" diff --git a/tests/Functional/App/config/inlineTrace/config.yml b/tests/Functional/App/config/inlineTrace/config.yml new file mode 100644 index 00000000..fa8db70d --- /dev/null +++ b/tests/Functional/App/config/inlineTrace/config.yml @@ -0,0 +1,16 @@ +imports: + - { resource: ../config.yml } + - { resource: services.yml } + +redeye_graphql: + inline_trace: true + definitions: + class_namespace: 'Redeye\GraphQLBundle\InlineTrace\__DEFINITIONS__' + schema: + query: Query + mutation: ~ + mappings: + types: + - + type: yaml + dir: '%kernel.project_dir%/config/inlineTrace/mapping' diff --git a/tests/Functional/App/config/inlineTrace/mapping/inlineTrace.types.yml b/tests/Functional/App/config/inlineTrace/mapping/inlineTrace.types.yml new file mode 100644 index 00000000..b3fb8173 --- /dev/null +++ b/tests/Functional/App/config/inlineTrace/mapping/inlineTrace.types.yml @@ -0,0 +1,23 @@ +Query: + type: object + config: + fields: + me: + type: "User!" + resolve: '@=query("inline_trace_me")' + users: + type: "[User]" + resolve: '@=query("inline_trace_users")' + +User: + type: object + config: + fields: + name: + type: String + slowName: + type: String + resolve: '@=query("inline_trace_slow_name", value)' + broken: + type: String + resolve: '@=query("inline_trace_broken")' diff --git a/tests/Functional/App/config/inlineTrace/services.yml b/tests/Functional/App/config/inlineTrace/services.yml new file mode 100644 index 00000000..e12d468b --- /dev/null +++ b/tests/Functional/App/config/inlineTrace/services.yml @@ -0,0 +1,8 @@ +services: + redeye_graphql.test.resolver.inline_trace: + class: Redeye\GraphQLBundle\Tests\Functional\App\Resolver\InlineTraceResolver + tags: + - { name: "redeye_graphql.query", alias: "inline_trace_me", method: "resolveMe" } + - { name: "redeye_graphql.query", alias: "inline_trace_users", method: "resolveUsers" } + - { name: "redeye_graphql.query", alias: "inline_trace_slow_name", method: "resolveSlowName" } + - { name: "redeye_graphql.query", alias: "inline_trace_broken", method: "resolveBroken" } diff --git a/tests/Functional/App/config/inlineTraceUnmodified/config.yml b/tests/Functional/App/config/inlineTraceUnmodified/config.yml new file mode 100644 index 00000000..1f775799 --- /dev/null +++ b/tests/Functional/App/config/inlineTraceUnmodified/config.yml @@ -0,0 +1,8 @@ +imports: + - { resource: ../inlineTrace/config.yml } + +redeye_graphql: + inline_trace: + include_errors: unmodified + definitions: + class_namespace: 'Redeye\GraphQLBundle\InlineTraceUnmodified\__DEFINITIONS__' diff --git a/tests/Functional/CacheControl/CacheControlTest.php b/tests/Functional/CacheControl/CacheControlTest.php new file mode 100644 index 00000000..e2ced0d3 --- /dev/null +++ b/tests/Functional/CacheControl/CacheControlTest.php @@ -0,0 +1,126 @@ +assertCacheControl('max-age=60, public', '{ posts { id title } }'); + } + + public function testTypeLevelHintAppliesToAnUnannotatedField(): void + { + $this->assertCacheControl('max-age=120, public', '{ featured { title } }'); + } + + public function testPrivateScope(): void + { + $this->assertCacheControl('max-age=30, private', '{ me { id email } }'); + } + + public function testUnannotatedRootFieldIsUncacheable(): void + { + $this->assertCacheControl(self::NO_STORE, '{ uncached }'); + } + + public function testTheMostRestrictiveFieldWins(): void + { + $this->assertCacheControl('max-age=30, public', '{ posts { id } shortLived }'); + } + + public function testOneUncacheableFieldPoisonsTheQuery(): void + { + $this->assertCacheControl(self::NO_STORE, '{ posts { id } uncached }'); + } + + public function testAnErroredResponseIsUncacheable(): void + { + $client = self::createClientAuthenticated('ryan', 'cacheControl'); + $result = self::sendRequest($client, '{ boom }', true); + + $this->assertNotEmpty($result['errors'] ?? [], 'the query was supposed to fail'); + $this->assertSame(self::NO_STORE, $this->header($client)); + } + + public function testBatchTakesTheMostRestrictiveOperation(): void + { + $client = $this->sendBatch('cacheControl', [ + ['id' => 'a', 'query' => '{ posts { id } }'], + ['id' => 'b', 'query' => '{ shortLived }'], + ]); + + $this->assertSame('max-age=30, public', $this->header($client)); + } + + public function testBatchWithOneUncacheableOperation(): void + { + $client = $this->sendBatch('cacheControl', [ + ['id' => 'a', 'query' => '{ posts { id } }'], + ['id' => 'b', 'query' => '{ uncached }'], + ]); + + $this->assertSame(self::NO_STORE, $this->header($client)); + } + + public function testIfCacheableLeavesUncacheableResponsesAlone(): void + { + $this->assertCacheControl(self::UNTOUCHED, '{ uncached }', 'cacheControlIfCacheable'); + } + + public function testIfCacheableStillAnnotatesCacheableResponses(): void + { + $this->assertCacheControl('max-age=60, public', '{ posts { id } }', 'cacheControlIfCacheable'); + } + + /** + * Existing consumers of this bundle must see no change until they opt in. + */ + public function testDisabledNeverTouchesTheHeader(): void + { + $this->assertCacheControl(self::UNTOUCHED, '{ posts { id } }', 'cacheControlDisabled'); + $this->assertCacheControl(self::UNTOUCHED, '{ uncached }', 'cacheControlDisabled'); + } + + private function assertCacheControl(string $expected, string $query, string $testCase = 'cacheControl'): void + { + $client = self::createClientAuthenticated('ryan', $testCase); + $result = self::sendRequest($client, $query, true); + + $this->assertArrayNotHasKey('errors', $result, json_encode($result)); + $this->assertSame($expected, $this->header($client)); + } + + private function sendBatch(string $testCase, array $queries): KernelBrowser + { + $client = self::createClientAuthenticated('ryan', $testCase); + $client->request('POST', '/batch', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($queries)); + + return $client; + } + + private function header(KernelBrowser $client): ?string + { + return $client->getResponse()->headers->get('Cache-Control'); + } +} diff --git a/tests/Functional/CacheControl/FederatedCacheControlTest.php b/tests/Functional/CacheControl/FederatedCacheControlTest.php new file mode 100644 index 00000000..c73ecf29 --- /dev/null +++ b/tests/Functional/CacheControl/FederatedCacheControlTest.php @@ -0,0 +1,101 @@ +get('redeye_graphql.request_executor'); + + return FederatedSchemaPrinter::doPrint($executor->getSchema('default')); + } + + public function testLinksTheFederationVersionThatDefinesCacheTag(): void + { + $sdl = self::sdl(); + + $this->assertStringContainsString('https://specs.apollo.dev/federation/v2.12', $sdl, $sdl); + $this->assertStringContainsString('"@cacheTag"', $sdl, $sdl); + } + + /** + * @cacheTag comes from the @link import, so a local definition would be a composition error. + */ + public function testCacheTagHasNoLocalDefinition(): void + { + $this->assertStringNotContainsString('directive @cacheTag', self::sdl()); + } + + /** + * @cacheControl is not a federation directive, so it needs one — and exactly one. + */ + public function testCacheControlIsDeclaredExactlyOnce(): void + { + $sdl = self::sdl(); + + $this->assertSame(1, substr_count($sdl, 'directive @cacheControl'), $sdl); + $this->assertSame(1, substr_count($sdl, 'enum CacheControlScope'), $sdl); + } + + public function testFieldDirectivesArePrinted(): void + { + $sdl = self::sdl(); + + $this->assertStringContainsString('@cacheControl(maxAge: 60)', $sdl, $sdl); + $this->assertStringContainsString('@cacheTag(format: "posts")', $sdl, $sdl); + $this->assertStringContainsString('@cacheControl(inheritMaxAge: true)', $sdl, $sdl); + } + + public function testEntityTypeDirectivesArePrinted(): void + { + $sdl = self::sdl(); + + $this->assertStringContainsString('@cacheControl(maxAge: 120)', $sdl, $sdl); + $this->assertStringContainsString('@cacheTag(format: "post-{$key.id}")', $sdl, $sdl); + $this->assertStringContainsString('@cacheTag(format: "post")', $sdl, $sdl); + } + + public function testObjectInterfaceAndUnionDirectivesArePrinted(): void + { + $sdl = self::sdl(); + + $this->assertMatchesRegularExpression('/type Author @cacheControl\(maxAge: 300, scope: PRIVATE\)/', $sdl, $sdl); + $this->assertMatchesRegularExpression('/interface Timestamped @cacheControl\(maxAge: 45\)/', $sdl, $sdl); + $this->assertMatchesRegularExpression('/union Content @cacheControl\(maxAge: 15\) = /', $sdl, $sdl); + } + + /** + * Without the `_entities` exemption this would be no-store, since `_entities` is a root field. + */ + public function testEntitiesQueryTakesTheHintOfTheConcreteType(): void + { + $client = static::createClientAuthenticated('ryan', self::TEST_CASE); + + $query = 'query($reps: [_Any!]!) { _entities(representations: $reps) { ... on Post { title } } }'; + $variables = ['reps' => [['__typename' => 'Post', 'id' => '1']]]; + + $client->request('GET', '/', ['query' => $query, 'variables' => json_encode($variables)]); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertSame([['title' => 'First']], $result['data']['_entities'] ?? null, json_encode($result)); + + $this->assertSame('max-age=120, public', $client->getResponse()->headers->get('Cache-Control')); + } +} diff --git a/tests/Functional/Federation/InlineTraceTest.php b/tests/Functional/Federation/InlineTraceTest.php new file mode 100644 index 00000000..63fdb2e6 --- /dev/null +++ b/tests/Functional/Federation/InlineTraceTest.php @@ -0,0 +1,272 @@ + + */ + private function ftv1Request(KernelBrowser $client, string $query, ?string $header = InlineTraceListener::HEADER_VALUE): array + { + $server = null === $header + ? [] + : ['HTTP_APOLLO_FEDERATION_INCLUDE_TRACE' => $header]; + + $client->request('GET', '/', ['query' => $query], [], $server); + + return json_decode((string) $client->getResponse()->getContent(), true); + } + + /** + * @param array $response + */ + private function assertFtv1(array $response): Trace + { + $this->assertArrayHasKey('extensions', $response); + $this->assertArrayHasKey('ftv1', $response['extensions']); + + $trace = new Trace(); + $trace->mergeFromString((string) base64_decode($response['extensions']['ftv1'], true)); + + return $trace; + } + + private function childNamed(Node $node, string $responseName): Node + { + foreach ($node->getChild() as $child) { + if ($responseName === $child->getResponseName()) { + return $child; + } + } + + self::fail(sprintf('no child named "%s"', $responseName)); + } + + public function testNoTraceWithoutTheHeader(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $response = $this->ftv1Request($client, '{ me { name } }', null); + + $this->assertSame(['me' => ['name' => 'Ada']], $response['data']); + $this->assertArrayNotHasKey('ftv1', $response['extensions'] ?? []); + } + + /** + * @dataProvider nonMatchingHeaderProvider + */ + public function testNoTraceForAnotherHeaderValue(string $header): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $response = $this->ftv1Request($client, '{ me { name } }', $header); + + $this->assertArrayNotHasKey('ftv1', $response['extensions'] ?? []); + } + + public function nonMatchingHeaderProvider(): iterable + { + yield 'empty' => ['']; + yield 'v1' => ['v1']; + yield 'ftv2' => ['ftv2']; + } + + public function testTraceIsAbsentWhenTheFeatureIsOff(): void + { + $client = static::createClient(['test_case' => 'connection']); + + $response = $this->ftv1Request($client, '{ user { name } }'); + + $this->assertArrayNotHasKey('ftv1', $response['extensions'] ?? []); + } + + public function testEmitsATraceWhoseRootMirrorsTheSelectionSet(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $trace = $this->assertFtv1($this->ftv1Request($client, '{ me { name } }')); + + $this->assertGreaterThan(0, (int) $trace->getDurationNs()); + $this->assertGreaterThan(0, (int) $trace->getStartTime()->getSeconds()); + $this->assertGreaterThan(0, (int) $trace->getEndTime()->getSeconds()); + $this->assertSame(1.0, $trace->getFieldExecutionWeight()); + + $me = $this->childNamed($trace->getRoot(), 'me'); + $this->assertSame('User!', $me->getType()); + $this->assertSame('Query', $me->getParentType()); + + $name = $this->childNamed($me, 'name'); + $this->assertSame('String', $name->getType()); + $this->assertSame('User', $name->getParentType()); + } + + public function testListItemsBecomeIndexNodes(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $trace = $this->assertFtv1($this->ftv1Request($client, '{ users { name } }')); + + $users = $this->childNamed($trace->getRoot(), 'users'); + $indices = []; + foreach ($users->getChild() as $child) { + $indices[] = $child->getIndex(); + $this->assertSame('index', $child->getId()); + $this->assertSame('name', $this->childNamed($child, 'name')->getResponseName()); + } + + $this->assertSame([0, 1], $indices); + } + + public function testAliasedFieldCarriesTheSchemaFieldName(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $trace = $this->assertFtv1($this->ftv1Request($client, '{ currentUser: me { name } }')); + + $node = $this->childNamed($trace->getRoot(), 'currentUser'); + $this->assertSame('me', $node->getOriginalFieldName()); + } + + public function testDeferredFieldIsTimedWhenItSettles(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $trace = $this->assertFtv1($this->ftv1Request($client, '{ me { slowName } }')); + + $slowName = $this->childNamed($this->childNamed($trace->getRoot(), 'me'), 'slowName'); + $this->assertGreaterThan( + 2_000_000, + (int) $slowName->getEndTime() - (int) $slowName->getStartTime(), + 'a deferred field was timed synchronously instead of on settle' + ); + } + + public function testErrorIsMaskedAndAttachedToItsNode(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $response = $this->ftv1Request($client, '{ me { broken } }'); + $trace = $this->assertFtv1($response); + + $broken = $this->childNamed($this->childNamed($trace->getRoot(), 'me'), 'broken'); + $this->assertCount(1, $broken->getError()); + + $error = $broken->getError()[0]; + $this->assertSame(TraceErrorFilter::MASKED_MESSAGE, $error->getMessage()); + $this->assertStringNotContainsString('a secret internal detail', $error->getJson()); + $this->assertSame(['me', 'broken'], json_decode($error->getJson(), true)['path']); + $this->assertSame(TraceErrorFilter::MASKED_BY, json_decode($error->getJson(), true)['extensions']['maskedBy']); + } + + /** + * `unmodified` must report the message the client sees, never the raw exception text. + */ + public function testUnmodifiedReportsTheClientVisibleMessage(): void + { + $client = static::createClient(['test_case' => 'inlineTraceUnmodified']); + + $response = $this->ftv1Request($client, '{ me { broken } }'); + $trace = $this->assertFtv1($response); + + $broken = $this->childNamed($this->childNamed($trace->getRoot(), 'me'), 'broken'); + $error = $broken->getError()[0]; + + $this->assertSame($response['errors'][0]['message'], $error->getMessage()); + $this->assertStringNotContainsString('a secret internal detail', $error->getJson()); + } + + /** + * A query that never parses resolves no fields at all, but must still produce a trace. + */ + public function testEmitsATraceForAnUnparseableQuery(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $response = $this->ftv1Request($client, '{ me {'); + + $this->assertArrayHasKey('errors', $response); + $trace = $this->assertFtv1($response); + + $this->assertCount(0, $trace->getRoot()->getChild()); + $this->assertCount(1, $trace->getRoot()->getError()); + // Parse errors are masked like any other under the default policy. + $this->assertSame(TraceErrorFilter::MASKED_MESSAGE, $trace->getRoot()->getError()[0]->getMessage()); + } + + public function testSyntaxErrorDetailReachesTheTraceWhenErrorsAreUnmodified(): void + { + $client = static::createClient(['test_case' => 'inlineTraceUnmodified']); + + $trace = $this->assertFtv1($this->ftv1Request($client, '{ me {')); + + $this->assertCount(1, $trace->getRoot()->getError()); + $this->assertStringContainsString('Syntax Error', $trace->getRoot()->getError()[0]->getMessage()); + } + + public function testEmitsATraceForAQueryThatFailsValidation(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $response = $this->ftv1Request($client, '{ me { noSuchField } }'); + + $this->assertArrayHasKey('errors', $response); + $trace = $this->assertFtv1($response); + + $this->assertCount(0, $trace->getRoot()->getChild()); + $this->assertCount(1, $trace->getRoot()->getError()); + } + + /** + * The trace lives in the per-execution context rather than on the listener service, so each + * query in a batch must get its own. + */ + public function testEachQueryInABatchGetsItsOwnTrace(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $content = (string) json_encode([ + ['id' => 'ok', 'query' => '{ me { name } }'], + ['id' => 'broken', 'query' => '{ me { broken } }'], + ]); + $client->request('POST', '/batch', [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_APOLLO_FEDERATION_INCLUDE_TRACE' => InlineTraceListener::HEADER_VALUE, + ], $content); + + $payloads = json_decode((string) $client->getResponse()->getContent(), true); + $this->assertCount(2, $payloads); + + $first = $this->assertFtv1($payloads[0]['payload']); + $second = $this->assertFtv1($payloads[1]['payload']); + + $this->assertNotSame( + $payloads[0]['payload']['extensions']['ftv1'], + $payloads[1]['payload']['extensions']['ftv1'] + ); + + $this->assertSame('name', $this->childNamed($this->childNamed($first->getRoot(), 'me'), 'name')->getResponseName()); + $this->assertCount(0, $this->childNamed($first->getRoot(), 'me')->getError()); + + $broken = $this->childNamed($this->childNamed($second->getRoot(), 'me'), 'broken'); + $this->assertCount(1, $broken->getError()); + } + + public function testTraceCoexistsWithOtherExtensions(): void + { + $client = static::createClient(['test_case' => 'inlineTrace']); + + $trace = $this->assertFtv1($this->ftv1Request($client, '{ me { name } }')); + + $this->assertNotNull($trace->getRoot()); + } +}