diff --git a/src/Speckle.Sdk/Api/Exceptions.cs b/src/Speckle.Sdk/Api/Exceptions.cs index 0608d7da..b7267df9 100644 --- a/src/Speckle.Sdk/Api/Exceptions.cs +++ b/src/Speckle.Sdk/Api/Exceptions.cs @@ -98,6 +98,23 @@ public sealed class SpeckleGraphQLInvalidQueryException : SpeckleGraphQLExceptio : base(message, innerException) { } } +/// +/// Represents a WORKSPACES_MODULE_DISABLED_ERROR GraphQL error as an exception +/// +/// +/// A GraphQL request for workspace resources was made to a server that does not have the FF_WORKSPACES_MODULE_ENABLED feature flag enabled +/// +public sealed class SpeckleGraphQLWorkspaceNotEnabledException : SpeckleGraphQLException +{ + public SpeckleGraphQLWorkspaceNotEnabledException() { } + + public SpeckleGraphQLWorkspaceNotEnabledException(string? message) + : base(message) { } + + public SpeckleGraphQLWorkspaceNotEnabledException(string? message, Exception? innerException) + : base(message, innerException) { } +} + /// public sealed class WorkspacePermissionException : SpeckleGraphQLException { diff --git a/src/Speckle.Sdk/Api/GraphQL/Client.cs b/src/Speckle.Sdk/Api/GraphQL/Client.cs index 9e774db0..c7969585 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Client.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Client.cs @@ -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 diff --git a/src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs b/src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs index 5fff1eea..4fa7d9f5 100644 --- a/src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs +++ b/src/Speckle.Sdk/Api/GraphQL/GraphQLErrorHandler.cs @@ -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); diff --git a/src/Speckle.Sdk/Api/GraphQL/Inputs/CommentInputs.cs b/src/Speckle.Sdk/Api/GraphQL/Inputs/CommentInputs.cs index 057f3058..4204cb0e 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Inputs/CommentInputs.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Inputs/CommentInputs.cs @@ -1,8 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Inputs; -internal sealed record CommentContentInput(IReadOnlyCollection? blobIds, object? doc); +internal record CommentContentInput(IReadOnlyCollection? 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); diff --git a/src/Speckle.Sdk/Api/GraphQL/Inputs/ModelInputs.cs b/src/Speckle.Sdk/Api/GraphQL/Inputs/ModelInputs.cs index 134300e3..eb5a8b93 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Inputs/ModelInputs.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Inputs/ModelInputs.cs @@ -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 priorityIds, bool? priorityIdsOnly); +public record ModelVersionsFilter(IReadOnlyList priorityIds, bool? priorityIdsOnly); diff --git a/src/Speckle.Sdk/Api/GraphQL/Inputs/ProjectInputs.cs b/src/Speckle.Sdk/Api/GraphQL/Inputs/ProjectInputs.cs index a4ac847a..ff968c3e 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Inputs/ProjectInputs.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Inputs/ProjectInputs.cs @@ -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? contributors = null, IReadOnlyList? excludeIds = null, IReadOnlyList? ids = null, @@ -26,7 +26,7 @@ public sealed record ProjectModelsFilter( IReadOnlyList? 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); diff --git a/src/Speckle.Sdk/Api/GraphQL/Inputs/SubscriptionInputs.cs b/src/Speckle.Sdk/Api/GraphQL/Inputs/SubscriptionInputs.cs index 4736a640..32fcd8be 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Inputs/SubscriptionInputs.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Inputs/SubscriptionInputs.cs @@ -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); diff --git a/src/Speckle.Sdk/Api/GraphQL/Inputs/UserInputs.cs b/src/Speckle.Sdk/Api/GraphQL/Inputs/UserInputs.cs index 2584c772..1a66e07d 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Inputs/UserInputs.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Inputs/UserInputs.cs @@ -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? 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); diff --git a/src/Speckle.Sdk/Api/GraphQL/Inputs/VersionInputs.cs b/src/Speckle.Sdk/Api/GraphQL/Inputs/VersionInputs.cs index af694dac..54cd8a4b 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Inputs/VersionInputs.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Inputs/VersionInputs.cs @@ -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 versionIds); +public record MoveVersionsInput(string projectId, string targetModelName, IReadOnlyList versionIds); -public sealed record DeleteVersionsInput(IReadOnlyList versionIds, string projectId); +public record DeleteVersionsInput(IReadOnlyList 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? parents = null ); -public sealed record MarkReceivedVersionInput( +public record MarkReceivedVersionInput( string versionId, string projectId, string sourceApplication, diff --git a/src/Speckle.Sdk/Api/GraphQL/Resources/ServerResource.cs b/src/Speckle.Sdk/Api/GraphQL/Resources/ServerResource.cs new file mode 100644 index 00000000..f14ed714 --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/Resources/ServerResource.cs @@ -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; + } + + /// + /// if server is workspaces enabled + /// the requested user, or null if was initialised with an unauthenticated account + /// + public async Task 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>>>(request, cancellationToken) + .ConfigureAwait(false); + + return response.data.data.data; + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.ActiveUserGetWorkspaces.verified.json b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.ActiveUserGetWorkspaces.verified.json index bed49301..a684ea66 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.ActiveUserGetWorkspaces.verified.json +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.ActiveUserGetWorkspaces.verified.json @@ -3,6 +3,6 @@ "InnerException": { "Data": {}, "Message": "WORKSPACES_MODULE_DISABLED_ERROR: Workspaces are not enabled on this server", - "Type": "SpeckleGraphQLException" + "Type": "SpeckleGraphQLWorkspaceNotEnabledException" } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.cs index bfa9093a..34f6e1dc 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ActiveUserResourceTests.cs @@ -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(); } + + /// + /// Frequently, When the server makes a change in the graphql schema to add a new property to an input + /// object like , 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 overrides the + /// to ensure that `null` values are not serialized + /// Since Apollo also treats a null 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 + /// + [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(async () => + { + _ = await Sut.GetProjects(filter: filterNotNull); + }); + ex.InnerExceptions.Single().Should().BeOfType(); + } + + public record FakeProjectInput(string? fakeProperty) : UserProjectsFilter { } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ServerResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ServerResourceTests.cs new file mode 100644 index 00000000..da102547 --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ServerResourceTests.cs @@ -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); + } +} diff --git a/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs b/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs index b379f8eb..f3adbcb8 100644 --- a/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs +++ b/tests/Speckle.Sdk.Tests.Unit/Api/GraphQLErrorHandler.cs @@ -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" } }]; }