Compare commits

..

2 Commits

Author SHA1 Message Date
Jedd Morgan 3fbd9c17ba format
.NET Build and Publish / build (push) Has been cancelled
2025-11-24 18:41:10 +00:00
Jedd Morgan 937eb94730 First pass 2025-11-24 18:40:17 +00:00
132 changed files with 1356 additions and 3745 deletions
@@ -1,61 +0,0 @@
name: Integration Test
on:
workflow_call:
inputs:
speckle-sharp-sdk-ref:
required: true
type: string
jobs:
integration-test:
env:
CLIENT_DIR: "./client"
CLIENT_REPO: "specklesystems/speckle-sharp-sdk"
SERVER_DIR: "./server"
SERVER_REPO: "specklesystems/speckle-server-internal"
SOLUTION: "Speckle.Sdk.sln"
SPECKLE_SERVER_IMAGE: "speckle-server:local"
runs-on: ubuntu-latest
steps:
- name: Checkout ${{ env.CLIENT_REPO }}
uses: actions/checkout@v6
with:
path: ${{ env.CLIENT_DIR }}
repository: ${{ env.CLIENT_REPO }}
ref: ${{ inputs.speckle-sharp-sdk-ref }}
- name: Checkout ${{ env.SERVER_REPO }}
uses: actions/checkout@v6
with:
repository: ${{ env.SERVER_REPO }}
path: ${{ env.SERVER_DIR }}
- name: Setup .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.x.x
# cache: true
# cache-dependency-path: "**/packages.lock.json"
- name: 🏗️ Build Server
run: docker build --file "./packages/server/Dockerfile" --tag ${{ env.SPECKLE_SERVER_IMAGE }} .
working-directory: ${{ env.SERVER_DIR }}
- name: ⚙️ Spin up Server
run: docker compose --file "../${{ env.CLIENT_DIR }}/docker-compose-internal.yml" up --wait
working-directory: ${{ env.SERVER_DIR }}
env:
SPECKLE_SERVER_IMAGE: ${{ env.SPECKLE_SERVER_IMAGE }}
- name: 📦 Restore .NET Solution
run: dotnet restore ${{ env.SOLUTION }} --locked-mode
working-directory: ${{ env.CLIENT_DIR }}
- name: 🏗️ Build .NET Solution
run: dotnet build ${{ env.SOLUTION }} --configuration Release --no-restore -warnaserror
working-directory: ${{ env.CLIENT_DIR }}
- name: 🔨 Run .NET Integration Tests
run: dotnet test ${{ env.SOLUTION }} --filter "(Category=Integration)&(Server!=Public)" --configuration Release --no-build --no-restore --verbosity=normal
working-directory: ${{ env.CLIENT_DIR }}
+9 -18
View File
@@ -6,12 +6,10 @@ on:
docker-compose-file:
required: true
type: string
use-internal-image:
use-github-container-registry:
default: false
type: boolean
secrets:
CODECOV_TOKEN:
required: true
jobs:
integration-test:
env:
@@ -19,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -29,15 +27,15 @@ jobs:
cache-dependency-path: "**/packages.lock.json"
- name: 🔐 Login to Github Container Registry
if: ${{ inputs.use-internal-image }}
uses: docker/login-action@v4
if: ${{ inputs.use-github-container-registry }}
uses: docker/login-action@v3
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ github.token }}
- name: ⚙️ Spin up Server
run: docker compose --file ${{ inputs.docker-compose-file }} up --wait
run: docker compose -f ${{ inputs.docker-compose-file }} up --wait
- name: 📦 Restore
run: dotnet restore ${{ env.Solution }} --locked-mode
@@ -45,18 +43,11 @@ jobs:
- name: 🏗️ Build
run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror
- 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)&(Server!=Public)" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: 🔨 Integration Tests
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@v6
continue-on-error: true
uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
+5 -15
View File
@@ -1,11 +1,7 @@
name: PR Test
on:
pull_request: {}
push:
branches:
- "main" # Need to run for codecov to compare against the BASE
pull_request:
jobs:
build:
@@ -14,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -42,10 +38,8 @@ jobs:
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v6
continue-on-error: true
uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
@@ -53,13 +47,9 @@ jobs:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose-internal.yml"
use-internal-image: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
use-github-container-registry: true
integration-test-public:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose.yml"
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+6 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -46,6 +46,11 @@ jobs:
SEMVER: ${{ steps.set-version.outputs.SEMVER }}
FILE_VERSION: ${{ steps.set-version.outputs.FILE_VERSION }}
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
with:
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
- name: NuGet login (OIDC → temp API key)
uses: NuGet/login@v1
-1
View File
@@ -1 +0,0 @@
dotnet 8.0.400
+1 -1
View File
@@ -15,8 +15,8 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="[9.0.4,)" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="[5.0.0,)" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageVersion Include="Open.ChannelExtensions" Version="9.1.0" />
-3
View File
@@ -10,7 +10,6 @@
<File Path="Directory.Build.props" />
<File Path="Directory.Build.Targets" />
<File Path="Directory.Packages.props" />
<File Path="docker-compose-internal.yml" />
<File Path="docker-compose.yml" />
<File Path="global.json" />
<File Path="README.md" />
@@ -18,8 +17,6 @@
<File Path=".github\git-commit-instructions.md" />
</Folder>
<Folder Name="/config/workflows/">
<File Path=".github/workflows/integration-test-callable-from-server-repo.yml" />
<File Path=".github/workflows/integration-test.yml" />
<File Path=".github/workflows/pr.yml" />
<File Path=".github/workflows/release.yml" />
</Folder>
+5 -2
View File
@@ -52,7 +52,7 @@ services:
start_period: 10s
speckle-server:
image: ${SPECKLE_SERVER_IMAGE:-ghcr.io/specklesystems/speckle-server:latest}
image: ghcr.io/specklesystems/speckle-server:latest
restart: always
healthcheck:
test:
@@ -97,7 +97,10 @@ services:
STRATEGY_LOCAL: "true"
POSTGRES_URL: 'postgres://speckle:speckle@postgres:5432/speckle'
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
LOG_PRETTY: "true"
@@ -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,10 +74,6 @@ 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;
}
+5 -7
View File
@@ -281,6 +281,7 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.Bcl.AsyncInterfaces": "[5.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
@@ -291,10 +292,7 @@
}
},
"speckle.sdk.dependencies": {
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
"type": "Project"
},
"GraphQL.Client": {
"type": "CentralTransitive",
@@ -309,9 +307,9 @@
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"requested": "[5.0.0, )",
"resolved": "8.0.0",
"contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
-6
View File
@@ -1,6 +0,0 @@
using Speckle.Sdk.Models;
namespace Speckle.Objects.Geometry;
[SpeckleType("Objects.Geometry.SolidX")]
public class SolidX : RawEncodedObject;
-2
View File
@@ -20,6 +20,4 @@ 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";
}
+5 -7
View File
@@ -228,6 +228,7 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.Bcl.AsyncInterfaces": "[5.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
@@ -238,10 +239,7 @@
}
},
"speckle.sdk.dependencies": {
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
"type": "Project"
},
"GraphQL.Client": {
"type": "CentralTransitive",
@@ -256,9 +254,9 @@
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
@@ -5,7 +5,6 @@ public interface ISdkActivity : IDisposable
void SetTag(string key, object? value);
void RecordException(Exception e);
string TraceId { get; }
string SpanId { get; }
void SetStatus(SdkActivityStatusCode code);
void InjectHeaders(Action<string, string> header);
@@ -1,20 +1,8 @@
using System.Runtime.CompilerServices;
using Speckle.Connectors.Logging;
namespace Speckle.Sdk.Logging;
public interface ISdkActivityFactory : IDisposable
{
ISdkActivity? Start(
string? name = null,
SdkActivityKind kind = SdkActivityKind.Internal,
[CallerMemberName] string source = ""
);
ISdkActivity? StartRemote(
string traceContext,
SdkActivityKind kind,
string? name = null,
[CallerMemberName] string source = ""
);
ISdkActivity? Start(string? name = default, [CallerMemberName] string source = "");
}
@@ -1,49 +0,0 @@
using System.Threading.Channels;
namespace Speckle.Sdk.Dependencies;
/// <summary>
/// For various reasons related to our use of ILRepack.FullAuto,
/// we cannot use Channels from the SDK project.
/// We have to keep usage of it inside the Sdk.Dependencies project.
///
/// For the sake of quick development, I've wrapped the <see cref="Channel"/> class here in a type
/// that is safe to use from the SDK project.
///
/// As and when we need more functions, we can add them here.
///
/// And yes... I'm not very happy about the way we've set this up
/// </summary>
/// <typeparam name="T"></typeparam>
public sealed class RepackedChannel<T>
{
private readonly Channel<T> _channel;
public RepackedChannel(int capacity, bool singleReader, bool singleWriter)
{
_channel = Channel.CreateBounded<T>(
new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = singleReader,
SingleWriter = singleWriter,
}
);
}
public void CompleteWriter() => _channel.Writer.Complete();
public ValueTask WriteAsync(T item, CancellationToken cancellationToken) =>
_channel.Writer.WriteAsync(item, cancellationToken);
public IAsyncEnumerable<T> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
// public async Task ReadAllAsync(Func<T, Task> callback, CancellationToken cancellationToken)
// {
// await foreach (T item in _channel.Reader.ReadAllAsync(cancellationToken))
// {
// await callback.Invoke(item).ConfigureAwait(false);
// }
// }
}
@@ -28,36 +28,4 @@
<PackageReference Include="Open.ChannelExtensions" PrivateAssets="all" />
<PackageReference Include="System.Threading.Channels" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
<Target Name="BeforeILRepackPrepareBuild" BeforeTargets="ILRepackPrepareBuild">
<ItemGroup>
<!--
We're Being selective about which assemblies we're il-repacking
Avoiding repacling `Microsoft.Bcl.AsyncInterfaces.dll` because we need types like `ValueTask` and `IAsyncEnumerable` to be external
Yes, this does beg the question, why are we using `IlRepack.FullAuto` instead of raw ILRepack. Well the truth is, I'd like to move away from FullAuto
since it's unmaintaned and is lagging behind ILRepack version.
-->
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Numerics.Vectors.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Runtime.CompilerServices.Unsafe.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Memory.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Open.ChannelExtensions.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Threading.Channels.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Collections.Immutable.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.Contrib.WaitAndRetry.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.Extensions.Http.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Microsoft.Extensions.ObjectPool.dll" />
<_ILRepackExcludeAssemblies_Items Include="$(OutputPath)*.dll" Exclude="@(_ILRepackIncludeAssemblies_Items)" />
</ItemGroup>
<Message
Text="These are the packages we are NOT ilrepacking '@(_ILRepackExcludeAssemblies_Items)'"
Importance="high"
/>
<PropertyGroup>
<ILRepackExcludeAssemblies>@(_ILRepackExcludeAssemblies_Items)</ILRepackExcludeAssemblies>
</PropertyGroup>
</Target>
</Project>
@@ -1,30 +0,0 @@
namespace Speckle.Connectors.Logging;
public enum SdkActivityKind
{
/// <summary>
/// Default value.
/// Indicates that the Activity represents an internal operation within an application, as opposed to an operations with remote parents or children.
/// </summary>
Internal = 0,
/// <summary>
/// Server activity represents request incoming from external component.
/// </summary>
Server = 1,
/// <summary>
/// Client activity represents outgoing request to the external component.
/// </summary>
Client = 2,
/// <summary>
/// Producer activity represents output provided to external components.
/// </summary>
Producer = 3,
/// <summary>
/// Consumer activity represents output received from an external component.
/// </summary>
Consumer = 4,
}
@@ -11,15 +11,6 @@
"ILRepack": "2.0.33"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Direct",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Direct",
"requested": "[9.0.4, )",
@@ -160,6 +151,15 @@
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
}
},
"net8.0": {
+24 -1
View File
@@ -193,7 +193,30 @@ public sealed class BlobApi : IBlobApi
using var response = await _unauthedClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return BlobApiHelpers.ParseEtagHeader(response.Headers);
return ParseEtagHeader(response.Headers);
}
private static string ParseEtagHeader(HttpResponseHeaders headers)
{
if (!headers.TryGetValues("ETag", out var etagValues))
{
throw new ArgumentException(
"Response does not have an ETag attached to it, cannot use this as an upload",
nameof(headers)
);
}
var etagValuesArray = etagValues.ToArray();
if (etagValuesArray.Length != 1)
{
throw new ArgumentException(
$"Expected Etag header to have a single value but got {etagValuesArray.Length}",
nameof(headers)
);
}
return etagValuesArray[0];
}
/// <summary>
+4 -24
View File
@@ -35,7 +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 IngestResource Ingest { get; }
public Uri ServerUrl => new(Account.serverInfo.url);
@@ -72,7 +72,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
Workspace = new(this);
Server = new(this);
FileImport = new(this, blobApiFactory.Create(account));
Ingestion = new(this);
Ingest = new(this);
}
[AutoInterfaceIgnore]
@@ -87,26 +87,6 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
catch (Exception ex) when (!ex.IsFatal()) { }
}
/// <summary>
/// Ensure the <see cref="GQLClient"/>'s websocket is fully initialized.
/// <br/>
/// You don't <i>need</i> to call this function, if you don't, then it will be setup for you when you call <see cref="SubscribeTo"/> (e.g. when you create a <see cref="Subscription"/>),
/// but due to <see cref="GraphQL"/>'s WebSocket implementation, it's not awaited (deferred) thus the subscription make take a while to actually be setup.
/// </summary>
/// <remarks>
/// We only use websockets for GraphQL subscriptions, so if you're not using subscriptions, don't call this
///
/// Note. due to other sources (potentially on the GraphQL side) you still need a ~100ms delay between setting up the subscription, and being able to relaibly trigger it
/// This should only really negatively affect test projects.
/// </remarks>
public async Task InitializeWebsocket()
{
if (GQLClient.WebSocketSubProtocol is null)
{
await GQLClient.InitializeWebsocketConnection().ConfigureAwait(false);
}
}
internal async Task<T> ExecuteWithResiliencePolicies<T>(Func<Task<T>> func) =>
await GraphQLRetry
.ExecuteAsync<T, SpeckleGraphQLInternalErrorException>(
@@ -154,10 +134,10 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
activity?.SetStatus(SdkActivityStatusCode.Ok);
return ret;
}
catch (Exception ex)
catch (Exception)
{
activity?.SetStatus(SdkActivityStatusCode.Error);
activity?.RecordException(ex);
// Don't record exception as it's rethrown.
throw;
}
}
@@ -1,9 +1,6 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// 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)
public enum FileUploadConversionStatus
{
Queued = 0,
@@ -1,14 +0,0 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ModelIngestionStatus
{
cancelled,
failed,
processing,
queued,
success,
}
@@ -1,8 +1,5 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectCommentsUpdatedMessageType
{
ARCHIVED,
@@ -1,8 +1,5 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectFileImportUpdatedMessageType
{
CREATED,
@@ -1,13 +0,0 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectModelIngestionUpdatedMessageType
{
cancellationRequested,
created,
deleted,
updated,
}
@@ -1,8 +1,5 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectModelsUpdatedMessageType
{
CREATED,
@@ -1,8 +1,5 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectPendingModelsUpdatedMessageType
{
CREATED,
@@ -1,8 +1,5 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectUpdatedMessageType
{
DELETED,
@@ -1,8 +1,5 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectVersionsUpdatedMessageType
{
CREATED,
@@ -1,9 +1,5 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectVisibility
{
Private,
@@ -1,9 +1,5 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ResourceType
{
commit,
@@ -1,8 +1,5 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum UserProjectsUpdatedMessageType
{
ADDED,
@@ -2,10 +2,8 @@
public record GenerateFileUploadUrlInput(string projectId, string fileName);
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public record StartFileImportInput(string projectId, string modelId, string fileId, string etag);
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public record FileImportResult(
double durationSeconds,
double downloadDurationSeconds,
@@ -16,23 +14,14 @@ public record FileImportResult(
public abstract class FileImportInputBase
{
internal const string FILE_IMPORT_DEPRECATION_MESSAGE =
"Part of the old API surface and will be removed in the future. Use the new ingestion API instead. Field will be deleted on June 1st, 2026";
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
protected FileImportInputBase() { }
public required string projectId { get; init; }
public required string jobId { get; init; }
public required IReadOnlyCollection<string> warnings { get; init; }
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public required FileImportResult result { get; init; }
}
#pragma warning disable CA1822 //Mark members as static
[Obsolete(FILE_IMPORT_DEPRECATION_MESSAGE)]
public sealed class FileImportSuccessInput() : FileImportInputBase()
{
public const string TYPE_STATUS = "success";
@@ -40,7 +29,6 @@ public sealed class FileImportSuccessInput() : FileImportInputBase()
public string status => TYPE_STATUS;
}
[Obsolete(FILE_IMPORT_DEPRECATION_MESSAGE)]
public sealed class FileImportErrorInput() : FileImportInputBase()
{
public const string TYPE_STATUS = "error";
@@ -0,0 +1,19 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record IngestCreateInput(
string fileName,
int? maxIdleTimeoutMinutes,
string modelId,
string projectId,
string sourceApplication,
string sourceApplicationVersion,
IReadOnlyDictionary<string, object?> sourceFileData
);
public record IngestFinishInput(string id, string? message, string objectId, string projectId);
public record IngestErrorInput(string errorReason, string errorStacktrace, string id, string projectId);
public record CancelRequestInput(string id, string projectId);
public record IngestUpdateInput(string id, double? progress, string? progressMessage, string projectId);
@@ -1,71 +0,0 @@
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,
int? maxIdleTimeoutSeconds = null
);
public record ModelIngestionUpdateInput(string ingestionId, string projectId, string progressMessage, double? progress);
public record ModelIngestionSuccessInput(
string ingestionId,
string projectId,
string rootObjectId,
string? versionMessage
);
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);
@@ -1,6 +1,4 @@
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Sdk.Api.GraphQL.Inputs;
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record UpdateVersionInput(string versionId, string projectId, string? message);
@@ -18,10 +16,6 @@ public record CreateVersionInput(
IReadOnlyList<string>? parents = null
);
/// <param name="versionId"></param>
/// <param name="projectId"></param>
/// <param name="sourceApplication">IMPORTANT: this is meant to be the slug of the application that has done the receiving, not to be confused with <see cref="Version.sourceApplication"/></param>
/// <param name="message"></param>
public record MarkReceivedVersionInput(
string versionId,
string projectId,
@@ -0,0 +1,23 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class Ingest
{
public required DateTime createdAt { get; init; }
public required string errorReason { get; init; }
public required string errorStacktrace { get; init; }
public required string fileName { get; init; }
public required string id { get; init; }
public required long maxIdleTimeoutMinutes { get; init; }
public required string modelId { get; init; }
public required Dictionary<string, object?> performanceData { get; init; }
public required double progress { get; init; }
public required string? progressMessage { get; init; }
public required string projectId { get; init; }
public required string sourceApplication { get; init; }
public required string sourceApplicationVersion { get; init; }
public required Dictionary<string, object?> sourceFileData { get; init; }
public required string status { get; init; }
public required DateTime updatedAt { get; init; }
public required string versionId { get; init; }
public required LimitedUser user { get; init; }
}
@@ -1,13 +0,0 @@
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 string projectId { get; init; }
public required string userId { get; init; }
public required bool cancellationRequested { get; init; }
public required ModelIngestionStatusData statusData { get; init; }
}
@@ -1,10 +0,0 @@
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; }
public required string? versionId { get; init; }
}
@@ -1,8 +0,0 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelPermissionChecks
{
public PermissionCheckResult canUpdate { get; init; }
public PermissionCheckResult canDelete { get; init; }
public PermissionCheckResult canCreateVersion { get; init; }
}
@@ -10,7 +10,7 @@ public sealed class PendingStreamCollaborator
public string projectName { get; init; }
public string title { get; init; }
public string role { get; init; }
public LimitedUser? invitedBy { get; init; }
public LimitedUser invitedBy { get; init; }
public LimitedUser? user { get; init; }
public string? token { get; init; }
}
@@ -5,7 +5,5 @@ public sealed class ProjectPermissionChecks
public PermissionCheckResult canCreateModel { get; init; }
public PermissionCheckResult canDelete { get; init; }
public PermissionCheckResult canLoad { get; init; }
[Obsolete("Use ModelPermissionChecks.CanCreateVersion instead", true)]
public PermissionCheckResult canPublish { get; init; }
}
@@ -6,10 +6,10 @@ namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class UserProjectsUpdatedMessage : EventArgs
{
[JsonRequired]
public required string id { get; init; }
public string id { get; init; }
[JsonRequired]
public required UserProjectsUpdatedMessageType type { get; init; }
public 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 required string id { get; init; }
public string id { get; init; }
[JsonRequired]
public required ProjectCommentsUpdatedMessageType type { get; init; }
public 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 required string id { get; init; }
public string id { get; init; }
[JsonRequired]
public required ProjectFileImportUpdatedMessageType type { get; init; }
public 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 required string id { get; init; }
public string id { get; init; }
[JsonRequired]
public required ProjectModelsUpdatedMessageType type { get; init; }
public 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 required string id { get; init; }
public string id { get; init; }
[JsonRequired]
public required ProjectPendingModelsUpdatedMessageType type { get; init; }
public 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 required string id { get; init; }
public string id { get; init; }
[JsonRequired]
public required ProjectUpdatedMessageType type { get; init; }
public ProjectUpdatedMessageType type { get; init; }
public Project? project { get; init; }
}
@@ -72,22 +72,13 @@ public sealed class ProjectUpdatedMessage : EventArgs
public sealed class ProjectVersionsUpdatedMessage : EventArgs
{
[JsonRequired]
public required string id { get; init; }
public string id { get; init; }
[JsonRequired]
public required ProjectVersionsUpdatedMessageType type { get; init; }
public ProjectVersionsUpdatedMessageType type { get; init; }
[JsonRequired]
public required string modelId { get; init; }
public 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; }
}
@@ -397,6 +397,11 @@ public sealed class ActiveUserResource
authorized
message
}
canPublish {
code
authorized
message
}
}
}
}
@@ -29,10 +29,8 @@ public sealed class FileImportResource : IDisposable
/// <remarks>
/// Only use this if you are writing a file importer, that is responsible for
/// processing file import jobs.
/// Only works on servers version >=2.25.8 but from 3.0.7 onwards has been deprecated and replaced by model ingestion api
/// see <see cref="ModelIngestionResource.Complete"/>
/// Only works on servers version >=2.25.8
/// </remarks>
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public async Task<bool> FinishFileImportJob(FileImportInputBase input, CancellationToken cancellationToken)
{
//language=graphql
@@ -59,11 +57,7 @@ public sealed class FileImportResource : IDisposable
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <remarks>
/// Only works on servers version >=2.25.8 but from 3.0.7 onwards has been deprecated and replaced by model ingestion api
/// see <see cref="ModelIngestionResource.StartProcessing"/>
/// </remarks>
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
/// <remarks>Only works on servers version >=2.25.8</remarks>
public async Task<FileImport> StartFileImportJob(
StartFileImportInput input,
CancellationToken cancellationToken = default
@@ -0,0 +1,253 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Sdk.Api.GraphQL.Resources;
public sealed class IngestResource
{
private readonly ISpeckleGraphQLClient _client;
internal IngestResource(ISpeckleGraphQLClient client)
{
_client = client;
}
/// <param name="modelId"></param>
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ResourceCollection<Ingest>> GetIngests(
string modelId,
string projectId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query GetIngest($modelId: String!, $projectId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:ingests {
cursor
items {
createdAt
errorReason
errorStacktrace
fileName
id
maxIdleTimeoutMinutes
modelId
performanceData
progress
progressMessage
projectId
sourceApplication
sourceApplicationVersion
sourceFileData
status
updatedAt
versionId
user {
avatar
bio
company
id
name
role
verified
}
}
}
}
}
}
""";
var request = new GraphQLRequest { Query = QUERY, Variables = new { modelId, projectId } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ResourceCollection<Ingest>>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return response.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> Update(IngestUpdateInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestUpdate($projectId: ID!, $input: IngestUpdateInput!) {
data: projectMutations {
data: ingestMutations(projectId: $projectId) {
data: update(input: $input)
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<Ingest> Create(IngestCreateInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestCreate($projectId: ID!, $input: IngestCreateInput!) {
data: projectMutations {
data:ingestMutations(projectId: $projectId) {
data:create(input: $input) {
createdAt
errorReason
errorStacktrace
fileName
id
maxIdleTimeoutMinutes
modelId
performanceData
progress
progressMessage
projectId
sourceApplication
sourceApplicationVersion
sourceFileData
status
updatedAt
versionId
user {
avatar
bio
company
id
name
role
verified
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<Ingest>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<Version> End(IngestFinishInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestEnd($projectId: ID!, $input: IngestFinishInput!) {
data: projectMutations {
data:ingestMutations(projectId: $projectId) {
data:end(input: $input) {
id
referencedObject
message
sourceApplication
createdAt
previewUrl
authorUser {
id
name
bio
company
verified
role
avatar
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<Version>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> Error(IngestErrorInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestError($projectId: ID!, $input: IngestErrorInput!) {
data: projectMutations {
data:ingestMutations(projectId: $projectId) {
data:error(input: $input)
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> Cancel(CancelRequestInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestCancel($projectId: ID!, $input: CancelRequestInput!) {
data:projectMutations {
data:ingestMutations(projectId: $projectId) {
data:cancel(input: $input)
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
}
@@ -1,506 +0,0 @@
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</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</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
projectId
userId
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</c> and above
/// </remarks>
/// <param name="modelIngestionId"></param>
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> Get(
string modelIngestionId,
string projectId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query Query($projectId: String!, $modelIngestionId: ID!) {
data:project(id: $projectId) {
data:ingestion(id: $modelIngestionId) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
... on ModelIngestionSuccessStatus
{
versionId
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { projectId, modelIngestionId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<ModelIngestion>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data;
}
/// <summary>
/// For File Import / Cloud integrations only
/// </summary>
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3</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
projectId
userId
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</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
projectId
userId
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</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
projectId
userId
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</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</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
projectId
userId
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</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
projectId
userId
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</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
projectId
userId
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;
}
}
@@ -312,88 +312,4 @@ public sealed class ModelResource
return res.data.data;
}
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelPermissionChecks> GetPermissions(
string projectId,
string modelId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
canUpdate {
authorized
code
message
}
canDelete {
authorized
code
message
}
canCreateVersion {
authorized
code
message
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { projectId, modelId } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelPermissionChecks>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return response.data.data.data;
}
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="SpeckleGraphQLBadInputException">server versions &lt;3.0.11 do not have <c>canCreateIngestion</c> and will throw this exception</exception>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<PermissionCheckResult> CanCreateModelIngestion(
string projectId,
string modelId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
data:canCreateIngestion {
authorized
code
message
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { projectId, modelId } };
var response = await _client
.ExecuteGraphQLRequest<
RequiredResponse<RequiredResponse<RequiredResponse<RequiredResponse<PermissionCheckResult>>>>
>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data.data.data;
}
}
@@ -1,5 +1,4 @@
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;
@@ -213,66 +212,6 @@ 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
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
... on ModelIngestionSuccessStatus
{
versionId
}
}
}
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)
@@ -1,2 +1,2 @@
schema: https://latest.speckle.systems/graphql
schema: https://app.speckle.systems/graphql
documents: '**/*.graphql'
+32 -2
View File
@@ -1,8 +1,11 @@
using System.Runtime.InteropServices;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Common;
namespace Speckle.Sdk.Credentials;
[ClassInterface(ClassInterfaceType.AutoDual)]
[ComVisible(true)]
public class Account : IEquatable<Account>
{
private string _id;
@@ -34,8 +37,6 @@ public class Account : IEquatable<Account>
public string? refreshToken { get; set; }
public bool isDefault { get; set; }
[Obsolete("Not used in v3")]
public bool isOnline { get; set; } = true;
public ServerInfo serverInfo { get; set; }
@@ -100,4 +101,33 @@ public class Account : IEquatable<Account>
}
#endregion
internal const string LOCAL_IDENTIFIER_DEPRECATION_MESSAGE = "Local identifiers no longer nesseary";
/// <summary>
/// Retrieves the local identifier for the current user.
/// </summary>
/// <returns>
/// Returns a <see cref="Uri"/> object representing the local identifier for the current user.
/// The local identifier is created by appending the user ID as a query parameter to the server URL.
/// </returns>
/// <remarks>
/// Notice that the generated Uri is not intended to be used as a functioning Uri, but rather as a
/// unique identifier for a specific account in a local environment. The format of the Uri, containing a query parameter with the user ID,
/// serves this specific purpose. Therefore, it should not be used for forming network requests or
/// expecting it to lead to an actual webpage. The primary intent of this Uri is for unique identification in a Uri format.
/// </remarks>
/// <example>
/// This sample shows how to call the GetLocalIdentifier method.
/// <code>
/// Uri localIdentifier = GetLocalIdentifier();
/// Console.WriteLine(localIdentifier);
/// </code>
/// For a fictional `User ID: 123` and `Server: https://speckle.xyz`, the output might look like this:
/// <code>
/// https://speckle.xyz?id=123
/// </code>
/// </example>
[Obsolete(LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
internal Uri GetLocalIdentifier() => new($"{serverInfo.url}?id={userInfo.id}");
}
+540 -112
View File
@@ -1,35 +1,145 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using GraphQL;
using GraphQL.Client.Http;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
using Speckle.Sdk.Common;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
using Speckle.Sdk.SQLite;
using Stream = System.IO.Stream;
namespace Speckle.Sdk.Credentials;
public partial interface IAccountManager : IDisposable;
/// <summary>
/// Manages <see cref="Account"/> data in the local sqlite account store
/// Manage accounts locally for desktop applications.
/// </summary>
[GenerateAutoInterface]
public sealed class AccountManager(
ISpeckleApplication application,
ILogger<AccountManager> logger,
IGraphQLClientFactory graphQLClientFactory,
ISpeckleHttp speckleHttp,
IAccountFactory accountFactory,
IAuthFlow authFlow,
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
) : IAccountManager
{
public const string DEFAULT_SERVER_URL = "https://app.speckle.systems";
private readonly ISqLiteJsonCacheManager _accountStorage = sqLiteJsonCacheManagerFactory.CreateForUser("Accounts");
private static volatile bool s_isAddingAccount;
private readonly ISqLiteJsonCacheManager _accountAddLockStorage = sqLiteJsonCacheManagerFactory.CreateForUser(
"AccountAddFlow"
);
[AutoInterfaceIgnore]
public void Dispose()
{
_accountStorage.Dispose();
_accountAddLockStorage.Dispose();
}
/// <summary>
/// Gets the basic information about a server.
/// </summary>
/// <param name="server">Server Information</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default)
{
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, null);
//lang=graphql
const string QUERY_STRING = "query { serverInfo { name company migration { movedFrom movedTo } } }";
var request = new GraphQLRequest { Query = QUERY_STRING };
var response = await gqlClient.SendQueryAsync<ServerInfoResponse>(request, cancellationToken).ConfigureAwait(false);
response.EnsureGraphQLSuccess();
ServerInfo serverInfo = response.Data.serverInfo;
serverInfo.url = server.ToString().TrimEnd('/');
return response.Data.serverInfo;
}
/// <summary>
/// Gets basic user information given a token and a server.
/// </summary>
/// <param name="token"></param>
/// <param name="server">Server URL</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<UserInfo> GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default)
{
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, token);
//language=graphql
const string QUERY = """
query {
data:activeUser {
name
email
id
company
}
}
""";
var request = new GraphQLRequest { Query = QUERY };
var response = await gqlClient
.SendQueryAsync<RequiredResponse<UserInfo>>(request, cancellationToken)
.ConfigureAwait(false);
response.EnsureGraphQLSuccess();
return response.Data.data;
}
/// <summary>
/// The Default Server URL for authentication, can be overridden by placing a file with the alternatrive url in the Speckle folder or with an ENV_VAR
/// </summary>
public Uri GetDefaultServerUrl()
{
var customServerUrl = "";
// first mechanism, check for local file
var customServerFile = Path.Combine(SpecklePathProvider.UserSpeckleFolderPath, "server");
if (File.Exists(customServerFile))
{
customServerUrl = File.ReadAllText(customServerFile);
}
// second mechanism, check ENV VAR
var customServerEnvVar = Environment.GetEnvironmentVariable("SPECKLE_SERVER");
if (!string.IsNullOrEmpty(customServerEnvVar))
{
customServerUrl = customServerEnvVar;
}
if (!string.IsNullOrEmpty(customServerUrl))
{
if (Uri.TryCreate(customServerUrl, UriKind.Absolute, out Uri? url))
{
return url;
}
}
return new Uri(DEFAULT_SERVER_URL);
}
/// <param name="id">The Id of the account to fetch</param>
@@ -41,6 +151,37 @@ public sealed class AccountManager(
?? throw new SpeckleAccountManagerException($"Account {id} not found");
}
/// <summary>
/// Upgrades an account from the account.serverInfo.movedFrom account to the account.serverInfo.movedTo account
/// </summary>
/// <param name="id">Id of the account to upgrade</param>
public void UpgradeAccount(string id)
{
Account account = GetAccount(id);
if (account.serverInfo.migration?.movedTo is not Uri upgradeUri)
{
throw new SpeckleAccountManagerException(
$"Server with url {account.serverInfo.url} does not have information about the upgraded server"
);
}
account.serverInfo.migration.movedTo = null;
account.serverInfo.migration.movedFrom = new Uri(account.serverInfo.url);
account.serverInfo.url = upgradeUri.ToString().TrimEnd('/');
// setting the id to null will force it to be recreated
account.id = null!; //TODO this is gross so remove when id is nullable
RemoveAccount(id);
_accountStorage.UpdateObject(account.id.NotNull(), JsonConvert.SerializeObject(account));
}
public IEnumerable<Account> GetAccounts(string serverUrl)
{
return GetAccounts(new Uri(serverUrl));
}
/// <summary>
/// Returns all unique accounts matching the serverUrl provided. If an account exists on more than one server,
/// typically because it has been migrated, then only the upgraded account (and therefore server) are returned.
@@ -104,6 +245,7 @@ public sealed class AccountManager(
static bool IsInvalid(Account ac) => ac.userInfo == null || ac.serverInfo == null;
var sqlAccounts = _accountStorage.GetAllObjects().Select(x => JsonConvert.DeserializeObject<Account>(x.Json));
var localAccounts = GetLocalAccounts();
foreach (var acc in sqlAccounts)
{
@@ -117,55 +259,119 @@ public sealed class AccountManager(
yield return acc;
}
}
}
/// <summary>
/// Refetches all local accounts (in local db), including <see cref="ServerInfo"/> and <see cref="UserInfo"/>.
/// If the <see cref="Account.token"/> looks to be expired, this function will also attempt to use the <see cref="Account.refreshToken"/> to refresh it.
/// Will write the changes to the local accounts db
/// </summary>
/// <seealso cref="UpdateAccount"/>
/// <param name="cancellationToken"></param>
/// <exception cref="AggregateException"></exception>
public async Task UpdateAccount(Account account, CancellationToken cancellationToken = default)
{
string oldAccountId = account.id;
await UpdateAccountInMemory(account, cancellationToken).ConfigureAwait(false);
if (oldAccountId != account.id)
foreach (var acc in localAccounts)
{
// ID may have changed, e.g. users email changed, or server url migrated
_accountStorage.DeleteObject(oldAccountId);
yield return acc;
}
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
}
/// <summary>
/// Refetches the <paramref name="account"/> information, including <see cref="ServerInfo"/> and <see cref="UserInfo"/>
///
/// Will only mutate <paramref name="account"/> in memory only, and only if successful.
/// Gets the local accounts
/// These are accounts not handled by Manager and are stored in json format in a local directory
/// </summary>
/// <returns></returns>
private IList<Account> GetLocalAccounts()
{
var accountsDir = SpecklePathProvider.AccountsFolderPath;
if (!Directory.Exists(accountsDir))
{
return Array.Empty<Account>();
}
var accounts = new List<Account>();
string[] files = Directory.GetFiles(accountsDir, "*.json", SearchOption.AllDirectories);
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
Account? account = JsonConvert.DeserializeObject<Account>(json);
if (
account is not null
&& !string.IsNullOrEmpty(account.token)
&& !string.IsNullOrEmpty(account.userInfo.id)
&& !string.IsNullOrEmpty(account.userInfo.email)
&& !string.IsNullOrEmpty(account.userInfo.name)
&& !string.IsNullOrEmpty(account.serverInfo.url)
&& !string.IsNullOrEmpty(account.serverInfo.name)
)
{
accounts.Add(account);
}
}
catch (Exception ex) when (!ex.IsFatal())
{
logger.LogWarning(ex, "Failed to load json account at {filePath}", file);
}
}
return accounts;
}
/// <summary>
/// Refetches user and server info for each account
/// </summary>
/// <param name="app"> It is defaultAppId in the server. By default it is "sca" to not break existing parts that this function involves.</param>
/// <returns></returns>
public async Task UpdateAccounts(CancellationToken ct = default, string app = "sca")
{
// need to ToList() the GetAccounts call or the UpdateObject call at the end of this method
// will not work because sqlite does not support concurrent db calls
foreach (var account in GetAccounts().ToList())
{
try
{
Uri url = new(account.serverInfo.url);
var userServerInfo = await accountFactory.GetUserServerInfo(url, account.token, ct).ConfigureAwait(false);
//the token has expired
//TODO: once we get a token expired exception from the server use that instead
if (userServerInfo.activeUser == null || userServerInfo.serverInfo == null)
{
// We were initially was handling refresh token here bc quite a while ago server was returning null
// for activeUser and serverInfo instead of throwing exception. In short, our logic moved into catch block to cover both.
throw new SpeckleException("Token is expired");
}
account.isOnline = true;
account.userInfo = userServerInfo.activeUser;
account.serverInfo = userServerInfo.serverInfo;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (!ex.IsFatal())
{
await RefreshAndSetAccountToken(account, app).ConfigureAwait(false);
}
ct.ThrowIfCancellationRequested();
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
}
}
/// <summary>
/// Mutates the account with new tokens.
/// </summary>
/// <seealso cref="UpdateAccount"/>
/// <param name="account"></param>
/// <param name="cancellationToken"></param>
/// <exception cref="GraphQLHttpRequestException"></exception>
public async Task UpdateAccountInMemory(Account account, CancellationToken cancellationToken = default)
/// <param name="app"></param>
private async Task RefreshAndSetAccountToken(Account account, string app)
{
Uri url = account.serverInfo.migration?.movedTo ?? new(account.serverInfo.url);
ActiveUserServerInfoResponse userServerInfo = await accountFactory
.GetUserServerInfo(url, account.token, cancellationToken)
.ConfigureAwait(false);
if (userServerInfo.activeUser == null)
try
{
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
Uri url = new(account.serverInfo.url);
var tokenResponse = await GetRefreshedToken(account.refreshToken, url, app).ConfigureAwait(false);
account.token = tokenResponse.token;
account.refreshToken = tokenResponse.refreshToken;
account.isOnline = true;
}
catch (Exception ex) when (!ex.IsFatal())
{
account.isOnline = false;
}
account.userInfo = userServerInfo.activeUser;
account.serverInfo = userServerInfo.serverInfo;
//This is a bit gross, since id is not marked nullable
//but this will force re-generate the id (e.g. if the user's email, or servers url has changed)
account.id = null!;
}
/// <summary>
@@ -206,103 +412,325 @@ public sealed class AccountManager(
}
/// <summary>
/// Adds an account to local storage by prompting the user to log in via their browser.
/// Retrieves the local identifier for the specified account.
/// </summary>
/// <example>
/// <code>
/// Account account = await AuthenticateAccount(new Uri("https://app.speckle.systems"), TimeSpan.FromMinutes(1));
/// </code>
/// </example>
/// <param name="serverUrl"></param>
/// <param name="timeout">Timeout for user to auth with browser, recommend 1 min timeout</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Account> AuthenticateAccount(Uri serverUrl, TimeSpan timeout, CancellationToken cancellationToken)
/// <param name="account">The account for which to retrieve the local identifier.</param>
/// <returns>The local identifier for the specified account in the form of "SERVER_URL?u=USER_ID".</returns>
/// <remarks>
/// <inheritdoc cref="Account.GetLocalIdentifier"/>
/// </remarks>
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
public Uri? GetLocalIdentifierForAccount(Account account)
{
logger.LogDebug("Starting to add account for {ServerUrl}", serverUrl);
var identifier = account.GetLocalIdentifier();
TokenExchangeResponse tokenResponse = await authFlow
.TriggerAuthFlowWithTimeout(serverUrl, AuthApp.ConnectorsV3, timeout, cancellationToken)
.ConfigureAwait(false);
// Validate account is stored locally
var searchResult = GetAccountForLocalIdentifier(identifier);
return await CreateAndAddAccount(serverUrl, tokenResponse, cancellationToken).ConfigureAwait(false);
return searchResult == null ? null : identifier;
}
public async Task<Account> CreateAndAddAccount(
Uri serverUrl,
TokenExchangeResponse tokenResponse,
CancellationToken cancellationToken
)
public async Task<UserInfo> Validate(Account account)
{
var account = await accountFactory
.CreateAccount(serverUrl, tokenResponse.token, tokenResponse.refreshToken, cancellationToken)
.ConfigureAwait(false);
account.isDefault = !GetAccounts().Any();
_accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account));
logger.LogInformation("Successfully authenticated account {AccountId} for {ServerUrl}", account.id, serverUrl);
return account;
Uri server = new(account.serverInfo.url);
return await GetUserInfo(account.token, server).ConfigureAwait(false);
}
/// <summary>
/// The Default Server URL for authentication, can be overridden by placing a file with the alternative url in the Speckle folder or with an ENV_VAR
/// Gets the account that corresponds to the given local identifier.
/// </summary>
[Obsolete("Unused")]
public Uri GetDefaultServerUrl()
/// <param name="localIdentifier">The local identifier of the account.</param>
/// <returns>The account that matches the local identifier, or null if no match is found.</returns>
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
public Account? GetAccountForLocalIdentifier(Uri localIdentifier)
{
var customServerUrl = "";
// first mechanism, check for local file
var customServerFile = Path.Combine(SpecklePathProvider.UserSpeckleFolderPath, "server");
if (File.Exists(customServerFile))
{
customServerUrl = File.ReadAllText(customServerFile);
}
// second mechanism, check ENV VAR
var customServerEnvVar = Environment.GetEnvironmentVariable("SPECKLE_SERVER");
if (!string.IsNullOrEmpty(customServerEnvVar))
{
customServerUrl = customServerEnvVar;
}
if (!string.IsNullOrEmpty(customServerUrl))
{
if (Uri.TryCreate(customServerUrl, UriKind.Absolute, out Uri? url))
var searchResult = GetAccounts()
.FirstOrDefault(acc =>
{
return url;
var id = acc.GetLocalIdentifier();
return id == localIdentifier;
});
return searchResult;
}
private Uri EnsureCorrectServerUrl(Uri? server)
{
var localUrl = server;
if (localUrl == null)
{
localUrl = GetDefaultServerUrl();
logger.LogDebug("The provided server url was null or empty. Changed to the default url {serverUrl}", localUrl);
}
return localUrl;
}
private void EnsureGetAccessCodeFlowIsSupported()
{
if (!HttpListener.IsSupported)
{
logger.LogError("HttpListener not supported");
throw new PlatformNotSupportedException("Your operating system is not supported");
}
}
private async Task<string> GetAccessCode(Uri server, string challenge, TimeSpan timeout)
{
EnsureGetAccessCodeFlowIsSupported();
logger.LogDebug("Starting auth process for {server}/authn/verify/sca/{challenge}", server, challenge);
var accessCode = "";
Process.Start(new ProcessStartInfo($"{server}/authn/verify/sca/{challenge}") { UseShellExecute = true });
var task = Task.Run(() =>
{
using var listener = new HttpListener();
var localUrl = "http://localhost:29363/";
listener.Prefixes.Add(localUrl);
listener.Start();
logger.LogDebug("Listening for auth redirects on {localUrl}", localUrl);
// Note: The GetContext method blocks while waiting for a request.
HttpListenerContext context = listener.GetContext();
HttpListenerRequest request = context.Request;
HttpListenerResponse response = context.Response;
accessCode = request.QueryString["access_code"];
logger.LogDebug("Got access code {accessCode}", accessCode);
string message =
accessCode != null
? "Success!<br/><br/>You can close this window now.<script>window.close();</script>"
: "Oups, something went wrong...!";
var responseString =
$"<HTML><BODY Style='background: linear-gradient(to top right, #ffffff, #c8e8ff); font-family: Roboto, sans-serif; font-size: 2rem; font-weight: 500; text-align: center;'><br/>{message}</BODY></HTML>";
byte[] buffer = Encoding.UTF8.GetBytes(responseString);
response.ContentLength64 = buffer.Length;
Stream output = response.OutputStream;
output.Write(buffer, 0, buffer.Length);
output.Close();
logger.LogDebug("Processed finished processing the access code");
listener.Stop();
listener.Close();
});
var completedTask = await Task.WhenAny(task, Task.Delay(timeout)).ConfigureAwait(false);
// this is means the task timed out
if (completedTask != task)
{
logger.LogWarning(
"Local auth flow failed to complete within the timeout window. Access code is {accessCode}",
accessCode
);
throw new AuthFlowException("Local auth flow failed to complete within the timeout window");
}
if (task.IsFaulted && task.Exception is not null)
{
logger.LogError(
task.Exception,
"Getting access code flow failed with {exceptionMessage}",
task.Exception.Message
);
throw new AuthFlowException($"Auth flow failed: {task.Exception.Message}", task.Exception);
}
// task completed within timeout
logger.LogInformation(
"Local auth flow completed successfully within the timeout window. Access code is {accessCode}",
accessCode
);
return accessCode;
}
private async Task<Account> CreateAccount(string accessCode, string challenge, Uri server)
{
try
{
var tokenResponse = await GetToken(accessCode, challenge, server).ConfigureAwait(false);
var account = await accountFactory
.CreateAccount(server, tokenResponse.token, tokenResponse.refreshToken)
.ConfigureAwait(false);
account.isDefault = !GetAccounts().Any();
logger.LogInformation("Successfully created account for {serverUrl}", server);
return account;
}
catch (Exception ex) when (!ex.IsFatal())
{
throw new SpeckleAccountManagerException("Failed to create account from access code and challenge", ex);
}
}
private void TryLockAccountAddFlow(TimeSpan timespan)
{
// use a static variable to quickly
// prevent launching this flow multiple times
if (s_isAddingAccount)
{
// this should probably throw with an error message
throw new SpeckleAccountFlowLockedException("The account add flow is already launched.");
}
// this uses the SQLite transport to store locks
var lockIds = _accountAddLockStorage.GetAllObjects().Select(x => x.Id).OrderByDescending(d => d).ToList();
var now = DateTime.Now;
foreach (var l in lockIds)
{
var lockArray = l.Split('@');
var lockName = lockArray.Length == 2 ? lockArray[0] : "the other app";
var lockTime =
lockArray.Length == 2
? DateTime.ParseExact(lockArray[1], "o", null)
: DateTime.ParseExact(lockArray[0], "o", null);
if (lockTime > now)
{
var lockString = string.Format("{0:mm} minutes {0:ss} seconds", lockTime - now);
throw new SpeckleAccountFlowLockedException(
$"The account add flow was already started in {lockName}, retry in {lockString}"
);
}
}
return new Uri(DEFAULT_SERVER_URL);
var lockId = application.ApplicationAndVersion + "@" + DateTime.Now.Add(timespan).ToString("o");
// using the lock release time as an id and value
// for ease of deletion and retrieval
_accountAddLockStorage.SaveObject(lockId, lockId);
s_isAddingAccount = true;
}
[Obsolete("Use Uri overload")]
public IEnumerable<Account> GetAccounts(string serverUrl)
private void UnlockAccountAddFlow()
{
return GetAccounts(new Uri(serverUrl));
s_isAddingAccount = false;
// make sure all old locks are removed
foreach (var (id, _) in _accountAddLockStorage.GetAllObjects())
{
_accountAddLockStorage.DeleteObject(id);
}
}
[Obsolete("Use UpdateAccount instead for more control over error handling", true)]
public Task UpdateAccounts(CancellationToken ct = default, string app = "sca") => throw new NotImplementedException();
/// <summary>
/// Adds an account by propting the user to log in via a web flow
/// </summary>
/// <param name="server">Server to use to add the account, if not provied the default Server will be used</param>
/// <returns></returns>
public async Task AddAccount(Uri? server = null)
{
logger.LogDebug("Starting to add account for {serverUrl}", server);
[Obsolete("Use UpdateAccount instead", true)]
public void UpgradeAccount(string id) => throw new NotImplementedException();
server = EnsureCorrectServerUrl(server);
[Obsolete($"Use {nameof(AuthenticateAccount)} instead", true)]
public Task AddAccount(Uri? server = null) => throw new NotImplementedException();
// locking for 1 minute
var timeout = TimeSpan.FromMinutes(1);
// this is not part of the try finally block
// we do not want to clean up the existing locks
TryLockAccountAddFlow(timeout);
var challenge = GenerateChallenge();
[Obsolete("Use serverInfo stored on a client instead", true)]
public Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
try
{
string accessCode = await GetAccessCode(server, challenge, timeout).ConfigureAwait(false);
if (string.IsNullOrEmpty(accessCode))
{
throw new SpeckleAccountManagerException("Access code is invalid");
}
[Obsolete("Use userInfo stored on a client instead", true)]
public Task<UserInfo> GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
var account = await CreateAccount(accessCode, challenge, server).ConfigureAwait(false);
[Obsolete("Accounts must now be stored in sqlite db, no more json workaround", true)]
public IList<Account> GetLocalAccounts() => throw new NotImplementedException();
//if the account already exists it will not be added again
_accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account));
logger.LogDebug("Finished adding account {accountId} for {serverUrl}", account.id, server);
}
catch (SpeckleAccountManagerException ex)
{
logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message);
// rethrowing any known errors
throw;
}
catch (Exception ex) when (!ex.IsFatal())
{
logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message);
throw new SpeckleAccountManagerException($"Failed to add account: {ex.Message}", ex);
}
finally
{
UnlockAccountAddFlow();
}
}
[Obsolete("Use UpdateAccount or UpdateAccountInMemory Instead", true)]
public IList<Account> Validate() => throw new NotImplementedException();
private async Task<TokenExchangeResponse> GetToken(string accessCode, string challenge, Uri server)
{
try
{
using var client = speckleHttp.CreateHttpClient();
var body = new
{
appId = "sca",
appSecret = "sca",
accessCode,
challenge,
};
using var content = new StringContent(JsonConvert.SerializeObject(body));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await client.PostAsync(new Uri(server, "/auth/token"), content).ConfigureAwait(false);
return JsonConvert
.DeserializeObject<TokenExchangeResponse>(await response.Content.ReadAsStringAsync().ConfigureAwait(false))
.NotNull();
}
catch (Exception ex) when (!ex.IsFatal())
{
throw new SpeckleException($"Failed to get authentication token from {server}", ex);
}
}
private async Task<TokenExchangeResponse> GetRefreshedToken(string? refreshToken, Uri server, string app = "sca")
{
try
{
using var client = speckleHttp.CreateHttpClient();
var body = new
{
appId = app,
appSecret = app,
refreshToken,
};
using var content = new StringContent(JsonConvert.SerializeObject(body));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await client.PostAsync(new Uri(server, "/auth/token"), content).ConfigureAwait(false);
return JsonConvert
.DeserializeObject<TokenExchangeResponse>(await response.Content.ReadAsStringAsync().ConfigureAwait(false))
.NotNull();
}
catch (Exception ex) when (!ex.IsFatal())
{
throw new SpeckleException($"Failed to get refreshed token from {server}", ex);
}
}
private static string GenerateChallenge()
{
#if NET8_0
byte[] challengeData = RandomNumberGenerator.GetBytes(32);
#else
using RNGCryptoServiceProvider rng = new();
byte[] challengeData = new byte[32];
rng.GetBytes(challengeData);
#endif
//escaped chars like % do not play nice with the server
return Regex.Replace(Convert.ToBase64String(challengeData), @"[^\w\.@-]", "");
}
}
-13
View File
@@ -1,13 +0,0 @@
namespace Speckle.Sdk.Credentials;
public readonly record struct AuthApp(string AppId, string AppSecret, Uri CallbackUrl)
{
//These values are defined on the server, and specify the scopes the app is requesting
public static AuthApp ConnectorsV3 { get; } =
new()
{
AppId = "connectrV3",
AppSecret = "connectrV3",
CallbackUrl = new Uri("http://localhost:29355"),
};
}
-330
View File
@@ -1,330 +0,0 @@
using System.Diagnostics;
using System.Diagnostics.Contracts;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Common;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Credentials;
/// <summary>
/// Authentication flow with the Speckle Server to create a application token for the <c>connectorsV3</c> application
/// Starts the browser based authentication flow where the user's browser will be opened, they'll be asked to
/// confirm permission, then an access code will be given via a <see cref="HttpListener"/> which will be exchanged
/// for a <see cref="TokenExchangeResponse"/>
/// </summary>
/// <remarks>
/// Note, this class is not coupled in any way to <see cref="Account"/>
/// lets keep it that way...
/// See instead <see cref="AccountManager"/>
/// </remarks>
[GenerateAutoInterface]
public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp speckleHttp) : IAuthFlow
{
private readonly JsonSerializerSettings _serializerSettings = new()
{
MissingMemberHandling = MissingMemberHandling.Error,
NullValueHandling = NullValueHandling.Ignore,
};
public async Task<TokenExchangeResponse> TriggerAuthFlowWithTimeout(
Uri serverUrl,
AuthApp authApp,
TimeSpan timeout,
CancellationToken cancellationToken
)
{
using HttpClient client = speckleHttp.CreateHttpClient();
Uri tokenEndpoint = new(serverUrl, "/oauth/token");
string codeVerifier = GenerateCodeVerifier();
Uri authnVerify;
using var req = await client.GetAsync(tokenEndpoint, cancellationToken).ConfigureAwait(false);
bool useLegacyEndpoint = req.StatusCode != HttpStatusCode.OK;
if (useLegacyEndpoint)
{
string challenge = codeVerifier; // Old endpoint only supports PKCE "plain" method
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}", UriKind.Relative);
tokenEndpoint = new(serverUrl, "/auth/token");
}
else
{
string challenge = GenerateCodeChallenge(codeVerifier);
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}?code_challenge_method=S256", UriKind.Relative);
}
Uri endpoint = new(serverUrl, authnVerify);
_ = Process.Start(new ProcessStartInfo(endpoint.ToString()) { UseShellExecute = true });
string accessCode = await RunListenerWithTimeout(authApp.CallbackUrl, timeout, cancellationToken)
.ConfigureAwait(false);
object body = useLegacyEndpoint
? new
{
appId = authApp.AppId,
appSecret = authApp.AppSecret,
accessCode = accessCode,
challenge = codeVerifier,
}
: new
{
appId = authApp.AppId,
accessCode = accessCode,
codeVerifier = codeVerifier,
};
return await ExchangeAccessCodeForToken(
client,
JsonConvert.SerializeObject(body, _serializerSettings),
tokenEndpoint,
cancellationToken
)
.ConfigureAwait(false);
}
/// <summary>
///
/// </summary>
/// <param name="applicationCallbackUrl"></param>
/// <param name="timeout"></param>
/// <param name="userCancellation"></param>
/// <returns></returns>
/// <exception cref="OperationCanceledException"><paramref name="userCancellation"/> requested cancel</exception>
/// <exception cref="TimeoutException">timeout was reached</exception>
public async Task<string> RunListenerWithTimeout(
Uri applicationCallbackUrl,
TimeSpan timeout,
CancellationToken userCancellation
)
{
using CancellationTokenSource cancelOnTimeout = new(timeout);
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(
cancelOnTimeout.Token,
userCancellation
);
try
{
using var activity = activityFactory.Start("Listening for authflow access code");
return await RunListener(applicationCallbackUrl, linkedSource.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (userCancellation.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException ex) when (cancelOnTimeout.IsCancellationRequested)
{
throw new TimeoutException($"Auth flow was cancelled after {timeout:g} timeout", ex);
}
}
/// <summary>
///
/// </summary>
/// <param name="refreshToken"></param>
/// <param name="serverUrl"></param>
/// <param name="authApp">Auth app, needs to match the app that generated the refresh token originally</param>
/// <param name="cancellationToken"></param>
/// <exception cref="HttpRequestException">HTTP exceptions</exception>
/// <exception cref="JsonSerializationException">Server response was invalid or partial</exception>
/// <exception cref="ArgumentOutOfRangeException ">Invalid <paramref name="serverUrl"/> (must be absolute url)</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> requested cancel</exception>
/// <returns></returns>
public async Task<TokenExchangeResponse> GetRefreshedToken(
string? refreshToken,
Uri serverUrl,
AuthApp authApp,
CancellationToken cancellationToken
)
{
using var client = speckleHttp.CreateHttpClient();
var body = new
{
appId = authApp.AppId,
appSecret = authApp.AppSecret,
refreshToken = refreshToken,
};
using var content = new StringContent(JsonConvert.SerializeObject(body, _serializerSettings));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await client
.PostAsync(new Uri(serverUrl, "/auth/token"), content, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
#if NET8_0_OR_GREATER
string read = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
string read = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
return JsonConvert.DeserializeObject<TokenExchangeResponse>(read, _serializerSettings).NotNull();
}
private static async Task<HttpListenerContext> GetContext(HttpListener listener, CancellationToken cancellationToken)
{
//GetContextAsync doesn't support cancellation, so we have to do this song and dance...
Task timeoutTask = Task.Delay(Timeout.Infinite, cancellationToken);
Task<HttpListenerContext> getContextTask = listener.GetContextAsync();
Task completed = await Task.WhenAny(getContextTask, timeoutTask).ConfigureAwait(false);
if (completed == getContextTask)
{
return getContextTask.Result;
}
cancellationToken.ThrowIfCancellationRequested();
throw new InvalidOperationException("Cancellation should have thrown, this shouldn't be possible");
}
public static async Task<string> RunListener(Uri localUrl, CancellationToken cancellationToken)
{
using HttpListener listener = new();
listener.Prefixes.Add(localUrl.ToString());
listener.Start();
HttpListenerContext context = await GetContext(listener, cancellationToken).ConfigureAwait(false);
HttpListenerRequest request = context.Request;
using HttpListenerResponse response = context.Response;
string? accessCode = request.QueryString["access_code"];
string? denied = request.QueryString["denied"];
bool isDenied = denied == "true";
if (isDenied)
{
//lang=html
WriteResponse(
"""
<h1>Denied!</h1>
<br/><br/>
Please close this window and return to your Speckle Connector.
"""
);
throw new AuthFlowException("Authentication flow was denied"); //denied presumably by the user
}
else if (accessCode != null)
{
//lang=html
WriteResponse(
"""
<h1>Success!</h1>
<br/><br/>
Your Speckle Connector is now authorized
<br/><br/>
You may now close this window and return to your Speckle Connector
"""
);
return accessCode;
}
else
{
//lang=html
WriteResponse(
"""
<h1>Failed!</h1>
<br/><br/>
Something went wrong trying to authorize your Speckle Connector
<br/><br/>
Please close this window and try again from your Speckle Connector.
"""
);
throw new AuthFlowException("Failed to receive access code");
}
void WriteResponse(string message)
{
//lang=html
string responseString = $"""
<HTML>
<BODY Style='background: #FAFAFAFF; font-family: Inter, Roboto, sans-serif; font-size: 1rem; font-weight: 500; text-align: center;'>
<br/>
{message}
</BODY>
</HTML>
""";
byte[] buffer = Encoding.UTF8.GetBytes(responseString);
response.ContentLength64 = buffer.Length;
response.OutputStream.Write(buffer, 0, buffer.Length);
}
}
private async Task<TokenExchangeResponse> ExchangeAccessCodeForToken(
HttpClient client,
string jsonContent,
Uri tokenEndpoint,
CancellationToken cancellationToken
)
{
using StringContent content = new(jsonContent);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using HttpResponseMessage response = await client
.PostAsync(tokenEndpoint, content, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
#if NET8_0_OR_GREATER
string read = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
string read = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
return JsonConvert.DeserializeObject<TokenExchangeResponse>(read, _serializerSettings).NotNull();
}
[Pure]
public static string GenerateCodeVerifier()
{
#if NET8_0_OR_GREATER
Span<byte> codeVerifierData = stackalloc byte[32];
RandomNumberGenerator.Fill(codeVerifierData);
#else
using RNGCryptoServiceProvider rng = new();
byte[] codeVerifierData = new byte[32];
rng.GetBytes(codeVerifierData);
#endif
return Base64UrlEncode(codeVerifierData);
}
[Pure]
public static string GenerateCodeChallenge(string codeVerifier)
{
#if NET8_0_OR_GREATER
int byteCount = Encoding.UTF8.GetByteCount(codeVerifier.AsSpan());
Span<byte> codeVerifierBytes = stackalloc byte[byteCount];
Encoding.UTF8.GetBytes(codeVerifier, codeVerifierBytes);
Span<byte> challengeData = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(codeVerifierBytes, challengeData);
#else
byte[] codeVerifierBytes = Encoding.UTF8.GetBytes(codeVerifier);
using SHA256 hash = SHA256.Create();
byte[] challengeData = hash.ComputeHash(codeVerifierBytes);
#endif
return Base64UrlEncode(challengeData);
}
[Pure]
private static string Base64UrlEncode(
#if NET8_0_OR_GREATER
ReadOnlySpan<byte> bytes
#else
byte[] bytes
#endif
)
{
// Base64Url is available in .NET 9, or via the Microsoft.Bcl.Memory polyfill
// But for simplicity r.e. dll dependencies, we're doing it the dumb way...
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
}
@@ -0,0 +1,14 @@
namespace Speckle.Sdk.Credentials;
#pragma warning disable CA2237
public sealed class AuthFlowException : Exception
#pragma warning restore CA2237
{
public AuthFlowException(string? message, Exception? innerException)
: base(message, innerException) { }
public AuthFlowException(string? message)
: base(message) { }
public AuthFlowException() { }
}
+11 -11
View File
@@ -1,16 +1,5 @@
namespace Speckle.Sdk.Credentials;
public sealed class AuthFlowException : SpeckleException
{
public AuthFlowException(string? message, Exception? innerException)
: base(message, innerException) { }
public AuthFlowException(string? message)
: base(message) { }
public AuthFlowException() { }
}
public class SpeckleAccountManagerException : SpeckleException
{
public SpeckleAccountManagerException(string message)
@@ -21,3 +10,14 @@ public class SpeckleAccountManagerException : SpeckleException
public SpeckleAccountManagerException() { }
}
public class SpeckleAccountFlowLockedException : SpeckleAccountManagerException
{
public SpeckleAccountFlowLockedException(string message)
: base(message) { }
public SpeckleAccountFlowLockedException() { }
public SpeckleAccountFlowLockedException(string message, Exception? innerException)
: base(message, innerException) { }
}
+5 -8
View File
@@ -6,19 +6,16 @@ namespace Speckle.Sdk.Credentials;
internal sealed class ActiveUserServerInfoResponse
{
[property: JsonProperty(Required = Required.AllowNull)]
public required UserInfo? activeUser { get; init; }
public UserInfo? activeUser { get; init; }
[property: JsonProperty(Required = Required.Always)]
public required ServerInfo serverInfo { get; init; }
public ServerInfo serverInfo { get; init; }
}
public sealed class TokenExchangeResponse
internal sealed class TokenExchangeResponse
{
[JsonRequired]
public required string token { get; init; }
[JsonRequired]
public required string refreshToken { get; init; }
public string token { get; init; }
public string refreshToken { get; init; }
}
public sealed class UserInfo
-29
View File
@@ -1,29 +0,0 @@
using System.Net.Http.Headers;
namespace Speckle.Sdk.Helpers;
public static class BlobApiHelpers
{
public static string ParseEtagHeader(HttpResponseHeaders headers)
{
if (!headers.TryGetValues("ETag", out var etagValues))
{
throw new ArgumentException(
"Response does not have an ETag attached to it, cannot use this as an upload",
nameof(headers)
);
}
var etagValuesArray = etagValues.ToArray();
if (etagValuesArray.Length != 1)
{
throw new ArgumentException(
$"Expected Etag header to have a single value but got {etagValuesArray.Length}",
nameof(headers)
);
}
return etagValuesArray[0];
}
}
-34
View File
@@ -1,34 +0,0 @@
using Microsoft.Extensions.Logging;
namespace Speckle.Sdk.Helpers;
/// <summary>
/// <see cref="IDisposable"/> wrapper around the downloaded file to try and delete the file on Dispose
/// </summary>
/// <remarks>
/// We're using a similar pattern in the Rhino File Importer codebase (see <c>ImportJobFile</c>)
/// </remarks>
/// <param name="logger"></param>
/// <param name="file"></param>
public sealed class DisposableFile(FileInfo file, ILogger logger, bool deleteOnDispose = true) : IDisposable
{
public FileInfo FileInfo => file;
public void Dispose()
{
if (!deleteOnDispose)
{
return;
}
try
{
file.Delete();
logger.LogInformation("Cleaned up {File}", file);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "Failed to clean up {File}", file);
}
}
}
@@ -1,21 +0,0 @@
using System.Diagnostics;
namespace Speckle.Sdk.Helpers;
public static class StopwatchPolyfills
{
#if !NET7_0_OR_GREATER
private static readonly double s_tickFrequency = (double)TimeSpan.TicksPerSecond / Stopwatch.Frequency;
#endif
public static TimeSpan GetElapsedTime(long startingTimestamp)
{
#if NET7_0_OR_GREATER
return Stopwatch.GetElapsedTime(startingTimestamp);
#else
long elapsedTicks = Stopwatch.GetTimestamp() - startingTimestamp;
return new TimeSpan((long)(elapsedTicks * s_tickFrequency));
#endif
}
}
+1 -16
View File
@@ -140,22 +140,7 @@ internal static class TypeLoader
return typeof(Base);
}
/// <summary>
/// For testing purposes only
/// </summary>
internal static void ReInitialize(params Assembly[] assemblies)
{
lock (s_availableTypes)
{
Reset();
Load(assemblies);
s_initialized = true;
}
}
/// <summary>
/// For testing purposes only
/// </summary>
//Don't use unless you're testing
public static void Reset()
{
s_availableTypes = new();
@@ -1,12 +1,8 @@
using Speckle.Connectors.Logging;
namespace Speckle.Sdk.Logging;
namespace Speckle.Sdk.Logging;
public sealed class NullActivityFactory : ISdkActivityFactory
{
public void Dispose() { }
public ISdkActivity? Start(string? name, SdkActivityKind kind, string source) => null;
public ISdkActivity? StartRemote(string traceContext, SdkActivityKind kind, string? name, string source) => null;
public ISdkActivity? Start(string? name = default, string source = "") => null;
}
@@ -7,12 +7,12 @@ namespace Speckle.Sdk.Models;
public enum DynamicBaseMemberType
{
/// <summary>
/// The typed members of the <see cref="DynamicBase"/> object
/// The typed members of the DynamicBase object
/// </summary>
Instance = 1,
/// <summary>
/// The dynamically added members of the <see cref="DynamicBase"/> object
/// The dynamically added members of the DynamicBase object
/// </summary>
Dynamic = 2,
@@ -22,9 +22,8 @@ public enum DynamicBaseMemberType
Obsolete = 4,
/// <summary>
/// Old feature supported in v2 for grasshopper
/// The typed methods flagged with TODO:
/// </summary>
[Obsolete("Feature no longer supported")]
SchemaComputed = 16,
/// <summary>
@@ -1,12 +0,0 @@
namespace Speckle.Sdk.Pipelines.Progress;
public sealed class AggregateProgress<T>(params IProgress<T>[] progresses) : IProgress<T>
{
public void Report(T value)
{
foreach (var progress in progresses)
{
progress.Report(value);
}
}
}
@@ -1,89 +0,0 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Helpers;
namespace Speckle.Sdk.Pipelines.Progress;
public partial interface IIngestionProgressManager : IProgress<CardProgress>;
/// <summary>
/// An <see langword="IProgress{IngestionProgressEventArgs}"/> implementation for the entire client side Ingestion progress update reporting
/// Will throttles ingestion progress messages and reports their progress
/// </summary>
/// <remarks>
/// Normally we would pick quite a coarse updateInterval to try and spamming the server (1-5s)
/// </remarks>
[GenerateAutoInterface]
public sealed class IngestionProgressManager(
ILogger<IngestionProgressManager> logger,
IClient speckleClient,
ModelIngestion ingestion,
TimeSpan updateInterval,
CancellationToken cancellationToken
) : IIngestionProgressManager
{
public Task? LastUpdate { get; private set; }
private long _lastUpdatedAt;
private readonly object _lock = new();
[AutoInterfaceIgnore]
public void Report(CardProgress value)
{
cancellationToken.ThrowIfCancellationRequested();
string trimmedMessage;
lock (_lock)
{
if (ShouldIgnoreProgressUpdate())
{
return;
}
_lastUpdatedAt = Stopwatch.GetTimestamp();
trimmedMessage = value.Status.TrimEnd('.');
LastUpdate = speckleClient
.Ingestion.UpdateProgress(
new ModelIngestionUpdateInput(ingestion.id, ingestion.projectId, trimmedMessage, value.Progress),
cancellationToken
)
.ContinueWith(
Continuation,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default
);
}
logger.LogInformation("Progress update {Message} {Progress}", trimmedMessage, value.Progress);
}
/// <returns><see langword="true"/> if the update should be ignored, otherwise <see langword="false"/></returns>
private bool ShouldIgnoreProgressUpdate()
{
if (LastUpdate is not null && !LastUpdate.IsCompleted)
{
return true;
}
TimeSpan msSinceLastUpdate = StopwatchPolyfills.GetElapsedTime(_lastUpdatedAt);
return msSinceLastUpdate < updateInterval;
}
private void Continuation(Task updateTask)
{
// The progress report failed... could be many reasons.
// For now, we're not letting this fail the Ingestion in any way
// we'll log but otherwise let it slide while leaving no unobserved task exceptions
if (updateTask.IsFaulted)
{
logger.LogWarning(updateTask.Exception, "A progress update failed unexpectedly");
}
}
}
@@ -1,21 +0,0 @@
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Models;
namespace Speckle.Sdk.Pipelines.Progress;
[GenerateAutoInterface]
public sealed class IngestionProgressManagerFactory(ILogger<IngestionProgressManager> logger)
: IIngestionProgressManagerFactory
{
public IIngestionProgressManager CreateInstance(
IClient speckleClient,
ModelIngestion ingestion,
TimeSpan updateInterval,
CancellationToken cancellationToken
)
{
return new IngestionProgressManager(logger, speckleClient, ingestion, updateInterval, cancellationToken);
}
}
@@ -1,6 +0,0 @@
namespace Speckle.Sdk.Pipelines.Progress;
//TODO: rename PipelineProgressArgs
public readonly record struct CardProgress(string Status, double? Progress);
public readonly record struct StreamProgressArgs(long BytesStreamed, long ExpectedTotalBytes);
@@ -1,103 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace Speckle.Sdk.Pipelines.Progress;
/// <summary>
/// Wraps <paramref name="innerStream"/> to report streaming progress as bytes are read/written.
/// </summary>
public sealed class ProgressStream(Stream innerStream, IProgress<StreamProgressArgs>? progress = null) : Stream
{
private long _bytesStreamed;
public override bool CanRead => innerStream.CanRead;
public override bool CanSeek => innerStream.CanSeek;
public override bool CanWrite => innerStream.CanWrite;
public override long Length => innerStream.Length;
public override long Position
{
get => innerStream.Position;
set => innerStream.Position = value;
}
public override int Read(byte[] buffer, int offset, int count)
{
int bytesRead = innerStream.Read(buffer, offset, count);
ReportProgress(bytesRead);
return bytesRead;
}
[SuppressMessage(
"Performance",
"CA1835:Prefer the \'Memory\'-based overloads for \'ReadAsync\' and \'WriteAsync\'",
Justification = "Analyser warning forwarded to caller"
)]
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
int bytesRead = await innerStream.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
ReportProgress(bytesRead);
return bytesRead;
}
#if NET8_0_OR_GREATER
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
int bytesRead = await innerStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
ReportProgress(bytesRead);
return bytesRead;
}
#endif
private void ReportProgress(int newBytesProcessed)
{
_bytesStreamed += newBytesProcessed;
progress?.Report(new(_bytesStreamed, Length));
}
public override void Flush() => innerStream.Flush();
public override Task FlushAsync(CancellationToken cancellationToken) => innerStream.FlushAsync(cancellationToken);
public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin);
public override void SetLength(long value) => throw new NotSupportedException(); //intentionally not supporting, as changing length of stream mid-flight will fuck up progress
public override void Write(byte[] buffer, int offset, int count)
{
innerStream.Write(buffer, offset, count);
ReportProgress(count);
}
[SuppressMessage(
"Performance",
"CA1835:Prefer the \'Memory\'-based overloads for \'ReadAsync\' and \'WriteAsync\'",
Justification = "Analyser warning forwarded to caller"
)]
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await innerStream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
ReportProgress(count);
}
#if NET6_0_OR_GREATER
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
await innerStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
ReportProgress(buffer.Length);
}
#endif
protected override void Dispose(bool disposing)
{
innerStream.Dispose();
base.Dispose(disposing);
}
#if NET6_0_OR_GREATER
public override async ValueTask DisposeAsync()
{
await innerStream.DisposeAsync().ConfigureAwait(false);
await base.DisposeAsync().ConfigureAwait(false);
}
#endif
}
@@ -1,40 +0,0 @@
namespace Speckle.Sdk.Pipelines.Progress;
/// <summary>
/// Renders "low level" data stream updates
/// into "high level" <see cref="CardProgress"/> that is expected by Ingestion progress and DUI3
/// </summary>
/// <param name="progress"></param>
public sealed class RenderedStreamProgress(IProgress<CardProgress> progress) : IProgress<StreamProgressArgs>
{
public void Report(StreamProgressArgs value)
{
var (suffix, scaleFactor) = GetFileSizeRendering(value.ExpectedTotalBytes);
progress.Report(
new(
$"Uploading data... ({value.BytesStreamed * scaleFactor:F1}/{value.ExpectedTotalBytes * scaleFactor:F1} {suffix})",
(double)value.BytesStreamed / value.ExpectedTotalBytes
)
);
}
private static readonly string[] s_suffixes = ["B", "KB", "MB", "GB", "TB", "PB"];
internal static (string suffix, double scaleFactor) GetFileSizeRendering(long value)
{
if (value <= 0)
{
return (s_suffixes[0], 1d);
}
for (int i = 0; i < s_suffixes.Length; i++)
{
if (value <= Math.Pow(1024, i + 1))
{
return (s_suffixes[i], 1 / Math.Pow(1024, i));
}
}
throw new ArgumentOutOfRangeException(nameof(value), "Value is too large to convert to a file size");
}
}
@@ -1,90 +0,0 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class DiskStoreFactory(ILogger<DiskStore> logger, ISdkActivityFactory activityFactory) : IDiskStoreFactory
{
public DiskStore CreateInstance(CancellationToken cancellationToken) =>
new(logger, activityFactory, cancellationToken);
}
public sealed class DiskStore
{
private readonly RepackedChannel<UploadItem> _channel;
private readonly Task<DisposableFile> _writeToDiskTask;
private readonly ILogger<DiskStore> _logger;
private readonly ISdkActivityFactory _activityFactory;
private readonly CancellationToken _cancellationToken;
internal DiskStore(
ILogger<DiskStore> logger,
ISdkActivityFactory activityFactory,
CancellationToken cancellationToken
)
{
_logger = logger;
_activityFactory = activityFactory;
_cancellationToken = cancellationToken;
_channel = new RepackedChannel<UploadItem>(1000, true, false);
_writeToDiskTask = Task.Run(WriteFile, cancellationToken);
}
public async Task PushAsync(UploadItem item) =>
await _channel.WriteAsync(item, _cancellationToken).ConfigureAwait(false);
public async Task<DisposableFile> CompleteAsync()
{
using var a = _activityFactory.Start("Waiting for DiskStore to complete");
_channel.CompleteWriter();
return await _writeToDiskTask.ConfigureAwait(false);
}
/// <summary>
/// Reads from the Channel and streams the <see cref="UploadItem"/>s to a temporary file on disk.
/// Will keep reading until <see cref="CompleteAsync"/> is called.
/// </summary>
/// <returns>the file that was written</returns>
private async Task<DisposableFile> WriteFile()
{
string tempFilePath = Path.GetTempFileName();
var tempFile = new DisposableFile(new FileInfo(tempFilePath), _logger);
_logger.LogInformation("Writing temp file to {TempFilePath}", tempFilePath);
try
{
using var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
using var gzip = new GZipStream(fileStream, CompressionLevel.Optimal);
using var writer = new StreamWriter(gzip);
await foreach (var item in _channel.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
{
await writer.WriteAsync(item.Id).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.SpeckleType).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.Json.Value).ConfigureAwait(false);
await writer.WriteLineAsync().ConfigureAwait(false);
}
#if NET8_0_OR_GREATER
await writer.FlushAsync(_cancellationToken).ConfigureAwait(false);
#else
await writer.FlushAsync().ConfigureAwait(false);
#endif
tempFile.FileInfo.Refresh();
return tempFile;
}
catch
{
tempFile.Dispose();
throw;
}
}
}
@@ -1,74 +0,0 @@
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Models;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class SendPipelineFactory(IUploaderFactory uploaderFactory, IDiskStoreFactory diskStoreFactory)
: ISendPipelineFactory
{
public SendPipeline CreateInstance(
string projectId,
string ingestionId,
Account account,
IProgress<StreamProgressArgs> uploadProgress,
CancellationToken cancellationToken
)
{
var uploader = uploaderFactory.CreateInstance(projectId, ingestionId, account, uploadProgress, cancellationToken);
var diskStore = diskStoreFactory.CreateInstance(cancellationToken);
return new SendPipeline(uploader, diskStore);
}
}
public sealed class SendPipeline : IDisposable
{
private readonly Serializer _serializer = new();
private readonly Uploader _uploader;
private readonly DiskStore _diskStore;
internal SendPipeline(Uploader uploader, DiskStore diskStore)
{
_uploader = uploader;
_diskStore = diskStore;
}
private UploadItem _lastItem;
public async Task<ObjectReference> Process(Base @base)
{
var results = _serializer.Serialize(@base).ToArray();
var first = results.First();
foreach (var item in results)
{
// we're not doing fire and forget here so that we get the backpressure from the uploader
await _diskStore.PushAsync(item).ConfigureAwait(false);
}
// NOTE: this is important to keep track of. When we serialze an object, we get back a list of objects, with the first one being the original root.
// In the case of the commit root object, this means the last object is not necessarily the root; we therefore need to manually track its existance here
// and ensure it's the last one through in the uploader's stream. See WaitForUpload down below.
_lastItem = first;
return first.Reference;
}
public async Task WaitForUpload()
{
await _diskStore.PushAsync(_lastItem).ConfigureAwait(false);
using DisposableFile tempFile = await _diskStore.CompleteAsync().ConfigureAwait(false);
using Stream fileStreamUpload = new FileStream(
tempFile.FileInfo.FullName,
FileMode.Open,
FileAccess.Read,
FileShare.Read
);
await _uploader.Send(fileStreamUpload).ConfigureAwait(false);
}
public void Dispose() => _uploader.Dispose();
}
@@ -1,351 +0,0 @@
using System.Collections;
using System.Drawing;
using System.Globalization;
using System.Reflection;
using Speckle.DoubleNumerics;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Models;
using Speckle.Sdk.Serialisation;
namespace Speckle.Sdk.Pipelines.Send;
/// <summary>
/// Another serializer, cleaner and meaner. Provides methods for serializing Speckle objects into a format suitable for upload or storage.
/// This class handles the conversion of <see cref="Speckle.Sdk.Models.Base"/> and its derivatives
/// into serialized JSON structures along with associated metadata, closures, and references.
/// <para>Any reference objects coming through are being "passed through" serialized - they do not get double encoded.</para>
/// </summary>
internal sealed class Serializer
{
private readonly record struct PropertyInfo(string Name, object? Value, bool IsDetachable);
public IEnumerable<UploadItem> Serialize(Base root)
{
// Special case: if root is already an ObjectReference, serialize it verbatim
if (root is ObjectReference existingRef)
{
var uploadItem = ReferenceToUploadItem(existingRef);
yield return uploadItem;
yield break;
}
var detachedObjects = new List<(Id, Json, Dictionary<string, int>, Base, string)>();
var rootClosures = new Dictionary<string, int>();
var (rootId, rootJson) = SerializeBase(root, false, rootClosures, detachedObjects);
var rootReference = new ObjectReference
{
referencedId = rootId.Value,
applicationId = root.applicationId,
closure = rootClosures.Count > 0 ? rootClosures : null,
};
yield return new UploadItem(rootId.Value, rootJson, root.speckle_type, rootReference);
foreach (var (id, json, closures, baseObj, speckleType) in detachedObjects)
{
var reference = new ObjectReference
{
referencedId = id.Value,
applicationId = baseObj.applicationId,
closure = closures.Count > 0 ? closures : null,
};
yield return new UploadItem(id.Value, json, speckleType, reference);
}
}
private IEnumerable<PropertyInfo> ExtractProperties(Base baseObj)
{
var typedProperties = baseObj.GetInstanceMembers();
foreach (var prop in typedProperties)
{
if (prop.Name == "id" || prop.Name.StartsWith("__"))
{
continue;
}
if (prop.IsDefined(typeof(JsonIgnoreAttribute), false))
{
continue;
}
var value = prop.GetValue(baseObj);
var isDetachable = prop.GetCustomAttribute<DetachPropertyAttribute>(true)?.Detachable ?? false;
yield return new PropertyInfo(prop.Name, value, isDetachable);
}
foreach (var propName in baseObj.DynamicPropertyKeys)
{
if (propName.StartsWith("__"))
{
continue;
}
var value = baseObj[propName];
#pragma warning disable CA1866
var isDetachable = propName.StartsWith("@");
#pragma warning restore CA1866
yield return new PropertyInfo(propName, value, isDetachable);
}
}
private (Id, Json) SerializeBase(
Base baseObj,
bool forceDetach,
Dictionary<string, int> closures,
List<(Id, Json, Dictionary<string, int>, Base, string)> detachedObjects
)
{
var childClosures = new Dictionary<string, int>();
var sb = Pools.StringBuilders.Get();
try
{
using var stringWriter = new StringWriter(sb);
using var jsonWriter = new JsonTextWriter(stringWriter);
using var idWriter = new SerializerIdWriter(jsonWriter);
idWriter.WriteStartObject();
foreach (var prop in ExtractProperties(baseObj))
{
idWriter.WritePropertyName(prop.Name);
SerializeValue(prop.Value, idWriter, prop.IsDetachable, childClosures, detachedObjects);
}
var (jsonForId, finalWriter) = idWriter.FinishIdWriter();
var id = IdGenerator.ComputeId(jsonForId);
finalWriter.WritePropertyName("id");
finalWriter.WriteValue(id.Value);
baseObj.id = id.Value;
if ((forceDetach || childClosures.Count > 0) && childClosures.Count > 0)
{
finalWriter.WritePropertyName("__closure");
finalWriter.WriteStartObject();
foreach (var kvp in childClosures)
{
finalWriter.WritePropertyName(kvp.Key);
finalWriter.WriteValue(kvp.Value);
}
finalWriter.WriteEndObject();
foreach (var kvp in childClosures)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existing) ? existing + kvp.Value : kvp.Value;
}
}
finalWriter.WriteEndObject();
finalWriter.Flush();
var json = new Json(stringWriter.ToString());
return (id, json);
}
finally
{
Pools.StringBuilders.Return(sb);
}
}
private void SerializeValue(
object? value,
JsonWriter writer,
bool isDetachable,
Dictionary<string, int> closures,
List<(Id, Json, Dictionary<string, int>, Base, string)> detachedObjects
)
{
switch (value)
{
case Enum:
writer.WriteValue((int)value);
return;
case Guid g:
writer.WriteValue(g.ToString());
return;
case Color c:
writer.WriteValue(c.ToArgb());
return;
case DateTime dt:
writer.WriteValue(dt.ToString("o", CultureInfo.InvariantCulture));
return;
case Matrix4x4 md:
writer.WriteStartArray();
writer.WriteValue(md.M11);
writer.WriteValue(md.M12);
writer.WriteValue(md.M13);
writer.WriteValue(md.M14);
writer.WriteValue(md.M21);
writer.WriteValue(md.M22);
writer.WriteValue(md.M23);
writer.WriteValue(md.M24);
writer.WriteValue(md.M31);
writer.WriteValue(md.M32);
writer.WriteValue(md.M33);
writer.WriteValue(md.M34);
writer.WriteValue(md.M41);
writer.WriteValue(md.M42);
writer.WriteValue(md.M43);
writer.WriteValue(md.M44);
writer.WriteEndArray();
return;
// Handle ObjectReference before Base (since ObjectReference extends Base)
// This prevents double-serialization and properly propagates closures
case ObjectReference objRef:
{
writer.WriteStartObject();
writer.WritePropertyName("speckle_type");
writer.WriteValue("reference");
writer.WritePropertyName("referencedId");
writer.WriteValue(objRef.referencedId);
writer.WriteEndObject();
// Propagate closure: add the referenced ID
closures[objRef.referencedId] = closures.TryGetValue(objRef.referencedId, out var existing) ? existing + 1 : 1;
// Propagate nested closures from the ObjectReference.closure dictionary
if (objRef.closure != null)
{
foreach (var kvp in objRef.closure)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existingDepth)
? existingDepth + kvp.Value
: kvp.Value;
}
}
return;
}
case Base baseObj:
{
if (isDetachable)
{
var childClosures = new Dictionary<string, int>();
var (childId, childJson) = SerializeBase(baseObj, true, childClosures, detachedObjects);
detachedObjects.Add((childId, childJson, childClosures, baseObj, baseObj.speckle_type));
writer.WriteStartObject();
writer.WritePropertyName("speckle_type");
writer.WriteValue("reference");
writer.WritePropertyName("referencedId");
writer.WriteValue(childId.Value);
writer.WriteEndObject();
closures[childId.Value] = closures.TryGetValue(childId.Value, out var existing) ? existing + 1 : 1;
foreach (var kvp in childClosures)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existingDepth)
? existingDepth + kvp.Value
: kvp.Value;
}
}
else
{
var inlineClosures = new Dictionary<string, int>();
var (_, inlineJson) = SerializeBase(baseObj, false, inlineClosures, detachedObjects);
writer.WriteRawValue(inlineJson.Value);
foreach (var kvp in inlineClosures)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existingDepth)
? existingDepth + kvp.Value
: kvp.Value;
}
}
return;
}
case IDictionary dict:
{
writer.WriteStartObject();
foreach (DictionaryEntry kvp in dict)
{
if (kvp.Key is not string key)
{
throw new ArgumentException("Dictionary keys must be strings", nameof(value));
}
writer.WritePropertyName(key);
SerializeValue(kvp.Value, writer, false, closures, detachedObjects);
}
writer.WriteEndObject();
return;
}
case ICollection collection:
{
writer.WriteStartArray();
foreach (var item in collection)
{
SerializeValue(item, writer, isDetachable, closures, detachedObjects);
}
writer.WriteEndArray();
return;
}
default:
// This case will handle primitives and `null`
// Will throw JsonWriterException if not supported
writer.WriteValue(value);
return;
}
}
private UploadItem ReferenceToUploadItem(ObjectReference existingRef)
{
var sb = Pools.StringBuilders.Get();
try
{
using var stringWriter = new StringWriter(sb);
using var jsonWriter = new JsonTextWriter(stringWriter);
jsonWriter.WriteStartObject();
jsonWriter.WritePropertyName("speckle_type");
jsonWriter.WriteValue("reference");
jsonWriter.WritePropertyName("referencedId");
jsonWriter.WriteValue(existingRef.referencedId);
jsonWriter.WritePropertyName("__closure");
if (existingRef.closure != null && existingRef.closure.Count > 0)
{
jsonWriter.WriteStartObject();
foreach (var kvp in existingRef.closure)
{
jsonWriter.WritePropertyName(kvp.Key);
jsonWriter.WriteValue(kvp.Value);
}
jsonWriter.WriteEndObject();
}
else
{
jsonWriter.WriteNull();
}
jsonWriter.WriteEndObject();
jsonWriter.Flush();
var refJson = new Json(stringWriter.ToString());
return new UploadItem(
existingRef.referencedId,
refJson,
existingRef.speckle_type,
existingRef // Pass through the original ObjectReference
);
}
finally
{
Pools.StringBuilders.Return(sb);
}
}
}
-132
View File
@@ -1,132 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class UploaderFactory(ISpeckleHttp httpClientFactory, ISdkActivityFactory activityFactory)
: IUploaderFactory
{
public Uploader CreateInstance(
string projectId,
string ingestionId,
Account account,
IProgress<StreamProgressArgs> progress,
CancellationToken cancellationToken
) => new(projectId, ingestionId, activityFactory, httpClientFactory, account, progress, cancellationToken);
}
public sealed class Uploader : IDisposable
{
private readonly string _projectId;
private readonly string _ingestionId;
private readonly CancellationToken _cancellationToken;
private readonly HttpClient _speckleClient;
private readonly HttpClient _s3Client;
private readonly ISdkActivityFactory _activity;
private readonly IProgress<StreamProgressArgs> _progress;
internal Uploader(
string projectId,
string ingestionId,
ISdkActivityFactory activity,
ISpeckleHttp httpClientFactory,
Account speckleAccount,
IProgress<StreamProgressArgs> progress,
CancellationToken cancellationToken
)
{
_projectId = projectId;
_ingestionId = ingestionId;
_activity = activity;
_cancellationToken = cancellationToken;
_progress = progress;
_speckleClient = httpClientFactory.CreateHttpClient(authorizationToken: speckleAccount.token);
_speckleClient.BaseAddress = new(new(speckleAccount.serverInfo.url), "/api/v1/");
_s3Client = httpClientFactory.CreateHttpClient();
}
public async Task Send(Stream fileStream)
{
PresignedUploadResponse presignedUploadResponse = await GetPresignedUrl().ConfigureAwait(false);
var etag = await UploadToS3(fileStream, presignedUploadResponse).ConfigureAwait(false);
await TriggerProcessing(new() { Etag = etag }).ConfigureAwait(false);
}
private async Task<PresignedUploadResponse> GetPresignedUrl()
{
using var a = _activity.Start("Get Presigned Url");
var signUri = new Uri($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/sign", UriKind.Relative);
using var signResponse = await _speckleClient.PostAsync(signUri, null, _cancellationToken).ConfigureAwait(false);
signResponse.EnsureSuccessStatusCode();
#if NET5_0_OR_GREATER
string signResponseString = await signResponse.Content.ReadAsStringAsync(_cancellationToken).ConfigureAwait(false);
#else
string signResponseString = await signResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
PresignedUploadResponse presignedUpload =
JsonConvert.DeserializeObject<PresignedUploadResponse>(signResponseString)
?? throw new InvalidOperationException("Failed to get presigned upload URL");
return presignedUpload;
}
private async Task<string> UploadToS3(Stream fileStream, PresignedUploadResponse presignedUploadResponse)
{
using var a = _activity.Start("Uploading file to pre-signed url");
Stream progressStream = new ProgressStream(fileStream, _progress);
using var streamContent = new StreamContent(progressStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
streamContent.Headers.ContentLength = fileStream.Length;
using var uploadRequest = new HttpRequestMessage(HttpMethod.Put, presignedUploadResponse.Url);
foreach (var kvp in presignedUploadResponse.AdditionalRequestHeaders)
{
uploadRequest.Headers.Add(kvp.Key, kvp.Value);
}
uploadRequest.Content = streamContent;
using var uploadResponse = await _s3Client
.SendAsync(uploadRequest, HttpCompletionOption.ResponseHeadersRead, _cancellationToken)
.ConfigureAwait(false);
uploadResponse.EnsureSuccessStatusCode();
return BlobApiHelpers.ParseEtagHeader(uploadResponse.Headers);
}
private async Task TriggerProcessing(TriggerUploadRequest request)
{
using var a = _activity.Start("Triggering Processing");
Uri processUri = new($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/process", UriKind.Relative);
string requestBody = JsonConvert.SerializeObject(request);
using var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
using HttpResponseMessage processResponse = await _speckleClient
.PostAsync(processUri, content, _cancellationToken)
.ConfigureAwait(false);
string body = await processResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
processResponse.EnsureSuccessStatusCode();
}
public void Dispose()
{
_speckleClient.Dispose();
_s3Client.Dispose();
}
}
@@ -1,20 +0,0 @@
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Models;
using Speckle.Sdk.Serialisation;
namespace Speckle.Sdk.Pipelines.Send;
public record UploadItem(string Id, Json Json, string SpeckleType, ObjectReference Reference);
internal record PresignedUploadResponse
{
public required Uri Url { get; init; }
public required string Key { get; init; }
public Dictionary<string, string> AdditionalRequestHeaders { get; init; } = new();
}
internal readonly struct TriggerUploadRequest
{
[JsonProperty("etag")]
public required string Etag { get; init; }
}
@@ -2,13 +2,7 @@ using System.Text;
namespace Speckle.Sdk.Serialisation.V2.Send;
public sealed record BaseItem(
Id Id,
Json Json,
bool NeedsStorage,
Dictionary<Id, int>? Closures,
bool? IsReference = false
) : IHasByteSize
public sealed record BaseItem(Id Id, Json Json, bool NeedsStorage, Dictionary<Id, int>? Closures) : IHasByteSize
{
public int ByteSize { get; } = Encoding.UTF8.GetByteCount(Json.Value);
@@ -113,6 +113,16 @@ public sealed class SerializeProcess(
_processSource.Token
);
var findTotalObjectsTask = Task.CompletedTask;
if (!options.SkipFindTotalObjects)
{
ThrowIfFailed();
findTotalObjectsTask = Task.Factory.StartNew(
() => TraverseTotal(root),
_processSource.Token,
TaskCreationOptions.AttachedToParent | TaskCreationOptions.PreferFairness,
_highest
);
}
await Traverse(root).ConfigureAwait(false);
ThrowIfFailed();
@@ -123,7 +133,6 @@ public sealed class SerializeProcess(
ThrowIfFailed();
await WaitForSchedulerCompletion().ConfigureAwait(false);
ThrowIfFailed();
return new(root.id.NotNull(), baseSerializer.ObjectReferences.Freeze());
}
catch (OperationCanceledException)
+1 -3
View File
@@ -8,7 +8,6 @@ using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Host;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Models.GraphTraversal;
using Speckle.Sdk.Pipelines.Progress;
using Speckle.Sdk.Serialisation.V2;
using Speckle.Sdk.Serialisation.V2.Receive;
using Speckle.Sdk.Serialisation.V2.Send;
@@ -97,8 +96,7 @@ public static class ServiceRegistration
typeof(DeserializeProcess),
typeof(ObjectLoader),
typeof(TraversalRule),
typeof(Client),
typeof(IngestionProgressManager)
typeof(Client)
);
serviceCollection.AddMatchingInterfacesAsTransient(typeof(GraphQLRetry).Assembly);
return serviceCollection;
+1
View File
@@ -35,6 +35,7 @@
<PackageReference Include="Microsoft.CSharp" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Speckle.Sdk.Dependencies\Speckle.Sdk.Dependencies.csproj" />
+10 -13
View File
@@ -13,6 +13,15 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CSharp": {
"type": "Direct",
"requested": "[4.7.0, )",
@@ -282,19 +291,7 @@
}
},
"speckle.sdk.dependencies": {
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
"type": "Project"
}
},
"net8.0": {
@@ -382,7 +382,7 @@
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"requested": "[5.0.0, )",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
@@ -184,7 +184,7 @@ public class SerializationTests
idToBase.Count.Should().Be(count);
}
[Theory(Skip = "Takes too long")]
[Theory]
[InlineData(1)]
[InlineData(4)]
public async Task Roundtrip_Test_New(int concurrency)
@@ -52,7 +52,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, default, "DownloadObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, "DownloadObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -91,7 +91,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, default, "DownloadSingleObject")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, "DownloadSingleObject")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -132,7 +132,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, default, "HasObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, "HasObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -171,7 +171,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, default, "UploadObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, "UploadObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -0,0 +1,122 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
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;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
public class IngestResourceTests : IAsyncLifetime
{
private IClient _testUser;
private IngestResource Sut => _testUser.Ingest;
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 input = new IngestCreateInput(
"myTestFile",
1,
_model.id,
_project.id,
".NET",
"0.0.0",
new Dictionary<string, object?>()
);
Ingest ingest = await Sut.Create(input);
var errorInput = new IngestErrorInput("A bad thing happened", "Over hear!", ingest.id, _project.id);
var res = await Sut.Error(errorInput);
Assert.True(res);
var result = await Sut.GetIngests(_model.id, _project.id);
await Verify(result);
}
[Fact]
public async Task CreateAndCancel()
{
var input = new IngestCreateInput(
"myTestFile",
1,
_model.id,
_project.id,
".NET",
"0.0.0",
new Dictionary<string, object?>()
);
Ingest ingest = await Sut.Create(input);
var errorInput = new CancelRequestInput(ingest.id, _project.id);
var res = await Sut.Cancel(errorInput);
Assert.True(res);
var result = await Sut.GetIngests(_model.id, _project.id);
await Verify(result);
}
[Fact]
public async Task CreateAndEnd()
{
var create = new IngestCreateInput(
"myTestFile",
1,
_model.id,
_project.id,
".NET",
"0.0.0",
new Dictionary<string, object?>()
);
Ingest ingest = await Sut.Create(create);
var myObject = Fixtures.GenerateNestedObject();
var sendResult = await _operations.Send2(
_testUser.ServerUrl,
_project.id,
_testUser.Account.token,
myObject,
new Progress<ProgressArgs>(x =>
{
var updateInput = new IngestUpdateInput(
ingest.id,
x.Total == null ? null : x.Count / x.Total,
$"{x.Count} / {x.Total}",
_project.id
);
_ = Sut.Update(updateInput).Result;
}),
CancellationToken.None,
new(true, true)
);
var finish = new IngestFinishInput(ingest.id, "Yay! we completed", sendResult.RootId, _project.id);
var res = await Sut.End(finish);
Assert.NotNull(res);
var result = await Sut.GetIngests(_model.id, _project.id);
await Verify(result);
}
}
@@ -1,71 +0,0 @@
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Resources;
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()
{
_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);
});
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLStreamNotFoundException>(item));
}
[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);
});
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLException>(item));
}
[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);
});
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLException>(item));
}
}
@@ -1,196 +0,0 @@
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.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()
{
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, "yay!");
string versionId = await Sut.Complete(finish);
Version version = await _testUser.Version.Get(versionId, _project.id);
ModelIngestion finalIngestion = await _testUser.Ingestion.Get(ingest.id, _project.id);
Assert.Equal(version.id, versionId);
Assert.Equal(sendResult.RootId, version.referencedObject);
Assert.Equal(finalIngestion.statusData.versionId, versionId);
}
[Fact]
public async Task CreateAndGet()
{
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);
ModelIngestion res = await Sut.Get(ingest.id, _project.id);
Assert.Equal(ingest.id, res.id);
Assert.Equal(ingest.statusData.status, res.statusData.status);
Assert.Equal(ingest.statusData.versionId, res.statusData.versionId);
Assert.Null(res.statusData.versionId);
Assert.Equal(_model.id, res.modelId);
Assert.Equal(_project.id, res.projectId);
Assert.Equal(_testUser.Account.userInfo.id, res.userId);
}
[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,6 +1,5 @@
using FluentAssertions;
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;
@@ -18,7 +17,7 @@ public class ModelResourceTests : IAsyncLifetime
{
// Runs instead of [SetUp] in NUnit
_testUser = await Fixtures.SeedUserWithClient();
_project = await _testUser.Project.Create(new("Test project", "", ProjectVisibility.Public));
_project = await _testUser.Project.Create(new("Test project", "", null));
_model = await _testUser.Model.Create(new("Test Model", "", _project.id));
}
@@ -124,40 +123,4 @@ public class ModelResourceTests : IAsyncLifetime
var delEx = await FluentActions.Invoking(() => Sut.Delete(input)).Should().ThrowAsync<AggregateException>();
getEx.WithInnerExceptionExactly<SpeckleGraphQLException>();
}
[Fact]
public async Task TestUserHasModelPermissions()
{
var ownerResult = await Sut.GetPermissions(_project.id, _model.id);
ownerResult.canUpdate.authorized.Should().Be(true);
ownerResult.canCreateVersion.authorized.Should().Be(true);
ownerResult.canDelete.authorized.Should().Be(true);
// Test with another user
var guest = await Fixtures.SeedUserWithClient();
var guestResult = await guest.Model.GetPermissions(_project.id, _model.id);
guestResult.canUpdate.authorized.Should().Be(false);
guestResult.canCreateVersion.authorized.Should().Be(false);
guestResult.canDelete.authorized.Should().Be(false);
}
[Fact]
[Trait("Server", "Internal")]
public async Task TestCanCreateModelIngestion_InternalServer()
{
var ownerResult = await Sut.CanCreateModelIngestion(_project.id, _model.id);
ownerResult.authorized.Should().Be(true);
}
[Fact]
[Trait("Server", "Public")]
public async Task TestCanCreateModelIngestion_PublicServer_Throws()
{
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
await Sut.CanCreateModelIngestion(_project.id, _model.id)
);
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLInvalidQueryException>();
}
}
@@ -106,7 +106,7 @@ public class ProjectResourceExceptionalTests : IAsyncLifetime
ProjectUpdateRoleInput input = new(_secondUser.Account.id.NotNull(), "NonExistentProject", newRole);
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.UpdateRole(input));
ex.InnerExceptions.Single().Should().BeAssignableTo<SpeckleGraphQLException>(); //v3 server responds with SpeckleGraphQLStreamNotFoundException exception, v2 reponds with SpeckleGraphQLForbiddenException
ex.InnerExceptions.Single().Should().BeOfType<SpeckleGraphQLForbiddenException>();
}
[Theory]
@@ -103,30 +103,8 @@ public class ProjectResourceTests
[Fact]
public async Task TestUserHasProjectPermissions()
{
var privateProject = await _testUser.Project.Create(
new ProjectCreateInput("asdfasdf", "desc", ProjectVisibility.Private)
);
var resp = await Sut.GetPermissions(privateProject.id);
resp.canCreateModel.authorized.Should().Be(true);
resp.canDelete.authorized.Should().Be(true);
resp.canLoad.authorized.Should().Be(true);
var publicProject = await _testUser.Project.Create(
new ProjectCreateInput("asdfasdf", "desc", ProjectVisibility.Public)
);
var res = await Sut.GetPermissions(publicProject.id);
var res = await Sut.GetPermissions(_testProject.id);
res.canCreateModel.authorized.Should().Be(true);
res.canDelete.authorized.Should().Be(true);
res.canLoad.authorized.Should().Be(true);
// Test with another user
var guest = await Fixtures.SeedUserWithClient();
var guestResult = await guest.Project.GetPermissions(publicProject.id);
guestResult.canCreateModel.authorized.Should().Be(false);
guestResult.canDelete.authorized.Should().Be(false);
guestResult.canLoad.authorized.Should().Be(false);
}
}
@@ -1,4 +1,4 @@
using FluentAssertions;
using FluentAssertions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
@@ -13,9 +13,9 @@ 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
#else
private const int WAIT_PERIOD = 600; // For CI runs, a much smaller wait time is acceptable
private const int WAIT_PERIOD = 400; // For CI runs, a much smaller wait time is acceptable
#endif
private const int TIMEOUT = WAIT_PERIOD + WAIT_PERIOD + 600;
private const int TIMEOUT = WAIT_PERIOD + WAIT_PERIOD + 400;
private IClient _testUser;
private Project _testProject;
private Model _testModel;
@@ -32,7 +32,6 @@ public class SubscriptionResourceTests : IAsyncLifetime
public async Task InitializeAsync()
{
_testUser = await Fixtures.SeedUserWithClient();
await _testUser.InitializeWebsocket();
_testProject = await _testUser.Project.Create(new("test project123", "desc", null));
_testModel = await _testUser.Model.Create(new("test model", "desc", _testProject.id));
_testVersion = await Fixtures.CreateVersion(_testUser, _testProject.id, _testModel.id);
@@ -81,15 +80,15 @@ public class SubscriptionResourceTests : IAsyncLifetime
public async Task ProjectUpdated_SubscriptionIsCalled()
{
TaskCompletionSource<ProjectUpdatedMessage> tcs = new();
using Subscription<ProjectUpdatedMessage> sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
using var sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
ProjectUpdateInput input = new(_testProject.id, "This is my new name");
Project created = await _testUser.Project.Update(input);
var input = new ProjectUpdateInput(_testProject.id, "This is my new name");
var created = await _testUser.Project.Update(input);
ProjectUpdatedMessage subscriptionMessage = await tcs.Task;
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage.id.Should().Be(created.id);
@@ -136,74 +135,4 @@ 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();
}
}
@@ -0,0 +1,8 @@
{
"Type": "AggregateException",
"InnerException": {
"Data": {},
"Message": "FORBIDDEN: Your auth token does not have the required scope: workspace:read.",
"Type": "SpeckleGraphQLForbiddenException"
}
}
@@ -0,0 +1,8 @@
{
"Type": "AggregateException",
"InnerException": {
"Data": {},
"Message": "FORBIDDEN: Your auth token does not have the required scope: workspace:read.",
"Type": "SpeckleGraphQLForbiddenException"
}
}
@@ -1,5 +1,4 @@
using FluentAssertions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Resources;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
@@ -26,15 +25,13 @@ public class WorkspaceResourceTests
public async Task TestGetWorkspace()
{
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.Get("non-existent-id"));
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
await Verify(ex);
}
[Fact]
public async Task TestGetProjects()
{
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.GetProjects("non-existent-id"));
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
await Verify(ex);
}
}
@@ -1,7 +0,0 @@
namespace Speckle.Sdk.Tests.Integration;
[CollectionDefinition(nameof(RequiresSqLiteAccountDb), DisableParallelization = true)]
public sealed class RequiresSqLiteAccountDb;
[CollectionDefinition(nameof(RequiresAuthFlowPort), DisableParallelization = true)]
public sealed class RequiresAuthFlowPort;
@@ -1,107 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Integration.Credentials;
[Collection(nameof(RequiresSqLiteAccountDb))]
public class AccountManagerTests
{
private IAccountManager _sut;
public AccountManagerTests()
{
_sut = Fixtures.ServiceProvider.GetRequiredService<IAccountManager>();
}
[Fact]
public async Task UpdateAccount_UpdatesUserInfo()
{
using IClient user = await Fixtures.SeedUserWithClient();
string realAccountId = user.Account.id;
UserInfo realUserData = user.Account.userInfo;
UserInfo staleData = new()
{
avatar = "my old avatar",
company = "my old company",
email = "my.old.email@example.com",
id = realUserData.id,
name = "my old name",
};
// Mutate with "fake" data to simulate a stale account data
user.Account.userInfo = staleData;
user.Account.id = null!; //force re-generate id
Assert.NotEqual(realAccountId, user.Account.id);
await _sut.UpdateAccountInMemory(user.Account);
Assert.Equal(realUserData.avatar, user.Account.userInfo.avatar);
Assert.Equal(realUserData.company, user.Account.userInfo.company);
Assert.Equal(realUserData.email, user.Account.userInfo.email);
Assert.Equal(realUserData.id, user.Account.userInfo.id);
Assert.Equal(realUserData.name, user.Account.userInfo.name);
Assert.Equal(realAccountId, user.Account.id);
}
[Fact]
public async Task UpdateAccount_UpdatesServerInfo()
{
using IClient user = await Fixtures.SeedUserWithClient();
string realAccountId = user.Account.id;
ServerInfo realServerData = user.Account.serverInfo;
ServerInfo staleData = new()
{
company = "This old company",
description = "this old description",
name = "This old name",
url = realServerData.url,
version = "0.0.123",
};
// Mutate with "fake" data to simulate a stale account data
user.Account.serverInfo = staleData;
user.Account.id = null!; //force re-generate id
Assert.Equal(realAccountId, user.Account.id); //account id should not change since we didn't change server url
await _sut.UpdateAccountInMemory(user.Account);
Assert.Equal(realServerData.company, user.Account.serverInfo.company);
Assert.Equal(realServerData.description, user.Account.serverInfo.description);
Assert.Equal(realServerData.name, user.Account.serverInfo.name);
Assert.Equal(realServerData.url, user.Account.serverInfo.url);
Assert.Equal(realServerData.version, user.Account.serverInfo.version);
Assert.Equal(realAccountId, user.Account.id);
}
[Fact]
public async Task UpdateAccount_ServerInfoMigration()
{
using IClient user = await Fixtures.SeedUserWithClient();
string realAccountId = user.Account.id;
ServerInfo realServerData = user.Account.serverInfo;
ServerInfo staleData = new()
{
company = "This old company",
description = "this old description",
name = "This old name",
url = realServerData.url,
version = "0.0.123",
};
// Mutate with "fake" data to simulate a stale account data
user.Account.serverInfo = staleData;
user.Account.id = null!; //force re-generate id
Assert.Equal(realAccountId, user.Account.id); //account id should not change since we didn't change server url
await _sut.UpdateAccountInMemory(user.Account);
Assert.Equal(realServerData.company, user.Account.serverInfo.company);
Assert.Equal(realServerData.description, user.Account.serverInfo.description);
Assert.Equal(realServerData.name, user.Account.serverInfo.name);
Assert.Equal(realServerData.url, user.Account.serverInfo.url);
Assert.Equal(realServerData.version, user.Account.serverInfo.version);
Assert.Equal(realAccountId, user.Account.id);
}
}
@@ -1,84 +0,0 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Integration.Credentials;
[Collection(nameof(RequiresAuthFlowPort))]
public class AuthFlowExceptionalTests : IAsyncLifetime
{
private IAuthFlow _authFlow;
private IClient _client;
private readonly Uri _url = AuthApp.ConnectorsV3.CallbackUrl;
[Fact]
public async Task GetRefreshToken_Cancellation()
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
_ = await _authFlow.GetRefreshedToken(
_client.Account.refreshToken,
_client.ServerUrl,
Fixtures.TestAuthApp,
new(true)
)
);
}
[Fact]
public async Task GetRefreshToken_UnknownApp()
{
//interestingly, the server responds with a 401 Unauthorized despite internally being a bad request
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
_ = await _authFlow.GetRefreshedToken(
_client.Account.refreshToken,
_client.ServerUrl,
new()
{
AppId = "doesn't exist",
AppSecret = "doesn't exist",
CallbackUrl = new("invalid://localhost"),
},
CancellationToken.None
)
);
}
[Fact]
public async Task GetRefreshToken_NullRefreshToken()
{
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
_ = await _authFlow.GetRefreshedToken(null, _client.ServerUrl, AuthApp.ConnectorsV3, CancellationToken.None)
);
}
[Fact]
public async Task SimultaneousListeners_SamePort_OneFails()
{
using CancellationTokenSource ct = new();
var task1 = AuthFlow.RunListener(_url, ct.Token);
await Task.Delay(50, CancellationToken.None);
await Assert.ThrowsAsync<HttpListenerException>(async () => await AuthFlow.RunListener(_url, ct.Token));
if (task1.IsCompleted)
{
throw new InvalidOperationException("Was expecting task to still be running", task1.Exception);
}
await ct.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await task1);
}
public async Task InitializeAsync()
{
_authFlow = Fixtures.ServiceProvider.GetRequiredService<IAuthFlow>();
_client = await Fixtures.SeedUserWithClient();
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
}
@@ -1,94 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Integration.Credentials;
[Collection(nameof(RequiresAuthFlowPort))]
public sealed class AuthFlowTests
{
private readonly IAuthFlow _authFlow;
private readonly Uri _url = AuthApp.ConnectorsV3.CallbackUrl;
public AuthFlowTests()
{
_authFlow = Fixtures.ServiceProvider.GetRequiredService<IAuthFlow>();
}
[Fact]
public async Task RunListener_ReturnsAccessCode_WhenQueryContainsAccessCode()
{
var listenerTask = AuthFlow.RunListener(_url, CancellationToken.None);
using var client = new HttpClient();
const string EXPECTED_ACCESS_CODE = "abcdef123456";
var response = await client.GetAsync(new Uri(_url, $"?access_code={EXPECTED_ACCESS_CODE}"));
response.EnsureSuccessStatusCode();
string result = await listenerTask;
Assert.Equal(EXPECTED_ACCESS_CODE, result);
}
[Fact]
public async Task RunListener_Throws_InvalidAccessCode()
{
var listenerTask = AuthFlow.RunListener(_url, CancellationToken.None);
using var client = new HttpClient();
var response = await client.GetAsync(new Uri(_url, ""));
response.EnsureSuccessStatusCode();
await Assert.ThrowsAsync<AuthFlowException>(async () =>
{
_ = await listenerTask;
});
}
[Fact]
public async Task RunListener_Throws_Cancellation()
{
using CancellationTokenSource cancellationTokenSource = new();
var listenerTask = AuthFlow.RunListener(_url, cancellationTokenSource.Token);
await cancellationTokenSource.CancelAsync();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
_ = await listenerTask;
});
}
[Theory]
[InlineData(0.1)]
[InlineData(1)]
[InlineData(5)]
public async Task RunListener_Timeout(double timeS)
{
await Assert.ThrowsAsync<TimeoutException>(async () =>
{
_ = await _authFlow.RunListenerWithTimeout(_url, TimeSpan.FromSeconds(timeS), CancellationToken.None);
});
}
[Fact]
public async Task CanGetRefreshToken()
{
using var user = await Fixtures.SeedUserWithClient();
var tokenExchange = await _authFlow.GetRefreshedToken(
user.Account.refreshToken,
user.ServerUrl,
Fixtures.TestAuthApp,
CancellationToken.None
);
Assert.NotNull(tokenExchange.token);
Assert.NotNull(tokenExchange.refreshToken);
user.Account.token = tokenExchange.token;
user.Account.refreshToken = tokenExchange.refreshToken;
var apiTest = await user.ActiveUser.Get();
Assert.NotNull(apiTest);
}
}
@@ -17,22 +17,12 @@ using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
[assembly: AssemblyTrait("Category", "Integration")]
#if DEBUG
[assembly: CollectionBehavior(MaxParallelThreads = 8)]
#endif
namespace Speckle.Sdk.Tests.Integration;
public static class Fixtures
{
public static readonly ServerInfo Server = new() { url = "http://localhost:3000", name = "Docker Server" };
public static readonly AuthApp TestAuthApp = new()
{
AppId = "spklwebapp",
AppSecret = "spklwebapp",
CallbackUrl = new Uri("invaid://localhost"),
};
public static IServiceProvider ServiceProvider { get; set; }
static Fixtures()
@@ -100,8 +90,8 @@ public static class Fixtures
Dictionary<string, string> tokenBody = new()
{
["accessCode"] = accessCode,
["appId"] = TestAuthApp.AppId,
["appSecret"] = TestAuthApp.AppSecret,
["appId"] = "spklwebapp",
["appSecret"] = "spklwebapp",
["challenge"] = "challengingchallenge",
};
@@ -114,11 +104,8 @@ public static class Fixtures
);
var token = deserialised.NotNull()["token"].NotNull();
var refreshToken = deserialised.NotNull()["refreshToken"].NotNull();
return await ServiceProvider
.GetRequiredService<IAccountFactory>()
.CreateAccount(new(Server.url), token, refreshToken);
return await ServiceProvider.GetRequiredService<IAccountFactory>().CreateAccount(new(Server.url), token);
}
public static Base GenerateSimpleObject()
@@ -1,6 +1,8 @@
using FluentAssertions;
using System.Reflection;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Host;
using Speckle.Sdk.Models;
using Speckle.Sdk.Transports;
@@ -14,7 +16,8 @@ public class MemoryTransportTests : IDisposable
public MemoryTransportTests()
{
CleanData();
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}

Some files were not shown because too many files have changed in this diff Show More