Compare commits

...

73 Commits

Author SHA1 Message Date
Jedd Morgan af0fc9f669 Merge pull request #439 from specklesystems/duckdev
.NET Build and Publish / build (push) Has been cancelled
feat(packfile): duckdb packfile api (duckdev -> main)
2026-03-23 11:08:57 +00:00
Jedd Morgan edbc884d74 Clean diff 2026-03-23 10:45:57 +00:00
Jedd Morgan 025d7f70ba Merge branch 'main' into duckdev 2026-03-23 10:41:22 +00:00
Jedd Morgan 70acc06f37 feat(otel): Change sig for remote (#460)
.NET Build and Publish / build (push) Has been cancelled
* Remote changes

* Fix tests

* Fix tests

* ditto
2026-03-16 15:32:33 +00:00
Jedd Morgan a2c99a537a feat(otel): Add proper support for remote spans (#458)
.NET Build and Publish / build (push) Has been cancelled
* Add proper support for remote spans

* Fix tests
2026-03-12 12:52:10 +00:00
Jedd Morgan 906ff9c3ff test: duck tests (#455)
.NET Build and Publish / build (push) Has been cancelled
* lets get some testing action!

* revert url

* we'll try these fixes

* we'll try this, if it doesn't work I'm deleting the test

* try this

* Add optional parent id to traces

* how about now

* and as zero

* Fix build

* fix code cov

* FML subscription tests are so flaky
2026-03-11 16:30:24 +00:00
Jedd Morgan 515d45528d feat(otel): traces for new send pipeline (#457)
* 😃 extra traces

* default
2026-03-11 14:51:58 +00:00
dependabot[bot] abf86eda03 chore(deps): bump docker/login-action from 3 to 4 (#456)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 09:50:34 +00:00
Jedd Morgan f777050c10 change order of tab delimited json (#454)
.NET Build and Publish / build (push) Has been cancelled
2026-03-05 13:40:18 +00:00
Jedd Morgan c4e956cdb4 fix(build): Option 2 - "keep channels il-repacked" (#453)
* Repacked channel

* transitive for sdk

* restore solution

* comment tweaks

* Fix print line
2026-03-05 12:47:56 +00:00
Oğuzhan Koral a662fb54c2 fix: channels reference only for net standard (#450)
.NET Build and Publish / build (push) Has been cancelled
2026-02-27 10:32:16 +00:00
Jedd Morgan 9a74195b24 feat(api): Add versionId to ModelIngestion (#448)
.NET Build and Publish / build (push) Has been cancelled
* Add version id to ingestion query

* and the sub

* Add project & user ids

* test
2026-02-26 17:10:54 +00:00
Jedd Morgan 0ca9162e7b pass etag to trigger processing (#447)
.NET Build and Publish / build (push) Has been cancelled
2026-02-26 16:09:19 +00:00
Jedd Morgan 070f21b075 feat(progress): Add progress reporting to uploader (#446)
.NET Build and Publish / build (push) Has been cancelled
* refactor uploader for progress

* progress

* not so many decimal places

* small tweak to RenderStreamProgress

* fix unit tests

* uploading data
2026-02-26 10:45:48 +00:00
Dimitrie Stefanescu 9bf6995b15 Add headers for Azure S3 compat 2026-02-24 11:57:16 +00:00
Jedd Morgan 43ebc84881 feat(api): DI Refactor for Duck DB + Gergo's API endpoint changes (#444)
* di

* di2

* Gergo's api changes
2026-02-20 14:20:29 +00:00
Jedd Morgan 7652cd385d Merge remote-tracking branch 'origin/main' into duckdev 2026-02-20 13:40:29 +00:00
Jedd Morgan a81aaca8fe Updated tests for recent server changes (#445) 2026-02-20 12:02:34 +00:00
Jedd Morgan 57843cc454 Test(integration): Add reusable workflow for running integration tests from the server repo (#443)
* experiment

* rename

* we'll give this a go

* fix path

* correct path

* the correct path this time

* build docker image first

* correct docker build

* try this

* ensure editor config is observed

* typo
2026-02-12 18:20:55 +00:00
Jedd Morgan bf6ae0f6af Duck: Cleanup dependencies (#441)
* Remove unused dependencies

* remove unused package references

* how's this

* missed a bit
2026-02-10 16:29:27 +00:00
Jedd Morgan 309cead189 changes to serializer value handilng (#440) 2026-02-10 12:25:05 +00:00
Dimitrie Stefanescu 4980796cd6 Dim/flopper experiments (#438)
* Adds object flopper for experimental object uploads

Implements a new mechanism for uploading objects to the server.

* wip

* pipe works

* WIP

* wip - refactors send pipeline
2026-02-10 10:17:38 +00:00
Jedd Morgan 12df19e431 fix(test): Ensure InitializeWebsocket runs async (#437)
* Force InitializeWebsocket

* experiment with lower values

* let's play it safe
2026-02-10 09:52:19 +00:00
Jedd Morgan c186d98ea7 feat(api): Add CanCreateModelIngestion model permission check (#436)
.NET Build and Publish / build (push) Has been cancelled
* CanCreateModelIngestion check

* assert this way

* correct exception type
2026-01-29 14:41:57 +00:00
Jedd Morgan 00a6619cbe feat(api)!: Add model permission checks and deprecate canPublish (#434)
.NET Build and Publish / build (push) Has been cancelled
* Add permission checks and deprecate canPublish

* Fix tests

* How's this

* make tests more reliable

* lets test this first

* test

* This should speed up unit tests

* skip slow tests

* I HATE flaky tests
2026-01-27 16:35:48 +00:00
Jedd Morgan 49ef9917c4 Model Ingestion Version message (#427)
.NET Build and Publish / build (push) Has been cancelled
2026-01-26 16:03:16 +00:00
Jedd Morgan 94b0473157 MarkReceivedVersionInput clarification (#433) 2026-01-26 15:52:56 +00:00
Jedd Morgan 8071990dd5 deprecate file import api (#431) 2026-01-15 16:29:33 +00:00
Jedd Morgan 8c7dbc89aa update nullability of invitedBy (#432) 2026-01-15 14:21:40 +00:00
Jedd Morgan 676a3df153 Update docker-compose-internal.yml (#430) 2026-01-07 15:54:30 +00:00
Jedd Morgan c75538e1c7 feat(api): Model Ingestion Get (#426)
.NET Build and Publish / build (push) Has been cancelled
* model ingeston get

* reinstate localhost:3000
2025-12-11 10:35:20 +00:00
Jedd Morgan 5d10b77ee4 feat(api): Model Ingestion api (#420) (#425)
.NET Build and Publish / build (push) Has been cancelled
* First pass

* format

* subscriptions

* Fixes

* fake a release

* fix tests

* subscription tests

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

* tests(integration): fix model ingestion test expectations

* todos

* revert this too

* Filter Integration-Internal tests

* use a different trait

* capitalize

* codecov tweaks

* fix

* add requeue and start processing

* requeue

---------

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

* format

* subscriptions

* Fixes

* fake a release

* fix tests

* subscription tests

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

* tests(integration): fix model ingestion test expectations

* todos

* revert this too

* Filter Integration-Internal tests

* use a different trait

* capitalize

* codecov tweaks

* fix

* add requeue and start processing

* requeue

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 19:46:31 +00:00
Jedd Morgan b0da4510bf Merge pull request #410 from specklesystems/jrm/subscription-tests
test(subscriptions): Make subscription tests a bit more reliable
2025-11-05 11:28:23 +00:00
Jedd Morgan 96392d0d2f chore(ci): Reliable integration Tests (#418)
* remove bad tests

* add pack to PR workflow
2025-11-05 11:22:07 +00:00
Jedd Morgan 0aacc3fe89 Merge pull request #417 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
main -> dev for 3.9.0 release
2025-11-05 11:03:28 +00:00
Björn Steinhagen 39f5257f85 Merge pull request #409 from specklesystems/bjorn/cnx-2722-grasshopper-root-collection-props
feat(grasshopper): add model-wide properties to send/receive
2025-11-05 12:53:21 +02:00
Björn Steinhagen 2ea6348689 fix: again :/ 2025-11-05 12:24:06 +02:00
Björn Steinhagen 42c26e38bf fix: unnecessary using directive 2025-11-05 12:22:27 +02:00
Björn Steinhagen cb31fd1a08 chore: namespace change 2025-11-05 12:18:40 +02:00
Björn 67236abafe refactor: rootCollection to use IProperties 2025-10-29 14:39:06 +02:00
Björn 59a4f8f864 Merge remote-tracking branch 'origin/dev' into bjorn/cnx-2722-grasshopper-root-collection-props 2025-10-29 14:19:33 +02:00
Jedd Morgan 0e98e1cccd refactor(ci): Refactor CI to run integration tests as separate workflow (#413)
* Refactor CI to run integration tests as separate workflow

* Tool restore

* correct cache path

* conditionally use container registry

* use sln because net8

* fix typo

* Correct trait filter

* Correct mistake again

* fix again

* fml

* clarify names

* hopefully we're properly filtering test categories now

* maybe this?

* What does this do?

* revert is test project changes

* IsTestProject fix

* Correct test setup for automate

* maybe fix unit tests

* docker-compose-file alighment

* remove debug

* Ok tests should now pass
2025-10-29 10:00:51 +00:00
Claire Kuang 79c6f02544 Merge branch 'dev' into bjorn/cnx-2722-grasshopper-root-collection-props 2025-10-29 09:45:31 +00:00
Jedd Morgan 07713b41e1 Fix(gql)!: Treat UNAUTHORIZED_ACCESS_ERROR as an SpeckleGraphQLForbiddenException (#411)
* Respect UNAUTHORIZED_ACCESS_ERROR

* Correct test setup for automate

* dumb dumb typo
2025-10-28 15:48:37 +00:00
Claire Kuang c3f944dcf1 Delete ViewProxy.cs (#415)
.NET Build and Publish / build (push) Has been cancelled
2025-10-28 13:52:19 +00:00
Claire Kuang 3c0b9e8b1c Merge pull request #414 from specklesystems/claire/add-camera-class
feat(objects): add Camera class
2025-10-28 13:42:39 +00:00
Jedd Morgan 8890f8cb36 Merge pull request #416 from specklesystems/jrm/attribute-mask-revert
feat(sdk)!: Unexpose attribute mask
2025-10-28 13:29:30 +00:00
Jedd Morgan a0eae88479 Merge branch 'dev' into jrm/attribute-mask-revert 2025-10-28 13:20:52 +00:00
Jedd Morgan 8785e49f73 Add send/receive integration tests (#412) 2025-10-28 14:12:12 +01:00
Jedd Morgan 6e35d6af6d Unexpose attribute mask 2025-10-28 10:33:47 +00:00
Björn b67eb8d8af refactor: model properties to properties 2025-10-27 13:03:52 +02:00
Björn 63f06c8541 chore: format 2025-10-25 15:41:00 +02:00
Björn 3f49bb05d1 chore: cleanup 2025-10-25 15:20:24 +02:00
Björn 9a879fd1ac fix: parameterless constructor 2025-10-25 13:35:58 +02:00
Björn c3230d5d91 refactor: naming conflict 2025-10-25 12:09:34 +02:00
Björn f1a64590d7 chore(models): adds RootCollection 2025-10-25 11:59:39 +02:00
Claire Kuang c2735f0a32 chore(objects): update view proxy and camera classes (#408)
* Adds new camera and view proxy classes

* Update ViewProxy.cs

* removes viewproxy and orthographic prop on camera class
2025-10-24 11:46:30 +01:00
Claire Kuang 0b01091209 feat(objects): Adds new camera and view proxy classes (#407)
* Adds new camera and view proxy classes

* Update ViewProxy.cs
2025-10-22 10:53:38 +01:00
Jedd Morgan 6568781275 Merge pull request #405 from specklesystems/dev
Dev -> Main
2025-10-15 11:07:32 +01:00
Jedd Morgan 98223e251c Use OIDC for auth (#397)
.NET Build and Publish / build (push) Has been cancelled
2025-10-15 10:45:58 +01:00
Jedd Morgan 6740659af4 dev -> main for release (#404)
* Expose options for sending and receiving (#394)

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

* path provider

* tweaks

* Update RenderMaterial.cs (#399)

* removes the extra serializer (#402)

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

* path provider

* tweaks

* Align with duplicated class

* skip some slow tests (#403)

---------

Co-authored-by: Adam Hathcock <adamhathcock@users.noreply.github.com>
Co-authored-by: Dogukan Karatas <61163577+dogukankaratas@users.noreply.github.com>
2025-10-15 10:45:26 +01:00
Jedd Morgan 08f702794a skip some slow tests (#403) 2025-10-15 09:25:07 +00:00
Jedd Morgan 879ebf7e3c feat(sdk): align SpecklePathProvider with connector repo (#400)
* path provider

* tweaks

* Align with duplicated class
2025-10-15 10:14:57 +01:00
Adam Hathcock 701013ad46 Merge pull request #393 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
dev to main for release (DONUT squash)
2025-09-24 10:22:05 +01:00
Adam Hathcock fdc0842b03 Merge pull request #388 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Main to dev (no squash)
2025-09-12 12:04:04 +01:00
Jedd Morgan 23d5dd44bc Merge pull request #382 from specklesystems/dev
.NET Build and Publish / build (push) Has been cancelled
Dev -> Main for release
2025-09-08 10:54:56 +01:00
143 changed files with 3559 additions and 439 deletions
@@ -0,0 +1,61 @@
name: Integration Test
on:
workflow_call:
inputs:
speckle-sharp-sdk-ref:
required: true
type: string
jobs:
integration-test:
env:
CLIENT_DIR: "./client"
CLIENT_REPO: "specklesystems/speckle-sharp-sdk"
SERVER_DIR: "./server"
SERVER_REPO: "specklesystems/speckle-server-internal"
SOLUTION: "Speckle.Sdk.sln"
SPECKLE_SERVER_IMAGE: "speckle-server:local"
runs-on: ubuntu-latest
steps:
- name: Checkout ${{ env.CLIENT_REPO }}
uses: actions/checkout@v6
with:
path: ${{ env.CLIENT_DIR }}
repository: ${{ env.CLIENT_REPO }}
ref: ${{ inputs.speckle-sharp-sdk-ref }}
- name: Checkout ${{ env.SERVER_REPO }}
uses: actions/checkout@v6
with:
repository: ${{ env.SERVER_REPO }}
path: ${{ env.SERVER_DIR }}
- name: Setup .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.x.x
# cache: true
# cache-dependency-path: "**/packages.lock.json"
- name: 🏗️ Build Server
run: docker build --file "./packages/server/Dockerfile" --tag ${{ env.SPECKLE_SERVER_IMAGE }} .
working-directory: ${{ env.SERVER_DIR }}
- name: ⚙️ Spin up Server
run: docker compose --file "../${{ env.CLIENT_DIR }}/docker-compose-internal.yml" up --wait
working-directory: ${{ env.SERVER_DIR }}
env:
SPECKLE_SERVER_IMAGE: ${{ env.SPECKLE_SERVER_IMAGE }}
- name: 📦 Restore .NET Solution
run: dotnet restore ${{ env.SOLUTION }} --locked-mode
working-directory: ${{ env.CLIENT_DIR }}
- name: 🏗️ Build .NET Solution
run: dotnet build ${{ env.SOLUTION }} --configuration Release --no-restore -warnaserror
working-directory: ${{ env.CLIENT_DIR }}
- name: 🔨 Run .NET Integration Tests
run: dotnet test ${{ env.SOLUTION }} --filter "(Category=Integration)&(Server!=Public)" --configuration Release --no-build --no-restore --verbosity=normal
working-directory: ${{ env.CLIENT_DIR }}
+62
View File
@@ -0,0 +1,62 @@
name: Integration Test
on:
workflow_call:
inputs:
docker-compose-file:
required: true
type: string
use-internal-image:
default: false
type: boolean
secrets:
CODECOV_TOKEN:
required: true
jobs:
integration-test:
env:
Solution: "Speckle.Sdk.sln"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.x.x
cache: true
cache-dependency-path: "**/packages.lock.json"
- name: 🔐 Login to Github Container Registry
if: ${{ inputs.use-internal-image }}
uses: docker/login-action@v4
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ github.token }}
- name: ⚙️ Spin up Server
run: docker compose --file ${{ inputs.docker-compose-file }} up --wait
- name: 📦 Restore
run: dotnet restore ${{ env.Solution }} --locked-mode
- name: 🏗️ Build
run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror
- name: 🔨 Integration Tests against Public Server
if: ${{ !inputs.use-internal-image }}
run: dotnet test ${{ env.Solution }} --filter "(Category=Integration)&(Server!=Internal)" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: 🔨 Integration Tests against Internal Server
if: ${{ inputs.use-internal-image }}
run: dotnet test ${{ env.Solution }} --filter "(Category=Integration)&(Server!=Public)" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
+56 -37
View File
@@ -1,46 +1,65 @@
name: .NET CI Build
name: PR Test
on:
pull_request:
pull_request: {}
push:
branches:
- "main" # Need to run for codecov to compare against the BASE
jobs:
build:
env:
Solution: "Speckle.Sdk.sln"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Checkout
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
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: |
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: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.x.x
cache: true
cache-dependency-path: "**/packages.lock.json"
- name: 📦 Tool Restore
run: dotnet tool restore
- name: 📄 Format
run: dotnet csharpier check .
- name: 📦 Restore
run: dotnet restore ${{ env.Solution }} --locked-mode
- name: 🏗️ Build
run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror
- name: 🔨 Unit Tests
run: dotnet test ${{ env.Solution }} --configuration Release --filter "Category!=Integration" --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: 🎁 Pack
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
integration-test-internal:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose-internal.yml"
use-internal-image: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
with:
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
integration-test-public:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose.yml"
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+18 -15
View File
@@ -2,25 +2,27 @@ name: .NET Build and Publish
on:
push:
tags: ["3.*"]
tags: ["3.*.*"]
jobs:
build:
runs-on: ubuntu-latest
environment:
name: "nuget.org"
permissions:
id-token: write # enable GitHub OIDC token issuance for this job
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.x.x
cache: true
cache-dependency-path: "**/packages.lock.json"
- 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: |
@@ -37,18 +39,19 @@ jobs:
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
- name: NuGet login (OIDC → temp API key)
uses: NuGet/login@v1
id: login
with:
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
user: ${{ secrets.NUGET_USER }}
- name: Push to nuget.org
run: dotnet nuget push output/*.nupkg --source "https://api.nuget.org/v3/index.json" --api-key ${{secrets.CONNECTORS_NUGET_TOKEN }} --skip-duplicate
run: dotnet nuget push output/*.nupkg --source "https://api.nuget.org/v3/index.json" --api-key ${{steps.login.outputs.NUGET_API_KEY}}
+1
View File
@@ -0,0 +1 @@
dotnet 8.0.400
+2 -2
View File
@@ -1,8 +1,8 @@
<Project>
<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
<PropertyGroup Condition="'$(IsTestProject)' == 'true' or '$(TestProjectAnalyserRules)' == 'true' ">
<NoWarn>
<!-- Things we need to test -->
CS0618;CA1034;CA2201;CA1051;CA1040;CA1724;
CS0618;CA1034;CA2201;CA1051;CA1040;CA1724;CA1065;
IDE0044;IDE0130;CA1508;
<!-- Analysers that provide no tangeable value to a test project -->
CA5394;CA2007;CA1852;CA1819;CA1711;CA1063;CA1816;CA2234;CS8618;CA1054;CA1810;CA2208;CA1019;CA1831;
+1 -1
View File
@@ -15,8 +15,8 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="[9.0.4,)" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="[5.0.0,)" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageVersion Include="Open.ChannelExtensions" Version="9.1.0" />
+4 -2
View File
@@ -65,10 +65,12 @@ Docs are a bit patchy [https://docs.speckle.systems/developers/looking-for-devel
### Tests
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` from the root of the repo to start the required containers.
You must have docker installed. Then you can run `docker compose up --wait` from the root of the repo to start the required containers.
In CI, they will be run against both the public and private versions of the server.
It is important that we remain compatible with both server versions.
## Contributing
Before embarking on submitting a patch, please make sure you read:
+2
View File
@@ -27,6 +27,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{DA2AED
CodeMetricsConfig.txt = CodeMetricsConfig.txt
Directory.Build.Targets = Directory.Build.Targets
.config\dotnet-tools.json = .config\dotnet-tools.json
docker-compose-internal.yml = docker-compose-internal.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{58D37DA9-F948-48CA-9A73-F5BBBD533DBF}"
@@ -41,6 +42,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
ProjectSection(SolutionItems) = preProject
.github\workflows\pr.yml = .github\workflows\pr.yml
.github\workflows\release.yml = .github\workflows\release.yml
.github\workflows\integration-test.yml = .github\workflows\integration-test.yml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Sdk.Tests.Performance", "tests\Speckle.Sdk.Tests.Performance\Speckle.Sdk.Tests.Performance.csproj", "{870E3396-E6F7-43AE-B120-E651FA4F46BD}"
+3
View File
@@ -10,6 +10,7 @@
<File Path="Directory.Build.props" />
<File Path="Directory.Build.Targets" />
<File Path="Directory.Packages.props" />
<File Path="docker-compose-internal.yml" />
<File Path="docker-compose.yml" />
<File Path="global.json" />
<File Path="README.md" />
@@ -17,6 +18,8 @@
<File Path=".github\git-commit-instructions.md" />
</Folder>
<Folder Name="/config/workflows/">
<File Path=".github/workflows/integration-test-callable-from-server-repo.yml" />
<File Path=".github/workflows/integration-test.yml" />
<File Path=".github/workflows/pr.yml" />
<File Path=".github/workflows/release.yml" />
</Folder>
+115
View File
@@ -0,0 +1,115 @@
name: "speckle-server"
services:
####
# Speckle Server dependencies
#######
postgres:
image: "postgres:16.4-alpine3.20@sha256:d898b0b78a2627cb4ee63464a14efc9d296884f1b28c841b0ab7d7c42f1fffdf"
restart: always
environment:
POSTGRES_DB: speckle
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- ./.volumes/postgres-data:/var/lib/postgresql/data/
healthcheck:
# the -U user has to match the POSTGRES_USER value
test: ["CMD-SHELL", "pg_isready -U speckle"]
interval: 5s
timeout: 5s
retries: 30
redis:
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
restart: always
volumes:
- ./.volumes/redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
timeout: 5s
retries: 30
minio:
image: "minio/minio:RELEASE.2023-10-25T06-33-25Z"
command: server /data --console-address ":9001"
restart: always
volumes:
- ./.volumes/minio-data:/data
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
healthcheck:
test:
[
"CMD-SHELL",
"curl -s -o /dev/null http://127.0.0.1:9000/minio/index.html",
]
interval: 5s
timeout: 30s
retries: 30
start_period: 10s
speckle-server:
image: ${SPECKLE_SERVER_IMAGE:-ghcr.io/specklesystems/speckle-server:latest}
restart: always
healthcheck:
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(Number(res.statusCode != 200 || body.toLowerCase().includes('error')));}); }).end(); } catch { process.exit(1); }"
interval: 10s
timeout: 10s
retries: 3
start_period: 90s
ports:
- "0.0.0.0:3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
environment:
# TODO: Change this to the URL of the speckle server, as accessed from the network
CANONICAL_URL: "http://127.0.0.1:8080"
SPECKLE_AUTOMATE_URL: "http://127.0.0.1:3030"
FRONTEND_ORIGIN: "http://127.0.0.1:8081"
# 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"
S3_CREATE_BUCKET: "true"
FILE_SIZE_LIMIT_MB: 100
MAX_PROJECT_MODELS_PER_PAGE: 500
# TODO: Change this to a unique secret for this server
SESSION_SECRET: "TODO:ReplaceWithLongString"
STRATEGY_LOCAL: "true"
POSTGRES_URL: 'postgres://speckle:speckle@postgres:5432/speckle'
ENABLE_MP: "false"
LOG_PRETTY: "true"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
networks:
default:
name: speckle-server
volumes:
postgres-data:
redis-data:
minio-data:
+8 -10
View File
@@ -12,7 +12,7 @@ services:
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- postgres-data:/var/lib/postgresql/data/
- ./.volumes/postgres-data:/var/lib/postgresql/data/
healthcheck:
# the -U user has to match the POSTGRES_USER value
test: ["CMD-SHELL", "pg_isready -U speckle"]
@@ -24,7 +24,7 @@ services:
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
restart: always
volumes:
- redis-data:/data
- ./.volumes/redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
@@ -36,7 +36,7 @@ services:
command: server /data --console-address ":9001"
restart: always
volumes:
- minio-data:/data
- ./.volumes/minio-data:/data
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
@@ -55,7 +55,7 @@ services:
image: speckle/speckle-server:latest
restart: always
healthcheck:
test:
test:
- CMD
- /nodejs/bin/node
- -e
@@ -81,9 +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_PUBLIC_ENDPOINT: "http://127.0.0.1:9000"
S3_ACCESS_KEY: "minioadmin"
S3_SECRET_KEY: "minioadmin"
S3_BUCKET: "speckle-server"
@@ -96,19 +96,17 @@ services:
SESSION_SECRET: "TODO:ReplaceWithLongString"
STRATEGY_LOCAL: "true"
DEBUG: "speckle:*"
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
LOG_PRETTY: "true"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
networks:
default:
@@ -63,7 +63,7 @@ internal sealed class AutomationContext(IOperations operations) : IAutomationCon
);
}
Base? rootObject = await operations
Base rootObject = await operations
.Receive2(
SpeckleClient.ServerUrl,
AutomationRunData.ProjectId,
@@ -74,6 +74,10 @@ internal sealed class AutomationContext(IOperations operations) : IAutomationCon
)
.ConfigureAwait(false);
await SpeckleClient
.Version.Received(new(version.id, AutomationRunData.ProjectId, "automate_function"), cancellationToken)
.ConfigureAwait(false);
Console.WriteLine($"It took {Elapsed.TotalSeconds} seconds to receive the speckle version {versionId}");
return rootObject;
}
+7 -5
View File
@@ -281,7 +281,6 @@
"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, )",
@@ -292,7 +291,10 @@
}
},
"speckle.sdk.dependencies": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
},
"GraphQL.Client": {
"type": "CentralTransitive",
@@ -307,9 +309,9 @@
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "8.0.0",
"contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
+6
View File
@@ -0,0 +1,6 @@
using Speckle.Sdk.Models;
namespace Speckle.Objects.Geometry;
[SpeckleType("Objects.Geometry.SolidX")]
public class SolidX : RawEncodedObject;
+2 -9
View File
@@ -2,6 +2,7 @@ using Speckle.Objects.Geometry;
using Speckle.Objects.Other;
using Speckle.Objects.Primitive;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Data;
namespace Speckle.Objects;
@@ -110,15 +111,7 @@ public interface IDisplayValue<out T> : ISpeckleObject
#region Data objects
/// <summary>
/// Specifies properties on objects to be used for data-based workflows
/// </summary>
public interface IProperties : ISpeckleObject
{
Dictionary<string, object?> properties { get; }
}
public interface IDataObject : IProperties, IDisplayValue<IReadOnlyList<Base>>
public interface IDataObject : IProperties, IDisplayValue<IReadOnlyList<Base>>, ISpeckleObject
{
/// <summary>
/// The name of the object, primarily used to decorate the object for consumption in frontend and other apps
+32
View File
@@ -0,0 +1,32 @@
using Speckle.Objects.Geometry;
using Speckle.Sdk.Models;
namespace Speckle.Objects.Other;
/// <summary>
/// Camera class to represent a perspective camera for a 3D view.
/// </summary>
/// <remarks>Assumes a Z-up, right-handed convention for orientation vectors</remarks>
[SpeckleType("Objects.Other.Camera")]
public class Camera : Base
{
/// <summary>
/// The name of the view that is created by this camera
/// </summary>
public required string name { get; set; }
/// <summary>
/// The location of the camera
/// </summary>
public required Point position { get; set; }
/// <summary>
/// The unit up vector of the camera
/// </summary>
public required Vector up { get; set; }
/// <summary>
/// The unit forward vector of the camera
/// </summary>
public required Vector forward { get; set; }
}
+2
View File
@@ -20,4 +20,6 @@ public class RawEncoding : Base // note: at this stage, since we're using this f
public static class RawEncodingFormats
{
public const string RHINO_3DM = "3dm";
public const string ACAD_DWG = "dwg";
public const string ACAD_SAT = "sat";
}
+7 -5
View File
@@ -228,7 +228,6 @@
"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, )",
@@ -239,7 +238,10 @@
}
},
"speckle.sdk.dependencies": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
},
"GraphQL.Client": {
"type": "CentralTransitive",
@@ -254,9 +256,9 @@
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
@@ -5,6 +5,7 @@ public interface ISdkActivity : IDisposable
void SetTag(string key, object? value);
void RecordException(Exception e);
string TraceId { get; }
string SpanId { get; }
void SetStatus(SdkActivityStatusCode code);
void InjectHeaders(Action<string, string> header);
@@ -1,8 +1,20 @@
using System.Runtime.CompilerServices;
using Speckle.Connectors.Logging;
namespace Speckle.Sdk.Logging;
public interface ISdkActivityFactory : IDisposable
{
ISdkActivity? Start(string? name = default, [CallerMemberName] string source = "");
ISdkActivity? Start(
string? name = null,
SdkActivityKind kind = SdkActivityKind.Internal,
[CallerMemberName] string source = ""
);
ISdkActivity? StartRemote(
string traceContext,
SdkActivityKind kind,
string? name = null,
[CallerMemberName] string source = ""
);
}
@@ -0,0 +1,49 @@
using System.Threading.Channels;
namespace Speckle.Sdk.Dependencies;
/// <summary>
/// For various reasons related to our use of ILRepack.FullAuto,
/// we cannot use Channels from the SDK project.
/// We have to keep usage of it inside the Sdk.Dependencies project.
///
/// For the sake of quick development, I've wrapped the <see cref="Channel"/> class here in a type
/// that is safe to use from the SDK project.
///
/// As and when we need more functions, we can add them here.
///
/// And yes... I'm not very happy about the way we've set this up
/// </summary>
/// <typeparam name="T"></typeparam>
public sealed class RepackedChannel<T>
{
private readonly Channel<T> _channel;
public RepackedChannel(int capacity, bool singleReader, bool singleWriter)
{
_channel = Channel.CreateBounded<T>(
new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = singleReader,
SingleWriter = singleWriter,
}
);
}
public void CompleteWriter() => _channel.Writer.Complete();
public ValueTask WriteAsync(T item, CancellationToken cancellationToken) =>
_channel.Writer.WriteAsync(item, cancellationToken);
public IAsyncEnumerable<T> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
// public async Task ReadAllAsync(Func<T, Task> callback, CancellationToken cancellationToken)
// {
// await foreach (T item in _channel.Reader.ReadAllAsync(cancellationToken))
// {
// await callback.Invoke(item).ConfigureAwait(false);
// }
// }
}
@@ -28,4 +28,36 @@
<PackageReference Include="Open.ChannelExtensions" PrivateAssets="all" />
<PackageReference Include="System.Threading.Channels" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
<Target Name="BeforeILRepackPrepareBuild" BeforeTargets="ILRepackPrepareBuild">
<ItemGroup>
<!--
We're Being selective about which assemblies we're il-repacking
Avoiding repacling `Microsoft.Bcl.AsyncInterfaces.dll` because we need types like `ValueTask` and `IAsyncEnumerable` to be external
Yes, this does beg the question, why are we using `IlRepack.FullAuto` instead of raw ILRepack. Well the truth is, I'd like to move away from FullAuto
since it's unmaintaned and is lagging behind ILRepack version.
-->
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Numerics.Vectors.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Runtime.CompilerServices.Unsafe.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Memory.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Open.ChannelExtensions.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Threading.Channels.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Collections.Immutable.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.Contrib.WaitAndRetry.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.Extensions.Http.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Microsoft.Extensions.ObjectPool.dll" />
<_ILRepackExcludeAssemblies_Items Include="$(OutputPath)*.dll" Exclude="@(_ILRepackIncludeAssemblies_Items)" />
</ItemGroup>
<Message
Text="These are the packages we are NOT ilrepacking '@(_ILRepackExcludeAssemblies_Items)'"
Importance="high"
/>
<PropertyGroup>
<ILRepackExcludeAssemblies>@(_ILRepackExcludeAssemblies_Items)</ILRepackExcludeAssemblies>
</PropertyGroup>
</Target>
</Project>
@@ -0,0 +1,30 @@
namespace Speckle.Connectors.Logging;
public enum SdkActivityKind
{
/// <summary>
/// Default value.
/// Indicates that the Activity represents an internal operation within an application, as opposed to an operations with remote parents or children.
/// </summary>
Internal = 0,
/// <summary>
/// Server activity represents request incoming from external component.
/// </summary>
Server = 1,
/// <summary>
/// Client activity represents outgoing request to the external component.
/// </summary>
Client = 2,
/// <summary>
/// Producer activity represents output provided to external components.
/// </summary>
Producer = 3,
/// <summary>
/// Consumer activity represents output received from an external component.
/// </summary>
Consumer = 4,
}
@@ -11,6 +11,15 @@
"ILRepack": "2.0.33"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Direct",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Direct",
"requested": "[9.0.4, )",
@@ -151,15 +160,6 @@
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
}
},
"net8.0": {
+1 -24
View File
@@ -193,30 +193,7 @@ public sealed class BlobApi : IBlobApi
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];
return BlobApiHelpers.ParseEtagHeader(response.Headers);
}
/// <summary>
+6 -1
View File
@@ -23,10 +23,15 @@ public class SpeckleGraphQLException : SpeckleException
}
/// <summary>
/// Represents a "FORBIDDEN" or "UNAUTHORIZED" GraphQL error as an exception.
/// Represents a "FORBIDDEN" or "UNAUTHENTICATED" or "UNAUTHORIZED" or "UNAUTHORIZED_ACCESS_ERROR" GraphQL error as an exception.
/// https://www.apollographql.com/docs/apollo-server/v2/data/errors/#unauthenticated
/// https://www.apollographql.com/docs/apollo-server/v2/data/errors/#forbidden
/// https://github.com/specklesystems/speckle-server/blob/v2.23.18/packages/server/modules/shared/errors/index.ts#L34
/// </summary>
/// <remarks>
/// Server is a bit inconsistent with these error codes, hence there's 4 different codes that mean "auth no work"
/// Apollo no longer considers "FORBIDDEN" or "UNAUTHENTICATED" as built in error codes, so everything is custom anyway.
/// </remarks>
public sealed class SpeckleGraphQLForbiddenException : SpeckleGraphQLException
{
public SpeckleGraphQLForbiddenException() { }
+22
View File
@@ -35,6 +35,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public WorkspaceResource Workspace { get; }
public ServerResource Server { get; }
public FileImportResource FileImport { get; }
public ModelIngestionResource Ingestion { get; }
public Uri ServerUrl => new(Account.serverInfo.url);
@@ -71,6 +72,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
Workspace = new(this);
Server = new(this);
FileImport = new(this, blobApiFactory.Create(account));
Ingestion = new(this);
}
[AutoInterfaceIgnore]
@@ -85,6 +87,26 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
catch (Exception ex) when (!ex.IsFatal()) { }
}
/// <summary>
/// Ensure the <see cref="GQLClient"/>'s websocket is fully initialized.
/// <br/>
/// You don't <i>need</i> to call this function, if you don't, then it will be setup for you when you call <see cref="SubscribeTo"/> (e.g. when you create a <see cref="Subscription"/>),
/// but due to <see cref="GraphQL"/>'s WebSocket implementation, it's not awaited (deferred) thus the subscription make take a while to actually be setup.
/// </summary>
/// <remarks>
/// We only use websockets for GraphQL subscriptions, so if you're not using subscriptions, don't call this
///
/// Note. due to other sources (potentially on the GraphQL side) you still need a ~100ms delay between setting up the subscription, and being able to relaibly trigger it
/// This should only really negatively affect test projects.
/// </remarks>
public async Task InitializeWebsocket()
{
if (GQLClient.WebSocketSubProtocol is null)
{
await GQLClient.InitializeWebsocketConnection().ConfigureAwait(false);
}
}
internal async Task<T> ExecuteWithResiliencePolicies<T>(Func<Task<T>> func) =>
await GraphQLRetry
.ExecuteAsync<T, SpeckleGraphQLInternalErrorException>(
@@ -1,6 +1,9 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
//This enum isn't explicitly defined in the schema, instead its usages are int typed (But represent an enum)
/// <remarks>
/// This enum isn't explicitly defined in the schema, instead its usages are int typed (But represent an enum)
/// </remarks>
public enum FileUploadConversionStatus
{
Queued = 0,
@@ -0,0 +1,14 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ModelIngestionStatus
{
cancelled,
failed,
processing,
queued,
success,
}
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectCommentsUpdatedMessageType
{
ARCHIVED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectFileImportUpdatedMessageType
{
CREATED,
@@ -0,0 +1,13 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectModelIngestionUpdatedMessageType
{
cancellationRequested,
created,
deleted,
updated,
}
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectModelsUpdatedMessageType
{
CREATED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectPendingModelsUpdatedMessageType
{
CREATED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectUpdatedMessageType
{
DELETED,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectVersionsUpdatedMessageType
{
CREATED,
@@ -1,5 +1,9 @@
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ProjectVisibility
{
Private,
@@ -1,5 +1,9 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
// ReSharper disable InconsistentNaming
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum ResourceType
{
commit,
@@ -1,5 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Enums;
/// <remarks>
/// string based enum
/// </remarks>
public enum UserProjectsUpdatedMessageType
{
ADDED,
@@ -28,7 +28,8 @@ internal static class GraphQLErrorHandler
var ex = code switch
{
"GRAPHQL_PARSE_FAILED" or "GRAPHQL_VALIDATION_FAILED" => new SpeckleGraphQLInvalidQueryException(message),
"FORBIDDEN" or "UNAUTHENTICATED" => new SpeckleGraphQLForbiddenException(message),
"FORBIDDEN" or "UNAUTHENTICATED" or "UNAUTHORIZED" or "UNAUTHORIZED_ACCESS_ERROR" =>
new SpeckleGraphQLForbiddenException(message),
"STREAM_NOT_FOUND" => new SpeckleGraphQLStreamNotFoundException(message),
"BAD_USER_INPUT" => new SpeckleGraphQLBadInputException(message),
"INTERNAL_SERVER_ERROR" => new SpeckleGraphQLInternalErrorException(message),
@@ -2,8 +2,10 @@
public record GenerateFileUploadUrlInput(string projectId, string fileName);
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public record StartFileImportInput(string projectId, string modelId, string fileId, string etag);
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public record FileImportResult(
double durationSeconds,
double downloadDurationSeconds,
@@ -14,14 +16,23 @@ public record FileImportResult(
public abstract class FileImportInputBase
{
internal const string FILE_IMPORT_DEPRECATION_MESSAGE =
"Part of the old API surface and will be removed in the future. Use the new ingestion API instead. Field will be deleted on June 1st, 2026";
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
protected FileImportInputBase() { }
public required string projectId { get; init; }
public required string jobId { get; init; }
public required IReadOnlyCollection<string> warnings { get; init; }
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public required FileImportResult result { get; init; }
}
#pragma warning disable CA1822 //Mark members as static
[Obsolete(FILE_IMPORT_DEPRECATION_MESSAGE)]
public sealed class FileImportSuccessInput() : FileImportInputBase()
{
public const string TYPE_STATUS = "success";
@@ -29,6 +40,7 @@ public sealed class FileImportSuccessInput() : FileImportInputBase()
public string status => TYPE_STATUS;
}
[Obsolete(FILE_IMPORT_DEPRECATION_MESSAGE)]
public sealed class FileImportErrorInput() : FileImportInputBase()
{
public const string TYPE_STATUS = "error";
@@ -0,0 +1,71 @@
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record SourceDataInput(
string sourceApplicationSlug,
string sourceApplicationVersion,
string? fileName,
long? fileSizeBytes
);
public record ModelIngestionCreateInput(
string modelId,
string projectId,
string progressMessage,
SourceDataInput sourceData,
int? maxIdleTimeoutSeconds = null
);
public record ModelIngestionUpdateInput(string ingestionId, string projectId, string progressMessage, double? progress);
public record ModelIngestionSuccessInput(
string ingestionId,
string projectId,
string rootObjectId,
string? versionMessage
);
public record ModelIngestionFailedInput(
string ingestionId,
string projectId,
string errorReason,
string? errorStacktrace
)
{
public static ModelIngestionFailedInput FromException(string ingestionId, string projectId, Exception ex)
{
return new ModelIngestionFailedInput(ingestionId, projectId, ex.Message, ex.ToString());
}
}
public record ModelIngestionCancelledInput(string ingestionId, string projectId, string cancellationMessage);
public record ModelIngestionStartProcessingInput(
string ingestionId,
string projectId,
string progressMessage,
SourceDataInput sourceData
);
public record ModelIngestionRequeueInput(string ingestionId, string projectId, string progressMessage);
public record ProjectModelIngestionSubscriptionInput(
string projectId,
ModelIngestionReference ingestionReference,
[property: JsonIgnore] ProjectModelIngestionUpdatedMessageType messageType
)
{
// The Newtonsoft serializer is setup to handle SCREAMING_CASE enums.
// But the API requires the enum to look exactly like they are
[JsonProperty(nameof(messageType))]
public string serializedType => messageType.ToString();
}
/// <remarks>
/// <c>@oneOf</c> i.e. server expects <b>either</b> <paramref name="ingestionId"/> or <paramref name="modelId"/>, but not both.
/// </remarks>
/// <param name="ingestionId"></param>
/// <param name="modelId"></param>
public record ModelIngestionReference(string? ingestionId, string? modelId);
@@ -1,4 +1,6 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record UpdateVersionInput(string versionId, string projectId, string? message);
@@ -16,6 +18,10 @@ public record CreateVersionInput(
IReadOnlyList<string>? parents = null
);
/// <param name="versionId"></param>
/// <param name="projectId"></param>
/// <param name="sourceApplication">IMPORTANT: this is meant to be the slug of the application that has done the receiving, not to be confused with <see cref="Version.sourceApplication"/></param>
/// <param name="message"></param>
public record MarkReceivedVersionInput(
string versionId,
string projectId,
@@ -14,7 +14,7 @@ public sealed class Comment
public string rawText { get; init; }
public ResourceCollection<Comment> replies { get; init; }
public CommentReplyAuthorCollection replyAuthors { get; init; }
public List<ResourceIdentifier> resources { get; init; }
public List<ResourceIdentifier> resources { get; init; } //todo: add resourceIds/baseResourceIds
public string? screenshot { get; init; }
public DateTime updatedAt { get; init; }
public DateTime? viewedAt { get; init; }
@@ -0,0 +1,13 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelIngestion
{
public required string id { get; init; }
public required DateTime createdAt { get; init; }
public required DateTime updatedAt { get; init; }
public required string modelId { get; init; }
public required string projectId { get; init; }
public required string userId { get; init; }
public required bool cancellationRequested { get; init; }
public required ModelIngestionStatusData statusData { get; init; }
}
@@ -0,0 +1,10 @@
using Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelIngestionStatusData
{
public required ModelIngestionStatus status { get; init; }
public required string? progressMessage { get; init; }
public required string? versionId { get; init; }
}
@@ -0,0 +1,8 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelPermissionChecks
{
public PermissionCheckResult canUpdate { get; init; }
public PermissionCheckResult canDelete { get; init; }
public PermissionCheckResult canCreateVersion { get; init; }
}
@@ -10,7 +10,7 @@ public sealed class PendingStreamCollaborator
public string projectName { get; init; }
public string title { get; init; }
public string role { get; init; }
public LimitedUser invitedBy { get; init; }
public LimitedUser? invitedBy { get; init; }
public LimitedUser? user { get; init; }
public string? token { get; init; }
}
@@ -5,5 +5,7 @@ public sealed class ProjectPermissionChecks
public PermissionCheckResult canCreateModel { get; init; }
public PermissionCheckResult canDelete { get; init; }
public PermissionCheckResult canLoad { get; init; }
[Obsolete("Use ModelPermissionChecks.CanCreateVersion instead", true)]
public PermissionCheckResult canPublish { get; init; }
}
@@ -6,10 +6,10 @@ namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class UserProjectsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public UserProjectsUpdatedMessageType type { get; init; }
public required UserProjectsUpdatedMessageType type { get; init; }
public Project? project { get; init; }
}
@@ -17,10 +17,10 @@ public sealed class UserProjectsUpdatedMessage : EventArgs
public sealed class ProjectCommentsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectCommentsUpdatedMessageType type { get; init; }
public required ProjectCommentsUpdatedMessageType type { get; init; }
public Comment? comment { get; init; }
}
@@ -28,10 +28,10 @@ public sealed class ProjectCommentsUpdatedMessage : EventArgs
public sealed class ProjectFileImportUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectFileImportUpdatedMessageType type { get; init; }
public required ProjectFileImportUpdatedMessageType type { get; init; }
public FileUpload? upload { get; init; }
}
@@ -39,10 +39,10 @@ public sealed class ProjectFileImportUpdatedMessage : EventArgs
public sealed class ProjectModelsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectModelsUpdatedMessageType type { get; init; }
public required ProjectModelsUpdatedMessageType type { get; init; }
public Model? model { get; init; }
}
@@ -50,10 +50,10 @@ public sealed class ProjectModelsUpdatedMessage : EventArgs
public sealed class ProjectPendingModelsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectPendingModelsUpdatedMessageType type { get; init; }
public required ProjectPendingModelsUpdatedMessageType type { get; init; }
public FileUpload? model { get; init; }
}
@@ -61,10 +61,10 @@ public sealed class ProjectPendingModelsUpdatedMessage : EventArgs
public sealed class ProjectUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectUpdatedMessageType type { get; init; }
public required ProjectUpdatedMessageType type { get; init; }
public Project? project { get; init; }
}
@@ -72,13 +72,22 @@ public sealed class ProjectUpdatedMessage : EventArgs
public sealed class ProjectVersionsUpdatedMessage : EventArgs
{
[JsonRequired]
public string id { get; init; }
public required string id { get; init; }
[JsonRequired]
public ProjectVersionsUpdatedMessageType type { get; init; }
public required ProjectVersionsUpdatedMessageType type { get; init; }
[JsonRequired]
public string modelId { get; init; }
public required string modelId { get; init; }
public Version? version { get; init; }
}
public sealed class ProjectModelIngestionUpdatedMessage : EventArgs
{
[JsonRequired]
public required ModelIngestion modelIngestion { get; init; }
[JsonRequired]
public required ProjectModelIngestionUpdatedMessageType type { get; init; }
}
@@ -397,11 +397,6 @@ public sealed class ActiveUserResource
authorized
message
}
canPublish {
code
authorized
message
}
}
}
}
@@ -29,8 +29,10 @@ public sealed class FileImportResource : IDisposable
/// <remarks>
/// Only use this if you are writing a file importer, that is responsible for
/// processing file import jobs.
/// Only works on servers version >=2.25.8
/// Only works on servers version >=2.25.8 but from 3.0.7 onwards has been deprecated and replaced by model ingestion api
/// see <see cref="ModelIngestionResource.Complete"/>
/// </remarks>
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public async Task<bool> FinishFileImportJob(FileImportInputBase input, CancellationToken cancellationToken)
{
//language=graphql
@@ -57,7 +59,11 @@ public sealed class FileImportResource : IDisposable
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
/// <remarks>Only works on servers version >=2.25.8</remarks>
/// <remarks>
/// Only works on servers version >=2.25.8 but from 3.0.7 onwards has been deprecated and replaced by model ingestion api
/// see <see cref="ModelIngestionResource.StartProcessing"/>
/// </remarks>
[Obsolete(FileImportInputBase.FILE_IMPORT_DEPRECATION_MESSAGE)]
public async Task<FileImport> StartFileImportJob(
StartFileImportInput input,
CancellationToken cancellationToken = default
@@ -0,0 +1,506 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
namespace Speckle.Sdk.Api.GraphQL.Resources;
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
public sealed class ModelIngestionResource
{
private readonly ISpeckleGraphQLClient _client;
internal ModelIngestionResource(ISpeckleGraphQLClient client)
{
_client = client;
}
/// <summary>
/// Create a new model ingestion
/// </summary>
/// <remarks>
/// The model ingestion created will have a <c>processing</c> state (not <c>queued</c>). This mutation is designed to be used
/// by client/connectors that are immediately processing
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> Create(
ModelIngestionCreateInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionCreate($input: ModelIngestionCreateInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: create(input: $input) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <param name="modelIngestionId"></param>
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> Get(
string modelIngestionId,
string projectId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query Query($projectId: String!, $modelIngestionId: ID!) {
data:project(id: $projectId) {
data:ingestion(id: $modelIngestionId) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
... on ModelIngestionSuccessStatus
{
versionId
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { projectId, modelIngestionId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<ModelIngestion>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data;
}
/// <summary>
/// For File Import / Cloud integrations only
/// </summary>
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> StartProcessing(
ModelIngestionStartProcessingInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionStartProcessing($input: ModelIngestionStartProcessingInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: startProcessing(input: $input) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// For File Import / Cloud integrations only
/// </summary>
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> Requeue(
ModelIngestionRequeueInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionStartProcessing($input: ModelIngestionRequeueInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: requeue(input: $input) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> UpdateProgress(
ModelIngestionUpdateInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionUpdateProgress(
$input: ModelIngestionUpdateInput!
) {
data: projectMutations {
data: modelIngestionMutations {
data: updateProgress(input: $input) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// Request that the server completes the ingestion by creating a version
/// If successful, the job will be in a terminal "successful" state.
/// </summary>
/// <remarks>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <seealso cref="FailWithError"/>
/// <seealso cref="FailWithCancel"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns>The version id</returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<string> Complete(ModelIngestionSuccessInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestionComplete($input: ModelIngestionSuccessInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: completeWithVersion(input: $input) {
data:statusData {
... on ModelIngestionSuccessStatus {
data:versionId
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<
RequiredResponse<RequiredResponse<RequiredResponse<RequiredResponse<RequiredResponse<string>>>>>
>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data.data.data;
}
/// <summary>
/// Fail the job with an error.
/// </summary>
/// <remarks>
/// For requested user cancellation, use <see cref="FailWithCancel"/> instead<br/>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <seealso cref="FailWithCancel"/>
/// <seealso cref="Complete"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> FailWithError(
ModelIngestionFailedInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionFailWithError($input: ModelIngestionFailedInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: failWithError(input: $input) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// Fail the ingestion with a <c>canceled</c> status.
/// This should only be done if the user has explicitly requested cancellation
/// Other forms of cancellation use <see cref="FailWithError"/>.
/// The ingestion should then enter a terminal "canceled" state.<br/>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </summary>
/// <seealso cref="FailWithError"/>
/// <seealso cref="Complete"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> FailWithCancel(
ModelIngestionCancelledInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionFailWithCancel($input: ModelIngestionCancelledInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: failWithCancel(input: $input) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <summary>
/// Request that the <see cref="ModelIngestion"/> is canceled.
/// </summary>
/// <remarks>
/// Note simply calling this mutation does not imediatly cancel, it doesn't even guarantee it will be canceled at all.
/// It's up to the client to observe this cancellation request
/// via <see cref="SubscriptionResource.CreateProjectModelIngestionCancellationRequestedSubscription"/>
/// and report it as canceled via <see cref="ModelIngestionResource.FailWithCancel"/>
/// See "cooperative cancellation pattern"<br/>
/// Model Ingestion API is available for server versions <c>3.0.3</c> and above
/// </remarks>
/// <seealso cref="FailWithError"/>
/// <seealso cref="Complete"/>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelIngestion> RequestCancellation(
ModelIngestionCancelledInput input,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
mutation IngestionRequestCancellation($input: ModelIngestionRequestCancellationInput!) {
data: projectMutations {
data: modelIngestionMutations {
data: requestCancellation (input: $input) {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelIngestion>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return res.data.data.data;
}
}
@@ -312,4 +312,88 @@ public sealed class ModelResource
return res.data.data;
}
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ModelPermissionChecks> GetPermissions(
string projectId,
string modelId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
canUpdate {
authorized
code
message
}
canDelete {
authorized
code
message
}
canCreateVersion {
authorized
code
message
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { projectId, modelId } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ModelPermissionChecks>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return response.data.data.data;
}
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="SpeckleGraphQLBadInputException">server versions &lt;3.0.11 do not have <c>canCreateIngestion</c> and will throw this exception</exception>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<PermissionCheckResult> CanCreateModelIngestion(
string projectId,
string modelId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
data:canCreateIngestion {
authorized
code
message
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { projectId, modelId } };
var response = await _client
.ExecuteGraphQLRequest<
RequiredResponse<RequiredResponse<RequiredResponse<RequiredResponse<PermissionCheckResult>>>>
>(request, cancellationToken)
.ConfigureAwait(false);
return response.data.data.data.data;
}
}
@@ -1,4 +1,5 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
@@ -212,6 +213,66 @@ public sealed class SubscriptionResource : IDisposable
return subscription;
}
/// <summary>Subscribe to a cancellation request being made for a Model Ingestion</summary>
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
public Subscription<ProjectModelIngestionUpdatedMessage> CreateProjectModelIngestionUpdatedSubscription(
ProjectModelIngestionSubscriptionInput input
)
{
//language=graphql
const string QUERY = """
subscription IngestionUpdated($input: ProjectModelIngestionSubscriptionInput!) {
data: projectModelIngestionUpdated(input: $input) {
modelIngestion {
id
createdAt
updatedAt
modelId
projectId
userId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
... on ModelIngestionSuccessStatus
{
versionId
}
}
}
type
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
Subscription<ProjectModelIngestionUpdatedMessage> subscription = new(_client, request);
_subscriptions.Add(subscription);
return subscription;
}
/// <summary>Subscribe to a cancellation request being made for a Model Ingestion</summary>
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
public Subscription<ProjectModelIngestionUpdatedMessage> CreateProjectModelIngestionCancellationRequestedSubscription(
string ingestionId,
string projectId
)
{
return CreateProjectModelIngestionUpdatedSubscription(
new ProjectModelIngestionSubscriptionInput(
projectId,
new(ingestionId, null),
ProjectModelIngestionUpdatedMessageType.cancellationRequested
)
);
}
public void Dispose()
{
foreach (var subscription in _subscriptions)
@@ -1,2 +1,2 @@
schema: https://app.speckle.systems/graphql
schema: https://latest.speckle.systems/graphql
documents: '**/*.graphql'
@@ -16,6 +16,7 @@ public partial class Operations
/// <exception cref="ArgumentException">No transports were specified</exception>
/// <exception cref="ArgumentNullException">The <paramref name="objectId"/> was <see langword="null"/></exception>
/// <exception cref="SpeckleException">Serialization or Send operation was unsuccessful</exception>
/// <exception cref="HttpRequestException">HTTP layer errors</exception>
/// <exception cref="OperationCanceledException">The <paramref name="cancellationToken"/> requested cancellation</exception>
public async Task<Base> Receive2(
Uri url,
+29
View File
@@ -0,0 +1,29 @@
using System.Net.Http.Headers;
namespace Speckle.Sdk.Helpers;
public static class BlobApiHelpers
{
public static string ParseEtagHeader(HttpResponseHeaders headers)
{
if (!headers.TryGetValues("ETag", out var etagValues))
{
throw new ArgumentException(
"Response does not have an ETag attached to it, cannot use this as an upload",
nameof(headers)
);
}
var etagValuesArray = etagValues.ToArray();
if (etagValuesArray.Length != 1)
{
throw new ArgumentException(
$"Expected Etag header to have a single value but got {etagValuesArray.Length}",
nameof(headers)
);
}
return etagValuesArray[0];
}
}
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
namespace Speckle.Sdk.Helpers;
/// <summary>
/// <see cref="IDisposable"/> wrapper around the downloaded file to try and delete the file on Dispose
/// </summary>
/// <remarks>
/// We're using a similar pattern in the Rhino File Importer codebase (see <c>ImportJobFile</c>)
/// </remarks>
/// <param name="logger"></param>
/// <param name="file"></param>
public sealed class DisposableFile(FileInfo file, ILogger logger, bool deleteOnDispose = true) : IDisposable
{
public FileInfo FileInfo => file;
public void Dispose()
{
if (!deleteOnDispose)
{
return;
}
try
{
file.Delete();
logger.LogInformation("Cleaned up {File}", file);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "Failed to clean up {File}", file);
}
}
}
@@ -0,0 +1,21 @@
using System.Diagnostics;
namespace Speckle.Sdk.Helpers;
public static class StopwatchPolyfills
{
#if !NET7_0_OR_GREATER
private static readonly double s_tickFrequency = (double)TimeSpan.TicksPerSecond / Stopwatch.Frequency;
#endif
public static TimeSpan GetElapsedTime(long startingTimestamp)
{
#if NET7_0_OR_GREATER
return Stopwatch.GetElapsedTime(startingTimestamp);
#else
long elapsedTicks = Stopwatch.GetTimestamp() - startingTimestamp;
return new TimeSpan((long)(elapsedTicks * s_tickFrequency));
#endif
}
}
+16 -1
View File
@@ -140,7 +140,22 @@ internal static class TypeLoader
return typeof(Base);
}
//Don't use unless you're testing
/// <summary>
/// For testing purposes only
/// </summary>
internal static void ReInitialize(params Assembly[] assemblies)
{
lock (s_availableTypes)
{
Reset();
Load(assemblies);
s_initialized = true;
}
}
/// <summary>
/// For testing purposes only
/// </summary>
public static void Reset()
{
s_availableTypes = new();
@@ -1,8 +1,12 @@
namespace Speckle.Sdk.Logging;
using Speckle.Connectors.Logging;
namespace Speckle.Sdk.Logging;
public sealed class NullActivityFactory : ISdkActivityFactory
{
public void Dispose() { }
public ISdkActivity? Start(string? name = default, string source = "") => null;
public ISdkActivity? Start(string? name, SdkActivityKind kind, string source) => null;
public ISdkActivity? StartRemote(string traceContext, SdkActivityKind kind, string? name, string source) => null;
}
@@ -9,12 +9,14 @@ public static class SpecklePathProvider
{
private const string APPLICATION_NAME = "Speckle";
private const string LOG_FOLDER_NAME = "Logs";
private const string BLOB_FOLDER_NAME = "Blobs";
private const string ACCOUNTS_FOLDER_NAME = "Accounts";
private static string UserDataPathEnvVar => "SPECKLE_USERDATA_PATH";
private static string? Path => Environment.GetEnvironmentVariable(UserDataPathEnvVar);
public const string USER_DATA_PATH_ENV_VAR = "SPECKLE_USERDATA_PATH";
private static string? Path => Environment.GetEnvironmentVariable(USER_DATA_PATH_ENV_VAR);
/// <summary>
/// Get the installation path.
@@ -85,4 +87,7 @@ public static class SpecklePathProvider
Directory.CreateDirectory(path);
return path;
}
public static string LogFolderPath(string applicationAndVersion) =>
EnsureFolderExists(UserSpeckleFolderPath, LOG_FOLDER_NAME, applicationAndVersion);
}
@@ -0,0 +1,21 @@
using Speckle.Sdk.Models.Data;
namespace Speckle.Sdk.Models.Collections;
/// <summary>
/// Root collection that represents the top-level commit object.
/// Extends Collection to include model-wide properties that apply to the entire model.
/// </summary>
[SpeckleType("Speckle.Core.Models.Collections.RootCollection")]
public class RootCollection : Collection, IProperties
{
public RootCollection() { }
public RootCollection(string name)
: base(name) { }
/// <summary>
/// Model-wide properties that apply to the entire model.
/// </summary>
public Dictionary<string, object?> properties { get; set; } = new();
}
@@ -0,0 +1,10 @@
namespace Speckle.Sdk.Models.Data;
/// <summary>
/// Specifies properties on objects to be used for data-based workflows.
/// Can be applied to both objects and collections.
/// </summary>
public interface IProperties
{
Dictionary<string, object?> properties { get; }
}
@@ -7,12 +7,12 @@ namespace Speckle.Sdk.Models;
public enum DynamicBaseMemberType
{
/// <summary>
/// The typed members of the DynamicBase object
/// The typed members of the <see cref="DynamicBase"/> object
/// </summary>
Instance = 1,
/// <summary>
/// The dynamically added members of the DynamicBase object
/// The dynamically added members of the <see cref="DynamicBase"/> object
/// </summary>
Dynamic = 2,
@@ -22,8 +22,9 @@ public enum DynamicBaseMemberType
Obsolete = 4,
/// <summary>
/// The typed methods flagged with TODO:
/// Old feature supported in v2 for grasshopper
/// </summary>
[Obsolete("Feature no longer supported")]
SchemaComputed = 16,
/// <summary>
@@ -0,0 +1,12 @@
namespace Speckle.Sdk.Pipelines.Progress;
public sealed class AggregateProgress<T>(params IProgress<T>[] progresses) : IProgress<T>
{
public void Report(T value)
{
foreach (var progress in progresses)
{
progress.Report(value);
}
}
}
@@ -0,0 +1,89 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Helpers;
namespace Speckle.Sdk.Pipelines.Progress;
public partial interface IIngestionProgressManager : IProgress<CardProgress>;
/// <summary>
/// An <see langword="IProgress{IngestionProgressEventArgs}"/> implementation for the entire client side Ingestion progress update reporting
/// Will throttles ingestion progress messages and reports their progress
/// </summary>
/// <remarks>
/// Normally we would pick quite a coarse updateInterval to try and spamming the server (1-5s)
/// </remarks>
[GenerateAutoInterface]
public sealed class IngestionProgressManager(
ILogger<IngestionProgressManager> logger,
IClient speckleClient,
ModelIngestion ingestion,
TimeSpan updateInterval,
CancellationToken cancellationToken
) : IIngestionProgressManager
{
public Task? LastUpdate { get; private set; }
private long _lastUpdatedAt;
private readonly object _lock = new();
[AutoInterfaceIgnore]
public void Report(CardProgress value)
{
cancellationToken.ThrowIfCancellationRequested();
string trimmedMessage;
lock (_lock)
{
if (ShouldIgnoreProgressUpdate())
{
return;
}
_lastUpdatedAt = Stopwatch.GetTimestamp();
trimmedMessage = value.Status.TrimEnd('.');
LastUpdate = speckleClient
.Ingestion.UpdateProgress(
new ModelIngestionUpdateInput(ingestion.id, ingestion.projectId, trimmedMessage, value.Progress),
cancellationToken
)
.ContinueWith(
Continuation,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default
);
}
logger.LogInformation("Progress update {Message} {Progress}", trimmedMessage, value.Progress);
}
/// <returns><see langword="true"/> if the update should be ignored, otherwise <see langword="false"/></returns>
private bool ShouldIgnoreProgressUpdate()
{
if (LastUpdate is not null && !LastUpdate.IsCompleted)
{
return true;
}
TimeSpan msSinceLastUpdate = StopwatchPolyfills.GetElapsedTime(_lastUpdatedAt);
return msSinceLastUpdate < updateInterval;
}
private void Continuation(Task updateTask)
{
// The progress report failed... could be many reasons.
// For now, we're not letting this fail the Ingestion in any way
// we'll log but otherwise let it slide while leaving no unobserved task exceptions
if (updateTask.IsFaulted)
{
logger.LogWarning(updateTask.Exception, "A progress update failed unexpectedly");
}
}
}
@@ -0,0 +1,21 @@
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Models;
namespace Speckle.Sdk.Pipelines.Progress;
[GenerateAutoInterface]
public sealed class IngestionProgressManagerFactory(ILogger<IngestionProgressManager> logger)
: IIngestionProgressManagerFactory
{
public IIngestionProgressManager CreateInstance(
IClient speckleClient,
ModelIngestion ingestion,
TimeSpan updateInterval,
CancellationToken cancellationToken
)
{
return new IngestionProgressManager(logger, speckleClient, ingestion, updateInterval, cancellationToken);
}
}
@@ -0,0 +1,6 @@
namespace Speckle.Sdk.Pipelines.Progress;
//TODO: rename PipelineProgressArgs
public readonly record struct CardProgress(string Status, double? Progress);
public readonly record struct StreamProgressArgs(long BytesStreamed, long ExpectedTotalBytes);
@@ -0,0 +1,103 @@
using System.Diagnostics.CodeAnalysis;
namespace Speckle.Sdk.Pipelines.Progress;
/// <summary>
/// Wraps <paramref name="innerStream"/> to report streaming progress as bytes are read/written.
/// </summary>
public sealed class ProgressStream(Stream innerStream, IProgress<StreamProgressArgs>? progress = null) : Stream
{
private long _bytesStreamed;
public override bool CanRead => innerStream.CanRead;
public override bool CanSeek => innerStream.CanSeek;
public override bool CanWrite => innerStream.CanWrite;
public override long Length => innerStream.Length;
public override long Position
{
get => innerStream.Position;
set => innerStream.Position = value;
}
public override int Read(byte[] buffer, int offset, int count)
{
int bytesRead = innerStream.Read(buffer, offset, count);
ReportProgress(bytesRead);
return bytesRead;
}
[SuppressMessage(
"Performance",
"CA1835:Prefer the \'Memory\'-based overloads for \'ReadAsync\' and \'WriteAsync\'",
Justification = "Analyser warning forwarded to caller"
)]
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
int bytesRead = await innerStream.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
ReportProgress(bytesRead);
return bytesRead;
}
#if NET8_0_OR_GREATER
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
int bytesRead = await innerStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
ReportProgress(bytesRead);
return bytesRead;
}
#endif
private void ReportProgress(int newBytesProcessed)
{
_bytesStreamed += newBytesProcessed;
progress?.Report(new(_bytesStreamed, Length));
}
public override void Flush() => innerStream.Flush();
public override Task FlushAsync(CancellationToken cancellationToken) => innerStream.FlushAsync(cancellationToken);
public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin);
public override void SetLength(long value) => throw new NotSupportedException(); //intentionally not supporting, as changing length of stream mid-flight will fuck up progress
public override void Write(byte[] buffer, int offset, int count)
{
innerStream.Write(buffer, offset, count);
ReportProgress(count);
}
[SuppressMessage(
"Performance",
"CA1835:Prefer the \'Memory\'-based overloads for \'ReadAsync\' and \'WriteAsync\'",
Justification = "Analyser warning forwarded to caller"
)]
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await innerStream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
ReportProgress(count);
}
#if NET6_0_OR_GREATER
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
await innerStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
ReportProgress(buffer.Length);
}
#endif
protected override void Dispose(bool disposing)
{
innerStream.Dispose();
base.Dispose(disposing);
}
#if NET6_0_OR_GREATER
public override async ValueTask DisposeAsync()
{
await innerStream.DisposeAsync().ConfigureAwait(false);
await base.DisposeAsync().ConfigureAwait(false);
}
#endif
}
@@ -0,0 +1,40 @@
namespace Speckle.Sdk.Pipelines.Progress;
/// <summary>
/// Renders "low level" data stream updates
/// into "high level" <see cref="CardProgress"/> that is expected by Ingestion progress and DUI3
/// </summary>
/// <param name="progress"></param>
public sealed class RenderedStreamProgress(IProgress<CardProgress> progress) : IProgress<StreamProgressArgs>
{
public void Report(StreamProgressArgs value)
{
var (suffix, scaleFactor) = GetFileSizeRendering(value.ExpectedTotalBytes);
progress.Report(
new(
$"Uploading data... ({value.BytesStreamed * scaleFactor:F1}/{value.ExpectedTotalBytes * scaleFactor:F1} {suffix})",
(double)value.BytesStreamed / value.ExpectedTotalBytes
)
);
}
private static readonly string[] s_suffixes = ["B", "KB", "MB", "GB", "TB", "PB"];
internal static (string suffix, double scaleFactor) GetFileSizeRendering(long value)
{
if (value <= 0)
{
return (s_suffixes[0], 1d);
}
for (int i = 0; i < s_suffixes.Length; i++)
{
if (value <= Math.Pow(1024, i + 1))
{
return (s_suffixes[i], 1 / Math.Pow(1024, i));
}
}
throw new ArgumentOutOfRangeException(nameof(value), "Value is too large to convert to a file size");
}
}
@@ -0,0 +1,85 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class DiskStoreFactory(ILogger<DiskStore> logger, ISdkActivityFactory activityFactory) : IDiskStoreFactory
{
public DiskStore CreateInstance(CancellationToken cancellationToken) =>
new(logger, activityFactory, cancellationToken);
}
public sealed class DiskStore
{
private readonly RepackedChannel<UploadItem> _channel;
private readonly Task<DisposableFile> _writeToDiskTask;
private readonly ILogger<DiskStore> _logger;
private readonly ISdkActivityFactory _activityFactory;
private readonly CancellationToken _cancellationToken;
internal DiskStore(
ILogger<DiskStore> logger,
ISdkActivityFactory activityFactory,
CancellationToken cancellationToken
)
{
_logger = logger;
_activityFactory = activityFactory;
_cancellationToken = cancellationToken;
_channel = new RepackedChannel<UploadItem>(1000, true, false);
_writeToDiskTask = Task.Run(WriteFile, cancellationToken);
}
public async Task PushAsync(UploadItem item) =>
await _channel.WriteAsync(item, _cancellationToken).ConfigureAwait(false);
public async Task<DisposableFile> CompleteAsync()
{
using var a = _activityFactory.Start("Waiting for DiskStore to complete");
_channel.CompleteWriter();
return await _writeToDiskTask.ConfigureAwait(false);
}
/// <summary>
/// Reads from the Channel and streams the <see cref="UploadItem"/>s to a temporary file on disk.
/// Will keep reading until <see cref="CompleteAsync"/> is called.
/// </summary>
/// <returns>the file that was written</returns>
private async Task<DisposableFile> WriteFile()
{
string tempFilePath = Path.GetTempFileName();
var tempFile = new DisposableFile(new FileInfo(tempFilePath), _logger);
_logger.LogInformation("Writing temp file to {TempFilePath}", tempFilePath);
try
{
using var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
using var gzip = new GZipStream(fileStream, CompressionLevel.Optimal);
using var writer = new StreamWriter(gzip);
await foreach (var item in _channel.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
{
await writer.WriteLineAsync($"{item.Id}\t{item.SpeckleType}\t{item.Json}").ConfigureAwait(false);
}
#if NET8_0_OR_GREATER
await writer.FlushAsync(_cancellationToken).ConfigureAwait(false);
#else
await writer.FlushAsync().ConfigureAwait(false);
#endif
tempFile.FileInfo.Refresh();
return tempFile;
}
catch
{
tempFile.Dispose();
throw;
}
}
}
@@ -0,0 +1,74 @@
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Models;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class SendPipelineFactory(IUploaderFactory uploaderFactory, IDiskStoreFactory diskStoreFactory)
: ISendPipelineFactory
{
public SendPipeline CreateInstance(
string projectId,
string ingestionId,
Account account,
IProgress<StreamProgressArgs> uploadProgress,
CancellationToken cancellationToken
)
{
var uploader = uploaderFactory.CreateInstance(projectId, ingestionId, account, uploadProgress, cancellationToken);
var diskStore = diskStoreFactory.CreateInstance(cancellationToken);
return new SendPipeline(uploader, diskStore);
}
}
public sealed class SendPipeline : IDisposable
{
private readonly Serializer _serializer = new();
private readonly Uploader _uploader;
private readonly DiskStore _diskStore;
internal SendPipeline(Uploader uploader, DiskStore diskStore)
{
_uploader = uploader;
_diskStore = diskStore;
}
private UploadItem _lastItem;
public async Task<ObjectReference> Process(Base @base)
{
var results = _serializer.Serialize(@base).ToArray();
var first = results.First();
foreach (var item in results)
{
// we're not doing fire and forget here so that we get the backpressure from the uploader
await _diskStore.PushAsync(item).ConfigureAwait(false);
}
// NOTE: this is important to keep track of. When we serialze an object, we get back a list of objects, with the first one being the original root.
// In the case of the commit root object, this means the last object is not necessarily the root; we therefore need to manually track its existance here
// and ensure it's the last one through in the uploader's stream. See WaitForUpload down below.
_lastItem = first;
return first.Reference;
}
public async Task WaitForUpload()
{
await _diskStore.PushAsync(_lastItem).ConfigureAwait(false);
using DisposableFile tempFile = await _diskStore.CompleteAsync().ConfigureAwait(false);
using Stream fileStreamUpload = new FileStream(
tempFile.FileInfo.FullName,
FileMode.Open,
FileAccess.Read,
FileShare.Read
);
await _uploader.Send(fileStreamUpload).ConfigureAwait(false);
}
public void Dispose() => _uploader.Dispose();
}
@@ -0,0 +1,351 @@
using System.Collections;
using System.Drawing;
using System.Globalization;
using System.Reflection;
using Speckle.DoubleNumerics;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Models;
using Speckle.Sdk.Serialisation;
namespace Speckle.Sdk.Pipelines.Send;
/// <summary>
/// Another serializer, cleaner and meaner. Provides methods for serializing Speckle objects into a format suitable for upload or storage.
/// This class handles the conversion of <see cref="Speckle.Sdk.Models.Base"/> and its derivatives
/// into serialized JSON structures along with associated metadata, closures, and references.
/// <para>Any reference objects coming through are being "passed through" serialized - they do not get double encoded.</para>
/// </summary>
internal sealed class Serializer
{
private readonly record struct PropertyInfo(string Name, object? Value, bool IsDetachable);
public IEnumerable<UploadItem> Serialize(Base root)
{
// Special case: if root is already an ObjectReference, serialize it verbatim
if (root is ObjectReference existingRef)
{
var uploadItem = ReferenceToUploadItem(existingRef);
yield return uploadItem;
yield break;
}
var detachedObjects = new List<(Id, Json, Dictionary<string, int>, Base, string)>();
var rootClosures = new Dictionary<string, int>();
var (rootId, rootJson) = SerializeBase(root, false, rootClosures, detachedObjects);
var rootReference = new ObjectReference
{
referencedId = rootId.Value,
applicationId = root.applicationId,
closure = rootClosures.Count > 0 ? rootClosures : null,
};
yield return new UploadItem(rootId.Value, rootJson, root.speckle_type, rootReference);
foreach (var (id, json, closures, baseObj, speckleType) in detachedObjects)
{
var reference = new ObjectReference
{
referencedId = id.Value,
applicationId = baseObj.applicationId,
closure = closures.Count > 0 ? closures : null,
};
yield return new UploadItem(id.Value, json, speckleType, reference);
}
}
private IEnumerable<PropertyInfo> ExtractProperties(Base baseObj)
{
var typedProperties = baseObj.GetInstanceMembers();
foreach (var prop in typedProperties)
{
if (prop.Name == "id" || prop.Name.StartsWith("__"))
{
continue;
}
if (prop.IsDefined(typeof(JsonIgnoreAttribute), false))
{
continue;
}
var value = prop.GetValue(baseObj);
var isDetachable = prop.GetCustomAttribute<DetachPropertyAttribute>(true)?.Detachable ?? false;
yield return new PropertyInfo(prop.Name, value, isDetachable);
}
foreach (var propName in baseObj.DynamicPropertyKeys)
{
if (propName.StartsWith("__"))
{
continue;
}
var value = baseObj[propName];
#pragma warning disable CA1866
var isDetachable = propName.StartsWith("@");
#pragma warning restore CA1866
yield return new PropertyInfo(propName, value, isDetachable);
}
}
private (Id, Json) SerializeBase(
Base baseObj,
bool forceDetach,
Dictionary<string, int> closures,
List<(Id, Json, Dictionary<string, int>, Base, string)> detachedObjects
)
{
var childClosures = new Dictionary<string, int>();
var sb = Pools.StringBuilders.Get();
try
{
using var stringWriter = new StringWriter(sb);
using var jsonWriter = new JsonTextWriter(stringWriter);
using var idWriter = new SerializerIdWriter(jsonWriter);
idWriter.WriteStartObject();
foreach (var prop in ExtractProperties(baseObj))
{
idWriter.WritePropertyName(prop.Name);
SerializeValue(prop.Value, idWriter, prop.IsDetachable, childClosures, detachedObjects);
}
var (jsonForId, finalWriter) = idWriter.FinishIdWriter();
var id = IdGenerator.ComputeId(jsonForId);
finalWriter.WritePropertyName("id");
finalWriter.WriteValue(id.Value);
baseObj.id = id.Value;
if ((forceDetach || childClosures.Count > 0) && childClosures.Count > 0)
{
finalWriter.WritePropertyName("__closure");
finalWriter.WriteStartObject();
foreach (var kvp in childClosures)
{
finalWriter.WritePropertyName(kvp.Key);
finalWriter.WriteValue(kvp.Value);
}
finalWriter.WriteEndObject();
foreach (var kvp in childClosures)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existing) ? existing + kvp.Value : kvp.Value;
}
}
finalWriter.WriteEndObject();
finalWriter.Flush();
var json = new Json(stringWriter.ToString());
return (id, json);
}
finally
{
Pools.StringBuilders.Return(sb);
}
}
private void SerializeValue(
object? value,
JsonWriter writer,
bool isDetachable,
Dictionary<string, int> closures,
List<(Id, Json, Dictionary<string, int>, Base, string)> detachedObjects
)
{
switch (value)
{
case Enum:
writer.WriteValue((int)value);
return;
case Guid g:
writer.WriteValue(g.ToString());
return;
case Color c:
writer.WriteValue(c.ToArgb());
return;
case DateTime dt:
writer.WriteValue(dt.ToString("o", CultureInfo.InvariantCulture));
return;
case Matrix4x4 md:
writer.WriteStartArray();
writer.WriteValue(md.M11);
writer.WriteValue(md.M12);
writer.WriteValue(md.M13);
writer.WriteValue(md.M14);
writer.WriteValue(md.M21);
writer.WriteValue(md.M22);
writer.WriteValue(md.M23);
writer.WriteValue(md.M24);
writer.WriteValue(md.M31);
writer.WriteValue(md.M32);
writer.WriteValue(md.M33);
writer.WriteValue(md.M34);
writer.WriteValue(md.M41);
writer.WriteValue(md.M42);
writer.WriteValue(md.M43);
writer.WriteValue(md.M44);
writer.WriteEndArray();
return;
// Handle ObjectReference before Base (since ObjectReference extends Base)
// This prevents double-serialization and properly propagates closures
case ObjectReference objRef:
{
writer.WriteStartObject();
writer.WritePropertyName("speckle_type");
writer.WriteValue("reference");
writer.WritePropertyName("referencedId");
writer.WriteValue(objRef.referencedId);
writer.WriteEndObject();
// Propagate closure: add the referenced ID
closures[objRef.referencedId] = closures.TryGetValue(objRef.referencedId, out var existing) ? existing + 1 : 1;
// Propagate nested closures from the ObjectReference.closure dictionary
if (objRef.closure != null)
{
foreach (var kvp in objRef.closure)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existingDepth)
? existingDepth + kvp.Value
: kvp.Value;
}
}
return;
}
case Base baseObj:
{
if (isDetachable)
{
var childClosures = new Dictionary<string, int>();
var (childId, childJson) = SerializeBase(baseObj, true, childClosures, detachedObjects);
detachedObjects.Add((childId, childJson, childClosures, baseObj, baseObj.speckle_type));
writer.WriteStartObject();
writer.WritePropertyName("speckle_type");
writer.WriteValue("reference");
writer.WritePropertyName("referencedId");
writer.WriteValue(childId.Value);
writer.WriteEndObject();
closures[childId.Value] = closures.TryGetValue(childId.Value, out var existing) ? existing + 1 : 1;
foreach (var kvp in childClosures)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existingDepth)
? existingDepth + kvp.Value
: kvp.Value;
}
}
else
{
var inlineClosures = new Dictionary<string, int>();
var (_, inlineJson) = SerializeBase(baseObj, false, inlineClosures, detachedObjects);
writer.WriteRawValue(inlineJson.Value);
foreach (var kvp in inlineClosures)
{
closures[kvp.Key] = closures.TryGetValue(kvp.Key, out var existingDepth)
? existingDepth + kvp.Value
: kvp.Value;
}
}
return;
}
case IDictionary dict:
{
writer.WriteStartObject();
foreach (DictionaryEntry kvp in dict)
{
if (kvp.Key is not string key)
{
throw new ArgumentException("Dictionary keys must be strings", nameof(value));
}
writer.WritePropertyName(key);
SerializeValue(kvp.Value, writer, false, closures, detachedObjects);
}
writer.WriteEndObject();
return;
}
case ICollection collection:
{
writer.WriteStartArray();
foreach (var item in collection)
{
SerializeValue(item, writer, isDetachable, closures, detachedObjects);
}
writer.WriteEndArray();
return;
}
default:
// This case will handle primitives and `null`
// Will throw JsonWriterException if not supported
writer.WriteValue(value);
return;
}
}
private UploadItem ReferenceToUploadItem(ObjectReference existingRef)
{
var sb = Pools.StringBuilders.Get();
try
{
using var stringWriter = new StringWriter(sb);
using var jsonWriter = new JsonTextWriter(stringWriter);
jsonWriter.WriteStartObject();
jsonWriter.WritePropertyName("speckle_type");
jsonWriter.WriteValue("reference");
jsonWriter.WritePropertyName("referencedId");
jsonWriter.WriteValue(existingRef.referencedId);
jsonWriter.WritePropertyName("__closure");
if (existingRef.closure != null && existingRef.closure.Count > 0)
{
jsonWriter.WriteStartObject();
foreach (var kvp in existingRef.closure)
{
jsonWriter.WritePropertyName(kvp.Key);
jsonWriter.WriteValue(kvp.Value);
}
jsonWriter.WriteEndObject();
}
else
{
jsonWriter.WriteNull();
}
jsonWriter.WriteEndObject();
jsonWriter.Flush();
var refJson = new Json(stringWriter.ToString());
return new UploadItem(
existingRef.referencedId,
refJson,
existingRef.speckle_type,
existingRef // Pass through the original ObjectReference
);
}
finally
{
Pools.StringBuilders.Return(sb);
}
}
}
+132
View File
@@ -0,0 +1,132 @@
using System.Net.Http.Headers;
using System.Text;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class UploaderFactory(ISpeckleHttp httpClientFactory, ISdkActivityFactory activityFactory)
: IUploaderFactory
{
public Uploader CreateInstance(
string projectId,
string ingestionId,
Account account,
IProgress<StreamProgressArgs> progress,
CancellationToken cancellationToken
) => new(projectId, ingestionId, activityFactory, httpClientFactory, account, progress, cancellationToken);
}
public sealed class Uploader : IDisposable
{
private readonly string _projectId;
private readonly string _ingestionId;
private readonly CancellationToken _cancellationToken;
private readonly HttpClient _speckleClient;
private readonly HttpClient _s3Client;
private readonly ISdkActivityFactory _activity;
private readonly IProgress<StreamProgressArgs> _progress;
internal Uploader(
string projectId,
string ingestionId,
ISdkActivityFactory activity,
ISpeckleHttp httpClientFactory,
Account speckleAccount,
IProgress<StreamProgressArgs> progress,
CancellationToken cancellationToken
)
{
_projectId = projectId;
_ingestionId = ingestionId;
_activity = activity;
_cancellationToken = cancellationToken;
_progress = progress;
_speckleClient = httpClientFactory.CreateHttpClient(authorizationToken: speckleAccount.token);
_speckleClient.BaseAddress = new(new(speckleAccount.serverInfo.url), "/api/v1/");
_s3Client = httpClientFactory.CreateHttpClient();
}
public async Task Send(Stream fileStream)
{
PresignedUploadResponse presignedUploadResponse = await GetPresignedUrl().ConfigureAwait(false);
var etag = await UploadToS3(fileStream, presignedUploadResponse).ConfigureAwait(false);
await TriggerProcessing(new() { Etag = etag }).ConfigureAwait(false);
}
private async Task<PresignedUploadResponse> GetPresignedUrl()
{
using var a = _activity.Start("Get Presigned Url");
var signUri = new Uri($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/sign", UriKind.Relative);
using var signResponse = await _speckleClient.PostAsync(signUri, null, _cancellationToken).ConfigureAwait(false);
signResponse.EnsureSuccessStatusCode();
#if NET5_0_OR_GREATER
string signResponseString = await signResponse.Content.ReadAsStringAsync(_cancellationToken).ConfigureAwait(false);
#else
string signResponseString = await signResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
PresignedUploadResponse presignedUpload =
JsonConvert.DeserializeObject<PresignedUploadResponse>(signResponseString)
?? throw new InvalidOperationException("Failed to get presigned upload URL");
return presignedUpload;
}
private async Task<string> UploadToS3(Stream fileStream, PresignedUploadResponse presignedUploadResponse)
{
using var a = _activity.Start("Uploading file to pre-signed url");
Stream progressStream = new ProgressStream(fileStream, _progress);
using var streamContent = new StreamContent(progressStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
streamContent.Headers.ContentLength = fileStream.Length;
using var uploadRequest = new HttpRequestMessage(HttpMethod.Put, presignedUploadResponse.Url);
foreach (var kvp in presignedUploadResponse.AdditionalRequestHeaders)
{
uploadRequest.Headers.Add(kvp.Key, kvp.Value);
}
uploadRequest.Content = streamContent;
using var uploadResponse = await _s3Client
.SendAsync(uploadRequest, HttpCompletionOption.ResponseHeadersRead, _cancellationToken)
.ConfigureAwait(false);
uploadResponse.EnsureSuccessStatusCode();
return BlobApiHelpers.ParseEtagHeader(uploadResponse.Headers);
}
private async Task TriggerProcessing(TriggerUploadRequest request)
{
using var a = _activity.Start("Triggering Processing");
Uri processUri = new($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/process", UriKind.Relative);
string requestBody = JsonConvert.SerializeObject(request);
using var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
using HttpResponseMessage processResponse = await _speckleClient
.PostAsync(processUri, content, _cancellationToken)
.ConfigureAwait(false);
string body = await processResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
processResponse.EnsureSuccessStatusCode();
}
public void Dispose()
{
_speckleClient.Dispose();
_s3Client.Dispose();
}
}
@@ -0,0 +1,20 @@
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Models;
using Speckle.Sdk.Serialisation;
namespace Speckle.Sdk.Pipelines.Send;
public record UploadItem(string Id, Json Json, string SpeckleType, ObjectReference Reference);
internal record PresignedUploadResponse
{
public required Uri Url { get; init; }
public required string Key { get; init; }
public Dictionary<string, string> AdditionalRequestHeaders { get; init; } = new();
}
internal readonly struct TriggerUploadRequest
{
[JsonProperty("etag")]
public required string Etag { get; init; }
}
@@ -10,12 +10,11 @@ using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Serialisation.V2.Receive;
public record DeserializeProcessOptions(
bool SkipCache = false,
bool SkipCache = false, //TODO: This appears to be bugged when set to `true`, `LoadId` depends on sqlite
bool ThrowOnMissingReferences = true,
bool SkipInvalidConverts = false,
int? MaxParallelism = null,
bool SkipServer = false,
string? AttributeMask = null
bool SkipServer = false
);
public partial interface IDeserializeProcess : IAsyncDisposable;
@@ -45,7 +44,6 @@ public sealed class DeserializeProcess(
new ObjectLoader(
sqLiteJsonCacheManager,
serverObjectManager,
options?.AttributeMask,
progress,
loggerFactory.CreateLogger<ObjectLoader>(),
cancellationToken
@@ -16,13 +16,10 @@ public partial interface IObjectLoader : IDisposable;
public sealed class ObjectLoader(
ISqLiteJsonCacheManager sqLiteJsonCacheManager,
IServerObjectManager serverObjectManager,
string? attributeMask,
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
#pragma warning restore 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.
{
private int? _allChildrenCount;
private long _checkCache;
@@ -30,6 +27,7 @@ public sealed class ObjectLoader(
private long _downloaded;
private long _totalToDownload;
private DeserializeProcessOptions _options = new();
private readonly CancellationToken _cancellationToken = cancellationToken;
[AutoInterfaceIgnore]
public void Dispose() => sqLiteJsonCacheManager.Dispose();
@@ -47,7 +45,7 @@ public sealed class ObjectLoader(
{
//assume everything exists as the root is there.
var allChildren = ClosureParser
.GetClosuresSorted(rootJson, cancellationToken)
.GetClosuresSorted(rootJson, _cancellationToken)
.Select(x => new Id(x.Item1))
.ToList();
//this probably yields away from the Main thread to let host apps update progress
@@ -60,11 +58,11 @@ public sealed class ObjectLoader(
if (!options.SkipServer)
{
rootJson = await serverObjectManager
.DownloadSingleObject(rootId, progress, cancellationToken)
.DownloadSingleObject(rootId, progress, _cancellationToken)
.NotNull()
.ConfigureAwait(false);
IReadOnlyCollection<Id> allChildrenIds = ClosureParser
.GetClosures(rootJson, cancellationToken)
.GetClosures(rootJson, _cancellationToken)
.OrderByDescending(x => x.Item2)
.Select(x => new Id(x.Item1))
.Where(x => !x.Value.StartsWith("blob", StringComparison.Ordinal))
@@ -112,13 +110,13 @@ public sealed class ObjectLoader(
await foreach (
var (id, json) in serverObjectManager.DownloadObjects(
ids.Select(x => x.NotNull()).ToList(),
attributeMask,
null, //TODO: Implement attribute masking in a safe way that will not poison SQLite DB.
progress,
cancellationToken
_cancellationToken
)
)
{
cancellationToken.ThrowIfCancellationRequested();
_cancellationToken.ThrowIfCancellationRequested();
Interlocked.Increment(ref _downloaded);
progress?.Report(new(ProgressEvent.DownloadObjects, _downloaded, _totalToDownload));
toCache.Add(new(new(id), new(json), true, null));
@@ -140,7 +138,7 @@ public sealed class ObjectLoader(
{
if (!_options.SkipCache)
{
cancellationToken.ThrowIfCancellationRequested();
_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));
@@ -170,7 +168,7 @@ public sealed class ObjectLoader(
private void ThrowIfFailed()
{
//always check for cancellation first
cancellationToken.ThrowIfCancellationRequested();
_cancellationToken.ThrowIfCancellationRequested();
if (Exception is not null)
{
throw new SpeckleException($"Error while loading: {Exception.Message}", Exception);
@@ -2,7 +2,13 @@ using System.Text;
namespace Speckle.Sdk.Serialisation.V2.Send;
public sealed record BaseItem(Id Id, Json Json, bool NeedsStorage, Dictionary<Id, int>? Closures) : IHasByteSize
public sealed record BaseItem(
Id Id,
Json Json,
bool NeedsStorage,
Dictionary<Id, int>? Closures,
bool? IsReference = false
) : IHasByteSize
{
public int ByteSize { get; } = Encoding.UTF8.GetByteCount(Json.Value);
@@ -113,16 +113,6 @@ public sealed class SerializeProcess(
_processSource.Token
);
var findTotalObjectsTask = Task.CompletedTask;
if (!options.SkipFindTotalObjects)
{
ThrowIfFailed();
findTotalObjectsTask = Task.Factory.StartNew(
() => TraverseTotal(root),
_processSource.Token,
TaskCreationOptions.AttachedToParent | TaskCreationOptions.PreferFairness,
_highest
);
}
await Traverse(root).ConfigureAwait(false);
ThrowIfFailed();
@@ -133,6 +123,7 @@ public sealed class SerializeProcess(
ThrowIfFailed();
await WaitForSchedulerCompletion().ConfigureAwait(false);
ThrowIfFailed();
return new(root.id.NotNull(), baseSerializer.ObjectReferences.Freeze());
}
catch (OperationCanceledException)
+3 -1
View File
@@ -8,6 +8,7 @@ using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Host;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Models.GraphTraversal;
using Speckle.Sdk.Pipelines.Progress;
using Speckle.Sdk.Serialisation.V2;
using Speckle.Sdk.Serialisation.V2.Receive;
using Speckle.Sdk.Serialisation.V2.Send;
@@ -96,7 +97,8 @@ public static class ServiceRegistration
typeof(DeserializeProcess),
typeof(ObjectLoader),
typeof(TraversalRule),
typeof(Client)
typeof(Client),
typeof(IngestionProgressManager)
);
serviceCollection.AddMatchingInterfacesAsTransient(typeof(GraphQLRetry).Assembly);
return serviceCollection;
-1
View File
@@ -35,7 +35,6 @@
<PackageReference Include="Microsoft.CSharp" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Speckle.Sdk.Dependencies\Speckle.Sdk.Dependencies.csproj" />
+13 -10
View File
@@ -13,15 +13,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CSharp": {
"type": "Direct",
"requested": "[4.7.0, )",
@@ -291,7 +282,19 @@
}
},
"speckle.sdk.dependencies": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
}
},
"net8.0": {
@@ -3,6 +3,13 @@
<TargetFramework>net8.0</TargetFramework>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="altcover" />
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.assert" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Speckle.Sdk.Testing\Speckle.Sdk.Testing.csproj" />
<ProjectReference Include="..\..\src\Speckle.Automate.Sdk\Speckle.Automate.Sdk.csproj" />
@@ -25,7 +25,9 @@ public sealed class AutomationContextTest : IAsyncLifetime
public async Task InitializeAsync()
{
var serviceProvider = TestServiceSetup.GetServiceProvider();
var serviceCollection = new ServiceCollection();
serviceCollection.AddAutomateSdk();
var serviceProvider = serviceCollection.BuildServiceProvider();
_account = await Fixtures.SeedUser().ConfigureAwait(false);
_client = serviceProvider.GetRequiredService<IClientFactory>().Create(_account);
_runner = serviceProvider.GetRequiredService<IAutomationRunner>();
@@ -42,7 +44,7 @@ public sealed class AutomationContextTest : IAsyncLifetime
private async Task<AutomationRunData> AutomationRunData(Base testObject)
{
Project project = await _client.Project.Create(new("Automate function e2e test", null, ProjectVisibility.Public));
const string BRANCH_NAME = "main";
const string BRANCH_NAME = "Trigger";
var model = await _client.Model.Create(new(BRANCH_NAME, null, project.id));
string modelId = model.id;
@@ -2,6 +2,28 @@
"version": 2,
"dependencies": {
"net8.0": {
"altcover": {
"type": "Direct",
"requested": "[9.0.1, )",
"resolved": "9.0.1",
"contentHash": "aadciFNDT5bnylaYUkKal+s5hF7yU/lmZxImQWAlk1438iPqK1Uf79H5ylELpyLIU49HL5ql+tnWBihp3WVLCA=="
},
"AwesomeAssertions": {
"type": "Direct",
"requested": "[8.1.0, )",
"resolved": "8.1.0",
"contentHash": "IfNC4cpXPi9tclWvuNO9lfkuIxJsUTLTS1NXto55jDrAUQJYl0zLI9ByISrfkbBE2Xtg+IWaAXQ6jnUx3anDuw=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.13.0, )",
"resolved": "17.13.0",
"contentHash": "W19wCPizaIC9Zh47w8wWI/yxuqR7/dtABwOrc8r2jX/8mUNxM2vw4fXDh+DJTeogxV+KzKwg5jNNGQVwf3LXyA==",
"dependencies": {
"Microsoft.CodeCoverage": "17.13.0",
"Microsoft.TestPlatform.TestHost": "17.13.0"
}
},
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[8.0.0, )",
@@ -24,6 +46,18 @@
"resolved": "0.9.6",
"contentHash": "HKH7tYrYYlCK1ct483hgxERAdVdMtl7gUKW9ijWXxA1UsYR4Z+TrRHYmzZ9qmpu1NnTycSrp005NYM78GDKV1w=="
},
"xunit.assert": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow=="
},
"Argon": {
"type": "Transitive",
"resolved": "0.28.0",
@@ -364,18 +398,6 @@
"xunit.runner.visualstudio": "[3.0.2, )"
}
},
"altcover": {
"type": "CentralTransitive",
"requested": "[9.0.1, )",
"resolved": "9.0.1",
"contentHash": "aadciFNDT5bnylaYUkKal+s5hF7yU/lmZxImQWAlk1438iPqK1Uf79H5ylELpyLIU49HL5ql+tnWBihp3WVLCA=="
},
"AwesomeAssertions": {
"type": "CentralTransitive",
"requested": "[8.1.0, )",
"resolved": "8.1.0",
"contentHash": "IfNC4cpXPi9tclWvuNO9lfkuIxJsUTLTS1NXto55jDrAUQJYl0zLI9ByISrfkbBE2Xtg+IWaAXQ6jnUx3anDuw=="
},
"GraphQL.Client": {
"type": "CentralTransitive",
"requested": "[6.0.0, )",
@@ -424,16 +446,6 @@
"Microsoft.Extensions.Options": "2.2.0"
}
},
"Microsoft.NET.Test.Sdk": {
"type": "CentralTransitive",
"requested": "[17.13.0, )",
"resolved": "17.13.0",
"contentHash": "W19wCPizaIC9Zh47w8wWI/yxuqR7/dtABwOrc8r2jX/8mUNxM2vw4fXDh+DJTeogxV+KzKwg5jNNGQVwf3LXyA==",
"dependencies": {
"Microsoft.CodeCoverage": "17.13.0",
"Microsoft.TestPlatform.TestHost": "17.13.0"
}
},
"Moq": {
"type": "CentralTransitive",
"requested": "[4.20.72, )",
@@ -515,18 +527,6 @@
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.assert": {
"type": "CentralTransitive",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.runner.visualstudio": {
"type": "CentralTransitive",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow=="
}
}
}
@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TestProjectAnalyserRules>true</TestProjectAnalyserRules>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Speckle.Sdk\Speckle.Sdk.csproj" />
@@ -382,7 +382,7 @@
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"requested": "[9.0.4, )",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
@@ -112,16 +112,15 @@ public class ExceptionTests
new DummySqLiteReceiveManager(new Dictionary<string, string>()),
new ExceptionServerObjectManager(),
null,
null,
new NullLogger<ObjectLoader>(),
default
CancellationToken.None
);
await using var process = new DeserializeProcess(
o,
null,
new BaseDeserializer(new ObjectDeserializerFactory()),
new NullLoggerFactory(),
default,
CancellationToken.None,
new(SkipCache: true, MaxParallelism: 1, SkipServer: true)
);
@@ -145,7 +144,7 @@ public class ExceptionTests
null,
new BaseDeserializer(new ObjectDeserializerFactory()),
new NullLoggerFactory(),
default,
CancellationToken.None,
new(true, MaxParallelism: 1)
);
@@ -170,7 +169,7 @@ public class ExceptionTests
null,
new BaseDeserializer(new ObjectDeserializerFactory()),
new NullLoggerFactory(),
default,
CancellationToken.None,
new(MaxParallelism: 1)
);
@@ -195,9 +194,7 @@ public class ExceptionTests
[SpeckleType("Objects.Geometry.BadBase")]
public class BadBase : Base
{
#pragma warning disable CA1065
public string BadProp => throw new NotImplementedException();
#pragma warning restore CA1065
}
[Fact]
@@ -150,7 +150,7 @@ public class SerializationTests
id.Should().Be(newId.Value);
}
[Theory]
[Theory(Skip = "Takes too long")]
[InlineData("RevitObject.json.gz", "3416d3fe01c9196115514c4a2f41617b", 7818)]
public async Task Roundtrip_Test_Old(string fileName, string _, int count)
{
@@ -184,10 +184,8 @@ public class SerializationTests
idToBase.Count.Should().Be(count);
}
[Theory]
[Theory(Skip = "Takes too long")]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public async Task Roundtrip_Test_New(int concurrency)
{
@@ -204,9 +202,8 @@ public class SerializationTests
new DummySqLiteReceiveManager(closures),
new DummyReceiveServerObjectManager(closures),
null,
null,
new NullLogger<ObjectLoader>(),
default
CancellationToken.None
)
)
{
@@ -52,7 +52,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "DownloadObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "DownloadObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -91,7 +91,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "DownloadSingleObject")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "DownloadSingleObject")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -132,7 +132,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "HasObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "HasObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -171,7 +171,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "UploadObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "UploadObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
+13 -1
View File
@@ -8,7 +8,19 @@ public abstract class MoqTest : IDisposable
{
protected MoqTest() => Repository = new(MockBehavior.Strict);
public void Dispose() => Repository.VerifyAll();
protected virtual void Dispose(bool isDisposing)
{
if (isDisposing)
{
Repository.VerifyAll();
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected MockRepository Repository { get; private set; } = new(MockBehavior.Strict);
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsTestProject>true</IsTestProject>
<TestProjectAnalyserRules>true</TestProjectAnalyserRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
@@ -0,0 +1,71 @@
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Resources;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
[Trait("Server", "Internal")]
public sealed class ModelIngestionResourceExceptionalTests : IAsyncLifetime
{
private IClient _testUser;
private ModelIngestionResource Sut => _testUser.Ingestion;
private Project _project;
private Model _model;
public Task DisposeAsync() => Task.CompletedTask;
public async Task InitializeAsync()
{
_testUser = await Fixtures.SeedUserWithClient();
_project = await _testUser.Project.Create(new("Test project", "", null));
_model = await _testUser.Model.Create(new("Test Model 1", "", _project.id));
}
[Fact]
public async Task CreateIngestionNonExistentProject()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
"Doesn't exist...",
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
{
_ = await Sut.Create(createInput);
});
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLStreamNotFoundException>(item));
}
[Fact]
public async Task UpdateNonExistentNonExistent()
{
var updateInput = new ModelIngestionUpdateInput("Doesn't exist", _project.id, "Can't be", 0.5);
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
{
_ = await Sut.UpdateProgress(updateInput);
});
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLException>(item));
}
[Fact]
public async Task CancelNonExistentIngestion()
{
var input = new ModelIngestionCancelledInput(
"Non-existent-ingestion",
_project.id,
cancellationMessage: "This was cancelled for testing purposes"
);
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
{
_ = await Sut.FailWithCancel(input);
});
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLException>(item));
}
}
@@ -0,0 +1,196 @@
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Resources;
using Speckle.Sdk.Models;
using Speckle.Sdk.Transports;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
[Trait("Server", "Internal")]
public sealed class ModelIngestionResourceTests : IAsyncLifetime
{
private IClient _testUser;
private ModelIngestionResource Sut => _testUser.Ingestion;
private Project _project;
private Model _model;
private IOperations _operations;
public Task DisposeAsync() => Task.CompletedTask;
public async Task InitializeAsync()
{
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
_testUser = await Fixtures.SeedUserWithClient();
_project = await _testUser.Project.Create(new("Test project", "", null));
_model = await _testUser.Model.Create(new("Test Model 1", "", _project.id));
}
[Fact]
public async Task CreateAndError()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
var errorInput = new ModelIngestionFailedInput(ingest.id, _project.id, "A bad thing happened", "Over hear!");
var res = await Sut.FailWithError(errorInput);
Assert.Equal(ingest.id, res.id);
}
[Fact]
public async Task CreateAndUpdate()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
await Update(null, "None");
await Update(0.1, "0.1");
await Update(0.5, "Whoa-oh! We're half way there!");
await Update(1, "Finished");
await Update(0.2, "Back to processing again");
async Task Update(double? progress, string message)
{
var updateInput = new ModelIngestionUpdateInput(ingest.id, _project.id, message, progress);
var res = await Sut.UpdateProgress(updateInput);
Assert.Equal(message, res.statusData.progressMessage);
Assert.False(res.cancellationRequested);
Assert.Equal(ModelIngestionStatus.processing, res.statusData.status);
}
}
[Fact]
public async Task CreateAndCancel()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
var input = new ModelIngestionCancelledInput(
ingest.id,
_project.id,
cancellationMessage: "This was cancelled for testing purposes"
);
var res = await Sut.FailWithCancel(input);
Assert.Equal(ingest.id, res.id);
}
[Fact]
public async Task CreateAndComplete()
{
ModelIngestionCreateInput createInput = new(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
Base myObject = Fixtures.GenerateNestedObject();
var sendResult = await _operations.Send2(
_testUser.ServerUrl,
_project.id,
_testUser.Account.token,
myObject,
new Progress<ProgressArgs>(x =>
{
var updateInput = new ModelIngestionUpdateInput(
ingest.id,
_project.id,
$"{x.Count} / {x.Total}",
x.Total == null ? null : x.Count / x.Total
);
_ = Sut.UpdateProgress(updateInput).Result;
}),
CancellationToken.None,
new(true, true)
);
ModelIngestionSuccessInput finish = new(ingest.id, _project.id, sendResult.RootId, "yay!");
string versionId = await Sut.Complete(finish);
Version version = await _testUser.Version.Get(versionId, _project.id);
ModelIngestion finalIngestion = await _testUser.Ingestion.Get(ingest.id, _project.id);
Assert.Equal(version.id, versionId);
Assert.Equal(sendResult.RootId, version.referencedObject);
Assert.Equal(finalIngestion.statusData.versionId, versionId);
}
[Fact]
public async Task CreateAndGet()
{
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
ModelIngestion ingest = await Sut.Create(createInput);
ModelIngestion res = await Sut.Get(ingest.id, _project.id);
Assert.Equal(ingest.id, res.id);
Assert.Equal(ingest.statusData.status, res.statusData.status);
Assert.Equal(ingest.statusData.versionId, res.statusData.versionId);
Assert.Null(res.statusData.versionId);
Assert.Equal(_model.id, res.modelId);
Assert.Equal(_project.id, res.projectId);
Assert.Equal(_testUser.Account.userInfo.id, res.userId);
}
[Fact]
public async Task TestRequeue()
{
//Not sure if is desirable that ingestions created by the modelIngestionMutations.create mutation can be re-queued
//But the server allows it, so we test it
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
var ingestion = await Sut.Create(createInput);
var res = await Sut.Requeue(new(ingestion.id, _project.id, "we'll try and requeue this ingestion"));
Assert.Equal(ingestion.id, res.id);
Assert.Equal(ModelIngestionStatus.queued, res.statusData.status);
}
[Fact]
public async Task TestStartProcessing()
{
//Not sure if is desirable that StartProcessing can be used by ingestions created by the modelIngestionMutations.create mutation
//But the server allows it, so we test it
var createInput = new ModelIngestionCreateInput(
_model.id,
_project.id,
"Starting processing",
new(".NET test runner", "0.0.0", null, null)
);
var ingestion = await Sut.Create(createInput);
var res = await Sut.StartProcessing(
new(ingestion.id, _project.id, "", new SourceDataInput("what", "happens", "now", 0))
);
Assert.Equal(ingestion.id, res.id);
Assert.Equal(ModelIngestionStatus.processing, res.statusData.status);
}
}
@@ -1,5 +1,6 @@
using FluentAssertions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Resources;
@@ -17,7 +18,7 @@ public class ModelResourceTests : IAsyncLifetime
{
// Runs instead of [SetUp] in NUnit
_testUser = await Fixtures.SeedUserWithClient();
_project = await _testUser.Project.Create(new("Test project", "", null));
_project = await _testUser.Project.Create(new("Test project", "", ProjectVisibility.Public));
_model = await _testUser.Model.Create(new("Test Model", "", _project.id));
}
@@ -123,4 +124,40 @@ public class ModelResourceTests : IAsyncLifetime
var delEx = await FluentActions.Invoking(() => Sut.Delete(input)).Should().ThrowAsync<AggregateException>();
getEx.WithInnerExceptionExactly<SpeckleGraphQLException>();
}
[Fact]
public async Task TestUserHasModelPermissions()
{
var ownerResult = await Sut.GetPermissions(_project.id, _model.id);
ownerResult.canUpdate.authorized.Should().Be(true);
ownerResult.canCreateVersion.authorized.Should().Be(true);
ownerResult.canDelete.authorized.Should().Be(true);
// Test with another user
var guest = await Fixtures.SeedUserWithClient();
var guestResult = await guest.Model.GetPermissions(_project.id, _model.id);
guestResult.canUpdate.authorized.Should().Be(false);
guestResult.canCreateVersion.authorized.Should().Be(false);
guestResult.canDelete.authorized.Should().Be(false);
}
[Fact]
[Trait("Server", "Internal")]
public async Task TestCanCreateModelIngestion_InternalServer()
{
var ownerResult = await Sut.CanCreateModelIngestion(_project.id, _model.id);
ownerResult.authorized.Should().Be(true);
}
[Fact]
[Trait("Server", "Public")]
public async Task TestCanCreateModelIngestion_PublicServer_Throws()
{
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
await Sut.CanCreateModelIngestion(_project.id, _model.id)
);
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLInvalidQueryException>();
}
}
@@ -93,7 +93,7 @@ public class ProjectResourceExceptionalTests : IAsyncLifetime
new(_testProject.id, "My new name", ProjectVisibility.Public, "NonExistentWorkspace")
)
);
ex.InnerExceptions.Single().Should().BeOfType<SpeckleGraphQLException>();
ex.InnerExceptions.Single().Should().BeOfType<SpeckleGraphQLForbiddenException>();
}
[Theory]
@@ -106,7 +106,7 @@ public class ProjectResourceExceptionalTests : IAsyncLifetime
ProjectUpdateRoleInput input = new(_secondUser.Account.id.NotNull(), "NonExistentProject", newRole);
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.UpdateRole(input));
ex.InnerExceptions.Single().Should().BeOfType<SpeckleGraphQLForbiddenException>();
ex.InnerExceptions.Single().Should().BeAssignableTo<SpeckleGraphQLException>(); //v3 server responds with SpeckleGraphQLStreamNotFoundException exception, v2 reponds with SpeckleGraphQLForbiddenException
}
[Theory]
@@ -103,8 +103,30 @@ public class ProjectResourceTests
[Fact]
public async Task TestUserHasProjectPermissions()
{
var res = await Sut.GetPermissions(_testProject.id);
var privateProject = await _testUser.Project.Create(
new ProjectCreateInput("asdfasdf", "desc", ProjectVisibility.Private)
);
var resp = await Sut.GetPermissions(privateProject.id);
resp.canCreateModel.authorized.Should().Be(true);
resp.canDelete.authorized.Should().Be(true);
resp.canLoad.authorized.Should().Be(true);
var publicProject = await _testUser.Project.Create(
new ProjectCreateInput("asdfasdf", "desc", ProjectVisibility.Public)
);
var res = await Sut.GetPermissions(publicProject.id);
res.canCreateModel.authorized.Should().Be(true);
res.canDelete.authorized.Should().Be(true);
res.canLoad.authorized.Should().Be(true);
// Test with another user
var guest = await Fixtures.SeedUserWithClient();
var guestResult = await guest.Project.GetPermissions(publicProject.id);
guestResult.canCreateModel.authorized.Should().Be(false);
guestResult.canDelete.authorized.Should().Be(false);
guestResult.canLoad.authorized.Should().Be(false);
}
}

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