feat(api): Improvements to GrahpQL error handling (#304)

* Graphql extras

* extra server resource test

* usings

* Fixed test
This commit is contained in:
Jedd Morgan
2025-05-20 13:44:23 +01:00
committed by GitHub
parent 64a93345d6
commit 0f8752d5ab
14 changed files with 154 additions and 43 deletions
+17
View File
@@ -98,6 +98,23 @@ public sealed class SpeckleGraphQLInvalidQueryException : SpeckleGraphQLExceptio
: base(message, innerException) { }
}
/// <summary>
/// Represents a <c>WORKSPACES_MODULE_DISABLED_ERROR</c> GraphQL error as an exception
/// </summary>
/// <remarks>
/// A GraphQL request for workspace resources was made to a server that does not have the <c>FF_WORKSPACES_MODULE_ENABLED</c> feature flag enabled
/// </remarks>
public sealed class SpeckleGraphQLWorkspaceNotEnabledException : SpeckleGraphQLException
{
public SpeckleGraphQLWorkspaceNotEnabledException() { }
public SpeckleGraphQLWorkspaceNotEnabledException(string? message)
: base(message) { }
public SpeckleGraphQLWorkspaceNotEnabledException(string? message, Exception? innerException)
: base(message, innerException) { }
}
/// <seealso cref="PermissionCheckResult"/>
public sealed class WorkspacePermissionException : SpeckleGraphQLException
{
+4 -5
View File
@@ -37,6 +37,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public CommentResource Comment { get; }
public SubscriptionResource Subscription { get; }
public WorkspaceResource Workspace { get; }
public ServerResource Server { get; }
public Uri ServerUrl => new(Account.serverInfo.url);
@@ -71,6 +72,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
Comment = new(this);
Subscription = new(this);
Workspace = new(this);
Server = new(this);
HttpClient = CreateHttpClient(application, speckleHttp, account);
@@ -209,11 +211,8 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
{
ContractResolver = new CamelCasePropertyNamesContractResolver { IgnoreIsSpecifiedMembers = true }, //(Default)
MissingMemberHandling = MissingMemberHandling.Error, //(not default) If you query for a member that doesn't exist, this will throw (except websocket responses see https://github.com/graphql-dotnet/graphql-client/issues/660)
Converters =
{
new ConstantCaseEnumConverter(),
} //(Default) enums will be serialized using the GraphQL const case standard
,
NullValueHandling = NullValueHandling.Ignore, //(not default) We won't serialize nulls, as can open more opportunity for conflicting with servers that are old and don't have the latest schema
Converters = { new ConstantCaseEnumConverter() }, //(Default) enums will be serialized using the GraphQL const case standard
}
),
httpClient
@@ -32,6 +32,7 @@ internal static class GraphQLErrorHandler
"STREAM_NOT_FOUND" => new SpeckleGraphQLStreamNotFoundException(message),
"BAD_USER_INPUT" => new SpeckleGraphQLBadInputException(message),
"INTERNAL_SERVER_ERROR" => new SpeckleGraphQLInternalErrorException(message),
"WORKSPACES_MODULE_DISABLED_ERROR" => new SpeckleGraphQLWorkspaceNotEnabledException(message),
_ => new SpeckleGraphQLException(message),
};
exceptions.Add(ex);
@@ -1,8 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
internal sealed record CommentContentInput(IReadOnlyCollection<string>? blobIds, object? doc);
internal record CommentContentInput(IReadOnlyCollection<string>? blobIds, object? doc);
internal sealed record CreateCommentInput(
internal record CreateCommentInput(
CommentContentInput content,
string projectId,
string resourceIdString,
@@ -10,10 +10,10 @@ internal sealed record CreateCommentInput(
object? viewerState
);
internal sealed record EditCommentInput(CommentContentInput content, string commentId, string projectId);
internal record EditCommentInput(CommentContentInput content, string commentId, string projectId);
internal sealed record CreateCommentReplyInput(CommentContentInput content, string threadId, string projectId);
internal record CreateCommentReplyInput(CommentContentInput content, string threadId, string projectId);
public sealed record MarkCommentViewedInput(string commentId, string projectId);
public record MarkCommentViewedInput(string commentId, string projectId);
public sealed record ArchiveCommentInput(string commentId, string projectId, bool archived = true);
public record ArchiveCommentInput(string commentId, string projectId, bool archived = true);
@@ -1,9 +1,9 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record CreateModelInput(string name, string? description, string projectId);
public record CreateModelInput(string name, string? description, string projectId);
public sealed record DeleteModelInput(string id, string projectId);
public record DeleteModelInput(string id, string projectId);
public sealed record UpdateModelInput(string id, string? name, string? description, string projectId);
public record UpdateModelInput(string id, string? name, string? description, string projectId);
public sealed record ModelVersionsFilter(IReadOnlyList<string> priorityIds, bool? priorityIdsOnly);
public record ModelVersionsFilter(IReadOnlyList<string> priorityIds, bool? priorityIdsOnly);
@@ -2,22 +2,22 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record ProjectCommentsFilter(bool? includeArchived, bool? loadedVersionsOnly, string? resourceIdString);
public record ProjectCommentsFilter(bool? includeArchived, bool? loadedVersionsOnly, string? resourceIdString);
public sealed record ProjectCreateInput(string? name, string? description, ProjectVisibility? visibility);
public record ProjectCreateInput(string? name, string? description, ProjectVisibility? visibility);
public sealed record WorkspaceProjectCreateInput(
public record WorkspaceProjectCreateInput(
string? name,
string? description,
ProjectVisibility? visibility,
string workspaceId
);
public sealed record ProjectInviteCreateInput(string? email, string? role, string? serverRole, string? userId);
public record ProjectInviteCreateInput(string? email, string? role, string? serverRole, string? userId);
public sealed record ProjectInviteUseInput(bool accept, string projectId, string token);
public record ProjectInviteUseInput(bool accept, string projectId, string token);
public sealed record ProjectModelsFilter(
public record ProjectModelsFilter(
IReadOnlyList<string>? contributors = null,
IReadOnlyList<string>? excludeIds = null,
IReadOnlyList<string>? ids = null,
@@ -26,7 +26,7 @@ public sealed record ProjectModelsFilter(
IReadOnlyList<string>? sourceApps = null
);
public sealed record ProjectUpdateInput(
public record ProjectUpdateInput(
string id,
string? name = null,
string? description = null,
@@ -34,6 +34,6 @@ public sealed record ProjectUpdateInput(
ProjectVisibility? visibility = null
);
public sealed record ProjectUpdateRoleInput(string userId, string projectId, string? role);
public record ProjectUpdateRoleInput(string userId, string projectId, string? role);
public sealed record WorkspaceProjectsFilter(string? search, bool? withProjectRoleOnly);
public record WorkspaceProjectsFilter(string? search, bool? withProjectRoleOnly);
@@ -1,7 +1,3 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record ViewerUpdateTrackingTarget(
string projectId,
string resourceIdString,
bool? loadedVersionsOnly = null
);
public record ViewerUpdateTrackingTarget(string projectId, string resourceIdString, bool? loadedVersionsOnly = null);
@@ -1,13 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record UserUpdateInput(
string? avatar = null,
string? bio = null,
string? company = null,
string? name = null
);
public record UserUpdateInput(string? avatar = null, string? bio = null, string? company = null, string? name = null);
public sealed record UserProjectsFilter(
public record UserProjectsFilter(
string? search = null,
IReadOnlyList<string>? onlyWithRoles = null,
string? workspaceId = null,
@@ -15,4 +10,4 @@ public sealed record UserProjectsFilter(
bool? includeImplicitAccess = null
);
public sealed record UserWorkspacesFilter(string? search);
public record UserWorkspacesFilter(string? search);
@@ -1,12 +1,12 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record UpdateVersionInput(string versionId, string projectId, string? message);
public record UpdateVersionInput(string versionId, string projectId, string? message);
public sealed record MoveVersionsInput(string projectId, string targetModelName, IReadOnlyList<string> versionIds);
public record MoveVersionsInput(string projectId, string targetModelName, IReadOnlyList<string> versionIds);
public sealed record DeleteVersionsInput(IReadOnlyList<string> versionIds, string projectId);
public record DeleteVersionsInput(IReadOnlyList<string> versionIds, string projectId);
public sealed record CreateVersionInput(
public record CreateVersionInput(
string objectId,
string modelId,
string projectId,
@@ -16,7 +16,7 @@ public sealed record CreateVersionInput(
IReadOnlyList<string>? parents = null
);
public sealed record MarkReceivedVersionInput(
public record MarkReceivedVersionInput(
string versionId,
string projectId,
string sourceApplication,
@@ -0,0 +1,39 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
namespace Speckle.Sdk.Api.GraphQL.Resources;
public sealed class ServerResource
{
private readonly ISpeckleGraphQLClient _client;
internal ServerResource(ISpeckleGraphQLClient client)
{
_client = client;
}
/// <param name="cancellationToken"></param>
/// <returns><see langword="null"/> if server is workspaces enabled</returns>
/// <returns>the requested user, or null if <see cref="Client"/> was initialised with an unauthenticated account</returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> IsWorkspaceEnabled(CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
query {
data:serverInfo {
data:workspaces {
data:workspacesEnabled
}
}
}
""";
var request = new GraphQLRequest { Query = QUERY };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data.data;
}
}
@@ -3,6 +3,6 @@
"InnerException": {
"Data": {},
"Message": "WORKSPACES_MODULE_DISABLED_ERROR: Workspaces are not enabled on this server",
"Type": "SpeckleGraphQLException"
"Type": "SpeckleGraphQLWorkspaceNotEnabledException"
}
}
@@ -2,6 +2,7 @@
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Resources;
using Speckle.Sdk.Api.GraphQL.Serializer;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
@@ -137,4 +138,32 @@ public class ActiveUserResourceTests : IAsyncLifetime
.Should()
.ThrowAsync<SpeckleException>();
}
/// <summary>
/// Frequently, When the server makes a change in the graphql schema to add a new property to an input
/// object like <see cref="UserProjectsFilter"/>, we would like to implement that change in the SDK
/// in a way that is non-breaking with older servers... Apollo will respond with a BAD_USER_INPUT otherwise
///
/// To do that, the <see cref="Client"/> overrides the <see cref="NewtonsoftJsonSerializer"/>
/// to ensure that `null` values are not serialized
/// Since Apollo also treats a <c>null</c> value the same as no value at all, the server does not complain.
///
/// This test emulates there being a property on an input that the server doesn't understand.
/// And we expect it to not complain when the value is <see langword="null"/>
/// </summary>
[Fact]
public async Task RequestWithNewerInput()
{
var filterNull = new FakeProjectInput(null);
_ = await Sut.GetProjects(filter: filterNull);
var filterNotNull = new FakeProjectInput("fake value");
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
{
_ = await Sut.GetProjects(filter: filterNotNull);
});
ex.InnerExceptions.Single().Should().BeOfType<SpeckleGraphQLBadInputException>();
}
public record FakeProjectInput(string? fakeProperty) : UserProjectsFilter { }
}
@@ -0,0 +1,30 @@
using FluentAssertions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Resources;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
public class ServerResourceTests : IAsyncLifetime
{
private IClient _testUser;
private ServerResource Sut => _testUser.Server;
public async Task InitializeAsync()
{
// Runs instead of [SetUp] in NUnit
_testUser = await Fixtures.SeedUserWithClient();
}
public Task DisposeAsync()
{
// Perform any cleanup, if needed
return Task.CompletedTask;
}
[Fact]
public async Task ExpectWorkspaceNotEnabled()
{
bool result = await Sut.IsWorkspaceEnabled();
result.Should().Be(false);
}
}
@@ -16,6 +16,11 @@ public class GraphQLErrorHandlerTests
yield return [typeof(SpeckleGraphQLBadInputException), new Map { { "code", "BAD_USER_INPUT" } }];
yield return [typeof(SpeckleGraphQLInvalidQueryException), new Map { { "code", "GRAPHQL_PARSE_FAILED" } }];
yield return [typeof(SpeckleGraphQLInvalidQueryException), new Map { { "code", "GRAPHQL_VALIDATION_FAILED" } }];
yield return
[
typeof(SpeckleGraphQLWorkspaceNotEnabledException),
new Map { { "code", "WORKSPACES_MODULE_DISABLED_ERROR" } },
];
yield return [typeof(SpeckleGraphQLException), new Map { { "foo", "bar" } }];
yield return [typeof(SpeckleGraphQLException), new Map { { "code", "CUSTOM_THING" } }];
}