Refactor MaybeThrowGraphqlException to throw AggregateException with all GraphQL errors (#169)

* first pass

* Updated tests

* Removed path from message
This commit is contained in:
Jedd Morgan
2024-11-19 13:22:52 +00:00
committed by GitHub
parent af35edf0a2
commit 39dcc5da0b
20 changed files with 458 additions and 419 deletions
+3 -3
View File
@@ -5,15 +5,15 @@ namespace Speckle.Sdk.Dependencies;
public static class GraphQLRetry
{
public static async Task<T> ExecuteAsync<T, TException>(
public static async Task<T> ExecuteAsync<T, TInnerException>(
Func<Task<T>> func,
Action<Exception, TimeSpan>? onRetry = null
)
where TException : Exception
where TInnerException : Exception
{
var delay = Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 5);
var graphqlRetry = Policy
.Handle<TException>()
.HandleInner<TInnerException>()
.WaitAndRetryAsync(
delay,
(ex, timeout, _) =>
+49 -68
View File
@@ -1,53 +1,17 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL;
namespace Speckle.Sdk.Api;
/// <summary>
/// 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 <see cref="GraphQLErrorHandler"/>
/// <seealso cref="SpeckleGraphQLForbiddenException"/>
/// <seealso cref="SpeckleGraphQLInternalErrorException"/>
/// <seealso cref="SpeckleGraphQLBadInputException"/>
/// <seealso cref="SpeckleGraphQLInvalidQueryException"/>
/// </summary>
public class SpeckleGraphQLException<T> : SpeckleGraphQLException
{
public new GraphQLResponse<T>? Response => (GraphQLResponse<T>?)base.Response;
public SpeckleGraphQLException(
string message,
GraphQLRequest request,
GraphQLResponse<T>? 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<string> ErrorMessages =>
Response?.Errors != null ? Response.Errors.Select(e => e.Message) : Enumerable.Empty<string>();
public IDictionary<string, object>? 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
}
/// <summary>
/// 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
/// </summary>
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
/// <summary>
/// Represents a "INTERNAL_SERVER_ERROR" GraphQL error as an exception.
/// https://www.apollographql.com/docs/apollo-server/v2/data/errors#internal_server_error
/// </summary>
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
/// <summary>
/// Represents the custom "STREAM_NOT_FOUND" GraphQL error as an exception.
/// </summary>
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) { }
}
/// <summary>
/// Represents a "BAD_USER_INPUT" GraphQL error as an exception.
/// https://www.apollographql.com/docs/apollo-server/v2/data/errors#bad_user_input
/// </summary>
public sealed class SpeckleGraphQLBadInputException : SpeckleGraphQLException
{
public SpeckleGraphQLBadInputException() { }
public SpeckleGraphQLBadInputException(string? message)
: base(message) { }
public SpeckleGraphQLBadInputException(string? message, Exception? innerException)
: base(message, innerException) { }
}
/// <summary>
/// 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
/// </summary>
public sealed class SpeckleGraphQLInvalidQueryException : SpeckleGraphQLException
{
public SpeckleGraphQLInvalidQueryException() { }
public SpeckleGraphQLInvalidQueryException(string? message)
: base(message) { }
public SpeckleGraphQLInvalidQueryException(string? message, Exception? innerException)
: base(message, innerException) { }
}
+33 -114
View File
@@ -117,7 +117,7 @@ public sealed class Client : ISpeckleGraphQLClient, IDisposable
GraphQLResponse<T> result = await GQLClient
.SendMutationAsync<T>(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<T>(GraphQLRequest request, GraphQLResponse<T> 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<string, object>("code", "FORBIDDEN"))
|| e.Extensions.Contains(new KeyValuePair<string, object>("code", "UNAUTHENTICATED"))
)
)
)
{
throw new SpeckleGraphQLForbiddenException(request, response);
}
if (
errors.Any(e =>
e.Extensions != null && e.Extensions.Contains(new KeyValuePair<string, object>("code", "STREAM_NOT_FOUND"))
)
)
{
throw new SpeckleGraphQLStreamNotFoundException(request, response);
}
if (
errors.Any(e =>
e.Extensions != null
&& e.Extensions.Contains(new KeyValuePair<string, object>("code", "INTERNAL_SERVER_ERROR"))
)
)
{
throw new SpeckleGraphQLInternalErrorException(request, response);
}
throw new SpeckleGraphQLException<T>("Request failed with errors", request, response);
}
}
IDisposable ISpeckleGraphQLClient.SubscribeTo<T>(GraphQLRequest request, Action<object, T> callback) =>
SubscribeTo(request, callback);
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
private IDisposable SubscribeTo<T>(GraphQLRequest request, Action<object, T> callback)
{
//using (LogContext.Push(CreateEnrichers<T>(request)))
try
{
try
{
var res = GQLClient.CreateSubscriptionStream<T>(request);
return res.Subscribe(
response =>
var res = GQLClient.CreateSubscriptionStream<T>(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<T> 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<T>(
"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);
}
}
@@ -0,0 +1,49 @@
using System.Diagnostics.Contracts;
using GraphQL;
namespace Speckle.Sdk.Api.GraphQL;
internal static class GraphQLErrorHandler
{
/// <exception cref="AggregateException"><inheritdoc cref="EnsureGraphQLSuccess(IReadOnlyCollection{GraphQLError}?)"/></exception>
public static void EnsureGraphQLSuccess(this IGraphQLResponse response) => EnsureGraphQLSuccess(response.Errors);
/// <exception cref="AggregateException">Containing a <see cref="SpeckleGraphQLException"/> (or subclass of) for each graphql Error</exception>
public static void EnsureGraphQLSuccess(IReadOnlyCollection<GraphQLError>? 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<SpeckleGraphQLException> 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}";
}
}
@@ -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
/// <summary>
/// Gets the version of the current server. Useful for guarding against unsupported api calls on newer or older servers.
/// </summary>
/// <param name="cancellationToken">[Optional] defaults to an empty cancellation token</param>
/// <returns><see cref="Version"/> object excluding any strings (eg "2.7.2-alpha.6995" becomes "2.7.2.6995")</returns>
/// <exception cref="SpeckleGraphQLException{ServerInfoResponse}"></exception>
/// <remarks>
/// Expects the response to either be<br/>
/// - 1. The literal string <c>dev</c>, which will return <c>999.999.999</c><br/>
/// - 2. A 3 numeral semver (anything after the first <c>-</c> character will be ignored)<br/>
/// </remarks>
/// <param name="cancellationToken"></param>
/// <returns>A 3 numeral <see cref="Version"/> object (e.g. <c>2.21.3.alpha123</c> becomes <c>2.21.3</c>)</returns>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
/// <exception cref="FormatException">Server responded with a server version, but it was not in an expected format</exception>
public static async Task<System.Version> 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<ServerInfoResponse>(request, cancellationToken).ConfigureAwait(false);
var response = await client
.SendQueryAsync<RequiredResponse<RequiredResponse<string>>>(request, cancellationToken)
.ConfigureAwait(false);
if (response.Errors != null)
response.EnsureGraphQLSuccess();
string versionString = response.Data.data.data;
if (versionString == "dev")
{
throw new SpeckleGraphQLException<ServerInfoResponse>(
$"Query {nameof(GetServerVersion)} failed",
request,
response
);
return new Version(999, 999, 999);
}
if (string.IsNullOrWhiteSpace(response.Data.serverInfo.version))
{
throw new SpeckleGraphQLException<ServerInfoResponse>(
$"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}\"");
}
}
}
@@ -1,19 +1,20 @@
using GraphQL;
using GraphQL.Client.Http;
using Speckle.Newtonsoft.Json;
namespace Speckle.Sdk.Api.GraphQL;
internal interface ISpeckleGraphQLClient
{
/// <exception cref="SpeckleGraphQLForbiddenException">"FORBIDDEN" on "UNAUTHORIZED" response from server</exception>
/// <exception cref="SpeckleGraphQLException">All other request errors</exception>
/// <exception cref="AggregateException">Request failed on the GraphQL layer, each GraphQL error will be a <see cref="SpeckleGraphQLException"/> (or subclass of) as an inner exception</exception>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (non-successful response code)</exception>
/// <exception cref="HttpRequestException">The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate validation or timeout</exception>
/// <exception cref="OperationCanceledException">The <paramref name="cancellationToken"/> requested a cancel</exception>
/// <exception cref="ObjectDisposedException">This <see cref="Client"/> already been disposed</exception>
/// <exception cref="JsonSerializationException">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)</exception>
/// <exception cref="JsonException">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)</exception>
internal Task<T> ExecuteGraphQLRequest<T>(GraphQLRequest request, CancellationToken cancellationToken);
/// <exception cref="SpeckleGraphQLForbiddenException">"FORBIDDEN" on "UNAUTHORIZED" response from server</exception>
/// <exception cref="SpeckleGraphQLException">All other request errors</exception>
/// <exception cref="AggregateException">Containing a <see cref="SpeckleGraphQLException"/> (or subclass of) for each graphql Error</exception>
/// <exception cref="ObjectDisposedException">This <see cref="Client"/> already been disposed</exception>
internal IDisposable SubscribeTo<T>(GraphQLRequest request, Action<object, T> callback);
}
@@ -144,6 +144,7 @@ public sealed class ActiveUserResource
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <exception cref="SpeckleException">The ActiveUser could not be found (e.g. the client is not authenticated)</exception>
public async Task<List<PendingStreamCollaborator>> 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;
@@ -0,0 +1,2 @@
schema: https://app.speckle.systems/graphql
documents: '**/*.graphql'
@@ -5,6 +5,7 @@ namespace Speckle.Sdk.Common;
public static class NotNullExtensions
{
/// <exception cref="ArgumentNullException">Thrown when the awaited <paramref name="task"/> returns <see langword="null"/></exception>
public static async ValueTask<T> NotNull<T>(
this ValueTask<T?> task,
[CallerArgumentExpression(nameof(task))] string? message = null
@@ -19,6 +20,7 @@ public static class NotNullExtensions
return x;
}
/// <inheritdoc cref="NotNull{T}(System.Threading.Tasks.ValueTask{T?},string?)"/>
public static async ValueTask<T> NotNull<T>(
this ValueTask<T?> task,
[CallerArgumentExpression(nameof(task))] string? message = null
@@ -33,6 +35,7 @@ public static class NotNullExtensions
return x.Value;
}
/// <inheritdoc cref="NotNull{T}(System.Threading.Tasks.ValueTask{T?},string?)"/>
public static async Task<T> NotNull<T>(
this Task<T?> task,
[CallerArgumentExpression(nameof(task))] string? message = null
@@ -47,6 +50,7 @@ public static class NotNullExtensions
return x;
}
/// <inheritdoc cref="NotNull{T}(System.Threading.Tasks.ValueTask{T?},string?)"/>
public static async Task<T> NotNull<T>(
this Task<T?> task,
[CallerArgumentExpression(nameof(task))] string? message = null
@@ -61,6 +65,7 @@ public static class NotNullExtensions
return x.Value;
}
/// <exception cref="ArgumentNullException">Thrown when <paramref name="obj"/> is <see langword="null"/></exception>
public static T NotNull<T>([NotNull] this T? obj, [CallerArgumentExpression(nameof(obj))] string? paramName = null)
where T : class
{
@@ -71,6 +76,7 @@ public static class NotNullExtensions
return obj;
}
/// <inheritdoc cref="NotNull{T}(T?,string?)"/>
public static T NotNull<T>([NotNull] this T? obj, [CallerArgumentExpression(nameof(obj))] string? paramName = null)
where T : struct
{
+42 -63
View File
@@ -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<AccountMana
/// <summary>
/// Gets the basic information about a server.
/// </summary>
/// <param name="server">Server URL</param>
/// <param name="server">Server Information</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default)
{
using var httpClient = speckleHttp.CreateHttpClient();
@@ -78,14 +79,7 @@ public class AccountManager(ISpeckleApplication application, ILogger<AccountMana
var response = await gqlClient.SendQueryAsync<ServerInfoResponse>(request, cancellationToken).ConfigureAwait(false);
if (response.Errors is not null)
{
throw new SpeckleGraphQLException<ServerInfoResponse>(
$"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<AccountMana
/// <param name="token"></param>
/// <param name="server">Server URL</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<UserInfo> 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<AccountMana
.SendQueryAsync<RequiredResponse<UserInfo>>(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<AccountMana
CancellationToken ct = default
)
{
try
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)
{
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<ActiveUserServerInfoResponse>(request, ct).ConfigureAwait(false);
if (response.Errors != null)
{
throw new SpeckleGraphQLException<ActiveUserServerInfoResponse>(
$"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<ActiveUserServerInfoResponse>(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;
}
/// <summary>
@@ -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<HttpRequestException>(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<AggregateException>(
async () => await _sut.ExecuteGraphQLRequest<dynamic>(request).ConfigureAwait(false)
);
Assert.That(ex?.InnerExceptions, Has.Exactly(1).TypeOf<SpeckleGraphQLForbiddenException>());
}
[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<AggregateException>(
async () => await _sut.ExecuteGraphQLRequest<dynamic>(request).ConfigureAwait(false)
);
Assert.That(ex?.InnerExceptions, Has.Exactly(1).TypeOf<SpeckleGraphQLInvalidQueryException>());
}
[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<AggregateException>(
async () => await _sut.Project.UpdateRole(input).ConfigureAwait(false)
);
Assert.That(ex?.InnerExceptions, Has.Exactly(2).TypeOf<SpeckleGraphQLBadInputException>());
}
[Test]
public void TestCancel()
{
using CancellationTokenSource cts = new();
cts.Cancel();
var ex = Assert.CatchAsync<OperationCanceledException>(
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<ObjectDisposedException>(() => _ = _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<JsonException>(async () => await _sut.ExecuteGraphQLRequest<int>(request).ConfigureAwait(false));
}
}
@@ -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<SpeckleGraphQLException>(async () => await Sut.Create(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Create(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLException>());
}
[Test]
public void ModelGet_Throws_NoAuth()
{
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Fixtures.Unauthed.Model.Get(_model.id, _project.id));
var ex = Assert.ThrowsAsync<AggregateException>(
async () => await Fixtures.Unauthed.Model.Get(_model.id, _project.id)
);
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
public void ModelGet_Throws_NonExistentModel()
{
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Sut.Get("non existent model", _project.id));
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Get("non existent model", _project.id));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLException>());
}
[Test]
public void ModelGet_Throws_NonExistentProject()
{
Assert.ThrowsAsync<SpeckleGraphQLStreamNotFoundException>(
async () => await Sut.Get(_model.id, "non existent project")
);
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Get(_model.id, "non existent project"));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLStreamNotFoundException>());
}
[Test]
@@ -56,7 +59,8 @@ public class ModelResourceExceptionalTests
{
UpdateModelInput input = new("non-existent model", "MY new name", "MY new desc", _project.id);
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Sut.Update(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Update(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLException>());
}
[Test]
@@ -64,7 +68,8 @@ public class ModelResourceExceptionalTests
{
UpdateModelInput input = new(_model.id, "MY new name", "MY new desc", "non-existent project");
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(async () => await Sut.Update(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Update(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
@@ -72,7 +77,8 @@ public class ModelResourceExceptionalTests
{
UpdateModelInput input = new(_model.id, "MY new name", "MY new desc", _project.id);
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Fixtures.Unauthed.Model.Update(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Fixtures.Unauthed.Model.Update(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
@@ -82,6 +88,7 @@ public class ModelResourceExceptionalTests
DeleteModelInput input = new(toDelete.id, _project.id);
await Sut.Delete(input);
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Sut.Delete(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Delete(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLException>());
}
}
@@ -91,7 +91,10 @@ public class ModelResourceTests
await Sut.Delete(input);
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Sut.Get(_model.id, _project.id));
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Sut.Delete(input));
var getEx = Assert.CatchAsync<AggregateException>(async () => await Sut.Get(_model.id, _project.id));
Assert.That(getEx?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLException>());
var delEx = Assert.CatchAsync<AggregateException>(async () => await Sut.Delete(input));
Assert.That(delEx?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLException>());
}
}
@@ -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<SpeckleGraphQLException>(async () =>
var ex = Assert.CatchAsync<AggregateException>(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<SpeckleGraphQLException>());
}
}
@@ -38,7 +38,8 @@ public class ProjectResourceExceptionalTests
ProjectCreateInput input =
new("The best project", "The best description for the best project", ProjectVisibility.Private);
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(async () => await _unauthedUser.Project.Create(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => _ = await _unauthedUser.Project.Create(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
@@ -48,29 +49,33 @@ public class ProjectResourceExceptionalTests
Project privateStream = await Sut.Create(input);
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(async () => await _unauthedUser.Project.Get(privateStream.id));
var ex = Assert.ThrowsAsync<AggregateException>(async () => _ = await _unauthedUser.Project.Get(privateStream.id));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
public void ProjectGet_NonExistentProject()
{
Assert.ThrowsAsync<SpeckleGraphQLStreamNotFoundException>(async () => await Sut.Get("NonExistentProject"));
var ex = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Get("NonExistentProject"));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLStreamNotFoundException>());
}
[Test]
public void ProjectUpdate_NonExistentProject()
{
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(
var ex = Assert.ThrowsAsync<AggregateException>(
async () => _ = await Sut.Update(new("NonExistentProject", "My new name"))
);
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
public void ProjectUpdate_NoAuth()
{
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(
var ex = Assert.ThrowsAsync<AggregateException>(
async () => _ = await _unauthedUser.Project.Update(new(_testProject.id, "My new name"))
);
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
@@ -82,7 +87,8 @@ public class ProjectResourceExceptionalTests
{
ProjectUpdateRoleInput input = new(_secondUser.Account.id, "NonExistentProject", newRole);
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(async () => await Sut.UpdateRole(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.UpdateRole(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
@@ -93,7 +99,9 @@ public class ProjectResourceExceptionalTests
public void ProjectUpdateRole_NonAuth(string newRole)
{
ProjectUpdateRoleInput input = new(_secondUser.Account.id, "NonExistentProject", newRole);
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(async () => await _unauthedUser.Project.UpdateRole(input));
var ex = Assert.ThrowsAsync<AggregateException>(async () => _ = await _unauthedUser.Project.UpdateRole(input));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLForbiddenException>());
}
[Test]
@@ -101,12 +109,13 @@ public class ProjectResourceExceptionalTests
{
await Sut.Delete(_testProject.id);
Assert.ThrowsAsync<SpeckleGraphQLStreamNotFoundException>(async () => _ = await Sut.Get(_testProject.id)); //TODO: Exception types
var ex = Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.Get(_testProject.id));
Assert.That(ex?.InnerExceptions, Has.One.Items.And.All.TypeOf<SpeckleGraphQLStreamNotFoundException>());
}
[Test]
public void ProjectInvites_NoAuth()
{
Assert.ThrowsAsync<SpeckleGraphQLException>(async () => await Fixtures.Unauthed.ActiveUser.ProjectInvites());
Assert.ThrowsAsync<SpeckleException>(async () => await Fixtures.Unauthed.ActiveUser.ProjectInvites());
}
}
@@ -66,6 +66,7 @@ public class ProjectResourceTests
Project toDelete = await Sut.Create(new("Delete me", null, null));
await Sut.Delete(toDelete.id);
Assert.ThrowsAsync<SpeckleGraphQLStreamNotFoundException>(async () => _ = await Sut.Get(toDelete.id));
var getEx = Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.Get(toDelete.id));
Assert.That(getEx?.InnerExceptions, Has.Exactly(1).TypeOf<SpeckleGraphQLStreamNotFoundException>());
}
}
@@ -99,7 +99,9 @@ public class VersionResourceTests
await Sut.Delete(input);
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Sut.Get(_version.id, _project.id));
Assert.CatchAsync<SpeckleGraphQLException>(async () => await Sut.Delete(input));
var getEx = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Get(_version.id, _project.id));
Assert.That(getEx?.InnerExceptions, Has.Exactly(1).TypeOf<SpeckleGraphQLException>());
var delEx = Assert.ThrowsAsync<AggregateException>(async () => await Sut.Delete(input));
Assert.That(delEx?.InnerExceptions, Has.Exactly(1).TypeOf<SpeckleGraphQLException>());
}
}
@@ -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<IOperations>();
_account = await Fixtures.SeedUser();
_client = serviceProvider.GetRequiredService<IClientFactory>().Create(_account);
}
[Test]
public void ThrowsForbiddenException()
{
Assert.ThrowsAsync<SpeckleGraphQLForbiddenException>(
async () =>
await _client.ExecuteGraphQLRequest<Dictionary<string, object>>(
new GraphQLRequest
{
Query =
@"query {
adminStreams{
totalCount
}
}",
}
)
);
}
[Test]
public void Cancellation()
{
using CancellationTokenSource tokenSource = new();
tokenSource.Cancel();
Assert.CatchAsync<OperationCanceledException>(
async () =>
await _client.ExecuteGraphQLRequest<Dictionary<string, object>>(
new GraphQLRequest
{
Query =
@"query {
adminStreams{
totalCount
}
}",
},
tokenSource.Token
)
);
}
public void Dispose()
{
_client?.Dispose();
}
}
@@ -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<TestCaseData> 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<FakeGqlResponseModel>), new Map { { "foo", "bar" } });
}
[Test, TestCaseSource(nameof(ErrorCases))]
public void TestExceptionThrowingFromGraphQLErrors(Type exType, Map extensions)
{
Assert.Throws(
exType,
() =>
_client.MaybeThrowFromGraphQLErrors(
new GraphQLRequest(),
new GraphQLResponse<FakeGqlResponseModel>
{
Errors = new GraphQLError[] { new() { Extensions = extensions } },
}
)
);
}
[Test]
public void TestMaybeThrowsDoesntThrowForNoErrors()
{
Assert.DoesNotThrow(() => _client.MaybeThrowFromGraphQLErrors(new GraphQLRequest(), new GraphQLResponse<string>()));
}
[Test]
public void TestExecuteWithResiliencePoliciesDoesntRetryTaskCancellation()
{
@@ -110,7 +75,7 @@ public sealed class GraphQLClientTests : IDisposable
counter++;
if (counter < maxRetryCount)
{
throw new SpeckleGraphQLInternalErrorException(new GraphQLRequest(), new GraphQLResponse<string>());
throw new SpeckleGraphQLInternalErrorException();
}
return Task.FromResult(expectedResult);
@@ -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<TestCaseData> 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<AggregateException>(
() =>
GraphQLErrorHandler.EnsureGraphQLSuccess(
new GraphQLResponse<GraphQLClientTests.FakeGqlResponseModel>
{
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<string>()));
}
}