feat(api): Improvements to GrahpQL error handling (#304)
* Graphql extras * extra server resource test * usings * Fixed test
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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" } }];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user