Compare commits

...

15 Commits

Author SHA1 Message Date
Jedd Morgan 5d10b77ee4 feat(api): Model Ingestion api (#420) (#425)
.NET Build and Publish / build (push) Has been cancelled
* First pass

* format

* subscriptions

* Fixes

* fake a release

* fix tests

* subscription tests

* tests(sdk): fix model ingestion sub test'

* tests(integration): fix model ingestion test expectations

* todos

* revert this too

* Filter Integration-Internal tests

* use a different trait

* capitalize

* codecov tweaks

* fix

* add requeue and start processing

* requeue

---------

Co-authored-by: Gergo Jedlicska <gergo@jedlicska.com>
2025-12-10 10:20:33 +00:00
Jedd Morgan 82dca56fbd feat(api): Model Ingestion api (#420)
* First pass

* format

* subscriptions

* Fixes

* fake a release

* fix tests

* subscription tests

* tests(sdk): fix model ingestion sub test'

* tests(integration): fix model ingestion test expectations

* todos

* revert this too

* Filter Integration-Internal tests

* use a different trait

* capitalize

* codecov tweaks

* fix

* add requeue and start processing

* requeue

---------

Co-authored-by: Gergo Jedlicska <gergo@jedlicska.com>
2025-12-10 13:18:31 +03:00
Dogukan Karatas 80d1df8eca Merge pull request #424 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
dev -> main for release
2025-12-04 12:01:06 +01:00
Dogukan Karatas b5796245aa Merge pull request #422 from specklesystems/dogukan/solidx-class
.NET Build and Publish / build (push) Has been cancelled
feat (objects): introducing SolidX class
2025-12-04 09:20:34 +01:00
Dogukan Karatas 639c774f80 sat added 2025-12-04 09:07:30 +01:00
Dogukan Karatas 3bb5d1e73a SolidX class added
.NET Build and Publish / build (push) Has been cancelled
2025-11-26 12:09:24 +01:00
Jedd Morgan e01360ad03 mark version received (#419) 2025-11-24 19:53:09 +00:00
dependabot[bot] 2494b160e8 chore(deps): bump actions/checkout from 5 to 6 (#421)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 19:46:31 +00:00
Jedd Morgan 0aacc3fe89 Merge pull request #417 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
main -> dev for 3.9.0 release
2025-11-05 11:03:28 +00:00
Claire Kuang 3c0b9e8b1c Merge pull request #414 from specklesystems/claire/add-camera-class
feat(objects): add Camera class
2025-10-28 13:42:39 +00:00
Jedd Morgan 6568781275 Merge pull request #405 from specklesystems/dev
Dev -> Main
2025-10-15 11:07:32 +01:00
Jedd Morgan 6740659af4 dev -> main for release (#404)
* Expose options for sending and receiving (#394)

* chore(docs): Update doc comments (#398)

* path provider

* tweaks

* Update RenderMaterial.cs (#399)

* removes the extra serializer (#402)

* feat(sdk): align SpecklePathProvider with connector repo (#400)

* path provider

* tweaks

* Align with duplicated class

* skip some slow tests (#403)

---------

Co-authored-by: Adam Hathcock <adamhathcock@users.noreply.github.com>
Co-authored-by: Dogukan Karatas <61163577+dogukankaratas@users.noreply.github.com>
2025-10-15 10:45:26 +01:00
Adam Hathcock 701013ad46 Merge pull request #393 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
dev to main for release (DONUT squash)
2025-09-24 10:22:05 +01:00
Adam Hathcock fdc0842b03 Merge pull request #388 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Main to dev (no squash)
2025-09-12 12:04:04 +01:00
Jedd Morgan 23d5dd44bc Merge pull request #382 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Dev -> Main for release
2025-09-08 10:54:56 +01:00
33 changed files with 1061 additions and 34 deletions
+14 -5
View File
@@ -6,10 +6,12 @@ on:
docker-compose-file:
required: true
type: string
use-github-container-registry:
use-internal-image:
default: false
type: boolean
secrets:
CODECOV_TOKEN:
required: true
jobs:
integration-test:
env:
@@ -17,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -27,7 +29,7 @@ jobs:
cache-dependency-path: "**/packages.lock.json"
- name: 🔐 Login to Github Container Registry
if: ${{ inputs.use-github-container-registry }}
if: ${{ inputs.use-internal-image }}
uses: docker/login-action@v3
with:
registry: "ghcr.io"
@@ -43,11 +45,18 @@ jobs:
- name: 🏗️ Build
run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror
- name: 🔨 Integration Tests
- name: 🔨 Integration Tests against Public Server
if: ${{ !inputs.use-internal-image }}
run: dotnet test ${{ env.Solution }} --filter "(Category=Integration)&(Server!=Internal)" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: 🔨 Integration Tests against Internal Server
if: ${{ inputs.use-internal-image }}
run: dotnet test ${{ env.Solution }} --filter "Category=Integration" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
+9 -3
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -39,7 +39,9 @@ jobs:
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
@@ -47,9 +49,13 @@ jobs:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose-internal.yml"
use-github-container-registry: true
use-internal-image: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
integration-test-public:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose.yml"
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+3 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -48,7 +48,9 @@ jobs:
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
+1
View File
@@ -0,0 +1 @@
dotnet 8.0.400
+1
View File
@@ -17,6 +17,7 @@
<File Path=".github\git-commit-instructions.md" />
</Folder>
<Folder Name="/config/workflows/">
<File Path=".github/workflows/integration-test.yml" />
<File Path=".github/workflows/pr.yml" />
<File Path=".github/workflows/release.yml" />
</Folder>
@@ -63,7 +63,7 @@ internal sealed class AutomationContext(IOperations operations) : IAutomationCon
);
}
Base? rootObject = await operations
Base rootObject = await operations
.Receive2(
SpeckleClient.ServerUrl,
AutomationRunData.ProjectId,
@@ -74,6 +74,10 @@ internal sealed class AutomationContext(IOperations operations) : IAutomationCon
)
.ConfigureAwait(false);
await SpeckleClient
.Version.Received(new(version.id, AutomationRunData.ProjectId, "automate_function"), cancellationToken)
.ConfigureAwait(false);
Console.WriteLine($"It took {Elapsed.TotalSeconds} seconds to receive the speckle version {versionId}");
return rootObject;
}
+6
View File
@@ -0,0 +1,6 @@
using Speckle.Sdk.Models;
namespace Speckle.Objects.Geometry;
[SpeckleType("Objects.Geometry.SolidX")]
public class SolidX : RawEncodedObject;
+2
View File
@@ -20,4 +20,6 @@ public class RawEncoding : Base // note: at this stage, since we're using this f
public static class RawEncodingFormats
{
public const string RHINO_3DM = "3dm";
public const string ACAD_DWG = "dwg";
public const string ACAD_SAT = "sat";
}
+2
View File
@@ -35,6 +35,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public WorkspaceResource Workspace { get; }
public ServerResource Server { get; }
public FileImportResource FileImport { get; }
public ModelIngestionResource Ingestion { get; }
public Uri ServerUrl => new(Account.serverInfo.url);
@@ -71,6 +72,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
Workspace = new(this);
Server = new(this);
FileImport = new(this, blobApiFactory.Create(account));
Ingestion = new(this);
}
[AutoInterfaceIgnore]
@@ -1,6 +1,9 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
//This enum isn't explicitly defined in the schema, instead its usages are int typed (But represent an enum)
/// <remarks>
/// This enum isn't explicitly defined in the schema, instead its usages are int typed (But represent an enum)
/// </remarks>
public enum FileUploadConversionStatus
{
Queued = 0,
@@ -0,0 +1,14 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ModelIngestionStatus
{
cancelled,
failed,
processing,
queued,
success,
}
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectCommentsUpdatedMessageType
{
ARCHIVED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectFileImportUpdatedMessageType
{
CREATED,
@@ -0,0 +1,13 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectModelIngestionUpdatedMessageType
{
cancellationRequested,
created,
deleted,
updated,
}
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectModelsUpdatedMessageType
{
CREATED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectPendingModelsUpdatedMessageType
{
CREATED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectUpdatedMessageType
{
DELETED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectVersionsUpdatedMessageType
{
CREATED,
@@ -1,5 +1,9 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectVisibility
{
Private,
@@ -1,5 +1,9 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ResourceType
{
commit,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum UserProjectsUpdatedMessageType
{
ADDED,
@@ -0,0 +1,65 @@
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record SourceDataInput(
string sourceApplicationSlug,
string sourceApplicationVersion,
string? fileName,
long? fileSizeBytes
);
public record ModelIngestionCreateInput(
string modelId,
string projectId,
string progressMessage,
SourceDataInput sourceData
);
public record ModelIngestionUpdateInput(string ingestionId, string projectId, string progressMessage, double? progress);
public record ModelIngestionSuccessInput(string ingestionId, string projectId, string rootObjectId);
public record ModelIngestionFailedInput(
string ingestionId,
string projectId,
string errorReason,
string? errorStacktrace
)
{
public static ModelIngestionFailedInput FromException(string ingestionId, string projectId, Exception ex)
{
return new ModelIngestionFailedInput(ingestionId, projectId, ex.Message, ex.ToString());
}
}
public record ModelIngestionCancelledInput(string ingestionId, string projectId, string cancellationMessage);
public record ModelIngestionStartProcessingInput(
string ingestionId,
string projectId,
string progressMessage,
SourceDataInput sourceData
);
public record ModelIngestionRequeueInput(string ingestionId, string projectId, string progressMessage);
public record ProjectModelIngestionSubscriptionInput(
string projectId,
ModelIngestionReference ingestionReference,
[property: JsonIgnore] ProjectModelIngestionUpdatedMessageType messageType
)
{
// The Newtonsoft serializer is setup to handle SCREAMING_CASE enums.
// But the API requires the enum to look exactly like they are
[JsonProperty(nameof(messageType))]
public string serializedType => messageType.ToString();
}
/// <remarks>
/// <c>@oneOf</c> i.e. server expects <b>either</b> <paramref name="ingestionId"/> or <paramref name="modelId"/>, but not both.
/// </remarks>
/// <param name="ingestionId"></param>
/// <param name="modelId"></param>
public record ModelIngestionReference(string? ingestionId, string? modelId);
@@ -0,0 +1,12 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelIngestion
{
public required string id { get; init; }
public required DateTime createdAt { get; init; }
public required DateTime updatedAt { get; init; }
public required string modelId { get; init; }
public required bool cancellationRequested { get; init; }
public required ModelIngestionStatusData statusData { get; init; }
// public required LimitedUser user { get; init; }
}
@@ -0,0 +1,9 @@
using Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelIngestionStatusData
{
public required ModelIngestionStatus status { get; init; }
public required string? progressMessage { get; init; }
}
@@ -6,10 +6,10 @@ namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class UserProjectsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public UserProjectsUpdatedMessageType type { get; init; }
public required UserProjectsUpdatedMessageType type { get; init; }
public Project? project { get; init; }
}
@@ -17,10 +17,10 @@ public sealed class UserProjectsUpdatedMessage : EventArgs
public sealed class ProjectCommentsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectCommentsUpdatedMessageType type { get; init; }
public required ProjectCommentsUpdatedMessageType type { get; init; }
public Comment? comment { get; init; }
}
@@ -28,10 +28,10 @@ public sealed class ProjectCommentsUpdatedMessage : EventArgs
public sealed class ProjectFileImportUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectFileImportUpdatedMessageType type { get; init; }
public required ProjectFileImportUpdatedMessageType type { get; init; }
public FileUpload? upload { get; init; }
}
@@ -39,10 +39,10 @@ public sealed class ProjectFileImportUpdatedMessage : EventArgs
public sealed class ProjectModelsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectModelsUpdatedMessageType type { get; init; }
public required ProjectModelsUpdatedMessageType type { get; init; }
public Model? model { get; init; }
}
@@ -50,10 +50,10 @@ public sealed class ProjectModelsUpdatedMessage : EventArgs
public sealed class ProjectPendingModelsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectPendingModelsUpdatedMessageType type { get; init; }
public required ProjectPendingModelsUpdatedMessageType type { get; init; }
public FileUpload? model { get; init; }
}
@@ -61,10 +61,10 @@ public sealed class ProjectPendingModelsUpdatedMessage : EventArgs
public sealed class ProjectUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectUpdatedMessageType type { get; init; }
public required ProjectUpdatedMessageType type { get; init; }
public Project? project { get; init; }
}
@@ -72,13 +72,22 @@ public sealed class ProjectUpdatedMessage : EventArgs
public sealed class ProjectVersionsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectVersionsUpdatedMessageType type { get; init; }
public required ProjectVersionsUpdatedMessageType type { get; init; }
[JsonRequired]
public string modelId { get; init; }
public required string modelId { get; init; }
public Version? version { get; init; }
}
public sealed class ProjectModelIngestionUpdatedMessage : EventArgs
{
[JsonRequired]
public required ModelIngestion modelIngestion { get; init; }
[JsonRequired]
public required ProjectModelIngestionUpdatedMessageType type { get; init; }
}
@@ -0,0 +1,440 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
namespace Speckle.Sdk.Api.GraphQL.Resources;
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
public sealed class ModelIngestionResource
{
private readonly ISpeckleGraphQLClient _client;
internal ModelIngestionResource(ISpeckleGraphQLClient client)
{
_client = client;
}
/// <summary>
/// Create a new model ingestion
/// </summary>
/// <remarks>
/// The model ingestion created will have a <c>processing</c> state (not <c>queued</c>). This mutation is designed to be used
/// by client/connectors that are immediately processing
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> Create(
ModelIngestionCreateInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionCreate($input: ModelIngestionCreateInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: create(input: $input) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// For File Import / Cloud integrations only
/// </summary>
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> StartProcessing(
ModelIngestionStartProcessingInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionStartProcessing($input: ModelIngestionStartProcessingInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: startProcessing(input: $input) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// For File Import / Cloud integrations only
/// </summary>
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> Requeue(
ModelIngestionRequeueInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionStartProcessing($input: ModelIngestionRequeueInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: requeue(input: $input) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> UpdateProgress(
ModelIngestionUpdateInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionUpdateProgress(
$input: ModelIngestionUpdateInput!
) {
data: projectMutations {
data: modelIngestionMutations {
data: updateProgress(input: $input) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// Request that the server completes the ingestion by creating a version
/// If successful, the job will be in a terminal "successful" state.
/// </summary>
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
/// <seealso cref="FailWithError"/>
/// <seealso cref="FailWithCancel"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns>The version id</returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<string> Complete(ModelIngestionSuccessInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestionComplete($input: ModelIngestionSuccessInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: completeWithVersion(input: $input) {
data:statusData {
... on ModelIngestionSuccessStatus {
data:versionId
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<
RequiredResponse<RequiredResponse<RequiredResponse<RequiredResponse<RequiredResponse<string>>>>>
>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data.data.data;
}
/// <summary>
/// Fail the job with an error.
/// </summary>
/// <remarks>
/// For requested user cancellation, use <see cref="FailWithCancel"/> instead<br/>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
/// <seealso cref="FailWithCancel"/>
/// <seealso cref="Complete"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> FailWithError(
ModelIngestionFailedInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionFailWithError($input: ModelIngestionFailedInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: failWithError(input: $input) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// Fail the ingestion with a <c>canceled</c> status.
/// This should only be done if the user has explicitly requested cancellation
/// Other forms of cancellation use <see cref="FailWithError"/>.
/// The ingestion should then enter a terminal "canceled" state.<br/>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </summary>
/// <seealso cref="FailWithError"/>
/// <seealso cref="Complete"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> FailWithCancel(
ModelIngestionCancelledInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionFailWithCancel($input: ModelIngestionCancelledInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: failWithCancel(input: $input) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// Request that the <see cref="ModelIngestion"/> is canceled.
/// </summary>
/// <remarks>
/// Note simply calling this mutation does not imediatly cancel, it doesn't even guarantee it will be canceled at all.
/// It's up to the client to observe this cancellation request
/// via <see cref="SubscriptionResource.CreateProjectModelIngestionCancellationRequestedSubscription"/>
/// and report it as canceled via <see cref="ModelIngestionResource.FailWithCancel"/>
/// See "cooperative cancellation pattern"<br/>
/// Model Ingestion API is available for server versions <c>3.0.3-alpha.583</c> and above
/// </remarks>
/// <seealso cref="FailWithError"/>
/// <seealso cref="Complete"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> RequestCancellation(
ModelIngestionCancelledInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionRequestCancellation($input: ModelIngestionRequestCancellationInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: requestCancellation (input: $input) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
}
@@ -1,4 +1,5 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
@@ -212,6 +213,60 @@ public sealed class SubscriptionResource : IDisposable
return subscription;
}
/// <summary>Subscribe to a cancellation request being made for a Model Ingestion</summary>
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
public Subscription<ProjectModelIngestionUpdatedMessage> CreateProjectModelIngestionUpdatedSubscription(
ProjectModelIngestionSubscriptionInput input
)
{
//language=graphql
const string QUERY = """
subscription IngestionUpdated($input: ProjectModelIngestionSubscriptionInput!) {
data: projectModelIngestionUpdated(input: $input) {
modelIngestion {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
type
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
Subscription<ProjectModelIngestionUpdatedMessage> subscription = new(_client, request);
_subscriptions.Add(subscription);
return subscription;
}
/// <summary>Subscribe to a cancellation request being made for a Model Ingestion</summary>
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
public Subscription<ProjectModelIngestionUpdatedMessage> CreateProjectModelIngestionCancellationRequestedSubscription(
string ingestionId,
string projectId
)
{
return CreateProjectModelIngestionUpdatedSubscription(
new ProjectModelIngestionSubscriptionInput(
projectId,
new(ingestionId, null),
ProjectModelIngestionUpdatedMessageType.cancellationRequested
)
);
}
public void Dispose()
{
foreach (var subscription in _subscriptions)
@@ -0,0 +1,8 @@
{
"Type": "AggregateException",
"InnerException": {
"Data": {},
"Message": "NOT_FOUND_ERROR: Model ingestion not found",
"Type": "SpeckleGraphQLException"
}
}
@@ -0,0 +1,8 @@
{
"Type": "AggregateException",
"InnerException": {
"Data": {},
"Message": "STREAM_NOT_FOUND: Project not found",
"Type": "SpeckleGraphQLStreamNotFoundException"
}
}
@@ -0,0 +1,8 @@
{
"Type": "AggregateException",
"InnerException": {
"Data": {},
"Message": "NOT_FOUND_ERROR: Model ingestion not found",
"Type": "SpeckleGraphQLException"
}
}
@@ -0,0 +1,74 @@
using System.Reflection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Resources;
using Speckle.Sdk.Host;
using Speckle.Sdk.Models;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
[Trait("Server", "Internal")]
public sealed class ModelIngestionResourceExceptionalTests : IAsyncLifetime
{
private IClient _testUser;
private ModelIngestionResource Sut => _testUser.Ingestion;
private Project _project;
private Model _model;
public Task DisposeAsync() => Task.CompletedTask;
public async Task InitializeAsync()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
_testUser = await Fixtures.SeedUserWithClient();
_project = await _testUser.Project.Create(new("Test project", "", null));
_model = await _testUser.Model.Create(new("Test Model 1", "", _project.id));
}
[Fact]
public async Task CreateIngestionNonExistentProject()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
"Doesn't exist...",
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
{
_ = await Sut.Create(createInput);
});
await Verify(ex);
}
[Fact]
public async Task UpdateNonExistentNonExistent()
{
var updateInput = new ModelIngestionUpdateInput("Doesn't exist", _project.id, "Can't be", 0.5);
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
{
_ = await Sut.UpdateProgress(updateInput);
});
await Verify(ex);
}
[Fact]
public async Task CancelNonExistentIngestion()
{
var input = new ModelIngestionCancelledInput(
"Non-existent-ingestion",
_project.id,
cancellationMessage: "This was cancelled for testing purposes"
);
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
{
_ = await Sut.FailWithCancel(input);
});
await Verify(ex);
}
}
@@ -0,0 +1,177 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Resources;
using Speckle.Sdk.Host;
using Speckle.Sdk.Models;
using Speckle.Sdk.Transports;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
[Trait("Server", "Internal")]
public sealed class ModelIngestionResourceTests : IAsyncLifetime
{
private IClient _testUser;
private ModelIngestionResource Sut => _testUser.Ingestion;
private Project _project;
private Model _model;
private IOperations _operations;
public Task DisposeAsync() => Task.CompletedTask;
public async Task InitializeAsync()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
_testUser = await Fixtures.SeedUserWithClient();
_project = await _testUser.Project.Create(new("Test project", "", null));
_model = await _testUser.Model.Create(new("Test Model 1", "", _project.id));
}
[Fact]
public async Task CreateAndError()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
var errorInput = new ModelIngestionFailedInput(ingest.id, _project.id, "A bad thing happened", "Over hear!");
var res = await Sut.FailWithError(errorInput);
Assert.Equal(ingest.id, res.id);
}
[Fact]
public async Task CreateAndUpdate()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
await Update(null, "None");
await Update(0.1, "0.1");
await Update(0.5, "Whoa-oh! We're half way there!");
await Update(1, "Finished");
await Update(0.2, "Back to processing again");
async Task Update(double? progress, string message)
{
var updateInput = new ModelIngestionUpdateInput(ingest.id, _project.id, message, progress);
var res = await Sut.UpdateProgress(updateInput);
Assert.Equal(message, res.statusData.progressMessage);
Assert.False(res.cancellationRequested);
Assert.Equal(ModelIngestionStatus.processing, res.statusData.status);
}
}
[Fact]
public async Task CreateAndCancel()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
var input = new ModelIngestionCancelledInput(
ingest.id,
_project.id,
cancellationMessage: "This was cancelled for testing purposes"
);
var res = await Sut.FailWithCancel(input);
Assert.Equal(ingest.id, res.id);
}
[Fact]
public async Task CreateAndComplete()
{
ModelIngestionCreateInput createInput = new(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
Base myObject = Fixtures.GenerateNestedObject();
var sendResult = await _operations.Send2(
_testUser.ServerUrl,
_project.id,
_testUser.Account.token,
myObject,
new Progress<ProgressArgs>(x =>
{
var updateInput = new ModelIngestionUpdateInput(
ingest.id,
_project.id,
$"{x.Count} / {x.Total}",
x.Total == null ? null : x.Count / x.Total
);
_ = Sut.UpdateProgress(updateInput).Result;
}),
CancellationToken.None,
new(true, true)
);
ModelIngestionSuccessInput finish = new(ingest.id, _project.id, sendResult.RootId);
string versionId = await Sut.Complete(finish);
Version version = await _testUser.Version.Get(versionId, _project.id);
Assert.Equal(version.id, versionId);
Assert.Equal(sendResult.RootId, version.referencedObject);
}
[Fact]
public async Task TestRequeue()
{
//Not sure if is desirable that ingestions created by the modelIngestionMutations.create mutation can be re-queued
//But the server allows it, so we test it
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
var ingestion = await Sut.Create(createInput);
var res = await Sut.Requeue(new(ingestion.id, _project.id, "we'll try and requeue this ingestion"));
Assert.Equal(ingestion.id, res.id);
Assert.Equal(ModelIngestionStatus.queued, res.statusData.status);
}
[Fact]
public async Task TestStartProcessing()
{
//Not sure if is desirable that StartProcessing can be used by ingestions created by the modelIngestionMutations.create mutation
//But the server allows it, so we test it
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
var ingestion = await Sut.Create(createInput);
var res = await Sut.StartProcessing(
new(ingestion.id, _project.id, "", new SourceDataInput("what", "happens", "now", 0))
);
Assert.Equal(ingestion.id, res.id);
Assert.Equal(ModelIngestionStatus.processing, res.statusData.status);
}
}
@@ -1,4 +1,4 @@
using FluentAssertions;
using FluentAssertions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
@@ -11,7 +11,7 @@ namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
public class SubscriptionResourceTests : IAsyncLifetime
{
#if DEBUG
private const int WAIT_PERIOD = 3000; // WSL is slow AF, so for local runs, we're being extra generous
private const int WAIT_PERIOD = 4000; // WSL is slow AF, so for local runs, we're being extra generous
#else
private const int WAIT_PERIOD = 400; // For CI runs, a much smaller wait time is acceptable
#endif
@@ -80,15 +80,15 @@ public class SubscriptionResourceTests : IAsyncLifetime
public async Task ProjectUpdated_SubscriptionIsCalled()
{
TaskCompletionSource<ProjectUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
using Subscription<ProjectUpdatedMessage> sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
var input = new ProjectUpdateInput(_testProject.id, "This is my new name");
var created = await _testUser.Project.Update(input);
ProjectUpdateInput input = new(_testProject.id, "This is my new name");
Project created = await _testUser.Project.Update(input);
var subscriptionMessage = await tcs.Task;
ProjectUpdatedMessage subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage.id.Should().Be(created.id);
@@ -135,4 +135,74 @@ public class SubscriptionResourceTests : IAsyncLifetime
subscriptionMessage.type.Should().Be(ProjectCommentsUpdatedMessageType.CREATED);
subscriptionMessage.comment.Should().NotBeNull();
}
[Fact(Timeout = TIMEOUT), Trait("Server", "Internal")]
public async Task ProjectModelIngestionCancellationRequested_SubscriptionIsCalled()
{
ModelIngestion ingestion = await _testUser.Ingestion.Create(
new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null))
);
TaskCompletionSource<ProjectModelIngestionUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectModelIngestionCancellationRequestedSubscription(ingestion.id, _testProject.id);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
await _testUser.Ingestion.RequestCancellation(new(ingestion.id, _testProject.id, "please cancel"));
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage.type.Should().Be(ProjectModelIngestionUpdatedMessageType.cancellationRequested);
subscriptionMessage.modelIngestion.id.Should().Be(ingestion.id);
}
[Fact(Timeout = TIMEOUT), Trait("Server", "Internal")]
public async Task ProjectModelIngestionUpdate_UpdateSubscriptionIs()
{
ModelIngestion ingestion = await _testUser.Ingestion.Create(
new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null))
);
TaskCompletionSource<ProjectModelIngestionUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectModelIngestionUpdatedSubscription(
new(
_testProject.id,
new ModelIngestionReference(ingestion.id, null),
ProjectModelIngestionUpdatedMessageType.updated
)
);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
await _testUser.Ingestion.UpdateProgress(new(ingestion.id, _testProject.id, "Here's an update", 0.314));
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage.type.Should().Be(ProjectModelIngestionUpdatedMessageType.updated);
subscriptionMessage.modelIngestion.id.Should().Be(ingestion.id);
}
[Fact(Timeout = TIMEOUT), Trait("Server", "Internal")]
public async Task ProjectModelIngestionUpdate_CancelSubscriptionIsNotCalled()
{
ModelIngestion ingestion = await _testUser.Ingestion.Create(
new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null))
);
TaskCompletionSource<ProjectModelIngestionUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectModelIngestionCancellationRequestedSubscription(ingestion.id, _testProject.id);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
await _testUser.Ingestion.UpdateProgress(new(ingestion.id, _testProject.id, "this shouldn't cancel", null));
await Task.Delay(WAIT_PERIOD); // Give time to subscription to maybe fire
tcs.Task.IsCompleted.Should().BeFalse();
}
}