diff --git a/src/Core/Services/DabGraphQLResultSerializer.cs b/src/Core/Services/DabGraphQLResultSerializer.cs index b9cce8334c..f71f7dc130 100644 --- a/src/Core/Services/DabGraphQLResultSerializer.cs +++ b/src/Core/Services/DabGraphQLResultSerializer.cs @@ -11,10 +11,17 @@ namespace Azure.DataApiBuilder.Core.Services; /// /// The DabGraphQLResultSerializer inspects the IExecutionResult created by HotChocolate /// and determines the appropriate HTTP error code to return based on the errors in the result. -/// By Default, without this serializer, HotChocolate will return a 500 status code when database errors -/// exist. However, there is a specific error code we check for that should return a 400 status code: -/// - DatabaseInputError. This indicates that the client can make a change to request contents to influence -/// a change in the response. +/// +/// By default, without this serializer, HotChocolate will return a 500 status code for errors. +/// This serializer maps DataApiBuilderException.SubStatusCodes to their appropriate HTTP status codes. +/// +/// For example: +/// - Authentication/Authorization errors will return 401/403 +/// - Database input validation errors will return 400 BadRequest +/// - Entity not found errors will return 404 NotFound +/// +/// This ensures that GraphQL endpoints return appropriate and consistent HTTP status codes +/// for all types of DataApiBuilderException errors. /// public class DabGraphQLResultSerializer : DefaultHttpResultSerializer { @@ -22,12 +29,62 @@ public override HttpStatusCode GetStatusCode(IExecutionResult result) { if (result is IQueryResult queryResult && queryResult.Errors?.Count > 0) { - if (queryResult.Errors.Any(error => error.Code == DataApiBuilderException.SubStatusCodes.DatabaseInputError.ToString())) + // Check if any of the errors are from DataApiBuilderException by looking at error.Code + foreach (var error in queryResult.Errors) { - return HttpStatusCode.BadRequest; + if (error.Code != null && + Enum.TryParse(error.Code, out var subStatusCode)) + { + return MapSubStatusCodeToHttpStatusCode(subStatusCode); + } } } return base.GetStatusCode(result); } + + /// + /// Maps DataApiBuilderException.SubStatusCodes to appropriate HTTP status codes. + /// + private static HttpStatusCode MapSubStatusCodeToHttpStatusCode(DataApiBuilderException.SubStatusCodes subStatusCode) => subStatusCode switch + { + // Authentication/Authorization errors + DataApiBuilderException.SubStatusCodes.AuthenticationChallenge + => HttpStatusCode.Unauthorized, // 401 + + DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed or + DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure or + DataApiBuilderException.SubStatusCodes.AuthorizationCumulativeColumnCheckFailed + => HttpStatusCode.Forbidden, // 403 + + // Not Found errors + DataApiBuilderException.SubStatusCodes.EntityNotFound or + DataApiBuilderException.SubStatusCodes.ItemNotFound or + DataApiBuilderException.SubStatusCodes.RelationshipNotFound or + DataApiBuilderException.SubStatusCodes.RelationshipFieldNotFound or + DataApiBuilderException.SubStatusCodes.DataSourceNotFound + => HttpStatusCode.NotFound, // 404 + + // Bad Request errors + DataApiBuilderException.SubStatusCodes.BadRequest or + DataApiBuilderException.SubStatusCodes.DatabaseInputError or + DataApiBuilderException.SubStatusCodes.InvalidIdentifierField or + DataApiBuilderException.SubStatusCodes.ErrorProcessingData or + DataApiBuilderException.SubStatusCodes.ExposedColumnNameMappingError or + DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType or + DataApiBuilderException.SubStatusCodes.ErrorProcessingEasyAuthHeader + => HttpStatusCode.BadRequest, // 400 + + // Not Implemented errors + DataApiBuilderException.SubStatusCodes.NotSupported or + DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled + => HttpStatusCode.NotImplemented, // 501 + + // Conflict errors + DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists + => HttpStatusCode.Conflict, // 409 + + // Server errors - Internal Server Error (default) + _ => HttpStatusCode.InternalServerError // 500 + }; } diff --git a/src/Service.Tests/Unittests/DabGraphQLResultSerializerTests.cs b/src/Service.Tests/Unittests/DabGraphQLResultSerializerTests.cs new file mode 100644 index 0000000000..62789130b4 --- /dev/null +++ b/src/Service.Tests/Unittests/DabGraphQLResultSerializerTests.cs @@ -0,0 +1,591 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Net; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate; +using HotChocolate.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Unit tests for DabGraphQLResultSerializer + /// + [TestClass] + public class DabGraphQLResultSerializerTests + { + private DabGraphQLResultSerializer _serializer = null!; + + [TestInitialize] + public void TestInitialize() + { + _serializer = new DabGraphQLResultSerializer(); + } + + #region Authentication/Authorization Tests + + /// + /// Verify that AuthenticationChallenge maps to 401 Unauthorized + /// + [TestMethod] + public void AuthenticationChallenge_Returns401() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.AuthenticationChallenge); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.Unauthorized, statusCode); + } + + /// + /// Verify that AuthorizationCheckFailed maps to 403 Forbidden + /// + [TestMethod] + public void AuthorizationCheckFailed_Returns403() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.Forbidden, statusCode); + } + + /// + /// Verify that DatabasePolicyFailure maps to 403 Forbidden + /// + [TestMethod] + public void DatabasePolicyFailure_Returns403() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.Forbidden, statusCode); + } + + /// + /// Verify that AuthorizationCumulativeColumnCheckFailed maps to 403 Forbidden + /// + [TestMethod] + public void AuthorizationCumulativeColumnCheckFailed_Returns403() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.AuthorizationCumulativeColumnCheckFailed); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.Forbidden, statusCode); + } + + #endregion + + #region Not Found Tests + + /// + /// Verify that EntityNotFound maps to 404 NotFound + /// + [TestMethod] + public void EntityNotFound_Returns404() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.EntityNotFound); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, statusCode); + } + + /// + /// Verify that ItemNotFound maps to 404 NotFound + /// + [TestMethod] + public void ItemNotFound_Returns404() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.ItemNotFound); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, statusCode); + } + + /// + /// Verify that RelationshipNotFound maps to 404 NotFound + /// + [TestMethod] + public void RelationshipNotFound_Returns404() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.RelationshipNotFound); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, statusCode); + } + + /// + /// Verify that RelationshipFieldNotFound maps to 404 NotFound + /// + [TestMethod] + public void RelationshipFieldNotFound_Returns404() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.RelationshipFieldNotFound); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, statusCode); + } + + /// + /// Verify that DataSourceNotFound maps to 404 NotFound + /// + [TestMethod] + public void DataSourceNotFound_Returns404() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.DataSourceNotFound); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, statusCode); + } + + #endregion + + #region Bad Request Tests + + /// + /// Verify that BadRequest maps to 400 BadRequest + /// + [TestMethod] + public void BadRequest_Returns400() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.BadRequest); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, statusCode); + } + + /// + /// Verify that DatabaseInputError maps to 400 BadRequest + /// + [TestMethod] + public void DatabaseInputError_Returns400() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.DatabaseInputError); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, statusCode); + } + + /// + /// Verify that InvalidIdentifierField maps to 400 BadRequest + /// + [TestMethod] + public void InvalidIdentifierField_Returns400() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.InvalidIdentifierField); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, statusCode); + } + + /// + /// Verify that ErrorProcessingData maps to 400 BadRequest + /// + [TestMethod] + public void ErrorProcessingData_Returns400() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.ErrorProcessingData); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, statusCode); + } + + /// + /// Verify that ExposedColumnNameMappingError maps to 400 BadRequest + /// + [TestMethod] + public void ExposedColumnNameMappingError_Returns400() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.ExposedColumnNameMappingError); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, statusCode); + } + + /// + /// Verify that UnsupportedClaimValueType maps to 400 BadRequest + /// + [TestMethod] + public void UnsupportedClaimValueType_Returns400() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, statusCode); + } + + /// + /// Verify that ErrorProcessingEasyAuthHeader maps to 400 BadRequest + /// + [TestMethod] + public void ErrorProcessingEasyAuthHeader_Returns400() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.ErrorProcessingEasyAuthHeader); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, statusCode); + } + + #endregion + + #region Not Implemented Tests + + /// + /// Verify that NotSupported maps to 501 NotImplemented + /// + [TestMethod] + public void NotSupported_Returns501() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.NotSupported); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.NotImplemented, statusCode); + } + + /// + /// Verify that GlobalRestEndpointDisabled maps to 501 NotImplemented + /// + [TestMethod] + public void GlobalRestEndpointDisabled_Returns501() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.NotImplemented, statusCode); + } + + #endregion + + #region Conflict Tests + + /// + /// Verify that OpenApiDocumentAlreadyExists maps to 409 Conflict + /// + [TestMethod] + public void OpenApiDocumentAlreadyExists_Returns409() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.Conflict, statusCode); + } + + #endregion + + #region Server Error Tests + + /// + /// Verify that ConfigValidationError maps to 500 InternalServerError + /// + [TestMethod] + public void ConfigValidationError_Returns500() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.ConfigValidationError); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that ErrorInInitialization maps to 500 InternalServerError + /// + [TestMethod] + public void ErrorInInitialization_Returns500() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that DatabaseOperationFailed maps to 500 InternalServerError + /// + [TestMethod] + public void DatabaseOperationFailed_Returns500() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.DatabaseOperationFailed); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that GraphQLMapping maps to 500 InternalServerError + /// + [TestMethod] + public void GraphQLMapping_Returns500() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.GraphQLMapping); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that UnexpectedError maps to 500 InternalServerError + /// + [TestMethod] + public void UnexpectedError_Returns500() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.UnexpectedError); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that OpenApiDocumentCreationFailure maps to 500 InternalServerError + /// + [TestMethod] + public void OpenApiDocumentCreationFailure_Returns500() + { + // Arrange + IExecutionResult result = CreateResultWithError(DataApiBuilderException.SubStatusCodes.OpenApiDocumentCreationFailure); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(result); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + #endregion + + #region Edge Case Tests + + /// + /// Verify that when there are no errors, the base status code is returned (500) + /// + [TestMethod] + public void NoErrors_ReturnsBaseStatusCode() + { + // Arrange + Mock mockResult = new(); + mockResult.Setup(r => r.Errors).Returns((IReadOnlyList)null); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(mockResult.Object); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that when errors list is empty, the base status code is returned (500) + /// + [TestMethod] + public void EmptyErrorsList_ReturnsBaseStatusCode() + { + // Arrange + Mock mockResult = new(); + mockResult.Setup(r => r.Errors).Returns(new List()); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(mockResult.Object); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that when error has null code, the base status code is returned (500) + /// + [TestMethod] + public void NullErrorCode_ReturnsBaseStatusCode() + { + // Arrange + Mock mockError = new(); + mockError.Setup(e => e.Code).Returns((string)null); + + Mock mockResult = new(); + mockResult.Setup(r => r.Errors).Returns(new List { mockError.Object }); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(mockResult.Object); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that when error code is not a valid SubStatusCode, the base status code is returned (500) + /// + [TestMethod] + public void InvalidErrorCode_ReturnsBaseStatusCode() + { + // Arrange + Mock mockError = new(); + mockError.Setup(e => e.Code).Returns("InvalidErrorCode"); + + Mock mockResult = new(); + mockResult.Setup(r => r.Errors).Returns(new List { mockError.Object }); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(mockResult.Object); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + /// + /// Verify that when there are multiple errors, the first DAB exception error code is used + /// + [TestMethod] + public void MultipleErrors_ReturnsFirstDabExceptionStatusCode() + { + // Arrange + Mock mockError1 = new(); + mockError1.Setup(e => e.Code).Returns("InvalidCode"); // Not a DAB exception + + Mock mockError2 = new(); + mockError2.Setup(e => e.Code).Returns(DataApiBuilderException.SubStatusCodes.EntityNotFound.ToString()); + + Mock mockError3 = new(); + mockError3.Setup(e => e.Code).Returns(DataApiBuilderException.SubStatusCodes.BadRequest.ToString()); + + Mock mockResult = new(); + mockResult.Setup(r => r.Errors).Returns(new List { mockError1.Object, mockError2.Object, mockError3.Object }); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(mockResult.Object); + + // Assert - Should return status code for first valid DAB exception (EntityNotFound = 404) + Assert.AreEqual(HttpStatusCode.NotFound, statusCode); + } + + /// + /// Verify that result that is not IQueryResult returns base status code (500) + /// + [TestMethod] + public void NonQueryResult_ReturnsBaseStatusCode() + { + // Arrange + Mock mockResult = new(); + + // Act + HttpStatusCode statusCode = _serializer.GetStatusCode(mockResult.Object); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, statusCode); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a mock IExecutionResult with a single error containing the specified SubStatusCode + /// + private static IExecutionResult CreateResultWithError(DataApiBuilderException.SubStatusCodes subStatusCode) + { + Mock mockError = new(); + mockError.Setup(e => e.Code).Returns(subStatusCode.ToString()); + + Mock mockResult = new(); + mockResult.Setup(r => r.Errors).Returns(new List { mockError.Object }); + + return mockResult.Object; + } + + #endregion + } +}