diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.AspNetCore/DynamicOpenApiDocumentTransformer.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.AspNetCore/DynamicOpenApiDocumentTransformer.cs index e21ef622ae3..f3ab58b737d 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.AspNetCore/DynamicOpenApiDocumentTransformer.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.AspNetCore/DynamicOpenApiDocumentTransformer.cs @@ -21,7 +21,9 @@ namespace HotChocolate.Adapters.OpenApi; -internal sealed class DynamicOpenApiDocumentTransformer : IOpenApiDocumentTransformer, IDynamicOpenApiDocumentTransformer +internal sealed class DynamicOpenApiDocumentTransformer + : IOpenApiDocumentTransformer, + IDynamicOpenApiDocumentTransformer { private const string JsonContentType = "application/json"; @@ -301,18 +303,13 @@ private EndpointDescriptor CreateEndpointDescriptor(OpenApiEndpointDefinition en operation.RequestBody = requestBody; } - var operationType = _schema.GetOperationType(endpoint.OperationDefinition.Operation); + var responseBodySelection = endpoint.GetResponseBodySelection(_schema); + var fieldType = responseBodySelection.FieldType + ?? throw new InvalidOperationException("Expected to resolve the response field type."); - if (endpoint.OperationDefinition.SelectionSet.Selections is not [FieldNode rootField]) - { - throw new InvalidOperationException("Expected to have a single field selection on the root"); - } - - var fieldType = operationType.Fields[rootField.Name.Value].Type; - - var responseSchema = rootField.SelectionSet is not null + var responseSchema = responseBodySelection.SelectionSet is not null ? CreateOpenApiSchemaForSelectionSet( - rootField.SelectionSet, + responseBodySelection.SelectionSet, fieldType, endpoint.LocalFragmentsByName) : CreateOpenApiSchemaForType(fieldType); diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiEndpointDefinition.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiEndpointDefinition.cs index b60bc59cba8..41d6923e955 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiEndpointDefinition.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiEndpointDefinition.cs @@ -3,49 +3,14 @@ namespace HotChocolate.Adapters.OpenApi; -public sealed class OpenApiEndpointDefinition : IOpenApiDefinition -{ - public OpenApiEndpointDefinition( - string httpMethod, - string route, - string? description, - ImmutableArray routeParameters, - ImmutableArray queryParameters, - string? bodyVariableName, - DocumentNode document, - OperationDefinitionNode operationDefinition, - Dictionary localFragmentsByName, - HashSet externalFragmentReferences) - { - HttpMethod = httpMethod; - Route = route; - Description = description; - RouteParameters = routeParameters; - QueryParameters = queryParameters; - BodyVariableName = bodyVariableName; - Document = document; - OperationDefinition = operationDefinition; - LocalFragmentsByName = localFragmentsByName; - ExternalFragmentReferences = externalFragmentReferences; - } - - public string HttpMethod { get; } - - public string Route { get; } - - public string? Description { get; } - - public ImmutableArray RouteParameters { get; } - - public ImmutableArray QueryParameters { get; } - - public string? BodyVariableName { get; } - - public DocumentNode Document { get; } - - public OperationDefinitionNode OperationDefinition { get; } - - public Dictionary LocalFragmentsByName { get; } - - public HashSet ExternalFragmentReferences { get; } -} +public sealed record OpenApiEndpointDefinition( + string HttpMethod, + string Route, + string? Description, + ImmutableArray RouteParameters, + ImmutableArray QueryParameters, + string? BodyVariableName, + DocumentNode Document, + OperationDefinitionNode OperationDefinition, + Dictionary LocalFragmentsByName, + HashSet ExternalFragmentReferences) : IOpenApiDefinition; diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiModelDefinition.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiModelDefinition.cs index a029716852a..ef6a8dc5287 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiModelDefinition.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Definitions/OpenApiModelDefinition.cs @@ -2,33 +2,10 @@ namespace HotChocolate.Adapters.OpenApi; -public sealed record OpenApiModelDefinition : IOpenApiDefinition -{ - public OpenApiModelDefinition( - string name, - string? description, - DocumentNode document, - FragmentDefinitionNode fragmentDefinition, - Dictionary localFragmentsByName, - HashSet externalFragmentReferences) - { - Name = name; - Description = description; - Document = document; - FragmentDefinition = fragmentDefinition; - LocalFragmentsByName = localFragmentsByName; - ExternalFragmentReferences = externalFragmentReferences; - } - - public string Name { get; } - - public string? Description { get; } - - public DocumentNode Document { get; } - - public FragmentDefinitionNode FragmentDefinition { get; } - - public Dictionary LocalFragmentsByName { get; } - - public HashSet ExternalFragmentReferences { get; } -} +public sealed record OpenApiModelDefinition( + string Name, + string? Description, + DocumentNode Document, + FragmentDefinitionNode FragmentDefinition, + Dictionary LocalFragmentsByName, + HashSet ExternalFragmentReferences) : IOpenApiDefinition; diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDefinitionExtensions.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDefinitionExtensions.cs new file mode 100644 index 00000000000..f971e22ba4f --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDefinitionExtensions.cs @@ -0,0 +1,129 @@ +using System.Collections.Immutable; +using System.Runtime.InteropServices; +using HotChocolate.Language; +using HotChocolate.Language.Visitors; +using HotChocolate.Types; + +namespace HotChocolate.Adapters.OpenApi; + +internal static class OpenApiEndpointDefinitionExtensions +{ + private static readonly ResponseBodySelectionFinder s_responseBodySelectionFinder = new(); + + public static OpenApiResponseBodySelection GetResponseBodySelection( + this OpenApiEndpointDefinition endpoint, + ISchemaDefinition schema) + { + var operation = endpoint.OperationDefinition; + var rootType = schema.GetOperationType(operation.Operation); + + return FindResponseBody(operation.SelectionSet, rootType) + ?? CreateDefaultResponseBody(operation.SelectionSet, rootType); + } + + private static OpenApiResponseBodySelection CreateDefaultResponseBody( + SelectionSetNode selectionSet, + IOutputType? rootType) + { + var rootField = selectionSet.Selections.FirstOrDefault() as FieldNode + ?? throw new InvalidOperationException("Expected to have a response field."); + + return new OpenApiResponseBodySelection( + [rootField.Alias?.Value ?? rootField.Name.Value], + rootField.SelectionSet, + ResolveFieldType(rootField, rootType)); + } + + private static OpenApiResponseBodySelection? FindResponseBody( + SelectionSetNode selectionSet, + IOutputType? rootType) + { + var context = new ResponseBodySelectionFinderContext(rootType); + s_responseBodySelectionFinder.Visit(selectionSet, context); + return context.ResponseBodySelection; + } + + private static IOutputType? ResolveFieldType(FieldNode field, IOutputType? declaringType) + { + if (declaringType?.NamedType() is IComplexTypeDefinition complexType + && complexType.Fields.TryGetField(field.Name.Value, out var fieldDefinition)) + { + return fieldDefinition.Type; + } + + return null; + } + + private sealed class ResponseBodySelectionFinder + : SyntaxWalker + { + protected override ISyntaxVisitorAction Enter( + FieldNode node, + ResponseBodySelectionFinderContext context) + { + var declaringType = context.Path.Count == 0 + ? context.RootType + : context.Path[^1].FieldType; + var fieldType = ResolveFieldType(node, declaringType); + context.Path.Add( + new ResponseBodyPathSegment( + node.Alias?.Value ?? node.Name.Value, + fieldType)); + + if (node.Directives.Any( + d => d.Name.Value == WellKnownDirectiveNames.ResponseBody)) + { + context.ResponseBodySelection = new OpenApiResponseBodySelection( + CreateResponseNamePath(context.Path), + node.SelectionSet, + fieldType); + return Break; + } + + return Continue; + } + + protected override ISyntaxVisitorAction Leave( + FieldNode node, + ResponseBodySelectionFinderContext context) + { + context.Path.RemoveAt(context.Path.Count - 1); + return Continue; + } + + protected override ISyntaxVisitorAction Enter( + InlineFragmentNode node, + ResponseBodySelectionFinderContext context) + => node.TypeCondition is null ? Continue : Skip; + + private static ImmutableArray CreateResponseNamePath( + List path) + { + var responseNamePath = new string[path.Count]; + for (var i = 0; i < path.Count; i++) + { + responseNamePath[i] = path[i].ResponseName; + } + + return ImmutableCollectionsMarshal.AsImmutableArray(responseNamePath); + } + } + + private sealed class ResponseBodySelectionFinderContext(IOutputType? rootType) + { + public IOutputType? RootType { get; } = rootType; + + public List Path { get; } = []; + + public OpenApiResponseBodySelection? ResponseBodySelection { get; set; } + } + + private readonly record struct ResponseBodyPathSegment( + string ResponseName, + IOutputType? FieldType); +} + +internal sealed record OpenApiResponseBodySelection( + ImmutableArray ResponseNamePath, + SelectionSetNode? SelectionSet, + IOutputType? FieldType); diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs index 10e7891a2db..379e6c5a596 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs @@ -11,7 +11,7 @@ internal sealed record OpenApiEndpointDescriptor( RoutePattern Route, VariableValueInsertionTrie ParameterTrie, string? VariableFilledThroughBody, - string ResponseNameToExtract); + OpenApiResponseBodySelection ResponseBodySelection); internal interface IVariableValueInsertionTrieSegment; diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs index d7b6d3c9c26..9d9a54b6359 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs @@ -58,43 +58,10 @@ public static OpenApiEndpointDescriptor CreateEndpointDescriptor( IDictionary modelsByName, ISchemaDefinition schema) { - List definitions = [ - ..endpointDefinition.Document.Definitions.OfType() - ]; - - var externalFragmentReferencesQueue = new Queue(endpointDefinition.ExternalFragmentReferences); - var processedFragmentReferences = new HashSet(); - - while (externalFragmentReferencesQueue.TryDequeue(out var referencedFragmentName)) - { - if (!processedFragmentReferences.Add(referencedFragmentName)) - { - continue; - } - - if (!modelsByName.TryGetValue(referencedFragmentName, out var model)) - { - continue; - } - - foreach (var definition in model.Document.Definitions.OfType()) - { - definitions.Add(definition); - } - - foreach (var externalFragmentReference in model.ExternalFragmentReferences) - { - externalFragmentReferencesQueue.Enqueue(externalFragmentReference); - } - } + var responseBodySelection = endpointDefinition.GetResponseBodySelection(schema); + var document = ComposeExecutionDocument(endpointDefinition, modelsByName); - var document = new DocumentNode(definitions); - - var rootField = endpointDefinition.OperationDefinition.SelectionSet.Selections - .OfType() - .First(); - - var responseNameToExtract = rootField.Alias?.Value ?? rootField.Name.Value; + document = ResponseBodyDirectiveRewriter.Instance.Rewrite(document); var route = RoutePatternFactory.Parse(endpointDefinition.Route); @@ -115,7 +82,7 @@ public static OpenApiEndpointDescriptor CreateEndpointDescriptor( route, parameterTrie, endpointDefinition.BodyVariableName, - responseNameToExtract); + responseBodySelection); void InsertParametersIntoTrie( IEnumerable parameters, @@ -186,6 +153,36 @@ void InsertParametersIntoTrie( } } + private static DocumentNode ComposeExecutionDocument( + OpenApiEndpointDefinition endpoint, + IDictionary modelsByName) + { + List definitions = + [ + .. endpoint.Document.Definitions.OfType() + ]; + var pendingFragments = new Queue(endpoint.ExternalFragmentReferences); + var processedFragments = new HashSet(); + + while (pendingFragments.TryDequeue(out var fragmentName)) + { + if (!processedFragments.Add(fragmentName) + || !modelsByName.TryGetValue(fragmentName, out var model)) + { + continue; + } + + definitions.AddRange(model.Document.Definitions.OfType()); + + foreach (var externalFragmentReference in model.ExternalFragmentReferences) + { + pendingFragments.Enqueue(externalFragmentReference); + } + } + + return new DocumentNode(definitions); + } + private static (ITypeDefinition Type, bool HasDefaultValue, bool IsNonNullType) GetParameterDetails( OpenApiEndpointDefinitionParameter parameter, OperationDefinitionNode operation, diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/ResponseBodyDirectiveRewriter.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/ResponseBodyDirectiveRewriter.cs new file mode 100644 index 00000000000..6065a76cded --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/ResponseBodyDirectiveRewriter.cs @@ -0,0 +1,27 @@ +using HotChocolate.Language; +using HotChocolate.Language.Visitors; + +namespace HotChocolate.Adapters.OpenApi; + +internal sealed class ResponseBodyDirectiveRewriter : SyntaxRewriter +{ + public static ResponseBodyDirectiveRewriter Instance { get; } = new(); + + public DocumentNode Rewrite(DocumentNode document) + => (DocumentNode)Rewrite(document, null)!; + + protected override FieldNode? RewriteField(FieldNode node, object? context) + { + var rewritten = base.RewriteField(node, context)!; + if (!rewritten.Directives.Any( + d => d.Name.Value == WellKnownDirectiveNames.ResponseBody)) + { + return rewritten; + } + + return rewritten.WithDirectives( + rewritten.Directives + .Where(d => d.Name.Value != WellKnownDirectiveNames.ResponseBody) + .ToArray()); + } +} diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/IDynamicOpenApiDocumentTransformer.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/IDynamicOpenApiDocumentTransformer.cs index 5ac2a6239ca..6e061261a9c 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/IDynamicOpenApiDocumentTransformer.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/IDynamicOpenApiDocumentTransformer.cs @@ -1,6 +1,6 @@ namespace HotChocolate.Adapters.OpenApi; -public interface IDynamicOpenApiDocumentTransformer +internal interface IDynamicOpenApiDocumentTransformer { void AddDefinitions( OpenApiEndpointDefinition[] endpoints, diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/OpenApiDefinitionRegistry.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/OpenApiDefinitionRegistry.cs index e8558339495..b0e32246bf6 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/OpenApiDefinitionRegistry.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/OpenApiDefinitionRegistry.cs @@ -175,10 +175,10 @@ private void UpdateEndpointsAndOpenApiDefinitions( // wins. If no duplicate produces a valid descriptor, the first one whose document // failed validation is kept so the route is still registered (the middleware returns // HTTP 500 on call). Descriptors that fail to construct outright are skipped. - // Track the chosen definitions in parallel with the descriptors so the same selection - // feeds both the runtime endpoints and the OpenAPI document generation. - var chosenDefinitions = new List(); - var chosenDescriptors = new List(); + // Keep each chosen definition paired with its descriptor so promotion cannot select + // a definition and runtime descriptor from different endpoints. + var chosenEndpoints = + new List<(OpenApiEndpointDefinition Definition, OpenApiEndpointDescriptor Descriptor)>(); var keyToIndex = new Dictionary<(string, string), int>(); var keyHasValid = new HashSet<(string, string)>(); @@ -207,35 +207,36 @@ private void UpdateEndpointsAndOpenApiDefinitions( if (keyToIndex.TryGetValue(key, out var existingIndex)) { // Promote: an earlier invalid descriptor is being replaced by a valid one. - chosenDefinitions[existingIndex] = endpoint; - chosenDescriptors[existingIndex] = descriptor; + chosenEndpoints[existingIndex] = (endpoint, descriptor); } else { - keyToIndex[key] = chosenDescriptors.Count; - chosenDefinitions.Add(endpoint); - chosenDescriptors.Add(descriptor); + keyToIndex[key] = chosenEndpoints.Count; + chosenEndpoints.Add((endpoint, descriptor)); } keyHasValid.Add(key); } else if (!keyToIndex.ContainsKey(key)) { - keyToIndex[key] = chosenDescriptors.Count; - chosenDefinitions.Add(endpoint); - chosenDescriptors.Add(descriptor); + keyToIndex[key] = chosenEndpoints.Count; + chosenEndpoints.Add((endpoint, descriptor)); } } - _transformer.AddDefinitions(chosenDefinitions.ToArray(), models, modelsByName, schema); + _transformer.AddDefinitions( + chosenEndpoints.Select(e => e.Definition).ToArray(), + models, + modelsByName, + schema); var httpEndpoints = new List(); - foreach (var descriptor in chosenDescriptors) + foreach (var endpoint in chosenEndpoints) { try { - httpEndpoints.Add(OpenApiEndpointFactory.CreateEndpoint(schema.Name, descriptor)); + httpEndpoints.Add(OpenApiEndpointFactory.CreateEndpoint(schema.Name, endpoint.Descriptor)); } catch { diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/OpenApiDefinitionValidator.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/OpenApiDefinitionValidator.cs index 37df9fe1ba3..a238cad520e 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/OpenApiDefinitionValidator.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/OpenApiDefinitionValidator.cs @@ -7,6 +7,7 @@ public sealed class OpenApiDefinitionValidator { private static readonly ImmutableArray s_modelValidationRules = [ + new ModelNoResponseBodyDirectiveRule(), new ModelNoDeferStreamDirectiveRule() ]; @@ -15,6 +16,8 @@ public sealed class OpenApiDefinitionValidator new EndpointMustHaveNameRule(), new EndpointMustBeQueryOrMutationRule(), new EndpointMustHaveSingleRootFieldRule(), + new EndpointNoResponseBodyDirectiveInFragmentsRule(), + new EndpointSingleResponseBodyDirectiveRule(), new EndpointNoDeferStreamDirectiveRule(), new EndpointHttpMethodMustBeValidRule(), new EndpointMustHaveValidRouteRule(), diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Endpoints/EndpointNoResponseBodyDirectiveInFragmentsRule.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Endpoints/EndpointNoResponseBodyDirectiveInFragmentsRule.cs new file mode 100644 index 00000000000..7ca6fdeabe7 --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Endpoints/EndpointNoResponseBodyDirectiveInFragmentsRule.cs @@ -0,0 +1,32 @@ +namespace HotChocolate.Adapters.OpenApi.Validation; + +/// +/// Validates that named fragments on an endpoint cannot contain the @responseBody directive. +/// +internal sealed class EndpointNoResponseBodyDirectiveInFragmentsRule + : IOpenApiEndpointDefinitionValidationRule +{ + private static readonly ResponseBodyDirectiveFinder s_finder = new(); + + public OpenApiDefinitionValidationResult Validate( + OpenApiEndpointDefinition endpoint, + IOpenApiDefinitionValidationContext context) + { + var finderContext = new ResponseBodyDirectiveFinder.Context(); + + foreach (var fragment in endpoint.LocalFragmentsByName.Values) + { + s_finder.Visit(fragment, finderContext); + } + + if (finderContext.Count > 0) + { + return OpenApiDefinitionValidationResult.Failure( + new OpenApiDefinitionValidationError( + "Endpoint named fragments cannot contain the '@responseBody' directive.", + endpoint)); + } + + return OpenApiDefinitionValidationResult.Success(); + } +} diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Endpoints/EndpointSingleResponseBodyDirectiveRule.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Endpoints/EndpointSingleResponseBodyDirectiveRule.cs new file mode 100644 index 00000000000..ec1ff327ad4 --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Endpoints/EndpointSingleResponseBodyDirectiveRule.cs @@ -0,0 +1,166 @@ +using HotChocolate.Language; +using HotChocolate.Types; + +namespace HotChocolate.Adapters.OpenApi.Validation; + +/// +/// Validates the @responseBody directive on an endpoint definition. +/// +internal sealed class EndpointSingleResponseBodyDirectiveRule + : IOpenApiEndpointDefinitionValidationRule +{ + private static readonly ResponseBodyDirectiveFinder s_finder = new(); + + public OpenApiDefinitionValidationResult Validate( + OpenApiEndpointDefinition endpoint, + IOpenApiDefinitionValidationContext context) + { + var finderContext = new ResponseBodyDirectiveFinder.Context(); + + s_finder.Visit(endpoint.OperationDefinition, finderContext); + + if (finderContext.Count > 1) + { + return OpenApiDefinitionValidationResult.Failure( + new OpenApiDefinitionValidationError( + "Endpoint operations can contain at most one '@responseBody' directive.", + endpoint)); + } + + if (finderContext.HasResponseBodyInTypedInlineFragment) + { + return OpenApiDefinitionValidationResult.Failure( + new OpenApiDefinitionValidationError( + "Endpoint operations cannot contain the '@responseBody' directive " + + "within an inline fragment with a type condition.", + endpoint)); + } + + if (HasResponseBodyDirectiveOnNonField(endpoint.OperationDefinition)) + { + return OpenApiDefinitionValidationResult.Failure( + new OpenApiDefinitionValidationError( + "The '@responseBody' directive can only be applied to fields.", + endpoint)); + } + + if (context.Schema is { } schema + && schema.TryGetOperationType(endpoint.OperationDefinition.Operation, out var rootType) + && ResponseBodyPathContainsList( + endpoint.OperationDefinition.SelectionSet, + rootType, + false)) + { + return OpenApiDefinitionValidationResult.Failure( + new OpenApiDefinitionValidationError( + "The path to a field with the '@responseBody' directive cannot contain list fields.", + endpoint)); + } + + return OpenApiDefinitionValidationResult.Success(); + } + + private static bool HasResponseBodyDirectiveOnNonField( + OperationDefinitionNode operation) + { + if (HasResponseBodyDirective(operation.Directives) + || operation.VariableDefinitions.Any( + variable => HasResponseBodyDirective(variable.Directives))) + { + return true; + } + + return HasResponseBodyDirectiveOnNonField(operation.SelectionSet); + } + + private static bool HasResponseBodyDirectiveOnNonField(SelectionSetNode selectionSet) + { + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field: + if (field.SelectionSet is not null + && HasResponseBodyDirectiveOnNonField(field.SelectionSet)) + { + return true; + } + break; + + case InlineFragmentNode inlineFragment: + if (HasResponseBodyDirective(inlineFragment.Directives) + || HasResponseBodyDirectiveOnNonField(inlineFragment.SelectionSet)) + { + return true; + } + break; + + case FragmentSpreadNode fragmentSpread: + if (HasResponseBodyDirective(fragmentSpread.Directives)) + { + return true; + } + break; + } + } + + return false; + } + + private static bool HasResponseBodyDirective(IReadOnlyList directives) + => directives.Any(d => d.Name.Value == WellKnownDirectiveNames.ResponseBody); + + private static bool ResponseBodyPathContainsList( + SelectionSetNode selectionSet, + IOutputType? declaringType, + bool containsList) + { + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field: + var fieldType = ResolveFieldType(field, declaringType); + + if (HasResponseBodyDirective(field.Directives)) + { + return containsList; + } + + if (field.SelectionSet is not null + && fieldType is not null + && ResponseBodyPathContainsList( + field.SelectionSet, + fieldType, + containsList || fieldType.IsListType())) + { + return true; + } + break; + + case InlineFragmentNode { TypeCondition: null } inlineFragment: + if (ResponseBodyPathContainsList( + inlineFragment.SelectionSet, + declaringType, + containsList)) + { + return true; + } + break; + } + } + + return false; + } + + private static IOutputType? ResolveFieldType(FieldNode field, IOutputType? declaringType) + { + if (declaringType?.NamedType() is IComplexTypeDefinition complexType + && complexType.Fields.TryGetField(field.Name.Value, out var fieldDefinition)) + { + return fieldDefinition.Type; + } + + return null; + } +} diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Models/ModelNoResponseBodyDirectiveRule.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Models/ModelNoResponseBodyDirectiveRule.cs new file mode 100644 index 00000000000..e7d60fd9afe --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/Models/ModelNoResponseBodyDirectiveRule.cs @@ -0,0 +1,28 @@ +namespace HotChocolate.Adapters.OpenApi.Validation; + +/// +/// Validates that a model definition cannot contain the @responseBody directive. +/// +internal sealed class ModelNoResponseBodyDirectiveRule : IOpenApiModelDefinitionValidationRule +{ + private static readonly ResponseBodyDirectiveFinder s_finder = new(); + + public OpenApiDefinitionValidationResult Validate( + OpenApiModelDefinition model, + IOpenApiDefinitionValidationContext context) + { + var finderContext = new ResponseBodyDirectiveFinder.Context(); + + s_finder.Visit(model.Document, finderContext); + + if (finderContext.Count > 0) + { + return OpenApiDefinitionValidationResult.Failure( + new OpenApiDefinitionValidationError( + "OpenAPI models cannot contain the '@responseBody' directive.", + model)); + } + + return OpenApiDefinitionValidationResult.Success(); + } +} diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/ResponseBodyDirectiveFinder.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/ResponseBodyDirectiveFinder.cs new file mode 100644 index 00000000000..cc8bd82ffe4 --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Validation/Rules/ResponseBodyDirectiveFinder.cs @@ -0,0 +1,48 @@ +using HotChocolate.Language; +using HotChocolate.Language.Visitors; + +namespace HotChocolate.Adapters.OpenApi.Validation; + +internal sealed class ResponseBodyDirectiveFinder() + : SyntaxVisitor( + new SyntaxVisitorOptions { VisitDirectives = true }) +{ + protected override ISyntaxVisitorAction Enter(ISyntaxNode node, Context context) + { + if (node is InlineFragmentNode { TypeCondition: not null }) + { + context.TypedInlineFragmentDepth++; + } + else if (node is DirectiveNode directive + && directive.Name.Value == WellKnownDirectiveNames.ResponseBody) + { + context.Count++; + + if (context.TypedInlineFragmentDepth > 0) + { + context.HasResponseBodyInTypedInlineFragment = true; + } + } + + return Continue; + } + + protected override ISyntaxVisitorAction Leave(ISyntaxNode node, Context context) + { + if (node is InlineFragmentNode { TypeCondition: not null }) + { + context.TypedInlineFragmentDepth--; + } + + return Continue; + } + + public sealed class Context + { + public int Count { get; set; } + + public int TypedInlineFragmentDepth { get; set; } + + public bool HasResponseBodyInTypedInlineFragment { get; set; } + } +} diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/WellKnownDirectiveNames.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/WellKnownDirectiveNames.cs index 2f6fd660657..42be3e617d6 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/WellKnownDirectiveNames.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/WellKnownDirectiveNames.cs @@ -3,5 +3,6 @@ namespace HotChocolate.Adapters.OpenApi; internal static class WellKnownDirectiveNames { public const string Body = "body"; + public const string ResponseBody = "responseBody"; public const string Http = "http"; } diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi/OpenApiResultFormatter.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi/OpenApiResultFormatter.cs index c21477b11c8..9c23e56df08 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi/OpenApiResultFormatter.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi/OpenApiResultFormatter.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Execution; using HotChocolate.Text.Json; using Microsoft.AspNetCore.Http; @@ -18,22 +19,28 @@ public async Task FormatResultAsync( return; } - if (!resultDocument.Data.TryGetProperty(endpoint.ResponseNameToExtract, out var rootProperty)) - { - await Results.InternalServerError().ExecuteAsync(httpContext); - return; - } + var responseValue = resultDocument.Data; - // If the root field is null, and we don't have any errors, - // we return HTTP 404 for queries and HTTP 500 otherwise. - if (rootProperty.IsNullOrInvalidated) + foreach (var segment in endpoint.ResponseBodySelection.ResponseNamePath) { - var result = endpoint.HttpMethod == HttpMethods.Get - ? Results.NotFound() - : Results.InternalServerError(); - - await result.ExecuteAsync(httpContext); - return; + if (responseValue.ValueKind is not JsonValueKind.Object + || !responseValue.TryGetProperty(segment, out responseValue)) + { + await Results.InternalServerError().ExecuteAsync(httpContext); + return; + } + + // If any field on the response name path is null, and we don't have any errors, + // we return HTTP 404 for queries and HTTP 500 otherwise. + if (responseValue.IsNullOrInvalidated) + { + var result = endpoint.HttpMethod == HttpMethods.Get + ? Results.NotFound() + : Results.InternalServerError(); + + await result.ExecuteAsync(httpContext); + return; + } } httpContext.Response.StatusCode = StatusCodes.Status200OK; @@ -41,7 +48,7 @@ public async Task FormatResultAsync( var bodyWriter = httpContext.Response.BodyWriter; - rootProperty.WriteTo(bodyWriter); + responseValue.WriteTo(bodyWriter); await bodyWriter.FlushAsync(cancellationToken); } diff --git a/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/FusionOpenApiResultFormatter.cs b/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/FusionOpenApiResultFormatter.cs index ca3ab4dc520..12ffc357a24 100644 --- a/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/FusionOpenApiResultFormatter.cs +++ b/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/FusionOpenApiResultFormatter.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Execution; using HotChocolate.Fusion.Text.Json; using Microsoft.AspNetCore.Http; @@ -18,22 +19,28 @@ public async Task FormatResultAsync( return; } - if (!resultDocument.Data.TryGetProperty(endpoint.ResponseNameToExtract, out var rootProperty)) - { - await Results.InternalServerError().ExecuteAsync(httpContext); - return; - } + var responseValue = resultDocument.Data; - // If the root field is null, and we don't have any errors, - // we return HTTP 404 for queries and HTTP 500 otherwise. - if (rootProperty.IsNullOrInvalidated) + foreach (var segment in endpoint.ResponseBodySelection.ResponseNamePath) { - var result = endpoint.HttpMethod == HttpMethods.Get - ? Results.NotFound() - : Results.InternalServerError(); - - await result.ExecuteAsync(httpContext); - return; + if (responseValue.ValueKind is not JsonValueKind.Object + || !responseValue.TryGetProperty(segment, out responseValue)) + { + await Results.InternalServerError().ExecuteAsync(httpContext); + return; + } + + // If any field on the response name path is null, and we don't have any errors, + // we return HTTP 404 for queries and HTTP 500 otherwise. + if (responseValue.IsNullOrInvalidated) + { + var result = endpoint.HttpMethod == HttpMethods.Get + ? Results.NotFound() + : Results.InternalServerError(); + + await result.ExecuteAsync(httpContext); + return; + } } httpContext.Response.StatusCode = StatusCodes.Status200OK; @@ -41,7 +48,7 @@ public async Task FormatResultAsync( var bodyWriter = httpContext.Response.BodyWriter; - rootProperty.WriteTo(bodyWriter); + responseValue.WriteTo(bodyWriter); await bodyWriter.FlushAsync(cancellationToken); } diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs index e836eb83f68..20564ac4a4e 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs @@ -254,6 +254,144 @@ public async Task Http_Get_Root_Field_Returns_Null_Without_Errors() response.MatchSnapshot(); } + [Fact] + public async Task Http_Get_With_ResponseBody_And_Unrelated_Type_Refinements() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetAddress($userId: ID!) @http(method: GET, route: "/users/{userId}/address") { + user: userById(id: $userId) { + ... on User { + name + } + ... { + email + } + address: address @responseBody { + ... on Address { + road: street + } + } + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync( + "/users/1/address", + TestContext.Current.CancellationToken); + + // assert + response.MatchSnapshot(); + } + + [Fact] + public async Task Http_Get_ResponseBody_Field_Returns_NotFound() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetPreferences($userId: ID!) @http(method: GET, route: "/users/{userId}/preferences") { + userById(id: $userId) { + preferences @responseBody { + color + } + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync( + "/users/1/preferences", + TestContext.Current.CancellationToken); + + // assert + response.MatchSnapshot(); + } + + [Fact] + public async Task Http_Get_ResponseBody_Ancestor_Returns_NotFound() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetAddress($userId: ID!) @http(method: GET, route: "/users/{userId}/address") { + userById(id: $userId) { + address @responseBody { + street + } + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync( + "/users/4/address", + TestContext.Current.CancellationToken); + + // assert + response.MatchSnapshot(); + } + + [Fact] + public async Task Http_Get_Skipped_ResponseBody_Field_Returns_InternalServerError() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetAddress($userId: ID!) @http(method: GET, route: "/users/{userId}/address") { + userById(id: $userId) { + ... { + address @skip(if: true) @responseBody { + street + } + } + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync( + "/users/1/address", + TestContext.Current.CancellationToken); + + // assert + response.MatchSnapshot(); + } + + [Fact] + public async Task Http_Get_Should_Return_List_When_ResponseBody_Field_Returns_List() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUsers @http(method: GET, route: "/users") { + usersWithoutAuth @responseBody { + name + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync( + "/users", + TestContext.Current.CancellationToken); + + // assert + response.MatchSnapshot(); + } + [Fact] public async Task Http_Get_Has_GraphQL_Errors() { @@ -324,6 +462,38 @@ public async Task Http_Get_Root_Field_Has_Authorization_Not_Authorized() #region POST + [Fact] + public async Task Http_Post_ResponseBody_Field_Returns_InternalServerError() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + mutation UpdatePreferences($user: UserInput! @body) + @http(method: POST, route: "/users/preferences") { + updateUser(user: $user) { + preferences @responseBody { + color + } + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + var content = new StringContent( + """{ "id": "1", "name": "Test", "email": "test@example.com" }""", + Encoding.UTF8, + "application/json"); + + // act + var response = await client.PostAsync( + "/users/preferences", + content, + TestContext.Current.CancellationToken); + + // assert + response.MatchSnapshot(); + } + [Fact] public async Task Http_Post() { @@ -854,6 +1024,47 @@ query GetUsers @http(method: GET, route: "/users") { Assert.Equal(HttpStatusCode.InternalServerError, invalidResponse.StatusCode); } + [Fact] + public async Task Request_Should_Return_InternalServerError_When_Models_Contain_Duplicate_Fragment_Names() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUser @http(method: GET, route: "/users/1") { + userById(id: "1") { + ...UserId + ...UserName + } + } + """, + """ + fragment UserId on User { + ...UserFields + } + + fragment UserFields on User { + id + } + """, + """ + fragment UserName on User { + ...UserFields + } + + fragment UserFields on User { + name + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync("/users/1", TestContext.Current.CancellationToken); + + // assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + [Fact] public async Task Duplicated_Routes() { @@ -892,16 +1103,18 @@ public async Task Duplicated_Routes_FirstInvalidSecondValid_PrefersValid() // arrange var storage = new TestOpenApiDefinitionStorage( """ - query A_InvalidFirst @http(method: GET, route: "/users") { + query AInvalidFirst @http(method: GET, route: "/users/1") { doesNotExist { - id + id @responseBody } } """, """ - query B_ValidSecond @http(method: GET, route: "/users") { - usersWithoutAuth { - id + query BValidSecond @http(method: GET, route: "/users/1") { + userById(id: "1") { + address @responseBody { + road: street + } } } """); @@ -909,7 +1122,7 @@ query B_ValidSecond @http(method: GET, route: "/users") { var client = server.CreateClient(); // act - var response = await client.GetAsync("/users", TestContext.Current.CancellationToken); + var response = await client.GetAsync("/users/1", TestContext.Current.CancellationToken); // assert response.MatchSnapshot(); diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_PrefersValid.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_PrefersValid.snap index 84fd66beee4..c97f028b581 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_PrefersValid.snap +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_PrefersValid.snap @@ -3,4 +3,4 @@ Content-Type: application/json --------------------------> Status Code: OK --------------------------> -[{"id":"1"},{"id":"2"},{"id":"3"}] +{"road":"1 Street"} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_ResponseBody_Ancestor_Returns_NotFound.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_ResponseBody_Ancestor_Returns_NotFound.snap new file mode 100644 index 00000000000..f195887770c --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_ResponseBody_Ancestor_Returns_NotFound.snap @@ -0,0 +1,2 @@ +Status Code: NotFound +--------------------------> diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_ResponseBody_Field_Returns_NotFound.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_ResponseBody_Field_Returns_NotFound.snap new file mode 100644 index 00000000000..f195887770c --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_ResponseBody_Field_Returns_NotFound.snap @@ -0,0 +1,2 @@ +Status Code: NotFound +--------------------------> diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_Should_Return_List_When_ResponseBody_Field_Returns_List.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_Should_Return_List_When_ResponseBody_Field_Returns_List.snap new file mode 100644 index 00000000000..cdcfe0fe2a6 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_Should_Return_List_When_ResponseBody_Field_Returns_List.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/json +--------------------------> +Status Code: OK +--------------------------> +[{"name":"User 1"},{"name":"User 2"},{"name":"User 3"}] diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_Skipped_ResponseBody_Field_Returns_InternalServerError.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_Skipped_ResponseBody_Field_Returns_InternalServerError.snap new file mode 100644 index 00000000000..c32a359c1ec --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_Skipped_ResponseBody_Field_Returns_InternalServerError.snap @@ -0,0 +1,2 @@ +Status Code: InternalServerError +--------------------------> diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_ResponseBody_And_Unrelated_Type_Refinements.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_ResponseBody_And_Unrelated_Type_Refinements.snap new file mode 100644 index 00000000000..c97f028b581 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_ResponseBody_And_Unrelated_Type_Refinements.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/json +--------------------------> +Status Code: OK +--------------------------> +{"road":"1 Street"} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Post_ResponseBody_Field_Returns_InternalServerError.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Post_ResponseBody_Field_Returns_InternalServerError.snap new file mode 100644 index 00000000000..c32a359c1ec --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Post_ResponseBody_Field_Returns_InternalServerError.snap @@ -0,0 +1,2 @@ +Status Code: InternalServerError +--------------------------> diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/OpenApiIntegrationTestBase.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/OpenApiIntegrationTestBase.cs index d4c04b46f0b..9d13f0dc373 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/OpenApiIntegrationTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/OpenApiIntegrationTestBase.cs @@ -50,6 +50,52 @@ query GetUserById($userId: ID!) @http(method: GET, route: "/users/{userId}") { openApiDocument.MatchSnapshot(postFix: TestEnvironment.TargetFramework, extension: ".json"); } + [Fact] + public async Task OperationDocument_With_ResponseBody_Field() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetAddress($userId: ID!) @http(method: GET, route: "/users/{userId}/address") { + userById(id: $userId) { + address @responseBody { + road: street + } + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var openApiDocument = await GetOpenApiDocumentAsync(client); + + // assert + openApiDocument.MatchSnapshot(postFix: TestEnvironment.TargetFramework, extension: ".json"); + } + + [Fact] + public async Task OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUsers @http(method: GET, route: "/users") { + usersWithoutAuth @responseBody { + name + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var openApiDocument = await GetOpenApiDocumentAsync(client); + + // assert + openApiDocument.MatchSnapshot(postFix: TestEnvironment.TargetFramework, extension: ".json"); + } + [Fact] public async Task OperationDocument_With_Default_Value_For_Variable() { @@ -871,6 +917,37 @@ query GetUsers @http(method: GET, route: "/users") { openApiDocument.MatchSnapshot(postFix: TestEnvironment.TargetFramework, extension: ".json"); } + [Fact] + public async Task Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query AInvalidFirst @http(method: GET, route: "/users/1") { + doesNotExist { + id @responseBody + } + } + """, + """ + query BValidSecond @http(method: GET, route: "/users/1") { + userById(id: "1") { + address @responseBody { + road: street + } + } + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var openApiDocument = await GetOpenApiDocumentAsync(client); + + // assert + openApiDocument.MatchSnapshot(postFix: TestEnvironment.TargetFramework, extension: ".json"); + } + [Fact] public async Task Duplicated_Operation_Names() { diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET10_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET10_0.json new file mode 100644 index 00000000000..b02fff28987 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET10_0.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost/" + } + ], + "paths": { + "/users/1": { + "get": { + "operationId": "bValidSecond", + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "required": [ + "road" + ], + "type": "object", + "properties": { + "road": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET11_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET11_0.json new file mode 100644 index 00000000000..c3aa89ba102 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET11_0.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost" + } + ], + "paths": { + "/users/1": { + "get": { + "operationId": "bValidSecond", + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "required": [ + "road" + ], + "type": "object", + "properties": { + "road": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET9_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET9_0.json new file mode 100644 index 00000000000..ebc24b73574 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.Duplicated_Routes_FirstInvalidSecondValid_UsesValidResponseBodySelection_NET9_0.json @@ -0,0 +1,35 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "paths": { + "/users/1": { + "get": { + "operationId": "bValidSecond", + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "required": [ + "road" + ], + "type": "object", + "properties": { + "road": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "components": { } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET10_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET10_0.json new file mode 100644 index 00000000000..c29506d6ddb --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET10_0.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost/" + } + ], + "paths": { + "/users": { + "get": { + "operationId": "getUsers", + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET11_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET11_0.json new file mode 100644 index 00000000000..9ea54353b00 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET11_0.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost" + } + ], + "paths": { + "/users": { + "get": { + "operationId": "getUsers", + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET9_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET9_0.json new file mode 100644 index 00000000000..4ce48c02ca3 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_Should_Use_Array_Response_When_ResponseBody_Field_Returns_List_NET9_0.json @@ -0,0 +1,38 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "operationId": "getUsers", + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "components": { } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET10_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET10_0.json new file mode 100644 index 00000000000..b07a7531643 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET10_0.json @@ -0,0 +1,56 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost/" + } + ], + "paths": { + "/users/{userId}/address": { + "get": { + "operationId": "getAddress", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + } + ], + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "required": [ + "road" + ], + "type": "object", + "properties": { + "road": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET11_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET11_0.json new file mode 100644 index 00000000000..6c880b4100c --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET11_0.json @@ -0,0 +1,56 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost" + } + ], + "paths": { + "/users/{userId}/address": { + "get": { + "operationId": "getAddress", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + } + ], + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "required": [ + "road" + ], + "type": "object", + "properties": { + "road": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET9_0.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET9_0.json new file mode 100644 index 00000000000..c10914e2f0b --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OperationDocument_With_ResponseBody_Field_NET9_0.json @@ -0,0 +1,52 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "HotChocolate.Adapters.OpenApi.Tests | v1", + "version": "1.0.0" + }, + "paths": { + "/users/{userId}/address": { + "get": { + "operationId": "getAddress", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + } + ], + "responses": { + "200": { + "description": null, + "content": { + "application/json": { + "schema": { + "required": [ + "road" + ], + "type": "object", + "properties": { + "road": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "components": { } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Validation/ValidationTestBase.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Validation/ValidationTestBase.cs index b26a4d0d790..ea9a1449dda 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Validation/ValidationTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Validation/ValidationTestBase.cs @@ -64,6 +64,44 @@ users @stream { Assert.Equal("Model contains the '@stream' directive, which is not supported for OpenAPI models.", error.Message); } + [Fact] + public async Task External_Model_Contains_ResponseBody_Directive_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUser @http(method: GET, route: "/user") { + userById(id: "1") { + address @responseBody { + street + } + ...UserPreferences + } + } + """, + """ + fragment UserPreferences on User { + preferences @responseBody { + color + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "OpenAPI models cannot contain the '@responseBody' directive.", + error.Message); + } + #endregion #region Endpoint @@ -154,6 +192,254 @@ query GetUser @http(method: GET, route: "/user") { Assert.Equal("Endpoint must select exactly one root field.", error.Message); } + [Fact] + public async Task Endpoint_Multiple_ResponseBody_Directives_In_Operation_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUser @http(method: GET, route: "/user") { + userById(id: "1") { + address @responseBody { + street + } + preferences @responseBody { + color + } + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "Endpoint operations can contain at most one '@responseBody' directive.", + error.Message); + } + + [Fact] + public async Task Endpoint_Named_Fragment_Contains_ResponseBody_Directive_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUser @http(method: GET, route: "/user") { + userById(id: "1") { + ...UserPreferences + } + } + + fragment UserPreferences on User { + preferences @responseBody { + color + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "Endpoint named fragments cannot contain the '@responseBody' directive.", + error.Message); + } + + [Fact] + public async Task Endpoint_Field_Has_Multiple_ResponseBody_Directives_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUser @http(method: GET, route: "/user") { + userById(id: "1") { + ... on User { + address @responseBody @responseBody { + street + } + } + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "Endpoint operations can contain at most one '@responseBody' directive.", + error.Message); + } + + [Fact] + public async Task Endpoint_ResponseBody_Directive_Is_Not_On_Field_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUser @http(method: GET, route: "/user") { + userById(id: "1") { + ... @responseBody { + name + } + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "The '@responseBody' directive can only be applied to fields.", + error.Message); + } + + [Fact] + public async Task Endpoint_ResponseBody_Path_Contains_List_Field_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUserNames @http(method: GET, route: "/users/names") { + usersWithoutAuth { + name @responseBody + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "The path to a field with the '@responseBody' directive cannot contain list fields.", + error.Message); + } + + [Fact] + public async Task Endpoint_ResponseBody_Path_Contains_Aliased_List_Field_And_Untyped_Fragment_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUserNames @http(method: GET, route: "/users/names") { + users: usersWithoutAuth { + ... { + name @responseBody + } + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "The path to a field with the '@responseBody' directive cannot contain list fields.", + error.Message); + } + + [Fact] + public async Task Endpoint_ResponseBody_Field_Returns_List_IsValid() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUsers @http(method: GET, route: "/users") { + usersWithoutAuth @responseBody { + name + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + // assert + Assert.Empty(eventListener.Errors); + } + + [Fact] + public async Task Endpoint_ResponseBody_Path_Contains_Type_Refinement_RaisesError() + { + // arrange + using var cts = new CancellationTokenSource(s_testTimeout); + var storage = new TestOpenApiDefinitionStorage( + """ + query GetUser @http(method: GET, route: "/user") { + userById(id: "1") { + ... on User { + ... on User { + name + } + ... { + address @responseBody { + street + } + } + } + } + } + """); + var eventListener = new TestOpenApiDiagnosticEventListener(); + var server = CreateTestServer(storage, eventListener); + + // act + await server.Services.GetRequestExecutorAsync(cancellationToken: cts.Token); + + eventListener.HasReportedErrors.Wait(cts.Token); + + // assert + var error = Assert.Single(eventListener.Errors); + Assert.Equal( + "Endpoint operations cannot contain the '@responseBody' directive " + + "within an inline fragment with a type condition.", + error.Message); + } + [Fact] public async Task Endpoint_Parameter_Conflict_RaisesError() {