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" } }];
}