Compare commits

..

21 Commits

Author SHA1 Message Date
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
102 changed files with 2645 additions and 231 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 }}
+15 -6
View File
@@ -6,10 +6,12 @@ on:
docker-compose-file:
required: true
type: string
use-github-container-registry:
use-internal-image:
default: false
type: boolean
secrets:
CODECOV_TOKEN:
required: true
jobs:
integration-test:
env:
@@ -27,7 +29,7 @@ jobs:
cache-dependency-path: "**/packages.lock.json"
- name: 🔐 Login to Github Container Registry
if: ${{ inputs.use-github-container-registry }}
if: ${{ inputs.use-internal-image }}
uses: docker/login-action@v3
with:
registry: "ghcr.io"
@@ -35,7 +37,7 @@ jobs:
password: ${{ github.token }}
- name: ⚙️ Spin up Server
run: docker compose -f ${{ inputs.docker-compose-file }} up --wait
run: docker compose --file ${{ inputs.docker-compose-file }} up --wait
- name: 📦 Restore
run: dotnet restore ${{ env.Solution }} --locked-mode
@@ -43,11 +45,18 @@ jobs:
- name: 🏗️ Build
run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror
- name: 🔨 Integration Tests
run: dotnet test ${{ env.Solution }} --filter "Category=Integration" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: 🔨 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 }}
+8 -2
View File
@@ -39,7 +39,9 @@ jobs:
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
@@ -47,9 +49,13 @@ jobs:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose-internal.yml"
use-github-container-registry: true
use-internal-image: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
integration-test-public:
uses: "./.github/workflows/integration-test.yml"
with:
docker-compose-file: "docker-compose.yml"
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+2
View File
@@ -48,7 +48,9 @@ jobs:
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
+1
View File
@@ -0,0 +1 @@
dotnet 8.0.400
+1 -2
View File
@@ -16,7 +16,6 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="[2.2.0,)" />
<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" />
@@ -28,7 +27,7 @@
<PackageVersion Include="Speckle.DoubleNumerics" Version="4.1.0" />
<PackageVersion Include="SimpleExec" Version="12.0.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.4" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.1" />
<PackageVersion Include="Verify.Quibble" Version="2.1.1" />
<PackageVersion Include="Verify.Xunit" Version="29.4.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
+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>
+2 -5
View File
@@ -52,7 +52,7 @@ services:
start_period: 10s
speckle-server:
image: ghcr.io/specklesystems/speckle-server:latest
image: ${SPECKLE_SERVER_IMAGE:-ghcr.io/specklesystems/speckle-server:latest}
restart: always
healthcheck:
test:
@@ -97,10 +97,7 @@ services:
STRATEGY_LOCAL: "true"
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
POSTGRES_URL: 'postgres://speckle:speckle@postgres:5432/speckle'
ENABLE_MP: "false"
LOG_PRETTY: "true"
+33 -17
View File
@@ -86,6 +86,14 @@
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "E1HSLkPHXEO30JEij2pWbOuzz1Z5ND4a5l7IP1T2RgQuE0a0NzEIvtO64RNy3Otn6PFezbT80cfm3M/Cgt70PA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -242,8 +250,8 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Runtime.InteropServices.WindowsRuntime": {
"type": "Transitive",
@@ -265,10 +273,10 @@
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"speckle.objects": {
@@ -281,14 +289,14 @@
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.Bcl.AsyncInterfaces": "[5.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -305,15 +313,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "8.0.0",
"contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -359,6 +358,16 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.1",
"System.Threading.Tasks.Extensions": "4.6.3"
}
}
},
"net8.0": {
@@ -556,7 +565,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -612,6 +622,12 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
}
}
}
+33 -17
View File
@@ -54,6 +54,14 @@
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "E1HSLkPHXEO30JEij2pWbOuzz1Z5ND4a5l7IP1T2RgQuE0a0NzEIvtO64RNy3Otn6PFezbT80cfm3M/Cgt70PA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -205,8 +213,8 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "4.5.3",
"contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw=="
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Runtime.InteropServices.WindowsRuntime": {
"type": "Transitive",
@@ -218,24 +226,24 @@
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"speckle.sdk": {
"type": "Project",
"dependencies": {
"GraphQL.Client": "[6.0.0, )",
"Microsoft.Bcl.AsyncInterfaces": "[5.0.0, )",
"Microsoft.CSharp": "[4.7.0, )",
"Microsoft.Data.Sqlite": "[7.0.5, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -252,15 +260,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -306,6 +305,16 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.1",
"System.Threading.Tasks.Extensions": "4.6.3"
}
}
},
"net8.0": {
@@ -477,7 +486,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -533,6 +543,12 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
}
}
}
+21 -22
View File
@@ -82,12 +82,12 @@
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "4qBn2H6/aXBpE/Pm3wY5yusY/pEvQz99NlWHrTUji0qCmOdbhhjaALcpmbfW2ksxlPM6i6S+QFLkpOQdyfeKYQ==",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "9.0.4",
"System.Threading.Tasks.Extensions": "4.5.4"
"Microsoft.Bcl.AsyncInterfaces": "10.0.1",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"ILRepack": {
@@ -95,6 +95,14 @@
"resolved": "2.0.33",
"contentHash": "xb2h1CsOepoYwdXEPui9VcQglwABQwNf9cccZbf+acarEzF5PUp8Xx71nFXIhOgEdm6wrxAoF6xAxK4m/XFRUQ=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "E1HSLkPHXEO30JEij2pWbOuzz1Z5ND4a5l7IP1T2RgQuE0a0NzEIvtO64RNy3Otn6PFezbT80cfm3M/Cgt70PA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -141,24 +149,15 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"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"
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
}
},
@@ -229,9 +228,9 @@
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "4qBn2H6/aXBpE/Pm3wY5yusY/pEvQz99NlWHrTUji0qCmOdbhhjaALcpmbfW2ksxlPM6i6S+QFLkpOQdyfeKYQ=="
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
},
"ILRepack": {
"type": "Transitive",
+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>
+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,
@@ -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,70 @@
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record SourceDataInput(
string sourceApplicationSlug,
string sourceApplicationVersion,
string? fileName,
long? fileSizeBytes
);
public record ModelIngestionCreateInput(
string modelId,
string projectId,
string progressMessage,
SourceDataInput sourceData
);
public record ModelIngestionUpdateInput(string ingestionId, string projectId, string progressMessage, double? progress);
public record ModelIngestionSuccessInput(
string ingestionId,
string projectId,
string rootObjectId,
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,
@@ -0,0 +1,12 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelIngestion
{
public required string id { get; init; }
public required DateTime createdAt { get; init; }
public required DateTime updatedAt { get; init; }
public required string modelId { get; init; }
public required bool cancellationRequested { get; init; }
public required ModelIngestionStatusData statusData { get; init; }
// public required LimitedUser user { get; init; }
}
@@ -0,0 +1,9 @@
using Speckle.Sdk.Api.GraphQL.Enums;
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class ModelIngestionStatusData
{
public required ModelIngestionStatus status { get; init; }
public required string? progressMessage { get; init; }
}
@@ -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,486 @@
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
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
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
}
}
""";
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
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
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
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
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
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
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,60 @@ public sealed class SubscriptionResource : IDisposable
return subscription;
}
/// <summary>Subscribe to a cancellation request being made for a Model Ingestion</summary>
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
public Subscription<ProjectModelIngestionUpdatedMessage> CreateProjectModelIngestionUpdatedSubscription(
ProjectModelIngestionSubscriptionInput input
)
{
//language=graphql
const string QUERY = """
subscription IngestionUpdated($input: ProjectModelIngestionSubscriptionInput!) {
data: projectModelIngestionUpdated(input: $input) {
modelIngestion {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
type
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input } };
Subscription<ProjectModelIngestionUpdatedMessage> subscription = new(_client, request);
_subscriptions.Add(subscription);
return subscription;
}
/// <summary>Subscribe to a cancellation request being made for a Model Ingestion</summary>
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
public Subscription<ProjectModelIngestionUpdatedMessage> CreateProjectModelIngestionCancellationRequestedSubscription(
string ingestionId,
string projectId
)
{
return CreateProjectModelIngestionUpdatedSubscription(
new ProjectModelIngestionSubscriptionInput(
projectId,
new(ingestionId, null),
ProjectModelIngestionUpdatedMessageType.cancellationRequested
)
);
}
public void Dispose()
{
foreach (var subscription in _subscriptions)
@@ -1,2 +1,2 @@
schema: https://app.speckle.systems/graphql
schema: https://latest.speckle.systems/graphql
documents: '**/*.graphql'
+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();
@@ -17,7 +17,7 @@ public enum DynamicBaseMemberType
Dynamic = 2,
/// <summary>
/// The typed members flagged with <see cref="ObsoleteAttribute"/> attribute.
/// The typed members flagged with ObsoleteAttribute attribute.
/// </summary>
Obsolete = 4,
@@ -27,12 +27,12 @@ public enum DynamicBaseMemberType
SchemaComputed = 16,
/// <summary>
/// All the typed members, including ones with <see cref="ObsoleteAttribute"/> attributes.
/// All the typed members, including ones with ObsoleteAttribute attributes.
/// </summary>
InstanceAll = Instance + Obsolete,
/// <summary>
/// All the members, including dynamic and instance members flagged with <see cref="ObsoleteAttribute"/> attributes
/// All the members, including dynamic and instance members flagged with ObsoleteAttribute attributes
/// </summary>
All = InstanceAll + Dynamic,
}
@@ -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>
[GenerateAutoInterface]
public sealed class IngestionProgressManager(
ILogger<IngestionProgressManager> logger,
IClient speckleClient,
ModelIngestion ingestion,
string projectId,
TimeSpan updateInterval,
CancellationToken cancellationToken
) : IIngestionProgressManager
{
/// <remarks>
/// Normally we would pick quite a coarse throttle window to try and avoid over pressure (1-5s)
/// </remarks>
private Task? _lastUpdate;
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, projectId, trimmedMessage, value.Progress),
cancellationToken
)
.ContinueWith(
HandleFaultedContinuation,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | 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 HandleFaultedContinuation(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,22 @@
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,
string projectId,
TimeSpan updateInterval,
CancellationToken cancellationToken
)
{
return new IngestionProgressManager(logger, speckleClient, ingestion, projectId, 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", "EB", "ZB", "YB"];
private 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,77 @@
using System.IO.Compression;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Helpers;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class DiskStoreFactory(ILogger<DiskStore> logger) : IDiskStoreFactory
{
public DiskStore CreateInstance(CancellationToken cancellationToken) => new(logger, cancellationToken);
}
public sealed class DiskStore
{
private readonly Channel<UploadItem> _channel;
private readonly Task<DisposableFile> _writeToDiskTask;
private readonly ILogger<DiskStore> _logger;
private readonly CancellationToken _cancellationToken;
internal DiskStore(ILogger<DiskStore> logger, CancellationToken cancellationToken)
{
_logger = logger;
_cancellationToken = cancellationToken;
_channel = Channel.CreateBounded<UploadItem>(
new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true }
);
_writeToDiskTask = Task.Run(WriteFile, cancellationToken);
}
public ValueTask PushAsync(UploadItem item) => _channel.Writer.WriteAsync(item, _cancellationToken);
public async Task<DisposableFile> CompleteAsync()
{
_channel.Writer.Complete();
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.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
{
await writer.WriteLineAsync($"{item.Id}\t{item.Json}\t{item.SpeckleType}").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,80 @@
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 async Task<string> WaitForUploadAndServerProcessing()
{
// TODO: in some way, wait for the server to process the upload and return the actual new version id
return await Task.FromResult("todo").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);
}
}
}
+127
View File
@@ -0,0 +1,127 @@
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class UploaderFactory(ISpeckleHttp httpClientFactory, ILogger<Uploader> logger) : IUploaderFactory
{
public Uploader CreateInstance(
string projectId,
string ingestionId,
Account account,
IProgress<StreamProgressArgs> progress,
CancellationToken cancellationToken
) => new(projectId, ingestionId, logger, 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 ILogger<Uploader> _logger;
private readonly IProgress<StreamProgressArgs> _progress;
internal Uploader(
string projectId,
string ingestionId,
ILogger<Uploader> logger,
ISpeckleHttp httpClientFactory,
Account speckleAccount,
IProgress<StreamProgressArgs> progress,
CancellationToken cancellationToken
)
{
_projectId = projectId;
_ingestionId = ingestionId;
_logger = logger;
_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()
{
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)
{
_logger.LogInformation("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)
{
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; }
}
@@ -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 -1
View File
@@ -26,6 +26,7 @@
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Speckle.DoubleNumerics" />
<PackageReference Include="Speckle.Newtonsoft.Json" />
<PackageReference Include="System.Threading.Channels" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" OverrideVersion="8.0.0" />
@@ -35,7 +36,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" />
+29 -14
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, )",
@@ -99,6 +90,16 @@
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.1",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"GraphQL.Client.Abstractions": {
"type": "Transitive",
"resolved": "6.0.0",
@@ -120,6 +121,14 @@
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "E1HSLkPHXEO30JEij2pWbOuzz1Z5ND4a5l7IP1T2RgQuE0a0NzEIvtO64RNy3Otn6PFezbT80cfm3M/Cgt70PA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -271,8 +280,8 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "4.5.3",
"contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw=="
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Runtime.InteropServices.WindowsRuntime": {
"type": "Transitive",
@@ -284,10 +293,10 @@
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"speckle.sdk.dependencies": {
@@ -368,6 +377,12 @@
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
},
"GraphQL.Client.Abstractions": {
"type": "Transitive",
"resolved": "6.0.0",
@@ -370,7 +370,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -491,6 +492,12 @@
"System.Text.Encodings.Web": "8.0.0"
}
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
},
"Verify.Quibble": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
@@ -348,7 +348,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -432,6 +433,12 @@
"System.Text.Encodings.Web": "8.0.0"
}
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
},
"Verify.Quibble": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
@@ -74,6 +74,11 @@
"resolved": "1.17.0",
"contentHash": "8x+HCVTl/HHTGpscH3vMBhV8sknN/muZFw9s3TsI8SA6+c43cOTCi2+jE4KsU8pNLbJ++iF2ZFcpcXHXtDglnw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -337,7 +342,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -380,12 +386,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
@@ -425,6 +425,12 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
}
}
}
@@ -184,7 +184,7 @@ public class SerializationTests
idToBase.Count.Should().Be(count);
}
[Theory]
[Theory(Skip = "Takes too long")]
[InlineData(1)]
[InlineData(4)]
public async Task Roundtrip_Test_New(int concurrency)
@@ -393,7 +393,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -474,6 +475,12 @@
"System.Text.Encodings.Web": "8.0.0"
}
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
},
"Verify.Quibble": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
+8 -1
View File
@@ -311,7 +311,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -376,6 +377,12 @@
"dependencies": {
"System.Text.Encodings.Web": "8.0.0"
}
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
}
}
}
@@ -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,189 @@
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);
Assert.Equal(version.id, versionId);
Assert.Equal(sendResult.RootId, version.referencedObject);
}
[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);
}
[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>();
}
}
@@ -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);
}
}
@@ -1,4 +1,4 @@
using FluentAssertions;
using FluentAssertions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Inputs;
@@ -15,7 +15,7 @@ public class SubscriptionResourceTests : IAsyncLifetime
#else
private const int WAIT_PERIOD = 400; // For CI runs, a much smaller wait time is acceptable
#endif
private const int TIMEOUT = WAIT_PERIOD + WAIT_PERIOD + 400;
private const int TIMEOUT = WAIT_PERIOD + WAIT_PERIOD + 600;
private IClient _testUser;
private Project _testProject;
private Model _testModel;
@@ -32,6 +32,7 @@ public class SubscriptionResourceTests : IAsyncLifetime
public async Task InitializeAsync()
{
_testUser = await Fixtures.SeedUserWithClient();
await _testUser.InitializeWebsocket();
_testProject = await _testUser.Project.Create(new("test project123", "desc", null));
_testModel = await _testUser.Model.Create(new("test model", "desc", _testProject.id));
_testVersion = await Fixtures.CreateVersion(_testUser, _testProject.id, _testModel.id);
@@ -80,15 +81,15 @@ public class SubscriptionResourceTests : IAsyncLifetime
public async Task ProjectUpdated_SubscriptionIsCalled()
{
TaskCompletionSource<ProjectUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
using Subscription<ProjectUpdatedMessage> sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
var input = new ProjectUpdateInput(_testProject.id, "This is my new name");
var created = await _testUser.Project.Update(input);
ProjectUpdateInput input = new(_testProject.id, "This is my new name");
Project created = await _testUser.Project.Update(input);
var subscriptionMessage = await tcs.Task;
ProjectUpdatedMessage subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage.id.Should().Be(created.id);
@@ -135,4 +136,74 @@ public class SubscriptionResourceTests : IAsyncLifetime
subscriptionMessage.type.Should().Be(ProjectCommentsUpdatedMessageType.CREATED);
subscriptionMessage.comment.Should().NotBeNull();
}
[Fact(Timeout = TIMEOUT), Trait("Server", "Internal")]
public async Task ProjectModelIngestionCancellationRequested_SubscriptionIsCalled()
{
ModelIngestion ingestion = await _testUser.Ingestion.Create(
new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null))
);
TaskCompletionSource<ProjectModelIngestionUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectModelIngestionCancellationRequestedSubscription(ingestion.id, _testProject.id);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
await _testUser.Ingestion.RequestCancellation(new(ingestion.id, _testProject.id, "please cancel"));
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage.type.Should().Be(ProjectModelIngestionUpdatedMessageType.cancellationRequested);
subscriptionMessage.modelIngestion.id.Should().Be(ingestion.id);
}
[Fact(Timeout = TIMEOUT), Trait("Server", "Internal")]
public async Task ProjectModelIngestionUpdate_UpdateSubscriptionIs()
{
ModelIngestion ingestion = await _testUser.Ingestion.Create(
new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null))
);
TaskCompletionSource<ProjectModelIngestionUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectModelIngestionUpdatedSubscription(
new(
_testProject.id,
new ModelIngestionReference(ingestion.id, null),
ProjectModelIngestionUpdatedMessageType.updated
)
);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
await _testUser.Ingestion.UpdateProgress(new(ingestion.id, _testProject.id, "Here's an update", 0.314));
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage.type.Should().Be(ProjectModelIngestionUpdatedMessageType.updated);
subscriptionMessage.modelIngestion.id.Should().Be(ingestion.id);
}
[Fact(Timeout = TIMEOUT), Trait("Server", "Internal")]
public async Task ProjectModelIngestionUpdate_CancelSubscriptionIsNotCalled()
{
ModelIngestion ingestion = await _testUser.Ingestion.Create(
new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null))
);
TaskCompletionSource<ProjectModelIngestionUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectModelIngestionCancellationRequestedSubscription(ingestion.id, _testProject.id);
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
await _testUser.Ingestion.UpdateProgress(new(ingestion.id, _testProject.id, "this shouldn't cancel", null));
await Task.Delay(WAIT_PERIOD); // Give time to subscription to maybe fire
tcs.Task.IsCompleted.Should().BeFalse();
}
}
@@ -17,6 +17,11 @@ using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
[assembly: AssemblyTrait("Category", "Integration")]
#if DEBUG
[assembly: CollectionBehavior(MaxParallelThreads = 8)]
#endif
namespace Speckle.Sdk.Tests.Integration;
public static class Fixtures
@@ -1,8 +1,6 @@
using System.Reflection;
using FluentAssertions;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Host;
using Speckle.Sdk.Models;
using Speckle.Sdk.Transports;
@@ -16,8 +14,7 @@ public class MemoryTransportTests : IDisposable
public MemoryTransportTests()
{
CleanData();
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}
@@ -1,11 +1,8 @@
using System.Reflection;
using System.Text;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Enums;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Host;
using Speckle.Sdk.Models;
namespace Speckle.Sdk.Tests.Integration;
@@ -19,8 +16,6 @@ public sealed class SendReceiveTests : IAsyncLifetime
public async Task InitializeAsync()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
ClearCache();
@@ -370,7 +370,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -454,6 +455,12 @@
"System.Text.Encodings.Web": "8.0.0"
}
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
},
"Verify.Quibble": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
@@ -92,6 +92,11 @@
"resolved": "1.17.0",
"contentHash": "8x+HCVTl/HHTGpscH3vMBhV8sknN/muZFw9s3TsI8SA6+c43cOTCi2+jE4KsU8pNLbJ++iF2ZFcpcXHXtDglnw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -355,7 +360,8 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.1, )"
}
},
"speckle.sdk.dependencies": {
@@ -372,12 +378,6 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[5.0.0, )",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
@@ -417,6 +417,12 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "YRqU6Y2Cl6C+HrG5h1ftgKZ5VDTSA7j1wMKs5RtlauPeQ2EZ639Jt5aOFHdX3naP01hDDWFOWPApmNDVKwOpmg=="
}
}
}
@@ -10,14 +10,14 @@ using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Unit.Api.Operations;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class Closures
{
private readonly IOperations _operations;
public Closures()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(TableLegFixture).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(TableLegFixture).Assembly);
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}
@@ -7,6 +7,7 @@ using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Unit.Api.Operations;
[Collection(nameof(RequiresTypeLoaderCollection))]
public sealed partial class OperationsReceiveTests : IDisposable
{
private static readonly Base[] s_testObjects;
@@ -44,8 +45,7 @@ public sealed partial class OperationsReceiveTests : IDisposable
private static void Reset()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
TypeLoader.ReInitialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
}
public static IEnumerable<object[]> TestCases()
@@ -7,14 +7,14 @@ using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Unit.Api.Operations;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class SendObjectReferences
{
private readonly IOperations _operations;
public SendObjectReferences()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(DataChunk).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(DataChunk).Assembly);
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}
@@ -9,14 +9,14 @@ using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Unit.Api.Operations;
[Collection(nameof(RequiresTypeLoaderCollection))]
public sealed class SendReceiveLocal : IDisposable
{
private readonly IOperations _operations;
public SendReceiveLocal()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(Point).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(Point).Assembly);
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}
@@ -9,14 +9,14 @@ using Point = Speckle.Sdk.Tests.Unit.Host.Point;
namespace Speckle.Sdk.Tests.Unit.Api.Operations;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class ObjectSerialization
{
private readonly IOperations _operations;
public ObjectSerialization()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(DataChunk).Assembly, typeof(ColorMock).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(DataChunk).Assembly, typeof(ColorMock).Assembly);
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}
+1 -1
View File
@@ -1 +1 @@
[assembly: CollectionBehavior(DisableTestParallelization = true)]
[assembly: CollectionBehavior()]
@@ -0,0 +1,7 @@
namespace Speckle.Sdk.Tests.Unit;
[CollectionDefinition(nameof(RequiresTypeLoaderCollection), DisableParallelization = true)]
public class RequiresTypeLoaderCollection;
[CollectionDefinition(nameof(RequiresSqLiteAccountDb), DisableParallelization = true)]
public class RequiresSqLiteAccountDb;
@@ -9,6 +9,7 @@ using Speckle.Sdk.Testing;
namespace Speckle.Sdk.Tests.Unit.Credentials;
[Collection(nameof(RequiresSqLiteAccountDb))]
public sealed class AccountManagerTests : MoqTest
{
private class TestAccountFactory : IAccountFactory
@@ -5,6 +5,7 @@ using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Unit.Credentials;
[Collection(nameof(RequiresSqLiteAccountDb))]
public class AccountServerMigrationTests : IDisposable
{
private readonly List<Account> _accountsToCleanUp = [];
@@ -5,6 +5,7 @@ using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Unit.Credentials;
[Collection(nameof(RequiresSqLiteAccountDb))]
public class CredentialInfrastructure : IDisposable
{
private readonly IAccountManager _accountManager;
@@ -5,12 +5,12 @@ using Speckle.Sdk.Models;
namespace Speckle.Sdk.Tests.Unit.Models;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class BaseTests
{
public BaseTests()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(BaseTests).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(BaseTests).Assembly);
}
[Fact]
@@ -5,12 +5,12 @@ using Speckle.Sdk.Models;
namespace Speckle.Sdk.Tests.Unit.Models;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class DynamicBaseTests
{
public DynamicBaseTests()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(BaseTests).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(BaseTests).Assembly);
}
[Fact]
@@ -6,12 +6,12 @@ using Speckle.Sdk.Models.Extensions;
namespace Speckle.Sdk.Tests.Unit.Models.Extensions;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class BaseExtensionsTests
{
public BaseExtensionsTests()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(TestBase).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(TestBase).Assembly);
}
[Theory]
@@ -5,6 +5,7 @@ using Speckle.Sdk.Models.Extensions;
namespace Speckle.Sdk.Tests.Unit.Models.Extensions;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class DisplayValueTests
{
private const string PAYLOAD = "This is my payload";
@@ -17,8 +18,7 @@ public class DisplayValueTests
private static void Reset()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly);
}
[Fact]
@@ -6,12 +6,12 @@ using Speckle.Sdk.Models.GraphTraversal;
namespace Speckle.Sdk.Tests.Unit.Models.GraphTraversal;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class GraphTraversalTests
{
public GraphTraversalTests()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(TraversalMock).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(TraversalMock).Assembly);
}
private static IEnumerable<TraversalContext> Traverse(Base testCase, params ITraversalRule[] rules)
@@ -6,13 +6,12 @@ using Speckle.Sdk.Tests.Unit.Host;
namespace Speckle.Sdk.Tests.Unit.Models;
// Removed [TestFixture] and [TestOf] annotations as they are NUnit specific
[Collection(nameof(RequiresTypeLoaderCollection))]
public class Hashing
{
public Hashing()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(DiningTable).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(DiningTable).Assembly);
}
[Fact(DisplayName = "Checks that hashing (as represented by object IDs) actually works.")]
@@ -5,13 +5,13 @@ using Speckle.Sdk.Tests.Unit.Models.TestModels;
namespace Speckle.Sdk.Tests.Unit.Models
{
[Collection(nameof(RequiresTypeLoaderCollection))]
public class SpeckleTypeTests
{
public SpeckleTypeTests()
{
// Setup logic during test class initialization
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(Foo).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(Foo).Assembly);
}
[Theory]
@@ -7,13 +7,13 @@ using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Unit.Serialisation;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class ChunkingTests
{
public static IEnumerable<object[]> TestCases()
{
// Initialize type loader
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(IgnoreTest).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(IgnoreTest).Assembly);
// Return test data as a collection of objects for xUnit
yield return [CreateDynamicTestCase(10, 100), 10];
@@ -12,12 +12,12 @@ namespace Speckle.Sdk.Tests.Unit.Serialisation;
/// Tests that the <see cref="JsonIgnoreAttribute"/> leads to properties being ignored both from the final JSON output,
/// But also from the id calculation
/// </summary>
[Collection(nameof(RequiresTypeLoaderCollection))]
public sealed class JsonIgnoreRespected
{
public JsonIgnoreRespected()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(IgnoreTest).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(IgnoreTest).Assembly);
}
public static IEnumerable<object[]> IgnoredTestCases()
@@ -5,13 +5,13 @@ using Speckle.Sdk.Serialisation.Deprecated;
namespace Speckle.Sdk.Tests.Unit.Serialisation
{
[Collection(nameof(RequiresTypeLoaderCollection))]
public class TypeLoaderTests
{
// Constructor replaces the [SetUp] functionality in NUnit
public TypeLoaderTests()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(MySpeckleBase).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(MySpeckleBase).Assembly);
}
[Fact] // Replaces [Test]
@@ -14,6 +14,7 @@ namespace Speckle.Sdk.Tests.Unit.Serialisation;
/// This doesn't guarantee things work this way for SpecklePy
/// Nor does it encompass other tricks (like deserialize callback, or computed json ignored properties)
/// </summary>
[Collection(nameof(RequiresTypeLoaderCollection))]
public class SerializerBreakingChanges : PrimitiveTestFixture
{
private readonly IOperations _operations;
@@ -21,8 +22,7 @@ public class SerializerBreakingChanges : PrimitiveTestFixture
// xUnit does not support a Setup method; instead, you can use the constructor for initialization.
public SerializerBreakingChanges()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(Base).Assembly, typeof(Point).Assembly);
TypeLoader.ReInitialize(typeof(Base).Assembly, typeof(Point).Assembly);
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}
@@ -9,14 +9,14 @@ using Matrix4x4 = Speckle.DoubleNumerics.Matrix4x4;
namespace Speckle.Sdk.Tests.Unit.Serialisation;
[Collection(nameof(RequiresTypeLoaderCollection))]
public class SerializerNonBreakingChanges : PrimitiveTestFixture
{
private readonly IOperations _operations;
public SerializerNonBreakingChanges()
{
TypeLoader.Reset();
TypeLoader.Initialize(typeof(StringValueMock).Assembly);
TypeLoader.ReInitialize(typeof(StringValueMock).Assembly);
var serviceProvider = TestServiceSetup.GetServiceProvider();
_operations = serviceProvider.GetRequiredService<IOperations>();
}

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