Compare commits

...

36 Commits

Author SHA1 Message Date
Adam Hathcock 4f7b470901 Merge pull request #329 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
SDK 3.4 release (do not squash)
2025-06-05 13:59:43 +01:00
Jedd Morgan 8c6426d617 Updated one more usage of newtonsoft (#332)
Co-authored-by: Adam Hathcock <adamhathcock@users.noreply.github.com>
2025-06-05 10:39:16 +00:00
Adam Hathcock 5562ce1a2d Add detail to the message in logs (#331) 2025-06-05 09:54:36 +01:00
Jedd Morgan 7019b8d7c6 Fix(automate): STJ camel casing (#330)
* stj required

* More requireds

* Use JSON serializer settings for camel case rule

* readonly

* static naming
2025-06-04 15:56:50 +00:00
Adam Hathcock 58a0326060 Receive should sort the root closures to see a speed improvement (#311)
* Maybe really fixes closures

* fornat

* add ai generated tests

* fix tests

* fix tests

* added test with correct number of closures?

* closures are self contained.  don't increment on attached properties

* format

* MergeClosure should reuse if exists, not just set

* Add generated tests and sort the parser correctly when using get closures

* add extra options to not sort and make sorting default for receive

* hide private method
2025-06-04 13:54:26 +00:00
Adam Hathcock 55f83919d1 Don't log aggregate exceptions that only contain cancelled exceptions (#326)
* Don't log aggregate exceptions that only contain cancelled exceptions

* check if all are not cancelled
2025-06-04 12:59:51 +00:00
Adam Hathcock 46c57b18be Cancel before kicking off tasks to ensure they throw (#328) 2025-06-04 13:49:03 +01:00
Adam Hathcock 7b5ada57cd Changing uploaded to uploading to better show progress than just rate (#321) 2025-06-03 16:27:02 +00:00
Adam Hathcock e29b27bcd3 Add some debugging stats about the sent or received payloads to add debugging when things are massive (#319) 2025-06-03 14:15:37 +00:00
Jedd Morgan ff1b688321 refactor(accountmanager): Refactor account manager for automate (#320)
* First pass refactor account manager

* Use GraphQLClient factory in account manager also

* update account references

* cleanup

* Added test + comments

* more docstrings

* More tests
2025-06-03 12:54:44 +00:00
Adam Hathcock 0be143d391 Make nulls appear as soon as possible (#324) 2025-06-03 12:09:55 +00:00
Adam Hathcock c0a66a297a Add info to send/receives for debugging (#325) 2025-06-03 12:48:05 +01:00
Adam Hathcock aad604e819 Merge pull request #323 from specklesystems/main-dev
Main to dev
2025-06-03 09:41:16 +01:00
Adam Hathcock 48313cb082 Merge remote-tracking branch 'origin/dev' into main-dev 2025-06-02 15:50:19 +01:00
kekesidavid 0361a6e5b7 Merge pull request #318 from specklesystems/david/move-text-class-update-pr-to-main
.NET Build and Publish / build (push) Has been cancelled
fix (sdk) Text class updates: removed origin from Text class and added screenAligned prop
2025-06-02 09:22:57 +02:00
David Kekesi 0e97782c29 fixed comment 2025-06-02 09:12:12 +02:00
David Kekesi 298dedc3af text class update pr moved to main 2025-05-30 19:01:02 +02:00
Adam Hathcock 422403d499 fix concurrent tests 2 - This should wait for cancellation to happen (#316)
* Fix test to be deterministic

* This should wait for cancellation to happen

* update csharpier
2025-05-30 14:17:04 +00:00
Adam Hathcock b652ffa773 Fix test to be deterministic (#315) 2025-05-30 12:30:57 +00:00
Adam Hathcock 68ace02e2d Use custom md5 just for account/user IDs, not anything real (#314)
* Use custom md5 just for account/user IDs, not anything real

* test fixes

* To lower and upper as needed
2025-05-30 12:15:44 +00:00
Jedd Morgan b6be7a351f feat(automate): Add automate SDK (#313)
* First pass

* First pass adding service registraiton

* Finished up service registration

* Json exception

* Moved to the right place

* Fixed tests

* Added some missing docs strings

* Reflecting Gergo's specklepy changes

* Correct the DI registration

* Readme

* No warn beta packages

* Format

* renamed misleading variable

* Fixed lock files

* Disable SQLite for automate
2025-05-30 13:05:14 +01:00
Adam Hathcock 1039e75d0c Calculate closures correctly (#309)
* Maybe really fixes closures

* fornat

* add ai generated tests

* fix tests

* fix tests

* added test with correct number of closures?

* closures are self contained.  don't increment on attached properties

* format

* MergeClosure should reuse if exists, not just set

* add not null on a method
2025-05-27 14:05:10 +01:00
Jedd Morgan 0f8752d5ab feat(api): Improvements to GrahpQL error handling (#304)
* Graphql extras

* extra server resource test

* usings

* Fixed test
2025-05-20 12:44:23 +00:00
Adam Hathcock 64a93345d6 Merge pull request #310 from specklesystems/main
Main to dev (no squash)
2025-05-19 12:00:56 +01:00
Jedd Morgan efc38d8f5c Added Workspace project visibility (#307)
.NET Build and Publish / build (push) Has been cancelled
2025-05-14 21:37:39 +03:00
Jedd Morgan e3ca75abe1 removed csharp 4.7 dependency from .net8 target (#306) 2025-05-14 13:12:37 +01:00
Adam Hathcock b479d368ad SLNX added, only supported in IDEs and .NET 9+ (#294) 2025-05-14 09:35:43 +00:00
Adam Hathcock 8d3985f93b Merge pull request #305 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Dev to Main
2025-05-14 10:19:51 +01:00
Jedd Morgan 915a18dc98 Merge pull request #303 from specklesystems/jrm/main-dev
Main -> Dev
2025-05-13 11:38:56 +01:00
Jedd Morgan 5fcb3223d6 Merge branch 'dev' into jrm/main-dev 2025-05-13 11:28:13 +01:00
Adam Hathcock 21851c06d2 Use WhenAll instead of WhenAny and avoid manual task exception processing (#300)
* No reason to process exceptions manually

* formatting

* use a pool for gathering child task results

* Use a smaller whenany with a cancellation

* formatting
2025-05-13 10:00:53 +01:00
Adam Hathcock 3a9a633d30 Add empty DataObject tests (#301)
* Add empty DataObject tests

* format
2025-05-09 14:51:03 +00:00
Jedd Morgan 7f092d529c feat(api): Add ActiveUserResource.GetProjectsWithPermissions (#299)
.NET Build and Publish / build (push) Has been cancelled
* Fixed Mistakes (#296)

* Added extra permission checks (#297)

* Add extra query for project with permissions

---------

Co-authored-by: Adam Hathcock <adamhathcock@users.noreply.github.com>
Co-authored-by: Adam Hathcock <adam@hathcock.uk>
2025-05-08 15:11:53 +00:00
Jedd Morgan a4f0e0e4aa Added extra permission checks (#297)
.NET Build and Publish / build (push) Has been cancelled
2025-05-08 09:25:08 +00:00
Jedd Morgan 227729a0df Fixed Mistakes (#296)
.NET Build and Publish / build (push) Has been cancelled
2025-05-07 17:01:56 +00:00
Jedd Morgan 178085f3f8 dev -> main (#295)
.NET Build and Publish / build (push) Has been cancelled
* Sanitize test references

* add registration tests to SDK and ignore classes that can't be made

* add additional test

* chore(dev) Update to csharpier 1.0 (#284)

* Update to csharpier 1.0

* Fix check and nowarn

* format

* Update dependencies (#285)

* Added extra test for GetMembers (#290)

* Added extra test for GetMembers

* fixed tests

* verify

* Format

* Run the module init on unit tests and make it json

* update deps

* gitignore support was disabled in csharpier 1.0.1

---------

Co-authored-by: Adam Hathcock <adam@hathcock.uk>

* Add workspaces queries (#291)

* Add workspaces queries

* Format

* extra tweaks

* init speckle verify

* Add workspace creation state

* Add workspace creation test

* test exceptional cases

* GetActiveWorkspace tests

* fixed test

---------

Co-authored-by: Adam Hathcock <adamhathcock@users.noreply.github.com>
Co-authored-by: Adam Hathcock <adam@hathcock.uk>
2025-05-06 18:02:59 +03:00
141 changed files with 5092 additions and 675 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.0.1",
"version": "1.0.2",
"commands": [
"csharpier"
],
+14
View File
@@ -0,0 +1,14 @@
# Coding standards, domain knowledge, and preferences that AI should follow
## C# Coding Standards
- Use the csharpier formatter for formatting C# code.
- Use the .editorconfig file for code style settings.
- Always use `var` when the type is obvious from the right side of the assignment.
- Always add braces for `if`, `else`, `for`, `foreach`, `while`, and `do` statements, even if they are single-line statements.
## Testing
- Use xUnit for unit testing.
- Use FluentAssertions for assertions in tests.
- Use Moq for mocking dependencies in tests.
+22
View File
@@ -0,0 +1,22 @@
# Git Commit Instructions
To ensure high-quality and consistent commits, please follow these guidelines:
1. **Format your code**
- Run the `csharpier` formatter on all C# files before committing.
- Ensure your code adheres to the `.editorconfig` settings.
2. **Write clear commit messages**
- Use the present tense ("Add feature" not "Added feature").
- Start with a short summary (max 72 characters), followed by a blank line and a detailed description if necessary.
3. **Test your changes**
- Run all unit tests before committing.
- Add or update xUnit tests as needed.
- Use FluentAssertions for assertions and Moq for mocking in tests.
4. **Review your changes**
- Double-check for accidental debug code or commented-out code.
- Ensure only relevant files are staged.
Thank you for helping maintain code quality!
+3
View File
@@ -18,6 +18,7 @@
<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" />
<PackageVersion Include="Polly" Version="7.2.3" />
<PackageVersion Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
@@ -26,9 +27,11 @@
<PackageVersion Include="Speckle.Newtonsoft.Json" Version="13.0.2" />
<PackageVersion Include="Speckle.DoubleNumerics" Version="4.1.0" />
<PackageVersion Include="SimpleExec" Version="12.0.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.4" />
<PackageVersion Include="Verify.Quibble" Version="2.1.1" />
<PackageVersion Include="Verify.Xunit" Version="29.4.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.assert" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
+2 -1
View File
@@ -18,8 +18,9 @@ Speckle | Sharp | SDK
This repo is the home of our next-generation Speckle .NET SDK. It uses .NET Standard 2.0 and has been tested on Windows and MacOS.
- **SDK**
- [`Speckle.Sdk`](https://github.com/specklesystems/speckle-sharp-sdk/tree/dev/src/Speckle.Sdk): Transports, serialization, API wrappers, and logging.
- [`Speckle.Sdk`](https://github.com/specklesystems/speckle-sharp-sdk/tree/dev/src/Speckle.Sdk): Send/Receive operations, Serialization, API wrappers, and more!.
- [`Speckle.Sdk.Dependencies`](https://github.com/specklesystems/speckle-sharp-sdk/tree/dev/src/Speckle.Sdk.Dependencies): Dependencies and code that shouldn't cause conflicts in Host Apps. This uses [IL Repack](https://github.com/gluck/il-repack) to merge together and interalized only to be used by Speckle.
- [`Speckle.Automate.Sdk`](https://github.com/specklesystems/speckle-sharp-sdk/tree/dev/src/Speckle.Automate.Sdk): .NET SDK for [Speckle Automate](https://www.speckle.systems/product/automate)
- **Speckle Objects**
- [`Speckle.Objects`](https://github.com/specklesystems/speckle-sharp-sdk/tree/dev/src/Speckle.Objects): The Speckle Objects classes used for conversions.
- **Tests**
+17
View File
@@ -9,6 +9,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Objects.Tests.Unit", "tests\Speckle.Objects.Tests.Unit\Speckle.Objects.Tests.Unit.csproj", "{A0338FC0-3011-498F-AD09-01230FABD3ED}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5CB96C27-FC5B-4A41-86B6-951AF99B8116}"
ProjectSection(SolutionItems) = preProject
src\graphql.config.yml = src\graphql.config.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{35047EE7-AD1D-4741-80A7-8F0E874718E9}"
EndProject
@@ -51,6 +54,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Sdk.Testing", "test
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "performance", "performance", "{FFB07238-87E8-463A-AA39-3B38AAAA94C1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Automate.Sdk", "src\Speckle.Automate.Sdk\Speckle.Automate.Sdk.csproj", "{4EB20EFA-5A38-415E-B3FD-29CA3ACD1EF5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Automate.Sdk.Integration", "tests\Speckle.Automate.Sdk.Integration\Speckle.Automate.Sdk.Integration.csproj", "{B6129DC3-F285-4E5F-85E2-6D2533A4005E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -105,6 +112,14 @@ Global
{7B617C0D-2354-415C-993C-5071D4113E27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B617C0D-2354-415C-993C-5071D4113E27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B617C0D-2354-415C-993C-5071D4113E27}.Release|Any CPU.Build.0 = Release|Any CPU
{4EB20EFA-5A38-415E-B3FD-29CA3ACD1EF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4EB20EFA-5A38-415E-B3FD-29CA3ACD1EF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4EB20EFA-5A38-415E-B3FD-29CA3ACD1EF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4EB20EFA-5A38-415E-B3FD-29CA3ACD1EF5}.Release|Any CPU.Build.0 = Release|Any CPU
{B6129DC3-F285-4E5F-85E2-6D2533A4005E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B6129DC3-F285-4E5F-85E2-6D2533A4005E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B6129DC3-F285-4E5F-85E2-6D2533A4005E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B6129DC3-F285-4E5F-85E2-6D2533A4005E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A413E196-3696-4F48-B635-04B5F76BF9C9} = {5CB96C27-FC5B-4A41-86B6-951AF99B8116}
@@ -119,5 +134,7 @@ Global
{7B617C0D-2354-415C-993C-5071D4113E27} = {35047EE7-AD1D-4741-80A7-8F0E874718E9}
{FF922B6D-D416-4348-8CB8-0C8B28691070} = {FFB07238-87E8-463A-AA39-3B38AAAA94C1}
{870E3396-E6F7-43AE-B120-E651FA4F46BD} = {FFB07238-87E8-463A-AA39-3B38AAAA94C1}
{4EB20EFA-5A38-415E-B3FD-29CA3ACD1EF5} = {5CB96C27-FC5B-4A41-86B6-951AF99B8116}
{B6129DC3-F285-4E5F-85E2-6D2533A4005E} = {35047EE7-AD1D-4741-80A7-8F0E874718E9}
EndGlobalSection
EndGlobal
+46
View File
@@ -0,0 +1,46 @@
<Solution>
<Folder Name="/build/">
<Project Path="build/build.csproj" />
</Folder>
<Folder Name="/config/">
<File Path=".config/dotnet-tools.json" />
<File Path=".csharpierrc.yaml" />
<File Path=".editorconfig" />
<File Path="CodeMetricsConfig.txt" />
<File Path="Directory.Build.props" />
<File Path="Directory.Build.Targets" />
<File Path="Directory.Packages.props" />
<File Path="docker-compose.yml" />
<File Path="GitVersion.yml" />
<File Path="global.json" />
<File Path="README.md" />
<File Path=".github\copilot-instructions.md" />
<File Path=".github\git-commit-instructions.md" />
</Folder>
<Folder Name="/config/workflows/">
<File Path=".github/workflows/pr.yml" />
<File Path=".github/workflows/release.yml" />
</Folder>
<Folder Name="/performance/">
<Project Path="tests/Speckle.Sdk.Serialization.Testing/Speckle.Sdk.Serialization.Testing.csproj" />
<Project Path="tests/Speckle.Sdk.Tests.Performance/Speckle.Sdk.Tests.Performance.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Speckle.Automate.Sdk/Speckle.Automate.Sdk.csproj" />
<Project Path="src/Speckle.Objects/Speckle.Objects.csproj" />
<Project Path="src/Speckle.Sdk.Dependencies/Speckle.Sdk.Dependencies.csproj" />
<Project Path="src/Speckle.Sdk/Speckle.Sdk.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Speckle.Sdk.Testing/Speckle.Sdk.Testing.csproj" />
</Folder>
<Folder Name="/tests/integration/">
<Project Path="tests/Speckle.Automate.Sdk.Integration/Speckle.Automate.Sdk.Integration.csproj" />
<Project Path="tests/Speckle.Sdk.Tests.Integration/Speckle.Sdk.Tests.Integration.csproj" />
</Folder>
<Folder Name="/tests/unit/">
<Project Path="tests/Speckle.Objects.Tests.Unit/Speckle.Objects.Tests.Unit.csproj" />
<Project Path="tests/Speckle.Sdk.Serialization.Tests/Speckle.Sdk.Serialization.Tests.csproj" />
<Project Path="tests/Speckle.Sdk.Tests.Unit/Speckle.Sdk.Tests.Unit.csproj" />
</Folder>
</Solution>
+2
View File
@@ -100,6 +100,8 @@ services:
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
LOG_PRETTY: "true"
networks:
default:
@@ -0,0 +1,61 @@
using System.Diagnostics;
using System.Text.Json;
using GraphQL;
using GraphQL.Client.Http;
using Speckle.Automate.Sdk.Schema;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Api;
using Speckle.Sdk.Credentials;
namespace Speckle.Automate.Sdk;
[GenerateAutoInterface(VisibilityModifier = "public")]
internal sealed class AutomationContextFactory(
IClientFactory clientFactory,
IAccountFactory accountFactory,
IOperations operations
) : IAutomationContextFactory
{
private static readonly JsonSerializerOptions s_jsonSerializerSettings = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <inheritdoc cref="Initialize(AutomationRunData, string)"/>
public async Task<IAutomationContext> Initialize(string automationRunData, string speckleToken)
{
var runData = JsonSerializer.Deserialize<AutomationRunData>(automationRunData, s_jsonSerializerSettings);
return await Initialize(runData, speckleToken).ConfigureAwait(false);
}
/// <inheritdoc cref="Initialize(AutomationRunData, Account)"/>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="Speckle.Sdk.Api.GraphQL.GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<IAutomationContext> Initialize(AutomationRunData automationRunData, string speckleToken)
{
Account account = await accountFactory
.CreateAccount(automationRunData.SpeckleServerUrl, speckleToken)
.ConfigureAwait(false);
return Initialize(automationRunData, account);
}
/// <summary>
/// Creates an <see cref="AutomationContext"/> from the provided data
/// </summary>
public IAutomationContext Initialize(AutomationRunData automationRunData, Account account)
{
IClient client = clientFactory.Create(account);
Stopwatch initTime = Stopwatch.StartNew();
return new AutomationContext(operations)
{
AutomationRunData = automationRunData,
SpeckleClient = client,
_speckleToken = account.token,
_initTime = initTime,
AutomationResult = new AutomationResult(),
};
}
}
@@ -0,0 +1,375 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using GraphQL;
using Speckle.Automate.Sdk.Schema;
using Speckle.Automate.Sdk.Schema.Triggers;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Common;
using Speckle.Sdk.Models;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Automate.Sdk;
[GenerateAutoInterface(VisibilityModifier = "public")]
internal sealed class AutomationContext(IOperations operations) : IAutomationContext
{
public AutomationRunData AutomationRunData { get; set; }
public string? ContextView
{
get => AutomationResult.ResultView;
private set => AutomationResult.ResultView = value;
}
public required IClient SpeckleClient { get; init; }
public required string _speckleToken { get; init; }
// added for performance measuring
public required Stopwatch _initTime { get; init; }
public required AutomationResult AutomationResult { get; init; }
public string RunStatus => AutomationResult.RunStatus;
public string? StatusMessage => AutomationResult.StatusMessage;
public TimeSpan Elapsed => _initTime.Elapsed;
/// <summary>
/// Receive version for automation.
/// </summary>
/// <returns> Commit object. </returns>
/// <exception cref="SpeckleException">Throws if commit object is null.</exception>
public async Task<Base> ReceiveVersion(CancellationToken cancellationToken = default)
{
// TODO: this is a quick hack to keep implementation consistency. Move to proper receive many versions
if (AutomationRunData.Triggers.First() is not VersionCreationTrigger trigger)
{
throw new SpeckleException("Processed automation run data without any triggers");
}
var versionId = trigger.Payload.VersionId;
var version = await SpeckleClient
.Version.Get(versionId, AutomationRunData.ProjectId, cancellationToken)
.ConfigureAwait(false);
if (version.referencedObject == null)
{
throw new SpeckleException(
"The requested speckle model version has exceeded workspace version history limits or the reference object is otherwise null"
);
}
Base? rootObject = await operations
.Receive2(
SpeckleClient.ServerUrl,
AutomationRunData.ProjectId,
version.referencedObject,
SpeckleClient.Account.token,
null,
cancellationToken
)
.ConfigureAwait(false);
Console.WriteLine($"It took {Elapsed.TotalSeconds} seconds to receive the speckle version {versionId}");
return rootObject;
}
/// <summary>
/// Creates new version in the project.
/// </summary>
/// <param name="rootObject">Object to send to project.</param>
/// <param name="model">The model to create the version under</param>
/// <param name="versionMessage">Version message.</param>
/// <param name="cancellationToken">Version message.</param>
/// <returns>Version id.</returns>
/// <exception cref="SpeckleException"> Throws if given model name is as same as with model name in automation run data.
/// The reason is to prevent circular run loop in automation.</exception>
public async Task<Version> CreateNewVersionInProject(
Base rootObject,
Model model,
string versionMessage = "",
CancellationToken cancellationToken = default
)
{
// Confirm target branch is not the same as source branch
foreach (var trigger in AutomationRunData.Triggers)
{
if (trigger.Payload.ModelId == model.id)
{
throw new SpeckleException(
$"""
The target model: {model.name} ({model.id}) cannot match the model
that triggered this automation:
{trigger.Payload.ModelId}
"""
);
}
}
var (rootObjectId, _) = await operations
.Send2(
SpeckleClient.ServerUrl,
AutomationRunData.ProjectId,
SpeckleClient.Account.token,
rootObject,
null,
cancellationToken
)
.ConfigureAwait(false);
var newVersion = await SpeckleClient
.Version.Create(
new CreateVersionInput(rootObjectId, model.id, AutomationRunData.ProjectId, versionMessage),
cancellationToken
)
.ConfigureAwait(false);
AutomationResult.ResultVersions.Add(newVersion.id);
return newVersion;
}
/// <summary>
/// Set context view for automation result view.
/// </summary>
/// <param name="resourceIds"> Resource contexts to bind into view.</param>
/// <param name="includeSourceModelVersion"> Whether bind source version into result view or not.</param>
/// <exception cref="SpeckleException"> Throws if there is no context to create result view.</exception>
[MemberNotNull(nameof(ContextView))]
[AutoInterfaceIgnore] //Ignore so we can explicitly add the MemberNotNull attibute to the interface method
public void SetContextView(IReadOnlyCollection<string>? resourceIds = null, bool includeSourceModelVersion = true)
{
List<string> linkResources = new();
if (includeSourceModelVersion)
{
foreach (var trigger in AutomationRunData.Triggers)
{
switch (trigger)
{
case VersionCreationTrigger versionCreationTrigger:
{
linkResources.Add($"{versionCreationTrigger.Payload.ModelId}@{versionCreationTrigger.Payload.VersionId}");
break;
}
default:
{
throw new SpeckleException($"Could not link resource specified by {trigger.TriggerType} trigger");
}
}
}
}
if (resourceIds is not null)
{
linkResources.AddRange(resourceIds);
}
if (linkResources.Count == 0)
{
throw new SpeckleException("We do not have enough resource ids to compose a context view");
}
ContextView = $"/projects/{AutomationRunData.ProjectId}/models/{string.Join(",", linkResources)}";
}
public async Task ReportRunStatus()
{
ObjectResults? objectResults = null;
if (RunStatus is "SUCCEEDED" or "FAILED")
{
objectResults = new ObjectResults
{
Version = 2,
Values = new ObjectResultValues
{
BlobIds = AutomationResult.Blobs,
ObjectResults = AutomationResult.ObjectResults,
},
};
}
//language=graphql
const string QUERY = """
mutation AutomateFunctionRunStatusReport($projectId: String!, $functionRunId: String!, $status: AutomateRunStatus!, $statusMessage: String, $results: JSONObject, $contextView: String) {
automateFunctionRunStatusReport(
input: {projectId: $projectId, functionRunId: $functionRunId, status: $status, statusMessage: $statusMessage, contextView: $contextView, results: $results}
)
}
""";
GraphQLRequest request = new()
{
Query = QUERY,
Variables = new
{
projectId = AutomationRunData.ProjectId,
functionRunId = AutomationRunData.FunctionRunId,
status = RunStatus,
statusMessage = AutomationResult.StatusMessage,
contextView = ContextView,
results = objectResults,
},
};
await SpeckleClient.ExecuteGraphQLRequest<Dictionary<string, object>>(request).ConfigureAwait(false);
}
/// <summary>
/// Stores result file in automation result. It will be available to download on Frontend if added.
/// </summary>
/// <param name="filePath"> File path to store.</param>
/// <exception cref="FileNotFoundException"> Throws if given file path is not exist.</exception>
/// <exception cref="SpeckleException"> Throws if upload requests return no result.</exception>
public async Task StoreFileResult(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException("The given file path doesn't exist", fileName: filePath);
}
using MultipartFormDataContent formData = new();
FileStream fileStream = new(filePath, FileMode.Open, FileAccess.Read);
using StreamContent streamContent = new(fileStream);
formData.Add(streamContent, "files", Path.GetFileName(filePath));
HttpResponseMessage request = await SpeckleClient
.GQLClient.HttpClient.PostAsync(
new Uri(AutomationRunData.SpeckleServerUrl, $"api/stream/{AutomationRunData.ProjectId}/blob"),
formData
)
.ConfigureAwait(false);
request.EnsureSuccessStatusCode();
string responseString = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
Console.WriteLine("RESPONSE - " + responseString);
BlobUploadResponse uploadResponse = JsonConvert.DeserializeObject<BlobUploadResponse>(responseString);
if (uploadResponse.UploadResults.Count != 1)
{
throw new SpeckleException("Expected one upload result.");
}
AutomationResult.Blobs.AddRange(uploadResponse.UploadResults.Select(r => r.BlobId));
}
private void MarkRun(AutomationStatus status, string? statusMessage)
{
double duration = Elapsed.TotalSeconds;
AutomationResult.StatusMessage = statusMessage;
string statusValue = AutomationStatusMapping.Get(status);
AutomationResult.RunStatus = statusValue;
AutomationResult.Elapsed = duration;
string msg = $"Automation run {statusValue} after {duration} seconds.";
if (statusMessage is not null)
{
msg += $"\n{statusMessage}";
}
Console.WriteLine(msg);
}
public void MarkRunFailed(string statusMessage) => MarkRun(AutomationStatus.Failed, statusMessage);
public void MarkRunException(string? statusMessage) => MarkRun(AutomationStatus.Exception, statusMessage);
public void MarkRunSuccess(string? statusMessage) => MarkRun(AutomationStatus.Succeeded, statusMessage);
/// <summary>
/// Add a new error case to the run results.
/// </summary>
/// <param name="category">A short tag for the error type.</param>
/// <param name="affectedObjects">A list of objects that are causing the result.</param>
/// <param name="message">Optional error message.</param>
/// <param name="metadata">User provided metadata key value pairs.</param>
/// <param name="visualOverrides">Case specific 3D visual overrides.</param>
/// <exception cref="ArgumentException">Throws if the provided <paramref name="affectedObjects"/> input is empty.</exception>
public void AttachErrorToObjects(
string category,
IReadOnlyCollection<Base> affectedObjects,
string? message = null,
Dictionary<string, object>? metadata = null,
Dictionary<string, object>? visualOverrides = null
) => AttachResultToObjects(ObjectResultLevel.Error, category, affectedObjects, message, metadata, visualOverrides);
/// <summary>
/// Add a new warning case to the run results.
/// </summary>
/// <inheritdoc cref="AttachErrorToObjects"/>
public void AttachWarningToObjects(
string category,
IReadOnlyCollection<Base> affectedObjects,
string? message = null,
Dictionary<string, object>? metadata = null,
Dictionary<string, object>? visualOverrides = null
) => AttachResultToObjects(ObjectResultLevel.Warning, category, affectedObjects, message, metadata, visualOverrides);
/// <summary>
/// Add a new info case to the run results.
/// </summary>
/// <inheritdoc cref="AttachErrorToObjects"/>
public void AttachInfoToObjects(
string category,
IReadOnlyCollection<Base> affectedObjects,
string? message = null,
Dictionary<string, object>? metadata = null,
Dictionary<string, object>? visualOverrides = null
) => AttachResultToObjects(ObjectResultLevel.Info, category, affectedObjects, message, metadata, visualOverrides);
/// <summary>
/// Add a new success case to the run results.
/// </summary>
/// <inheritdoc cref="AttachErrorToObjects"/>
public void AttachSuccessToObjects(
string category,
IReadOnlyCollection<Base> affectedObjects,
string? message = null,
Dictionary<string, object>? metadata = null,
Dictionary<string, object>? visualOverrides = null
) => AttachResultToObjects(ObjectResultLevel.Success, category, affectedObjects, message, metadata, visualOverrides);
/// <summary>
/// Add a new case to the run results.
/// </summary>
/// <param name="level">The level assigned to this result.</param>
/// <inheritdoc cref="AttachErrorToObjects"/>
public void AttachResultToObjects(
ObjectResultLevel level,
string category,
IReadOnlyCollection<Base> affectedObjects,
string? message = null,
Dictionary<string, object>? metadata = null,
Dictionary<string, object>? visualOverrides = null
)
{
if (affectedObjects.Count == 0)
{
throw new ArgumentException($"Need at least one affected object to report a(n) {level}");
}
string levelString = ObjectResultLevelMapping.Get(level);
Dictionary<string, string?> ids = affectedObjects.ToDictionary(
x => x.id.NotNull($"You can only attach {level} results to objects with an id"),
x => x.applicationId
);
Console.WriteLine($"Created new {levelString.ToUpper()} category: {category} caused by: {message}");
ResultCase resultCase = new()
{
Category = category,
Level = levelString,
ObjectAppIds = ids,
Message = message,
Metadata = metadata,
VisualOverrides = visualOverrides,
};
AutomationResult.ObjectResults.Add(resultCase);
}
}
public partial interface IAutomationContext
{
[MemberNotNull(nameof(ContextView))]
public void SetContextView(IReadOnlyCollection<string>? resourceIds = null, bool includeSourceModelVersion = true);
}
@@ -0,0 +1,7 @@
namespace Speckle.Automate.Sdk.DataAnnotations;
/// <summary>
/// If specified, the given function input will be redacted in all contexts.
/// </summary>
[AttributeUsage(AttributeTargets.All)]
public sealed class SecretAttribute : Attribute { }
+195
View File
@@ -0,0 +1,195 @@
using System.CommandLine;
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using Newtonsoft.Json.Schema;
using Newtonsoft.Json.Schema.Generation;
using Newtonsoft.Json.Serialization;
using Speckle.Automate.Sdk.DataAnnotations;
using Speckle.Automate.Sdk.Schema;
using Speckle.InterfaceGenerator;
using Speckle.Sdk;
namespace Speckle.Automate.Sdk;
/// <summary>
/// Provides mechanisms to execute any function that conforms to the AutomateFunction "interface"
/// </summary>
[GenerateAutoInterface(VisibilityModifier = "public")]
internal class AutomationRunner(IAutomationContextFactory contextFactory) : IAutomationRunner
{
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public async Task<IAutomationContext> RunFunction<TInput>(
Func<IAutomationContext, TInput, Task> automateFunction,
AutomationRunData automationRunData,
string speckleToken,
TInput inputs
)
where TInput : struct
{
var automationContext = await contextFactory.Initialize(automationRunData, speckleToken).ConfigureAwait(false);
try
{
await automateFunction.Invoke(automationContext, inputs).ConfigureAwait(false);
if (automationContext.RunStatus is not ("FAILED" or "SUCCEEDED"))
{
automationContext.MarkRunSuccess(
"WARNING: Automate assumed a success status, but it was not marked as so by the function."
);
}
}
catch (Exception ex) when (!ex.IsFatal())
{
Console.WriteLine(ex.ToString());
automationContext.MarkRunException("Function error. Check the automation run logs for details.");
}
finally
{
if (automationContext.ContextView is null)
{
automationContext.SetContextView();
}
await automationContext.ReportRunStatus().ConfigureAwait(false);
}
return automationContext;
}
public async Task<IAutomationContext> RunFunction(
Func<IAutomationContext, Task> automateFunction,
AutomationRunData automationRunData,
string speckleToken
) =>
await RunFunction(
async (context, _) => await automateFunction(context).ConfigureAwait(false),
automationRunData,
speckleToken,
new Fake()
)
.ConfigureAwait(false);
private struct Fake { }
/// <summary>
/// Main entrypoint to execute an Automate function with no input data
/// </summary>
/// <param name="args">The command line arguments passed into the function by automate</param>
/// <param name="automateFunction">The automate function to execute</param>
/// <remarks>This should always be called in your own functions, as it contains the logic to trigger the function automatically.</remarks>
public async Task<int> Main(string[] args, Func<IAutomationContext, Task> automateFunction)
{
return await Main(
args,
async (IAutomationContext context, Fake _) => await automateFunction(context).ConfigureAwait(false)
)
.ConfigureAwait(false);
}
/// <summary>
/// Main entrypoint to execute an Automate function with input data of type <typeparamref name="TInput"/>.
/// </summary>
/// <param name="args">The command line arguments passed into the function by automate</param>
/// <param name="automateFunction">The automate function to execute</param>
/// <typeparam name="TInput">The provided input data</typeparam>
/// <remarks>This should always be called in your own functions, as it contains the logic to trigger the function automatically.</remarks>
public async Task<int> Main<TInput>(string[] args, Func<IAutomationContext, TInput, Task> automateFunction)
where TInput : struct
{
Argument<string> pathArg = new(name: "Input Path", description: "A file path to retrieve function inputs");
RootCommand rootCommand = new();
// a stupid hack to be able to exit with a specific integer exit code
// read more at https://github.com/dotnet/command-line-api/issues/1570
var exitCode = 0;
rootCommand.AddArgument(pathArg);
rootCommand.SetHandler(
async inputPath =>
{
try
{
FunctionRunData<TInput> data = FunctionRunDataParser.FromPath<TInput>(inputPath);
var context = await RunFunction(
automateFunction,
data.AutomationRunData,
data.SpeckleToken,
data.FunctionInputs
)
.ConfigureAwait(false);
if (context.RunStatus is "EXCEPTION")
{
exitCode = 1;
}
}
catch (Exception)
{
exitCode = 1;
throw;
}
},
pathArg
);
Argument<string> schemaFilePathArg = new(
name: "Function inputs file path",
description: "A token to talk to the Speckle server with"
);
Command generateSchemaCommand = new("generate-schema", "Generate JSON schema for the function inputs");
generateSchemaCommand.AddArgument(schemaFilePathArg);
generateSchemaCommand.SetHandler(
schemaFilePath =>
{
try
{
JSchemaGenerator generator = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
generator.GenerationProviders.Add(new SpeckleSecretProvider());
JSchema schema = generator.Generate(typeof(TInput));
schema.ToString(SchemaVersion.Draft2019_09);
File.WriteAllText(schemaFilePath, schema.ToString());
}
catch (Exception)
{
exitCode = 1;
throw;
}
},
schemaFilePathArg
);
rootCommand.Add(generateSchemaCommand);
await rootCommand.InvokeAsync(args).ConfigureAwait(false);
// if we've gotten this far, the execution should technically be completed as expected
// thus exiting with 0 is the semantically correct thing to do
return exitCode;
}
}
internal sealed class SpeckleSecretProvider : JSchemaGenerationProvider
{
public override JSchema? GetSchema(JSchemaTypeGenerationContext context)
{
var attributes = context.MemberProperty?.AttributeProvider?.GetAttributes(false) ?? new List<Attribute>();
var isSecretString = attributes.Any(att => att is SecretAttribute);
if (isSecretString)
{
return CreateSchemaWithWriteOnly(context.ObjectType, context.Required);
}
return null;
}
private static JSchema CreateSchemaWithWriteOnly(Type type, Required required)
{
JSchemaGenerator generator = new();
JSchema schema = generator.Generate(type, required != Required.Always);
schema.WriteOnly = true;
return schema;
}
}
@@ -0,0 +1,12 @@
namespace Speckle.Automate.Sdk.Schema;
public class AutomationResult
{
public double Elapsed { get; set; }
public string? ResultView { get; set; }
public List<string> ResultVersions { get; set; } = new();
public List<string> Blobs { get; set; } = new();
public string RunStatus { get; set; } = AutomationStatusMapping.Get(AutomationStatus.Running);
public string? StatusMessage { get; set; }
public List<ResultCase> ObjectResults { get; set; } = new();
}
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
using Speckle.Automate.Sdk.Schema.Triggers;
namespace Speckle.Automate.Sdk.Schema;
///<summary>
/// Values of the project, model and automation that triggered this function run.
///</summary>
public readonly struct AutomationRunData
{
[JsonRequired]
public required string ProjectId { get; init; }
[JsonRequired]
public required Uri SpeckleServerUrl { get; init; }
[JsonRequired]
public required string AutomationId { get; init; }
[JsonRequired]
public required string AutomationRunId { get; init; }
[JsonRequired]
public required string FunctionRunId { get; init; }
[JsonRequired]
public required List<VersionCreationTrigger> Triggers { get; init; }
}
@@ -0,0 +1,13 @@
namespace Speckle.Automate.Sdk.Schema;
///<summary>
/// Set the status of the automation.
///</summary>
public enum AutomationStatus
{
Initializing,
Running,
Failed,
Succeeded,
Exception,
}
@@ -0,0 +1,21 @@
namespace Speckle.Automate.Sdk.Schema;
public abstract class AutomationStatusMapping
{
private const string INITIALIZING = "INITIALIZING";
private const string RUNNING = "RUNNING";
private const string FAILED = "FAILED";
private const string SUCCEEDED = "SUCCEEDED";
private const string EXCEPTION = "EXCEPTION";
public static string Get(AutomationStatus status) =>
status switch
{
AutomationStatus.Running => RUNNING,
AutomationStatus.Failed => FAILED,
AutomationStatus.Succeeded => SUCCEEDED,
AutomationStatus.Initializing => INITIALIZING,
AutomationStatus.Exception => EXCEPTION,
_ => throw new ArgumentOutOfRangeException($"Not valid value for enum {status}"),
};
}
@@ -0,0 +1,6 @@
namespace Speckle.Automate.Sdk.Schema;
public readonly struct BlobUploadResponse
{
public required List<UploadResult> UploadResults { get; init; }
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Speckle.Automate.Sdk.Schema;
/// <summary>
/// Required data to run a function.
/// </summary>
/// <typeparam name="T"> Type for <see cref="FunctionInputs"/>.</typeparam>
public sealed class FunctionRunData<T>
{
[JsonRequired]
public required string SpeckleToken { get; init; }
[JsonRequired]
public required AutomationRunData AutomationRunData { get; init; }
public required T? FunctionInputs { get; init; }
}
@@ -0,0 +1,52 @@
using System.Text.Json;
namespace Speckle.Automate.Sdk.Schema;
public static class FunctionRunDataParser
{
private static readonly JsonSerializerOptions s_jsonSerializerSettings = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// Function run data parser from json file path./>
/// </summary>
/// <param name="inputLocation"> Path to retrieve function run data.</param>
/// <typeparam name="T"> Type for function inputs.</typeparam>
/// <returns>The data to be able to run function.</returns>
/// <exception cref="JsonException">Json was not valid</exception>
/// <exception cref="FileNotFoundException"> Throws unless file exists.</exception>
public static FunctionRunData<T> FromPath<T>(string inputLocation)
{
string inputJsonString = ReadInputData(inputLocation);
//It's important to use System.Text.Json here. The template FunctionInputs are decorated with STJ attributes
FunctionRunData<T>? functionRunData = JsonSerializer.Deserialize<FunctionRunData<T>>(
inputJsonString,
s_jsonSerializerSettings
);
if (functionRunData is null)
{
throw new JsonException($"Function run data couldn't deserialized at {inputLocation}");
}
return functionRunData;
}
/// <summary>
/// Read text from file.
/// </summary>
/// <param name="inputLocation"> Path to check file is exist.</param>
/// <returns>Text in file.</returns>
/// <exception cref="FileNotFoundException"> Throws unless file exists.</exception>
private static string ReadInputData(string inputLocation)
{
if (!File.Exists(inputLocation))
{
throw new FileNotFoundException($"Cannot find the function inputs file at {inputLocation}");
}
return File.ReadAllText(inputLocation);
}
}
@@ -0,0 +1,9 @@
namespace Speckle.Automate.Sdk.Schema;
public enum ObjectResultLevel
{
Success,
Info,
Warning,
Error,
}
@@ -0,0 +1,19 @@
namespace Speckle.Automate.Sdk.Schema;
public abstract class ObjectResultLevelMapping
{
private const string SUCCESS = "SUCCESS";
private const string INFO = "INFO";
private const string WARNING = "WARNING";
private const string ERROR = "ERROR";
public static string Get(ObjectResultLevel level) =>
level switch
{
ObjectResultLevel.Error => ERROR,
ObjectResultLevel.Warning => WARNING,
ObjectResultLevel.Info => INFO,
ObjectResultLevel.Success => SUCCESS,
_ => throw new ArgumentOutOfRangeException($"Not valid value for enum {level}"),
};
}
@@ -0,0 +1,7 @@
namespace Speckle.Automate.Sdk.Schema;
public readonly struct ObjectResultValues
{
public required List<ResultCase> ObjectResults { get; init; }
public required List<string> BlobIds { get; init; }
}
@@ -0,0 +1,7 @@
namespace Speckle.Automate.Sdk.Schema;
public readonly struct ObjectResults
{
public int Version { get; init; }
public ObjectResultValues Values { get; init; }
}
@@ -0,0 +1,11 @@
namespace Speckle.Automate.Sdk.Schema;
public readonly struct ResultCase
{
public required string Category { get; init; }
public required string Level { get; init; }
public required Dictionary<string, string?> ObjectAppIds { get; init; }
public required string? Message { get; init; }
public required Dictionary<string, object>? Metadata { get; init; }
public required Dictionary<string, object>? VisualOverrides { get; init; }
}
@@ -0,0 +1,6 @@
namespace Speckle.Automate.Sdk.Schema.Triggers;
public abstract class AutomationRunTriggerBase
{
public required string TriggerType { get; init; }
}
@@ -0,0 +1,36 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Speckle.Automate.Sdk.Schema.Triggers;
/// <summary>
/// Represents a single version creation trigger for the automation run.
/// </summary>
public sealed class VersionCreationTrigger : AutomationRunTriggerBase
{
public const string VERSION_CREATION_TRIGGER_TYPE = "versionCreation";
[JsonRequired]
public required VersionCreationTriggerPayload Payload { get; init; }
public VersionCreationTrigger() { }
[SetsRequiredMembers]
public VersionCreationTrigger(string modelId, string versionId)
{
Payload = new() { ModelId = modelId, VersionId = versionId };
TriggerType = VERSION_CREATION_TRIGGER_TYPE;
}
}
/// <summary>
/// Represents the version creation trigger payload.
/// </summary>
public sealed record VersionCreationTriggerPayload
{
[JsonRequired]
public required string ModelId { get; init; }
[JsonRequired]
public required string VersionId { get; init; }
}
@@ -0,0 +1,8 @@
namespace Speckle.Automate.Sdk.Schema;
public readonly struct UploadResult
{
public required string BlobId { get; init; }
public required string FileName { get; init; }
public required int UploadStatus { get; init; }
}
@@ -0,0 +1,50 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Objects.Geometry;
using Speckle.Sdk;
using Speckle.Sdk.Models;
using Speckle.Sdk.Serialisation.V2;
namespace Speckle.Automate.Sdk;
public static class ServiceRegistration
{
/// <summary>
/// Sets-up the serviceCollection with all the services in Speckle.Automate.Sdk and Speckle.Sdk
/// </summary>
/// <param name="serviceCollection"></param>
/// <returns></returns>
public static IServiceCollection AddAutomateSdk(this IServiceCollection serviceCollection)
{
var executingAssembly = Assembly.GetExecutingAssembly().GetName();
var speckleAssembly = typeof(Base).Assembly.GetName();
AddAutomateSdk(
serviceCollection,
new SpeckleSdkOptions(
new(executingAssembly.FullName, "automatefunction"),
executingAssembly.Version?.ToString() ?? "Unknown",
speckleAssembly.Version?.ToString(),
[typeof(Base).Assembly, typeof(Point).Assembly]
)
);
return serviceCollection;
}
public static IServiceCollection AddAutomateSdk(
this IServiceCollection serviceCollection,
SpeckleSdkOptions speckleSdkOptions
)
{
serviceCollection.AddSpeckleSdk(speckleSdkOptions);
//Overwrite the SDK's default IDeserializeProcessFactory to ensure SQLite is not used to cache objects
serviceCollection.AddTransient<IDeserializeProcessFactory, DeserializeProcessFactoryNoCache>();
//Add automate assembly services
serviceCollection.AddTransient<IAutomationContextFactory, AutomationContextFactory>();
serviceCollection.AddTransient<IAutomationRunner, AutomationRunner>();
return serviceCollection;
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Compiler Properties">
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
</PropertyGroup>
<PropertyGroup Label="Nugetspec Package Properties">
<PackageId>Speckle.Automate.Sdk</PackageId>
<Description>Speckle Automate SDK</Description>
<PackageTags>$(PackageTags) speckle automation</PackageTags>
</PropertyGroup>
<PropertyGroup Label="Nuget Package Properties">
<IsPackable>true</IsPackable>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup Label="Expose internals to test projects">
<InternalsVisibleTo Include="Speckle.Automate.Sdk.Tests.Integration" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Newtonsoft.Json.Schema" />
<PackageReference Include="System.CommandLine" NoWarn="NU5104" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup Label="Project References">
<ProjectReference Include="..\Speckle.Objects\Speckle.Objects.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,79 @@
using Speckle.Newtonsoft.Json;
using Speckle.Sdk;
namespace Speckle.Automate.Sdk.Test;
public class TestAppSettings
{
public string? SpeckleToken { get; set; }
public string? SpeckleServerUrl { get; set; }
public string? SpeckleProjectId { get; set; }
public string? SpeckleAutomationId { get; set; }
}
public static class TestAutomateEnvironment
{
public static TestAppSettings? AppSettings { get; private set; }
private static string GetEnvironmentVariable(string environmentVariableName)
{
var value = TryGetEnvironmentVariable(environmentVariableName);
if (value is null)
{
throw new SpeckleException($"Cannot run tests without a {environmentVariableName} environment variable");
}
return value;
}
private static string? TryGetEnvironmentVariable(string environmentVariableName)
{
return Environment.GetEnvironmentVariable(environmentVariableName);
}
private static TestAppSettings? GetAppSettings()
{
if (AppSettings != null)
{
return AppSettings;
}
var path = "./appsettings.json";
var json = File.ReadAllText(path);
var appSettings = JsonConvert.DeserializeObject<TestAppSettings>(json);
AppSettings = appSettings;
return AppSettings;
}
public static string GetSpeckleToken()
{
return GetAppSettings()?.SpeckleToken ?? GetEnvironmentVariable("SPECKLE_TOKEN");
}
public static Uri GetSpeckleServerUrl()
{
var urlString =
GetAppSettings()?.SpeckleServerUrl ?? TryGetEnvironmentVariable("SPECKLE_SERVER_URL") ?? "http://127.0.0.1:3000";
return new Uri(urlString);
}
public static string GetSpeckleProjectId()
{
return GetAppSettings()?.SpeckleProjectId ?? GetEnvironmentVariable("SPECKLE_PROJECT_ID");
}
public static string GetSpeckleAutomationId()
{
return GetAppSettings()?.SpeckleAutomationId ?? GetEnvironmentVariable("SPECKLE_AUTOMATION_ID");
}
public static void Clear()
{
AppSettings = null;
}
}
@@ -0,0 +1,90 @@
using GraphQL;
using Speckle.Automate.Sdk.Schema;
using Speckle.Automate.Sdk.Schema.Triggers;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
namespace Speckle.Automate.Sdk.Test;
internal class TestAutomationRun
{
[JsonRequired]
public required string AutomationRunId { get; init; }
[JsonRequired]
public required string FunctionRunId { get; init; }
[JsonRequired]
public required IReadOnlyList<TestAutomationRunTrigger> Triggers { get; init; }
}
internal class TestAutomationRunTrigger : AutomationRunTriggerBase
{
/// <remarks>This should really be a TestAutomationRunTriggerPayload, but right now, they look the samee</remarks>
public required VersionCreationTriggerPayload Payload { get; init; }
}
public static class TestAutomateUtils
{
public static async Task<AutomationRunData> CreateTestRun(
IClient speckleClient,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation Mutation($projectId: ID!, $automationId: ID!) {
data:projectMutations {
data:automationMutations(projectId: $projectId) {
data:createTestAutomationRun(automationId: $automationId) {
automationRunId
functionRunId
triggers {
payload {
modelId
versionId
}
triggerType
}
}
}
}
}
""";
GraphQLRequest request = new(
query: QUERY,
variables: new
{
automationId = TestAutomateEnvironment.GetSpeckleAutomationId(),
projectId = TestAutomateEnvironment.GetSpeckleProjectId(),
}
);
var res = await speckleClient
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<TestAutomationRun>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
var runData = res.data.data.data;
var triggerData = runData.Triggers[0].Payload;
string modelId = triggerData.ModelId;
string versionId = triggerData.VersionId;
var data = new AutomationRunData()
{
ProjectId = TestAutomateEnvironment.GetSpeckleProjectId(),
SpeckleServerUrl = TestAutomateEnvironment.GetSpeckleServerUrl(),
AutomationId = TestAutomateEnvironment.GetSpeckleAutomationId(),
AutomationRunId = runData.AutomationRunId,
FunctionRunId = runData.FunctionRunId,
Triggers = [new(modelId: modelId, versionId: versionId)],
};
return data;
}
}
+618
View File
@@ -0,0 +1,618 @@
{
"version": 2,
"dependencies": {
".NETStandard,Version=v2.0": {
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "8.0.0",
"Microsoft.SourceLink.Common": "8.0.0"
}
},
"NETStandard.Library": {
"type": "Direct",
"requested": "[2.0.3, )",
"resolved": "2.0.3",
"contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0"
}
},
"Newtonsoft.Json.Schema": {
"type": "Direct",
"requested": "[4.0.1, )",
"resolved": "4.0.1",
"contentHash": "rbHUKp5WTIbqmLEeJ21nTTDGcfR0LA7bVMzm0bYc3yx6NFKiCIHzzvYbwA4Sqgs7+wNldc5nBlkbithWj8IZig==",
"dependencies": {
"Newtonsoft.Json": "13.0.3"
}
},
"PolySharp": {
"type": "Direct",
"requested": "[1.15.0, )",
"resolved": "1.15.0",
"contentHash": "FbU0El+EEjdpuIX4iDbeS7ki1uzpJPx8vbqOzEtqnl1GZeAGJfq+jCbxeJL2y0EPnUNk8dRnnqR2xnYXg9Tf+g=="
},
"Speckle.InterfaceGenerator": {
"type": "Direct",
"requested": "[0.9.6, )",
"resolved": "0.9.6",
"contentHash": "HKH7tYrYYlCK1ct483hgxERAdVdMtl7gUKW9ijWXxA1UsYR4Z+TrRHYmzZ9qmpu1NnTycSrp005NYM78GDKV1w=="
},
"System.CommandLine": {
"type": "Direct",
"requested": "[2.0.0-beta4.22272.1, )",
"resolved": "2.0.0-beta4.22272.1",
"contentHash": "1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg==",
"dependencies": {
"System.Memory": "4.5.4"
}
},
"System.Text.Json": {
"type": "Direct",
"requested": "[8.0.5, )",
"resolved": "8.0.5",
"contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "8.0.0",
"System.Buffers": "4.5.1",
"System.Memory": "4.5.5",
"System.Runtime.CompilerServices.Unsafe": "6.0.0",
"System.Text.Encodings.Web": "8.0.0",
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"GraphQL.Client.Abstractions": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "h7uzWFORHZ+CCjwr/ThAyXMr0DPpzEANDa4Uo54wqCQ+j7qUKwqYTgOrb1W40sqbvNaZm9v/X7It31SUw0maHA==",
"dependencies": {
"GraphQL.Primitives": "6.0.0"
}
},
"GraphQL.Client.Abstractions.Websocket": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "Nr9bPf8gIOvLuXpqEpqr9z9jslYFJOvd0feHth3/kPqeR3uMbjF5pjiwh4jxyMcxHdr8Pb6QiXkV3hsSyt0v7A==",
"dependencies": {
"GraphQL.Client.Abstractions": "6.0.0"
}
},
"GraphQL.Primitives": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "7.0.5",
"contentHash": "FTerRmQPqHrCrnoUzhBu+E+1DNGwyrAMLqHkAqOOOu5pGfyMOj8qQUBxI/gDtWtG11p49UxSfWmBzRNlwZqfUg==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.4"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "nOP8R1mVb/6mZtm2qgAJXn/LFm/2kMjHDAg/QJLFG6CuWYJtaD3p1BwQhufBVvRzL9ceJ/xF0SQ0qsI2GkDQAA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "vJ9xvOZCnUAIHcGC3SU35r3HKmHTVIeHzo6u/qzlHAqD8m6xv92MLin4oJntTvkpKxVX3vI1GFFkIQtU3AdlsQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "2.2.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "UpZLNLBpIZ0GTebShui7xXYh6DmBHjWM8NxGxZbdQh/bPZ5e6YswqI+bru6BnEL5eWiOdodsXtEz3FROcgi/qg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0",
"Microsoft.Extensions.Primitives": "2.2.0",
"System.ComponentModel.Annotations": "4.5.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==",
"dependencies": {
"System.Memory": "4.5.1",
"System.Runtime.CompilerServices.Unsafe": "4.5.1"
}
},
"Microsoft.NETCore.Platforms": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
},
"Microsoft.NETCore.Targets": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg=="
},
"Microsoft.SourceLink.Common": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "EWI1olKDjFEBMJu0+3wuxwziIAdWDVMYLhuZ3Qs84rrz+DHwD00RzWPZCa+bLnHCf3oJwuFZIRsHT5p236QXww==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.4",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.4"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "inBjvSHo9UDKneGNzfUfDjK08JzlcIhn1+SP5Y3m6cgXpCxXKCJDy6Mka7LpgSV+UZmKSnC8rTwB0SQ0xKu5pA==",
"dependencies": {
"System.Memory": "4.5.3"
}
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "2C9Q9eX7CPLveJA0rIhf9RXAvu+7nWZu1A2MdG6SD/NOu26TakGgL1nsbc0JAspGijFOo3HoN79xrx8a368fBg=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "CSlb5dUp1FMIkez9Iv5EXzpeq7rHryVNqwJMWnpq87j9zWZexaEMdisDktMsnnrzKM6ahNrsTkjqNodTBPBxtQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.4"
}
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.5.1",
"contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg=="
},
"System.ComponentModel.Annotations": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "UxYQ3FGUOtzJ7LfSdnYSFd7+oEv6M8NgUatatIN2HxNtDdlcvFAf+VIq4Of9cDMJEJC0aSRv/x898RYhB4Yppg=="
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.5",
"contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==",
"dependencies": {
"System.Buffers": "4.5.1",
"System.Numerics.Vectors": "4.4.0",
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
},
"System.Numerics.Vectors": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ=="
},
"System.Reactive": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "erBZjkQHWL9jpasCE/0qKAryzVBJFxGHVBAvgRN1bzM0q2s1S4oYREEEL0Vb+1kA/6BKb5FjUZMp5VXmy+gzkQ==",
"dependencies": {
"System.Runtime.InteropServices.WindowsRuntime": "4.3.0",
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"System.Runtime": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0",
"Microsoft.NETCore.Targets": "1.1.0"
}
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
},
"System.Runtime.InteropServices.WindowsRuntime": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "J4GUi3xZQLUBasNwZnjrffN8i5wpHrBtZoLG+OhRyGo/+YunMRWWtwoMDlUAIdmX0uRfpHIBDSV6zyr3yf00TA==",
"dependencies": {
"System.Runtime": "4.3.0"
}
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==",
"dependencies": {
"System.Buffers": "4.5.1",
"System.Memory": "4.5.5",
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
}
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
},
"speckle.objects": {
"type": "Project",
"dependencies": {
"Speckle.Sdk": "[1.0.0, )"
}
},
"speckle.sdk": {
"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, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
}
},
"speckle.sdk.dependencies": {
"type": "Project"
},
"GraphQL.Client": {
"type": "CentralTransitive",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "8yPNBbuVBpTptivyAlak4GZvbwbUcjeQTL4vN1HKHRuOykZ4r7l5fcLS6vpyPyLn0x8FsL31xbOIKyxbmR9rbA==",
"dependencies": {
"GraphQL.Client.Abstractions": "6.0.0",
"GraphQL.Client.Abstractions.Websocket": "6.0.0",
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "8.0.0",
"contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
"resolved": "4.7.0",
"contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
"resolved": "7.0.5",
"contentHash": "KGxbPeWsQMnmQy43DSBxAFtHz3l2JX8EWBSGUCvT3CuZ8KsuzbkqMIJMDOxWtG8eZSoCDI04aiVQjWuuV8HmSw==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "7.0.5",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.4"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[2.2.0, )",
"resolved": "2.2.0",
"contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw=="
},
"Microsoft.Extensions.Logging": {
"type": "CentralTransitive",
"requested": "[2.2.0, )",
"resolved": "2.2.0",
"contentHash": "Nxqhadc9FCmFHzU+fz3oc8sFlE6IadViYg8dfUdGzJZ2JUxnCsRghBhhOWdM4B2zSZqEc+0BjliBh/oNdRZuig==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "2.2.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0",
"Microsoft.Extensions.Logging.Abstractions": "2.2.0",
"Microsoft.Extensions.Options": "2.2.0"
}
},
"Speckle.DoubleNumerics": {
"type": "CentralTransitive",
"requested": "[4.1.0, )",
"resolved": "4.1.0",
"contentHash": "20DtS+FsDRsOD9+AU3TwNFZ0qrKo5f6f7B5ZR9wStsIHHHC9k7DpjbCvuNtmnSjx54MD+TJC7wV2f5iyGVPj1A=="
},
"Speckle.Newtonsoft.Json": {
"type": "CentralTransitive",
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
}
},
"net8.0": {
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "8.0.0",
"Microsoft.SourceLink.Common": "8.0.0"
}
},
"Newtonsoft.Json.Schema": {
"type": "Direct",
"requested": "[4.0.1, )",
"resolved": "4.0.1",
"contentHash": "rbHUKp5WTIbqmLEeJ21nTTDGcfR0LA7bVMzm0bYc3yx6NFKiCIHzzvYbwA4Sqgs7+wNldc5nBlkbithWj8IZig==",
"dependencies": {
"Newtonsoft.Json": "13.0.3"
}
},
"PolySharp": {
"type": "Direct",
"requested": "[1.15.0, )",
"resolved": "1.15.0",
"contentHash": "FbU0El+EEjdpuIX4iDbeS7ki1uzpJPx8vbqOzEtqnl1GZeAGJfq+jCbxeJL2y0EPnUNk8dRnnqR2xnYXg9Tf+g=="
},
"Speckle.InterfaceGenerator": {
"type": "Direct",
"requested": "[0.9.6, )",
"resolved": "0.9.6",
"contentHash": "HKH7tYrYYlCK1ct483hgxERAdVdMtl7gUKW9ijWXxA1UsYR4Z+TrRHYmzZ9qmpu1NnTycSrp005NYM78GDKV1w=="
},
"System.CommandLine": {
"type": "Direct",
"requested": "[2.0.0-beta4.22272.1, )",
"resolved": "2.0.0-beta4.22272.1",
"contentHash": "1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg=="
},
"GraphQL.Client.Abstractions": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "h7uzWFORHZ+CCjwr/ThAyXMr0DPpzEANDa4Uo54wqCQ+j7qUKwqYTgOrb1W40sqbvNaZm9v/X7It31SUw0maHA==",
"dependencies": {
"GraphQL.Primitives": "6.0.0"
}
},
"GraphQL.Client.Abstractions.Websocket": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "Nr9bPf8gIOvLuXpqEpqr9z9jslYFJOvd0feHth3/kPqeR3uMbjF5pjiwh4jxyMcxHdr8Pb6QiXkV3hsSyt0v7A==",
"dependencies": {
"GraphQL.Client.Abstractions": "6.0.0"
}
},
"GraphQL.Primitives": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "7.0.5",
"contentHash": "FTerRmQPqHrCrnoUzhBu+E+1DNGwyrAMLqHkAqOOOu5pGfyMOj8qQUBxI/gDtWtG11p49UxSfWmBzRNlwZqfUg==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.4"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "nOP8R1mVb/6mZtm2qgAJXn/LFm/2kMjHDAg/QJLFG6CuWYJtaD3p1BwQhufBVvRzL9ceJ/xF0SQ0qsI2GkDQAA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "vJ9xvOZCnUAIHcGC3SU35r3HKmHTVIeHzo6u/qzlHAqD8m6xv92MLin4oJntTvkpKxVX3vI1GFFkIQtU3AdlsQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "2.2.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "UpZLNLBpIZ0GTebShui7xXYh6DmBHjWM8NxGxZbdQh/bPZ5e6YswqI+bru6BnEL5eWiOdodsXtEz3FROcgi/qg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0",
"Microsoft.Extensions.Primitives": "2.2.0",
"System.ComponentModel.Annotations": "4.5.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==",
"dependencies": {
"System.Memory": "4.5.1",
"System.Runtime.CompilerServices.Unsafe": "4.5.1"
}
},
"Microsoft.SourceLink.Common": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "EWI1olKDjFEBMJu0+3wuxwziIAdWDVMYLhuZ3Qs84rrz+DHwD00RzWPZCa+bLnHCf3oJwuFZIRsHT5p236QXww==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.4",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.4"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "inBjvSHo9UDKneGNzfUfDjK08JzlcIhn1+SP5Y3m6cgXpCxXKCJDy6Mka7LpgSV+UZmKSnC8rTwB0SQ0xKu5pA==",
"dependencies": {
"System.Memory": "4.5.3"
}
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "2C9Q9eX7CPLveJA0rIhf9RXAvu+7nWZu1A2MdG6SD/NOu26TakGgL1nsbc0JAspGijFOo3HoN79xrx8a368fBg=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "CSlb5dUp1FMIkez9Iv5EXzpeq7rHryVNqwJMWnpq87j9zWZexaEMdisDktMsnnrzKM6ahNrsTkjqNodTBPBxtQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.4"
}
},
"System.ComponentModel.Annotations": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "UxYQ3FGUOtzJ7LfSdnYSFd7+oEv6M8NgUatatIN2HxNtDdlcvFAf+VIq4Of9cDMJEJC0aSRv/x898RYhB4Yppg=="
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.3",
"contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA=="
},
"System.Reactive": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "erBZjkQHWL9jpasCE/0qKAryzVBJFxGHVBAvgRN1bzM0q2s1S4oYREEEL0Vb+1kA/6BKb5FjUZMp5VXmy+gzkQ=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "4.5.1",
"contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw=="
},
"speckle.objects": {
"type": "Project",
"dependencies": {
"Speckle.Sdk": "[1.0.0, )"
}
},
"speckle.sdk": {
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
}
},
"speckle.sdk.dependencies": {
"type": "Project"
},
"GraphQL.Client": {
"type": "CentralTransitive",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "8yPNBbuVBpTptivyAlak4GZvbwbUcjeQTL4vN1HKHRuOykZ4r7l5fcLS6vpyPyLn0x8FsL31xbOIKyxbmR9rbA==",
"dependencies": {
"GraphQL.Client.Abstractions": "6.0.0",
"GraphQL.Client.Abstractions.Websocket": "6.0.0",
"System.Reactive": "5.0.0"
}
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
"resolved": "7.0.5",
"contentHash": "KGxbPeWsQMnmQy43DSBxAFtHz3l2JX8EWBSGUCvT3CuZ8KsuzbkqMIJMDOxWtG8eZSoCDI04aiVQjWuuV8HmSw==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "7.0.5",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.4"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[2.2.0, )",
"resolved": "2.2.0",
"contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw=="
},
"Microsoft.Extensions.Logging": {
"type": "CentralTransitive",
"requested": "[2.2.0, )",
"resolved": "2.2.0",
"contentHash": "Nxqhadc9FCmFHzU+fz3oc8sFlE6IadViYg8dfUdGzJZ2JUxnCsRghBhhOWdM4B2zSZqEc+0BjliBh/oNdRZuig==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "2.2.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0",
"Microsoft.Extensions.Logging.Abstractions": "2.2.0",
"Microsoft.Extensions.Options": "2.2.0"
}
},
"Speckle.DoubleNumerics": {
"type": "CentralTransitive",
"requested": "[4.1.0, )",
"resolved": "4.1.0",
"contentHash": "20DtS+FsDRsOD9+AU3TwNFZ0qrKo5f6f7B5ZR9wStsIHHHC9k7DpjbCvuNtmnSjx54MD+TJC7wV2f5iyGVPj1A=="
},
"Speckle.Newtonsoft.Json": {
"type": "CentralTransitive",
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
}
}
}
}
+7 -8
View File
@@ -1,6 +1,5 @@
using Speckle.Objects.Geometry;
using Speckle.Sdk.Models;
using Point = Speckle.Objects.Geometry.Point;
namespace Speckle.Objects.Annotation;
@@ -15,11 +14,6 @@ public class Text : Base
/// </summary>
public required string value { get; set; }
/// <summary>
/// Origin point, relation to the text is defined by AlignmentHorizontal and AlignmentVertical
/// </summary>
public required Point origin { get; set; }
/// <summary>
/// Height in linear units or pixels (if Units.None)
/// </summary>
@@ -31,6 +25,11 @@ public class Text : Base
/// </summary>
public required string units { get; set; }
/// <summary>
/// If true, the text is oriented to face the screen (camera-aligned).
/// </summary>
public required bool screenOriented { get; set; }
/// <summary>
/// Horizontal alignment: Left, Center or Right
/// </summary>
@@ -42,9 +41,9 @@ public class Text : Base
public AlignmentVertical alignmentV { get; set; }
/// <summary>
/// Plane will be null if the text object orientation follows camera view
/// Plane axis vectors will be ignored if screenOriented is true
/// </summary>
public Plane? plane { get; set; }
public required Plane plane { get; set; }
/// <summary>
/// Maximum width of the text field (in 'units').
-7
View File
@@ -472,7 +472,6 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
@@ -495,12 +494,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
"resolved": "4.7.0",
"contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
+17
View File
@@ -98,6 +98,23 @@ public sealed class SpeckleGraphQLInvalidQueryException : SpeckleGraphQLExceptio
: base(message, innerException) { }
}
/// <summary>
/// Represents a <c>WORKSPACES_MODULE_DISABLED_ERROR</c> GraphQL error as an exception
/// </summary>
/// <remarks>
/// A GraphQL request for workspace resources was made to a server that does not have the <c>FF_WORKSPACES_MODULE_ENABLED</c> feature flag enabled
/// </remarks>
public sealed class SpeckleGraphQLWorkspaceNotEnabledException : SpeckleGraphQLException
{
public SpeckleGraphQLWorkspaceNotEnabledException() { }
public SpeckleGraphQLWorkspaceNotEnabledException(string? message)
: base(message) { }
public SpeckleGraphQLWorkspaceNotEnabledException(string? message, Exception? innerException)
: base(message, innerException) { }
}
/// <seealso cref="PermissionCheckResult"/>
public sealed class WorkspacePermissionException : SpeckleGraphQLException
{
+5 -71
View File
@@ -1,18 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.WebSockets;
using System.Reflection;
using GraphQL;
using GraphQL.Client.Http;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Newtonsoft.Json.Serialization;
using Speckle.Sdk.Api.GraphQL;
using Speckle.Sdk.Api.GraphQL.Resources;
using Speckle.Sdk.Api.GraphQL.Serializer;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Api;
@@ -37,14 +32,13 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public CommentResource Comment { get; }
public SubscriptionResource Subscription { get; }
public WorkspaceResource Workspace { get; }
public ServerResource Server { get; }
public Uri ServerUrl => new(Account.serverInfo.url);
[JsonIgnore]
public Account Account { get; }
private HttpClient HttpClient { get; }
[AutoInterfaceIgnore]
public GraphQLHttpClient GQLClient { get; }
@@ -53,8 +47,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public Client(
ILogger<Client> logger,
ISdkActivityFactory activityFactory,
ISpeckleApplication application,
ISpeckleHttp speckleHttp,
IGraphQLClientFactory graphqlClientFactory,
Account account
)
{
@@ -71,10 +64,9 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
Comment = new(this);
Subscription = new(this);
Workspace = new(this);
Server = new(this);
HttpClient = CreateHttpClient(application, speckleHttp, account);
GQLClient = CreateGraphQLClient(account, HttpClient);
GQLClient = graphqlClientFactory.CreateGraphQLClient(account);
}
[AutoInterfaceIgnore]
@@ -107,7 +99,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
)
.ConfigureAwait(false);
/// <inheritdoc/>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}" />
public async Task<T> ExecuteGraphQLRequest<T>(GraphQLRequest request, CancellationToken cancellationToken = default)
{
using var activity = _activityFactory.Start();
@@ -188,62 +180,4 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
throw new SpeckleGraphQLException($"Subscription for {typeof(T)} failed to start", ex);
}
}
private static GraphQLHttpClient CreateGraphQLClient(Account account, HttpClient httpClient)
{
var gQLClient = new GraphQLHttpClient(
new GraphQLHttpClientOptions
{
EndPoint = new Uri(new Uri(account.serverInfo.url), "/graphql"),
UseWebSocketForQueriesAndMutations = false,
WebSocketProtocol = "graphql-ws",
ConfigureWebSocketConnectionInitPayload = _ =>
{
return SpeckleHttp.CanAddAuth(account.token, out string? authValue)
? new { Authorization = authValue }
: null;
},
},
new NewtonsoftJsonSerializer(
new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver { IgnoreIsSpecifiedMembers = true }, //(Default)
MissingMemberHandling = MissingMemberHandling.Error, //(not default) If you query for a member that doesn't exist, this will throw (except websocket responses see https://github.com/graphql-dotnet/graphql-client/issues/660)
Converters =
{
new ConstantCaseEnumConverter(),
} //(Default) enums will be serialized using the GraphQL const case standard
,
}
),
httpClient
);
gQLClient.WebSocketReceiveErrors.Subscribe(e =>
{
if (e is WebSocketException we)
{
Console.WriteLine(
$"WebSocketException: {we.Message} (WebSocketError {we.WebSocketErrorCode}, ErrorCode {we.ErrorCode}, NativeErrorCode {we.NativeErrorCode}"
);
}
else
{
Console.WriteLine($"Exception in websocket receive stream: {e}");
}
});
return gQLClient;
}
private static HttpClient CreateHttpClient(ISpeckleApplication application, ISpeckleHttp speckleHttp, Account account)
{
var httpClient = speckleHttp.CreateHttpClient(timeoutSeconds: 30, authorizationToken: account.token);
httpClient.DefaultRequestHeaders.Add("apollographql-client-name", application.ApplicationAndVersion);
httpClient.DefaultRequestHeaders.Add(
"apollographql-client-version",
Assembly.GetExecutingAssembly().GetName().Version?.ToString()
);
return httpClient;
}
}
+2 -4
View File
@@ -1,7 +1,6 @@
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Api;
@@ -10,10 +9,9 @@ namespace Speckle.Sdk.Api;
public class ClientFactory(
ILoggerFactory loggerFactory,
ISdkActivityFactory activityFactory,
ISpeckleApplication application,
ISpeckleHttp speckleHttp
IGraphQLClientFactory graphQLClientFactory
) : IClientFactory
{
public IClient Create(Account account) =>
new Client(loggerFactory.CreateLogger<Client>(), activityFactory, application, speckleHttp, account);
new Client(loggerFactory.CreateLogger<Client>(), activityFactory, graphQLClientFactory, account);
}
@@ -1,10 +1,11 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Enums;
public enum ProjectVisibility
{
Private = 0,
Private,
Public,
[Obsolete("Use Unlisted instead", true)]
Public = 1,
Unlisted = 2,
[Obsolete("Use Public instead")]
Unlisted,
Workspace,
}
@@ -32,6 +32,7 @@ internal static class GraphQLErrorHandler
"STREAM_NOT_FOUND" => new SpeckleGraphQLStreamNotFoundException(message),
"BAD_USER_INPUT" => new SpeckleGraphQLBadInputException(message),
"INTERNAL_SERVER_ERROR" => new SpeckleGraphQLInternalErrorException(message),
"WORKSPACES_MODULE_DISABLED_ERROR" => new SpeckleGraphQLWorkspaceNotEnabledException(message),
_ => new SpeckleGraphQLException(message),
};
exceptions.Add(ex);
@@ -1,8 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
internal sealed record CommentContentInput(IReadOnlyCollection<string>? blobIds, object? doc);
internal record CommentContentInput(IReadOnlyCollection<string>? blobIds, object? doc);
internal sealed record CreateCommentInput(
internal record CreateCommentInput(
CommentContentInput content,
string projectId,
string resourceIdString,
@@ -10,10 +10,10 @@ internal sealed record CreateCommentInput(
object? viewerState
);
internal sealed record EditCommentInput(CommentContentInput content, string commentId, string projectId);
internal record EditCommentInput(CommentContentInput content, string commentId, string projectId);
internal sealed record CreateCommentReplyInput(CommentContentInput content, string threadId, string projectId);
internal record CreateCommentReplyInput(CommentContentInput content, string threadId, string projectId);
public sealed record MarkCommentViewedInput(string commentId, string projectId);
public record MarkCommentViewedInput(string commentId, string projectId);
public sealed record ArchiveCommentInput(string commentId, string projectId, bool archived = true);
public record ArchiveCommentInput(string commentId, string projectId, bool archived = true);
@@ -1,9 +1,9 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record CreateModelInput(string name, string? description, string projectId);
public record CreateModelInput(string name, string? description, string projectId);
public sealed record DeleteModelInput(string id, string projectId);
public record DeleteModelInput(string id, string projectId);
public sealed record UpdateModelInput(string id, string? name, string? description, string projectId);
public record UpdateModelInput(string id, string? name, string? description, string projectId);
public sealed record ModelVersionsFilter(IReadOnlyList<string> priorityIds, bool? priorityIdsOnly);
public record ModelVersionsFilter(IReadOnlyList<string> priorityIds, bool? priorityIdsOnly);
@@ -2,22 +2,22 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record ProjectCommentsFilter(bool? includeArchived, bool? loadedVersionsOnly, string? resourceIdString);
public record ProjectCommentsFilter(bool? includeArchived, bool? loadedVersionsOnly, string? resourceIdString);
public sealed record ProjectCreateInput(string? name, string? description, ProjectVisibility? visibility);
public record ProjectCreateInput(string? name, string? description, ProjectVisibility? visibility);
public sealed record WorkspaceProjectCreateInput(
public record WorkspaceProjectCreateInput(
string? name,
string? description,
ProjectVisibility? visibility,
string workspaceId
);
public sealed record ProjectInviteCreateInput(string? email, string? role, string? serverRole, string? userId);
public record ProjectInviteCreateInput(string? email, string? role, string? serverRole, string? userId);
public sealed record ProjectInviteUseInput(bool accept, string projectId, string token);
public record ProjectInviteUseInput(bool accept, string projectId, string token);
public sealed record ProjectModelsFilter(
public record ProjectModelsFilter(
IReadOnlyList<string>? contributors = null,
IReadOnlyList<string>? excludeIds = null,
IReadOnlyList<string>? ids = null,
@@ -26,7 +26,7 @@ public sealed record ProjectModelsFilter(
IReadOnlyList<string>? sourceApps = null
);
public sealed record ProjectUpdateInput(
public record ProjectUpdateInput(
string id,
string? name = null,
string? description = null,
@@ -34,6 +34,6 @@ public sealed record ProjectUpdateInput(
ProjectVisibility? visibility = null
);
public sealed record ProjectUpdateRoleInput(string userId, string projectId, string? role);
public record ProjectUpdateRoleInput(string userId, string projectId, string? role);
public sealed record WorkspaceProjectsFilter(string? search, bool? withProjectRoleOnly);
public record WorkspaceProjectsFilter(string? search, bool? withProjectRoleOnly);
@@ -1,7 +1,3 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record ViewerUpdateTrackingTarget(
string projectId,
string resourceIdString,
bool? loadedVersionsOnly = null
);
public record ViewerUpdateTrackingTarget(string projectId, string resourceIdString, bool? loadedVersionsOnly = null);
@@ -1,13 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record UserUpdateInput(
string? avatar = null,
string? bio = null,
string? company = null,
string? name = null
);
public record UserUpdateInput(string? avatar = null, string? bio = null, string? company = null, string? name = null);
public sealed record UserProjectsFilter(
public record UserProjectsFilter(
string? search = null,
IReadOnlyList<string>? onlyWithRoles = null,
string? workspaceId = null,
@@ -15,4 +10,4 @@ public sealed record UserProjectsFilter(
bool? includeImplicitAccess = null
);
public sealed record UserWorkspacesFilter(string? search);
public record UserWorkspacesFilter(string? search);
@@ -1,12 +1,12 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public sealed record UpdateVersionInput(string versionId, string projectId, string? message);
public record UpdateVersionInput(string versionId, string projectId, string? message);
public sealed record MoveVersionsInput(string projectId, string targetModelName, IReadOnlyList<string> versionIds);
public record MoveVersionsInput(string projectId, string targetModelName, IReadOnlyList<string> versionIds);
public sealed record DeleteVersionsInput(IReadOnlyList<string> versionIds, string projectId);
public record DeleteVersionsInput(IReadOnlyList<string> versionIds, string projectId);
public sealed record CreateVersionInput(
public record CreateVersionInput(
string objectId,
string modelId,
string projectId,
@@ -16,7 +16,7 @@ public sealed record CreateVersionInput(
IReadOnlyList<string>? parents = null
);
public sealed record MarkReceivedVersionInput(
public record MarkReceivedVersionInput(
string versionId,
string projectId,
string sourceApplication,
@@ -26,3 +26,8 @@ public sealed class ProjectWithTeam : Project
public List<PendingStreamCollaborator> invitedTeam { get; init; }
public List<ProjectCollaborator> team { get; init; }
}
public sealed class ProjectWithPermissions : Project
{
public ProjectPermissionChecks permissions { get; init; }
}
@@ -4,4 +4,6 @@ public sealed class ProjectPermissionChecks
{
public PermissionCheckResult canCreateModel { get; init; }
public PermissionCheckResult canDelete { get; init; }
public PermissionCheckResult canLoad { get; init; }
public PermissionCheckResult canPublish { get; init; }
}
@@ -9,10 +9,10 @@ namespace Speckle.Sdk.Api.GraphQL.Models.Responses;
/// </summary>
/// <param name="data"></param>
/// <typeparam name="T"></typeparam>
internal record RequiredResponse<T>([property: JsonProperty(Required = Required.Always)] T data);
public record RequiredResponse<T>([property: JsonProperty(Required = Required.Always)] T data);
/// <inheritdoc cref="RequiredResponse{T}"/>
internal record NullableResponse<T>([property: JsonProperty(Required = Required.AllowNull)] T? data);
public record NullableResponse<T>([property: JsonProperty(Required = Required.AllowNull)] T? data);
//TODO: replace with RequiredResponse{T}
internal record ServerInfoResponse([property: JsonProperty(Required = Required.Always)] ServerInfo serverInfo);
@@ -16,12 +16,8 @@ public sealed class ServerInfo
public string? version { get; init; }
public string? description { get; init; }
/// <remarks>
/// This field is not returned from the GQL API,
/// it should be populated after construction from the response headers.
/// see <see cref="Speckle.Sdk.Credentials.AccountManager"/>
/// </remarks>
public bool frontend2 { get; set; }
[Obsolete("Don't use")]
public bool frontend2 { get; set; } = true;
/// <remarks>
/// This field is not returned from the GQL API,
@@ -7,6 +7,10 @@ public sealed class Workspace
public string role { get; init; }
public string slug { get; init; }
public string? description { get; init; }
public string? logo { get; init; }
public DateTime? createdAt { get; init; }
public DateTime? updatedAt { get; init; }
public bool? readOnly { get; init; }
public WorkspacePermissionChecks permissions { get; init; }
public WorkspaceCreationState? creationState { get; init; }
}
@@ -361,4 +361,89 @@ public sealed class ActiveUserResource
return response.data.data;
}
/// <param name="limit">Max number of projects to fetch</param>
/// <param name="cursor">Optional cursor for pagination</param>
/// <param name="filter">Optional filter</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <exception cref="SpeckleException">The ActiveUser could not be found (e.g. the client is not authenticated)</exception>
public async Task<ResourceCollection<ProjectWithPermissions>> GetProjectsWithPermissions(
int limit = ServerLimits.DEFAULT_PAGINATION_REQUEST,
string? cursor = null,
UserProjectsFilter? filter = null,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query User($limit: Int!, $cursor: String, $filter: UserProjectsFilter) {
data: activeUser {
data: projects(limit: $limit, cursor: $cursor, filter: $filter) {
totalCount
cursor
items {
id
name
description
visibility
allowPublicComments
role
createdAt
updatedAt
sourceApps
workspaceId
permissions {
canCreateModel {
code
authorized
message
}
canDelete {
code
authorized
message
}
canLoad {
code
authorized
message
}
canPublish {
code
authorized
message
}
}
}
}
}
}
""";
var request = new GraphQLRequest
{
Query = QUERY,
Variables = new
{
limit,
cursor,
filter,
},
};
var response = await _client
.ExecuteGraphQLRequest<NullableResponse<RequiredResponse<ResourceCollection<ProjectWithPermissions>>?>>(
request,
cancellationToken
)
.ConfigureAwait(false);
if (response.data is null)
{
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
}
return response.data.data;
}
}
@@ -71,6 +71,16 @@ public sealed class ProjectResource
code
message
}
canLoad {
authorized
code
message
}
canPublish {
authorized
code
message
}
}
}
}
@@ -0,0 +1,39 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
namespace Speckle.Sdk.Api.GraphQL.Resources;
public sealed class ServerResource
{
private readonly ISpeckleGraphQLClient _client;
internal ServerResource(ISpeckleGraphQLClient client)
{
_client = client;
}
/// <param name="cancellationToken"></param>
/// <returns><see langword="null"/> if server is workspaces enabled</returns>
/// <returns>the requested user, or null if <see cref="Client"/> was initialised with an unauthenticated account</returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> IsWorkspaceEnabled(CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
query {
data:serverInfo {
data:workspaces {
data:workspacesEnabled
}
}
}
""";
var request = new GraphQLRequest { Query = QUERY };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data.data;
}
}
@@ -50,10 +50,10 @@ public sealed class WorkspaceResource
var request = new GraphQLRequest { Query = QUERY, Variables = new { workspaceId } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<Workspace>>>(request, cancellationToken)
.ExecuteGraphQLRequest<RequiredResponse<Workspace>>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data;
return response.data;
}
/// <param name="workspaceId"></param>
@@ -26,9 +26,11 @@ public partial class Operations
)
{
using var receiveActivity = activityFactory.Start("Operations.Receive");
receiveActivity?.SetTag("speckle.url", url);
receiveActivity?.SetTag("speckle.projectId", streamId);
receiveActivity?.SetTag("speckle.objectId", objectId);
metricsFactory.CreateCounter<long>("Receive").Add(1);
receiveActivity?.SetTag("objectId", objectId);
var process = deserializeProcessFactory.CreateDeserializeProcess(
url,
streamId,
@@ -29,6 +29,8 @@ public partial class Operations
)
{
using var receiveActivity = activityFactory.Start("Operations.Send");
receiveActivity?.SetTag("speckle.url", url);
receiveActivity?.SetTag("speckle.projectId", streamId);
metricsFactory.CreateCounter<long>("Send").Add(1);
var process = serializeProcessFactory.CreateSerializeProcess(
+165
View File
@@ -0,0 +1,165 @@
namespace Speckle.Sdk.Common;
// MD5 implementation in pure C# (public domain / no dependencies)
// Not for cryptographic purposes
// Using this instead of changing ID generation but avoiding built in MD5 for FIPS compliance
public static class Md5
{
// Standard initial values
private static readonly uint[] T =
[
0xd76aa478,
0xe8c7b756,
0x242070db,
0xc1bdceee,
0xf57c0faf,
0x4787c62a,
0xa8304613,
0xfd469501,
0x698098d8,
0x8b44f7af,
0xffff5bb1,
0x895cd7be,
0x6b901122,
0xfd987193,
0xa679438e,
0x49b40821,
0xf61e2562,
0xc040b340,
0x265e5a51,
0xe9b6c7aa,
0xd62f105d,
0x02441453,
0xd8a1e681,
0xe7d3fbc8,
0x21e1cde6,
0xc33707d6,
0xf4d50d87,
0x455a14ed,
0xa9e3e905,
0xfcefa3f8,
0x676f02d9,
0x8d2a4c8a,
0xfffa3942,
0x8771f681,
0x6d9d6122,
0xfde5380c,
0xa4beea44,
0x4bdecfa9,
0xf6bb4b60,
0xbebfbc70,
0x289b7ec6,
0xeaa127fa,
0xd4ef3085,
0x04881d05,
0xd9d4d039,
0xe6db99e5,
0x1fa27cf8,
0xc4ac5665,
0xf4292244,
0x432aff97,
0xab9423a7,
0xfc93a039,
0x655b59c3,
0x8f0ccc92,
0xffeff47d,
0x85845dd1,
0x6fa87e4f,
0xfe2ce6e0,
0xa3014314,
0x4e0811a1,
0xf7537e82,
0xbd3af235,
0x2ad7d2bb,
0xeb86d391,
];
public static byte[] ComputeHash(byte[] input)
{
// Pad input
int origLenBits = input.Length * 8;
int padLen = (56 - (input.Length + 1) % 64 + 64) % 64;
byte[] padded = new byte[input.Length + 1 + padLen + 8];
Array.Copy(input, padded, input.Length);
padded[input.Length] = 0x80;
BitConverter.GetBytes((long)origLenBits).CopyTo(padded, padded.Length - 8);
// Initialize MD5 buffer
uint a = 0x67452301;
uint b = 0xefcdab89;
uint c = 0x98badcfe;
uint d = 0x10325476;
for (int i = 0; i < padded.Length / 64; i++)
{
uint[] M = new uint[16];
for (int j = 0; j < 16; j++)
{
M[j] = BitConverter.ToUInt32(padded, (i * 64) + j * 4);
}
uint AA = a,
BB = b,
CC = c,
DD = d;
for (int j = 0; j < 64; j++)
{
uint f,
g;
if (j < 16)
{
f = (b & c) | (~b & d);
g = (uint)j;
}
else if (j < 32)
{
f = (d & b) | (~d & c);
g = (uint)((5 * j + 1) % 16);
}
else if (j < 48)
{
f = b ^ c ^ d;
g = (uint)((3 * j + 5) % 16);
}
else
{
f = c ^ (b | ~d);
g = (uint)((7 * j) % 16);
}
uint temp = d;
d = c;
c = b;
b += LeftRotate(a + f + T[j] + M[g], S(j));
a = temp;
}
a += AA;
b += BB;
c += CC;
d += DD;
}
byte[] output = new byte[16];
Array.Copy(BitConverter.GetBytes(a), 0, output, 0, 4);
Array.Copy(BitConverter.GetBytes(b), 0, output, 4, 4);
Array.Copy(BitConverter.GetBytes(c), 0, output, 8, 4);
Array.Copy(BitConverter.GetBytes(d), 0, output, 12, 4);
return output;
}
private static int S(int i)
{
int[] s = { 7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21 };
return s[(i / 16) * 4 + (i % 4)];
}
private static uint LeftRotate(uint x, int c) => (x << c) | (x >> (32 - c));
// Convenience method to get hex string
public static string GetString(string input)
{
var hash = ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
return BitConverter.ToString(hash).Replace("-", "");
}
}
@@ -95,4 +95,22 @@ public static class NotNullExtensions
}
return obj;
}
public static string NotNullOrWhiteSpace(
[NotNull] this string? value,
[CallerArgumentExpression(nameof(value))] string? paramName = null
)
{
if (value is null)
{
throw new ArgumentNullException(paramName ?? "Value is null");
}
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be empty or whitespace.", paramName);
}
return value;
}
}
@@ -6,17 +6,17 @@ using System.Text;
using System.Runtime.InteropServices;
#endif
namespace Speckle.Sdk.Helpers;
namespace Speckle.Sdk.Common;
public static class Crypt
public static class Sha256
{
#if NET6_0_OR_GREATER
/// <param name="input">the value to hash</param>
/// <param name="format"><c>"x2"</c> for lower case, <c>"X2"</c> for uppercase.</param>
/// <param name="length">Desired length of the returned string. Must be 2 &#x2264; Length &#x2264; 64, and must be a multiple of 2</param>
/// <returns><inheritdoc cref="Sha256(string, string?, int)"/></returns>
/// <returns><inheritdoc cref="GetString(string, string?, int)"/></returns>
[Pure]
public static string Sha256(
public static string GetString(
ReadOnlySpan<char> input,
[StringSyntax(StringSyntaxAttribute.NumericFormat)] string? format = "x2",
int length = SHA256.HashSizeInBytes * sizeof(char)
@@ -45,7 +45,7 @@ public static class Crypt
/// <exception cref="FormatException"><paramref name="format"/> is not a recognised numeric format</exception>
/// <exception cref="ArgumentOutOfRangeException"><inheritdoc cref="StringBuilder.ToString(int, int)"/></exception>
[Pure]
public static string Sha256(
public static string GetString(
string input,
[StringSyntax(StringSyntaxAttribute.NumericFormat)] string? format = "x2",
int length = 64
@@ -67,30 +67,4 @@ public static class Crypt
return sb.ToString(0, length);
}
/// <inheritdoc cref="Sha256(string, string?, int)"/>
/// <remarks>MD5 is a broken cryptographic algorithm and should be used subject to review see CA5351</remarks>
[Pure]
[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public static string Md5(
string input,
[StringSyntax(StringSyntaxAttribute.NumericFormat)] string? format = "x2",
int length = 32
)
{
byte[] inputBytes = Encoding.ASCII.GetBytes(input.ToLowerInvariant());
#if NETSTANDARD2_0
using MD5 md5 = MD5.Create();
byte[] hashBytes = md5.ComputeHash(inputBytes);
#else
byte[] hashBytes = MD5.HashData(inputBytes);
#endif
StringBuilder sb = new(32);
for (int i = 0; i < hashBytes.Length; i++)
{
sb.Append(hashBytes[i].ToString(format));
}
return sb.ToString(0, length);
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Common;
namespace Speckle.Sdk.Credentials;
@@ -25,7 +25,7 @@ public class Account : IEquatable<Account>
throw new InvalidOperationException("Incomplete account info: cannot generate id.");
}
_id = Crypt.Md5(userInfo.email + serverInfo.url, "X2");
_id = Md5.GetString(userInfo.email + serverInfo.url).ToUpperInvariant();
}
return _id;
}
@@ -34,7 +34,7 @@ public class Account : IEquatable<Account>
public string token { get; set; }
public string refreshToken { get; set; }
public string? refreshToken { get; set; }
public bool isDefault { get; set; }
public bool isOnline { get; set; } = true;
@@ -62,13 +62,13 @@ public class Account : IEquatable<Account>
public string GetHashedEmail()
{
string email = userInfo?.email ?? "unknown";
return "@" + Crypt.Md5(email, "X2");
return "@" + Md5.GetString(email).ToUpperInvariant();
}
public string GetHashedServer()
{
string url = serverInfo?.url ?? AccountManager.DEFAULT_SERVER_URL;
return Crypt.Md5(CleanURL(url), "X2");
return Md5.GetString(CleanURL(url)).ToUpperInvariant();
}
public override string ToString()
@@ -0,0 +1,108 @@
using System.Diagnostics;
using GraphQL;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Api.GraphQL;
using Speckle.Sdk.Api.GraphQL.Models;
namespace Speckle.Sdk.Credentials;
public partial interface IAccountFactory
{
internal Task<ActiveUserServerInfoResponse> GetUserServerInfo(Uri serverUrl, string? authToken, CancellationToken ct);
}
[GenerateAutoInterface]
public sealed class AccountFactory(IGraphQLClientFactory graphQLClientFactory) : IAccountFactory
{
/// <summary>
/// Gets the User and Server info required for <see cref="Account"/> object creation
/// </summary>
/// <param name="serverUrl"></param>
/// <param name="authToken">If <see lang="null"/>, the server will respond with a <see lang="null"/> <see cref="UserInfo"/></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="GetUserServerInfoInternal"/>
[DebuggerStepThrough]
async Task<ActiveUserServerInfoResponse> IAccountFactory.GetUserServerInfo(
Uri serverUrl,
string? authToken,
CancellationToken cancellationToken
) => await GetUserServerInfoInternal(serverUrl, authToken, cancellationToken).ConfigureAwait(false);
/// <exception cref="SpeckleException">Server could not find user info given the speckleToken, suggests expired or non-existent user</exception>
/// <inheritdoc cref="Speckle.Sdk.Api.GraphQL.GraphQLErrorHandler.EnsureGraphQLSuccess(IReadOnlyCollection{GraphQLError}?)"/>
private async Task<ActiveUserServerInfoResponse> GetUserServerInfoInternal(
Uri serverUrl,
string? authToken,
CancellationToken cancellationToken
)
{
using var client = graphQLClientFactory.CreateGraphQLClient(serverUrl, authToken);
//language=graphql
const string QUERY_STRING = """
query {
activeUser {
id
name
email
company
avatar
}
serverInfo {
name
company
description
version
migration {
movedFrom
movedTo
}
}
}
""";
var request = new GraphQLRequest { Query = QUERY_STRING };
var response = await client
.SendQueryAsync<ActiveUserServerInfoResponse>(request, cancellationToken)
.ConfigureAwait(false);
response.EnsureGraphQLSuccess();
ServerInfo serverInfo = response.Data.serverInfo;
serverInfo.url = serverUrl.ToString().TrimEnd('/');
return response.Data;
}
/// <summary>
/// Creates a new <see cref="Account"/> object by fetching the required server/user information from the specified server
/// </summary>
/// <remarks>
/// This does not create a new account on the server, nor does it read/write from the SQLite DB. For that see <see cref="AccountManager"/>.
/// This is just a Factory pattern around an <see cref="Account"/> object
/// </remarks>
/// <exception cref="SpeckleException">Server could not find user info given the speckleToken, suggests expired or non-existent user</exception>
/// <inheritdoc cref="Speckle.Sdk.Api.GraphQL.GraphQLErrorHandler.EnsureGraphQLSuccess(IReadOnlyCollection{GraphQLError}?)"/>
public async Task<Account> CreateAccount(
Uri serverUrl,
string speckleToken,
string? refreshToken = null,
CancellationToken cancellationToken = default
)
{
var res = await GetUserServerInfoInternal(serverUrl, speckleToken, cancellationToken).ConfigureAwait(false);
if (res.activeUser == null)
{
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
}
return new Account()
{
token = speckleToken,
refreshToken = refreshToken,
serverInfo = res.serverInfo,
userInfo = res.activeUser,
};
}
}
+16 -137
View File
@@ -12,7 +12,6 @@ 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.Api.GraphQL.Serializer;
using Speckle.Sdk.Common;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
@@ -30,7 +29,9 @@ public partial interface IAccountManager : IDisposable;
public sealed class AccountManager(
ISpeckleApplication application,
ILogger<AccountManager> logger,
IGraphQLClientFactory graphQLClientFactory,
ISpeckleHttp speckleHttp,
IAccountFactory accountFactory,
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
) : IAccountManager
{
@@ -58,39 +59,12 @@ public sealed class AccountManager(
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default)
{
using var httpClient = speckleHttp.CreateHttpClient();
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, null);
using var gqlClient = new GraphQLHttpClient(
new GraphQLHttpClientOptions
{
EndPoint = new Uri(server, "/graphql"),
UseWebSocketForQueriesAndMutations = false,
},
new NewtonsoftJsonSerializer(),
httpClient
);
//lang=graphql
const string QUERY_STRING = "query { serverInfo { name company migration { movedFrom movedTo } } }";
System.Version version = await gqlClient
.GetServerVersion(cancellationToken: cancellationToken)
.ConfigureAwait(false);
// serverMigration property was added in 2.18.5, so only query for it
// if the server has been updated past that version
System.Version serverMigrationVersion = new(2, 18, 5);
string queryString;
if (version >= serverMigrationVersion)
{
//language=graphql
queryString = "query { serverInfo { name company migration { movedFrom movedTo } } }";
}
else
{
//language=graphql
queryString = "query { serverInfo { name company } }";
}
var request = new GraphQLRequest { Query = queryString };
var request = new GraphQLRequest { Query = QUERY_STRING };
var response = await gqlClient.SendQueryAsync<ServerInfoResponse>(request, cancellationToken).ConfigureAwait(false);
@@ -98,7 +72,6 @@ public sealed class AccountManager(
ServerInfo serverInfo = response.Data.serverInfo;
serverInfo.url = server.ToString().TrimEnd('/');
serverInfo.frontend2 = await IsFrontend2Server(server).ConfigureAwait(false);
return response.Data.serverInfo;
}
@@ -113,13 +86,8 @@ public sealed class AccountManager(
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<UserInfo> GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default)
{
using var httpClient = speckleHttp.CreateHttpClient(authorizationToken: token);
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, token);
using var gqlClient = new GraphQLHttpClient(
new GraphQLHttpClientOptions { EndPoint = new Uri(server, "/graphql") },
new NewtonsoftJsonSerializer(),
httpClient
);
//language=graphql
const string QUERY = """
query {
@@ -142,59 +110,6 @@ public sealed class AccountManager(
return response.Data.data;
}
/// <summary>
/// Gets basic user and server information given a token and a server.
/// </summary>
/// <param name="token"></param>
/// <param name="server">Server URL</param>
/// <returns></returns>
internal async Task<ActiveUserServerInfoResponse> GetUserServerInfo(
string token,
Uri server,
CancellationToken ct = default
)
{
using var httpClient = speckleHttp.CreateHttpClient(authorizationToken: token);
using var client = new GraphQLHttpClient(
new GraphQLHttpClientOptions { EndPoint = new Uri(server, "/graphql") },
new NewtonsoftJsonSerializer(),
httpClient
);
System.Version version = await client.GetServerVersion(ct).ConfigureAwait(false);
// serverMigration property was added in 2.18.5, so only query for it
// if the server has been updated past that version
System.Version serverMigrationVersion = new(2, 18, 5);
string queryString;
if (version >= serverMigrationVersion)
{
//language=graphql
queryString =
"query { activeUser { id name email company avatar streams { totalCount } commits { totalCount } } serverInfo { name company adminContact description version migration { movedFrom movedTo } } }";
}
else
{
//language=graphql
queryString =
"query { activeUser { id name email company avatar streams { totalCount } commits { totalCount } } serverInfo { name company adminContact description version } }";
}
var request = new GraphQLRequest { Query = queryString };
var response = await client.SendQueryAsync<ActiveUserServerInfoResponse>(request, ct).ConfigureAwait(false);
response.EnsureGraphQLSuccess();
ServerInfo serverInfo = response.Data.serverInfo;
serverInfo.url = server.ToString().TrimEnd('/');
serverInfo.frontend2 = await IsFrontend2Server(server).ConfigureAwait(false);
return response.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>
@@ -254,7 +169,6 @@ public sealed class AccountManager(
account.serverInfo.migration.movedTo = null;
account.serverInfo.migration.movedFrom = new Uri(account.serverInfo.url);
account.serverInfo.url = upgradeUri.ToString().TrimEnd('/');
account.serverInfo.frontend2 = true;
// setting the id to null will force it to be recreated
account.id = null!; //TODO this is gross so remove when id is nullable
@@ -410,11 +324,11 @@ public sealed class AccountManager(
try
{
Uri url = new(account.serverInfo.url);
var userServerInfo = await GetUserServerInfo(account.token, url, ct).ConfigureAwait(false);
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)
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.
@@ -635,16 +549,13 @@ public sealed class AccountManager(
try
{
var tokenResponse = await GetToken(accessCode, challenge, server).ConfigureAwait(false);
var userResponse = await GetUserServerInfo(tokenResponse.token, server).ConfigureAwait(false);
var account = new Account
{
token = tokenResponse.token,
refreshToken = tokenResponse.refreshToken,
isDefault = !GetAccounts().Any(),
serverInfo = userResponse.serverInfo,
userInfo = userResponse.activeUser,
};
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;
@@ -781,7 +692,7 @@ public sealed class AccountManager(
}
}
private async Task<TokenExchangeResponse> GetRefreshedToken(string refreshToken, Uri server, string app = "sca")
private async Task<TokenExchangeResponse> GetRefreshedToken(string? refreshToken, Uri server, string app = "sca")
{
try
{
@@ -808,38 +719,6 @@ public sealed class AccountManager(
}
}
/// <summary>
/// Sends a simple get request to the <paramref name="server"/>, and checks the response headers for a <c>"x-speckle-frontend-2"</c> <see cref="Boolean"/> value
/// </summary>
/// <param name="server">Server endpoint to get header</param>
/// <returns><see langword="true"/> if response contains FE2 header and the value was <see langword="true"/></returns>
/// <exception cref="SpeckleException">response contained FE2 header, but the value was <see langword="null"/>, empty, or not parseable to a <see cref="Boolean"/></exception>
/// <exception cref="System.Net.Http.HttpRequestException">Request to <paramref name="server"/> failed to send or response was not successful</exception>
private async Task<bool> IsFrontend2Server(Uri server)
{
using var httpClient = speckleHttp.CreateHttpClient();
var response = await speckleHttp.HttpPing(server).ConfigureAwait(false);
var headers = response.Headers;
const string HEADER = "x-speckle-frontend-2";
if (!headers.TryGetValues(HEADER, out IEnumerable<string>? values))
{
return false;
}
string? headerValue = values.FirstOrDefault();
if (!bool.TryParse(headerValue, out bool value))
{
throw new SpeckleException(
$"Headers contained {HEADER} header, but value {headerValue} could not be parsed to a bool"
);
}
return value;
}
private static string GenerateChallenge()
{
#if NET8_0
@@ -0,0 +1,91 @@
using System.Net.WebSockets;
using System.Reflection;
using GraphQL.Client.Http;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Newtonsoft.Json.Serialization;
using Speckle.Sdk.Api.GraphQL.Serializer;
using Speckle.Sdk.Helpers;
namespace Speckle.Sdk.Credentials;
[GenerateAutoInterface]
public class GraphQLClientFactory(
ISpeckleApplication application,
ISpeckleHttp speckleHttp,
ILogger<GraphQLClientFactory> logger
) : IGraphQLClientFactory
{
/// <summary>
/// <inheritdoc cref="CreateGraphQLClient(Uri, string)"/>
/// </summary>
/// <param name="account">The account to use for authentication</param>
/// <returns></returns>
public GraphQLHttpClient CreateGraphQLClient(Account account)
{
return CreateGraphQLClient(new(account.serverInfo.url), account.token);
}
/// <summary>
/// Creates a <see cref="GraphQLHttpClient"/> configured for communication with a Speckle server
/// </summary>
/// <param name="serverUrl">The base url of the speckle server to communicate with</param>
/// <param name="authToken">If provided, all requests will be authenticated</param>
/// <returns></returns>
public GraphQLHttpClient CreateGraphQLClient(Uri serverUrl, string? authToken)
{
var gQLClient = new GraphQLHttpClient(
new GraphQLHttpClientOptions
{
EndPoint = new Uri(serverUrl, "/graphql"),
UseWebSocketForQueriesAndMutations = false,
WebSocketProtocol = "graphql-ws",
ConfigureWebSocketConnectionInitPayload = _ =>
{
return SpeckleHttp.CanAddAuth(authToken, out string? authValue) ? new { Authorization = authValue } : null;
},
},
new NewtonsoftJsonSerializer(
new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver { IgnoreIsSpecifiedMembers = true }, //(Default)
MissingMemberHandling = MissingMemberHandling.Error, //(not default) If you query for a member that doesn't exist, this will throw (except websocket responses see https://github.com/graphql-dotnet/graphql-client/issues/660)
NullValueHandling = NullValueHandling.Ignore, //(not default) We won't serialize nulls, as can open more opportunity for conflicting with servers that are old and don't have the latest schema
Converters = { new ConstantCaseEnumConverter() }, //(Default) enums will be serialized using the GraphQL const case standard
}
),
CreateHttpClient(authToken)
);
gQLClient.WebSocketReceiveErrors.Subscribe(ex =>
{
if (ex is WebSocketException we)
{
logger.LogError(
we,
"GraphQL Websocket received an {WebSocketErrorCode} ({NativeErrorCode}) error that has been swallowed",
we.WebSocketErrorCode,
we.ErrorCode
);
}
else
{
logger.LogError(ex, "GraphQL Websocket received an error that has been swallowed");
}
});
return gQLClient;
}
private HttpClient CreateHttpClient(string? token)
{
var httpClient = speckleHttp.CreateHttpClient(timeoutSeconds: 30, authorizationToken: token);
httpClient.DefaultRequestHeaders.Add("apollographql-client-name", application.ApplicationAndVersion);
httpClient.DefaultRequestHeaders.Add(
"apollographql-client-version",
Assembly.GetExecutingAssembly().GetName().Version?.ToString()
);
return httpClient;
}
}
+5 -1
View File
@@ -1,10 +1,14 @@
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL.Models;
namespace Speckle.Sdk.Credentials;
internal sealed class ActiveUserServerInfoResponse
{
public UserInfo activeUser { get; init; }
[property: JsonProperty(Required = Required.AllowNull)]
public UserInfo? activeUser { get; init; }
[property: JsonProperty(Required = Required.Always)]
public ServerInfo serverInfo { get; init; }
}
+1 -3
View File
@@ -51,9 +51,7 @@ public class SpeckleHttp(ILogger<SpeckleHttp> logger, ISpeckleHttpClientHandlerF
var client = new HttpClient(speckleHandler)
{
Timeout =
Timeout.InfiniteTimeSpan //timeout is configured on the SpeckleHttpClientHandler through policy
,
Timeout = Timeout.InfiniteTimeSpan, //timeout is configured on the SpeckleHttpClientHandler through policy
};
AddAuthHeader(client, authorizationToken);
return client;
@@ -1,6 +1,7 @@
using System.Text;
using Microsoft.Data.Sqlite;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Common;
using Speckle.Sdk.Dependencies;
namespace Speckle.Sdk.SQLite;
@@ -120,7 +121,10 @@ public sealed class SqLiteJsonCacheManager : ISqLiteJsonCacheManager
);
//This does an insert or ignores if already exists
public void SaveObject(string id, string json) =>
public void SaveObject(string id, string json)
{
id.NotNullOrWhiteSpace();
json.NotNullOrWhiteSpace();
_pool.Use(
CacheOperation.InsertOrIgnore,
command =>
@@ -130,6 +134,7 @@ public sealed class SqLiteJsonCacheManager : ISqLiteJsonCacheManager
command.ExecuteNonQuery();
}
);
}
//This does an insert or replaces if already exists
public void UpdateObject(string id, string json) =>
@@ -148,29 +153,45 @@ public sealed class SqLiteJsonCacheManager : ISqLiteJsonCacheManager
CacheOperation.BulkInsertOrIgnore,
cmd =>
{
CreateBulkInsert(cmd, items);
return cmd.ExecuteNonQuery();
if (CreateBulkInsert(cmd, items))
{
cmd.ExecuteNonQuery();
}
}
);
private void CreateBulkInsert(SqliteCommand cmd, IEnumerable<(string id, string json)> items)
private bool CreateBulkInsert(SqliteCommand cmd, IEnumerable<(string id, string json)> items)
{
StringBuilder sb = Pools.StringBuilders.Get();
sb.AppendLine(CacheDbCommands.Commands[(int)CacheOperation.BulkInsertOrIgnore]);
int i = 0;
foreach (var (id, json) in items)
try
{
sb.Append($"(@key{i}, @value{i}),");
cmd.Parameters.AddWithValue($"@key{i}", id);
cmd.Parameters.AddWithValue($"@value{i}", json);
i++;
}
sb.Remove(sb.Length - 1, 1);
sb.Append(';');
sb.AppendLine(CacheDbCommands.Commands[(int)CacheOperation.BulkInsertOrIgnore]);
int i = 0;
foreach (var (id, json) in items)
{
sb.Append($"(@key{i}, @value{i}),");
cmd.Parameters.AddWithValue($"@key{i}", id);
cmd.Parameters.AddWithValue($"@value{i}", json);
i++;
}
if (i == 0)
{
return false;
}
sb.Remove(sb.Length - 1, 1);
sb.Append(';');
#pragma warning disable CA2100
cmd.CommandText = sb.ToString();
cmd.CommandText = sb.ToString();
#pragma warning restore CA2100
Pools.StringBuilders.Return(sb);
}
finally
{
Pools.StringBuilders.Return(sb);
}
return true;
}
public bool HasObject(string objectId) =>
+3 -3
View File
@@ -1,5 +1,5 @@
using System.Diagnostics.Contracts;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Common;
using Speckle.Sdk.Models;
namespace Speckle.Sdk.Serialisation;
@@ -10,9 +10,9 @@ public static class IdGenerator
public static Id ComputeId(Json serialized)
{
#if NET6_0_OR_GREATER
string hash = Crypt.Sha256(serialized.Value.AsSpan(), length: HashUtility.HASH_LENGTH);
string hash = Sha256.GetString(serialized.Value.AsSpan(), length: HashUtility.HASH_LENGTH);
#else
string hash = Crypt.Sha256(serialized.Value, length: HashUtility.HASH_LENGTH);
string hash = Sha256.GetString(serialized.Value, length: HashUtility.HASH_LENGTH);
#endif
return new Id(hash);
}
@@ -2,13 +2,25 @@
public readonly record struct SerializationResult(Json Json, Id? Id);
public readonly record struct Json(string Value)
public readonly record struct Json
{
public Json(string json)
{
Value = json ?? throw new ArgumentNullException(nameof(json));
}
public override string ToString() => Value;
public string Value { get; }
}
public readonly record struct Id(string Value)
public readonly record struct Id
{
public Id(string id)
{
Value = id ?? throw new ArgumentNullException(nameof(id));
}
public override string ToString() => Value;
public bool Equals(Id? other)
@@ -22,4 +34,6 @@ public readonly record struct Id(string Value)
}
public override int GetHashCode() => Value.GetHashCode();
public string Value { get; }
}
@@ -5,7 +5,10 @@ namespace Speckle.Sdk.Serialisation.Utilities;
public static class ClosureParser
{
public static IReadOnlyList<(string, int)> GetClosures(string json, CancellationToken cancellationToken)
public static IReadOnlyList<(string, int)> GetClosures(string json, CancellationToken cancellationToken) =>
GetClosuresPrivate(json, cancellationToken);
private static List<(string, int)> GetClosuresPrivate(string json, CancellationToken cancellationToken)
{
try
{
@@ -31,10 +34,17 @@ public static class ClosureParser
return [];
}
public static IReadOnlyList<(string, int)> GetClosuresSorted(string json, CancellationToken cancellationToken)
{
var closures = GetClosuresPrivate(json, cancellationToken);
closures.Sort((a, b) => b.Item2.CompareTo(a.Item2));
return closures;
}
public static IEnumerable<string> GetChildrenIds(string json, CancellationToken cancellationToken) =>
GetClosures(json, cancellationToken).Select(x => x.Item1);
private static IReadOnlyList<(string, int)> ReadObject(JsonTextReader reader, CancellationToken cancellationToken)
private static List<(string, int)> ReadObject(JsonTextReader reader, CancellationToken cancellationToken)
{
reader.Read();
while (reader.TokenType != JsonToken.EndObject)
@@ -7,6 +7,8 @@ using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Serialisation.V2;
/// <seealso cref="DeserializeProcessFactoryNoCache"/>
/// <seealso cref="DeserializeProcess"/>
[GenerateAutoInterface]
public class DeserializeProcessFactory(
IBaseDeserializer baseDeserializer,
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Serialisation.V2.Receive;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Serialisation.V2;
/// <summary>
/// A version of <seealso cref="DeserializeProcessFactory"/> but without any SQLite usage.
/// This class doesn't have a matching <seealso cref="GenerateAutoInterfaceAttribute"/>, so will not be registered by <seealso cref="ServiceRegistration"/> automatically
/// Instead consumers can register this to override the default <seealso cref="DeserializeProcessFactory"/>
/// </summary>
/// <seealso cref="DeserializeProcessFactory"/>
/// <seealso cref="DeserializeProcess"/>
public sealed class DeserializeProcessFactoryNoCache(
IBaseDeserializer baseDeserializer,
IServerObjectManagerFactory serverObjectManagerFactory,
ILoggerFactory loggerFactory
) : IDeserializeProcessFactory
{
public IDeserializeProcess CreateDeserializeProcess(
Uri url,
string streamId,
string? authorizationToken,
IProgress<ProgressArgs>? progress,
CancellationToken cancellationToken,
DeserializeProcessOptions? options = null
)
{
var sqLiteJsonCacheManager = new MemoryJsonCacheManager(new());
var serverObjectManager = serverObjectManagerFactory.Create(url, streamId, authorizationToken);
return new DeserializeProcess(
sqLiteJsonCacheManager,
serverObjectManager,
progress,
baseDeserializer,
loggerFactory,
cancellationToken,
options
);
}
public IDeserializeProcess CreateDeserializeProcess(
ConcurrentDictionary<Id, Json> jsonCache,
ConcurrentDictionary<string, string> objects,
IProgress<ProgressArgs>? progress,
CancellationToken cancellationToken,
DeserializeProcessOptions? options = null
) =>
new DeserializeProcess(
new MemoryJsonCacheManager(jsonCache),
new MemoryServerObjectManager(objects),
progress,
baseDeserializer,
loggerFactory,
cancellationToken,
options
);
}
@@ -41,7 +41,13 @@ public sealed class DeserializeProcess(
:
#pragma warning disable CA2000
this(
new ObjectLoader(sqLiteJsonCacheManager, serverObjectManager, progress, cancellationToken),
new ObjectLoader(
sqLiteJsonCacheManager,
serverObjectManager,
progress,
loggerFactory.CreateLogger<ObjectLoader>(),
cancellationToken
),
progress,
baseDeserializer,
loggerFactory,
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Common;
using Speckle.Sdk.Dependencies;
@@ -16,6 +17,7 @@ public sealed class ObjectLoader(
ISqLiteJsonCacheManager sqLiteJsonCacheManager,
IServerObjectManager serverObjectManager,
IProgress<ProgressArgs>? progress,
ILogger<ObjectLoader> logger,
CancellationToken cancellationToken
#pragma warning disable CS9107 // Parameter is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
) : ChannelLoader<BaseItem>(cancellationToken), IObjectLoader
@@ -43,7 +45,10 @@ public sealed class ObjectLoader(
if (rootJson != null)
{
//assume everything exists as the root is there.
var allChildren = ClosureParser.GetChildrenIds(rootJson, cancellationToken).Select(x => new Id(x)).ToList();
var allChildren = ClosureParser
.GetClosuresSorted(rootJson, cancellationToken)
.Select(x => new Id(x.Item1))
.ToList();
//this probably yields away from the Main thread to let host apps update progress
//in any case, this fixes a Revit only issue for this situation
await Task.Yield();
@@ -129,12 +134,32 @@ public sealed class ObjectLoader(
[AutoInterfaceIgnore]
protected override void SaveToCacheInternal(List<BaseItem> batch)
{
if (!_options.SkipCache)
try
{
cancellationToken.ThrowIfCancellationRequested();
sqLiteJsonCacheManager.SaveObjects(batch.Select(x => (x.Id.Value, x.Json.Value)));
Interlocked.Exchange(ref _cached, _cached + batch.Count);
progress?.Report(new(ProgressEvent.CachedToLocal, _cached, _allChildrenCount));
if (!_options.SkipCache)
{
cancellationToken.ThrowIfCancellationRequested();
sqLiteJsonCacheManager.SaveObjects(batch.Select(x => (x.Id.Value, x.Json.Value)));
Interlocked.Exchange(ref _cached, _cached + batch.Count);
progress?.Report(new(ProgressEvent.CachedToLocal, _cached, _allChildrenCount));
}
}
catch (OperationCanceledException)
{
throw;
}
#pragma warning disable CA1031
catch (Exception)
#pragma warning restore CA1031
{
logger.LogError(
"Error while saving to cache, some stats of the payload: {Count} objects, {Serialized} serialized, {Cached} cached, {BatchByteSize} batch bytes",
batch.Count,
_allChildrenCount,
_cached,
batch.Sum(x => x.ByteSize)
);
throw;
}
}
@@ -0,0 +1,58 @@
namespace Speckle.Sdk.Serialisation.V2.Send;
public static class ClosureMath
{
public static void IncrementClosures(this Dictionary<Id, int> current, IEnumerable<KeyValuePair<Id, int>> child)
{
foreach (var closure in child)
{
if (current.TryGetValue(closure.Key, out var count))
{
current[closure.Key] = Math.Max(closure.Value, count) + 1;
}
else
{
current[closure.Key] = closure.Value + 1;
}
}
}
public static void MergeClosures(this Dictionary<Id, int> current, IEnumerable<KeyValuePair<Id, int>> child)
{
foreach (var closure in child)
{
if (current.TryGetValue(closure.Key, out var count))
{
current[closure.Key] = Math.Max(closure.Value, count);
}
else
{
current[closure.Key] = closure.Value;
}
}
}
public static void IncrementClosure(this Dictionary<Id, int> current, Id id)
{
if (current.TryGetValue(id, out var count))
{
current[id] = count + 1;
}
else
{
current[id] = 1;
}
}
public static void MergeClosure(this Dictionary<Id, int> current, Id id)
{
if (current.TryGetValue(id, out var count))
{
current[id] = count;
}
else
{
current[id] = 1;
}
}
}
@@ -34,7 +34,7 @@ public sealed class ObjectSaver(
private readonly SerializeProcessOptions _options = options ?? new();
private long _uploaded;
private long _uploading;
private long _cached;
private long _objectsSerialized;
@@ -56,13 +56,12 @@ public sealed class ObjectSaver(
objectBatch = batch.Items.Where(x => !hasObjects[x.Id.Value]).ToList();
if (objectBatch.Count != 0)
{
Interlocked.Add(ref _uploading, batch.Items.Count);
progress?.Report(new(ProgressEvent.UploadingObjects, _uploading, null));
await serverObjectManager
.UploadObjects(objectBatch, true, progress, _cancellationTokenSource.Token)
.ConfigureAwait(false);
Interlocked.Add(ref _uploaded, batch.Items.Count);
}
progress?.Report(new(ProgressEvent.UploadedObjects, _uploaded, null));
}
}
catch (OperationCanceledException)
@@ -74,6 +73,13 @@ public sealed class ObjectSaver(
#pragma warning restore CA1031
{
RecordException(e);
logger.LogError(
"Error while sending objects to server, some stats of the payload: {Count} objects, {Serialized} serialized, {Cached} cached, {BatchByteSize} batch bytes",
batch.Items.Count,
_objectsSerialized,
_cached,
batch.BatchByteSize
);
}
}
@@ -107,13 +113,20 @@ public sealed class ObjectSaver(
#pragma warning restore CA1031
{
RecordException(e);
logger.LogError(
"Error while saving to cache, some stats of the payload: {Count} objects, {Serialized} serialized, {Cached} cached, {BatchByteSize} batch bytes",
batch.Count,
_objectsSerialized,
_cached,
batch.Sum(x => x.ByteSize)
);
}
}
private void RecordException(Exception e)
{
//order here matters
logger.LogError(e, "Error in SDK");
logger.LogError(e, "Error in SDK: {message}", e.Message);
Exception = e;
_cancellationTokenSource.Cancel();
}
@@ -25,7 +25,6 @@ public partial interface IObjectSerializer : IDisposable;
public sealed class ObjectSerializer : IObjectSerializer
{
private HashSet<object> _parentObjects = new();
private readonly Dictionary<Id, int> _currentClosures = new();
private readonly IReadOnlyDictionary<Id, NodeInfo> _childCache;
@@ -92,15 +91,16 @@ public sealed class ObjectSerializer : IObjectSerializer
try
{
(Id, Json) item;
Closures closures = [];
try
{
item = SerializeBase(baseObj, true, default).NotNull();
item = SerializeBase(baseObj, true, closures, default).NotNull();
}
catch (Exception ex) when (!ex.IsFatal() && ex is not OperationCanceledException)
{
throw new SpeckleSerializeException($"Failed to extract (pre-serialize) properties from the {baseObj}", ex);
}
yield return (item.Item1, item.Item2, _currentClosures);
yield return (item.Item1, item.Item2, closures);
foreach (var chunk in _chunks)
{
yield return chunk;
@@ -114,7 +114,12 @@ public sealed class ObjectSerializer : IObjectSerializer
// `Preserialize` means transforming all objects into the final form that will appear in json, with basic .net objects
// (primitives, lists and dictionaries with string keys)
private void SerializeProperty(object? obj, JsonWriter writer, PropertyAttributeInfo propertyAttributeInfo)
private void SerializeProperty(
object? obj,
JsonWriter writer,
Closures closures,
PropertyAttributeInfo propertyAttributeInfo
)
{
_cancellationToken.ThrowIfCancellationRequested();
@@ -161,17 +166,14 @@ public sealed class ObjectSerializer : IObjectSerializer
["referencedId"] = r.referencedId,
["__closure"] = r.closure,
};
closures.IncrementClosure(new(r.referencedId));
//references can be externally provided and need to know the ids in the closure and reference here
//AddClosure can take the same value twice
foreach (var kvp in r.closure.Empty())
{
AddClosure(new(kvp.Key));
}
AddClosure(new(r.referencedId));
SerializeProperty(ret, writer, default);
closures.IncrementClosures(r.closure.Empty().Select(x => new KeyValuePair<Id, int>(new Id(x.Key), x.Value)));
SerializeProperty(ret, writer, closures, default);
break;
case Base b:
var result = SerializeBase(b, false, propertyAttributeInfo);
var result = SerializeBase(b, false, closures, propertyAttributeInfo);
if (result is not null)
{
writer.WriteRawValue(result.Value.Item2.Value);
@@ -196,7 +198,7 @@ public sealed class ObjectSerializer : IObjectSerializer
}
writer.WritePropertyName(key);
SerializeProperty(kvp.Value, writer, propertyAttributeInfo);
SerializeProperty(kvp.Value, writer, closures, propertyAttributeInfo);
}
writer.WriteEndObject();
}
@@ -206,7 +208,7 @@ public sealed class ObjectSerializer : IObjectSerializer
writer.WriteStartArray();
foreach (object? element in e)
{
SerializeProperty(element, writer, propertyAttributeInfo);
SerializeProperty(element, writer, closures, propertyAttributeInfo);
}
writer.WriteEndArray();
}
@@ -253,7 +255,12 @@ public sealed class ObjectSerializer : IObjectSerializer
}
}
private (Id, Json)? SerializeBase(Base baseObj, bool isRoot, PropertyAttributeInfo inheritedDetachInfo)
private (Id, Json)? SerializeBase(
Base baseObj,
bool isRequestedObject,
Closures closures,
PropertyAttributeInfo inheritedDetachInfo
)
{
// handle circular references
bool alreadySerialized = !_parentObjects.Add(baseObj);
@@ -272,67 +279,65 @@ public sealed class ObjectSerializer : IObjectSerializer
return new(json, id);*/
}
var isDataChunk = baseObj is DataChunk;
if (inheritedDetachInfo.IsDetachable)
{
Closures childClosures;
Id id;
Json json;
//avoid multiple serialization to get closures
if (baseObj.id != null && _childCache.TryGetValue(new(baseObj.id), out var info))
{
id = new Id(baseObj.id);
childClosures = info.GetClosures(_cancellationToken);
json = info.Json;
MergeClosures(_currentClosures, childClosures);
}
else
{
if (isDataChunk) //datachunks never have child closures
{
childClosures = [];
}
else
{
childClosures = isRoot || inheritedDetachInfo.IsDetachable ? _currentClosures : [];
}
var sb = Pools.StringBuilders.Get();
using var writer = new StringWriter(sb);
using var jsonWriter = SpeckleObjectSerializerPool.Instance.GetJsonTextWriter(writer);
id = SerializeBaseObject(baseObj, jsonWriter, childClosures);
json = new Json(writer.ToString());
Pools.StringBuilders.Return(sb);
}
var json2 = ReferenceGenerator.CreateReference(id);
AddClosure(id);
// add to obj refs to return
if (baseObj.applicationId != null) // && baseObj is not DataChunk && baseObj is not Abstract) // not needed, as data chunks will never have application ids, and abstract objs are not really used.
{
ObjectReferences[new(baseObj.applicationId)] = new ObjectReference()
{
referencedId = id.Value,
applicationId = baseObj.applicationId,
closure = childClosures.ToDictionary(x => x.Key.Value, x => x.Value),
};
}
_chunks.Add(new(id, json, []));
return new(id, json2);
return SerializeDetachedBase(baseObj, closures);
}
//do attached
Closures childClosures = [];
var sb = Pools.StringBuilders.Get();
using var writer = new StringWriter(sb);
using var jsonWriter = SpeckleObjectSerializerPool.Instance.GetJsonTextWriter(writer);
var id = SerializeBaseWithClosures(baseObj, jsonWriter, childClosures, isRequestedObject);
//don't increment attached objects
closures.MergeClosures(childClosures);
var json = new Json(writer.ToString());
Pools.StringBuilders.Return(sb);
return new(id, json);
}
private (Id, Json)? SerializeDetachedBase(Base baseObj, Closures closures)
{
Closures childClosures;
Id id;
Json json;
//avoid multiple serialization to get closures
if (baseObj.id != null && _childCache.TryGetValue(new(baseObj.id), out var info))
{
id = new Id(baseObj.id);
childClosures = info.GetClosures(_cancellationToken);
json = info.Json;
closures.IncrementClosures(childClosures);
}
else
{
var childClosures = isRoot || inheritedDetachInfo.IsDetachable ? _currentClosures : [];
childClosures = [];
var sb = Pools.StringBuilders.Get();
using var writer = new StringWriter(sb);
using var jsonWriter = SpeckleObjectSerializerPool.Instance.GetJsonTextWriter(writer);
var id = SerializeBaseObject(baseObj, jsonWriter, childClosures);
var json = new Json(writer.ToString());
id = SerializeBaseWithClosures(baseObj, jsonWriter, childClosures, true);
closures.IncrementClosures(childClosures);
json = new Json(writer.ToString());
Pools.StringBuilders.Return(sb);
return new(id, json);
}
var json2 = ReferenceGenerator.CreateReference(id);
closures.MergeClosure(id);
// add to obj refs to return
if (baseObj.applicationId != null) // && baseObj is not DataChunk && baseObj is not Abstract) // not needed, as data chunks will never have application ids, and abstract objs are not really used.
{
ObjectReferences[new(baseObj.applicationId)] = new ObjectReference()
{
referencedId = id.Value,
applicationId = baseObj.applicationId,
closure = childClosures.ToDictionary(x => x.Key.Value, x => x.Value),
};
}
_chunks.Add(new(id, json, []));
return new(id, json2);
}
private Id SerializeBaseObject(Base baseObj, JsonWriter writer, Closures closure)
private Id SerializeBaseWithClosures(Base baseObj, JsonWriter writer, Closures closures, bool writeClosures)
{
if (baseObj is not Blob)
{
@@ -349,7 +354,7 @@ public sealed class ObjectSerializer : IObjectSerializer
}
writer.WritePropertyName(prop.Name);
SerializeOrChunkProperty(prop.Value, writer, prop.PropertyAttributeInfo);
SerializeOrChunkProperty(prop.Value, writer, closures, prop.PropertyAttributeInfo);
}
Id id;
@@ -366,11 +371,11 @@ public sealed class ObjectSerializer : IObjectSerializer
writer.WriteValue(id.Value);
baseObj.id = id.Value;
if (closure.Count > 0)
if (writeClosures && closures.Count > 0)
{
writer.WritePropertyName("__closure");
writer.WriteStartObject();
foreach (var c in closure)
foreach (var c in closures)
{
writer.WritePropertyName(c.Key.Value);
writer.WriteValue(c.Value);
@@ -392,6 +397,7 @@ public sealed class ObjectSerializer : IObjectSerializer
private void SerializeOrChunkProperty(
object? baseValue,
JsonWriter jsonWriter,
Closures closures,
PropertyAttributeInfo propertyAttributeInfo
)
{
@@ -417,20 +423,10 @@ public sealed class ObjectSerializer : IObjectSerializer
chunks.Add(crtChunk);
}
SerializeProperty(chunks, jsonWriter, new PropertyAttributeInfo(true, false, 0, null));
SerializeProperty(chunks, jsonWriter, closures, new PropertyAttributeInfo(true, false, 0, null));
return;
}
SerializeProperty(baseValue, jsonWriter, propertyAttributeInfo);
SerializeProperty(baseValue, jsonWriter, closures, propertyAttributeInfo);
}
private static void MergeClosures(Dictionary<Id, int> current, Closures child)
{
foreach (var closure in child)
{
current[closure.Key] = 100;
}
}
private void AddClosure(Id id) => _currentClosures[id] = 100;
}
@@ -26,9 +26,11 @@ public readonly record struct SerializeProcessResults(
IReadOnlyDictionary<Id, ObjectReference> ConvertedReferences
);
public partial interface ISerializeProcess : IAsyncDisposable;
public interface ISerializeProcess : IAsyncDisposable
{
Task<SerializeProcessResults> Serialize(Base root);
}
[GenerateAutoInterface]
public sealed class SerializeProcess(
IProgress<ProgressArgs>? progress,
IObjectSaver objectSaver,
@@ -69,6 +71,10 @@ public sealed class SerializeProcess(
NodeInfo
>();
private readonly Pool<List<Task<Dictionary<Id, NodeInfo>>>> _taskResultPool = Pools.CreateListPool<
Task<Dictionary<Id, NodeInfo>>
>();
private long _objectCount;
private long _objectsFound;
@@ -163,7 +169,7 @@ public sealed class SerializeProcess(
try
{
var tasks = new List<Task<Dictionary<Id, NodeInfo>>>();
var tasks = _taskResultPool.Get();
foreach (var child in baseChildFinder.GetChildren(obj))
{
// tmp is necessary because of the way closures close over loop variables
@@ -190,30 +196,27 @@ public sealed class SerializeProcess(
return EMPTY_CLOSURES;
}
List<Dictionary<Id, NodeInfo>> taskClosures = new();
Dictionary<Id, NodeInfo>[] taskClosures = [];
if (tasks.Count > 0)
{
var currentTasks = tasks.ToList();
do
//get child results
var childTask = Task.WhenAll(tasks);
await Task.WhenAny(childTask, Task.Delay(Timeout.InfiniteTimeSpan, _processSource.Token)).ConfigureAwait(false);
if (childTask.IsFaulted)
{
//grab when any Task is done and see if we're cancelling
var t = await Task.WhenAny(currentTasks).ConfigureAwait(false);
if (t.IsCanceled)
if (childTask.Exception is not null)
{
return EMPTY_CLOSURES;
RecordException(childTask.Exception);
}
if (t.IsFaulted)
{
if (t.Exception is not null)
{
RecordException(t.Exception);
}
return EMPTY_CLOSURES;
}
taskClosures.Add(t.Result);
currentTasks.Remove(t);
} while (currentTasks.Count > 0);
return EMPTY_CLOSURES;
}
if (!childTask.IsCompleted)
{
return EMPTY_CLOSURES;
}
taskClosures = childTask.Result;
}
_taskResultPool.Return(tasks);
if (_processSource.Token.IsCancellationRequested)
{
@@ -287,10 +290,22 @@ public sealed class SerializeProcess(
}
}
private void RecordException(Exception e)
public void RecordException(Exception e)
{
if (e is OperationCanceledException)
{
return;
}
if (
e is AggregateException ae
&& ae.InnerExceptions.Count == ae.InnerExceptions.OfType<OperationCanceledException>().Count()
)
{
return;
}
//order here matters
_logger.LogError(e, "Error in SDK");
_logger.LogError(e, "Error in SDK: {message}", e.Message);
objectSaver.Exception = e;
_processSource.Cancel();
}
+1 -1
View File
@@ -23,7 +23,6 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="GraphQL.Client" />
<PackageReference Include="Microsoft.CSharp" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Speckle.DoubleNumerics" />
<PackageReference Include="Speckle.Newtonsoft.Json" />
@@ -33,6 +32,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" OverrideVersion="8.0.0" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Microsoft.CSharp" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
+1 -1
View File
@@ -9,7 +9,7 @@ public enum ProgressEvent
FromCacheOrSerialized,
FindingChildren,
UploadBytes,
UploadedObjects,
UploadingObjects,
CacheCheck,
DownloadBytes,
-6
View File
@@ -306,12 +306,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.CSharp": {
"type": "Direct",
"requested": "[4.7.0, )",
"resolved": "4.7.0",
"contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA=="
},
"Microsoft.Data.Sqlite": {
"type": "Direct",
"requested": "[7.0.5, )",
+2
View File
@@ -0,0 +1,2 @@
schema: https://app.speckle.systems/graphql
documents: '**/*.graphql'
@@ -0,0 +1,84 @@
using GraphQL;
using Speckle.Sdk.Api;
namespace Speckle.Automate.Sdk.Integration;
public struct FunctionRun
{
public string StatusMessage { get; set; }
}
public struct AutomationRun
{
public string Status { get; set; }
public IList<FunctionRun> FunctionRuns { get; set; }
}
public struct AutomationStatus
{
public string Status { get; set; }
public IList<AutomationRun> AutomationRuns { get; set; }
}
public struct ModelAutomationStatus
{
public AutomationStatus AutomationStatus { get; set; }
}
public struct ProjectAutomationStatus
{
public ModelAutomationStatus Model { get; set; }
}
public struct AutomationStatusResponseModel
{
public ProjectAutomationStatus Project { get; set; }
}
public static class AutomationStatusOperations
{
public static async Task<AutomationStatus> Get(string projectId, string modelId, IClient speckleClient)
{
//language=graphql
GraphQLRequest query = new(
"""
query AutomationRuns($projectId: String!, $modelId: String!) {
project(id: $projectId) {
model(id: $modelId) {
automationStatus{
id
status
statusMessage
automationRuns {
id
automationId
versionId
createdAt
updatedAt
status
functionRuns {
id
functionId
elapsed
status
contextView
statusMessage
results
resultVersions {
id
}
}
}
}
}
}
}
""",
variables: new { projectId, modelId }
);
AutomationStatusResponseModel response = await speckleClient.ExecuteGraphQLRequest<AutomationStatusResponseModel>(
query
);
return response.Project.Model.AutomationStatus;
}
}
@@ -0,0 +1,4 @@
# Automate C# SDK Integration Tests
Tests are run on server to do GET/POST requests in order to simulate real scenarios.
Before running tests, to be able to run local server follow the instructions [here](https://speckle.guide/dev/server-local-dev.html).
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Speckle.Sdk.Testing\Speckle.Sdk.Testing.csproj" />
<ProjectReference Include="..\..\src\Speckle.Automate.Sdk\Speckle.Automate.Sdk.csproj" />
<ProjectReference Include="..\Speckle.Sdk.Tests.Integration\Speckle.Sdk.Tests.Integration.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,266 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Speckle.Automate.Sdk.Schema;
using Speckle.Automate.Sdk.Schema.Triggers;
using Speckle.Sdk;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Models;
using Speckle.Sdk.Tests.Integration;
using Utils = Speckle.Automate.Sdk.Integration.TestAutomateUtils;
namespace Speckle.Automate.Sdk.Integration;
public sealed class AutomationContextTest : IAsyncLifetime
{
private const string SERVER_SKIP_MESSAGE = "currently the function run cannot be integration tested with the server";
private IOperations _operations;
private IAutomationRunner _runner;
private IClient _client;
private Account _account;
private IAutomationContextFactory _contextFactory;
public async Task InitializeAsync()
{
var serviceProvider = TestServiceSetup.GetServiceProvider();
_account = await Fixtures.SeedUser().ConfigureAwait(false);
_client = serviceProvider.GetRequiredService<IClientFactory>().Create(_account);
_runner = serviceProvider.GetRequiredService<IAutomationRunner>();
_operations = serviceProvider.GetRequiredService<IOperations>();
_contextFactory = serviceProvider.GetRequiredService<IAutomationContextFactory>();
}
public Task DisposeAsync()
{
_client?.Dispose();
return Task.CompletedTask;
}
private async Task<AutomationRunData> AutomationRunData(Base testObject)
{
Project project = await _client.Project.Create(new("Automate function e2e test", null, ProjectVisibility.Public));
const string BRANCH_NAME = "main";
var model = await _client.Model.Create(new(BRANCH_NAME, null, project.id));
string modelId = model.id;
(string rootObjId, _) = await _operations.Send2(
_client.ServerUrl,
project.id,
_client.Account.token,
testObject,
null,
CancellationToken.None
);
var version = await _client.Version.Create(new(rootObjId, model.id, project.id));
string automationName = Utils.RandomString(10);
string automationId = Utils.RandomString(10);
string automationRevisionId = Utils.RandomString(10);
await Utils.RegisterNewAutomation(project.id, modelId, _client, automationId, automationName, automationRevisionId);
string automationRunId = Utils.RandomString(10);
string functionRunId = Utils.RandomString(10);
var triggers = new List<VersionCreationTrigger> { new(modelId, version.id) };
return new AutomationRunData
{
ProjectId = project.id,
SpeckleServerUrl = _client.ServerUrl,
AutomationId = automationId,
AutomationRunId = automationRunId,
FunctionRunId = functionRunId,
Triggers = triggers,
};
}
private VersionCreationTrigger GetVersionCreationTrigger(List<VersionCreationTrigger> triggers)
{
if (triggers.FirstOrDefault() is not VersionCreationTrigger trigger)
{
throw new Exception("Automation run data contained no valid triggers.");
}
return trigger;
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public async Task TestFunctionRun()
{
AutomationRunData automationRunData = await AutomationRunData(Utils.TestObject());
IAutomationContext automationContext = await _runner.RunFunction(
TestAutomateFunction.Run,
automationRunData,
_account.token,
new TestFunctionInputs { ForbiddenSpeckleType = "Base" }
);
automationContext.RunStatus.Should().Be("FAILED");
var trigger = GetVersionCreationTrigger(automationRunData.Triggers);
AutomationStatus status = await AutomationStatusOperations.Get(
automationRunData.ProjectId,
trigger.Payload.ModelId,
automationContext.SpeckleClient
);
status.Status.Should().Be(automationContext.RunStatus);
string statusMessage = status.AutomationRuns[0].FunctionRuns[0].StatusMessage;
statusMessage.Should().Be(automationContext.AutomationResult.StatusMessage);
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public void TestParseInputData()
{
const string FORBIDDEN_SPECKLE_TYPE = "Base";
TestFunctionInputs testFunctionInputs = new() { ForbiddenSpeckleType = FORBIDDEN_SPECKLE_TYPE };
FunctionRunData<TestFunctionInputs> functionRunData = new()
{
FunctionInputs = testFunctionInputs,
SpeckleToken = "",
AutomationRunData = default,
};
string serializedFunctionRunData = JsonConvert.SerializeObject(functionRunData);
File.WriteAllText("./inputData.json", serializedFunctionRunData);
FunctionRunData<TestFunctionInputs> data = FunctionRunDataParser.FromPath<TestFunctionInputs>("./inputData.json");
data.FunctionInputs.ForbiddenSpeckleType.Should().Be(FORBIDDEN_SPECKLE_TYPE);
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public async Task TestFileUploads()
{
AutomationRunData automationRunData = await AutomationRunData(Utils.TestObject());
IAutomationContext automationContext = await _contextFactory.Initialize(automationRunData, _account.token);
string filePath = $"./{Utils.RandomString(10)}";
await File.WriteAllTextAsync(filePath, "foobar");
await automationContext.StoreFileResult(filePath);
File.Delete(filePath);
automationContext.AutomationResult.Blobs.Should().HaveCount(1);
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public async Task TestCreateVersionInProject()
{
AutomationRunData automationRunData = await AutomationRunData(Utils.TestObject());
IAutomationContext automationContext = await _contextFactory.Initialize(automationRunData, _account.token);
const string BRANCH_NAME = "test-branch";
const string COMMIT_MSG = "automation test";
var model = await automationContext.SpeckleClient.Model.Create(
new(BRANCH_NAME, default, automationRunData.ProjectId)
);
await automationContext.CreateNewVersionInProject(Utils.TestObject(), model, COMMIT_MSG);
var modelWithVersions = await automationContext.SpeckleClient.Model.GetWithVersions(
model.id,
automationRunData.ProjectId
);
modelWithVersions.versions.items[0].message.Should().Be(COMMIT_MSG);
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public async Task TestCreateVersionInProject_ThrowsErrorForSameModel()
{
AutomationRunData automationRunData = await AutomationRunData(Utils.TestObject());
IAutomationContext automationContext = await _contextFactory.Initialize(automationRunData, _account.token);
var trigger = GetVersionCreationTrigger(automationRunData.Triggers);
var model = await automationContext.SpeckleClient.Model.Get(trigger.Payload.ModelId, automationRunData.ProjectId);
const string COMMIT_MSG = "automation test";
await Assert.ThrowsAsync<ArgumentException>(async () =>
{
await automationContext.CreateNewVersionInProject(Utils.TestObject(), model, COMMIT_MSG);
});
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public async Task TestSetContextView()
{
AutomationRunData automationRunData = await AutomationRunData(Utils.TestObject());
IAutomationContext automationContext = await _contextFactory.Initialize(automationRunData, _account.token);
automationContext.SetContextView();
var trigger = GetVersionCreationTrigger(automationRunData.Triggers);
automationContext.AutomationResult.ResultView.Should().NotBeNull();
string originModelView = $"{trigger.Payload.ModelId}@{trigger.Payload.VersionId}";
automationContext.AutomationResult.ResultView.Should().EndWith($"models/{originModelView}");
await automationContext.ReportRunStatus();
const string DUMMY_CONTEXT = "foo@bar";
automationContext.AutomationResult.ResultView = null;
automationContext.SetContextView(new List<string> { DUMMY_CONTEXT }, true);
automationContext.AutomationResult.ResultView.Should().NotBeNull();
automationContext.AutomationResult.ResultView.Should().EndWith($"models/{originModelView},{DUMMY_CONTEXT}");
await automationContext.ReportRunStatus();
automationContext.AutomationResult.ResultView = null;
automationContext.SetContextView(new List<string> { DUMMY_CONTEXT }, false);
automationContext.AutomationResult.ResultView.Should().NotBeNull();
automationContext.AutomationResult.ResultView.Should().EndWith($"models/{DUMMY_CONTEXT}");
await automationContext.ReportRunStatus();
automationContext.AutomationResult.ResultView = null;
Assert.Throws<SpeckleException>(() =>
{
automationContext.SetContextView(null, false);
});
await automationContext.ReportRunStatus();
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public async Task TestReportRunStatus_Succeeded()
{
AutomationRunData automationRunData = await AutomationRunData(Utils.TestObject());
IAutomationContext automationContext = await _contextFactory.Initialize(automationRunData, _account.token);
automationContext.RunStatus.Should().Be(AutomationStatusMapping.Get(Schema.AutomationStatus.Running));
automationContext.MarkRunSuccess("This is a success message");
automationContext.RunStatus.Should().Be(AutomationStatusMapping.Get(Schema.AutomationStatus.Succeeded));
}
[Fact(Skip = SERVER_SKIP_MESSAGE)]
public async Task TestReportRunStatus_Failed()
{
AutomationRunData automationRunData = await AutomationRunData(Utils.TestObject());
IAutomationContext automationContext = await _contextFactory.Initialize(automationRunData, _account.token);
automationContext.RunStatus.Should().Be(AutomationStatusMapping.Get(Schema.AutomationStatus.Running));
string message = "This is a failure message";
automationContext.MarkRunFailed(message);
automationContext.RunStatus.Should().Be(AutomationStatusMapping.Get(Schema.AutomationStatus.Failed));
automationContext.StatusMessage.Should().Be(message);
}
}
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using Speckle.Sdk.Models;
namespace Speckle.Automate.Sdk.Integration;
public struct TestFunctionInputs
{
[Required]
public string ForbiddenSpeckleType { get; set; }
}
public static class TestAutomateFunction
{
public static async Task Run(IAutomationContext automateContext, TestFunctionInputs testFunctionInputs)
{
Base versionRootObject = await automateContext.ReceiveVersion();
int count = 0;
if (versionRootObject.speckle_type == testFunctionInputs.ForbiddenSpeckleType)
{
if (versionRootObject.id is null)
{
throw new InvalidOperationException("Cannot operate on objects without their ids");
}
automateContext.AttachErrorToObjects(
"",
new[] { versionRootObject },
$"This project should not contain the type: {testFunctionInputs.ForbiddenSpeckleType} "
);
count += 1;
}
if (count > 0)
{
automateContext.MarkRunFailed(
"Automation failed: "
+ $"Found {count} object that have a forbidden speckle type: {testFunctionInputs.ForbiddenSpeckleType}"
);
}
else
{
automateContext.MarkRunSuccess("No forbidden types found.");
}
}
}
@@ -0,0 +1,69 @@
using System.Diagnostics.CodeAnalysis;
using GraphQL;
using Speckle.Sdk.Api;
using Speckle.Sdk.Models;
namespace Speckle.Automate.Sdk.Integration;
public static class TestAutomateUtils
{
[SuppressMessage("Security", "CA5394:Do not use insecure randomness")]
public static string RandomString(int length)
{
Random rand = new();
const string POOL = "abcdefghijklmnopqrstuvwxyz0123456789";
IEnumerable<char> chars = Enumerable.Range(0, length).Select(_ => POOL[rand.Next(0, POOL.Length)]);
return new string(chars.ToArray());
}
public static Base TestObject()
{
Base rootObject = new() { ["foo"] = "bar" };
return rootObject;
}
public static async Task RegisterNewAutomation(
string projectId,
string modelId,
IClient speckleClient,
string automationId,
string automationName,
string automationRevisionId
)
{
//language=graphql
GraphQLRequest query = new(
query: """
mutation CreateAutomation(
$projectId: String!
$modelId: String!
$automationName: String!
$automationId: String!
$automationRevisionId: String!
) {
automationMutations {
create(
input: {
projectId: $projectId
modelId: $modelId
automationName: $automationName
automationId: $automationId
automationRevisionId: $automationRevisionId
}
)
}
}
""",
variables: new
{
projectId,
modelId,
automationName,
automationId,
automationRevisionId,
}
);
await speckleClient.ExecuteGraphQLRequest<object>(query);
}
}
@@ -0,0 +1,533 @@
{
"version": 2,
"dependencies": {
"net8.0": {
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "8.0.0",
"Microsoft.SourceLink.Common": "8.0.0"
}
},
"PolySharp": {
"type": "Direct",
"requested": "[1.15.0, )",
"resolved": "1.15.0",
"contentHash": "FbU0El+EEjdpuIX4iDbeS7ki1uzpJPx8vbqOzEtqnl1GZeAGJfq+jCbxeJL2y0EPnUNk8dRnnqR2xnYXg9Tf+g=="
},
"Speckle.InterfaceGenerator": {
"type": "Direct",
"requested": "[0.9.6, )",
"resolved": "0.9.6",
"contentHash": "HKH7tYrYYlCK1ct483hgxERAdVdMtl7gUKW9ijWXxA1UsYR4Z+TrRHYmzZ9qmpu1NnTycSrp005NYM78GDKV1w=="
},
"Argon": {
"type": "Transitive",
"resolved": "0.28.0",
"contentHash": "78BmoFm8SK733nq4F/SjqNKkXJHdrg/MslvYfNjJX/nM/mEkltHUzPJRjBE9VI/zghsjFPQxMRPEUaqIgg98zg=="
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "16.2.1",
"contentHash": "UfMgXClqOGkPNfth210upiTY18LPCgjsfNrh0Olo5qI+QTkkCO6wHSuOwknxJdKtsWoaJ+E132Y2nzD0PiLWRw==",
"dependencies": {
"EmptyFiles": "8.9.1",
"System.Management": "8.0.0"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "8.9.1",
"contentHash": "GbGf+oH/xiI3C5vJ5TnoA4sx7x7LhtOvN00fxihRZJsj40XuXk2TMz/4m26PfNSJj8JMAqo3BUBirjvam+3xkA=="
},
"FSharp.Core": {
"type": "Transitive",
"resolved": "7.0.300",
"contentHash": "8vvItREJ1l5lcp3vBCSJ1mFevVAhR48I34DuF/EoUa7o1KlFpQpagyuZkVYMAsHPIjdp47ZxM9sI4eqeXaeWkA=="
},
"GraphQL.Client.Abstractions": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "h7uzWFORHZ+CCjwr/ThAyXMr0DPpzEANDa4Uo54wqCQ+j7qUKwqYTgOrb1W40sqbvNaZm9v/X7It31SUw0maHA==",
"dependencies": {
"GraphQL.Primitives": "6.0.0"
}
},
"GraphQL.Client.Abstractions.Websocket": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "Nr9bPf8gIOvLuXpqEpqr9z9jslYFJOvd0feHth3/kPqeR3uMbjF5pjiwh4jxyMcxHdr8Pb6QiXkV3hsSyt0v7A==",
"dependencies": {
"GraphQL.Client.Abstractions": "6.0.0"
}
},
"GraphQL.Primitives": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.13.0",
"contentHash": "9LIUy0y+DvUmEPtbRDw6Bay3rzwqFV8P4efTrK4CZhQle3M/QwLPjISghfcolmEGAPWxuJi6m98ZEfk4VR4Lfg=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "7.0.5",
"contentHash": "FTerRmQPqHrCrnoUzhBu+E+1DNGwyrAMLqHkAqOOOu5pGfyMOj8qQUBxI/gDtWtG11p49UxSfWmBzRNlwZqfUg==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.4"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "nOP8R1mVb/6mZtm2qgAJXn/LFm/2kMjHDAg/QJLFG6CuWYJtaD3p1BwQhufBVvRzL9ceJ/xF0SQ0qsI2GkDQAA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "vJ9xvOZCnUAIHcGC3SU35r3HKmHTVIeHzo6u/qzlHAqD8m6xv92MLin4oJntTvkpKxVX3vI1GFFkIQtU3AdlsQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "2.2.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "UpZLNLBpIZ0GTebShui7xXYh6DmBHjWM8NxGxZbdQh/bPZ5e6YswqI+bru6BnEL5eWiOdodsXtEz3FROcgi/qg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0",
"Microsoft.Extensions.Primitives": "2.2.0",
"System.ComponentModel.Annotations": "4.5.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==",
"dependencies": {
"System.Memory": "4.5.1",
"System.Runtime.CompilerServices.Unsafe": "4.5.1"
}
},
"Microsoft.SourceLink.Common": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.13.0",
"contentHash": "bt0E0Dx+iqW97o4A59RCmUmz/5NarJ7LRL+jXbSHod72ibL5XdNm1Ke+UO5tFhBG4VwHLcSjqq9BUSblGNWamw==",
"dependencies": {
"System.Reflection.Metadata": "1.6.0"
}
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.13.0",
"contentHash": "9GGw08Dc3AXspjekdyTdZ/wYWFlxbgcF0s7BKxzVX+hzAwpifDOdxM+ceVaaJSQOwqt3jtuNlHn3XTpKUS9x9Q==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.13.0",
"Newtonsoft.Json": "13.0.1"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"Quibble": {
"type": "Transitive",
"resolved": "0.3.1",
"contentHash": "LD6bz2p+4O/BQnmD4mqFZrmdN/IjsPo1wUvfmcH46Q05ng+dyMLl3d2ylj0x412F4fpJEtm0Z3EaCAx4FqgNuQ==",
"dependencies": {
"FSharp.Core": "7.0.300",
"System.Text.Json": "7.0.3"
}
},
"SimpleInfoName": {
"type": "Transitive",
"resolved": "3.1.0",
"contentHash": "j+ENh86NhxrgDc6T1ueqIR2QOdDkSJY2dbTFyPN/JvIXifB4GHAunlMw/x7P6m7XaXEHr3s+SMZfKBlmnmkO6g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "EWI1olKDjFEBMJu0+3wuxwziIAdWDVMYLhuZ3Qs84rrz+DHwD00RzWPZCa+bLnHCf3oJwuFZIRsHT5p236QXww==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.4",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.4"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "inBjvSHo9UDKneGNzfUfDjK08JzlcIhn1+SP5Y3m6cgXpCxXKCJDy6Mka7LpgSV+UZmKSnC8rTwB0SQ0xKu5pA==",
"dependencies": {
"System.Memory": "4.5.3"
}
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "2C9Q9eX7CPLveJA0rIhf9RXAvu+7nWZu1A2MdG6SD/NOu26TakGgL1nsbc0JAspGijFOo3HoN79xrx8a368fBg=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.4",
"contentHash": "CSlb5dUp1FMIkez9Iv5EXzpeq7rHryVNqwJMWnpq87j9zWZexaEMdisDktMsnnrzKM6ahNrsTkjqNodTBPBxtQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.4"
}
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "WTlRjL6KWIMr/pAaq3rYqh0TJlzpouaQ/W1eelssHgtlwHAH25jXTkUphTYx9HaIIf7XA6qs/0+YhtLEQRkJ+Q=="
},
"System.ComponentModel.Annotations": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "UxYQ3FGUOtzJ7LfSdnYSFd7+oEv6M8NgUatatIN2HxNtDdlcvFAf+VIq4Of9cDMJEJC0aSRv/x898RYhB4Yppg=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "9.0.4",
"contentHash": "WogPvgAFqQORFD8Iyha6RZ+/1QB3dsWRWxbwi8/HHVgiGQ8z0oMWpwe8Kk3Ti+Roe+P6a3sBg+WwBfEsyziZKg=="
},
"System.Management": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "jrK22i5LRzxZCfGb+tGmke2VH7oE0DvcDlJ1HAKYU8cPmD8XnpUT0bYn2Gy98GEhGjtfbR/sxKTVb+dE770pfA==",
"dependencies": {
"System.CodeDom": "8.0.0"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.3",
"contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA=="
},
"System.Reactive": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "erBZjkQHWL9jpasCE/0qKAryzVBJFxGHVBAvgRN1bzM0q2s1S4oYREEEL0Vb+1kA/6BKb5FjUZMp5VXmy+gzkQ=="
},
"System.Reflection.Metadata": {
"type": "Transitive",
"resolved": "1.6.0",
"contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "4.5.1",
"contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
},
"Verify": {
"type": "Transitive",
"resolved": "29.4.0",
"contentHash": "wlqJ6ygXORa3lrAaErTA4EWkDcK9pBG2k5iC/I5F2UpWfyV7aXw/+SwGiWYe4XSk9d7VObe4xi4+0AV9rLRsBw==",
"dependencies": {
"Argon": "0.28.0",
"DiffEngine": "16.2.1",
"SimpleInfoName": "3.1.0",
"System.IO.Hashing": "9.0.4"
}
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"speckle.automate.sdk": {
"type": "Project",
"dependencies": {
"Newtonsoft.Json.Schema": "[4.0.1, )",
"Speckle.Objects": "[1.0.0, )",
"System.CommandLine": "[2.0.0-beta4.22272.1, )"
}
},
"speckle.objects": {
"type": "Project",
"dependencies": {
"Speckle.Sdk": "[1.0.0, )"
}
},
"speckle.sdk": {
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
}
},
"speckle.sdk.dependencies": {
"type": "Project"
},
"speckle.sdk.testing": {
"type": "Project",
"dependencies": {
"Moq": "[4.20.72, )",
"Speckle.Sdk": "[1.0.0, )",
"Verify.Quibble": "[2.1.1, )",
"Verify.Xunit": "[29.4.0, )"
}
},
"speckle.sdk.tests.integration": {
"type": "Project",
"dependencies": {
"AwesomeAssertions": "[8.1.0, )",
"Microsoft.Extensions.DependencyInjection": "[2.2.0, )",
"Microsoft.NET.Test.Sdk": "[17.13.0, )",
"Speckle.Sdk": "[1.0.0, )",
"Speckle.Sdk.Testing": "[1.0.0, )",
"altcover": "[9.0.1, )",
"xunit": "[2.9.3, )",
"xunit.runner.visualstudio": "[3.0.2, )"
}
},
"altcover": {
"type": "CentralTransitive",
"requested": "[9.0.1, )",
"resolved": "9.0.1",
"contentHash": "aadciFNDT5bnylaYUkKal+s5hF7yU/lmZxImQWAlk1438iPqK1Uf79H5ylELpyLIU49HL5ql+tnWBihp3WVLCA=="
},
"AwesomeAssertions": {
"type": "CentralTransitive",
"requested": "[8.1.0, )",
"resolved": "8.1.0",
"contentHash": "IfNC4cpXPi9tclWvuNO9lfkuIxJsUTLTS1NXto55jDrAUQJYl0zLI9ByISrfkbBE2Xtg+IWaAXQ6jnUx3anDuw=="
},
"GraphQL.Client": {
"type": "CentralTransitive",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "8yPNBbuVBpTptivyAlak4GZvbwbUcjeQTL4vN1HKHRuOykZ4r7l5fcLS6vpyPyLn0x8FsL31xbOIKyxbmR9rbA==",
"dependencies": {
"GraphQL.Client.Abstractions": "6.0.0",
"GraphQL.Client.Abstractions.Websocket": "6.0.0",
"System.Reactive": "5.0.0"
}
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
"resolved": "7.0.5",
"contentHash": "KGxbPeWsQMnmQy43DSBxAFtHz3l2JX8EWBSGUCvT3CuZ8KsuzbkqMIJMDOxWtG8eZSoCDI04aiVQjWuuV8HmSw==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "7.0.5",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.4"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "CentralTransitive",
"requested": "[2.2.0, )",
"resolved": "2.2.0",
"contentHash": "MZtBIwfDFork5vfjpJdG5g8wuJFt7d/y3LOSVVtDK/76wlbtz6cjltfKHqLx2TKVqTj5/c41t77m1+h20zqtPA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[2.2.0, )",
"resolved": "2.2.0",
"contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw=="
},
"Microsoft.Extensions.Logging": {
"type": "CentralTransitive",
"requested": "[2.2.0, )",
"resolved": "2.2.0",
"contentHash": "Nxqhadc9FCmFHzU+fz3oc8sFlE6IadViYg8dfUdGzJZ2JUxnCsRghBhhOWdM4B2zSZqEc+0BjliBh/oNdRZuig==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "2.2.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0",
"Microsoft.Extensions.Logging.Abstractions": "2.2.0",
"Microsoft.Extensions.Options": "2.2.0"
}
},
"Microsoft.NET.Test.Sdk": {
"type": "CentralTransitive",
"requested": "[17.13.0, )",
"resolved": "17.13.0",
"contentHash": "W19wCPizaIC9Zh47w8wWI/yxuqR7/dtABwOrc8r2jX/8mUNxM2vw4fXDh+DJTeogxV+KzKwg5jNNGQVwf3LXyA==",
"dependencies": {
"Microsoft.CodeCoverage": "17.13.0",
"Microsoft.TestPlatform.TestHost": "17.13.0"
}
},
"Moq": {
"type": "CentralTransitive",
"requested": "[4.20.72, )",
"resolved": "4.20.72",
"contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Newtonsoft.Json.Schema": {
"type": "CentralTransitive",
"requested": "[4.0.1, )",
"resolved": "4.0.1",
"contentHash": "rbHUKp5WTIbqmLEeJ21nTTDGcfR0LA7bVMzm0bYc3yx6NFKiCIHzzvYbwA4Sqgs7+wNldc5nBlkbithWj8IZig==",
"dependencies": {
"Newtonsoft.Json": "13.0.3"
}
},
"Speckle.DoubleNumerics": {
"type": "CentralTransitive",
"requested": "[4.1.0, )",
"resolved": "4.1.0",
"contentHash": "20DtS+FsDRsOD9+AU3TwNFZ0qrKo5f6f7B5ZR9wStsIHHHC9k7DpjbCvuNtmnSjx54MD+TJC7wV2f5iyGVPj1A=="
},
"Speckle.Newtonsoft.Json": {
"type": "CentralTransitive",
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.CommandLine": {
"type": "CentralTransitive",
"requested": "[2.0.0-beta4.22272.1, )",
"resolved": "2.0.0-beta4.22272.1",
"contentHash": "1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg=="
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[8.0.5, )",
"resolved": "8.0.4",
"contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==",
"dependencies": {
"System.Text.Encodings.Web": "8.0.0"
}
},
"Verify.Quibble": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "Z8bVwFICa3Dog6Mcnx0wlrn4Y+CFpQXx1f+ijfLn6/v4q00q+jLm9Gu/nVyUFuc75cjn6ieI08UrqXKcR9fTYw==",
"dependencies": {
"Quibble": "0.3.1",
"System.Text.Json": "8.0.4",
"Verify": "26.1.1"
}
},
"Verify.Xunit": {
"type": "CentralTransitive",
"requested": "[29.4.0, )",
"resolved": "29.4.0",
"contentHash": "P8HYW7aromKGm90Cgx0XKL3qKKmYZHDwHTQfBfDCCPnhNlVWevJzrpuUQ0+3vTVdRAtsts2a1OE/tD+3yjJbHA==",
"dependencies": {
"Argon": "0.28.0",
"DiffEngine": "16.2.1",
"SimpleInfoName": "3.1.0",
"System.IO.Hashing": "9.0.4",
"Verify": "29.4.0",
"xunit.abstractions": "2.0.3",
"xunit.extensibility.execution": "2.9.3"
}
},
"xunit": {
"type": "CentralTransitive",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.assert": {
"type": "CentralTransitive",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.runner.visualstudio": {
"type": "CentralTransitive",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow=="
}
}
}
}
@@ -12,9 +12,9 @@
},
{
"__closure": {
"13da8e855141b835e68bba721411046e": 100,
"cc9d42395b317ed25947b1285bbd5103": 100,
"ede4848fc3abda9275a19fee3447ffbd": 100
"13da8e855141b835e68bba721411046e": 1,
"cc9d42395b317ed25947b1285bbd5103": 1,
"ede4848fc3abda9275a19fee3447ffbd": 1
},
"applicationId": "asdfasdf",
"area": 42.0,
@@ -301,14 +301,6 @@
"resolved": "8.0.0",
"contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
},
"System.Text.Json": {
"type": "Transitive",
"resolved": "8.0.4",
"contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==",
"dependencies": {
"System.Text.Encodings.Web": "8.0.0"
}
},
"Verify": {
"type": "Transitive",
"resolved": "29.4.0",
@@ -351,7 +343,6 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
@@ -383,12 +374,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
"resolved": "4.7.0",
"contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
@@ -438,6 +423,15 @@
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[8.0.5, )",
"resolved": "8.0.4",
"contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==",
"dependencies": {
"System.Text.Encodings.Web": "8.0.0"
}
},
"Verify.Quibble": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
@@ -332,7 +332,6 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
@@ -387,12 +386,6 @@
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
"resolved": "4.7.0",
"contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
@@ -0,0 +1,15 @@
{
"CancellationToken": {
"IsCancellationRequested": true,
"CanBeCanceled": true,
"WaitHandle": {
"SafeWaitHandle": {
"IsClosed": false,
"IsInvalid": false
}
}
},
"Data": {},
"Message": "The operation was canceled.",
"Type": "OperationCanceledException"
}
@@ -0,0 +1,100 @@
using System.Collections.Concurrent;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Serialisation;
using Speckle.Sdk.Serialisation.V2;
using Speckle.Sdk.Serialisation.V2.Send;
namespace Speckle.Sdk.Serialization.Tests;
public class AdditionalCancellationTests
{
private readonly ISerializeProcessFactory _factory;
public AdditionalCancellationTests()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddSpeckleSdk(new("Tests", "test"), "v3", typeof(TestClass).Assembly);
var serviceProvider = serviceCollection.BuildServiceProvider();
_factory = serviceProvider.GetRequiredService<ISerializeProcessFactory>();
}
[Fact]
public async Task Cancellation_Traversal()
{
var testClass = new TestClass() { RegularProperty = "Hello" };
using var cancellationSource = new CancellationTokenSource();
await using var serializeProcess = _factory.CreateSerializeProcess(
new ConcurrentDictionary<Id, Json>(),
new ConcurrentDictionary<string, string>(),
null,
cancellationSource.Token,
new SerializeProcessOptions(true, true, false, true)
);
await cancellationSource.CancelAsync();
var task = serializeProcess.Serialize(testClass);
var ex = await Assert.ThrowsAsync<OperationCanceledException>(async () => await task);
await Verify(ex);
cancellationSource.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public async Task Cancellation_MultipleConcurrent()
{
var testClass1 = new TestClass() { RegularProperty = "Hello" };
var testClass2 = new TestClass() { RegularProperty = "World" };
using var cancellationSource = new CancellationTokenSource();
await cancellationSource.CancelAsync();
var tasks = new List<Task>();
for (int i = 0; i < 2; i++)
{
var serializeProcess = _factory.CreateSerializeProcess(
new ConcurrentDictionary<Id, Json>(),
new ConcurrentDictionary<string, string>(),
null,
cancellationSource.Token,
new SerializeProcessOptions(true, true, false, true)
);
tasks.Add(serializeProcess.Serialize(i % 2 == 0 ? testClass1 : testClass2));
}
while (tasks.Count != 0)
{
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
var t = await Task.WhenAny(tasks);
tasks.Remove(t);
await t;
});
}
cancellationSource.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public async Task Cancellation_AfterCompletion()
{
var testClass = new TestClass() { RegularProperty = "Hello" };
using var cancellationSource = new CancellationTokenSource();
await using var serializeProcess = _factory.CreateSerializeProcess(
new ConcurrentDictionary<Id, Json>(),
new ConcurrentDictionary<string, string>(),
null,
cancellationSource.Token,
new SerializeProcessOptions(true, true, false, true)
);
await serializeProcess.Serialize(testClass);
await cancellationSource.CancelAsync();
cancellationSource.IsCancellationRequested.Should().BeTrue();
}
}
@@ -0,0 +1,188 @@
using FluentAssertions;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Serialisation.Utilities;
namespace Speckle.Sdk.Serialization.Tests;
public class ClosureParserTests
{
[Fact]
public void GetClosures_WithValidJson_ReturnsCorrectClosures()
{
// Arrange
var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}";
// Act
var result = ClosureParser.GetClosures(json, CancellationToken.None);
// Assert
result.Should().HaveCount(3);
result.Should().Contain((closure) => closure.Item1 == "id1" && closure.Item2 == 2);
result.Should().Contain((closure) => closure.Item1 == "id2" && closure.Item2 == 1);
result.Should().Contain((closure) => closure.Item1 == "id3" && closure.Item2 == 3);
}
[Fact]
public void GetClosures_WithValidJson_ReturnsUnsorted()
{
// Arrange
var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}";
// Act
var result = ClosureParser.GetClosures(json, CancellationToken.None);
// Assert
result.Should().HaveCount(3);
result[0].Item2.Should().Be(2);
result[1].Item2.Should().Be(1);
result[2].Item2.Should().Be(3);
}
[Fact]
public void GetClosures_WithValidJson_ReturnsSortedByDepthDescending()
{
// Arrange
var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}";
// Act
var result = ClosureParser.GetClosuresSorted(json, CancellationToken.None);
// Assert
result.Should().HaveCount(3);
result[0].Item2.Should().Be(3);
result[1].Item2.Should().Be(2);
result[2].Item2.Should().Be(1);
}
[Fact]
public void GetChildrenIds_WithValidJson_ReturnsCorrectIds()
{
// Arrange
var json = @"{""__closure"": {""id1"": 2, ""id2"": 1, ""id3"": 3}}";
// Act
var result = ClosureParser.GetChildrenIds(json, CancellationToken.None).ToList();
// Assert
result.Should().HaveCount(3);
result.Should().Contain("id1");
result.Should().Contain("id2");
result.Should().Contain("id3");
}
[Fact]
public void GetClosures_WithRandomOrderedClosures_ReturnsSortedByDepthDescending()
{
// Arrange
var random = new Random(42); // Fixed seed for reproducibility
var idDepthPairs = new List<(string id, int depth)>
{
("id1", 5),
("id2", 3),
("id3", 7),
("id4", 1),
("id5", 10),
("id6", 2),
};
// Randomize the order
var randomized = idDepthPairs.OrderBy(_ => random.Next()).ToList();
// Build JSON with randomized order
using var stringWriter = new StringWriter();
using var jsonWriter = new JsonTextWriter(stringWriter);
jsonWriter.WriteStartObject();
jsonWriter.WritePropertyName("__closure");
jsonWriter.WriteStartObject();
foreach (var pair in randomized)
{
jsonWriter.WritePropertyName(pair.id);
jsonWriter.WriteValue(pair.depth);
}
jsonWriter.WriteEndObject();
jsonWriter.WriteEndObject();
var json = stringWriter.ToString();
// Act
var result = ClosureParser.GetClosuresSorted(json, CancellationToken.None);
// Assert
result.Should().HaveCount(6);
// Verify sorting is correct (descending by depth)
for (int i = 0; i < result.Count - 1; i++)
{
result[i].Item2.Should().BeGreaterThanOrEqualTo(result[i + 1].Item2);
}
// Verify specific order
result[0].Item1.Should().Be("id5"); // depth 10
result[1].Item1.Should().Be("id3"); // depth 7
result[2].Item1.Should().Be("id1"); // depth 5
result[3].Item1.Should().Be("id2"); // depth 3
result[4].Item1.Should().Be("id6"); // depth 2
result[5].Item1.Should().Be("id4"); // depth 1
}
[Fact]
public void GetClosures_WithEmptyJson_ReturnsEmptyList()
{
// Arrange
var json = "{}";
// Act
var result = ClosureParser.GetClosures(json, CancellationToken.None);
// Assert
result.Should().BeEmpty();
}
[Fact]
public void GetClosures_WithInvalidJson_ReturnsEmptyList()
{
// Arrange
var json = "invalid json";
// Act
var result = ClosureParser.GetClosures(json, CancellationToken.None);
// Assert
result.Should().BeEmpty();
}
[Fact]
public void GetClosures_WithNullJson_ReturnsEmptyList()
{
// Arrange
string json = null!;
// Act
var result = ClosureParser.GetClosures(json, CancellationToken.None);
// Assert
result.Should().BeEmpty();
}
[Fact]
public void GetClosures_WithJsonReader_ReturnsCorrectClosures()
{
// Arrange
var json = @"{""id1"": 2, ""id2"": 1, ""id3"": 3}";
using var stringReader = new StringReader(json);
using var jsonReader = new JsonTextReader(stringReader);
// Act
jsonReader.Read(); // Move to start object
var result = ClosureParser.GetClosures(jsonReader, CancellationToken.None);
// Assert
result.Should().HaveCount(3);
result.Should().Contain((closure) => closure.Item1 == "id1" && closure.Item2 == 2);
result.Should().Contain((closure) => closure.Item1 == "id2" && closure.Item2 == 1);
result.Should().Contain((closure) => closure.Item1 == "id3" && closure.Item2 == 3);
}
}
@@ -0,0 +1,10 @@
{
"applicationId": null,
"displayValue": null,
"id": "15168a13ce3f336dee9aa1807cbf375c",
"name": null,
"properties": null,
"speckle_type": "Objects.Data.DataObject:Objects.Data.ArcgisObject",
"type": null,
"units": null
}
@@ -0,0 +1,11 @@
{
"applicationId": null,
"displayValue": null,
"elements": null,
"id": "bf80e8a10eca2264f11c39bae42538da",
"level": null,
"name": null,
"properties": null,
"speckle_type": "Objects.Data.DataObject:Objects.Data.ArchicadObject",
"type": null
}
@@ -0,0 +1,12 @@
{
"applicationId": null,
"baseCurves": null,
"displayValue": null,
"elements": null,
"id": "76b7634117981a9fb9d3cffca5464f26",
"name": null,
"properties": null,
"speckle_type": "Objects.Data.DataObject:Objects.Data.Civil3dObject",
"type": null,
"units": null
}
@@ -0,0 +1,8 @@
{
"applicationId": null,
"displayValue": null,
"id": "a1567a1cf10417294c93b70bf5ca97c1",
"name": null,
"properties": null,
"speckle_type": "Objects.Data.DataObject"
}
@@ -0,0 +1,11 @@
{
"applicationId": null,
"displayValue": null,
"elements": null,
"id": "39d4deaa7cd20e7004812304f41a68d5",
"name": null,
"properties": null,
"speckle_type": "Objects.Data.DataObject:Objects.Data.EtabsObject",
"type": null,
"units": null
}
@@ -0,0 +1,9 @@
{
"applicationId": null,
"displayValue": null,
"id": "fbda4ea7bb1b3722ca28e97573742a4e",
"name": null,
"properties": null,
"speckle_type": "Objects.Data.DataObject:Objects.Data.NavisworksObject",
"units": null
}

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