From 39dcc5da0bc1bc36efdebcc0d1c1f5f2db7cef11 Mon Sep 17 00:00:00 2001 From: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:22:52 +0000 Subject: [PATCH] Refactor MaybeThrowGraphqlException to throw AggregateException with all GraphQL errors (#169) * first pass * Updated tests * Removed path from message --- src/Speckle.Sdk.Dependencies/GraphQLRetry.cs | 6 +- src/Speckle.Sdk/Api/Exceptions.cs | 117 ++++++-------- src/Speckle.Sdk/Api/GraphQL/Client.cs | 147 ++++-------------- .../Api/GraphQL/GraphQLErrorHandler.cs | 49 ++++++ .../GraphQL/GraphQLHttpClientExtensions.cs | 66 ++++---- .../Api/GraphQL/ISpeckleGraphQLClient.cs | 11 +- .../GraphQL/Resources/ActiveUserResource.cs | 3 +- .../Api/GraphQL/graphql.config.yml | 2 + src/Speckle.Sdk/Common/NotNullExtensions.cs | 6 + src/Speckle.Sdk/Credentials/AccountManager.cs | 105 +++++-------- .../GraphQL/GraphQLClientExceptionHandling.cs | 126 +++++++++++++++ .../ModelResourceExceptionalTests.cs | 29 ++-- .../GraphQL/Resources/ModelResourceTests.cs | 7 +- .../ProjectInviteResourceExceptionalTests.cs | 3 +- .../ProjectResourceExceptionalTests.cs | 27 ++-- .../GraphQL/Resources/ProjectResourceTests.cs | 3 +- .../GraphQL/Resources/VersionResourceTests.cs | 6 +- .../GraphQLCLient.cs | 72 --------- ...lient.cs => ClientResiliencyPolicyTest.cs} | 37 +---- .../Api/GraphQLErrorHandler.cs | 55 +++++++ 20 files changed, 458 insertions(+), 419 deletions(-) create mode 100644 src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs create mode 100644 src/Speckle.Sdk/Api/GraphQL/graphql.config.yml create mode 100644 tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/GraphQLClientExceptionHandling.cs delete mode 100644 tests/Speckle.Sdk.Tests.Integration/GraphQLCLient.cs rename tests/Speckle.Sdk.Tests.Unit/Api/{GraphQLClient.cs => ClientResiliencyPolicyTest.cs} (62%) create mode 100644 tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs diff --git a/src/Speckle.Sdk.Dependencies/GraphQLRetry.cs b/src/Speckle.Sdk.Dependencies/GraphQLRetry.cs index 9366bf65..13d60031 100644 --- a/src/Speckle.Sdk.Dependencies/GraphQLRetry.cs +++ b/src/Speckle.Sdk.Dependencies/GraphQLRetry.cs @@ -5,15 +5,15 @@ namespace Speckle.Sdk.Dependencies; public static class GraphQLRetry { - public static async Task ExecuteAsync( + public static async Task ExecuteAsync( Func> func, Action? onRetry = null ) - where TException : Exception + where TInnerException : Exception { var delay = Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 5); var graphqlRetry = Policy - .Handle() + .HandleInner() .WaitAndRetryAsync( delay, (ex, timeout, _) => diff --git a/src/Speckle.Sdk/Api/Exceptions.cs b/src/Speckle.Sdk/Api/Exceptions.cs index 5be89d06..838b3df1 100644 --- a/src/Speckle.Sdk/Api/Exceptions.cs +++ b/src/Speckle.Sdk/Api/Exceptions.cs @@ -1,53 +1,17 @@ -using GraphQL; +using Speckle.Sdk.Api.GraphQL; namespace Speckle.Sdk.Api; /// -/// Base class for GraphQL API exceptions +/// The base class for all GraphQL errors (these are errors in the graphql response) +/// Some specific codes are maped to subtypes +/// +/// +/// +/// /// -public class SpeckleGraphQLException : SpeckleGraphQLException -{ - public new GraphQLResponse? Response => (GraphQLResponse?)base.Response; - - public SpeckleGraphQLException( - string message, - GraphQLRequest request, - GraphQLResponse? response, - Exception? innerException = null - ) - : base(message, request, response, innerException) { } - - public SpeckleGraphQLException() { } - - public SpeckleGraphQLException(string? message) - : base(message) { } - - public SpeckleGraphQLException(string? message, Exception? innerException) - : base(message, innerException) { } -} - public class SpeckleGraphQLException : SpeckleException { - private readonly GraphQLRequest _request; - public IGraphQLResponse? Response { get; } - - public IEnumerable ErrorMessages => - Response?.Errors != null ? Response.Errors.Select(e => e.Message) : Enumerable.Empty(); - - public IDictionary? Extensions => Response?.Extensions; - - public SpeckleGraphQLException( - string? message, - GraphQLRequest request, - IGraphQLResponse? response, - Exception? innerException = null - ) - : base(message, innerException) - { - _request = request; - Response = response; - } - public SpeckleGraphQLException() { } public SpeckleGraphQLException(string? message) @@ -58,19 +22,12 @@ public class SpeckleGraphQLException : SpeckleException } /// -/// Represents a "FORBIDDEN" on "UNAUTHORIZED" GraphQL error as an exception. +/// Represents a "FORBIDDEN" or "UNAUTHORIZED" GraphQL error as an exception. /// https://www.apollographql.com/docs/apollo-server/v2/data/errors/#unauthenticated /// https://www.apollographql.com/docs/apollo-server/v2/data/errors/#forbidden /// -public class SpeckleGraphQLForbiddenException : SpeckleGraphQLException +public sealed class SpeckleGraphQLForbiddenException : SpeckleGraphQLException { - public SpeckleGraphQLForbiddenException( - GraphQLRequest request, - IGraphQLResponse response, - Exception? innerException = null - ) - : base("Your request was forbidden", request, response, innerException) { } - public SpeckleGraphQLForbiddenException() { } public SpeckleGraphQLForbiddenException(string? message) @@ -80,15 +37,12 @@ public class SpeckleGraphQLForbiddenException : SpeckleGraphQLException : base(message, innerException) { } } -public class SpeckleGraphQLInternalErrorException : SpeckleGraphQLException +/// +/// Represents a "INTERNAL_SERVER_ERROR" GraphQL error as an exception. +/// https://www.apollographql.com/docs/apollo-server/v2/data/errors#internal_server_error +/// +public sealed class SpeckleGraphQLInternalErrorException : SpeckleGraphQLException { - public SpeckleGraphQLInternalErrorException( - GraphQLRequest request, - IGraphQLResponse response, - Exception? innerException = null - ) - : base("Your request failed on the server side", request, response, innerException) { } - public SpeckleGraphQLInternalErrorException() { } public SpeckleGraphQLInternalErrorException(string? message) @@ -98,15 +52,11 @@ public class SpeckleGraphQLInternalErrorException : SpeckleGraphQLException : base(message, innerException) { } } -public class SpeckleGraphQLStreamNotFoundException : SpeckleGraphQLException +/// +/// Represents the custom "STREAM_NOT_FOUND" GraphQL error as an exception. +/// +public sealed class SpeckleGraphQLStreamNotFoundException : SpeckleGraphQLException { - public SpeckleGraphQLStreamNotFoundException( - GraphQLRequest request, - IGraphQLResponse response, - Exception? innerException = null - ) - : base("Stream not found", request, response, innerException) { } - public SpeckleGraphQLStreamNotFoundException() { } public SpeckleGraphQLStreamNotFoundException(string? message) @@ -115,3 +65,34 @@ public class SpeckleGraphQLStreamNotFoundException : SpeckleGraphQLException public SpeckleGraphQLStreamNotFoundException(string? message, Exception? innerException) : base(message, innerException) { } } + +/// +/// Represents a "BAD_USER_INPUT" GraphQL error as an exception. +/// https://www.apollographql.com/docs/apollo-server/v2/data/errors#bad_user_input +/// +public sealed class SpeckleGraphQLBadInputException : SpeckleGraphQLException +{ + public SpeckleGraphQLBadInputException() { } + + public SpeckleGraphQLBadInputException(string? message) + : base(message) { } + + public SpeckleGraphQLBadInputException(string? message, Exception? innerException) + : base(message, innerException) { } +} + +/// +/// Represents a "GRAPHQL_PARSE_FAILED" or "GRAPHQL_VALIDATION_FAILED" GraphQL error as an exception. +/// https://www.apollographql.com/docs/apollo-server/v2/data/errors#graphql_parse_failed +/// https://www.apollographql.com/docs/apollo-server/v2/data/errors#graphql_validation_failed +/// +public sealed class SpeckleGraphQLInvalidQueryException : SpeckleGraphQLException +{ + public SpeckleGraphQLInvalidQueryException() { } + + public SpeckleGraphQLInvalidQueryException(string? message) + : base(message) { } + + public SpeckleGraphQLInvalidQueryException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/src/Speckle.Sdk/Api/GraphQL/Client.cs b/src/Speckle.Sdk/Api/GraphQL/Client.cs index 5cf2a9e5..d575e494 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Client.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Client.cs @@ -117,7 +117,7 @@ public sealed class Client : ISpeckleGraphQLClient, IDisposable GraphQLResponse result = await GQLClient .SendMutationAsync(request, cancellationToken) .ConfigureAwait(false); - MaybeThrowFromGraphQLErrors(request, result); + result.EnsureGraphQLSuccess(); return result.Data; }) .ConfigureAwait(false); @@ -132,129 +132,48 @@ public sealed class Client : ISpeckleGraphQLClient, IDisposable } } - internal void MaybeThrowFromGraphQLErrors(GraphQLRequest request, GraphQLResponse response) - { - // The errors reflect the Apollo server v2 API, which is deprecated. It is bound to change, - // once we migrate to a newer version. - var errors = response.Errors; - if (errors != null && errors.Length != 0) - { - if ( - errors.Any(e => - e.Extensions != null - && ( - e.Extensions.Contains(new KeyValuePair("code", "FORBIDDEN")) - || e.Extensions.Contains(new KeyValuePair("code", "UNAUTHENTICATED")) - ) - ) - ) - { - throw new SpeckleGraphQLForbiddenException(request, response); - } - - if ( - errors.Any(e => - e.Extensions != null && e.Extensions.Contains(new KeyValuePair("code", "STREAM_NOT_FOUND")) - ) - ) - { - throw new SpeckleGraphQLStreamNotFoundException(request, response); - } - - if ( - errors.Any(e => - e.Extensions != null - && e.Extensions.Contains(new KeyValuePair("code", "INTERNAL_SERVER_ERROR")) - ) - ) - { - throw new SpeckleGraphQLInternalErrorException(request, response); - } - - throw new SpeckleGraphQLException("Request failed with errors", request, response); - } - } - IDisposable ISpeckleGraphQLClient.SubscribeTo(GraphQLRequest request, Action callback) => SubscribeTo(request, callback); /// private IDisposable SubscribeTo(GraphQLRequest request, Action callback) { - //using (LogContext.Push(CreateEnrichers(request))) + try { - try - { - var res = GQLClient.CreateSubscriptionStream(request); - return res.Subscribe( - response => + var res = GQLClient.CreateSubscriptionStream(request); + return res.Subscribe( + response => + { + try { - try - { - MaybeThrowFromGraphQLErrors(request, response); + response.EnsureGraphQLSuccess(); - if (response.Data != null) - { - callback(this, response.Data); - } - else - { - // Serilog.Log.ForContext("graphqlResponse", response) - _logger.LogError( - "Cannot execute graphql callback for {resultType}, the response has no data.", - typeof(T).Name - ); - } - } - // we catch forbidden to rethrow, making sure its not logged. - catch (SpeckleGraphQLForbiddenException) - { - throw; - } - // anything else related to graphql gets logged - catch (SpeckleGraphQLException gqlException) - { - /* Speckle.Sdk.Logging..ForContext("graphqlResponse", gqlException.Response) - .ForContext("graphqlExtensions", gqlException.Extensions) - .ForContext("graphqlErrorMessages", gqlException.ErrorMessages.ToList())*/ - _logger.LogWarning( - gqlException, - "Execution of the graphql request to get {resultType} failed with {graphqlExceptionType} {exceptionMessage}.", - typeof(T).Name, - gqlException.GetType().Name, - gqlException.Message - ); - throw; - } - // we're not handling the bare Exception type here, - // since we have a response object on the callback, we know the Exceptions - // can only be thrown from the MaybeThrowFromGraphQLErrors which wraps - // every exception into SpeckleGraphQLException - }, - ex => - { - // we're logging this as an error for now, to keep track of failures - // so far we've swallowed these errors - _logger.LogError( - ex, - "Subscription for {resultType} terminated unexpectedly with {exceptionMessage}", - typeof(T).Name, - ex.Message - ); - // we could be throwing like this: - // throw ex; + callback(this, response.Data); } - ); - } - catch (Exception ex) when (!ex.IsFatal()) - { - throw new SpeckleGraphQLException( - "The graphql request failed without a graphql response", - request, - null, - ex - ); - } + catch (AggregateException ex) + { + _logger.LogWarning(ex, "Subscription for {type} got a response with errors", typeof(T).Name); + throw; + } + }, + ex => + { + // we're logging this as an error for now, to keep track of failures + // so far we've swallowed these errors + _logger.LogError( + ex, + "Subscription for {resultType} terminated unexpectedly with {exceptionMessage}", + typeof(T).Name, + ex.Message + ); + // we could be throwing like this: + // throw ex; + } + ); + } + catch (Exception ex) when (!ex.IsFatal() && ex is not ObjectDisposedException) + { + throw new SpeckleGraphQLException($"Subscription for {typeof(T)} failed to start", ex); } } diff --git a/src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs b/src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs new file mode 100644 index 00000000..5fff1eea --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.Contracts; +using GraphQL; + +namespace Speckle.Sdk.Api.GraphQL; + +internal static class GraphQLErrorHandler +{ + /// + public static void EnsureGraphQLSuccess(this IGraphQLResponse response) => EnsureGraphQLSuccess(response.Errors); + + /// Containing a (or subclass of) for each graphql Error + public static void EnsureGraphQLSuccess(IReadOnlyCollection? errors) + { + // The errors reflect the Apollo server v2 API, which is deprecated. It is bound to change, + // once we migrate to a newer version. + if (errors == null || errors.Count == 0) + { + return; + } + + List exceptions = new(errors.Count); + foreach (var error in errors) + { + object? code = null; + _ = error.Extensions?.TryGetValue("code", out code); + + var message = FormatErrorMessage(error, code); + var ex = code switch + { + "GRAPHQL_PARSE_FAILED" or "GRAPHQL_VALIDATION_FAILED" => new SpeckleGraphQLInvalidQueryException(message), + "FORBIDDEN" or "UNAUTHENTICATED" => new SpeckleGraphQLForbiddenException(message), + "STREAM_NOT_FOUND" => new SpeckleGraphQLStreamNotFoundException(message), + "BAD_USER_INPUT" => new SpeckleGraphQLBadInputException(message), + "INTERNAL_SERVER_ERROR" => new SpeckleGraphQLInternalErrorException(message), + _ => new SpeckleGraphQLException(message), + }; + exceptions.Add(ex); + } + + throw new AggregateException("Request failed with GraphQL errors, see inner exceptions", exceptions); + } + + [Pure] + private static string FormatErrorMessage(GraphQLError error, object? code) + { + code ??= "ERROR"; + return $"{code}: {error.Message}"; + } +} diff --git a/src/Speckle.Sdk/Api/GraphQL/GraphQLHttpClientExtensions.cs b/src/Speckle.Sdk/Api/GraphQL/GraphQLHttpClientExtensions.cs index 07492053..9fe5c7d5 100644 --- a/src/Speckle.Sdk/Api/GraphQL/GraphQLHttpClientExtensions.cs +++ b/src/Speckle.Sdk/Api/GraphQL/GraphQLHttpClientExtensions.cs @@ -1,7 +1,6 @@ using GraphQL; using GraphQL.Client.Http; using Speckle.Sdk.Api.GraphQL.Models.Responses; -using Speckle.Sdk.Common; namespace Speckle.Sdk.Api.GraphQL; @@ -10,46 +9,51 @@ public static class GraphQLHttpClientExtensions /// /// Gets the version of the current server. Useful for guarding against unsupported api calls on newer or older servers. /// - /// [Optional] defaults to an empty cancellation token - /// object excluding any strings (eg "2.7.2-alpha.6995" becomes "2.7.2.6995") - /// + /// + /// Expects the response to either be
+ /// - 1. The literal string dev, which will return 999.999.999
+ /// - 2. A 3 numeral semver (anything after the first - character will be ignored)
+ ///
+ /// + /// A 3 numeral object (e.g. 2.21.3.alpha123 becomes 2.21.3) + /// + /// Server responded with a server version, but it was not in an expected format public static async Task GetServerVersion( this GraphQLHttpClient client, CancellationToken cancellationToken = default ) { - var request = new GraphQLRequest - { - Query = - @"query Server { - serverInfo { - version - } - }", - }; + //lang=graphql + const string QUERY = """ + query Server { + data:serverInfo { + data:version + } + } + """; + var request = new GraphQLRequest { Query = QUERY }; - var response = await client.SendQueryAsync(request, cancellationToken).ConfigureAwait(false); + var response = await client + .SendQueryAsync>>(request, cancellationToken) + .ConfigureAwait(false); - if (response.Errors != null) + response.EnsureGraphQLSuccess(); + + string versionString = response.Data.data.data; + if (versionString == "dev") { - throw new SpeckleGraphQLException( - $"Query {nameof(GetServerVersion)} failed", - request, - response - ); + return new Version(999, 999, 999); } - if (string.IsNullOrWhiteSpace(response.Data.serverInfo.version)) - { - throw new SpeckleGraphQLException( - $"Query {nameof(GetServerVersion)} did not provide a valid server version", - request, - response - ); - } + string? semverString = versionString.Split('-').First(); - return response.Data.serverInfo.version == "dev" - ? new System.Version(999, 999, 999) - : new System.Version(response.Data.serverInfo.version.NotNull().Split('-').First()); + if (Version.TryParse(semverString!, out Version? semver)) + { + return semver; + } + else + { + throw new FormatException($"Server responded with an invalid semver string \"{semverString}\""); + } } } diff --git a/src/Speckle.Sdk/Api/GraphQL/ISpeckleGraphQLClient.cs b/src/Speckle.Sdk/Api/GraphQL/ISpeckleGraphQLClient.cs index 53a4f699..49e46b27 100644 --- a/src/Speckle.Sdk/Api/GraphQL/ISpeckleGraphQLClient.cs +++ b/src/Speckle.Sdk/Api/GraphQL/ISpeckleGraphQLClient.cs @@ -1,19 +1,20 @@ using GraphQL; +using GraphQL.Client.Http; using Speckle.Newtonsoft.Json; namespace Speckle.Sdk.Api.GraphQL; internal interface ISpeckleGraphQLClient { - /// "FORBIDDEN" on "UNAUTHORIZED" response from server - /// All other request errors + /// Request failed on the GraphQL layer, each GraphQL error will be a (or subclass of) as an inner exception + /// Request failed on the HTTP layer (non-successful response code) + /// The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate validation or timeout /// The requested a cancel /// This already been disposed - /// The response failed to deserialize, probably because the server version is incompatible with this version of the SDK, or there is a mistake in a query (queried for a property that isn't in the C# model, or a required property was null) + /// The response failed to deserialize, probably because the server version is incompatible with this version of the SDK, or there is a mistake in a query (queried for a property that isn't in the C# model, or a required property was null) internal Task ExecuteGraphQLRequest(GraphQLRequest request, CancellationToken cancellationToken); - /// "FORBIDDEN" on "UNAUTHORIZED" response from server - /// All other request errors + /// Containing a (or subclass of) for each graphql Error /// This already been disposed internal IDisposable SubscribeTo(GraphQLRequest request, Action callback); } diff --git a/src/Speckle.Sdk/Api/GraphQL/Resources/ActiveUserResource.cs b/src/Speckle.Sdk/Api/GraphQL/Resources/ActiveUserResource.cs index 3dd3bb0e..9a6bf0e4 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Resources/ActiveUserResource.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Resources/ActiveUserResource.cs @@ -144,6 +144,7 @@ public sealed class ActiveUserResource /// /// /// + /// The ActiveUser could not be found (e.g. the client is not authenticated) public async Task> GetProjectInvites(CancellationToken cancellationToken = default) { //language=graphql @@ -192,7 +193,7 @@ public sealed class ActiveUserResource if (response.data is null) { - throw new SpeckleGraphQLException("GraphQL response indicated that the ActiveUser could not be found"); + throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found"); } return response.data.data; diff --git a/src/Speckle.Sdk/Api/GraphQL/graphql.config.yml b/src/Speckle.Sdk/Api/GraphQL/graphql.config.yml new file mode 100644 index 00000000..64c50ab2 --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/graphql.config.yml @@ -0,0 +1,2 @@ +schema: https://app.speckle.systems/graphql +documents: '**/*.graphql' diff --git a/src/Speckle.Sdk/Common/NotNullExtensions.cs b/src/Speckle.Sdk/Common/NotNullExtensions.cs index 0dd7a6d6..7d07c704 100644 --- a/src/Speckle.Sdk/Common/NotNullExtensions.cs +++ b/src/Speckle.Sdk/Common/NotNullExtensions.cs @@ -5,6 +5,7 @@ namespace Speckle.Sdk.Common; public static class NotNullExtensions { + /// Thrown when the awaited returns public static async ValueTask NotNull( this ValueTask task, [CallerArgumentExpression(nameof(task))] string? message = null @@ -19,6 +20,7 @@ public static class NotNullExtensions return x; } + /// public static async ValueTask NotNull( this ValueTask task, [CallerArgumentExpression(nameof(task))] string? message = null @@ -33,6 +35,7 @@ public static class NotNullExtensions return x.Value; } + /// public static async Task NotNull( this Task task, [CallerArgumentExpression(nameof(task))] string? message = null @@ -47,6 +50,7 @@ public static class NotNullExtensions return x; } + /// public static async Task NotNull( this Task task, [CallerArgumentExpression(nameof(task))] string? message = null @@ -61,6 +65,7 @@ public static class NotNullExtensions return x.Value; } + /// Thrown when is public static T NotNull([NotNull] this T? obj, [CallerArgumentExpression(nameof(obj))] string? paramName = null) where T : class { @@ -71,6 +76,7 @@ public static class NotNullExtensions return obj; } + /// public static T NotNull([NotNull] this T? obj, [CallerArgumentExpression(nameof(obj))] string? paramName = null) where T : struct { diff --git a/src/Speckle.Sdk/Credentials/AccountManager.cs b/src/Speckle.Sdk/Credentials/AccountManager.cs index c824a932..6a4d7bcb 100644 --- a/src/Speckle.Sdk/Credentials/AccountManager.cs +++ b/src/Speckle.Sdk/Credentials/AccountManager.cs @@ -9,7 +9,6 @@ using GraphQL.Client.Http; using Microsoft.Extensions.Logging; using Speckle.InterfaceGenerator; using Speckle.Newtonsoft.Json; -using Speckle.Sdk.Api; using Speckle.Sdk.Api.GraphQL; using Speckle.Sdk.Api.GraphQL.Models; using Speckle.Sdk.Api.GraphQL.Models.Responses; @@ -38,8 +37,10 @@ public class AccountManager(ISpeckleApplication application, ILogger /// Gets the basic information about a server. /// - /// Server URL + /// Server Information /// + /// Request failed on the HTTP layer (received a non-successful response code) + /// public async Task GetServerInfo(Uri server, CancellationToken cancellationToken = default) { using var httpClient = speckleHttp.CreateHttpClient(); @@ -78,14 +79,7 @@ public class AccountManager(ISpeckleApplication application, ILogger(request, cancellationToken).ConfigureAwait(false); - if (response.Errors is not null) - { - throw new SpeckleGraphQLException( - $"GraphQL request {nameof(GetServerInfo)} failed", - request, - response - ); - } + response.EnsureGraphQLSuccess(); ServerInfo serverInfo = response.Data.serverInfo; serverInfo.url = server.ToString().TrimEnd('/'); @@ -100,6 +94,8 @@ public class AccountManager(ISpeckleApplication application, ILogger /// Server URL /// + /// Request failed on the HTTP layer (received a non-successful response code) + /// public async Task GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default) { using var httpClient = speckleHttp.CreateHttpClient(authorizationToken: token); @@ -126,10 +122,7 @@ public class AccountManager(ISpeckleApplication application, ILogger>(request, cancellationToken) .ConfigureAwait(false); - if (response.Errors != null) - { - throw new SpeckleGraphQLException($"GraphQL request {nameof(GetUserInfo)} failed", request, response); - } + response.EnsureGraphQLSuccess(); return response.Data.data; } @@ -146,59 +139,45 @@ public class AccountManager(ISpeckleApplication application, ILogger= serverMigrationVersion) { - using var httpClient = speckleHttp.CreateHttpClient(authorizationToken: token); - - using var client = new GraphQLHttpClient( - new GraphQLHttpClientOptions { EndPoint = new Uri(server, "/graphql") }, - new NewtonsoftJsonSerializer(), - httpClient - ); - - System.Version version = await client.GetServerVersion(ct).ConfigureAwait(false); - - // serverMigration property was added in 2.18.5, so only query for it - // if the server has been updated past that version - System.Version serverMigrationVersion = new(2, 18, 5); - - string queryString; - if (version >= serverMigrationVersion) - { - //language=graphql - queryString = - "query { activeUser { id name email company avatar streams { totalCount } commits { totalCount } } serverInfo { name company adminContact description version migration { movedFrom movedTo } } }"; - } - else - { - //language=graphql - queryString = - "query { activeUser { id name email company avatar streams { totalCount } commits { totalCount } } serverInfo { name company adminContact description version } }"; - } - - var request = new GraphQLRequest { Query = queryString }; - - var response = await client.SendQueryAsync(request, ct).ConfigureAwait(false); - - if (response.Errors != null) - { - throw new SpeckleGraphQLException( - $"Query {nameof(GetUserServerInfo)} failed", - request, - response - ); - } - - ServerInfo serverInfo = response.Data.serverInfo; - serverInfo.url = server.ToString().TrimEnd('/'); - serverInfo.frontend2 = await IsFrontend2Server(server).ConfigureAwait(false); - - return response.Data; + //language=graphql + queryString = + "query { activeUser { id name email company avatar streams { totalCount } commits { totalCount } } serverInfo { name company adminContact description version migration { movedFrom movedTo } } }"; } - catch (Exception ex) when (!ex.IsFatal()) + else { - throw new SpeckleException($"Failed to get user + server info from {server}", ex); + //language=graphql + queryString = + "query { activeUser { id name email company avatar streams { totalCount } commits { totalCount } } serverInfo { name company adminContact description version } }"; } + + var request = new GraphQLRequest { Query = queryString }; + + var response = await client.SendQueryAsync(request, ct).ConfigureAwait(false); + + response.EnsureGraphQLSuccess(); + + ServerInfo serverInfo = response.Data.serverInfo; + serverInfo.url = server.ToString().TrimEnd('/'); + serverInfo.frontend2 = await IsFrontend2Server(server).ConfigureAwait(false); + + return response.Data; } /// diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/GraphQLClientExceptionHandling.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/GraphQLClientExceptionHandling.cs new file mode 100644 index 00000000..dfa31a87 --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/GraphQLClientExceptionHandling.cs @@ -0,0 +1,126 @@ +using GraphQL; +using GraphQL.Client.Http; +using Speckle.Newtonsoft.Json; +using Speckle.Sdk.Api; +using Speckle.Sdk.Api.GraphQL.Inputs; + +namespace Speckle.Sdk.Tests.Integration.Api.GraphQL; + +[TestOf(typeof(Client))] +public class GraphQLClientExceptionHandling +{ + private Client _sut; + + [SetUp] + public async Task Setup() + { + _sut = await Fixtures.SeedUserWithClient(); + } + + [Test] + [Description($"Attempts to execute a query on a non-existent server, expect a {nameof(GraphQLHttpRequestException)}")] + public void TestHttpLayer() + { + _sut.GQLClient.Options.EndPoint = new Uri("http://127.0.0.1:1234"); //There is no server on this port... + + Assert.ThrowsAsync(async () => await _sut.ActiveUser.Get().ConfigureAwait(false)); + } + + [Test] + [Description( + $"Attempts to execute a admin only command from a regular user, expect an inner {nameof(SpeckleGraphQLForbiddenException)}" + )] + public void TestGraphQLLayer_Forbidden() + { + //language=graphql + const string QUERY = """ + query Query { + admin { + userList { + items { + id + } + } + } + } + + """; + GraphQLRequest request = new(query: QUERY); + var ex = Assert.ThrowsAsync( + async () => await _sut.ExecuteGraphQLRequest(request).ConfigureAwait(false) + ); + Assert.That(ex?.InnerExceptions, Has.Exactly(1).TypeOf()); + } + + [Test, Description($"Attempts to execute a bad query, expect an inner {nameof(SpeckleGraphQLInvalidQueryException)}")] + public void TestGraphQLLayer_BadQuery() + { + //language=graphql + const string QUERY = """ + query User { + data:NonExistentQuery { + id + } + } + """; + GraphQLRequest request = new(query: QUERY); + var ex = Assert.ThrowsAsync( + async () => await _sut.ExecuteGraphQLRequest(request).ConfigureAwait(false) + ); + + Assert.That(ex?.InnerExceptions, Has.Exactly(1).TypeOf()); + } + + [Test] + [Description( + $"Attempts to execute a query with an invalid input, expect an inner {nameof(SpeckleGraphQLBadInputException)}" + )] + public void TestGraphQLLayer_BadInput() + { + ProjectUpdateRoleInput input = new(null!, null!, null); + var ex = Assert.ThrowsAsync( + async () => await _sut.Project.UpdateRole(input).ConfigureAwait(false) + ); + + Assert.That(ex?.InnerExceptions, Has.Exactly(2).TypeOf()); + } + + [Test] + public void TestCancel() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + var ex = Assert.CatchAsync( + async () => await _sut.ActiveUser.Get(cts.Token).ConfigureAwait(false) + ); + + Assert.That(ex?.CancellationToken, Is.EqualTo(cts.Token)); + } + + [Test] + public void TestDisposal() + { + _sut.Dispose(); + + Assert.Throws(() => _ = _sut.Subscription.CreateUserProjectsUpdatedSubscription()); + } + + [ + Test, + Description($"Attempts to execute a query with a mismatched type, expect an {nameof(JsonSerializationException)}") + ] + public void TestDeserialization() + { + //language=graphql + const string QUERY = """ + query User { + data:activeUser { + id + } + } + """; + GraphQLRequest request = new(query: QUERY); + Assert.CatchAsync(async () => await _sut.ExecuteGraphQLRequest(request).ConfigureAwait(false)); + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceExceptionalTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceExceptionalTests.cs index bc8f7df5..7b72fb3f 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceExceptionalTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceExceptionalTests.cs @@ -22,33 +22,36 @@ public class ModelResourceExceptionalTests _model = await _testUser.Model.Create(new("Test Model", "", _project.id)); } - [TestCase(null)] [TestCase("")] [TestCase(" ")] public void ModelCreate_Throws_InvalidInput(string name) { CreateModelInput input = new(name, null, _project.id); - Assert.CatchAsync(async () => await Sut.Create(input)); + var ex = Assert.ThrowsAsync(async () => await Sut.Create(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] public void ModelGet_Throws_NoAuth() { - Assert.CatchAsync(async () => await Fixtures.Unauthed.Model.Get(_model.id, _project.id)); + var ex = Assert.ThrowsAsync( + async () => await Fixtures.Unauthed.Model.Get(_model.id, _project.id) + ); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] public void ModelGet_Throws_NonExistentModel() { - Assert.CatchAsync(async () => await Sut.Get("non existent model", _project.id)); + var ex = Assert.ThrowsAsync(async () => await Sut.Get("non existent model", _project.id)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] public void ModelGet_Throws_NonExistentProject() { - Assert.ThrowsAsync( - async () => await Sut.Get(_model.id, "non existent project") - ); + var ex = Assert.ThrowsAsync(async () => await Sut.Get(_model.id, "non existent project")); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -56,7 +59,8 @@ public class ModelResourceExceptionalTests { UpdateModelInput input = new("non-existent model", "MY new name", "MY new desc", _project.id); - Assert.CatchAsync(async () => await Sut.Update(input)); + var ex = Assert.ThrowsAsync(async () => await Sut.Update(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -64,7 +68,8 @@ public class ModelResourceExceptionalTests { UpdateModelInput input = new(_model.id, "MY new name", "MY new desc", "non-existent project"); - Assert.ThrowsAsync(async () => await Sut.Update(input)); + var ex = Assert.ThrowsAsync(async () => await Sut.Update(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -72,7 +77,8 @@ public class ModelResourceExceptionalTests { UpdateModelInput input = new(_model.id, "MY new name", "MY new desc", _project.id); - Assert.CatchAsync(async () => await Fixtures.Unauthed.Model.Update(input)); + var ex = Assert.ThrowsAsync(async () => await Fixtures.Unauthed.Model.Update(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -82,6 +88,7 @@ public class ModelResourceExceptionalTests DeleteModelInput input = new(toDelete.id, _project.id); await Sut.Delete(input); - Assert.CatchAsync(async () => await Sut.Delete(input)); + var ex = Assert.ThrowsAsync(async () => await Sut.Delete(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceTests.cs index 7142d173..b3bcdedc 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelResourceTests.cs @@ -91,7 +91,10 @@ public class ModelResourceTests await Sut.Delete(input); - Assert.CatchAsync(async () => await Sut.Get(_model.id, _project.id)); - Assert.CatchAsync(async () => await Sut.Delete(input)); + var getEx = Assert.CatchAsync(async () => await Sut.Get(_model.id, _project.id)); + Assert.That(getEx?.InnerExceptions, Has.One.Items.And.All.TypeOf()); + + var delEx = Assert.CatchAsync(async () => await Sut.Delete(input)); + Assert.That(delEx?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectInviteResourceExceptionalTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectInviteResourceExceptionalTests.cs index b74b0d45..07151fbc 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectInviteResourceExceptionalTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectInviteResourceExceptionalTests.cs @@ -23,10 +23,11 @@ public class ProjectInviteResourceExceptionalTests [TestCase(null, "something", "something", null)] public void ProjectInviteCreate_InvalidInput(string email, string role, string serverRole, string userId) { - Assert.CatchAsync(async () => + var ex = Assert.CatchAsync(async () => { var input = new ProjectInviteCreateInput(email, role, serverRole, userId); await Sut.Create(_project.id, input); }); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceExceptionalTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceExceptionalTests.cs index 7bf7ec76..78d2cae3 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceExceptionalTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceExceptionalTests.cs @@ -38,7 +38,8 @@ public class ProjectResourceExceptionalTests ProjectCreateInput input = new("The best project", "The best description for the best project", ProjectVisibility.Private); - Assert.ThrowsAsync(async () => await _unauthedUser.Project.Create(input)); + var ex = Assert.ThrowsAsync(async () => _ = await _unauthedUser.Project.Create(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -48,29 +49,33 @@ public class ProjectResourceExceptionalTests Project privateStream = await Sut.Create(input); - Assert.ThrowsAsync(async () => await _unauthedUser.Project.Get(privateStream.id)); + var ex = Assert.ThrowsAsync(async () => _ = await _unauthedUser.Project.Get(privateStream.id)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] public void ProjectGet_NonExistentProject() { - Assert.ThrowsAsync(async () => await Sut.Get("NonExistentProject")); + var ex = Assert.ThrowsAsync(async () => await Sut.Get("NonExistentProject")); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] public void ProjectUpdate_NonExistentProject() { - Assert.ThrowsAsync( + var ex = Assert.ThrowsAsync( async () => _ = await Sut.Update(new("NonExistentProject", "My new name")) ); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] public void ProjectUpdate_NoAuth() { - Assert.ThrowsAsync( + var ex = Assert.ThrowsAsync( async () => _ = await _unauthedUser.Project.Update(new(_testProject.id, "My new name")) ); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -82,7 +87,8 @@ public class ProjectResourceExceptionalTests { ProjectUpdateRoleInput input = new(_secondUser.Account.id, "NonExistentProject", newRole); - Assert.ThrowsAsync(async () => await Sut.UpdateRole(input)); + var ex = Assert.ThrowsAsync(async () => _ = await Sut.UpdateRole(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -93,7 +99,9 @@ public class ProjectResourceExceptionalTests public void ProjectUpdateRole_NonAuth(string newRole) { ProjectUpdateRoleInput input = new(_secondUser.Account.id, "NonExistentProject", newRole); - Assert.ThrowsAsync(async () => await _unauthedUser.Project.UpdateRole(input)); + + var ex = Assert.ThrowsAsync(async () => _ = await _unauthedUser.Project.UpdateRole(input)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] @@ -101,12 +109,13 @@ public class ProjectResourceExceptionalTests { await Sut.Delete(_testProject.id); - Assert.ThrowsAsync(async () => _ = await Sut.Get(_testProject.id)); //TODO: Exception types + var ex = Assert.ThrowsAsync(async () => _ = await Sut.Get(_testProject.id)); + Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf()); } [Test] public void ProjectInvites_NoAuth() { - Assert.ThrowsAsync(async () => await Fixtures.Unauthed.ActiveUser.ProjectInvites()); + Assert.ThrowsAsync(async () => await Fixtures.Unauthed.ActiveUser.ProjectInvites()); } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceTests.cs index d17845fc..18f4eb2b 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ProjectResourceTests.cs @@ -66,6 +66,7 @@ public class ProjectResourceTests Project toDelete = await Sut.Create(new("Delete me", null, null)); await Sut.Delete(toDelete.id); - Assert.ThrowsAsync(async () => _ = await Sut.Get(toDelete.id)); + var getEx = Assert.ThrowsAsync(async () => _ = await Sut.Get(toDelete.id)); + Assert.That(getEx?.InnerExceptions, Has.Exactly(1).TypeOf()); } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/VersionResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/VersionResourceTests.cs index 491c816c..28264563 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/VersionResourceTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/VersionResourceTests.cs @@ -99,7 +99,9 @@ public class VersionResourceTests await Sut.Delete(input); - Assert.CatchAsync(async () => await Sut.Get(_version.id, _project.id)); - Assert.CatchAsync(async () => await Sut.Delete(input)); + var getEx = Assert.ThrowsAsync(async () => await Sut.Get(_version.id, _project.id)); + Assert.That(getEx?.InnerExceptions, Has.Exactly(1).TypeOf()); + var delEx = Assert.ThrowsAsync(async () => await Sut.Delete(input)); + Assert.That(delEx?.InnerExceptions, Has.Exactly(1).TypeOf()); } } diff --git a/tests/Speckle.Sdk.Tests.Integration/GraphQLCLient.cs b/tests/Speckle.Sdk.Tests.Integration/GraphQLCLient.cs deleted file mode 100644 index c26f470e..00000000 --- a/tests/Speckle.Sdk.Tests.Integration/GraphQLCLient.cs +++ /dev/null @@ -1,72 +0,0 @@ -using GraphQL; -using Microsoft.Extensions.DependencyInjection; -using Speckle.Sdk.Api; -using Speckle.Sdk.Credentials; -using Speckle.Sdk.Host; -using Speckle.Sdk.Models; - -namespace Speckle.Sdk.Tests.Integration; - -public class GraphQLClientTests : IDisposable -{ - private Account _account; - private Client _client; - private IOperations _operations; - - [SetUp] - public async Task Setup() - { - TypeLoader.Reset(); - TypeLoader.Initialize(typeof(Base).Assembly, typeof(DataChunk).Assembly); - var serviceProvider = TestServiceSetup.GetServiceProvider(); - _operations = serviceProvider.GetRequiredService(); - _account = await Fixtures.SeedUser(); - _client = serviceProvider.GetRequiredService().Create(_account); - } - - [Test] - public void ThrowsForbiddenException() - { - Assert.ThrowsAsync( - async () => - await _client.ExecuteGraphQLRequest>( - new GraphQLRequest - { - Query = - @"query { - adminStreams{ - totalCount - } - }", - } - ) - ); - } - - [Test] - public void Cancellation() - { - using CancellationTokenSource tokenSource = new(); - tokenSource.Cancel(); - Assert.CatchAsync( - async () => - await _client.ExecuteGraphQLRequest>( - new GraphQLRequest - { - Query = - @"query { - adminStreams{ - totalCount - } - }", - }, - tokenSource.Token - ) - ); - } - - public void Dispose() - { - _client?.Dispose(); - } -} diff --git a/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLClient.cs b/tests/Speckle.Sdk.Tests.Unit/Api/ClientResiliencyPolicyTest.cs similarity index 62% rename from tests/Speckle.Sdk.Tests.Unit/Api/GraphQLClient.cs rename to tests/Speckle.Sdk.Tests.Unit/Api/ClientResiliencyPolicyTest.cs index 3cace755..53f4c394 100644 --- a/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLClient.cs +++ b/tests/Speckle.Sdk.Tests.Unit/Api/ClientResiliencyPolicyTest.cs @@ -3,10 +3,8 @@ using GraphQL; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Speckle.Sdk.Api; -using Speckle.Sdk.Api.GraphQL; using Speckle.Sdk.Api.GraphQL.Models; using Speckle.Sdk.Credentials; -using Speckle.Sdk.Host; namespace Speckle.Sdk.Tests.Unit.Api; @@ -35,39 +33,6 @@ public sealed class GraphQLClientTests : IDisposable _client?.Dispose(); } - private static IEnumerable ErrorCases() - { - yield return new TestCaseData(typeof(SpeckleGraphQLForbiddenException), new Map { { "code", "FORBIDDEN" } }); - yield return new TestCaseData(typeof(SpeckleGraphQLForbiddenException), new Map { { "code", "UNAUTHENTICATED" } }); - yield return new TestCaseData( - typeof(SpeckleGraphQLInternalErrorException), - new Map { { "code", "INTERNAL_SERVER_ERROR" } } - ); - yield return new TestCaseData(typeof(SpeckleGraphQLException), new Map { { "foo", "bar" } }); - } - - [Test, TestCaseSource(nameof(ErrorCases))] - public void TestExceptionThrowingFromGraphQLErrors(Type exType, Map extensions) - { - Assert.Throws( - exType, - () => - _client.MaybeThrowFromGraphQLErrors( - new GraphQLRequest(), - new GraphQLResponse - { - Errors = new GraphQLError[] { new() { Extensions = extensions } }, - } - ) - ); - } - - [Test] - public void TestMaybeThrowsDoesntThrowForNoErrors() - { - Assert.DoesNotThrow(() => _client.MaybeThrowFromGraphQLErrors(new GraphQLRequest(), new GraphQLResponse())); - } - [Test] public void TestExecuteWithResiliencePoliciesDoesntRetryTaskCancellation() { @@ -110,7 +75,7 @@ public sealed class GraphQLClientTests : IDisposable counter++; if (counter < maxRetryCount) { - throw new SpeckleGraphQLInternalErrorException(new GraphQLRequest(), new GraphQLResponse()); + throw new SpeckleGraphQLInternalErrorException(); } return Task.FromResult(expectedResult); diff --git a/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs b/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs new file mode 100644 index 00000000..72c78df2 --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs @@ -0,0 +1,55 @@ +using GraphQL; +using NUnit.Framework; +using Speckle.Sdk.Api; +using Speckle.Sdk.Api.GraphQL; + +namespace Speckle.Sdk.Tests.Unit.Api; + +public class GraphQLErrorHandlerTests +{ + private static IEnumerable ErrorCases() + { + yield return new TestCaseData(typeof(SpeckleGraphQLForbiddenException), new Map { { "code", "FORBIDDEN" } }); + yield return new TestCaseData(typeof(SpeckleGraphQLForbiddenException), new Map { { "code", "UNAUTHENTICATED" } }); + yield return new TestCaseData( + typeof(SpeckleGraphQLInternalErrorException), + new Map { { "code", "INTERNAL_SERVER_ERROR" } } + ); + yield return new TestCaseData( + typeof(SpeckleGraphQLStreamNotFoundException), + new Map { { "code", "STREAM_NOT_FOUND" } } + ); + yield return new TestCaseData(typeof(SpeckleGraphQLBadInputException), new Map { { "code", "BAD_USER_INPUT" } }); + yield return new TestCaseData( + typeof(SpeckleGraphQLInvalidQueryException), + new Map { { "code", "GRAPHQL_PARSE_FAILED" } } + ); + yield return new TestCaseData( + typeof(SpeckleGraphQLInvalidQueryException), + new Map { { "code", "GRAPHQL_VALIDATION_FAILED" } } + ); + yield return new TestCaseData(typeof(SpeckleGraphQLException), new Map { { "foo", "bar" } }); + yield return new TestCaseData(typeof(SpeckleGraphQLException), new Map { { "code", "CUSTOM_THING" } }); + } + + [Test, TestCaseSource(nameof(ErrorCases))] + public void TestExceptionThrowingFromGraphQLErrors(Type exType, Map extensions) + { + var ex = Assert.Throws( + () => + GraphQLErrorHandler.EnsureGraphQLSuccess( + new GraphQLResponse + { + Errors = new GraphQLError[] { new() { Extensions = extensions } }, + } + ) + ); + Assert.That(ex?.InnerExceptions, Has.Exactly(1).TypeOf(exType)); + } + + [Test] + public void TestMaybeThrowsDoesntThrowForNoErrors() + { + Assert.DoesNotThrow(() => GraphQLErrorHandler.EnsureGraphQLSuccess(new GraphQLResponse())); + } +}