From 6e5384c44703f5349248d754729bdcb7a940b0ce Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 29 Mar 2026 12:26:38 -0700 Subject: [PATCH 1/4] Add CacheControl support to fusion --- src/All.slnx | 3 + .../Caching/HotChocolate.Caching.slnx | 3 + .../CacheControlConstraintsComputer.cs | 301 +++++++++++++++++ .../CacheControlDefaults.cs | 0 .../CacheControlScope.cs | 0 ...QueryCacheQueryRequestBuilderExtensions.cs | 4 + .../HotChocolate.Caching.Core.csproj | 45 +++ .../ICacheControlConstraints.cs | 0 .../ImmutableCacheConstraints.cs | 5 + .../Options/CacheControlOptions.cs | 0 .../Options/CacheControlOptionsAccessor.cs | 0 .../Options/ICacheControlOptions.cs | 2 +- .../Options/ICacheControlOptionsAccessor.cs | 0 .../CacheControlCoreResources.Designer.cs | 54 ++++ .../Properties/CacheControlCoreResources.resx | 64 ++++ .../QueryCacheMiddleware.cs | 20 +- .../{Caching => Caching.Core}/ThrowHelper.cs | 2 +- .../CacheControlConstraintsOptimizer.cs | 197 +---------- ...ontrolInterfaceTypeDescriptorExtensions.cs | 4 + ...eControlObjectFieldDescriptorExtensions.cs | 4 + ...heControlObjectTypeDescriptorExtensions.cs | 4 + .../CacheControlSchemaBuilderExtensions.cs | 4 + ...cheControlUnionTypeDescriptorExtensions.cs | 4 + ...ryCacheRequestExecutorBuilderExtensions.cs | 50 +-- .../src/Caching/HotChocolate.Caching.csproj | 1 + .../CacheControlResources.Designer.cs | 60 ++-- .../Properties/CacheControlResources.resx | 6 - .../test/Caching.Tests/HttpCachingTests.cs | 34 +- ...colateExecutionRequestContextExtensions.cs | 1 + .../Fusion/HotChocolate.Fusion.slnx | 2 + .../CacheControlPlannerInterceptor.cs | 29 ++ .../FusionCachingGatewayBuilderExtensions.cs | 84 +++++ .../HotChocolate.Fusion.Caching.csproj | 19 ++ .../CacheControlDirectiveMerger.cs | 8 +- .../FusionRequestContextExtensions.cs | 1 + .../FusionCachingTests.cs | 305 ++++++++++++++++++ .../HotChocolate.Fusion.Caching.Tests.csproj | 15 + 37 files changed, 1044 insertions(+), 291 deletions(-) create mode 100644 src/HotChocolate/Caching/src/Caching.Core/CacheControlConstraintsComputer.cs rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/CacheControlDefaults.cs (100%) rename src/HotChocolate/Caching/src/{Caching/Types => Caching.Core}/CacheControlScope.cs (100%) rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/Extensions/QueryCacheQueryRequestBuilderExtensions.cs (76%) create mode 100644 src/HotChocolate/Caching/src/Caching.Core/HotChocolate.Caching.Core.csproj rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/ICacheControlConstraints.cs (100%) rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/ImmutableCacheConstraints.cs (71%) rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/Options/CacheControlOptions.cs (100%) rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/Options/CacheControlOptionsAccessor.cs (100%) rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/Options/ICacheControlOptions.cs (92%) rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/Options/ICacheControlOptionsAccessor.cs (100%) create mode 100644 src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.Designer.cs create mode 100644 src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.resx rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/QueryCacheMiddleware.cs (70%) rename src/HotChocolate/Caching/src/{Caching => Caching.Core}/ThrowHelper.cs (77%) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Caching/CacheControlPlannerInterceptor.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Caching/Extensions/FusionCachingGatewayBuilderExtensions.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Caching/HotChocolate.Fusion.Caching.csproj create mode 100644 src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Caching.Tests/HotChocolate.Fusion.Caching.Tests.csproj diff --git a/src/All.slnx b/src/All.slnx index c856c1f63a2..52396a2ede2 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -73,6 +73,7 @@ + @@ -213,6 +214,7 @@ + @@ -226,6 +228,7 @@ + diff --git a/src/HotChocolate/Caching/HotChocolate.Caching.slnx b/src/HotChocolate/Caching/HotChocolate.Caching.slnx index 45a48371fdd..87860220358 100644 --- a/src/HotChocolate/Caching/HotChocolate.Caching.slnx +++ b/src/HotChocolate/Caching/HotChocolate.Caching.slnx @@ -2,6 +2,9 @@ + + + diff --git a/src/HotChocolate/Caching/src/Caching.Core/CacheControlConstraintsComputer.cs b/src/HotChocolate/Caching/src/Caching.Core/CacheControlConstraintsComputer.cs new file mode 100644 index 00000000000..e05836fdef4 --- /dev/null +++ b/src/HotChocolate/Caching/src/Caching.Core/CacheControlConstraintsComputer.cs @@ -0,0 +1,301 @@ +using System.Collections.Immutable; +using HotChocolate.Execution; +using HotChocolate.Language; +using HotChocolate.Types; +using Microsoft.Net.Http.Headers; + +namespace HotChocolate.Caching; + +/// +/// Computes the cache control constraints for an operation by walking +/// the selection set and reading @cacheControl directives from the type system abstractions. +/// +internal static class CacheControlConstraintsComputer +{ + private const string DirectiveName = "cacheControl"; + private const string MaxAgeArg = "maxAge"; + private const string SharedMaxAgeArg = "sharedMaxAge"; + private const string InheritMaxAgeArg = "inheritMaxAge"; + private const string ScopeArg = "scope"; + private const string VaryArg = "vary"; + + /// + /// Computes the cache constraints for the given operation. + /// Returns null if the operation is not a query or no constraints apply. + /// + public static ImmutableCacheConstraints? Compute(IOperation operation) + { + if (operation.Definition.Operation is not OperationType.Query) + { + return null; + } + + var constraints = new CacheControlConstraints(); + var rootSelections = operation.RootSelectionSet.GetSelections(); + + foreach (var rootSelection in rootSelections) + { + if (rootSelection.Field.IsIntrospectionField + && !rootSelection.Field.Name.Equals("__typename", StringComparison.Ordinal)) + { + // If this is an introspection query, we will not cache it. + return null; + } + + ProcessSelection(rootSelection, constraints, operation); + } + + if (constraints.MaxAge is null && constraints.SharedMaxAge is null) + { + return null; + } + + ImmutableArray vary; + if (constraints.Vary is not null) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var value in constraints.Vary.Order(StringComparer.OrdinalIgnoreCase)) + { + builder.Add(value.ToLowerInvariant()); + } + + vary = builder.ToImmutable(); + } + else + { + vary = []; + } + + return new ImmutableCacheConstraints( + constraints.MaxAge, + constraints.SharedMaxAge, + constraints.Scope, + vary); + } + + /// + /// Creates a from the given constraints. + /// + public static CacheControlHeaderValue CreateHeaderValue(ImmutableCacheConstraints constraints) + { + return new CacheControlHeaderValue + { + Private = constraints.Scope == CacheControlScope.Private, + MaxAge = constraints.MaxAge is not null + ? TimeSpan.FromSeconds(constraints.MaxAge.Value) + : null, + SharedMaxAge = constraints.SharedMaxAge is not null + ? TimeSpan.FromSeconds(constraints.SharedMaxAge.Value) + : null + }; + } + + private static void ProcessSelection( + ISelection selection, + CacheControlConstraints constraints, + IOperation operation) + { + var field = selection.Field; + var maxAgeSet = false; + var sharedMaxAgeSet = false; + var scopeSet = false; + var varySet = false; + + ExtractCacheControlFromDirectives(field); + + if (!maxAgeSet || !sharedMaxAgeSet || !scopeSet || !varySet) + { + // Either maxAge or scope have not been specified by the @cacheControl + // directive on the field, so we try to infer these details + // from the type of the field. + + if (field.Type is IDirectivesProvider type) + { + // The type of the field is complex and can therefore be + // annotated with a @cacheControl directive. + ExtractCacheControlFromDirectives(type); + } + } + + if (!selection.IsLeaf) + { + var possibleTypes = operation.GetPossibleTypes(selection); + + foreach (var type in possibleTypes) + { + var selectionSet = operation.GetSelectionSet(selection, type); + var selections = selectionSet.GetSelections(); + + foreach (var childSelection in selections) + { + ProcessSelection(childSelection, constraints, operation); + } + } + } + + void ExtractCacheControlFromDirectives( + IDirectivesProvider typeSystemMember) + { + var directive = typeSystemMember.Directives.FirstOrDefault(DirectiveName); + + if (directive is null) + { + return; + } + + var directiveMaxAge = GetIntArgument(directive, MaxAgeArg); + var directiveSharedMaxAge = GetIntArgument(directive, SharedMaxAgeArg); + var directiveInheritMaxAge = GetBoolArgument(directive, InheritMaxAgeArg); + var directiveScope = GetScopeArgument(directive, ScopeArg); + var directiveVary = GetStringListArgument(directive, VaryArg); + + var previousMaxAge = constraints.MaxAge; + if (!maxAgeSet + && directiveMaxAge.HasValue) + { + // If only max-age has been set, we honor the expected behavior that a CDN + // cannot ever cache longer than this unless s-maxage specifies otherwise. + if (!constraints.MaxAge.HasValue || directiveMaxAge < constraints.MaxAge.Value) + { + constraints.MaxAge = directiveMaxAge.Value; + } + + if (!directiveSharedMaxAge.HasValue + && constraints.SharedMaxAge.HasValue + && constraints.SharedMaxAge.Value > directiveMaxAge.Value) + { + constraints.SharedMaxAge = directiveMaxAge; + } + + maxAgeSet = true; + } + else if (directiveInheritMaxAge == true) + { + // If inheritMaxAge is set, we keep the + // computed maxAge value as is. + maxAgeSet = true; + } + + if (!sharedMaxAgeSet + && directiveSharedMaxAge.HasValue + && (!constraints.SharedMaxAge.HasValue || directiveSharedMaxAge < constraints.SharedMaxAge.Value)) + { + // The maxAge of the @cacheControl directive is lower + // than the previously lowest maxAge value. + if (!constraints.SharedMaxAge.HasValue + && previousMaxAge.HasValue + && previousMaxAge.Value < directiveSharedMaxAge.Value) + { + // If only max-age has been set, we honor the expected behavior that a CDN + // cannot ever cache longer than this unless s-maxage specifies otherwise. + constraints.SharedMaxAge = previousMaxAge.Value; + } + else + { + constraints.SharedMaxAge = directiveSharedMaxAge.Value; + } + + sharedMaxAgeSet = true; + } + else if (directiveInheritMaxAge == true) + { + // If inheritMaxAge is set, we keep the + // computed maxAge value as is. + sharedMaxAgeSet = true; + } + + if (directiveScope.HasValue + && directiveScope < constraints.Scope) + { + // The scope of the @cacheControl directive is more + // restrictive than the computed scope. + constraints.Scope = directiveScope.Value; + scopeSet = true; + } + + if (directiveVary is { Length: > 0 }) + { + constraints.Vary ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var value in directiveVary) + { + constraints.Vary.Add(value); + } + + varySet = true; + } + } + } + + private static int? GetIntArgument(IDirective directive, string name) + { + var value = directive.Arguments.GetValueOrDefault(name); + + if (value is IntValueNode intNode) + { + return intNode.ToInt32(); + } + + return null; + } + + private static bool? GetBoolArgument(IDirective directive, string name) + { + var value = directive.Arguments.GetValueOrDefault(name); + + if (value is BooleanValueNode boolNode) + { + return boolNode.Value; + } + + return null; + } + + private static CacheControlScope? GetScopeArgument(IDirective directive, string name) + { + var value = directive.Arguments.GetValueOrDefault(name); + + if (value is EnumValueNode enumNode) + { + if (Enum.TryParse(enumNode.Value, ignoreCase: true, out var scope)) + { + return scope; + } + } + + return null; + } + + private static string[]? GetStringListArgument(IDirective directive, string name) + { + var value = directive.Arguments.GetValueOrDefault(name); + + if (value is ListValueNode listNode && listNode.Items.Count > 0) + { + var result = new string[listNode.Items.Count]; + for (var i = 0; i < listNode.Items.Count; i++) + { + if (listNode.Items[i] is StringValueNode strNode) + { + result[i] = strNode.Value; + } + } + + return result; + } + + return null; + } + + private sealed class CacheControlConstraints + { + public CacheControlScope Scope { get; set; } = CacheControlScope.Public; + + internal int? MaxAge { get; set; } + + internal int? SharedMaxAge { get; set; } + + internal HashSet? Vary { get; set; } + } +} diff --git a/src/HotChocolate/Caching/src/Caching/CacheControlDefaults.cs b/src/HotChocolate/Caching/src/Caching.Core/CacheControlDefaults.cs similarity index 100% rename from src/HotChocolate/Caching/src/Caching/CacheControlDefaults.cs rename to src/HotChocolate/Caching/src/Caching.Core/CacheControlDefaults.cs diff --git a/src/HotChocolate/Caching/src/Caching/Types/CacheControlScope.cs b/src/HotChocolate/Caching/src/Caching.Core/CacheControlScope.cs similarity index 100% rename from src/HotChocolate/Caching/src/Caching/Types/CacheControlScope.cs rename to src/HotChocolate/Caching/src/Caching.Core/CacheControlScope.cs diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheQueryRequestBuilderExtensions.cs b/src/HotChocolate/Caching/src/Caching.Core/Extensions/QueryCacheQueryRequestBuilderExtensions.cs similarity index 76% rename from src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheQueryRequestBuilderExtensions.cs rename to src/HotChocolate/Caching/src/Caching.Core/Extensions/QueryCacheQueryRequestBuilderExtensions.cs index 126996b75de..5987fef8e11 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheQueryRequestBuilderExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching.Core/Extensions/QueryCacheQueryRequestBuilderExtensions.cs @@ -1,5 +1,9 @@ namespace HotChocolate.Execution; +/// +/// Provides extension methods for +/// to control query result caching behavior. +/// public static class QueryCacheOperationRequestBuilderExtensions { /// diff --git a/src/HotChocolate/Caching/src/Caching.Core/HotChocolate.Caching.Core.csproj b/src/HotChocolate/Caching/src/Caching.Core/HotChocolate.Caching.Core.csproj new file mode 100644 index 00000000000..9283a0e70f0 --- /dev/null +++ b/src/HotChocolate/Caching/src/Caching.Core/HotChocolate.Caching.Core.csproj @@ -0,0 +1,45 @@ + + + + HotChocolate.Caching.Core + HotChocolate.Caching.Core + HotChocolate.Caching + + + + + + + + + + + + + + + + + + + + + + + + + + True + True + CacheControlCoreResources.resx + + + + + + ResXFileCodeGenerator + CacheControlCoreResources.Designer.cs + + + + diff --git a/src/HotChocolate/Caching/src/Caching/ICacheControlConstraints.cs b/src/HotChocolate/Caching/src/Caching.Core/ICacheControlConstraints.cs similarity index 100% rename from src/HotChocolate/Caching/src/Caching/ICacheControlConstraints.cs rename to src/HotChocolate/Caching/src/Caching.Core/ICacheControlConstraints.cs diff --git a/src/HotChocolate/Caching/src/Caching/ImmutableCacheConstraints.cs b/src/HotChocolate/Caching/src/Caching.Core/ImmutableCacheConstraints.cs similarity index 71% rename from src/HotChocolate/Caching/src/Caching/ImmutableCacheConstraints.cs rename to src/HotChocolate/Caching/src/Caching.Core/ImmutableCacheConstraints.cs index 91a1f454ef5..7f4bff21a54 100644 --- a/src/HotChocolate/Caching/src/Caching/ImmutableCacheConstraints.cs +++ b/src/HotChocolate/Caching/src/Caching.Core/ImmutableCacheConstraints.cs @@ -2,6 +2,11 @@ namespace HotChocolate.Caching; +/// +/// An immutable snapshot of the computed cache control constraints for an operation, +/// representing the most restrictive combination of all @cacheControl directives +/// encountered while walking the operation's selection set. +/// internal sealed class ImmutableCacheConstraints( int? maxAge, int? sharedMaxAge, diff --git a/src/HotChocolate/Caching/src/Caching/Options/CacheControlOptions.cs b/src/HotChocolate/Caching/src/Caching.Core/Options/CacheControlOptions.cs similarity index 100% rename from src/HotChocolate/Caching/src/Caching/Options/CacheControlOptions.cs rename to src/HotChocolate/Caching/src/Caching.Core/Options/CacheControlOptions.cs diff --git a/src/HotChocolate/Caching/src/Caching/Options/CacheControlOptionsAccessor.cs b/src/HotChocolate/Caching/src/Caching.Core/Options/CacheControlOptionsAccessor.cs similarity index 100% rename from src/HotChocolate/Caching/src/Caching/Options/CacheControlOptionsAccessor.cs rename to src/HotChocolate/Caching/src/Caching.Core/Options/CacheControlOptionsAccessor.cs diff --git a/src/HotChocolate/Caching/src/Caching/Options/ICacheControlOptions.cs b/src/HotChocolate/Caching/src/Caching.Core/Options/ICacheControlOptions.cs similarity index 92% rename from src/HotChocolate/Caching/src/Caching/Options/ICacheControlOptions.cs rename to src/HotChocolate/Caching/src/Caching.Core/Options/ICacheControlOptions.cs index 767eb780079..edf57c22102 100644 --- a/src/HotChocolate/Caching/src/Caching/Options/ICacheControlOptions.cs +++ b/src/HotChocolate/Caching/src/Caching.Core/Options/ICacheControlOptions.cs @@ -27,7 +27,7 @@ public interface ICacheControlOptions /// /// Denotes whether the and /// should be applied to all fields that do not already specify a - /// , are fields on the Query root type + /// @cacheControl directive, are fields on the Query root type /// or are responsible for fetching data. /// bool ApplyDefaults { get; } diff --git a/src/HotChocolate/Caching/src/Caching/Options/ICacheControlOptionsAccessor.cs b/src/HotChocolate/Caching/src/Caching.Core/Options/ICacheControlOptionsAccessor.cs similarity index 100% rename from src/HotChocolate/Caching/src/Caching/Options/ICacheControlOptionsAccessor.cs rename to src/HotChocolate/Caching/src/Caching.Core/Options/ICacheControlOptionsAccessor.cs diff --git a/src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.Designer.cs b/src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.Designer.cs new file mode 100644 index 00000000000..08bc3ceaa41 --- /dev/null +++ b/src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.Designer.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace HotChocolate.Caching.Properties { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class CacheControlCoreResources { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal CacheControlCoreResources() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("HotChocolate.Caching.Properties.CacheControlCoreResources", typeof(CacheControlCoreResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string ThrowHelper_UnexpectedCacheControlScopeValue { + get { + return ResourceManager.GetString("ThrowHelper_UnexpectedCacheControlScopeValue", resourceCulture); + } + } + } +} diff --git a/src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.resx b/src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.resx new file mode 100644 index 00000000000..f09b5f8f15c --- /dev/null +++ b/src/HotChocolate/Caching/src/Caching.Core/Properties/CacheControlCoreResources.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Unexpected CacheControlScope value {0} + + diff --git a/src/HotChocolate/Caching/src/Caching/QueryCacheMiddleware.cs b/src/HotChocolate/Caching/src/Caching.Core/QueryCacheMiddleware.cs similarity index 70% rename from src/HotChocolate/Caching/src/Caching/QueryCacheMiddleware.cs rename to src/HotChocolate/Caching/src/Caching.Core/QueryCacheMiddleware.cs index 3c9f0ecc49b..24285b5d63b 100644 --- a/src/HotChocolate/Caching/src/Caching/QueryCacheMiddleware.cs +++ b/src/HotChocolate/Caching/src/Caching.Core/QueryCacheMiddleware.cs @@ -5,6 +5,17 @@ namespace HotChocolate.Caching; +/// +/// A shared request middleware that reads computed cache control constraints +/// from the operation's features and writes them to the result's context data. +/// The ASP.NET Core HTTP response formatter then translates these into +/// Cache-Control and Vary HTTP response headers. +/// +/// +/// Both HotChocolate and Fusion store the on the +/// request context's feature collection, so this middleware reads it directly +/// from context.Features.Get<IOperation>(). +/// internal sealed class QueryCacheMiddleware { private readonly ICacheControlOptions _options; @@ -12,7 +23,7 @@ internal sealed class QueryCacheMiddleware private QueryCacheMiddleware( RequestDelegate next, - [SchemaService] ICacheControlOptionsAccessor optionsAccessor) + ICacheControlOptionsAccessor optionsAccessor) { _next = next; _options = optionsAccessor.CacheControl; @@ -29,7 +40,9 @@ public async ValueTask InvokeAsync(RequestContext context) return; } - if (!context.TryGetOperation(out var operation) + var operation = context.Features.Get(); + + if (operation is null || !operation.Features.TryGet(out var headerValue) || !operation.Features.TryGet(out var constraints)) { @@ -49,6 +62,9 @@ public async ValueTask InvokeAsync(RequestContext context) } } + /// + /// Creates a for the query cache middleware. + /// internal static RequestMiddlewareConfiguration Create() => new RequestMiddlewareConfiguration( (core, next) => diff --git a/src/HotChocolate/Caching/src/Caching/ThrowHelper.cs b/src/HotChocolate/Caching/src/Caching.Core/ThrowHelper.cs similarity index 77% rename from src/HotChocolate/Caching/src/Caching/ThrowHelper.cs rename to src/HotChocolate/Caching/src/Caching.Core/ThrowHelper.cs index ab13f50a750..16746be179a 100644 --- a/src/HotChocolate/Caching/src/Caching/ThrowHelper.cs +++ b/src/HotChocolate/Caching/src/Caching.Core/ThrowHelper.cs @@ -1,4 +1,4 @@ -using static HotChocolate.Caching.Properties.CacheControlResources; +using static HotChocolate.Caching.Properties.CacheControlCoreResources; namespace HotChocolate.Caching; diff --git a/src/HotChocolate/Caching/src/Caching/CacheControlConstraintsOptimizer.cs b/src/HotChocolate/Caching/src/Caching/CacheControlConstraintsOptimizer.cs index a12a2c80b87..9950925ae23 100644 --- a/src/HotChocolate/Caching/src/Caching/CacheControlConstraintsOptimizer.cs +++ b/src/HotChocolate/Caching/src/Caching/CacheControlConstraintsOptimizer.cs @@ -1,9 +1,7 @@ -using System.Collections.Immutable; using HotChocolate.Execution.Processing; using HotChocolate.Language; using HotChocolate.Types; using HotChocolate.Utilities; -using Microsoft.Net.Http.Headers; namespace HotChocolate.Caching; @@ -14,200 +12,22 @@ internal sealed class CacheControlConstraintsOptimizer : IOperationOptimizer { public void OptimizeOperation(OperationOptimizerContext context) { - // TODO : we need to include this again when defer is back. if (context.Operation.Kind is not OperationType.Query - // || context.HasIncrementalParts || ContainsIntrospectionFields(context)) { - // if this is an introspection query, we will not cache it. return; } - var constraints = ComputeCacheControlConstraints(context.Operation); + var constraints = CacheControlConstraintsComputer.Compute(context.Operation); - if (constraints.MaxAge is not null || constraints.SharedMaxAge is not null) + if (constraints is not null) { - var headerValue = new CacheControlHeaderValue - { - Private = constraints.Scope == CacheControlScope.Private, - MaxAge = constraints.MaxAge is not null - ? TimeSpan.FromSeconds(constraints.MaxAge.Value) - : null, - SharedMaxAge = constraints.SharedMaxAge is not null - ? TimeSpan.FromSeconds(constraints.SharedMaxAge.Value) - : null - }; - + var headerValue = CacheControlConstraintsComputer.CreateHeaderValue(constraints); context.Operation.Features.SetSafe(constraints); context.Operation.Features.SetSafe(headerValue); } } - private static ImmutableCacheConstraints ComputeCacheControlConstraints( - Operation operation) - { - var constraints = new CacheControlConstraints(); - var rootSelections = operation.RootSelectionSet.Selections; - - foreach (var rootSelection in rootSelections) - { - ProcessSelection(rootSelection, constraints, operation); - } - - ImmutableArray vary; - if (constraints.Vary is not null) - { - var builder = ImmutableArray.CreateBuilder(); - - foreach (var value in constraints.Vary.Order(StringComparer.OrdinalIgnoreCase)) - { - builder.Add(value.ToLowerInvariant()); - } - - vary = builder.ToImmutable(); - } - else - { - vary = []; - } - - return new ImmutableCacheConstraints( - constraints.MaxAge, - constraints.SharedMaxAge, - constraints.Scope, - vary); - } - - private static void ProcessSelection( - Selection selection, - CacheControlConstraints constraints, - Operation operation) - { - var field = selection.Field; - var maxAgeSet = false; - var sharedMaxAgeSet = false; - var scopeSet = false; - var varySet = false; - - ExtractCacheControlDetailsFromDirectives(field); - - if (!maxAgeSet || !sharedMaxAgeSet || !scopeSet || !varySet) - { - // Either maxAge or scope have not been specified by the @cacheControl - // directive on the field, so we try to infer these details - // from the type of the field. - - if (field.Type is IDirectivesProvider type) - { - // The type of the field is complex and can therefore be - // annotated with a @cacheControl directive. - ExtractCacheControlDetailsFromDirectives(type); - } - } - - if (selection.HasSelections) - { - var possibleTypes = operation.GetPossibleTypes(selection); - - foreach (var type in possibleTypes) - { - var selectionSet = operation.GetSelectionSet(selection, type); - var selections = selectionSet.Selections; - - foreach (var childSelection in selections) - { - ProcessSelection(childSelection, constraints, operation); - } - } - } - - void ExtractCacheControlDetailsFromDirectives( - IDirectivesProvider typeSystemMember) - { - var directive = typeSystemMember.Directives.FirstOrDefaultValue( - CacheControlDirectiveType.Names.DirectiveName); - - if (directive is not null) - { - var previousMaxAge = constraints.MaxAge; - if (!maxAgeSet - && directive.MaxAge.HasValue) - { - // If only max-age has been set, we honor the expected behavior that a CDN - // cannot ever cache longer than this unless s-maxage specifies otherwise. - if (!constraints.MaxAge.HasValue || directive.MaxAge < constraints.MaxAge.Value) - { - constraints.MaxAge = directive.MaxAge.Value; - } - - if (!directive.SharedMaxAge.HasValue - && constraints.SharedMaxAge.HasValue - && constraints.SharedMaxAge.Value > directive.MaxAge.Value) - { - constraints.SharedMaxAge = directive.MaxAge; - } - - maxAgeSet = true; - } - else if (directive.InheritMaxAge == true) - { - // If inheritMaxAge is set, we keep the - // computed maxAge value as is. - maxAgeSet = true; - } - - if (!sharedMaxAgeSet - && directive.SharedMaxAge.HasValue - && (!constraints.SharedMaxAge.HasValue || directive.SharedMaxAge < constraints.SharedMaxAge.Value)) - { - // The maxAge of the @cacheControl directive is lower - // than the previously lowest maxAge value. - if (!constraints.SharedMaxAge.HasValue - && previousMaxAge.HasValue - && previousMaxAge.Value < directive.SharedMaxAge.Value) - { - // If only max-age has been set, we honor the expected behavior that a CDN - // cannot ever cache longer than this unless s-maxage specifies otherwise. - constraints.SharedMaxAge = previousMaxAge.Value; - } - else - { - constraints.SharedMaxAge = directive.SharedMaxAge.Value; - } - - sharedMaxAgeSet = true; - } - else if (directive.InheritMaxAge == true) - { - // If inheritMaxAge is set, we keep the - // computed maxAge value as is. - sharedMaxAgeSet = true; - } - - if (directive.Scope.HasValue - && directive.Scope < constraints.Scope) - { - // The scope of the @cacheControl directive is more - // restrictive than the computed scope. - constraints.Scope = directive.Scope.Value; - scopeSet = true; - } - - if (directive.Vary is { Length: > 0 }) - { - constraints.Vary ??= new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var value in directive.Vary) - { - constraints.Vary.Add(value); - } - - varySet = true; - } - } - } - } - private static bool ContainsIntrospectionFields(OperationOptimizerContext context) { var selections = context.Operation.RootSelectionSet.Selections; @@ -224,15 +44,4 @@ private static bool ContainsIntrospectionFields(OperationOptimizerContext contex return false; } - - private sealed class CacheControlConstraints - { - public CacheControlScope Scope { get; set; } = CacheControlScope.Public; - - internal int? MaxAge { get; set; } - - internal int? SharedMaxAge { get; set; } - - internal HashSet? Vary { get; set; } - } } diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlInterfaceTypeDescriptorExtensions.cs b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlInterfaceTypeDescriptorExtensions.cs index ead983b0482..145631d5bdb 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlInterfaceTypeDescriptorExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlInterfaceTypeDescriptorExtensions.cs @@ -3,6 +3,10 @@ namespace HotChocolate.Types; +/// +/// Provides extension methods for +/// to apply @cacheControl directives to interface types. +/// public static class CacheControlInterfaceTypeDescriptorExtensions { /// diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectFieldDescriptorExtensions.cs b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectFieldDescriptorExtensions.cs index 0d867ad55fd..25719e6f5a8 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectFieldDescriptorExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectFieldDescriptorExtensions.cs @@ -3,6 +3,10 @@ namespace HotChocolate.Types; +/// +/// Provides extension methods for +/// to apply @cacheControl directives to object fields. +/// public static class CacheControlObjectFieldDescriptorExtensions { /// diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectTypeDescriptorExtensions.cs b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectTypeDescriptorExtensions.cs index 8e171780a48..00fe63d00e6 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectTypeDescriptorExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlObjectTypeDescriptorExtensions.cs @@ -3,6 +3,10 @@ namespace HotChocolate.Types; +/// +/// Provides extension methods for +/// to apply @cacheControl directives to object types. +/// public static class CacheControlObjectTypeDescriptorExtensions { /// diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlSchemaBuilderExtensions.cs b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlSchemaBuilderExtensions.cs index b8adc789486..af296162e17 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlSchemaBuilderExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlSchemaBuilderExtensions.cs @@ -2,6 +2,10 @@ namespace HotChocolate.Types; +/// +/// Provides extension methods for +/// to register cache control directive types and validation interceptors. +/// public static class CacheControlSchemaBuilderExtensions { /// diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlUnionTypeDescriptorExtensions.cs b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlUnionTypeDescriptorExtensions.cs index 3d533ad32e4..13108ec1fe7 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlUnionTypeDescriptorExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching/Extensions/CacheControlUnionTypeDescriptorExtensions.cs @@ -3,6 +3,10 @@ namespace HotChocolate.Types; +/// +/// Provides extension methods for +/// to apply @cacheControl directives to union types. +/// public static class CacheControlUnionTypeDescriptorExtensions { /// diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs index a924843cab9..99934a07168 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs @@ -1,10 +1,16 @@ using HotChocolate; using HotChocolate.Caching; +using HotChocolate.Execution; using HotChocolate.Execution.Configuration; using HotChocolate.Types; namespace Microsoft.Extensions.DependencyInjection; +/// +/// Provides extension methods for +/// to configure cache control support, including the query cache middleware, +/// cache control directive types, and default cache control options. +/// public static class QueryCacheRequestExecutorBuilderExtensions { /// @@ -13,39 +19,20 @@ public static class QueryCacheRequestExecutorBuilderExtensions /// /// The . /// - public static IRequestExecutorBuilder UseQueryCache( - this IRequestExecutorBuilder builder) - => builder.UseRequest(QueryCacheMiddleware.Create()); - - /// - /// Uses the default request pipeline including the - /// . - /// - /// - /// The . + /// + /// The middleware key after which to insert the query cache middleware. + /// Defaults to the timeout middleware. /// - public static IRequestExecutorBuilder UseQueryCachePipeline( - this IRequestExecutorBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - - return builder - .UseInstrumentation() - .UseExceptions() - .UseTimeout() - .UseQueryCache() - .UseDocumentCache() - .UseDocumentParser() - .UseDocumentValidation() - .UseOperationCache() - .UseOperationResolver() - .UseSkipWarmupExecution() - .UseOperationVariableCoercion() - .UseOperationExecution(); - } + public static IRequestExecutorBuilder UseQueryCache( + this IRequestExecutorBuilder builder, + string? after = null) + => builder.UseRequest( + QueryCacheMiddleware.Create(), + after: after ?? WellKnownRequestMiddleware.TimeoutMiddleware); /// - /// Add CacheControl types and + /// Adds cache control types, the constraints optimizer, and the default + /// cache control type interceptor to the request executor. /// /// /// The . @@ -53,8 +40,6 @@ public static IRequestExecutorBuilder UseQueryCachePipeline( public static IRequestExecutorBuilder AddCacheControl( this IRequestExecutorBuilder builder) { - ArgumentNullException.ThrowIfNull(builder); - builder.AddOperationCompilerOptimizer(); builder.ConfigureSchemaServices( @@ -85,7 +70,6 @@ public static IRequestExecutorBuilder ModifyCacheControlOptions( this IRequestExecutorBuilder builder, Action modifyOptions) { - ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(modifyOptions); builder.ConfigureSchemaServices(services => services.Configure(modifyOptions)); diff --git a/src/HotChocolate/Caching/src/Caching/HotChocolate.Caching.csproj b/src/HotChocolate/Caching/src/Caching/HotChocolate.Caching.csproj index 76d0623f5d7..2df53fcb458 100644 --- a/src/HotChocolate/Caching/src/Caching/HotChocolate.Caching.csproj +++ b/src/HotChocolate/Caching/src/Caching/HotChocolate.Caching.csproj @@ -18,6 +18,7 @@ + diff --git a/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.Designer.cs b/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.Designer.cs index 12fe2355bc0..ced0a70adbd 100644 --- a/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.Designer.cs +++ b/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -9,21 +9,21 @@ namespace HotChocolate.Caching.Properties { using System; - - + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [System.Diagnostics.DebuggerNonUserCodeAttribute()] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class CacheControlResources { - + private static System.Resources.ResourceManager resourceMan; - + private static System.Globalization.CultureInfo resourceCulture; - + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal CacheControlResources() { } - + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] internal static System.Resources.ResourceManager ResourceManager { get { @@ -34,7 +34,7 @@ internal static System.Resources.ResourceManager ResourceManager { return resourceMan; } } - + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] internal static System.Globalization.CultureInfo Culture { get { @@ -44,113 +44,101 @@ internal static System.Globalization.CultureInfo Culture { resourceCulture = value; } } - + internal static string CacheControlDirectiveType_Description { get { return ResourceManager.GetString("CacheControlDirectiveType_Description", resourceCulture); } } - + internal static string CacheControlDirectiveType_InheritMaxAge { get { return ResourceManager.GetString("CacheControlDirectiveType_InheritMaxAge", resourceCulture); } } - + internal static string CacheControlDirectiveType_MaxAge { get { return ResourceManager.GetString("CacheControlDirectiveType_MaxAge", resourceCulture); } } - + internal static string CacheControlDirectiveType_SharedMaxAge { get { return ResourceManager.GetString("CacheControlDirectiveType_SharedMaxAge", resourceCulture); } } - + internal static string CacheControlDirectiveType_Vary { get { return ResourceManager.GetString("CacheControlDirectiveType_Vary", resourceCulture); } } - + internal static string CacheControlDirectiveType_Scope { get { return ResourceManager.GetString("CacheControlDirectiveType_Scope", resourceCulture); } } - + internal static string CacheControlScopeType_Description { get { return ResourceManager.GetString("CacheControlScopeType_Description", resourceCulture); } } - + internal static string CacheControlScopeType_Private { get { return ResourceManager.GetString("CacheControlScopeType_Private", resourceCulture); } } - + internal static string CacheControlScopeType_Public { get { return ResourceManager.GetString("CacheControlScopeType_Public", resourceCulture); } } - + internal static string ErrorHelper_CacheControlBothMaxAgeAndInheritMaxAge { get { return ResourceManager.GetString("ErrorHelper_CacheControlBothMaxAgeAndInheritMaxAge", resourceCulture); } } - + internal static string ErrorHelper_CacheControlBothSharedMaxAgeAndInheritMaxAge { get { return ResourceManager.GetString("ErrorHelper_CacheControlBothSharedMaxAgeAndInheritMaxAge", resourceCulture); } } - + internal static string ErrorHelper_CacheControlInheritMaxAgeOnQueryTypeField { get { return ResourceManager.GetString("ErrorHelper_CacheControlInheritMaxAgeOnQueryTypeField", resourceCulture); } } - + internal static string ErrorHelper_CacheControlInheritMaxAgeOnType { get { return ResourceManager.GetString("ErrorHelper_CacheControlInheritMaxAgeOnType", resourceCulture); } } - + internal static string ErrorHelper_CacheControlNegativeMaxAge { get { return ResourceManager.GetString("ErrorHelper_CacheControlNegativeMaxAge", resourceCulture); } } - + internal static string ErrorHelper_CacheControlNegativeSharedMaxAge { get { return ResourceManager.GetString("ErrorHelper_CacheControlNegativeSharedMaxAge", resourceCulture); } } - + internal static string ErrorHelper_CacheControlOnInterfaceField { get { return ResourceManager.GetString("ErrorHelper_CacheControlOnInterfaceField", resourceCulture); } } - - internal static string ThrowHelper_EncounteredIntrospectionField { - get { - return ResourceManager.GetString("ThrowHelper_EncounteredIntrospectionField", resourceCulture); - } - } - - internal static string ThrowHelper_UnexpectedCacheControlScopeValue { - get { - return ResourceManager.GetString("ThrowHelper_UnexpectedCacheControlScopeValue", resourceCulture); - } - } } } diff --git a/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.resx b/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.resx index ec1f00a75e4..4247d4321ab 100644 --- a/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.resx +++ b/src/HotChocolate/Caching/src/Caching/Properties/CacheControlResources.resx @@ -165,10 +165,4 @@ Can not specify @cacheControl directive on interface fields, such as {0}. - - CacheControlConstraints could not be computed, since query contained an introspection field. - - - Unexpected CacheControlScope value {0} - diff --git a/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs b/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs index 9e6ee8be67e..497a6cf5bce 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs +++ b/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs @@ -18,7 +18,7 @@ public async Task MaxAge_NonZero_Should_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -40,7 +40,7 @@ public async Task MaxAge_Zero_Should_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -66,7 +66,7 @@ public async Task MaxAge_Multiple_Should_Cache_Shortest_Time(int time1, int time var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -93,7 +93,7 @@ public async Task MaxAge_Multiple_Combine_Public_Private_Caches_Private() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -120,7 +120,7 @@ public async Task SharedMaxAge_Multiple_Combine_Public_Private_Caches_Private() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -147,7 +147,7 @@ public async Task SharedMaxAge_MaxAge_Combine_Produces_Resolved_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -174,7 +174,7 @@ public async Task MaxAge_SharedMaxAge_Combine_Produces_Resolved_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -201,7 +201,7 @@ public async Task Just_Defaults_Should_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .AddQueryType(d => d.Name("Query") @@ -221,7 +221,7 @@ public async Task No_Applied_Defaults_Should_Not_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -242,7 +242,7 @@ public async Task Default_Max_Age_Should_Apply_And_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.DefaultMaxAge = 1000) .AddQueryType(d => @@ -263,7 +263,7 @@ public async Task Default_Scope_Should_Apply_And_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.DefaultScope = CacheControlScope.Private) .AddQueryType(d => @@ -284,7 +284,7 @@ public async Task JustScope_Should_Not_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -306,7 +306,7 @@ public async Task MaxAgeAndScope_Should_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -328,7 +328,7 @@ public async Task QueryError_Should_Not_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .AddQueryType(d => d.Name("Query") @@ -349,7 +349,7 @@ public async Task SharedMaxAgeAndScope_Should_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -371,7 +371,7 @@ public async Task SharedMaxAgeAndVary_Should_Cache() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => @@ -393,7 +393,7 @@ public async Task SharedMaxAgeAndVary_Multiple_Should_Cache_And_Combine() var server = CreateServer(services => { services.AddGraphQLServer() - .UseQueryCachePipeline() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) .AddQueryType(d => diff --git a/src/HotChocolate/Core/src/Types/Execution/Extensions/HotChocolateExecutionRequestContextExtensions.cs b/src/HotChocolate/Core/src/Types/Execution/Extensions/HotChocolateExecutionRequestContextExtensions.cs index 2573e075aa9..10513a62477 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Extensions/HotChocolateExecutionRequestContextExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Extensions/HotChocolateExecutionRequestContextExtensions.cs @@ -85,6 +85,7 @@ public void SetOperation(Operation operation) operationInfo.Operation = operation; operationInfo.Id = operation.Id; operationInfo.Definition = operation.Definition; + context.Features.Set(operation); } public bool TryGetOperationId([NotNullWhen(true)] out string? operationId) diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx index 69c04986eaf..b9b55fd012a 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx @@ -6,6 +6,7 @@ + @@ -18,6 +19,7 @@ + diff --git a/src/HotChocolate/Fusion/src/Fusion.Caching/CacheControlPlannerInterceptor.cs b/src/HotChocolate/Fusion/src/Fusion.Caching/CacheControlPlannerInterceptor.cs new file mode 100644 index 00000000000..4a494f98d7e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Caching/CacheControlPlannerInterceptor.cs @@ -0,0 +1,29 @@ +using HotChocolate.Caching; +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Planning; + +namespace HotChocolate.Fusion.Caching; + +/// +/// An that computes cache control constraints +/// from @cacheControl directives on the composite schema after the operation plan +/// is completed, and stores the computed constraints and HTTP header value on the +/// operation's features for the to consume. +/// +internal sealed class CacheControlPlannerInterceptor : IOperationPlannerInterceptor +{ + public void OnAfterPlanCompleted( + OperationDocumentInfo operationDocumentInfo, + OperationPlan operationPlan) + { + var constraints = CacheControlConstraintsComputer.Compute(operationPlan.Operation); + + if (constraints is not null) + { + var headerValue = CacheControlConstraintsComputer.CreateHeaderValue(constraints); + operationPlan.Operation.Features.Set(constraints); + operationPlan.Operation.Features.Set(headerValue); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Caching/Extensions/FusionCachingGatewayBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Caching/Extensions/FusionCachingGatewayBuilderExtensions.cs new file mode 100644 index 00000000000..58f6c4c4afd --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Caching/Extensions/FusionCachingGatewayBuilderExtensions.cs @@ -0,0 +1,84 @@ +using HotChocolate.Caching; +using HotChocolate.Execution; +using HotChocolate.Fusion.Caching; +using HotChocolate.Fusion.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods for +/// to add cache control support. +/// +public static class FusionCachingGatewayBuilderExtensions +{ + /// + /// Registers the query cache middleware in the Fusion gateway pipeline. + /// + /// + /// The . + /// + /// + /// The middleware key after which to insert the query cache middleware. + /// Defaults to the timeout middleware. + /// + /// + /// The for chaining. + /// + public static IFusionGatewayBuilder UseQueryCache( + this IFusionGatewayBuilder builder, + string? after = null) + => builder.UseRequest( + QueryCacheMiddleware.Create(), + after: after ?? WellKnownRequestMiddleware.TimeoutMiddleware); + + /// + /// Adds cache control support to the Fusion gateway, including + /// the planner interceptor that computes cache constraints. + /// + /// + /// The . + /// + /// + /// The for chaining. + /// + public static IFusionGatewayBuilder AddCacheControl( + this IFusionGatewayBuilder builder) + { + builder.ConfigureSchemaServices( + (_, services) => + { + services.AddOptions(); + services.TryAddSingleton(); + }); + + builder.AddOperationPlannerInterceptor( + _ => new CacheControlPlannerInterceptor()); + + return builder; + } + + /// + /// Modifies the . + /// + /// + /// The . + /// + /// + /// A delegate to configure the . + /// + /// + /// The for chaining. + /// + public static IFusionGatewayBuilder ModifyCacheControlOptions( + this IFusionGatewayBuilder builder, + Action modifyOptions) + { + ArgumentNullException.ThrowIfNull(modifyOptions); + + builder.ConfigureSchemaServices( + (_, services) => services.Configure(modifyOptions)); + + return builder; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Caching/HotChocolate.Fusion.Caching.csproj b/src/HotChocolate/Fusion/src/Fusion.Caching/HotChocolate.Fusion.Caching.csproj new file mode 100644 index 00000000000..c2610274591 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Caching/HotChocolate.Fusion.Caching.csproj @@ -0,0 +1,19 @@ + + + + HotChocolate.Fusion.Caching + HotChocolate.Fusion.Caching + HotChocolate.Fusion.Caching + Provides Hot Chocolate Fusion Cache Control support. + + + + + + + + + + + + diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/CacheControlDirectiveMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/CacheControlDirectiveMerger.cs index 80db2e11cbf..6594386d425 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/CacheControlDirectiveMerger.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/CacheControlDirectiveMerger.cs @@ -29,12 +29,18 @@ public override void MergeDirectiveDefinition( MutableDirectiveDefinition directiveDefinition, MutableSchemaDefinition mergedSchema) { + var scopeArgType = (MutableEnumTypeDefinition)directiveDefinition.Arguments["scope"].Type; + if (MergeBehavior is DirectiveMergeBehavior.IncludePrivate) { - var scopeArgType = (MutableEnumTypeDefinition)directiveDefinition.Arguments["scope"].Type; scopeArgType.Name = $"fusion__{scopeArgType.Name}"; mergedSchema.Types.Add(scopeArgType); } + else if (mergedSchema.Types.TryGetType( + WellKnownTypeNames.CacheControlScope, out var existingScopeType)) + { + directiveDefinition.Arguments["scope"].Type = existingScopeType; + } base.MergeDirectiveDefinition(directiveDefinition, mergedSchema); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs index 0ca8b85f8f8..eac4b34cf7f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs @@ -92,6 +92,7 @@ public static void SetOperationPlan( ArgumentNullException.ThrowIfNull(plan); context.Features.GetOrSet().OperationPlan = plan; + context.Features.Set(plan.Operation); } internal static bool CollectOperationPlanTelemetry( diff --git a/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs b/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs new file mode 100644 index 00000000000..18af059916e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs @@ -0,0 +1,305 @@ +using System.Text; +using global::HotChocolate.Caching; +using CacheControlAttribute = global::HotChocolate.Caching.CacheControlAttribute; +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Planning; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Caching; + +public class FusionCachingTests : FusionTestBase +{ + [Fact] + public async Task Query_With_CacheControl_MaxAge_Should_Return_CacheControl_Header() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("{ expensiveField }"); + + // assert + Assert.NotNull(response.Headers.CacheControl); + Assert.Equal(TimeSpan.FromSeconds(3600), response.Headers.CacheControl!.MaxAge); + } + + [Fact] + public async Task Query_With_Multiple_Fields_Should_Return_Shortest_MaxAge() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("{ expensiveField cheaperField }"); + + // assert + Assert.NotNull(response.Headers.CacheControl); + Assert.Equal(TimeSpan.FromSeconds(1800), response.Headers.CacheControl!.MaxAge); + } + + [Fact] + public async Task Query_With_Private_Scope_Should_Return_Private_Header() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("{ userData }"); + + // assert + Assert.NotNull(response.Headers.CacheControl); + Assert.True(response.Headers.CacheControl!.Private); + Assert.Equal(TimeSpan.FromSeconds(3600), response.Headers.CacheControl.MaxAge); + } + + [Fact] + public async Task Query_With_SharedMaxAge_Should_Return_SMaxAge_Header() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("{ sharedData }"); + + // assert + Assert.NotNull(response.Headers.CacheControl); + Assert.Equal(TimeSpan.FromSeconds(1800), response.Headers.CacheControl!.SharedMaxAge); + } + + [Fact] + public async Task Mutation_Should_Not_Return_CacheControl_Header() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddMutationType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("mutation { doSomething }"); + + // assert + Assert.Null(response.Headers.CacheControl); + } + + [Fact] + public async Task Query_With_No_CacheControl_Directive_Should_Not_Cache() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false) + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("{ plainField }"); + + // assert + Assert.Null(response.Headers.CacheControl); + } + + [Fact] + public async Task Query_With_Error_Should_Not_Cache() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("{ errorField }"); + + // assert + Assert.Null(response.Headers.CacheControl); + } + + [Fact] + public async Task Interceptor_Should_Compute_Constraints_From_Composite_Schema() + { + // arrange + using var server = CreateSourceSchema( + "a", + b => b.AddQueryType() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false)); + + var verifyingInterceptor = new VerifyingInterceptor(); + + using var gateway = await CreateCompositeSchemaAsync( + [("a", server)], + configureGatewayBuilder: b => b + .AddCacheControl() + .AddOperationPlannerInterceptor(_ => verifyingInterceptor) + .UseQueryCache()); + + // act + var httpClient = gateway.CreateClient(); + var response = await httpClient.PostGraphQLAsync("{ expensiveField }"); + + // assert + Assert.True(verifyingInterceptor.HasHitOnAfterPlanCompleted); + Assert.True(verifyingInterceptor.HasCacheConstraints); + Assert.True(verifyingInterceptor.HasCacheControlHeaderValue); + } + + private sealed class VerifyingInterceptor : IOperationPlannerInterceptor + { + public bool HasHitOnAfterPlanCompleted; + public bool HasCacheConstraints; + public bool HasCacheControlHeaderValue; + + public void OnAfterPlanCompleted( + OperationDocumentInfo operationDocumentInfo, + OperationPlan operationPlan) + { + HasHitOnAfterPlanCompleted = true; + HasCacheConstraints = operationPlan.Operation.Features + .TryGet(out _); + HasCacheControlHeaderValue = operationPlan.Operation.Features + .TryGet(out _); + } + } +} + +public static class CachedSchema +{ + public class Query + { + [CacheControl(3600)] + public string ExpensiveField() => "expensive"; + + [CacheControl(1800)] + public string CheaperField() => "cheaper"; + } +} + +public static class PrivateCachedSchema +{ + public class Query + { + [CacheControl(3600, Scope = CacheControlScope.Private)] + public string UserData() => "private-data"; + } +} + +public static class SharedCachedSchema +{ + public class Query + { + [CacheControl(SharedMaxAge = 1800)] + public string SharedData() => "shared"; + } +} + +public static class NoCacheSchema +{ + public class Query + { + public string PlainField() => "plain"; + } +} + +public static class ErrorSchema +{ + public class Query + { + [CacheControl(3600)] + public string ErrorField() => throw new Exception("Boom!"); + } +} + +public static class MutationSchema +{ + public class Query + { + public string Hello() => "world"; + } + + public class Mutation + { + public string DoSomething() => "done"; + } +} + +internal static class TestHttpClientExtensions +{ + public static async Task PostGraphQLAsync( + this HttpClient client, string query) + { + var payload = $$"""{"query":"{{query}}"}"""; + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + return await client.PostAsync("/graphql", content); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/HotChocolate.Fusion.Caching.Tests.csproj b/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/HotChocolate.Fusion.Caching.Tests.csproj new file mode 100644 index 00000000000..178c1e724f8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/HotChocolate.Fusion.Caching.Tests.csproj @@ -0,0 +1,15 @@ + + + + + HotChocolate.Fusion.Caching.Tests + HotChocolate.Fusion.Caching + + + + + + + + + From 9aaadd341ec6fd79f3b1d5ac64904bfbfff3be1d Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 29 Mar 2026 13:08:00 -0700 Subject: [PATCH 2/4] updated agents.md --- AGENTS.md | 250 +++++++++++------------------------------------------- 1 file changed, 49 insertions(+), 201 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87ccfa5bba1..a43d5e101e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,238 +1,86 @@ # AGENTS.md - OpenAI Codex Configuration -This file provides guidance to OpenAI Codex and other AI coding agents when working with this repository. +This file provides guidance to OpenAI Codex and other coding agents when working with this repository. -## Project Summary +## Build -**ChilliCream GraphQL Platform** - A comprehensive GraphQL ecosystem for .NET +### Website -| Product | Description | -| --------------- | ------------------------------------------------------ | -| Hot Chocolate | GraphQL server framework | -| Strawberry Shake| Type-safe GraphQL client | -| Green Donut | Data fetching primitives (DataLoader, pagination, etc.)| -| Nitro | GraphQL IDE | - -## Setup Instructions - -### Using Devcontainer (Recommended) - -This repository includes a devcontainer (`.devcontainer/`) with everything pre-installed: - -- .NET SDKs 6, 7, 8, 9, 10 -- Node.js LTS + Yarn -- GitHub CLI + Azure CLI -- Docker-in-docker support - -The devcontainer automatically runs `init.sh` on creation - no manual setup needed. - -### Manual Setup - -If not using devcontainer, initialize first: +Use `yarn` instead of `npm`. ```bash -./init.sh +cd website +yarn ``` -This restores website packages (yarn) and .NET packages. - -### Build - -```bash -./build.sh compile -``` +### C# Source Code -### Test +Build full solution: ```bash -./build.sh test +dotnet build src/All.slnx ``` -### Single Project Build +Build or test a subset directly (each area has its own solution file): ```bash -dotnet build src/HotChocolate/Core/src/Types/HotChocolate.Types.csproj -``` - -## Repository Layout - -```text -src/All.slnx # Master solution (244 projects) - -src/HotChocolate/ # GraphQL Server - Core/ - Abstractions/ # Interfaces, contracts - Execution/ # Query execution - Types/ # Type system - Validation/ # Query validation - Subscriptions/ # Real-time updates - AspNetCore/ # HTTP integration - Language/ # Parser, syntax tree - Fusion/ # Distributed GraphQL - Data/ # Database integrations - -src/GreenDonut/ # Data fetching primitives -src/StrawberryShake/ # GraphQL client -src/CookieCrumble/ # Snapshot testing - -website/ # Documentation (Next.js) -templates/ # Project templates -.build/ # Build scripts (Nuke) +dotnet test src/HotChocolate/Fusion ``` -## Tech Stack - -| Component | Technology | -| --------- | --------------- | -| Runtime | .NET 8/9/10 | -| SDK | 10.0.102 | -| Build | Nuke.Build | -| Tests | xUnit | -| Snapshots | CookieCrumble | -| Docs | Next.js + React | - -## Coding Standards +## Orchestration -### C# Conventions +- You are the orchestrator, not the worker. +- Keep the main context window focused on decisions. +- Do not do work yourself that a subagent could do. +- Context-window discipline: when instructed to "let it cook" or "don't inspect", trust the subagent and do not re-read its output. +- For non-trivial work, minimum team composition is lead developer plus devil's advocate. -- File-scoped namespaces: `namespace HotChocolate.Types;` -- 4-space indentation -- Use records for immutable types -- Expression-bodied members preferred -- No trailing whitespace +## Verification -### Naming Conventions +- "Done" means the code compiles, tests pass, and results are verified by running relevant tests. +- Never mark work complete without proving it works. +- During iteration, use `--filter` and avoid running the full suite unnecessarily. -- Projects: `HotChocolate.{Feature}` -- Tests: `HotChocolate.{Feature}.Tests` -- Interfaces: `I{Name}` prefix -- Async methods: `{Name}Async` suffix +## Core Principles -### File Organization +- Simplicity first: make every change as simple as possible and keep impact minimal. +- No lazy fixes: find root causes and avoid temporary patches. +- Minimal impact: touch only what is necessary and avoid regressions. -- One type per file (generally) -- Test files mirror source structure -- Snapshots in `__snapshots__/` folders +## Code Quality -## Testing Guidelines +### C# / .NET -### Running Tests +- Always use curly braces for loops and conditionals. +- Use file-scoped namespaces and 4-space indentation. +- Use test naming format: `Method_Should_Outcome_When_Condition`. +- Do not write vacuous assertions (`Assert.NotNull` alone is not a complete test). +- If a test requires excessive stubs and reflection, use a more appropriate test tier. -```bash -# All tests -./build.sh test - -# Specific project -dotnet test src/HotChocolate/Core/test/Types.Tests/ +### Testing -# Specific test -dotnet test --filter "FullyQualifiedName~MyTestName" -``` +- Prefer snapshot tests over manual `Assert` calls using CookieCrumble. +- Use CookieCrumble native snapshot support for `IExecutionResult`, `GraphQLHttpResponse`, and related core types. +- For small snapshots, prefer inline snapshots (`MatchInlineSnapshot`). +- For tests with multiple assertions, use markdown snapshots (`MatchMarkdownSnapshot`). +- For snapshot updates, use `__mismatch__/` and understand ordering issues before updating snapshots. +- Filter tests during iteration and avoid full-suite runs unless necessary. +- Use real databases in integration tests instead of mocks unless explicitly instructed otherwise. -### Snapshot Testing +## Performance -- Uses CookieCrumble library -- Snapshots stored in `__snapshots__/` -- Update snapshots by deleting old file and re-running test +### C# / .NET -### Test Structure +This is framework code. Performance matters; optimize for low allocations on hot paths. -```csharp -[Fact] -public async Task MyFeature_Should_WorkCorrectly() -{ - // Arrange - var schema = await new ServiceCollection() - .AddGraphQL() - .AddQueryType() - .BuildSchemaAsync(); +- Use `ChunkedArrayWriter` or `PooledArrayWriter` when an in-memory `IBufferWriter` is required. - // Act - var result = await schema.ExecuteAsync("{ field }"); - - // Assert - result.MatchSnapshot(); -} -``` +## Tools -## Key Abstractions +### C# / .NET -### GraphQL Types +Use `dotnet` CLI to search NuGet packages, for example: -```csharp -public class BookType : ObjectType -{ - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor.Field(b => b.Title).Type>(); - } -} -``` - -### DataLoader - -```csharp -public class BookByIdDataLoader : BatchDataLoader -{ - protected override async Task> LoadBatchAsync( - IReadOnlyList keys, CancellationToken ct) - { - // Batch load implementation - } -} -``` - -### Resolvers - -```csharp -public class Query -{ - public Book GetBook([Service] IBookRepository repo, int id) - => repo.GetById(id); -} +```bash +dotnet package search HotChocolate ``` - -## Important Paths - -| Path | Purpose | -| -------------------- | -------------------- | -| `src/All.slnx` | Open this in IDE | -| `global.json` | .NET SDK version | -| `.editorconfig` | Code style rules | -| `.build/Build.csproj`| Build configuration | -| `website/` | Documentation source | - -## Common Modifications - -### Add New Type Extension - -1. Create in `src/HotChocolate/Core/src/Types/Types/` -2. Add tests in `src/HotChocolate/Core/test/Types.Tests/` -3. Export from appropriate namespace - -### Add Database Integration - -1. Create project in `src/HotChocolate/Data/` -2. Reference `HotChocolate.Execution` -3. Implement `IQueryableExecutable` or similar - -### Modify Execution Pipeline - -1. Changes go in `src/HotChocolate/Core/src/Execution/` -2. Middleware in `Processing/` directory -3. Add tests covering the pipeline stage - -## Troubleshooting - -| Issue | Solution | -| ----------------- | ------------------------------------------ | -| Build fails | Run `./init.sh` first | -| Missing packages | Run `./build.sh restore` | -| Snapshot mismatch | Check `__snapshots__/` for expected output | -| SDK not found | Install .NET SDK 10.0.102 | - -## Links - -- Documentation: `website/src/docs/` -- Examples: `templates/` -- Build scripts: `.build/` From fa8b54340e9fc979b419b492202ddb11da4181b6 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 29 Mar 2026 17:34:42 -0700 Subject: [PATCH 3/4] Update docs --- website/src/docs/docs.json | 19 ++ .../v16/attribute-and-directive-reference.md | 46 ++-- website/src/docs/fusion/v16/cache-control.md | 178 ++++++++++++++++ .../docs/fusion/v16/guides/first-party-api.md | 16 ++ .../docs/fusion/v16/guides/third-party-api.md | 16 ++ website/src/docs/fusion/v16/index.md | 2 + .../src/docs/fusion/v16/performance-tuning.md | 1 + .../v16/api-reference/custom-attributes.md | 6 + .../hotchocolate/v16/api-reference/options.md | 43 ++-- .../hotchocolate/v16/server/cache-control.md | 199 ++++++++++++++++++ .../src/docs/hotchocolate/v16/server/index.md | 6 + 11 files changed, 489 insertions(+), 43 deletions(-) create mode 100644 website/src/docs/fusion/v16/cache-control.md create mode 100644 website/src/docs/fusion/v16/guides/first-party-api.md create mode 100644 website/src/docs/fusion/v16/guides/third-party-api.md create mode 100644 website/src/docs/hotchocolate/v16/server/cache-control.md diff --git a/website/src/docs/docs.json b/website/src/docs/docs.json index 76a1348301e..8e5ba2d7746 100644 --- a/website/src/docs/docs.json +++ b/website/src/docs/docs.json @@ -184,6 +184,20 @@ "path": "adding-a-subgraph", "title": "Adding a Subgraph" }, + { + "path": "guides", + "title": "Guides", + "items": [ + { + "path": "third-party-api", + "title": "Third-Party API" + }, + { + "path": "first-party-api", + "title": "First-Party API" + } + ] + }, { "path": "entities-and-lookups", "title": "Entities and Lookups" @@ -212,6 +226,10 @@ "path": "authentication-and-authorization", "title": "Authentication and Authorization" }, + { + "path": "cache-control", + "title": "Cache Control" + }, { "path": "performance-tuning", "title": "Performance Tuning" @@ -422,6 +440,7 @@ { "path": "index", "title": "Overview" }, { "path": "endpoints", "title": "Endpoints" }, { "path": "http-transport", "title": "Transports" }, + { "path": "cache-control", "title": "Cache Control" }, { "path": "interceptors", "title": "Interceptors" }, { "path": "warmup", "title": "Warmup" }, { "path": "global-state", "title": "Global State" }, diff --git a/website/src/docs/fusion/v16/attribute-and-directive-reference.md b/website/src/docs/fusion/v16/attribute-and-directive-reference.md index de39e74cb21..562ae484ca7 100644 --- a/website/src/docs/fusion/v16/attribute-and-directive-reference.md +++ b/website/src/docs/fusion/v16/attribute-and-directive-reference.md @@ -6,32 +6,34 @@ Quick reference for all Fusion-related attributes and their GraphQL directive eq # Attribute and Directive Reference Table -| Attribute | Directive | Description | Guide Page | -| --------------------------- | --------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `[ObjectType]` | — | Maps static class as extension to entity type T | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[QueryType]` | — | Marks class as contributing Query root fields | [Getting Started](/docs/fusion/v16/getting-started) | -| `[MutationType]` | — | Marks class as contributing Mutation root fields | [Getting Started](/docs/fusion/v16/getting-started) | -| `[SubscriptionType]` | — | Marks class as contributing Subscription root fields | [Getting Started](/docs/fusion/v16/getting-started) | -| `[Lookup]` | `@lookup` | Declares field as entity lookup resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[NodeResolver]` | — | Marks as Relay node resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Internal]` | `@internal` | Hides lookup from composed schema | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Shareable]` | `@shareable` | Allows multiple subgraphs to resolve field | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing) | -| `[Parent(requires: "...")]` | — | Declares field requirements from parent | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Require("...")]` | `@require` | Declares complex field requirements | [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping), [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | -| `[EntityKey("...")]` | `@key` | Declares entity key for resolution | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[BindMember(nameof(...))]` | — | Replaces raw FK with resolved entity | [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | -| `[Tag("...")]` | `@tag` | Applies tag for composition filtering | [Composition](/docs/fusion/v16/composition) | -| `[DataLoader]` | — | Source-generates DataLoader interface | [Getting Started](/docs/fusion/v16/getting-started), [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[UsePaging]` | — | Enables cursor-based pagination | [Getting Started](/docs/fusion/v16/getting-started) | -| `[ID]` | — | Declares field as Relay-style ID | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Inaccessible]` | `@inaccessible` | Hides from composite schema | [Composition](/docs/fusion/v16/composition) | -| `[Override(from: "...")]` | `@override` | Migrates field ownership | [Deployment and CI/CD](/docs/fusion/v16/deployment-and-ci-cd) | -| `[Provides("...")]` | `@provides` | Declares locally-resolvable subfields | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | -| `[External]` | `@external` | Field defined by another subgraph | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | +| Attribute | Directive | Description | Guide Page | +| --------------------------- | --------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `[ObjectType]` | — | Maps static class as extension to entity type T | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[QueryType]` | — | Marks class as contributing Query root fields | [Getting Started](/docs/fusion/v16/getting-started) | +| `[MutationType]` | — | Marks class as contributing Mutation root fields | [Getting Started](/docs/fusion/v16/getting-started) | +| `[SubscriptionType]` | — | Marks class as contributing Subscription root fields | [Getting Started](/docs/fusion/v16/getting-started) | +| `[Lookup]` | `@lookup` | Declares field as entity lookup resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[NodeResolver]` | — | Marks as Relay node resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Internal]` | `@internal` | Hides lookup from composed schema | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Shareable]` | `@shareable` | Allows multiple subgraphs to resolve field | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing) | +| `[Parent(requires: "...")]` | — | Declares field requirements from parent | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Require("...")]` | `@require` | Declares complex field requirements | [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping), [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | +| `[EntityKey("...")]` | `@key` | Declares entity key for resolution | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[BindMember(nameof(...))]` | — | Replaces raw FK with resolved entity | [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | +| `[Tag("...")]` | `@tag` | Applies tag for composition filtering | [Composition](/docs/fusion/v16/composition) | +| `[DataLoader]` | — | Source-generates DataLoader interface | [Getting Started](/docs/fusion/v16/getting-started), [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[UsePaging]` | — | Enables cursor-based pagination | [Getting Started](/docs/fusion/v16/getting-started) | +| `[ID]` | — | Declares field as Relay-style ID | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Inaccessible]` | `@inaccessible` | Hides from composite schema | [Composition](/docs/fusion/v16/composition) | +| `[Override(from: "...")]` | `@override` | Migrates field ownership | [Deployment and CI/CD](/docs/fusion/v16/deployment-and-ci-cd) | +| `[Provides("...")]` | `@provides` | Declares locally-resolvable subfields | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | +| `[External]` | `@external` | Field defined by another subgraph | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | +| `[CacheControl(...)]` | `@cacheControl` | Declares HTTP cache policy for gateway response hints | [Cache Control](/docs/fusion/v16/cache-control) | # See Also - **[Getting Started](/docs/fusion/v16/getting-started)** — Introduction to Fusion attributes in practice - **[Entities and Lookups](/docs/fusion/v16/entities-and-lookups)** — Deep dive into entity resolution - **[Composition](/docs/fusion/v16/composition)** — How attributes affect schema merging +- **[Cache Control](/docs/fusion/v16/cache-control)** — CDN and HTTP caching behavior in the gateway - **[Nitro CLI Reference](/docs/fusion/v16/nitro-cli-reference)** — Command-line tools for composition diff --git a/website/src/docs/fusion/v16/cache-control.md b/website/src/docs/fusion/v16/cache-control.md new file mode 100644 index 00000000000..3563a5a9214 --- /dev/null +++ b/website/src/docs/fusion/v16/cache-control.md @@ -0,0 +1,178 @@ +--- +title: "Cache Control" +description: "Understand HTTP Cache-Control and Vary headers, then learn how Fusion uses GraphQL @cacheControl directives to generate safe CDN and browser caching policies." +--- + +Cache-Control is the HTTP header field that tells browsers, reverse proxies, and CDNs how they are allowed to store and reuse a response instead of sending the same request back to the server every time. Together with related headers such as `Vary`, it makes cached responses safe and predictable by defining whether a response may be reused, how long it may be reused, and which parts of the request affect that decision. + +# Why Cache Control Matters + +Good cache rules help you: + +- Reduce latency for users. +- Reduce load on your backend services. +- Keep cache behavior predictable. +- Protect user-specific data from shared caches. + +To make caching work safely and predictably, you need two things: + +1. A deterministic GET route so caches get a stable cache key. +2. Correct Cache-Control (and Vary) headers so caches know where and how long a response may be reused. + +# Why GraphQL Needs Extra Care + +GraphQL usually exposes one endpoint, but each request can ask for different fields. Two requests to the same URL can therefore return very different response shapes and different data sensitivity. + +A single GraphQL response can also mix public data and user-specific data. Since HTTP cache headers apply to the full response, the gateway has to compute one safe final policy that represents everything selected in that operation. + +That means one response can include: + +- Public data (safe for shared caches), and +- User-specific data (not safe for shared caches). + +The GraphQL operation type matters. Query operations are side-effect free reads, so they are the primary target for HTTP and CDN caching. Mutations change data and should not be cached as shared HTTP responses. Subscriptions are long-running streams and are not HTTP-cacheable. + +# Deterministic GET Routes + +The GraphQL over HTTP specification allows query operations to be sent over HTTP GET. + +```http +GET /graphql?query=query GetProducts{products{nodes{name}}} +``` + +The same applies when variables are included. + +```http +GET /graphql?query=query GetProducts($first:Int!){products(first:$first){nodes{name}}}&variables={"first":5} +``` + +In real requests, these values are URL-encoded, and for larger operations the query string quickly becomes difficult to work with. This is where persisted operations help. + +## Persisted Operation Routes + +In large first-party GraphQL APIs, a common approach used by companies such as Netflix, Meta, and X is to rely on trusted documents. Client operations are stored in an operation store, and clients send a stable operation identifier instead of the full query text. + +> You can read more in the [First-Party API guide](/docs/fusion/v16/guides/first-party-api). + +With trusted documents in place, persisted-operation routes become short and stable. + +```http +GET /graphql/persisted/GetProducts/123456789 +``` + +Variables can then be sent as query parameters. + +```http +GET /graphql/persisted/GetProducts/123456789?first=5 +``` + +In order to use persisted-operation routes you need to add the middleware `MapGraphQLPersistedOperations`. + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder + .AddGraphQLGateway() + .AddFileSystemConfiguration("./gateway.far"); + +var app = builder.Build(); + +app.MapGraphQLPersistedOperations(); +``` + +# `@cacheControl` in GraphQL + +A deterministic route alone is not enough. The gateway also needs policy metadata to decide whether a response is public or private, and how long it may be reused. + +GraphQL provides the `@cacheControl` directive for this purpose. You can place it on fields and types to describe cache intent. + +```graphql +type Query { + productById(id: ID!): Product @cacheControl(maxAge: 300, sharedMaxAge: 900) + + me: UserProfile + @cacheControl(maxAge: 60, scope: PRIVATE, vary: ["Authorization"]) +} +``` + +# How Fusion Assembles the Final Headers + +Fusion computes one effective response policy by traversing the planned operation tree. It starts at the selected root fields, reads `@cacheControl` metadata on each field, falls back to the field return type when values are missing, and continues recursively through child selections, including interfaces and unions. + +All collected constraints are merged into one final policy. The merge is conservative: `max-age` and `s-maxage` take the lowest value, scope resolves to the strictest value (`private` over `public`), and `vary` values are merged, normalized, and deduplicated. + +Fusion computes these cache constraints for query operations. Mutation requests, Subscription request, introspection requests, and operations with no cache constraints do not get cache-control headers. + +# Cache Policy Starts in Subgraphs + +In Fusion, cache policy starts where data is resolved, which means your subgraphs define cache intent for their own fields. + +```graphql +type Product { + id: ID! + name: String! +} + +type UserProfile { + id: ID! + email: String! +} + +type Query { + productById(id: ID!): Product @cacheControl(maxAge: 300, sharedMaxAge: 900) + + me: UserProfile + @cacheControl(maxAge: 60, scope: PRIVATE, vary: ["Authorization"]) +} +``` + +If your subgraph runs on Hot Chocolate, you can express the same policy with `[CacheControl]` attributes. + +```csharp +using HotChocolate.Caching; + +[QueryType] +public static class Query +{ + [CacheControl(300, SharedMaxAge = 900)] + public static Product? GetProductById(int id) + => ProductRepository.GetById(id); + + [CacheControl(60, Scope = CacheControlScope.Private, Vary = ["Authorization"])] + public static UserProfile GetMe() + => UserProfileRepository.GetCurrent(); +} +``` + +Enable cache-control metadata on the subgraph: + +```csharp +builder + .AddGraphQL() + .AddCacheControl(); +``` + +# Enable Cache Control in the Fusion Gateway + +The gateway must be configured to read cache-control metadata during planning and to write the final HTTP headers on the response. + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder + .AddGraphQLGateway() + .AddFileSystemConfiguration("./gateway.far") + .AddCacheControl() + .UseQueryCache(); +``` + +`AddCacheControl()` enables cache-constraint planning. `UseQueryCache()` writes the final `Cache-Control` and `Vary` headers to HTTP responses. + +# Putting It Together + +In day-to-day terms, the flow is simple: + +- Subgraphs declare cache intent. +- Fusion composes that metadata. +- The gateway calculates one safe policy for each query result. +- HTTP caches enforce the resulting headers. diff --git a/website/src/docs/fusion/v16/guides/first-party-api.md b/website/src/docs/fusion/v16/guides/first-party-api.md new file mode 100644 index 00000000000..74839b767c8 --- /dev/null +++ b/website/src/docs/fusion/v16/guides/first-party-api.md @@ -0,0 +1,16 @@ +--- +title: "First-Party API" +description: "Draft guide for operating a Fusion gateway as a first-party GraphQL API, including trusted documents and deterministic routes." +--- + +This page is a draft. + +Use this guide when your Fusion gateway is consumed only by clients you control. + +Planned topics: + +- Trusted documents and persisted operation workflows. +- Deterministic GET routes for cache-friendly operation execution. +- Cache-control policy for first-party traffic. +- Deployment and contract versioning guidance. +- Operational checklist for private API environments. diff --git a/website/src/docs/fusion/v16/guides/third-party-api.md b/website/src/docs/fusion/v16/guides/third-party-api.md new file mode 100644 index 00000000000..8ddb94dfb52 --- /dev/null +++ b/website/src/docs/fusion/v16/guides/third-party-api.md @@ -0,0 +1,16 @@ +--- +title: "Third-Party API" +description: "Draft guide for operating a Fusion gateway as a third-party GraphQL API, including open-client concerns, caching strategy, and safety controls." +--- + +This page is a draft. + +Use this guide when your Fusion gateway is exposed to external or third-party clients that can send arbitrary queries. + +Planned topics: + +- Threat model and API posture for public GraphQL endpoints. +- Cache strategy for public traffic. +- Complexity and abuse protections. +- Authentication and authorization patterns. +- Operational guidance and rollout checklist. diff --git a/website/src/docs/fusion/v16/index.md b/website/src/docs/fusion/v16/index.md index 2286ca6da7e..e85a1bc1211 100644 --- a/website/src/docs/fusion/v16/index.md +++ b/website/src/docs/fusion/v16/index.md @@ -106,3 +106,5 @@ Where you go from here depends on what you need: - **"I'm migrating from another distributed GraphQL framework."** Read [Coming from Apollo Federation](/docs/fusion/v16/migration/coming-from-apollo-federation) or [Migrating from Schema Stitching](/docs/fusion/v16/migration/migrating-from-schema-stitching). These guides map familiar concepts to Fusion equivalents and walk through a migration. - **"I need to deploy this."** See [Deployment & CI/CD](/docs/fusion/v16/deployment-and-ci-cd) for pipeline setup, schema management, and gateway configuration. + +- **"I need CDN and browser caching behavior."** See [Cache Control](/docs/fusion/v16/cache-control) for `@cacheControl`, composition merge behavior, and gateway response headers. diff --git a/website/src/docs/fusion/v16/performance-tuning.md b/website/src/docs/fusion/v16/performance-tuning.md index 0566d2ba3cd..4c45908330b 100644 --- a/website/src/docs/fusion/v16/performance-tuning.md +++ b/website/src/docs/fusion/v16/performance-tuning.md @@ -183,6 +183,7 @@ Start with the default of 64 and adjust based on your workload. Set to `null` to ## Next Steps +- **"I need CDN and HTTP response caching behavior"**: [Cache Control](/docs/fusion/v16/cache-control) covers `@cacheControl`, composition merge behavior, and gateway response headers. - **"I need to secure my gateway"**: [Authentication and Authorization](/docs/fusion/v16/authentication-and-authorization) covers JWT validation, header propagation, and subgraph-level authorization. - **"I need to deploy this"**: [Deployment & CI/CD](/docs/fusion/v16/deployment-and-ci-cd) covers production deployment patterns and CI pipeline setup. - **"I want to monitor performance"**: Observability and distributed tracing will be covered in future documentation. diff --git a/website/src/docs/hotchocolate/v16/api-reference/custom-attributes.md b/website/src/docs/hotchocolate/v16/api-reference/custom-attributes.md index 6fcf8c5839a..da0e754504b 100644 --- a/website/src/docs/hotchocolate/v16/api-reference/custom-attributes.md +++ b/website/src/docs/hotchocolate/v16/api-reference/custom-attributes.md @@ -57,6 +57,12 @@ These attributes configure data loading and resolver behavior. | `[Cost]` | `HotChocolate.CostAnalysis` | Sets the cost weight for a field, used by the cost analyzer to calculate query complexity. | | `[ListSize]` | `HotChocolate.CostAnalysis` | Declares the expected list size for a field, used by the cost analyzer. | +# Caching Attributes + +| Attribute | Namespace | Description | +| --------------------- | ---------------------- | ------------------------------------------------------------------------------------------ | +| `[CacheControl(...)]` | `HotChocolate.Caching` | Declares HTTP cache policy hints (`@cacheControl`) for `Cache-Control` and `Vary` headers. | + # Identity Attributes | Attribute | Namespace | Description | diff --git a/website/src/docs/hotchocolate/v16/api-reference/options.md b/website/src/docs/hotchocolate/v16/api-reference/options.md index e7382f53b7f..5767b8e3385 100644 --- a/website/src/docs/hotchocolate/v16/api-reference/options.md +++ b/website/src/docs/hotchocolate/v16/api-reference/options.md @@ -10,8 +10,8 @@ Hot Chocolate provides several option groups that control different aspects of t Schema options control the type system and schema behavior. Configure them with `ModifyOptions`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .ModifyOptions(o => { o.StrictValidation = true; @@ -58,8 +58,8 @@ builder.Services Request options control the execution engine behavior. Configure them with `ModifyRequestOptions`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .ModifyRequestOptions(o => { o.ExecutionTimeout = TimeSpan.FromSeconds(60); @@ -78,8 +78,8 @@ builder.Services Cost options configure the cost analysis feature. Install the `HotChocolate.CostAnalysis` package and configure with `ModifyCostOptions`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .ModifyCostOptions(o => { o.MaxFieldCost = 1000; @@ -95,8 +95,8 @@ Refer to the cost analysis documentation for the full list of configurable prope Server options control HTTP-level behavior such as GET requests, batching, multipart requests, and schema retrieval. This is new in v16. Configure with `ModifyServerOptions`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .ModifyServerOptions(o => { o.EnableGetRequests = true; @@ -132,8 +132,8 @@ app.MapGraphQL().WithOptions(o => o.EnableGetRequests = false); The `Sockets` property on `GraphQLServerOptions` holds WebSocket-specific settings. You configure them through `ModifyServerOptions`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .ModifyServerOptions(o => { o.Sockets.ConnectionInitializationTimeout = TimeSpan.FromSeconds(30); @@ -151,8 +151,8 @@ builder.Services Paging options control the default behavior for cursor-based pagination. Configure with `ModifyPagingOptions`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .ModifyPagingOptions(o => { o.DefaultPageSize = 25; @@ -179,8 +179,8 @@ builder.Services Parser options control limits on the GraphQL document parser. These are important security and performance settings that protect against excessively large or complex queries. Configure them with `ModifyParserOptions`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .ModifyParserOptions(o => { o.MaxAllowedFields = 500; @@ -202,8 +202,8 @@ Parsing happens before validation, so even invalid queries consume resources. Se Subscription options control topic buffer behavior for subscription providers. You pass them when registering a subscription provider: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .AddInMemorySubscriptions(new SubscriptionOptions { TopicBufferCapacity = 128, @@ -224,8 +224,8 @@ All subscription providers (in-memory, Redis, NATS, RabbitMQ, Postgres) accept t Global object identification options configure the Relay-style `node` and `nodes` fields. You configure them through `AddGlobalObjectIdentification`: ```csharp -builder.Services - .AddGraphQLServer() +builder + .AddGraphQL() .AddGlobalObjectIdentification(o => { o.MaxAllowedNodeBatchSize = 25; @@ -244,9 +244,9 @@ builder.Services Cache control options configure HTTP response caching hints based on the `@cacheControl` directive. Install the `HotChocolate.Caching` package and configure with `ModifyCacheControlOptions`: ```csharp -builder.Services - .AddGraphQLServer() - .UseQueryCachePipeline() +builder + .AddGraphQL() + .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => { @@ -265,6 +265,7 @@ builder.Services # Next Steps - [Execution engine](/docs/hotchocolate/v16/execution-engine) for pipeline configuration +- [Cache Control](/docs/hotchocolate/v16/server/cache-control) for CDN and HTTP caching behavior - [Pagination](/docs/hotchocolate/v16/resolvers-and-data/pagination) for paging setup - [Persisted operations](/docs/hotchocolate/v16/performance/trusted-documents) for operation caching - [Migration guide](/docs/hotchocolate/v16/migrating/migrate-from-15-to-16) for breaking option changes diff --git a/website/src/docs/hotchocolate/v16/server/cache-control.md b/website/src/docs/hotchocolate/v16/server/cache-control.md new file mode 100644 index 00000000000..4402027bcef --- /dev/null +++ b/website/src/docs/hotchocolate/v16/server/cache-control.md @@ -0,0 +1,199 @@ +--- +title: Cache Control +description: Learn how to configure Cache-Control and Vary response headers for CDN and HTTP caching in Hot Chocolate GraphQL servers using @cacheControl directives, UseQueryCache, and cache-control options. +--- + +Cache control defines how HTTP clients, browsers, reverse proxies, and CDNs should cache GraphQL responses. + +This chapter explains HTTP cache semantics first, then shows how GraphQL `@cacheControl` metadata is translated into `Cache-Control` and `Vary` response headers by Hot Chocolate. + +# What Cache Control Means in HTTP + +`Cache-Control` is an HTTP response header that tells caches whether a response can be stored and how long it remains fresh. + +Common directives: + +| Directive | Meaning | +| -------------------- | --------------------------------------------- | +| `max-age=` | Response freshness lifetime. | +| `s-maxage=` | Shared-cache freshness lifetime (for CDNs). | +| `public` | Shared caches may store this response. | +| `private` | Shared caches should not store this response. | +| `Vary:
` | Cache key depends on request headers. | + +For GraphQL APIs, these headers matter because one endpoint serves many operations and cache behavior should still be explicit and predictable. + +# How GraphQL `@cacheControl` Works + +GraphQL uses `@cacheControl` to declare cache intent on fields and types. + +## GraphQL SDL Example + +```graphql +type Query { + productById(id: ID!): Product @cacheControl(maxAge: 300, sharedMaxAge: 900) + + me: UserProfile + @cacheControl(maxAge: 60, scope: PRIVATE, vary: ["Authorization"]) +} +``` + +## Hot Chocolate Resolver Example + +```csharp +using HotChocolate.Caching; + +public sealed class Query +{ + [CacheControl(300, SharedMaxAge = 900)] + public Product? ProductById(int id) + => ProductStore.GetById(id); + + [CacheControl(60, Scope = CacheControlScope.Private, Vary = ["Authorization"])] + public UserProfile Me() + => UserStore.GetCurrent(); +} +``` + +At execution time, Hot Chocolate computes one effective cache policy for the full response. + +# Enable Cache Control in Hot Chocolate + +Install the package: + +```bash +dotnet add package HotChocolate.Caching +``` + +Register schema support and response-header middleware: + +```csharp +using HotChocolate.Caching; + +builder + .AddGraphQL() + .UseQueryCache() + .AddCacheControl() + .ModifyCacheControlOptions(o => + { + o.ApplyDefaults = false; + }); +``` + +- `AddCacheControl()` registers the `@cacheControl` directive and cache-constraint computation. +- `UseQueryCache()` writes `Cache-Control` and `Vary` values to HTTP responses. + +# Cache-Control Options + +`ModifyCacheControlOptions` configures default behavior: + +```csharp +builder + .AddGraphQL() + .UseQueryCache() + .AddCacheControl() + .ModifyCacheControlOptions(o => + { + o.Enable = true; + o.DefaultMaxAge = 60; + o.DefaultScope = CacheControlScope.Public; + o.ApplyDefaults = true; + }); +``` + +| Option | Type | Default | Description | +| --------------- | ------------------- | -------- | --------------------------------------------------------------------- | +| `Enable` | `bool` | `true` | Enables cache-control response handling. | +| `DefaultMaxAge` | `int` | `0` | Default `max-age` when `ApplyDefaults` is enabled. | +| `DefaultScope` | `CacheControlScope` | `Public` | Default cache scope when `ApplyDefaults` is enabled. | +| `ApplyDefaults` | `bool` | `true` | Applies defaults to eligible fields without explicit `@cacheControl`. | + +# How Effective Response Policy Is Computed + +- Only **query operations** participate. +- `max-age` resolves to the most restrictive selected value. +- `s-maxage` resolves to the most restrictive selected shared value. +- Scope resolves to the most restrictive value (`private` over `public`). +- `Vary` values are merged across selected fields. + +No cache-control header is emitted when: + +- The operation is a mutation. +- The operation result contains errors. +- No selected field contributes cache constraints. + +# Skip Cache Control for a Specific Request + +Use `SkipQueryCaching()` on `OperationRequestBuilder` to bypass cache-control header generation for a specific request. + +```csharp +using HotChocolate.AspNetCore; +using HotChocolate.Execution; + +public sealed class NoCacheHeaderInterceptor : DefaultHttpRequestInterceptor +{ + public override ValueTask OnCreateAsync( + HttpContext context, + IRequestExecutor requestExecutor, + OperationRequestBuilder requestBuilder, + CancellationToken cancellationToken) + { + if (context.Request.Headers.ContainsKey("X-Skip-Cache-Control")) + { + requestBuilder.SkipQueryCaching(); + } + + return base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken); + } +} +``` + +Register the interceptor: + +```csharp +builder + .AddGraphQL() + .AddHttpRequestInterceptor(); +``` + +# Troubleshooting + +## `Cache-Control` header is missing + +Cause: + +- `.UseQueryCache()` or `.AddCacheControl()` is not configured. +- Selected fields do not contribute cache constraints. +- The response contains GraphQL errors. + +Solution: + +- Register both middleware and schema support. +- Verify selected fields use `@cacheControl` or enable defaults. +- Check operation errors before validating cache headers. + +## Mutations do not include cache headers + +Cause: + +- Cache-control headers are computed for query operations. + +Solution: + +- Treat mutation responses as non-cacheable. + +## CDN caches private user data + +Cause: + +- Scope is `public` for user-specific response data. + +Solution: + +- Use `Scope = CacheControlScope.Private` and relevant `Vary` headers. + +# Next Steps + +- [Transports](/docs/hotchocolate/v16/server/http-transport) for HTTP transport behavior. +- [Interceptors](/docs/hotchocolate/v16/server/interceptors) for request-level control. +- [Configuration Options](/docs/hotchocolate/v16/api-reference/options) for full option reference. diff --git a/website/src/docs/hotchocolate/v16/server/index.md b/website/src/docs/hotchocolate/v16/server/index.md index aece8d17a22..f20d03a62c4 100644 --- a/website/src/docs/hotchocolate/v16/server/index.md +++ b/website/src/docs/hotchocolate/v16/server/index.md @@ -16,6 +16,12 @@ Hot Chocolate implements the GraphQL over HTTP specification. In v16, the defaul [Learn more about the HTTP transport](/docs/hotchocolate/v16/server/http-transport) +# Cache Control + +Cache control lets your GraphQL server emit `Cache-Control` and `Vary` response headers that CDNs, reverse proxies, and browsers can use for HTTP caching decisions. + +[Learn more about cache control](/docs/hotchocolate/v16/server/cache-control) + # Interceptors Interceptors let you intercept GraphQL requests before execution. There are interceptors for both HTTP requests and WebSocket sessions. From 764356a00ab2d541684ea756d3c7fc967f4eda05 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 29 Mar 2026 20:01:58 -0700 Subject: [PATCH 4/4] docs --- website/src/docs/fusion/v16/cache-control.md | 37 +++- .../hotchocolate/v16/server/cache-control.md | 209 +++++++++++------- 2 files changed, 156 insertions(+), 90 deletions(-) diff --git a/website/src/docs/fusion/v16/cache-control.md b/website/src/docs/fusion/v16/cache-control.md index 3563a5a9214..b6350da3d65 100644 --- a/website/src/docs/fusion/v16/cache-control.md +++ b/website/src/docs/fusion/v16/cache-control.md @@ -63,18 +63,12 @@ GET /graphql/persisted/GetProducts/123456789 Variables can then be sent as query parameters. ```http -GET /graphql/persisted/GetProducts/123456789?first=5 +GET /graphql/persisted/GetProducts/123456789?variables={"first":5} ``` In order to use persisted-operation routes you need to add the middleware `MapGraphQLPersistedOperations`. ```csharp -var builder = WebApplication.CreateBuilder(args); - -builder - .AddGraphQLGateway() - .AddFileSystemConfiguration("./gateway.far"); - var app = builder.Build(); app.MapGraphQLPersistedOperations(); @@ -126,7 +120,7 @@ type Query { } ``` -If your subgraph runs on Hot Chocolate, you can express the same policy with `[CacheControl]` attributes. +If your subgraph runs on Hot Chocolate, you can express the `@cacheControl` directive with the `[CacheControl]` attribute. ```csharp using HotChocolate.Caching; @@ -161,13 +155,38 @@ var builder = WebApplication.CreateBuilder(args); builder .AddGraphQLGateway() - .AddFileSystemConfiguration("./gateway.far") .AddCacheControl() .UseQueryCache(); ``` `AddCacheControl()` enables cache-constraint planning. `UseQueryCache()` writes the final `Cache-Control` and `Vary` headers to HTTP responses. +# Skip Cache Control for a Specific Request + +Use `SkipQueryCaching()` on `OperationRequestBuilder` to bypass cache-control for a specific request. + +```csharp +using HotChocolate.AspNetCore; +using HotChocolate.Execution; + +public sealed class NoCacheHeaderInterceptor : DefaultHttpRequestInterceptor +{ + public override ValueTask OnCreateAsync( + HttpContext context, + IRequestExecutor requestExecutor, + OperationRequestBuilder requestBuilder, + CancellationToken cancellationToken) + { + if (context.Request.Headers.ContainsKey("X-Skip-Cache-Control")) + { + requestBuilder.SkipQueryCaching(); + } + + return base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken); + } +} +``` + # Putting It Together In day-to-day terms, the flow is simple: diff --git a/website/src/docs/hotchocolate/v16/server/cache-control.md b/website/src/docs/hotchocolate/v16/server/cache-control.md index 4402027bcef..1ef8f37246d 100644 --- a/website/src/docs/hotchocolate/v16/server/cache-control.md +++ b/website/src/docs/hotchocolate/v16/server/cache-control.md @@ -1,33 +1,97 @@ --- -title: Cache Control -description: Learn how to configure Cache-Control and Vary response headers for CDN and HTTP caching in Hot Chocolate GraphQL servers using @cacheControl directives, UseQueryCache, and cache-control options. +title: "Cache Control" +description: "Understand HTTP Cache-Control and Vary headers, then learn how Hot Chocolate uses GraphQL @cacheControl directives to generate safe CDN and browser caching policies." --- -Cache control defines how HTTP clients, browsers, reverse proxies, and CDNs should cache GraphQL responses. +Cache-Control is the HTTP header field that tells browsers, reverse proxies, and CDNs how they are allowed to store and reuse a response instead of sending the same request back to the server every time. Together with related headers such as `Vary`, it makes cached responses safe and predictable by defining whether a response may be reused, how long it may be reused, and which parts of the request affect that decision. -This chapter explains HTTP cache semantics first, then shows how GraphQL `@cacheControl` metadata is translated into `Cache-Control` and `Vary` response headers by Hot Chocolate. +# Why Cache Control Matters -# What Cache Control Means in HTTP +Good cache rules help you: -`Cache-Control` is an HTTP response header that tells caches whether a response can be stored and how long it remains fresh. +- Reduce latency for users. +- Reduce load on your backend services. +- Keep cache behavior predictable. +- Protect user-specific data from shared caches. -Common directives: +To make caching work safely and predictably, you need two things: -| Directive | Meaning | -| -------------------- | --------------------------------------------- | -| `max-age=` | Response freshness lifetime. | -| `s-maxage=` | Shared-cache freshness lifetime (for CDNs). | -| `public` | Shared caches may store this response. | -| `private` | Shared caches should not store this response. | -| `Vary:
` | Cache key depends on request headers. | +1. A deterministic GET route so caches get a stable cache key. +2. Correct Cache-Control (and Vary) headers so caches know where and how long a response may be reused. -For GraphQL APIs, these headers matter because one endpoint serves many operations and cache behavior should still be explicit and predictable. +# Why GraphQL Needs Extra Care -# How GraphQL `@cacheControl` Works +GraphQL usually exposes one endpoint, but each request can ask for different fields. Two requests to the same URL can therefore return very different response shapes and different data sensitivity. -GraphQL uses `@cacheControl` to declare cache intent on fields and types. +A single GraphQL response can also mix public data and user-specific data. Since HTTP cache headers apply to the full response, the gateway has to compute one safe final policy that represents everything selected in that operation. -## GraphQL SDL Example +That means one response can include: + +- Public data (safe for shared caches), and +- User-specific data (not safe for shared caches). + +The GraphQL operation type matters. Query operations are side-effect free reads, so they are the primary target for HTTP and CDN caching. Mutations change data and should not be cached as shared HTTP responses. Subscriptions are long-running streams and are not HTTP-cacheable. + +# Why GraphQL Needs Extra Care + +GraphQL usually exposes one endpoint, but each request can ask for different fields. Two requests to the same URL can therefore return very different response shapes and different data sensitivity. + +A single GraphQL response can also mix public data and user-specific data. Since HTTP cache headers apply to the full response, the server has to compute one safe final policy that represents everything selected in that operation. + +That means one response can include: + +- Public data, which is safe for shared caches. +- User-specific data, which is not safe for shared caches. + +The GraphQL operation type matters as well. Query operations are the primary target for HTTP and CDN caching. Mutations change data and should not be cached as shared HTTP responses. Subscriptions are long-running streams and are not HTTP-cacheable in the same way. + +# Deterministic GET Routes + +The GraphQL over HTTP specification allows query operations to be sent over HTTP GET. + +```http +GET /graphql?query=query GetProducts{products{nodes{name}}} +``` + +The same applies when variables are included. + +```http +GET /graphql?query=query GetProducts($first:Int!){products(first:$first){nodes{name}}}&variables={"first":5} +``` + +In real requests, these values are URL-encoded, and for larger operations the query string quickly becomes difficult to work with. This is where persisted operations help. + +## Persisted Operation Routes + +In large first-party GraphQL APIs, a common approach used by companies such as Netflix, Meta, and X is to rely on trusted documents. Client operations are stored in an operation store, and clients send a stable operation identifier instead of the full query text. + +> You can read more in the [First-Party API guide](/docs/fusion/v16/guides/first-party-api). + +With trusted documents in place, persisted-operation routes become short and stable. + +```http +GET /graphql/persisted/GetProducts/123456789 +``` + +Variables can then be sent as query parameters. + +```http +GET /graphql/persisted/GetProducts/123456789?variables={"first":5} +``` + +In order to use persisted-operation routes you need to add the middleware `MapGraphQLPersistedOperations`. + +```csharp +var app = builder.Build(); + +app.MapGraphQLPersistedOperations(); +``` + +# `@cacheControl` in GraphQL + +A deterministic route alone is not enough. The gateway also needs policy metadata to decide whether a response is public or private, and how long it may be reused. + +GraphQL provides the `@cacheControl` directive for this purpose. You can place it on fields and types to describe cache intent. ```graphql type Query { @@ -38,40 +102,48 @@ type Query { } ``` -## Hot Chocolate Resolver Example +In Hot Chocolate you can express `@cacheControl` directive with the `[CacheControl]` attribute. ```csharp using HotChocolate.Caching; -public sealed class Query +[QueryType] +public static class Query { [CacheControl(300, SharedMaxAge = 900)] - public Product? ProductById(int id) - => ProductStore.GetById(id); + public static Product? GetProductById(int id) + => ProductRepository.GetById(id); [CacheControl(60, Scope = CacheControlScope.Private, Vary = ["Authorization"])] - public UserProfile Me() - => UserStore.GetCurrent(); + public static UserProfile GetMe() + => UserProfileRepository.GetCurrent(); } ``` -At execution time, Hot Chocolate computes one effective cache policy for the full response. +# How Hot Chocolate Assembles the Final Headers + +Hot Chocolate computes one effective response policy by traversing the selected query fields. It reads `@cacheControl` metadata on each field, falls back to the field return type when values are missing, and continues recursively through child selections. + +All collected constraints are merged into one final policy. The merge is conservative: `max-age` and `s-maxage` take the lowest value, scope resolves to the strictest value (`private` over `public`), and `vary` values are merged, normalized, and deduplicated. + +Hot Chocolate computes cache constraints only for query operations. Introspection requests and operations for which no selected field contributes `maxAge` or `sharedMaxAge` do not produce a cache policy. `UseQueryCache()` writes the final headers only when the executed result has no GraphQL errors and the request has not opted out of cache-control header generation. # Enable Cache Control in Hot Chocolate -Install the package: +Install the package first: ```bash dotnet add package HotChocolate.Caching ``` -Register schema support and response-header middleware: +Then configure the server to register the directive and write the final headers: ```csharp using HotChocolate.Caching; -builder - .AddGraphQL() +builder.Services + .AddGraphQLServer()` + .AddQueryType() .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => @@ -80,16 +152,18 @@ builder }); ``` -- `AddCacheControl()` registers the `@cacheControl` directive and cache-constraint computation. -- `UseQueryCache()` writes `Cache-Control` and `Vary` values to HTTP responses. +`AddCacheControl()` registers the `@cacheControl` directive and the schema and execution components needed to compute cache constraints. `UseQueryCache()` writes the final `Cache-Control` and `Vary` values so the HTTP response formatter can emit them as HTTP headers. # Cache-Control Options `ModifyCacheControlOptions` configures default behavior: ```csharp -builder - .AddGraphQL() +using HotChocolate.Caching; + +builder.Services + .AddGraphQLServer() + .AddQueryType() .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => @@ -103,28 +177,24 @@ builder | Option | Type | Default | Description | | --------------- | ------------------- | -------- | --------------------------------------------------------------------- | -| `Enable` | `bool` | `true` | Enables cache-control response handling. | +| `Enable` | `bool` | `true` | Enables or disables cache-control header generation. | | `DefaultMaxAge` | `int` | `0` | Default `max-age` when `ApplyDefaults` is enabled. | | `DefaultScope` | `CacheControlScope` | `Public` | Default cache scope when `ApplyDefaults` is enabled. | | `ApplyDefaults` | `bool` | `true` | Applies defaults to eligible fields without explicit `@cacheControl`. | -# How Effective Response Policy Is Computed +With the default settings, eligible query fields that do not declare explicit cache metadata still contribute `max-age=0`. If you want headers only when fields opt in explicitly, set `ApplyDefaults = false`. + +# How HotChocolate Assembles the Final Headers -- Only **query operations** participate. -- `max-age` resolves to the most restrictive selected value. -- `s-maxage` resolves to the most restrictive selected shared value. -- Scope resolves to the most restrictive value (`private` over `public`). -- `Vary` values are merged across selected fields. +Hot Chocolate computes one effective response policy by traversing the planned operation tree. It starts at the selected root fields, reads `@cacheControl` metadata on each field, falls back to the field return type when values are missing, and continues recursively through child selections, including interfaces and unions. -No cache-control header is emitted when: +All collected constraints are merged into one final policy. The merge is conservative: `max-age` and `s-maxage` take the lowest value, scope resolves to the strictest value (`private` over `public`), and `vary` values are merged, normalized, and deduplicated. -- The operation is a mutation. -- The operation result contains errors. -- No selected field contributes cache constraints. +Hot Chocolate computes these cache constraints for query operations. Mutation requests, Subscription request, introspection requests, and operations with no cache constraints do not get cache-control headers. # Skip Cache Control for a Specific Request -Use `SkipQueryCaching()` on `OperationRequestBuilder` to bypass cache-control header generation for a specific request. +Use `SkipQueryCaching()` on `OperationRequestBuilder` to bypass cache-control for a specific request. ```csharp using HotChocolate.AspNetCore; @@ -151,49 +221,26 @@ public sealed class NoCacheHeaderInterceptor : DefaultHttpRequestInterceptor Register the interceptor: ```csharp -builder - .AddGraphQL() +builder.Services + .AddGraphQLServer() .AddHttpRequestInterceptor(); ``` -# Troubleshooting - -## `Cache-Control` header is missing - -Cause: - -- `.UseQueryCache()` or `.AddCacheControl()` is not configured. -- Selected fields do not contribute cache constraints. -- The response contains GraphQL errors. - -Solution: - -- Register both middleware and schema support. -- Verify selected fields use `@cacheControl` or enable defaults. -- Check operation errors before validating cache headers. - -## Mutations do not include cache headers - -Cause: - -- Cache-control headers are computed for query operations. - -Solution: - -- Treat mutation responses as non-cacheable. - -## CDN caches private user data - -Cause: +:::note +You can only register a single HttpRequestInterceptor per schema. +::: -- Scope is `public` for user-specific response data. +# Putting It Together -Solution: +In day-to-day terms, the flow is simple: -- Use `Scope = CacheControlScope.Private` and relevant `Vary` headers. +- Subgraphs declare cache intent. +- Fusion composes that metadata. +- The gateway calculates one safe policy for each query result. +- HTTP caches enforce the resulting headers. # Next Steps -- [Transports](/docs/hotchocolate/v16/server/http-transport) for HTTP transport behavior. +- [HTTP Transport](/docs/hotchocolate/v16/server/http-transport) for request and response behavior. - [Interceptors](/docs/hotchocolate/v16/server/interceptors) for request-level control. -- [Configuration Options](/docs/hotchocolate/v16/api-reference/options) for full option reference. +- [Configuration Options](/docs/hotchocolate/v16/api-reference/options) for the full options reference.