From 2f66e4596ffd6c559d71f9b2da8ba077050c9405 Mon Sep 17 00:00:00 2001 From: Magnus Nordlander Date: Tue, 18 Aug 2026 16:12:16 +0200 Subject: [PATCH 1/3] Add @cacheControl and @cacheTag support Gives this subgraph Apollo Server's cache-hint semantics: @cacheControl annotations in the schema are turned into a Cache-Control response header, and @cacheTag is passed through to the federated SDL for the router to use for invalidation. Previously the bundle emitted no cache headers at all, so a router in front of it could cache nothing. The per-field hint is collected in TracingReferenceExecutor, whose resolveFieldValueOrError override is the direct equivalent of Apollo's willResolveField. The recorder travels in the GraphQL context value, since graphql-php builds the executor through a static factory with no container access; that also makes it reachable from the _entities resolver. Query._entities is a root field, so the default rule would pin every federated entity fetch at maxAge 0 and no entity response could ever be cached. It is exempt from the root default and instead takes the hint from the concrete type of each representation, matching Apollo. The published SDL now links federation v2.12, which is where @cacheTag is defined. @cacheTag arrives through that import and gets no local definition; @cacheControl is not a federation directive, so it is declared locally along with its CacheControlScope argument type. Verified with rover supergraph compose: composition succeeds and @cacheTag survives into the supergraph. Ships opt-in behind redeye_graphql.cache_control.enabled, because switching it on gives a header to responses that previously had none, including no-store for anything unannotated. Also fixes three pre-existing bugs in code this makes load-bearing: - DirectiveNode broke out of its loop after @deprecated, silently dropping every directive written after it on the same field. - TracingReferenceExecutor called ->startChild() on a span that SentrySdk::getCurrentHub()->getSpan() may legitimately return as null. - A dead Webmozart\Assert import for a package the bundle never required. phpunit.xml.dist gets an explicit memory_limit: the suite boots a kernel per functional test case and already sat at ~124M of the 128M default. Co-Authored-By: Claude Opus 5 (1M context) --- composer.json | 1 + docs/definitions/cache-control.md | 163 ++++++++++++ docs/definitions/index.md | 1 + phpunit.xml.dist | 3 + src/CacheControl/CacheControlAccumulator.php | 144 ++++++++++ src/CacheControl/CacheControlRecorder.php | 106 ++++++++ src/CacheControl/CacheHint.php | 64 +++++ src/CacheControl/CacheHintResolver.php | 102 +++++++ src/CacheControl/CachePolicy.php | 68 +++++ src/CacheControl/CacheScope.php | 46 ++++ src/CacheControl/FieldCacheHint.php | 53 ++++ src/Config/EntityObjectDefinition.php | 4 + src/Config/EntityRefObjectDefinition.php | 4 + src/Config/InterfaceTypeDefinition.php | 1 + src/Config/ObjectTypeDefinition.php | 4 + .../ASTConverter/CacheDirectivesNode.php | 121 +++++++++ .../ASTConverter/DirectiveArguments.php | 174 ++++++++++++ .../GraphQL/ASTConverter/DirectiveNode.php | 7 +- .../GraphQL/ASTConverter/FieldsNode.php | 6 + .../GraphQL/ASTConverter/ObjectNode.php | 23 +- .../Parser/GraphQL/ASTConverter/UnionNode.php | 2 + src/Config/TypeDefinition.php | 77 ++++++ src/Config/TypeWithOutputFieldsDefinition.php | 4 +- src/Config/UnionTypeDefinition.php | 1 + src/Controller/GraphController.php | 12 +- src/DependencyInjection/Configuration.php | 44 ++++ .../RedeyeGraphQLExtension.php | 8 + src/EventListener/CacheControlListener.php | 49 ++++ src/Executor/TracingReferenceExecutor.php | 35 ++- src/Federation/Directives.php | 20 ++ .../Directives/CacheControlDirective.php | 47 ++++ .../Directives/CacheTagDirective.php | 37 +++ src/Federation/FederatedSchema.php | 32 +++ .../Types/CacheControlScopeType.php | 33 +++ .../Utils/FederatedSchemaPrinter.php | 101 ++++++- src/Generator/TypeBuilder.php | 20 ++ src/Resources/config/listeners.yaml | 8 + src/Resources/config/services.yaml | 13 + .../CacheControlAccumulatorTest.php | 179 +++++++++++++ .../CacheControl/CacheControlRecorderTest.php | 248 +++++++++++++++++ tests/CacheControl/CacheHintResolverTest.php | 249 ++++++++++++++++++ tests/CacheControl/CachePolicyTest.php | 119 +++++++++ .../Parser/GraphQLParserCacheControlTest.php | 90 +++++++ .../fixtures/graphql/cache-control.graphql | 27 ++ .../Parser/fixtures/graphql/cache-control.php | 101 +++++++ .../CacheControlTypesConfigurationTest.php | 142 ++++++++++ .../CacheControlEntityTypeResolver.php | 20 ++ .../CacheControlFederationResolverMap.php | 29 ++ .../Resolver/CacheControlQueryResolverMap.php | 32 +++ .../CacheControlReferenceResolver.php | 21 ++ .../App/config/cacheControl/config.yml | 23 ++ .../config/cacheControl/mapping/query.graphql | 28 ++ .../config/cacheControlDisabled/config.yml | 8 + .../config/cacheControlFederation/config.yml | 37 +++ .../mapping/query.graphql | 30 +++ .../config/cacheControlIfCacheable/config.yml | 8 + .../CacheControl/CacheControlTest.php | 126 +++++++++ .../FederatedCacheControlTest.php | 101 +++++++ 58 files changed, 3223 insertions(+), 33 deletions(-) create mode 100644 docs/definitions/cache-control.md create mode 100644 src/CacheControl/CacheControlAccumulator.php create mode 100644 src/CacheControl/CacheControlRecorder.php create mode 100644 src/CacheControl/CacheHint.php create mode 100644 src/CacheControl/CacheHintResolver.php create mode 100644 src/CacheControl/CachePolicy.php create mode 100644 src/CacheControl/CacheScope.php create mode 100644 src/CacheControl/FieldCacheHint.php create mode 100644 src/Config/Parser/GraphQL/ASTConverter/CacheDirectivesNode.php create mode 100644 src/Config/Parser/GraphQL/ASTConverter/DirectiveArguments.php create mode 100644 src/EventListener/CacheControlListener.php create mode 100644 src/Federation/Directives/CacheControlDirective.php create mode 100644 src/Federation/Directives/CacheTagDirective.php create mode 100644 src/Federation/Types/CacheControlScopeType.php create mode 100644 tests/CacheControl/CacheControlAccumulatorTest.php create mode 100644 tests/CacheControl/CacheControlRecorderTest.php create mode 100644 tests/CacheControl/CacheHintResolverTest.php create mode 100644 tests/CacheControl/CachePolicyTest.php create mode 100644 tests/Config/Parser/GraphQLParserCacheControlTest.php create mode 100644 tests/Config/Parser/fixtures/graphql/cache-control.graphql create mode 100644 tests/Config/Parser/fixtures/graphql/cache-control.php create mode 100644 tests/DependencyInjection/CacheControlTypesConfigurationTest.php create mode 100644 tests/Functional/App/Resolver/CacheControlEntityTypeResolver.php create mode 100644 tests/Functional/App/Resolver/CacheControlFederationResolverMap.php create mode 100644 tests/Functional/App/Resolver/CacheControlQueryResolverMap.php create mode 100644 tests/Functional/App/Resolver/CacheControlReferenceResolver.php create mode 100644 tests/Functional/App/config/cacheControl/config.yml create mode 100644 tests/Functional/App/config/cacheControl/mapping/query.graphql create mode 100644 tests/Functional/App/config/cacheControlDisabled/config.yml create mode 100644 tests/Functional/App/config/cacheControlFederation/config.yml create mode 100644 tests/Functional/App/config/cacheControlFederation/mapping/query.graphql create mode 100644 tests/Functional/App/config/cacheControlIfCacheable/config.yml create mode 100644 tests/Functional/CacheControl/CacheControlTest.php create mode 100644 tests/Functional/CacheControl/FederatedCacheControlTest.php diff --git a/composer.json b/composer.json index 9215fc24..082be332 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "webonyx/graphql-php": "^14.5" }, "suggest": { + "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." 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/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/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..9b693cbe --- /dev/null +++ b/src/CacheControl/CacheScope.php @@ -0,0 +1,46 @@ +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..74fb72fb 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; @@ -22,6 +23,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; @@ -60,11 +62,53 @@ 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 batchingMethodSection(): EnumNodeDefinition { $builder = new TreeBuilder('batching_method', 'enum'); diff --git a/src/DependencyInjection/RedeyeGraphQLExtension.php b/src/DependencyInjection/RedeyeGraphQLExtension.php index f4639e1e..f4f71d34 100644 --- a/src/DependencyInjection/RedeyeGraphQLExtension.php +++ b/src/DependencyInjection/RedeyeGraphQLExtension.php @@ -63,6 +63,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 +159,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']) { 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/Executor/TracingReferenceExecutor.php b/src/Executor/TracingReferenceExecutor.php index a2d68a23..06a2c883 100644 --- a/src/Executor/TracingReferenceExecutor.php +++ b/src/Executor/TracingReferenceExecutor.php @@ -14,10 +14,12 @@ use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Schema; +use Redeye\GraphQLBundle\CacheControl\CacheControlRecorder; use Sentry\SentrySdk; use Sentry\Tracing\SpanContext; use Sentry\State\Scope; -use Webmozart\Assert\Assert; +use ArrayObject; +use function is_array; class TracingReferenceExecutor extends ReferenceExecutor { @@ -42,6 +44,10 @@ public static function create(PromiseAdapter $promiseAdapter, Schema $schema, Do 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 +71,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)); @@ -90,6 +94,29 @@ protected function resolveFieldValueOrError(FieldDefinition $fieldDef, FieldNode } } + /** + * 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 { foreach ($def->args as $arg) { 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/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/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/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/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')); + } +} From 4745f5ae20bc6aa3bd52ef8162a73e88f0ada1ed Mon Sep 17 00:00:00 2001 From: Magnus Nordlander Date: Tue, 18 Aug 2026 16:26:18 +0200 Subject: [PATCH 2/3] Add Apollo Federation inline tracing (ftv1) Port ApolloServerPluginInlineTrace: when the gateway sends the header `apollo-federation-include-trace: ftv1`, respond with `extensions.ftv1` carrying a base64-encoded protobuf Trace of the resolver tree. There is no published FTV1 spec, so docs/federation/inline-trace.md derives one from Apollo's reports.proto and the Apollo Server / federation-jvm implementations. The trace is assembled at three points, mirroring Apollo's plugin lifecycle: InlineTraceListener starts it on graphql.pre_executor, TracingReferenceExecutor records per-field timings, and the listener encodes it on graphql.post_executor at priority 100 -- ahead of ErrorHandlerListener, which rebuilds errors and drops their extensions. State lives in the per-execution context ArrayObject rather than on the listener service, so batched queries each get their own trace. This is the same approach @cacheControl already uses for its recorder; the two hooks sit side by side in resolveFieldValueOrError, cache-control first because it only reads the field, inline trace last because it starts a clock and should not be charged for the Sentry setup above it. Notable details: - Field end times use ReferenceExecutor::getPromise(), not the promise adapter: DataLoader returns a GraphQL\Executor\Promise\Promise, which SyncPromiseAdapter::isThenable() rejects, so convertThenable() would throw on the most common async path in this bundle. - The end callback captures the trace node, never the ResolveInfo: completeListValue() mutates the shared ResolveInfo::path per list item. - `include_errors: unmodified` reports the client-visible message via FormattedError, not $error->getMessage(). The bundle's ErrorHandler masks at response-format time, so the raw exception text (SQL, DSNs, paths) is still on the Error object while the trace is built. - The federation default is resolved in a root validate() callback. Symfony normalizes config fragments individually before merging, so a beforeNormalization would not see both keys. Generated protobuf classes live in proto/gen/, outside the paths php-cs-fixer and PHPStan scan, so neither needs exclusion config. A CI job regenerates with a pinned protoc and fails on drift. Verified against a golden ftv1 payload captured from a real Apollo Server subgraph, against raw protobuf tag bytes, and by decoding this bundle's own output with `protoc --decode_raw`. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 22 ++ composer.json | 6 +- docs/federation/inline-trace.md | 237 +++++++++++++ docs/index.md | 2 + .../Tracing/Proto/Metadata/Reports.php | 26 ++ .../Federation/Tracing/Proto/Trace.php | 244 +++++++++++++ .../Federation/Tracing/Proto/Trace/Error.php | 140 ++++++++ .../Tracing/Proto/Trace/Location.php | 86 +++++ .../Federation/Tracing/Proto/Trace/Node.php | 319 +++++++++++++++++ proto/reports.proto | 95 +++++ src/DependencyInjection/Configuration.php | 51 +++ .../RedeyeGraphQLExtension.php | 40 +++ src/EventListener/InlineTraceListener.php | 89 +++++ src/Executor/TracingReferenceExecutor.php | 98 +++++- src/Federation/Tracing/TraceError.php | 28 ++ src/Federation/Tracing/TraceErrorFilter.php | 103 ++++++ .../TraceErrorTransformerInterface.php | 28 ++ src/Federation/Tracing/TraceNode.php | 64 ++++ src/Federation/Tracing/TraceTreeBuilder.php | 261 ++++++++++++++ .../InlineTraceConfigurationTest.php | 114 ++++++ .../EventListener/InlineTraceListenerTest.php | 250 ++++++++++++++ .../Executor/TracingReferenceExecutorTest.php | 214 ++++++++++++ .../Tracing/TraceErrorFilterTest.php | 238 +++++++++++++ .../Tracing/TraceSerializationTest.php | 220 ++++++++++++ .../Tracing/TraceTreeBuilderTest.php | 324 ++++++++++++++++++ tests/Federation/Tracing/fixtures/README.md | 94 +++++ .../Tracing/fixtures/apollo-ftv1.b64 | 1 + .../App/Resolver/InlineTraceResolver.php | 45 +++ .../App/config/inlineTrace/config.yml | 16 + .../inlineTrace/mapping/inlineTrace.types.yml | 23 ++ .../App/config/inlineTrace/services.yml | 8 + .../config/inlineTraceUnmodified/config.yml | 8 + .../Functional/Federation/InlineTraceTest.php | 272 +++++++++++++++ 33 files changed, 3760 insertions(+), 6 deletions(-) create mode 100644 docs/federation/inline-trace.md create mode 100644 proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Metadata/Reports.php create mode 100644 proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php create mode 100644 proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Error.php create mode 100644 proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Location.php create mode 100644 proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php create mode 100644 proto/reports.proto create mode 100644 src/EventListener/InlineTraceListener.php create mode 100644 src/Federation/Tracing/TraceError.php create mode 100644 src/Federation/Tracing/TraceErrorFilter.php create mode 100644 src/Federation/Tracing/TraceErrorTransformerInterface.php create mode 100644 src/Federation/Tracing/TraceNode.php create mode 100644 src/Federation/Tracing/TraceTreeBuilder.php create mode 100644 tests/DependencyInjection/InlineTraceConfigurationTest.php create mode 100644 tests/EventListener/InlineTraceListenerTest.php create mode 100644 tests/Executor/TracingReferenceExecutorTest.php create mode 100644 tests/Federation/Tracing/TraceErrorFilterTest.php create mode 100644 tests/Federation/Tracing/TraceSerializationTest.php create mode 100644 tests/Federation/Tracing/TraceTreeBuilderTest.php create mode 100644 tests/Federation/Tracing/fixtures/README.md create mode 100644 tests/Federation/Tracing/fixtures/apollo-ftv1.b64 create mode 100644 tests/Functional/App/Resolver/InlineTraceResolver.php create mode 100644 tests/Functional/App/config/inlineTrace/config.yml create mode 100644 tests/Functional/App/config/inlineTrace/mapping/inlineTrace.types.yml create mode 100644 tests/Functional/App/config/inlineTrace/services.yml create mode 100644 tests/Functional/App/config/inlineTraceUnmodified/config.yml create mode 100644 tests/Functional/Federation/InlineTraceTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a3b8886..20d26def 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,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 29.3" + run: | + curl -sSLo /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-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" diff --git a/composer.json b/composer.json index 082be332..66162cc8 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": { @@ -30,6 +31,7 @@ "require": { "php": "^8.0", "ext-json": "*", + "google/protobuf": "^3.25", "murtukov/php-code-generator": "^0.1.5", "phpdocumentor/reflection-docblock": "^5.2", "phpdocumentor/type-resolver": ">1.4.0,<1.6.0", @@ -46,6 +48,7 @@ "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", @@ -89,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/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..eb3f1345 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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/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..29dbb874 --- /dev/null +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php @@ -0,0 +1,244 @@ +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($var) + { + GPBUtil::checkMessage($var, \Google\Protobuf\Timestamp::class); + $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($var) + { + GPBUtil::checkMessage($var, \Google\Protobuf\Timestamp::class); + $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($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($var) + { + GPBUtil::checkMessage($var, \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node::class); + $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($var) + { + GPBUtil::checkDouble($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..b2c49797 --- /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 array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location>|\Google\Protobuf\Internal\RepeatedField $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($var) + { + GPBUtil::checkString($var, True); + $this->message = $var; + + return $this; + } + + /** + * Generated from protobuf field repeated .Trace.Location location = 2; + * @return \Google\Protobuf\Internal\RepeatedField + */ + public function getLocation() + { + return $this->location; + } + + /** + * Generated from protobuf field repeated .Trace.Location location = 2; + * @param array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location>|\Google\Protobuf\Internal\RepeatedField $var + * @return $this + */ + public function setLocation($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($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($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..e668a305 --- /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($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($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..f3b7d668 --- /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 array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error>|\Google\Protobuf\Internal\RepeatedField $error + * @type array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node>|\Google\Protobuf\Internal\RepeatedField $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($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($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($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($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($var) + { + GPBUtil::checkUint64($var); + $this->end_time = $var; + + return $this; + } + + /** + * Generated from protobuf field repeated .Trace.Error error = 11; + * @return \Google\Protobuf\Internal\RepeatedField + */ + public function getError() + { + return $this->error; + } + + /** + * Generated from protobuf field repeated .Trace.Error error = 11; + * @param array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error>|\Google\Protobuf\Internal\RepeatedField $var + * @return $this + */ + public function setError($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 \Google\Protobuf\Internal\RepeatedField + */ + public function getChild() + { + return $this->child; + } + + /** + * Generated from protobuf field repeated .Trace.Node child = 12; + * @param array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node>|\Google\Protobuf\Internal\RepeatedField $var + * @return $this + */ + public function setChild($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($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($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..a07b765a --- /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 29.3. 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/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 74fb72fb..be1bf38c 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,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; @@ -51,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()) @@ -109,6 +125,41 @@ private function cacheControlSection(): ArrayNodeDefinition 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 f4f71d34..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); @@ -175,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/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 06a2c883..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; @@ -42,6 +57,25 @@ 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 @@ -81,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) { @@ -92,6 +137,49 @@ 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); } /** @@ -134,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/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/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/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/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/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()); + } +} From 5da8ebc781b279641c9b633220437c38be2009ed Mon Sep 17 00:00:00 2001 From: Magnus Nordlander Date: Wed, 19 Aug 2026 08:04:37 +0200 Subject: [PATCH 3/3] Require google/protobuf 5.35.1 and raise the PHP floor to 8.2 The 5.x PHP runtime is not a drop-in for 3.x: protoc's PHP backend now emits Google\Protobuf\RepeatedField instead of Google\Protobuf\Internal\RepeatedField and adds native parameter types to setters, so the generated code only works against a matching runtime. proto/gen is therefore regenerated with protoc 35.1, the release that corresponds to the 5.35.1 PHP package, and the CI drift job's pin moves with it. Raising the floor to 8.2 drops PHP 8.0 and 8.1 from the test matrix, which also retires the two 8.0-with-Symfony-6.2/6.4 excludes. The two `include` legs (the optional-dependency check and the coverage run) move to 8.2. The coding-standard and static-analysis jobs, the AppVeyor build, and the docs version table follow. CacheScope's comment claimed a native enum was impossible because the bundle supports PHP 8.0. That is no longer true, so the comment says so; the class is otherwise left alone. Co-Authored-By: Claude Opus 5 (1M context) --- .appveyor.yml | 2 +- .github/workflows/ci.yml | 19 +++------ composer.json | 4 +- docs/index.md | 2 +- .../Federation/Tracing/Proto/Trace.php | 16 +++----- .../Federation/Tracing/Proto/Trace/Error.php | 20 +++++----- .../Tracing/Proto/Trace/Location.php | 6 +-- .../Federation/Tracing/Proto/Trace/Node.php | 40 +++++++++---------- proto/reports.proto | 2 +- src/CacheControl/CacheScope.php | 3 +- 10 files changed, 52 insertions(+), 62 deletions(-) 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 20d26def..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 @@ -110,9 +103,9 @@ jobs: # 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 29.3" + - name: "Install protoc 35.1" run: | - curl -sSLo /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-linux-x86_64.zip + 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 @@ -132,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 66162cc8..8f9e1ba1 100644 --- a/composer.json +++ b/composer.json @@ -29,9 +29,9 @@ } }, "require": { - "php": "^8.0", + "php": "^8.2", "ext-json": "*", - "google/protobuf": "^3.25", + "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", diff --git a/docs/index.md b/docs/index.md index eb3f1345..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 | diff --git a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php index 29dbb874..26a09ead 100644 --- a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace.php @@ -6,8 +6,8 @@ namespace Redeye\GraphQLBundle\Federation\Tracing\Proto; use Google\Protobuf\Internal\GPBType; -use Google\Protobuf\Internal\RepeatedField; use Google\Protobuf\Internal\GPBUtil; +use Google\Protobuf\RepeatedField; /** * Generated from protobuf message Trace @@ -102,9 +102,8 @@ public function clearEndTime() * @param \Google\Protobuf\Timestamp $var * @return $this */ - public function setEndTime($var) + public function setEndTime(\Google\Protobuf\Timestamp|null $var) { - GPBUtil::checkMessage($var, \Google\Protobuf\Timestamp::class); $this->end_time = $var; return $this; @@ -138,9 +137,8 @@ public function clearStartTime() * @param \Google\Protobuf\Timestamp $var * @return $this */ - public function setStartTime($var) + public function setStartTime(\Google\Protobuf\Timestamp|null $var) { - GPBUtil::checkMessage($var, \Google\Protobuf\Timestamp::class); $this->start_time = $var; return $this; @@ -166,7 +164,7 @@ public function getDurationNs() * @param int|string $var * @return $this */ - public function setDurationNs($var) + public function setDurationNs(int|string $var) { GPBUtil::checkUint64($var); $this->duration_ns = $var; @@ -202,9 +200,8 @@ public function clearRoot() * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node $var * @return $this */ - public function setRoot($var) + public function setRoot(\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node|null $var) { - GPBUtil::checkMessage($var, \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node::class); $this->root = $var; return $this; @@ -232,9 +229,8 @@ public function getFieldExecutionWeight() * @param float $var * @return $this */ - public function setFieldExecutionWeight($var) + public function setFieldExecutionWeight(float $var) { - GPBUtil::checkDouble($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 index b2c49797..72f16e19 100644 --- a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Error.php +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Error.php @@ -6,8 +6,8 @@ namespace Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace; use Google\Protobuf\Internal\GPBType; -use Google\Protobuf\Internal\RepeatedField; use Google\Protobuf\Internal\GPBUtil; +use Google\Protobuf\RepeatedField; /** * Generated from protobuf message Trace.Error @@ -38,7 +38,7 @@ class Error extends \Google\Protobuf\Internal\Message * Optional. Data for populating the Message object. * * @type string $message - * @type array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location>|\Google\Protobuf\Internal\RepeatedField $location + * @type \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location[] $location * @type int|string $time_ns * @type string $json * } @@ -62,9 +62,9 @@ public function getMessage() * @param string $var * @return $this */ - public function setMessage($var) + public function setMessage(string $var) { - GPBUtil::checkString($var, True); + GPBUtil::checkString($var, true); $this->message = $var; return $this; @@ -72,7 +72,7 @@ public function setMessage($var) /** * Generated from protobuf field repeated .Trace.Location location = 2; - * @return \Google\Protobuf\Internal\RepeatedField + * @return RepeatedField<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location> */ public function getLocation() { @@ -81,10 +81,10 @@ public function getLocation() /** * Generated from protobuf field repeated .Trace.Location location = 2; - * @param array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location>|\Google\Protobuf\Internal\RepeatedField $var + * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Location[] $var * @return $this */ - public function setLocation($var) + 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; @@ -106,7 +106,7 @@ public function getTimeNs() * @param int|string $var * @return $this */ - public function setTimeNs($var) + public function setTimeNs(int|string $var) { GPBUtil::checkUint64($var); $this->time_ns = $var; @@ -128,9 +128,9 @@ public function getJson() * @param string $var * @return $this */ - public function setJson($var) + public function setJson(string $var) { - GPBUtil::checkString($var, True); + 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 index e668a305..d7cccd05 100644 --- a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Location.php +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Location.php @@ -6,8 +6,8 @@ namespace Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace; use Google\Protobuf\Internal\GPBType; -use Google\Protobuf\Internal\RepeatedField; use Google\Protobuf\Internal\GPBUtil; +use Google\Protobuf\RepeatedField; /** * Generated from protobuf message Trace.Location @@ -52,7 +52,7 @@ public function getLine() * @param int $var * @return $this */ - public function setLine($var) + public function setLine(int $var) { GPBUtil::checkUint32($var); $this->line = $var; @@ -74,7 +74,7 @@ public function getColumn() * @param int $var * @return $this */ - public function setColumn($var) + public function setColumn(int $var) { GPBUtil::checkUint32($var); $this->column = $var; diff --git a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php index f3b7d668..2dbd5e1c 100644 --- a/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php +++ b/proto/gen/Redeye/GraphQLBundle/Federation/Tracing/Proto/Trace/Node.php @@ -6,8 +6,8 @@ namespace Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace; use Google\Protobuf\Internal\GPBType; -use Google\Protobuf\Internal\RepeatedField; use Google\Protobuf\Internal\GPBUtil; +use Google\Protobuf\RepeatedField; /** * We store information on each resolver execution as a Node on a tree. The structure @@ -71,8 +71,8 @@ class Node extends \Google\Protobuf\Internal\Message * relative to the trace's start_time, in ns * @type int|string $end_time * relative to the trace's start_time, in ns - * @type array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error>|\Google\Protobuf\Internal\RepeatedField $error - * @type array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node>|\Google\Protobuf\Internal\RepeatedField $child + * @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 @@ -102,9 +102,9 @@ public function hasResponseName() * @param string $var * @return $this */ - public function setResponseName($var) + public function setResponseName(string $var) { - GPBUtil::checkString($var, True); + GPBUtil::checkString($var, true); $this->writeOneof(1, $var); return $this; @@ -129,7 +129,7 @@ public function hasIndex() * @param int $var * @return $this */ - public function setIndex($var) + public function setIndex(int $var) { GPBUtil::checkUint32($var); $this->writeOneof(2, $var); @@ -155,9 +155,9 @@ public function getType() * @param string $var * @return $this */ - public function setType($var) + public function setType(string $var) { - GPBUtil::checkString($var, True); + GPBUtil::checkString($var, true); $this->type = $var; return $this; @@ -181,7 +181,7 @@ public function getStartTime() * @param int|string $var * @return $this */ - public function setStartTime($var) + public function setStartTime(int|string $var) { GPBUtil::checkUint64($var); $this->start_time = $var; @@ -207,7 +207,7 @@ public function getEndTime() * @param int|string $var * @return $this */ - public function setEndTime($var) + public function setEndTime(int|string $var) { GPBUtil::checkUint64($var); $this->end_time = $var; @@ -217,7 +217,7 @@ public function setEndTime($var) /** * Generated from protobuf field repeated .Trace.Error error = 11; - * @return \Google\Protobuf\Internal\RepeatedField + * @return RepeatedField<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error> */ public function getError() { @@ -226,10 +226,10 @@ public function getError() /** * Generated from protobuf field repeated .Trace.Error error = 11; - * @param array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error>|\Google\Protobuf\Internal\RepeatedField $var + * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Error[] $var * @return $this */ - public function setError($var) + 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; @@ -239,7 +239,7 @@ public function setError($var) /** * Generated from protobuf field repeated .Trace.Node child = 12; - * @return \Google\Protobuf\Internal\RepeatedField + * @return RepeatedField<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node> */ public function getChild() { @@ -248,10 +248,10 @@ public function getChild() /** * Generated from protobuf field repeated .Trace.Node child = 12; - * @param array<\Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node>|\Google\Protobuf\Internal\RepeatedField $var + * @param \Redeye\GraphQLBundle\Federation\Tracing\Proto\Trace\Node[] $var * @return $this */ - public function setChild($var) + 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; @@ -277,9 +277,9 @@ public function getParentType() * @param string $var * @return $this */ - public function setParentType($var) + public function setParentType(string $var) { - GPBUtil::checkString($var, True); + GPBUtil::checkString($var, true); $this->parent_type = $var; return $this; @@ -299,9 +299,9 @@ public function getOriginalFieldName() * @param string $var * @return $this */ - public function setOriginalFieldName($var) + public function setOriginalFieldName(string $var) { - GPBUtil::checkString($var, True); + GPBUtil::checkString($var, true); $this->original_field_name = $var; return $this; diff --git a/proto/reports.proto b/proto/reports.proto index a07b765a..91ad62a2 100644 --- a/proto/reports.proto +++ b/proto/reports.proto @@ -17,7 +17,7 @@ syntax = "proto3"; // // Regenerate with: composer proto // -// Generated with protoc 29.3. protoc's PHP backend changes its output between +// 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"; diff --git a/src/CacheControl/CacheScope.php b/src/CacheControl/CacheScope.php index 9b693cbe..9de48579 100644 --- a/src/CacheControl/CacheScope.php +++ b/src/CacheControl/CacheScope.php @@ -7,7 +7,8 @@ /** * The values of the `CacheControlScope` enum used by the `@cacheControl` directive. * - * A plain constant holder rather than a native enum: this bundle supports PHP 8.0. + * A plain constant holder rather than a native enum. That was originally forced by the PHP 8.0 + * floor; the floor is 8.2 now, so this could be converted to a native enum. */ final class CacheScope {