Compare commits

..

66 Commits

Author SHA1 Message Date
Jedd Morgan 786e683d89 Merge pull request #371 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Dev -> Main for 3.5.0 stable
2025-08-01 14:49:47 +01:00
Jedd Morgan 8d999f4f9c Update README.md (#370) 2025-07-30 07:53:10 +00:00
Jedd Morgan bb7542e254 chore(ci): Remove Gitversion (#369)
* Strip out gitversion

* Pr workflow

* fix?

* fix x2

* pass envvars properly
2025-07-30 08:41:32 +01:00
Jedd Morgan d6f6254a92 feat(file-import): Added file import resource and blob api functions (#367)
.NET Build and Publish / build (push) Has been cancelled
* add file import resource

* disabled health check

* re-enable healthcheck

* git ignore volumes

* disabled importer

* start_period

* Skipped broken tests

* Verify tests

* Fixed tests

* reverted volumes path

* Update docker-compose.yml
2025-07-29 14:52:12 +00:00
Jedd Morgan f60f85b639 Merge pull request #368 from specklesystems/jrm/main-dev-5
chore: main -> dev
2025-07-28 18:01:43 +01:00
Jedd Morgan bcdf73cc70 Updated active workspace query (#365) 2025-07-25 08:42:43 +01:00
Adam Hathcock 47e72ee1a7 Merge pull request #364 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Main to dev
2025-07-23 14:19:52 +01:00
Adam Hathcock f3de5324db Merge pull request #362 from specklesystems/main
Main to dev
2025-07-23 13:44:14 +01:00
Adam Hathcock 4dd6db886f insert or replace always...don't use ignore or insert (#363)
* SaveObject is always insert or replace.  Never use insert or ignore

* add/fix tests

* always replace even for bulk
2025-07-23 12:16:08 +00:00
Adam Hathcock 4b82db8ea2 Merge pull request #361 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Main to dev
2025-07-23 10:23:04 +01:00
Adam Hathcock 9e7f26f7a6 Add ModelCacheManager class and use it (#356)
* Introduce ModelCacheManager to manage cache and sizes and deletions

* move and abstract

* add tests and format

* Update src/Speckle.Sdk/Caching/ModelCacheManager.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Clean up

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-23 10:07:57 +01:00
Adam Hathcock b19f8c4219 Merge pull request #358 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
(NO SQUASH) Main to dev for release
2025-07-21 15:49:49 +01:00
Adam Hathcock c517e61517 Merge pull request #360 from specklesystems/main-dev
Main to dev
2025-07-21 15:47:38 +01:00
Adam Hathcock b3e0623856 Merge remote-tracking branch 'origin/main' into main-dev 2025-07-21 15:36:30 +01:00
Adam Hathcock e5d1ef2448 Merge pull request #354 from specklesystems/main-dev
Merge pull request #348 from specklesystems/dev
2025-07-21 11:12:54 +01:00
Adam Hathcock 83c3de05fa Merge remote-tracking branch 'origin/dev' into main-dev 2025-07-21 11:02:02 +01:00
Adam Hathcock 507ded7d4a Fix shallow copy allocations and perf (#357)
* add more DynamicBase Tests

* Move ShallowCopy to dynamic and try to be faster with copy

* Correct tests for macOS

* use cache obsolete attribute

* Update src/Speckle.Sdk/Models/DynamicBase.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/Speckle.Sdk.Tests.Unit/Models/DynamicBaseTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/Speckle.Sdk.Tests.Unit/Models/DynamicBaseTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix AI

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-21 11:01:00 +01:00
Adam Hathcock e15029bab3 Merge pull request #350 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
(dev to main) If we're already cancelling, ignore extra exceptions (#349)
2025-07-14 10:34:06 +01:00
Adam Hathcock a43fd44206 Stop recording an exception that's rethrown (#355)
* Stop recording an exception that's rethrown

* Update src/Speckle.Sdk/Api/GraphQL/Client.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-14 10:15:40 +01:00
Adam Hathcock 1bcd8ac3a4 Merge branch 'dev' into main-dev 2025-07-03 10:42:05 +01:00
Adam Hathcock a8dc93e22b Adds detail to message so that user isn't going WTF SDK (#351)
* Adds detail to message so that user isn't going WTF SDK

* update exception tests
2025-07-03 09:39:53 +00:00
Jedd Morgan 5a0f883b98 Add compatibility with :local docker images (#353)
Co-authored-by: Adam Hathcock <adamhathcock@users.noreply.github.com>
2025-07-03 10:28:35 +01:00
Adam Hathcock a5d035671a If we're already cancelling, ignore extra exceptions (#349)
* If we're already cancelling, ignore extra exceptions

* Do more robust cancellation

* Try to have more robust disposal and cancellation check
2025-07-01 10:20:32 +01:00
Adam Hathcock cd6ebad619 Merge pull request #348 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
New release for connectors
2025-06-30 15:15:51 +01:00
Adam Hathcock 33c2e6e1a4 Better handle graphql commit errors (#343)
* Better handle graphql commit errors

* add graphql error test
2025-06-30 10:54:44 +00:00
Adam Hathcock b97702adb1 Small fixes to SDK (#347)
* Increase channel capacity to make things more performant

* Avoid logging send cancellation exceptions, caller did it

* Try to avoid collection modified errors when cancelling by more aggressive checks

* oops, rethrow, don't catch
2025-06-30 10:37:30 +00:00
Adam Hathcock 80c4f694ec Merge pull request #346 from specklesystems/main-dev
Main to dev
2025-06-30 11:26:36 +01:00
Adam Hathcock fb5042004f Merge remote-tracking branch 'origin/dev' into main-dev 2025-06-30 08:58:57 +01:00
Adam Hathcock c0a9291632 Merge pull request #344 from specklesystems/oguzhan/level-proxies
.NET Build and Publish / build (push) Has been cancelled
Feat(objects): level proxies
2025-06-23 15:05:15 +01:00
oguzhankoral b783d2acb6 Format 2025-06-23 15:57:24 +03:00
oguzhankoral 93539adc1e Add level proxies 2025-06-23 15:42:48 +03:00
Adam Hathcock 98005933de Remove DistinctBy as we don't use it (#342) 2025-06-19 09:34:34 +01:00
Adam Hathcock 50906b172a Merge pull request #340 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
2025-06-11 17:35:27 +01:00
Adam Hathcock 05f7353925 Revert "Merge pull request #335 from specklesystems/adam/cnx-1786-allow-multiple-sends-to-access-sqlite-in-a-non-locking-2" (#339)
This reverts commit 59019bf846, reversing
changes made to 3afaf61a1a.

Co-authored-by: Adam Hathcock <adam@Adams-Mac-mini.localdomain>
2025-06-11 15:32:06 +00:00
Adam Hathcock 8328498553 Merge pull request #338 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Dev to Main (no squash)
2025-06-11 11:07:20 +01:00
Adam Hathcock 59019bf846 Merge pull request #335 from specklesystems/adam/cnx-1786-allow-multiple-sends-to-access-sqlite-in-a-non-locking-2
Use an object saver per stream instead of sqlite manager per stream
2025-06-11 10:50:34 +01:00
Adam Hathcock 3afaf61a1a Merge pull request #337 from specklesystems/main
Main to dev (no squash)
2025-06-11 10:50:18 +01:00
Adam Hathcock 424609fad0 fix tests 2025-06-10 13:18:34 +01:00
Adam Hathcock 46c067308e Fix DI dependency and tests 2025-06-10 11:39:42 +01:00
Adam Hathcock 0e33e8df8f add tests 2025-06-10 11:33:17 +01:00
Adam Hathcock bc81c21e9d format 2025-06-10 11:15:30 +01:00
Adam Hathcock 7f8b59d348 Pool object savers instead of sqlite 2025-06-10 11:15:01 +01:00
Adam Hathcock 44ba61e4a5 Adjustments to avoid sqlite "database is locked" errors (#333)
* add new exception test

* Make memory tests and file path tests be explicit

* set the default write parallelism to 1

* set to single reader for caching channel

* format

* Try to have consistent DB locked error test

* always a single reader of the channel

* Remove extra snapshot

* Revert "Try to have consistent DB locked error test"

This reverts commit 93669c57a3.

* remove extra test that doesn't do anything
2025-06-09 16:24:39 +00:00
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
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 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
172 changed files with 6819 additions and 935 deletions
+2 -9
View File
@@ -3,18 +3,11 @@
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.0.1",
"version": "1.0.2",
"commands": [
"csharpier"
],
"rollForward": false
},
"gitversion.tool": {
"version": "6.1.0",
"commands": [
"dotnet-gitversion"
],
"rollForward": false
}
}
}
}
+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 AwesomeAssertions 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!
+17 -3
View File
@@ -9,8 +9,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -21,9 +19,25 @@ jobs:
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
- id: set-version
name: Set version to output
run: |
SEMVER="3.0.99.${{ github.run_number }}"
FILE_VERSION=$(echo "$SEMVER" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
echo "fileVersion=$FILE_VERSION" >> "$GITHUB_OUTPUT"
echo $SEMVER
echo $FILE_VERSION
- name: 🔫 Build All
run: ./build.sh
env:
SEMVER: ${{ steps.set-version.outputs.SEMVER }}
FILE_VERSION: ${{ steps.set-version.outputs.FILE_VERSION }}
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
+23 -6
View File
@@ -2,7 +2,6 @@ name: .NET Build and Publish
on:
push:
branches: ["main", "dev"]
tags: ["3.*"]
jobs:
@@ -11,22 +10,40 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x.x
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
- id: set-version
name: Set version to output
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="3.0.99.${{ github.run_number }}"
fi
SEMVER="${TAG}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
echo "fileVersion=$FILE_VERSION" >> "$GITHUB_OUTPUT"
echo $SEMVER
echo $FILE_VERSION
- name: 🔫 Build and Pack
run: ./build.sh pack
env:
SEMVER: ${{ steps.set-version.outputs.SEMVER }}
FILE_VERSION: ${{ steps.set-version.outputs.FILE_VERSION }}
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
with:
+1
View File
@@ -15,6 +15,7 @@ tests/TestArchives/Scratch
tools
.vscode
.idea/
.volumes/
.DS_Store
*.snupkg
+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" />
-6
View File
@@ -1,6 +0,0 @@
workflow: GitFlow/v1
next-version: 3.0.0
branches:
main:
prevent-increment:
when-current-commit-tagged: true
+23 -11
View File
@@ -9,17 +9,21 @@ Speckle | Sharp | SDK
### .NET SDK, Tests, and Objects
[![codecov](https://codecov.io/gh/specklesystems/speckle-sharp-sdk/branch/dev/graph/badge.svg?token=TTM5OGr38m)](https://codecov.io/gh/specklesystems/speckle-sharp-sdk)
<a href="https://www.nuget.org/packages/Speckle.Sdk/"><img alt="NuGet Version" src="https://img.shields.io/nuget/v/Speckle.Sdk?label=Speckle.Sdk"></a>
<a href="https://www.nuget.org/packages/Speckle.Objects/"><img alt="NuGet Version" src="https://img.shields.io/nuget/v/Speckle.Sdk?label=Speckle.Objects"></a>
<a href="https://www.nuget.org/packages/Speckle.Automate.Sdk/"><img alt="NuGet Version" src="https://img.shields.io/nuget/v/Speckle.Sdk?label=Speckle.Automate.Sdk"></a>
> [!WARNING]
> This is an early beta release, not meant for use in production! We're working to stabilise the 3.0 API, and until then there will be breaking changes. You have been warned!
> Releases Speckle.Sdk and Speckle.Objects are reliable for production use, but the APIs may not be wholly stable, and there may be breaking changes between releases, with little documentation.
# Repo structure
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**
@@ -27,11 +31,13 @@ This repo is the home of our next-generation Speckle .NET SDK. It uses .NET Stan
### Other repos
Make sure to also check and ⭐️ these other Speckle next generation repositories:
Make sure to also check and ⭐️ these other repositories:
- [`speckle-sharp-connectors`](https://github.com/specklesystems/speckle-sharp-connectors): our csharp repo of next gen connectors
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-sketchup): Sketchup connector
- [`speckle-powerbi`](https://github.com/specklesystems/speckle-powerbi): PowerBi connector
- [`speckle-sharp-connectors`](https://github.com/specklesystems/speckle-sharp-connectors): our csharp repo of next gen connectors.
- [`speckle-server`](https://github.com/specklesystems/speckle-server): the speckle server.
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-blender): Blender connector.
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-sketchup): Sketchup connector.
- [`speckle-powerbi`](https://github.com/specklesystems/speckle-powerbi): PowerBi connector.
- and more [connectors & tooling](https://github.com/specklesystems/)!
## Documentation
@@ -44,18 +50,24 @@ Comprehensive developer and user documentation can be found in our:
### Building
Make sure you clone this repository together with its submodules: `git clone https://github.com/specklesystems/speckle-sharp-sdk.git -recursive`.
Afterwards, just restore all the NuGet packages and hit Build!
Ensure you're using a [8.0.4xx](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) .NET SDK.
After cloning this repository, just restore all the NuGet packages and hit Build!
### Developing
This project is evolving fast, to better understand how to use Core we suggest checking out the Unit and Integration tests. Running the integration tests locally requires a local server running on your computer.
It is highly recommended you use
- Either Jetbrains Rider or Visual Studio 2022
- Ensure your IDE is set to use [the correct .NET SDK version](https://github.com/specklesystems/speckle-sharp-sdk/blob/main/global.json) (newer major versions may work, but may incorrectly run analysers we haven't configured)
- You should install the cshapier plugin ([Rider](https://plugins.jetbrains.com/plugin/18243-csharpier), [VS](https://marketplace.visualstudio.com/items?itemName=csharpier.CSharpier)) and configure it to run on save
We'll be also adding [preliminary documentation on our forum](https://discourse.speckle.works/c/speckle-insider/10).
Docs are a bit patchy [https://docs.speckle.systems/developers/looking-for-developer-docs](https://docs.speckle.systems/developers/looking-for-developer-docs)
### Tests
There are two test projects, one for unit tests and one for integration tests. The latter needs a server running locally in order to run.
There are several test projects. It is a requirement that all tests pass for PRs to be merged.
The Integration test projects require a local server to be running.
You must have docker installed. Then you can run `docker compose up --wait` from the root of the repo to start the required containers.
## Contributing
+17 -1
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
@@ -20,7 +23,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{DA2AED
Directory.Packages.props = Directory.Packages.props
global.json = global.json
README.md = README.md
GitVersion.yml = GitVersion.yml
docker-compose.yml = docker-compose.yml
CodeMetricsConfig.txt = CodeMetricsConfig.txt
Directory.Build.Targets = Directory.Build.Targets
@@ -51,6 +53,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 +111,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 +133,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
+45
View File
@@ -0,0 +1,45 @@
<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="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>
+8 -10
View File
@@ -1,4 +1,3 @@
using System.Text.Json;
using GlobExpressions;
using static Bullseye.Targets;
using static SimpleExec.Command;
@@ -16,14 +15,13 @@ const string CLEAN_LOCKS = "clean-locks";
const string PERF = "perf";
const string DEEP_CLEAN = "deep-clean";
static async Task<(string, string)> GetVersions()
static (string semver, string fileVerison) GetVersions()
{
var (output, _) = await ReadAsync("dotnet", "dotnet-gitversion /output json").ConfigureAwait(false);
output = output.Trim();
var jDoc = JsonDocument.Parse(output);
var version = jDoc.RootElement.GetProperty("FullSemVer").GetString() ?? "3.0.0-localBuild";
var fileVersion = jDoc.RootElement.GetProperty("AssemblySemFileVer").GetString() ?? "3.0.0.0";
return (version, fileVersion);
string semver =
Environment.GetEnvironmentVariable("SEMVER") ?? throw new ArgumentException("Expected SEMVER env var");
string fileVersion =
Environment.GetEnvironmentVariable("FILE_VERSION") ?? throw new ArgumentException("Expected FILE_VERSION env var");
return (semver, fileVersion);
}
Target(
@@ -77,7 +75,7 @@ Target(
dependsOn: [RESTORE],
async () =>
{
var (version, fileVersion) = await GetVersions().ConfigureAwait(false);
var (version, fileVersion) = GetVersions();
Console.WriteLine($"Version: {version} & {fileVersion}");
await RunAsync(
"dotnet",
@@ -174,7 +172,7 @@ Target(
async () =>
{
{
var (version, fileVersion) = await GetVersions().ConfigureAwait(false);
var (version, fileVersion) = GetVersions();
Console.WriteLine($"Version: {version} & {fileVersion}");
await RunAsync("dotnet", $"pack Speckle.Sdk.sln -c Release -o output --no-build -p:Version={version}")
.ConfigureAwait(false);
+13 -6
View File
@@ -1,4 +1,3 @@
version: "3.9"
name: "speckle-server"
services:
@@ -22,7 +21,7 @@ services:
retries: 30
redis:
image: "redis:6.0-alpine"
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
restart: always
volumes:
- redis-data:/data
@@ -38,6 +37,9 @@ services:
restart: always
volumes:
- minio-data:/data
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
healthcheck:
test:
[
@@ -53,11 +55,11 @@ services:
image: speckle/speckle-server:latest
restart: always
healthcheck:
test:
test:
- CMD
- /nodejs/bin/node
- -e
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }"
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(Number(res.statusCode != 200 || body.toLowerCase().includes('error')));}); }).end(); } catch { process.exit(1); }"
interval: 10s
timeout: 10s
retries: 3
@@ -79,8 +81,9 @@ services:
# TODO: Change thvolumes:
REDIS_URL: "redis://redis"
S3_ENDPOINT: "http://minio:9000"
S3_PUBLIC_ENDPOINT: 'http://127.0.0.1:9000'
S3_ACCESS_KEY: "minioadmin"
S3_SECRET_KEY: "minioadmin"
S3_BUCKET: "speckle-server"
@@ -102,6 +105,10 @@ services:
ENABLE_MP: "false"
LOG_PRETTY: "true"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
networks:
default:
@@ -110,4 +117,4 @@ networks:
volumes:
postgres-data:
redis-data:
minio-data:
minio-data:
@@ -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=="
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using Speckle.Objects.Data;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Proxies;
namespace Speckle.Objects.Other;
/// <summary>
/// Proxy for levels as DataObject value.
/// <remarks> These proxy lives in Objects library because it depends on DataObject</remarks>
/// </summary>
[SpeckleType("Objects.Other.LevelProxy")]
public class LevelProxy : Base, IProxyCollection
{
/// <summary>
/// The list of application ids of objects that use this level
/// </summary>
public required List<string> objects { get; set; }
public required DataObject value { get; set; }
}
@@ -1,7 +1,6 @@
using System.Drawing;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Proxies;
namespace Speckle.Objects.Other;
@@ -39,20 +38,3 @@ public class RenderMaterial : Base
set => diffuse = value.ToArgb();
}
}
/// <summary>
/// Used to store render material to object relationships in root collections
/// </summary>
[SpeckleType("Objects.Other.RenderMaterialProxy")]
public class RenderMaterialProxy : Base, IProxyCollection
{
/// <summary>
/// The list of application ids of objects that use this render material
/// </summary>
public required List<string> objects { get; set; }
/// <summary>
/// The render material used by <see cref="objects"/>
/// </summary>
public required RenderMaterial value { get; set; }
}
@@ -0,0 +1,22 @@
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Proxies;
namespace Speckle.Objects.Other;
/// <summary>
/// Used to store render material to object relationships in root collections
/// <remarks> These proxy lives in Objects library because it depends on RenderMaterial</remarks>
/// </summary>
[SpeckleType("Objects.Other.RenderMaterialProxy")]
public class RenderMaterialProxy : Base, IProxyCollection
{
/// <summary>
/// The list of application ids of objects that use this render material
/// </summary>
public required List<string> objects { get; set; }
/// <summary>
/// The render material used by <see cref="objects"/>
/// </summary>
public required RenderMaterial value { get; set; }
}
-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, )",
@@ -22,23 +22,4 @@ public static class Collections
public static class EnumerableExtensions
{
public static IEnumerable<int> RangeFrom(int from, int to) => Enumerable.Range(from, to - from + 1);
#if NETSTANDARD2_0
public static IEnumerable<TSource> DistinctBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector
)
{
var keys = new HashSet<TKey>();
foreach (var element in source)
{
if (keys.Contains(keySelector(element)))
{
continue;
}
keys.Add(keySelector(element));
yield return element;
}
}
#endif
}
@@ -5,7 +5,7 @@ namespace Speckle.Sdk.Dependencies.Serialization;
public abstract class ChannelLoader<T>(CancellationToken cancellationToken)
{
private const int RECEIVE_CAPACITY = 5000;
private const int RECEIVE_CAPACITY = 10000;
private const int HTTP_GET_CHUNK_SIZE = 500;
private const int MAX_PARALLELISM_HTTP = 4;
@@ -109,6 +109,9 @@ public abstract class ChannelLoader<T>(CancellationToken cancellationToken)
Exception = ex;
_channel.Writer.TryComplete(ex);
//cancel everything!
_cts.Cancel();
if (!_cts.IsCancellationRequested)
{
_cts.Cancel();
}
}
}
@@ -8,13 +8,13 @@ namespace Speckle.Sdk.Dependencies.Serialization;
public abstract class ChannelSaver<T>
where T : IHasByteSize
{
private const int SEND_CAPACITY = 1000;
private const int SEND_CAPACITY = 10000;
private const int HTTP_SEND_CHUNK_SIZE = 25_000_000; //bytes
private static readonly TimeSpan HTTP_BATCH_TIMEOUT = TimeSpan.FromSeconds(2);
private const int MAX_PARALLELISM_HTTP = 4;
private const int HTTP_CAPACITY = 500;
private const int MAX_CACHE_WRITE_PARALLELISM = 4;
private const int MAX_CACHE_BATCH = 500;
private const int MAX_CACHE_WRITE_PARALLELISM = 1;
private const int MAX_CACHE_BATCH = 1000;
private readonly Channel<T> _checkCacheChannel = Channel.CreateBounded<T>(
new BoundedChannelOptions(SEND_CAPACITY)
@@ -45,9 +45,9 @@ public abstract class ChannelSaver<T>
cancellationToken
)
.Join()
.Batch(cacheBatchSize ?? MAX_CACHE_BATCH)
.Batch(cacheBatchSize ?? MAX_CACHE_BATCH, singleReader: true)
.WithTimeout(HTTP_BATCH_TIMEOUT)
.ReadAllConcurrently(maxParallelism ?? MAX_CACHE_WRITE_PARALLELISM, SaveToCache, cancellationToken)
.ReadAllConcurrently(MAX_CACHE_WRITE_PARALLELISM, SaveToCache, cancellationToken)
.ContinueWith(
t =>
{
@@ -41,13 +41,7 @@ internal sealed class SpeckleHttpClientHandler : DelegatingHandler
activity?.InjectHeaders((k, v) => request.Headers.TryAddWithoutValidation(k, v));
var policyResult = await _resiliencePolicy
.ExecuteAndCaptureAsync(
ctx =>
{
return base.SendAsync(request, cancellationToken);
},
context
)
.ExecuteAndCaptureAsync(ctx => base.SendAsync(request, cancellationToken), context)
.ConfigureAwait(false);
context.TryGetValue("retryCount", out var retryCount);
activity?.SetTag("retryCount", retryCount);
+272
View File
@@ -0,0 +1,272 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL.Resources;
using Speckle.Sdk.Common;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Transports;
using Speckle.Sdk.Transports.ServerUtils;
namespace Speckle.Sdk.Api.Blob;
public partial interface IBlobApi : IDisposable;
/// <summary>
/// Low level access to the blob API
/// </summary>
/// <seealso cref="FileImportResource"/>
/// <seealso cref="ServerApi"/>
[GenerateAutoInterface]
public sealed class BlobApi : IBlobApi
{
public const int DEFAULT_TIMEOUT_SECONDS = SpeckleHttp.DEFAULT_TIMEOUT_SECONDS;
private static readonly string[] s_filenameSeparator = ["filename="];
private readonly ISdkActivityFactory _activityFactory;
/// <summary>
/// HTTP client for communicating with Speckle Server with auth token header
/// </summary>
private readonly HttpClient _authedClient;
/// <summary>
/// HTTP client for communicating with pre-signed s3 url
/// </summary>
private readonly HttpClient _unauthedClient;
public BlobApi(
ISpeckleHttp speckleHttp,
ISdkActivityFactory activityFactory,
Account account,
int timeoutSeconds = DEFAULT_TIMEOUT_SECONDS
)
{
_activityFactory = activityFactory;
_authedClient = speckleHttp.CreateHttpClient(
new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip },
timeoutSeconds: timeoutSeconds,
authorizationToken: account.token
);
_authedClient.BaseAddress = new(account.serverInfo.url);
_unauthedClient = speckleHttp.CreateHttpClient(
new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip },
timeoutSeconds: timeoutSeconds
);
}
private static string GetBlobDownloadPath(string blobId, HttpResponseMessage response)
{
response.Content.Headers.TryGetValues("Content-Disposition", out IEnumerable<string>? cdHeaderValues);
var cdHeader = (cdHeaderValues?.FirstOrDefault()).NotNull(
"Expected response from server to contain attachment header"
);
string fileName = cdHeader.Split(s_filenameSeparator, StringSplitOptions.None)[1].TrimStart('"').TrimEnd('"');
return Path.Combine(
SpecklePathProvider.BlobStoragePath(),
$"{blobId[..Models.Blob.LocalHashPrefixLength]}-{fileName}"
);
}
/// <param name="blobId">The ID of the blob to download</param>
/// <param name="progress"></param>
/// <param name="cancellationToken"></param>
/// <exception cref="HttpRequestException">Request for the blob fails</exception>
/// <exception cref="OperationCanceledException"></exception>
/// <returns>File Path of the downloaded file</returns>
public async Task<string> DownloadBlob(
string projectId,
string blobId,
string? pathOverride = null,
IProgress<ProgressArgs>? progress = null,
CancellationToken cancellationToken = default
)
{
using var _ = _activityFactory.Start();
var url = new Uri($"api/stream/{projectId}/blob/{blobId}", UriKind.Relative);
using var response = await _authedClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string fileLocation = pathOverride ?? GetBlobDownloadPath(blobId, response);
using var source = new ProgressStream(
#if NET5_0_OR_GREATER
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
#else
await response.Content.ReadAsStreamAsync().ConfigureAwait(false),
#endif
response.Content.Headers.ContentLength,
progress,
true
);
using var fs = new FileStream(fileLocation, FileMode.OpenOrCreate);
#if NET5_0_OR_GREATER
await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
#else
await source.CopyToAsync(fs).ConfigureAwait(false);
#endif
return fileLocation;
}
/// <summary>Queries the server for diff of the given <paramref name="blobIds"/></summary>
/// <param name="blobIds"></param>
/// <param name="cancellationToken"></param>
/// <returns>A list of blob ids that the server doesn't have</returns>
/// <exception cref="HttpRequestException">Request for the blob fails</exception>
/// <exception cref="OperationCanceledException"></exception>
/// <exception cref="ArgumentNullException"></exception>
public async Task<List<string>> HasBlobs(
string projectId,
IReadOnlyCollection<string> blobIds,
CancellationToken cancellationToken
)
{
using var _ = _activityFactory.Start();
cancellationToken.ThrowIfCancellationRequested();
var payload = JsonConvert.SerializeObject(blobIds);
var url = new Uri($"/api/stream/{projectId}/blob/diff", UriKind.Relative);
using StringContent stringContent = new(payload, Encoding.UTF8, "application/json");
using var response = await _authedClient.PostAsync(url, stringContent, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
#if NET5_0_OR_GREATER
var responseString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
var parsed = JsonConvert
.DeserializeObject<List<string>>(responseString)
.NotNull($"Failed to deserialize successful response {response.Content}");
return parsed;
}
/// <summary>
/// Uploads a single file to the given S3 url.
/// This method should be used together with the <see cref="FileImportResource"/> <see cref="FileImportResource.GenerateUploadUrl"/> method,
/// which generates a pre-signed S3 url, that can be used to upload the file to.
/// </summary>
/// <param name="filePath"></param>
/// <param name="url"></param>
/// <param name="cancellationToken"></param>
/// <returns>etag header</returns>
/// <seealso cref="FileImportResource"/>
/// <exception cref="HttpRequestException"></exception>
/// <exception cref="ArgumentException">Unexpected response header the server</exception>
/// <exception cref="FileNotFoundException"><paramref name="filePath"/> does not point to a file</exception>
/// <exception cref="OperationCanceledException"></exception>
public async Task<string> UploadFile(
string filePath,
Uri url,
IProgress<ProgressArgs>? progress = null,
CancellationToken cancellationToken = default
)
{
using var _ = _activityFactory.Start();
if (!File.Exists(filePath))
{
throw new FileNotFoundException("File not found.", filePath);
}
var fileInfo = new FileInfo(filePath);
using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
using var requestMessage = new HttpRequestMessage(HttpMethod.Put, url);
requestMessage.Content = progress is null
? new StreamContent(fileStream)
: new ProgressContent(new StreamContent(fileStream), progress);
requestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
requestMessage.Content.Headers.ContentLength = fileInfo.Length;
using var response = await _unauthedClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return ParseEtagHeader(response.Headers);
}
private static string ParseEtagHeader(HttpResponseHeaders headers)
{
if (!headers.TryGetValues("ETag", out var etagValues))
{
throw new ArgumentException(
"Response does not have an ETag attached to it, cannot use this as an upload",
nameof(headers)
);
}
var etagValuesArray = etagValues.ToArray();
if (etagValuesArray.Length != 1)
{
throw new ArgumentException(
$"Expected Etag header to have a single value but got {etagValuesArray.Length}",
nameof(headers)
);
}
return etagValuesArray[0];
}
/// <summary>
/// Uploads blobs via the <c>"/api/stream/:streamId/blob"</c> endpoint
/// </summary>
/// <param name="projectId"></param>
/// <param name="blobPaths"></param>
/// <param name="progress"></param>
/// <param name="cancellationToken"></param>
public async Task UploadBlobs(
string projectId,
IReadOnlyCollection<(string id, string filePath)> blobPaths,
IProgress<ProgressArgs>? progress,
CancellationToken cancellationToken
)
{
using var _ = _activityFactory.Start();
cancellationToken.ThrowIfCancellationRequested();
if (blobPaths.Count == 0)
{
return;
}
using var multipartFormDataContent = new MultipartFormDataContent();
foreach (var (id, filePath) in blobPaths)
{
var fileName = Path.GetFileName(filePath);
var stream = File.OpenRead(filePath);
var fsc = new StreamContent(stream);
multipartFormDataContent.Add(fsc, $"hash:{id}", fileName);
}
using HttpContent content = progress is null
? multipartFormDataContent
: new ProgressContent(multipartFormDataContent, progress);
var url = new Uri($"/api/stream/{projectId}/blob", UriKind.Relative);
using var response = await _authedClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
[AutoInterfaceIgnore]
public void Dispose()
{
_activityFactory.Dispose();
_authedClient.Dispose();
_unauthedClient.Dispose();
}
}
@@ -0,0 +1,13 @@
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Api.Blob;
[GenerateAutoInterface]
public sealed class BlobApiFactory(ISpeckleHttp speckleHttp, ISdkActivityFactory activityFactory) : IBlobApiFactory
{
public IBlobApi Create(Account account, int timeoutSeconds = BlobApi.DEFAULT_TIMEOUT_SECONDS) =>
new BlobApi(speckleHttp, activityFactory, account, timeoutSeconds);
}
+28
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
{
@@ -109,3 +126,14 @@ public sealed class WorkspacePermissionException : SpeckleGraphQLException
public WorkspacePermissionException(string? message, Exception? innerException)
: base(message, innerException) { }
}
public sealed class CannotCreateCommitException : SpeckleGraphQLException
{
public CannotCreateCommitException() { }
public CannotCreateCommitException(string? message)
: base(message) { }
public CannotCreateCommitException(string? message, Exception? innerException)
: base(message, innerException) { }
}
+14 -75
View File
@@ -1,18 +1,14 @@
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.Blob;
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 +33,14 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public CommentResource Comment { get; }
public SubscriptionResource Subscription { get; }
public WorkspaceResource Workspace { get; }
public ServerResource Server { get; }
public FileImportResource FileImport { get; }
public Uri ServerUrl => new(Account.serverInfo.url);
[JsonIgnore]
public Account Account { get; }
private HttpClient HttpClient { get; }
[AutoInterfaceIgnore]
public GraphQLHttpClient GQLClient { get; }
@@ -53,14 +49,16 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public Client(
ILogger<Client> logger,
ISdkActivityFactory activityFactory,
ISpeckleApplication application,
ISpeckleHttp speckleHttp,
Account account
IGraphQLClientFactory graphqlClientFactory,
IBlobApiFactory blobApiFactory,
[NotNull] Account? account
)
{
_logger = logger;
_activityFactory = activityFactory;
Account = account ?? throw new ArgumentException("Provided account is null.");
GQLClient = graphqlClientFactory.CreateGraphQLClient(account);
Project = new(this);
Model = new(this);
@@ -71,10 +69,8 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
Comment = new(this);
Subscription = new(this);
Workspace = new(this);
HttpClient = CreateHttpClient(application, speckleHttp, account);
GQLClient = CreateGraphQLClient(account, HttpClient);
Server = new(this);
FileImport = new(this, blobApiFactory.Create(account));
}
[AutoInterfaceIgnore]
@@ -82,6 +78,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
{
try
{
FileImport.Dispose();
Subscription.Dispose();
GQLClient.Dispose();
}
@@ -107,7 +104,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();
@@ -135,10 +132,10 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
activity?.SetStatus(SdkActivityStatusCode.Ok);
return ret;
}
catch (Exception ex)
catch (Exception)
{
activity?.SetStatus(SdkActivityStatusCode.Error);
activity?.RecordException(ex);
// Don't record exception as it's rethrown.
throw;
}
}
@@ -188,62 +185,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;
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Api.Blob;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Api;
@@ -10,10 +10,10 @@ namespace Speckle.Sdk.Api;
public class ClientFactory(
ILoggerFactory loggerFactory,
ISdkActivityFactory activityFactory,
ISpeckleApplication application,
ISpeckleHttp speckleHttp
IGraphQLClientFactory graphQLClientFactory,
IBlobApiFactory blobApiFactory
) : IClientFactory
{
public IClient Create(Account account) =>
new Client(loggerFactory.CreateLogger<Client>(), activityFactory, application, speckleHttp, account);
new Client(loggerFactory.CreateLogger<Client>(), activityFactory, graphQLClientFactory, blobApiFactory, account);
}
@@ -32,6 +32,8 @@ 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),
"COMMIT_CREATE_ERROR" => new CannotCreateCommitException(message),
_ => new SpeckleGraphQLException(message),
};
exceptions.Add(ex);
@@ -40,7 +40,8 @@ public static class GraphQLHttpClientExtensions
response.EnsureGraphQLSuccess();
string versionString = response.Data.data.data;
if (versionString == "dev")
//Local server builds will have a non-numerical version string
if (versionString == "dev" || versionString == "custom")
{
return new Version(999, 999, 999);
}
@@ -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);
@@ -0,0 +1,38 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record GenerateFileUploadUrlInput(string projectId, string fileName);
public record StartFileImportInput(string projectId, string modelId, string fileId, string etag);
public record FileImportResult(
double durationSeconds,
double downloadDurationSeconds,
double parseDurationSeconds,
string parser,
string? versionId
);
public abstract class FileImportInputBase
{
public required string projectId { get; init; }
public required string jobId { get; init; }
public required IReadOnlyCollection<string> warnings { get; init; }
public required FileImportResult result { get; init; }
}
#pragma warning disable CA1822 //Mark members as static
public sealed class FileImportSuccessInput() : FileImportInputBase()
{
public const string TYPE_STATUS = "success";
public string status => TYPE_STATUS;
}
public sealed class FileImportErrorInput() : FileImportInputBase()
{
public const string TYPE_STATUS = "error";
public string status => TYPE_STATUS;
public required string reason { get; init; }
}
@@ -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,
@@ -0,0 +1,13 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class FileImport
{
public string id { get; init; }
public string projectId { get; init; }
public string? convertedVersionId { get; init; }
public string userId { get; init; }
public int convertedStatus { get; init; }
public string? convertedMessage { get; init; }
public string? modelId { get; init; }
public DateTime updatedAt { get; init; }
}
@@ -0,0 +1,7 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class FileUploadUrl
{
public Uri url { get; init; }
public string fileId { 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,
@@ -1,16 +1,20 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class Workspace
public class LimitedWorkspace
{
public string id { get; init; }
public string name { get; init; }
public string role { get; init; }
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 string? description { get; init; }
}
public class Workspace : LimitedWorkspace
{
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; }
}
@@ -313,10 +313,11 @@ public sealed class ActiveUserResource
}
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <returns>The active (last selected) workspace</returns>
/// <remarks>note this returns a <see cref="LimitedWorkspace"/>, because it may be a workspace the user is not a member of</remarks>
/// <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<Workspace?> GetActiveWorkspace(CancellationToken cancellationToken = default)
public async Task<LimitedWorkspace?> GetActiveWorkspace(CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
@@ -328,21 +329,7 @@ public sealed class ActiveUserResource
role
slug
logo
createdAt
updatedAt
readOnly
description
creationState
{
completed
}
permissions {
canCreateProject {
authorized
code
message
}
}
}
}
}
@@ -351,7 +338,7 @@ public sealed class ActiveUserResource
var request = new GraphQLRequest { Query = QUERY };
var response = await _client
.ExecuteGraphQLRequest<NullableResponse<NullableResponse<Workspace?>?>>(request, cancellationToken)
.ExecuteGraphQLRequest<NullableResponse<NullableResponse<LimitedWorkspace?>?>>(request, cancellationToken)
.ConfigureAwait(false);
if (response.data is null)
@@ -0,0 +1,214 @@
using System.Diagnostics;
using GraphQL;
using Speckle.Sdk.Api.Blob;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Api.GraphQL.Resources;
public sealed class FileImportResource : IDisposable
{
private readonly ISpeckleGraphQLClient _client;
private readonly IBlobApi _blobApi;
internal FileImportResource(ISpeckleGraphQLClient client, IBlobApi blobApi)
{
_client = client;
_blobApi = blobApi;
}
/// <summary>
/// This is mostly an internal api, that marks a file import job finished.
/// </summary>
/// <param name="input">Either <see cref="FileImportSuccessInput"/> or <see cref="FileImportErrorInput"/></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <remarks>
/// Only use this if you are writing a file importer, that is responsible for
/// processing file import jobs.
/// Only works on servers version >=2.25.8
/// </remarks>
public async Task<bool> FinishFileImportJob(FileImportInputBase input, CancellationToken cancellationToken)
{
//language=graphql
const string QUERY = """
mutation FinishFileImport($input: FinishFileImportInput!) {
data:fileUploadMutations {
data:finishFileImport(input: $input)
}
}
""";
var request = new GraphQLRequest { Query = QUERY, Variables = new { input } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<bool>>>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data;
}
/// <summary>
///
/// </summary>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <remarks>Only works on servers version >=2.25.8</remarks>
public async Task<FileImport> StartFileImportJob(
StartFileImportInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation StartFileImport($input: StartFileImportInput!) {
data:fileUploadMutations {
data:startFileImport(input: $input) {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
""";
var request = new GraphQLRequest { Query = QUERY, Variables = new { input } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<FileImport>>>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data;
}
/// <summary>
/// Get a file upload url from the Speckle server.
/// This method asks the server to create a pre-signed S3 url,
/// which can be used as a short term authenticated route, to put a file to the server.
/// </summary>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <remarks>Only works on servers version >=2.25.8</remarks>
public async Task<FileUploadUrl> GenerateUploadUrl(
GenerateFileUploadUrlInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {
data:fileUploadMutations {
data:generateUploadUrl(input: $input) {
fileId
url
}
}
}
""";
var request = new GraphQLRequest { Query = QUERY, Variables = new { input } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<FileUploadUrl>>>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data;
}
/// <inheritdoc cref="Blob.BlobApi.UploadFile"/>
[DebuggerStepThrough]
public Task<string> UploadFile(
string filePath,
Uri url,
IProgress<ProgressArgs>? progress = null,
CancellationToken cancellationToken = default
) => _blobApi.UploadFile(filePath, url, progress, cancellationToken);
/// <inheritdoc cref="Blob.BlobApi.DownloadBlob"/>
[DebuggerStepThrough]
public Task DownloadFile(
string projectId,
string fileId,
string targetFile,
IProgress<ProgressArgs>? progress = null,
CancellationToken cancellationToken = default
) => _blobApi.DownloadBlob(projectId, fileId, targetFile, progress, cancellationToken);
/// <param name="projectId"></param>
/// <param name="modelId"></param>
/// <param name="limit"></param>
/// <param name="cursor"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <remarks>Only works on servers version >=2.25.8</remarks>
public async Task<ResourceCollection<FileImport>> GetModelFileImportJobs(
string projectId,
string modelId,
int limit = ServerLimits.DEFAULT_PAGINATION_REQUEST,
string? cursor = null,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query ModelFileImportJobs(
$projectId: String!,
$modelId: String!,
$input: GetModelUploadsInput
) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:uploads(input: $input) {
totalCount
cursor
items {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
}
}
""";
var request = new GraphQLRequest
{
Query = QUERY,
Variables = new
{
projectId,
modelId,
input = new { limit, cursor },
},
};
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ResourceCollection<FileImport>>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return response.data.data.data;
}
public void Dispose()
{
_blobApi.Dispose();
}
}
@@ -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;
}
}
@@ -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(
@@ -45,6 +47,11 @@ public partial class Operations
receiveActivity?.SetStatus(SdkActivityStatusCode.Ok);
return results;
}
catch (OperationCanceledException)
{
//this is handled by the caller
throw;
}
catch (Exception ex)
{
receiveActivity?.SetStatus(SdkActivityStatusCode.Error);
+22
View File
@@ -0,0 +1,22 @@
using Speckle.InterfaceGenerator;
namespace Speckle.Sdk.Caching;
/// <summary>
/// This mocks away the file system operations for testing purposes.
/// </summary>
[GenerateAutoInterface]
public class FileSystem : IFileSystem
{
public bool DirectoryExists(string path) => Directory.Exists(path);
public void CreateDirectory(string path) => Directory.CreateDirectory(path);
public IEnumerable<string> EnumerateFiles(string path) => Directory.EnumerateFiles(path);
public void DeleteFile(string path) => File.Delete(path);
public long GetFileSize(string path) => new FileInfo(path).Length;
public string Combine(params string[] paths) => Path.Combine(paths);
}
@@ -0,0 +1,93 @@
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Caching;
/// <summary>
/// This class manages the cache for model data, providing methods to get stream paths, clear the cache, and calculate cache size.
/// </summary>
[GenerateAutoInterface]
public class ModelCacheManager(ILogger<ModelCacheManager> logger, IFileSystem fileSystem) : IModelCacheManager
{
private const string DATA_FOLDER = "Projects";
private static readonly string s_basePath = SpecklePathProvider.UserSpeckleFolderPath;
private static string CacheFolder => Path.Combine(s_basePath, DATA_FOLDER);
public string GetStreamPath(string streamId) => GetDbPath(streamId);
public static string GetDbPath(string streamId)
{
var db = Path.Combine(CacheFolder, $"{streamId}.db");
try
{
Directory.CreateDirectory(CacheFolder); //ensure dir is there
return db;
}
catch (Exception ex)
when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException)
{
throw new TransportException($"Path was invalid or could not be created {db}", ex);
}
}
public void ClearCache()
{
try
{
if (!fileSystem.DirectoryExists(CacheFolder))
{
return;
}
foreach (var db in fileSystem.EnumerateFiles(CacheFolder))
{
try
{
fileSystem.DeleteFile(db);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
logger.LogWarning(ex, "Failed to delete cache file {filePath}", db);
}
}
}
catch (Exception ex)
when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException)
{
throw new TransportException($"Cache folder could not be cleared: {CacheFolder}", ex);
}
}
public long GetCacheSize()
{
try
{
if (!fileSystem.DirectoryExists(CacheFolder))
{
return 0;
}
long size = 0;
foreach (var file in fileSystem.EnumerateFiles(CacheFolder))
{
try
{
size += fileSystem.GetFileSize(file);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
logger.LogWarning(ex, "Failed to get size for cache file {a}", file);
}
}
return size;
}
catch (Exception ex)
when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException)
{
throw new TransportException($"Cache folder size could not be determined: {CacheFolder}", ex);
}
}
}
+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,
};
}
}
+18 -139
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,13 +169,12 @@ 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
RemoveAccount(id);
_accountStorage.SaveObject(account.id.NotNull(), JsonConvert.SerializeObject(account));
_accountStorage.UpdateObject(account.id.NotNull(), JsonConvert.SerializeObject(account));
}
public IEnumerable<Account> GetAccounts(string serverUrl)
@@ -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.
@@ -493,7 +407,7 @@ public sealed class AccountManager(
{
account.isDefault = true;
}
_accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account));
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
}
}
@@ -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;
+4
View File
@@ -15,10 +15,14 @@ internal static class TypeLoader
private static ConcurrentDictionary<string, Type> s_cachedTypes = new();
private static ConcurrentDictionary<Type, string> s_fullTypeStrings = new();
private static ConcurrentDictionary<PropertyInfo, JsonPropertyAttribute?> s_jsonPropertyAttribute = new();
private static readonly ConcurrentDictionary<PropertyInfo, bool> s_obsolete = new();
private static ConcurrentDictionary<Type, IReadOnlyList<PropertyInfo>> s_propInfoCache = new();
public static IEnumerable<LoadedType> Types => s_availableTypes;
public static bool IsObsolete(PropertyInfo property) =>
s_obsolete.GetOrAdd(property, p => p.IsDefined(typeof(ObsoleteAttribute), true));
public static JsonPropertyAttribute? GetJsonPropertyAttribute(PropertyInfo property) =>
s_jsonPropertyAttribute.GetOrAdd(property, p => p.GetCustomAttribute<JsonPropertyAttribute>(true));
+1 -29
View File
@@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Speckle.Newtonsoft.Json;
using Speckle.Newtonsoft.Json.Linq;
using Speckle.Sdk.Common;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Host;
using Speckle.Sdk.Serialisation;
@@ -92,8 +91,7 @@ public class Base : DynamicBase, ISpeckleObject
var typedProps = @base.GetInstanceMembers();
foreach (var prop in typedProps.Where(p => p.CanRead))
{
bool isIgnored =
prop.IsDefined(typeof(ObsoleteAttribute), true) || prop.IsDefined(typeof(JsonIgnoreAttribute), true);
bool isIgnored = TypeLoader.IsObsolete(prop) || prop.IsDefined(typeof(JsonIgnoreAttribute), true);
if (isIgnored)
{
continue;
@@ -193,30 +191,4 @@ public class Base : DynamicBase, ISpeckleObject
return count;
}
}
/// <summary>
/// Creates a shallow copy of the current base object.
/// This operation does NOT copy/duplicate the data inside each prop.
/// The new object's property values will be pointers to the original object's property value.
/// </summary>
/// <returns>A shallow copy of the original object.</returns>
public Base ShallowCopy()
{
Type type = GetType();
Base myDuplicate = (Base)Activator.CreateInstance(type).NotNull();
myDuplicate.id = id;
myDuplicate.applicationId = applicationId;
foreach (var kvp in GetMembers())
{
var propertyInfo = type.GetProperty(kvp.Key);
if (propertyInfo is not null && !propertyInfo.CanWrite)
{
continue;
}
myDuplicate[kvp.Key] = kvp.Value;
}
return myDuplicate;
}
}
+40 -2
View File
@@ -7,7 +7,7 @@ namespace Speckle.Sdk.Models;
/// <summary>
/// Base class implementing a bunch of nice dynamic object methods, like adding and removing props dynamically. Makes c# feel like json.
/// <para>Orginally adapted from Rick Strahl 🤘</para>
/// <para>Originally adapted from Rick Strahl 🤘</para>
/// <para>https://weblog.west-wind.com/posts/2012/feb/08/creating-a-dynamic-extensible-c-expando-object</para>
/// </summary>
public class DynamicBase : DynamicObject, IDynamicMetaObjectProvider
@@ -84,6 +84,44 @@ public class DynamicBase : DynamicObject, IDynamicMetaObjectProvider
}
}
/// <summary>
/// Creates a shallow copy of the current base object.
/// This operation does NOT copy/duplicate the data inside each prop.
/// The new object's property values will be pointers to the original object's property value.
/// </summary>
/// <returns>A shallow copy of the original object.</returns>
public DynamicBase ShallowCopy()
{
Type type = GetType();
DynamicBase myDuplicate = (DynamicBase)(
Activator.CreateInstance(type) ?? throw new SpeckleException($"Failed to create instance of {type.Name}")
);
// Add dynamic members
foreach (var kvp in _properties)
{
myDuplicate._properties[kvp.Key] = kvp.Value;
}
var pinfos = TypeLoader.GetBaseProperties(type).Where(x => !TypeLoader.IsObsolete(x));
foreach (var pi in pinfos)
{
if (pi.CanWrite)
{
try
{
pi.SetValue(myDuplicate, pi.GetValue(this));
}
catch (Exception ex) when (!ex.IsFatal())
{
throw new SpeckleException($"Failed to set value for {type.Name}.{pi.Name}", ex);
}
}
}
return myDuplicate;
}
/// <inheritdoc />
/// <summary>
/// Gets properties via the dot syntax.
@@ -232,7 +270,7 @@ public class DynamicBase : DynamicObject, IDynamicMetaObjectProvider
.GetBaseProperties(GetType())
.Where(x =>
{
var hasObsolete = x.IsDefined(typeof(ObsoleteAttribute), true);
var hasObsolete = TypeLoader.IsObsolete(x);
// If obsolete is false and prop has obsolete attr
// OR
@@ -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;
@@ -12,7 +13,26 @@ public sealed class SqLiteJsonCacheManager : ISqLiteJsonCacheManager
{
private readonly CacheDbCommandPool _pool;
public SqLiteJsonCacheManager(string path, int concurrency)
public static ISqLiteJsonCacheManager FromMemory(int concurrency) => new SqLiteJsonCacheManager(concurrency);
private SqLiteJsonCacheManager(int concurrency)
{
//disable pooling as we pool ourselves
var builder = new SqliteConnectionStringBuilder
{
Pooling = false,
DataSource = ":memory:",
Cache = SqliteCacheMode.Shared,
Mode = SqliteOpenMode.Memory,
};
_pool = new CacheDbCommandPool(builder.ToString(), concurrency);
Initialize();
}
public static ISqLiteJsonCacheManager FromFilePath(string path, int concurrency) =>
new SqLiteJsonCacheManager(path, concurrency);
private SqLiteJsonCacheManager(string path, int concurrency)
{
//disable pooling as we pool ourselves
var builder = new SqliteConnectionStringBuilder { Pooling = false, DataSource = path };
@@ -46,12 +66,6 @@ public sealed class SqLiteJsonCacheManager : ISqLiteJsonCacheManager
command.ExecuteNonQuery();
}
// Insert Optimisations
//Note / Hack: This setting has the potential to corrupt the db.
//cmd = new SqliteCommand("PRAGMA synchronous=OFF;", Connection);
//cmd.ExecuteNonQuery();
using (SqliteCommand cmd1 = new("PRAGMA count_changes=OFF;", c))
{
cmd1.ExecuteNonQuery();
@@ -120,7 +134,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 +147,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 +166,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) =>
@@ -1,19 +1,20 @@
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Caching;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Serialisation.Utilities;
namespace Speckle.Sdk.SQLite;
[GenerateAutoInterface]
public class SqLiteJsonCacheManagerFactory : ISqLiteJsonCacheManagerFactory
public class SqLiteJsonCacheManagerFactory(IModelCacheManager modelCacheManager) : ISqLiteJsonCacheManagerFactory
{
public const int INITIAL_CONCURRENCY = 4;
private ISqLiteJsonCacheManager Create(string path, int concurrency) => new SqLiteJsonCacheManager(path, concurrency);
private ISqLiteJsonCacheManager Create(string path, int concurrency) =>
SqLiteJsonCacheManager.FromFilePath(path, concurrency);
public ISqLiteJsonCacheManager CreateForUser(string scope) =>
Create(Path.Combine(SpecklePathProvider.UserApplicationDataPath(), "Speckle", $"{scope}.db"), 1);
public ISqLiteJsonCacheManager CreateFromStream(string streamId) =>
Create(SqlitePaths.GetDBPath(streamId), INITIAL_CONCURRENCY);
Create(modelCacheManager.GetStreamPath(streamId), INITIAL_CONCURRENCY);
}
+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)
@@ -1,30 +0,0 @@
using Speckle.Sdk.Logging;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Serialisation.Utilities;
public static class SqlitePaths
{
private const string APPLICATION_NAME = "Speckle";
private const string DATA_FOLDER = "Projects";
private static readonly string basePath = SpecklePathProvider.UserApplicationDataPath();
public static string BlobStorageFolder =>
SpecklePathProvider.BlobStoragePath(Path.Combine(basePath, APPLICATION_NAME));
public static string GetDBPath(string streamId)
{
var dir = Path.Combine(basePath, APPLICATION_NAME, DATA_FOLDER);
var db = Path.Combine(dir, $"{streamId}.db");
try
{
Directory.CreateDirectory(dir); //ensure dir is there
return db;
}
catch (Exception ex)
when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException)
{
throw new TransportException($"Path was invalid or could not be created {db}", ex);
}
}
}
@@ -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;
}
}
@@ -146,7 +171,7 @@ public sealed class ObjectLoader(
cancellationToken.ThrowIfCancellationRequested();
if (Exception is not null)
{
throw new SpeckleException("Error while loading", Exception);
throw new SpeckleException($"Error while loading: {Exception.Message}", Exception);
}
}
}
@@ -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,14 +34,15 @@ public sealed class ObjectSaver(
private readonly SerializeProcessOptions _options = options ?? new();
private long _uploaded;
private long _uploading;
private long _cached;
private long _objectsSerialized;
private bool _disposed;
protected override async Task SendToServerInternal(Batch<BaseItem> batch)
{
if (_cancellationTokenSource.IsCancellationRequested)
if (IsCancelled())
{
return;
}
@@ -56,24 +57,30 @@ 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)
{
_cancellationTokenSource.Cancel();
CancelSaving();
}
#pragma warning disable CA1031
catch (Exception e)
#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
);
}
}
@@ -85,7 +92,7 @@ public sealed class ObjectSaver(
public override void SaveToCache(List<BaseItem> batch)
{
if (_cancellationTokenSource.IsCancellationRequested)
if (IsCancelled())
{
return;
}
@@ -100,26 +107,49 @@ public sealed class ObjectSaver(
}
catch (OperationCanceledException)
{
_cancellationTokenSource.Cancel();
CancelSaving();
}
#pragma warning disable CA1031
catch (Exception e)
#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 bool IsCancelled() => _disposed || _cancellationTokenSource.IsCancellationRequested;
private void CancelSaving()
{
if (IsCancelled())
{
return;
}
_cancellationTokenSource.Cancel();
}
private void RecordException(Exception e)
{
if (IsCancelled())
{
return;
}
//order here matters
logger.LogError(e, "Error in SDK");
logger.LogError(e, "Error in SDK: {message}", e.Message);
Exception = e;
_cancellationTokenSource.Cancel();
}
public void Dispose()
{
_disposed = true;
_cancellationTokenSource.Dispose();
sqLiteJsonCacheManager.Dispose();
}
@@ -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;
}
@@ -16,8 +16,8 @@ public record SerializeProcessOptions(
bool SkipFindTotalObjects = false
)
{
public int? MaxHttpSendSize { get; set; }
public int? MaxCacheSize { get; set; }
public int? MaxHttpSendBatchSize { get; set; }
public int? MaxCacheBatchSize { get; set; }
public int? MaxParallelism { get; set; }
}
@@ -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,
@@ -45,6 +47,7 @@ public sealed class SerializeProcess(
cancellationToken
);
private readonly ILogger<SerializeProcess> _logger = loggerFactory.CreateLogger<SerializeProcess>();
private bool _disposed;
//async dispose
[SuppressMessage("Usage", "CA2213:Disposable fields should be disposed")]
@@ -81,6 +84,7 @@ public sealed class SerializeProcess(
[AutoInterfaceIgnore]
public async ValueTask DisposeAsync()
{
_disposed = true;
await WaitForSchedulerCompletion().ConfigureAwait(false);
await _highest.DisposeAsync().ConfigureAwait(false);
await _belowNormal.DisposeAsync().ConfigureAwait(false);
@@ -93,7 +97,7 @@ public sealed class SerializeProcess(
//order here matters...null with cancellation means a user did it, otherwise it's a real Exception
if (objectSaver.Exception is not null)
{
throw new SpeckleException("Error while sending", objectSaver.Exception);
throw new SpeckleException($"Error while sending: {objectSaver.Exception.Message}", objectSaver.Exception);
}
_processSource.Token.ThrowIfCancellationRequested();
}
@@ -110,8 +114,8 @@ public sealed class SerializeProcess(
{
var channelTask = objectSaver.Start(
options?.MaxParallelism,
options?.MaxHttpSendSize,
options?.MaxCacheSize,
options?.MaxHttpSendBatchSize,
options?.MaxCacheBatchSize,
_processSource.Token
);
var findTotalObjectsTask = Task.CompletedTask;
@@ -146,7 +150,7 @@ public sealed class SerializeProcess(
private void TraverseTotal(Base obj)
{
if (_processSource.Token.IsCancellationRequested)
if (IsCancelled())
{
return;
}
@@ -160,7 +164,7 @@ public sealed class SerializeProcess(
private async Task<Dictionary<Id, NodeInfo>> Traverse(Base obj)
{
if (_processSource.Token.IsCancellationRequested)
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
@@ -172,7 +176,7 @@ public sealed class SerializeProcess(
{
// tmp is necessary because of the way closures close over loop variables
var tmp = child;
if (_processSource.Token.IsCancellationRequested)
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
@@ -189,7 +193,7 @@ public sealed class SerializeProcess(
tasks.Add(t);
}
if (_processSource.Token.IsCancellationRequested)
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
@@ -216,7 +220,7 @@ public sealed class SerializeProcess(
}
_taskResultPool.Return(tasks);
if (_processSource.Token.IsCancellationRequested)
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
@@ -224,22 +228,30 @@ public sealed class SerializeProcess(
var childClosures = _childClosurePool.Get();
foreach (var childClosure in taskClosures)
{
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
foreach (var kvp in childClosure)
{
childClosures[kvp.Key] = kvp.Value;
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
}
_currentClosurePool.Return(childClosure);
}
if (_processSource.Token.IsCancellationRequested)
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
var items = baseSerializer.Serialise(obj, childClosures, _options.SkipCacheRead, _processSource.Token);
if (_processSource.Token.IsCancellationRequested)
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
@@ -251,13 +263,13 @@ public sealed class SerializeProcess(
progress?.Report(new(ProgressEvent.FromCacheOrSerialized, _objectCount, Math.Max(_objectCount, _objectsFound)));
foreach (var item in items)
{
if (IsCancelled())
{
return EMPTY_CLOSURES;
}
if (item.NeedsStorage)
{
if (_processSource.Token.IsCancellationRequested)
{
return EMPTY_CLOSURES;
}
Interlocked.Increment(ref _objectsSerialized);
await objectSaver.SaveAsync(item).ConfigureAwait(false);
}
@@ -288,10 +300,29 @@ public sealed class SerializeProcess(
}
}
private void RecordException(Exception e)
public bool IsCancelled() => _disposed || _processSource.IsCancellationRequested;
public void RecordException(Exception e)
{
if (e is OperationCanceledException)
{
return;
}
if (
e is AggregateException ae
&& ae.InnerExceptions.Count == ae.InnerExceptions.OfType<OperationCanceledException>().Count()
)
{
return;
}
if (IsCancelled())
{
//if we are already cancelled, don't log or save the exceptions
return;
}
//order here matters
_logger.LogError(e, "Error in SDK");
_logger.LogError(e, "Error in SDK: {message}", e.Message);
objectSaver.Exception = e;
_processSource.Cancel();
}
@@ -64,18 +64,10 @@ public class SerializeProcessFactory(
#pragma warning disable CA2000
var memoryJsonCacheManager = new MemoryJsonCacheManager(jsonCache);
#pragma warning restore CA2000
return new SerializeProcess(
return CreateSerializeProcess(
memoryJsonCacheManager,
new MemoryServerObjectManager(objects),
progress,
new ObjectSaver(
progress,
memoryJsonCacheManager,
new MemoryServerObjectManager(objects),
loggerFactory.CreateLogger<ObjectSaver>(),
cancellationToken
),
baseChildFinder,
new BaseSerializer(memoryJsonCacheManager, objectSerializerFactory),
loggerFactory,
cancellationToken,
options
);
+2
View File
@@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.Blob;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Host;
@@ -86,6 +87,7 @@ public static class ServiceRegistration
typeof(ServerApi),
typeof(SqLiteJsonCacheManager),
typeof(ServerObjectManager),
typeof(BlobApi),
typeof(BaseSerializer),
typeof(SerializeProcess),
typeof(ObjectSaver),
+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" />

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