Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb5d1e73a | |||
| e01360ad03 | |||
| 2494b160e8 | |||
| b0da4510bf | |||
| 96392d0d2f | |||
| 39f5257f85 | |||
| 2ea6348689 | |||
| 42c26e38bf | |||
| cb31fd1a08 | |||
| 67236abafe | |||
| 59a4f8f864 | |||
| 0e98e1cccd | |||
| 79c6f02544 | |||
| 07713b41e1 | |||
| c3f944dcf1 | |||
| 8890f8cb36 | |||
| a0eae88479 | |||
| 8785e49f73 | |||
| 6e35d6af6d | |||
| b67eb8d8af | |||
| 63f06c8541 | |||
| 3f49bb05d1 | |||
| 9a879fd1ac | |||
| c3230d5d91 | |||
| f1a64590d7 | |||
| c2735f0a32 | |||
| 0b01091209 | |||
| 98223e251c | |||
| 08f702794a | |||
| 879ebf7e3c | |||
| 1046e2aafc | |||
| 5cb0eddf4e | |||
| e97ce83c6b | |||
| ea23e72c77 | |||
| 37358570ec | |||
| 02b9a73164 | |||
| 530387b87c | |||
| b16e32d1ff | |||
| d91fccbb10 | |||
| 62f687b589 | |||
| c0d4d951df | |||
| 4e1753e1fd | |||
| 4c615ae8c6 | |||
| d5ee9fb76c | |||
| ea118bcdbb | |||
| ba456ee3eb | |||
| 10d283a9f7 | |||
| 647c4733cb | |||
| 30c9b17dab | |||
| f7ddc19086 | |||
| 6d06901b4f | |||
| 4b588fc287 | |||
| 786e683d89 | |||
| 8d999f4f9c | |||
| bb7542e254 | |||
| d6f6254a92 | |||
| f60f85b639 | |||
| bcdf73cc70 |
@@ -3,18 +3,11 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"csharpier": {
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.2",
|
||||
"commands": [
|
||||
"csharpier"
|
||||
],
|
||||
"rollForward": false
|
||||
},
|
||||
"gitversion.tool": {
|
||||
"version": "6.1.0",
|
||||
"commands": [
|
||||
"dotnet-gitversion"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
docker-compose-file:
|
||||
required: true
|
||||
type: string
|
||||
use-github-container-registry:
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
integration-test:
|
||||
env:
|
||||
Solution: "Speckle.Sdk.sln"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 8.x.x
|
||||
cache: true
|
||||
cache-dependency-path: "**/packages.lock.json"
|
||||
|
||||
- name: 🔐 Login to Github Container Registry
|
||||
if: ${{ inputs.use-github-container-registry }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: "ghcr.io"
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: ⚙️ Spin up Server
|
||||
run: docker compose -f ${{ inputs.docker-compose-file }} up --wait
|
||||
|
||||
- name: 📦 Restore
|
||||
run: dotnet restore ${{ env.Solution }} --locked-mode
|
||||
|
||||
- name: 🏗️ Build
|
||||
run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror
|
||||
|
||||
- name: 🔨 Integration Tests
|
||||
run: dotnet test ${{ env.Solution }} --filter "Category=Integration" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
|
||||
|
||||
- name: Upload coverage reports to Codecov with GitHub Action
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: tests/**/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
+45
-22
@@ -1,32 +1,55 @@
|
||||
name: .NET CI Build
|
||||
name: PR Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
env:
|
||||
Solution: "Speckle.Sdk.sln"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.x.x
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 8.x.x
|
||||
cache: true
|
||||
cache-dependency-path: "**/packages.lock.json"
|
||||
|
||||
- name: 🔫 Build All
|
||||
run: ./build.sh
|
||||
|
||||
- name: Upload coverage reports to Codecov with GitHub Action
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: tests/**/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: 📦 Tool Restore
|
||||
run: dotnet tool restore
|
||||
|
||||
- name: 📄 Format
|
||||
run: dotnet csharpier check .
|
||||
|
||||
- name: 📦 Restore
|
||||
run: dotnet restore ${{ env.Solution }} --locked-mode
|
||||
|
||||
- name: 🏗️ Build
|
||||
run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror
|
||||
|
||||
- name: 🔨 Unit Tests
|
||||
run: dotnet test ${{ env.Solution }} --configuration Release --filter "Category!=Integration" --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
|
||||
|
||||
- name: 🎁 Pack
|
||||
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
|
||||
|
||||
- name: Upload coverage reports to Codecov with GitHub Action
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: tests/**/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
integration-test-internal:
|
||||
uses: "./.github/workflows/integration-test.yml"
|
||||
with:
|
||||
docker-compose-file: "docker-compose-internal.yml"
|
||||
use-github-container-registry: true
|
||||
|
||||
integration-test-public:
|
||||
uses: "./.github/workflows/integration-test.yml"
|
||||
with:
|
||||
docker-compose-file: "docker-compose.yml"
|
||||
|
||||
@@ -2,36 +2,61 @@ name: .NET Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "dev"]
|
||||
tags: ["3.*"]
|
||||
tags: ["3.*.*"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: "nuget.org"
|
||||
permissions:
|
||||
id-token: write # enable GitHub OIDC token issuance for this job
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 8.x.x
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
|
||||
|
||||
cache: true
|
||||
cache-dependency-path: "**/packages.lock.json"
|
||||
|
||||
- id: set-version
|
||||
name: Set version to output
|
||||
run: |
|
||||
TAG=${{ github.ref_name }}
|
||||
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
|
||||
TAG="3.0.99.${{ github.run_number }}"
|
||||
fi
|
||||
SEMVER="${TAG}"
|
||||
FILE_VERSION=$(echo "$TAG" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
|
||||
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
|
||||
|
||||
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
|
||||
echo "fileVersion=$FILE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo $SEMVER
|
||||
echo $FILE_VERSION
|
||||
|
||||
- name: 🔫 Build and Pack
|
||||
run: ./build.sh pack
|
||||
|
||||
env:
|
||||
SEMVER: ${{ steps.set-version.outputs.SEMVER }}
|
||||
FILE_VERSION: ${{ steps.set-version.outputs.FILE_VERSION }}
|
||||
|
||||
- name: Upload coverage reports to Codecov with GitHub Action
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: tests/**/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: NuGet login (OIDC → temp API key)
|
||||
uses: NuGet/login@v1
|
||||
id: login
|
||||
with:
|
||||
user: ${{ secrets.NUGET_USER }}
|
||||
|
||||
- name: Push to nuget.org
|
||||
run: dotnet nuget push output/*.nupkg --source "https://api.nuget.org/v3/index.json" --api-key ${{secrets.CONNECTORS_NUGET_TOKEN }} --skip-duplicate
|
||||
run: dotnet nuget push output/*.nupkg --source "https://api.nuget.org/v3/index.json" --api-key ${{steps.login.outputs.NUGET_API_KEY}}
|
||||
|
||||
@@ -15,6 +15,7 @@ tests/TestArchives/Scratch
|
||||
tools
|
||||
.vscode
|
||||
.idea/
|
||||
.volumes/
|
||||
|
||||
.DS_Store
|
||||
*.snupkg
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
|
||||
<PropertyGroup Condition="'$(IsTestProject)' == 'true' or '$(TestProjectAnalyserRules)' == 'true' ">
|
||||
<NoWarn>
|
||||
<!-- Things we need to test -->
|
||||
CS0618;CA1034;CA2201;CA1051;CA1040;CA1724;
|
||||
CS0618;CA1034;CA2201;CA1051;CA1040;CA1724;CA1065;
|
||||
IDE0044;IDE0130;CA1508;
|
||||
<!-- Analysers that provide no tangeable value to a test project -->
|
||||
CA5394;CA2007;CA1852;CA1819;CA1711;CA1063;CA1816;CA2234;CS8618;CA1054;CA1810;CA2208;CA1019;CA1831;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
workflow: GitFlow/v1
|
||||
next-version: 3.0.0
|
||||
branches:
|
||||
main:
|
||||
prevent-increment:
|
||||
when-current-commit-tagged: true
|
||||
@@ -9,9 +9,12 @@ Speckle | Sharp | SDK
|
||||
### .NET SDK, Tests, and Objects
|
||||
|
||||
[](https://codecov.io/gh/specklesystems/speckle-sharp-sdk)
|
||||
<a href="https://www.nuget.org/packages/Speckle.Sdk/"><img alt="NuGet Version" src="https://img.shields.io/nuget/v/Speckle.Sdk?label=Speckle.Sdk"></a>
|
||||
<a href="https://www.nuget.org/packages/Speckle.Objects/"><img alt="NuGet Version" src="https://img.shields.io/nuget/v/Speckle.Sdk?label=Speckle.Objects"></a>
|
||||
<a href="https://www.nuget.org/packages/Speckle.Automate.Sdk/"><img alt="NuGet Version" src="https://img.shields.io/nuget/v/Speckle.Sdk?label=Speckle.Automate.Sdk"></a>
|
||||
|
||||
> [!WARNING]
|
||||
> This is an early beta release, not meant for use in production! We're working to stabilise the 3.0 API, and until then there will be breaking changes. You have been warned!
|
||||
> Releases Speckle.Sdk and Speckle.Objects are reliable for production use, but the APIs may not be wholly stable, and there may be breaking changes between releases, with little documentation.
|
||||
|
||||
# Repo structure
|
||||
|
||||
@@ -28,11 +31,13 @@ This repo is the home of our next-generation Speckle .NET SDK. It uses .NET Stan
|
||||
|
||||
### Other repos
|
||||
|
||||
Make sure to also check and ⭐️ these other Speckle next generation repositories:
|
||||
Make sure to also check and ⭐️ these other repositories:
|
||||
|
||||
- [`speckle-sharp-connectors`](https://github.com/specklesystems/speckle-sharp-connectors): our csharp repo of next gen connectors
|
||||
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-sketchup): Sketchup connector
|
||||
- [`speckle-powerbi`](https://github.com/specklesystems/speckle-powerbi): PowerBi connector
|
||||
- [`speckle-sharp-connectors`](https://github.com/specklesystems/speckle-sharp-connectors): our csharp repo of next gen connectors.
|
||||
- [`speckle-server`](https://github.com/specklesystems/speckle-server): the speckle server.
|
||||
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-blender): Blender connector.
|
||||
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-sketchup): Sketchup connector.
|
||||
- [`speckle-powerbi`](https://github.com/specklesystems/speckle-powerbi): PowerBi connector.
|
||||
- and more [connectors & tooling](https://github.com/specklesystems/)!
|
||||
|
||||
## Documentation
|
||||
@@ -45,19 +50,27 @@ Comprehensive developer and user documentation can be found in our:
|
||||
|
||||
### Building
|
||||
|
||||
Make sure you clone this repository together with its submodules: `git clone https://github.com/specklesystems/speckle-sharp-sdk.git -recursive`.
|
||||
Afterwards, just restore all the NuGet packages and hit Build!
|
||||
Ensure you're using a [8.0.4xx](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) .NET SDK.
|
||||
After cloning this repository, just restore all the NuGet packages and hit Build!
|
||||
|
||||
### Developing
|
||||
|
||||
This project is evolving fast, to better understand how to use Core we suggest checking out the Unit and Integration tests. Running the integration tests locally requires a local server running on your computer.
|
||||
It is highly recommended you use
|
||||
- Either Jetbrains Rider or Visual Studio 2022
|
||||
- Ensure your IDE is set to use [the correct .NET SDK version](https://github.com/specklesystems/speckle-sharp-sdk/blob/main/global.json) (newer major versions may work, but may incorrectly run analysers we haven't configured)
|
||||
- You should install the cshapier plugin ([Rider](https://plugins.jetbrains.com/plugin/18243-csharpier), [VS](https://marketplace.visualstudio.com/items?itemName=csharpier.CSharpier)) and configure it to run on save
|
||||
|
||||
We'll be also adding [preliminary documentation on our forum](https://discourse.speckle.works/c/speckle-insider/10).
|
||||
Docs are a bit patchy [https://docs.speckle.systems/developers/looking-for-developer-docs](https://docs.speckle.systems/developers/looking-for-developer-docs)
|
||||
|
||||
### Tests
|
||||
|
||||
There are two test projects, one for unit tests and one for integration tests. The latter needs a server running locally in order to run.
|
||||
There are several test projects. It is a requirement that all tests pass for PRs to be merged.
|
||||
|
||||
The Integration test projects require a local server to be running.
|
||||
You must have docker installed. Then you can run `docker compose up` from the root of the repo to start the required containers.
|
||||
|
||||
In CI, they will be run against both the public and private versions of the server.
|
||||
It is important that we remain compatible with both server versions.
|
||||
## Contributing
|
||||
|
||||
Before embarking on submitting a patch, please make sure you read:
|
||||
|
||||
+2
-1
@@ -23,11 +23,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{DA2AED
|
||||
Directory.Packages.props = Directory.Packages.props
|
||||
global.json = global.json
|
||||
README.md = README.md
|
||||
GitVersion.yml = GitVersion.yml
|
||||
docker-compose.yml = docker-compose.yml
|
||||
CodeMetricsConfig.txt = CodeMetricsConfig.txt
|
||||
Directory.Build.Targets = Directory.Build.Targets
|
||||
.config\dotnet-tools.json = .config\dotnet-tools.json
|
||||
docker-compose-internal.yml = docker-compose-internal.yml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{58D37DA9-F948-48CA-9A73-F5BBBD533DBF}"
|
||||
@@ -42,6 +42,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.github\workflows\pr.yml = .github\workflows\pr.yml
|
||||
.github\workflows\release.yml = .github\workflows\release.yml
|
||||
.github\workflows\integration-test.yml = .github\workflows\integration-test.yml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Sdk.Tests.Performance", "tests\Speckle.Sdk.Tests.Performance\Speckle.Sdk.Tests.Performance.csproj", "{870E3396-E6F7-43AE-B120-E651FA4F46BD}"
|
||||
|
||||
+1
-2
@@ -11,7 +11,6 @@
|
||||
<File Path="Directory.Build.Targets" />
|
||||
<File Path="Directory.Packages.props" />
|
||||
<File Path="docker-compose.yml" />
|
||||
<File Path="GitVersion.yml" />
|
||||
<File Path="global.json" />
|
||||
<File Path="README.md" />
|
||||
<File Path=".github\copilot-instructions.md" />
|
||||
@@ -43,4 +42,4 @@
|
||||
<Project Path="tests/Speckle.Sdk.Serialization.Tests/Speckle.Sdk.Serialization.Tests.csproj" />
|
||||
<Project Path="tests/Speckle.Sdk.Tests.Unit/Speckle.Sdk.Tests.Unit.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
</Solution>
|
||||
|
||||
+8
-10
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using GlobExpressions;
|
||||
using static Bullseye.Targets;
|
||||
using static SimpleExec.Command;
|
||||
@@ -16,14 +15,13 @@ const string CLEAN_LOCKS = "clean-locks";
|
||||
const string PERF = "perf";
|
||||
const string DEEP_CLEAN = "deep-clean";
|
||||
|
||||
static async Task<(string, string)> GetVersions()
|
||||
static (string semver, string fileVerison) GetVersions()
|
||||
{
|
||||
var (output, _) = await ReadAsync("dotnet", "dotnet-gitversion /output json").ConfigureAwait(false);
|
||||
output = output.Trim();
|
||||
var jDoc = JsonDocument.Parse(output);
|
||||
var version = jDoc.RootElement.GetProperty("FullSemVer").GetString() ?? "3.0.0-localBuild";
|
||||
var fileVersion = jDoc.RootElement.GetProperty("AssemblySemFileVer").GetString() ?? "3.0.0.0";
|
||||
return (version, fileVersion);
|
||||
string semver =
|
||||
Environment.GetEnvironmentVariable("SEMVER") ?? throw new ArgumentException("Expected SEMVER env var");
|
||||
string fileVersion =
|
||||
Environment.GetEnvironmentVariable("FILE_VERSION") ?? throw new ArgumentException("Expected FILE_VERSION env var");
|
||||
return (semver, fileVersion);
|
||||
}
|
||||
|
||||
Target(
|
||||
@@ -77,7 +75,7 @@ Target(
|
||||
dependsOn: [RESTORE],
|
||||
async () =>
|
||||
{
|
||||
var (version, fileVersion) = await GetVersions().ConfigureAwait(false);
|
||||
var (version, fileVersion) = GetVersions();
|
||||
Console.WriteLine($"Version: {version} & {fileVersion}");
|
||||
await RunAsync(
|
||||
"dotnet",
|
||||
@@ -174,7 +172,7 @@ Target(
|
||||
async () =>
|
||||
{
|
||||
{
|
||||
var (version, fileVersion) = await GetVersions().ConfigureAwait(false);
|
||||
var (version, fileVersion) = GetVersions();
|
||||
Console.WriteLine($"Version: {version} & {fileVersion}");
|
||||
await RunAsync("dotnet", $"pack Speckle.Sdk.sln -c Release -o output --no-build -p:Version={version}")
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
name: "speckle-server"
|
||||
|
||||
services:
|
||||
####
|
||||
# Speckle Server dependencies
|
||||
#######
|
||||
postgres:
|
||||
image: "postgres:16.4-alpine3.20@sha256:d898b0b78a2627cb4ee63464a14efc9d296884f1b28c841b0ab7d7c42f1fffdf"
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: speckle
|
||||
POSTGRES_USER: speckle
|
||||
POSTGRES_PASSWORD: speckle
|
||||
volumes:
|
||||
- ./.volumes/postgres-data:/var/lib/postgresql/data/
|
||||
healthcheck:
|
||||
# the -U user has to match the POSTGRES_USER value
|
||||
test: ["CMD-SHELL", "pg_isready -U speckle"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
redis:
|
||||
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
|
||||
restart: always
|
||||
volumes:
|
||||
- ./.volumes/redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
minio:
|
||||
image: "minio/minio:RELEASE.2023-10-25T06-33-25Z"
|
||||
command: server /data --console-address ":9001"
|
||||
restart: always
|
||||
volumes:
|
||||
- ./.volumes/minio-data:/data
|
||||
ports:
|
||||
- '127.0.0.1:9000:9000'
|
||||
- '127.0.0.1:9001:9001'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -s -o /dev/null http://127.0.0.1:9000/minio/index.html",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 30s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
speckle-server:
|
||||
image: ghcr.io/specklesystems/speckle-server:latest
|
||||
restart: always
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- /nodejs/bin/node
|
||||
- -e
|
||||
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(Number(res.statusCode != 200 || body.toLowerCase().includes('error')));}); }).end(); } catch { process.exit(1); }"
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
ports:
|
||||
- "0.0.0.0:3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# TODO: Change this to the URL of the speckle server, as accessed from the network
|
||||
CANONICAL_URL: "http://127.0.0.1:8080"
|
||||
SPECKLE_AUTOMATE_URL: "http://127.0.0.1:3030"
|
||||
FRONTEND_ORIGIN: "http://127.0.0.1:8081"
|
||||
|
||||
# TODO: Change thvolumes:
|
||||
REDIS_URL: "redis://redis"
|
||||
|
||||
S3_ENDPOINT: "http://minio:9000"
|
||||
S3_PUBLIC_ENDPOINT: "http://127.0.0.1:9000"
|
||||
S3_ACCESS_KEY: "minioadmin"
|
||||
S3_SECRET_KEY: "minioadmin"
|
||||
S3_BUCKET: "speckle-server"
|
||||
S3_CREATE_BUCKET: "true"
|
||||
|
||||
FILE_SIZE_LIMIT_MB: 100
|
||||
MAX_PROJECT_MODELS_PER_PAGE: 500
|
||||
|
||||
# TODO: Change this to a unique secret for this server
|
||||
SESSION_SECRET: "TODO:ReplaceWithLongString"
|
||||
|
||||
STRATEGY_LOCAL: "true"
|
||||
|
||||
POSTGRES_URL: "postgres"
|
||||
POSTGRES_USER: "speckle"
|
||||
POSTGRES_PASSWORD: "speckle"
|
||||
POSTGRES_DB: "speckle"
|
||||
ENABLE_MP: "false"
|
||||
|
||||
LOG_PRETTY: "true"
|
||||
|
||||
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
|
||||
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: speckle-server
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
minio-data:
|
||||
+14
-9
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
name: "speckle-server"
|
||||
|
||||
services:
|
||||
@@ -13,7 +12,7 @@ services:
|
||||
POSTGRES_USER: speckle
|
||||
POSTGRES_PASSWORD: speckle
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data/
|
||||
- ./.volumes/postgres-data:/var/lib/postgresql/data/
|
||||
healthcheck:
|
||||
# the -U user has to match the POSTGRES_USER value
|
||||
test: ["CMD-SHELL", "pg_isready -U speckle"]
|
||||
@@ -22,10 +21,10 @@ services:
|
||||
retries: 30
|
||||
|
||||
redis:
|
||||
image: "redis:6.0-alpine"
|
||||
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
|
||||
restart: always
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
- ./.volumes/redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 5s
|
||||
@@ -37,7 +36,10 @@ services:
|
||||
command: server /data --console-address ":9001"
|
||||
restart: always
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
- ./.volumes/minio-data:/data
|
||||
ports:
|
||||
- '127.0.0.1:9000:9000'
|
||||
- '127.0.0.1:9001:9001'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -57,7 +59,7 @@ services:
|
||||
- CMD
|
||||
- /nodejs/bin/node
|
||||
- -e
|
||||
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }"
|
||||
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(Number(res.statusCode != 200 || body.toLowerCase().includes('error')));}); }).end(); } catch { process.exit(1); }"
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -81,6 +83,7 @@ services:
|
||||
REDIS_URL: "redis://redis"
|
||||
|
||||
S3_ENDPOINT: "http://minio:9000"
|
||||
S3_PUBLIC_ENDPOINT: "http://127.0.0.1:9000"
|
||||
S3_ACCESS_KEY: "minioadmin"
|
||||
S3_SECRET_KEY: "minioadmin"
|
||||
S3_BUCKET: "speckle-server"
|
||||
@@ -93,16 +96,18 @@ services:
|
||||
SESSION_SECRET: "TODO:ReplaceWithLongString"
|
||||
|
||||
STRATEGY_LOCAL: "true"
|
||||
DEBUG: "speckle:*"
|
||||
|
||||
POSTGRES_URL: "postgres"
|
||||
POSTGRES_USER: "speckle"
|
||||
POSTGRES_PASSWORD: "speckle"
|
||||
POSTGRES_DB: "speckle"
|
||||
ENABLE_MP: "false"
|
||||
|
||||
|
||||
LOG_PRETTY: "true"
|
||||
|
||||
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
|
||||
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: speckle-server
|
||||
@@ -110,4 +115,4 @@ networks:
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
minio-data:
|
||||
minio-data:
|
||||
|
||||
@@ -63,7 +63,7 @@ internal sealed class AutomationContext(IOperations operations) : IAutomationCon
|
||||
);
|
||||
}
|
||||
|
||||
Base? rootObject = await operations
|
||||
Base rootObject = await operations
|
||||
.Receive2(
|
||||
SpeckleClient.ServerUrl,
|
||||
AutomationRunData.ProjectId,
|
||||
@@ -74,6 +74,10 @@ internal sealed class AutomationContext(IOperations operations) : IAutomationCon
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await SpeckleClient
|
||||
.Version.Received(new(version.id, AutomationRunData.ProjectId, "automate_function"), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"It took {Elapsed.TotalSeconds} seconds to receive the speckle version {versionId}");
|
||||
return rootObject;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using Speckle.Sdk.Models;
|
||||
|
||||
namespace Speckle.Objects.Geometry;
|
||||
|
||||
[SpeckleType("Objects.Geometry.SolidX")]
|
||||
public class SolidX : RawEncodedObject;
|
||||
@@ -2,6 +2,7 @@ using Speckle.Objects.Geometry;
|
||||
using Speckle.Objects.Other;
|
||||
using Speckle.Objects.Primitive;
|
||||
using Speckle.Sdk.Models;
|
||||
using Speckle.Sdk.Models.Data;
|
||||
|
||||
namespace Speckle.Objects;
|
||||
|
||||
@@ -110,15 +111,7 @@ public interface IDisplayValue<out T> : ISpeckleObject
|
||||
|
||||
#region Data objects
|
||||
|
||||
/// <summary>
|
||||
/// Specifies properties on objects to be used for data-based workflows
|
||||
/// </summary>
|
||||
public interface IProperties : ISpeckleObject
|
||||
{
|
||||
Dictionary<string, object?> properties { get; }
|
||||
}
|
||||
|
||||
public interface IDataObject : IProperties, IDisplayValue<IReadOnlyList<Base>>
|
||||
public interface IDataObject : IProperties, IDisplayValue<IReadOnlyList<Base>>, ISpeckleObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the object, primarily used to decorate the object for consumption in frontend and other apps
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Speckle.Objects.Geometry;
|
||||
using Speckle.Sdk.Models;
|
||||
|
||||
namespace Speckle.Objects.Other;
|
||||
|
||||
/// <summary>
|
||||
/// Camera class to represent a perspective camera for a 3D view.
|
||||
/// </summary>
|
||||
/// <remarks>Assumes a Z-up, right-handed convention for orientation vectors</remarks>
|
||||
[SpeckleType("Objects.Other.Camera")]
|
||||
public class Camera : Base
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the view that is created by this camera
|
||||
/// </summary>
|
||||
public required string name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The location of the camera
|
||||
/// </summary>
|
||||
public required Point position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The unit up vector of the camera
|
||||
/// </summary>
|
||||
public required Vector up { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The unit forward vector of the camera
|
||||
/// </summary>
|
||||
public required Vector forward { get; set; }
|
||||
}
|
||||
@@ -20,4 +20,5 @@ public class RawEncoding : Base // note: at this stage, since we're using this f
|
||||
public static class RawEncodingFormats
|
||||
{
|
||||
public const string RHINO_3DM = "3dm";
|
||||
public const string ACAD_DWG = "dwg";
|
||||
}
|
||||
|
||||
@@ -35,6 +35,6 @@ public class RenderMaterial : Base
|
||||
public Color emissiveColor
|
||||
{
|
||||
get => Color.FromArgb(emissive);
|
||||
set => diffuse = value.ToArgb();
|
||||
set => emissive = value.ToArgb();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Speckle.InterfaceGenerator;
|
||||
using Speckle.Newtonsoft.Json;
|
||||
using Speckle.Sdk.Api.GraphQL.Resources;
|
||||
using Speckle.Sdk.Common;
|
||||
using Speckle.Sdk.Credentials;
|
||||
using Speckle.Sdk.Helpers;
|
||||
using Speckle.Sdk.Logging;
|
||||
using Speckle.Sdk.Transports;
|
||||
using Speckle.Sdk.Transports.ServerUtils;
|
||||
|
||||
namespace Speckle.Sdk.Api.Blob;
|
||||
|
||||
public partial interface IBlobApi : IDisposable;
|
||||
|
||||
/// <summary>
|
||||
/// Low level access to the blob API
|
||||
/// </summary>
|
||||
/// <seealso cref="FileImportResource"/>
|
||||
/// <seealso cref="ServerApi"/>
|
||||
[GenerateAutoInterface]
|
||||
public sealed class BlobApi : IBlobApi
|
||||
{
|
||||
public const int DEFAULT_TIMEOUT_SECONDS = SpeckleHttp.DEFAULT_TIMEOUT_SECONDS;
|
||||
private static readonly string[] s_filenameSeparator = ["filename="];
|
||||
|
||||
private readonly ISdkActivityFactory _activityFactory;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for communicating with Speckle Server with auth token header
|
||||
/// </summary>
|
||||
private readonly HttpClient _authedClient;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for communicating with pre-signed s3 url
|
||||
/// </summary>
|
||||
private readonly HttpClient _unauthedClient;
|
||||
|
||||
public BlobApi(
|
||||
ISpeckleHttp speckleHttp,
|
||||
ISdkActivityFactory activityFactory,
|
||||
Account account,
|
||||
int timeoutSeconds = DEFAULT_TIMEOUT_SECONDS
|
||||
)
|
||||
{
|
||||
_activityFactory = activityFactory;
|
||||
_authedClient = speckleHttp.CreateHttpClient(
|
||||
new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip },
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
authorizationToken: account.token
|
||||
);
|
||||
_authedClient.BaseAddress = new(account.serverInfo.url);
|
||||
|
||||
_unauthedClient = speckleHttp.CreateHttpClient(
|
||||
new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip },
|
||||
timeoutSeconds: timeoutSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private static string GetBlobDownloadPath(string blobId, HttpResponseMessage response)
|
||||
{
|
||||
response.Content.Headers.TryGetValues("Content-Disposition", out IEnumerable<string>? cdHeaderValues);
|
||||
var cdHeader = (cdHeaderValues?.FirstOrDefault()).NotNull(
|
||||
"Expected response from server to contain attachment header"
|
||||
);
|
||||
string fileName = cdHeader.Split(s_filenameSeparator, StringSplitOptions.None)[1].TrimStart('"').TrimEnd('"');
|
||||
return Path.Combine(
|
||||
SpecklePathProvider.BlobStoragePath(),
|
||||
$"{blobId[..Models.Blob.LocalHashPrefixLength]}-{fileName}"
|
||||
);
|
||||
}
|
||||
|
||||
/// <param name="blobId">The ID of the blob to download</param>
|
||||
/// <param name="progress"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <exception cref="HttpRequestException">Request for the blob fails</exception>
|
||||
/// <exception cref="OperationCanceledException"></exception>
|
||||
/// <returns>File Path of the downloaded file</returns>
|
||||
public async Task<string> DownloadBlob(
|
||||
string projectId,
|
||||
string blobId,
|
||||
string? pathOverride = null,
|
||||
IProgress<ProgressArgs>? progress = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using var _ = _activityFactory.Start();
|
||||
|
||||
var url = new Uri($"api/stream/{projectId}/blob/{blobId}", UriKind.Relative);
|
||||
|
||||
using var response = await _authedClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string fileLocation = pathOverride ?? GetBlobDownloadPath(blobId, response);
|
||||
using var source = new ProgressStream(
|
||||
#if NET5_0_OR_GREATER
|
||||
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
|
||||
#else
|
||||
await response.Content.ReadAsStreamAsync().ConfigureAwait(false),
|
||||
#endif
|
||||
response.Content.Headers.ContentLength,
|
||||
progress,
|
||||
true
|
||||
);
|
||||
|
||||
using var fs = new FileStream(fileLocation, FileMode.OpenOrCreate);
|
||||
#if NET5_0_OR_GREATER
|
||||
await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
await source.CopyToAsync(fs).ConfigureAwait(false);
|
||||
#endif
|
||||
return fileLocation;
|
||||
}
|
||||
|
||||
/// <summary>Queries the server for diff of the given <paramref name="blobIds"/></summary>
|
||||
/// <param name="blobIds"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>A list of blob ids that the server doesn't have</returns>
|
||||
/// <exception cref="HttpRequestException">Request for the blob fails</exception>
|
||||
/// <exception cref="OperationCanceledException"></exception>
|
||||
/// <exception cref="ArgumentNullException"></exception>
|
||||
public async Task<List<string>> HasBlobs(
|
||||
string projectId,
|
||||
IReadOnlyCollection<string> blobIds,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
using var _ = _activityFactory.Start();
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var payload = JsonConvert.SerializeObject(blobIds);
|
||||
|
||||
var url = new Uri($"/api/stream/{projectId}/blob/diff", UriKind.Relative);
|
||||
|
||||
using StringContent stringContent = new(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await _authedClient.PostAsync(url, stringContent, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
#if NET5_0_OR_GREATER
|
||||
var responseString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
var parsed = JsonConvert
|
||||
.DeserializeObject<List<string>>(responseString)
|
||||
.NotNull($"Failed to deserialize successful response {response.Content}");
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a single file to the given S3 url.
|
||||
/// This method should be used together with the <see cref="FileImportResource"/> <see cref="FileImportResource.GenerateUploadUrl"/> method,
|
||||
/// which generates a pre-signed S3 url, that can be used to upload the file to.
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="url"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>etag header</returns>
|
||||
/// <seealso cref="FileImportResource"/>
|
||||
/// <exception cref="HttpRequestException"></exception>
|
||||
/// <exception cref="ArgumentException">Unexpected response header the server</exception>
|
||||
/// <exception cref="FileNotFoundException"><paramref name="filePath"/> does not point to a file</exception>
|
||||
/// <exception cref="OperationCanceledException"></exception>
|
||||
public async Task<string> UploadFile(
|
||||
string filePath,
|
||||
Uri url,
|
||||
IProgress<ProgressArgs>? progress = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using var _ = _activityFactory.Start();
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("File not found.", filePath);
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
using var requestMessage = new HttpRequestMessage(HttpMethod.Put, url);
|
||||
requestMessage.Content = progress is null
|
||||
? new StreamContent(fileStream)
|
||||
: new ProgressContent(new StreamContent(fileStream), progress);
|
||||
|
||||
requestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
requestMessage.Content.Headers.ContentLength = fileInfo.Length;
|
||||
|
||||
using var response = await _unauthedClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return ParseEtagHeader(response.Headers);
|
||||
}
|
||||
|
||||
private static string ParseEtagHeader(HttpResponseHeaders headers)
|
||||
{
|
||||
if (!headers.TryGetValues("ETag", out var etagValues))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Response does not have an ETag attached to it, cannot use this as an upload",
|
||||
nameof(headers)
|
||||
);
|
||||
}
|
||||
|
||||
var etagValuesArray = etagValues.ToArray();
|
||||
|
||||
if (etagValuesArray.Length != 1)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected Etag header to have a single value but got {etagValuesArray.Length}",
|
||||
nameof(headers)
|
||||
);
|
||||
}
|
||||
|
||||
return etagValuesArray[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads blobs via the <c>"/api/stream/:streamId/blob"</c> endpoint
|
||||
/// </summary>
|
||||
/// <param name="projectId"></param>
|
||||
/// <param name="blobPaths"></param>
|
||||
/// <param name="progress"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
public async Task UploadBlobs(
|
||||
string projectId,
|
||||
IReadOnlyCollection<(string id, string filePath)> blobPaths,
|
||||
IProgress<ProgressArgs>? progress,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
using var _ = _activityFactory.Start();
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (blobPaths.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var multipartFormDataContent = new MultipartFormDataContent();
|
||||
foreach (var (id, filePath) in blobPaths)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
var stream = File.OpenRead(filePath);
|
||||
var fsc = new StreamContent(stream);
|
||||
multipartFormDataContent.Add(fsc, $"hash:{id}", fileName);
|
||||
}
|
||||
|
||||
using HttpContent content = progress is null
|
||||
? multipartFormDataContent
|
||||
: new ProgressContent(multipartFormDataContent, progress);
|
||||
|
||||
var url = new Uri($"/api/stream/{projectId}/blob", UriKind.Relative);
|
||||
|
||||
using var response = await _authedClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[AutoInterfaceIgnore]
|
||||
public void Dispose()
|
||||
{
|
||||
_authedClient.Dispose();
|
||||
_unauthedClient.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Speckle.InterfaceGenerator;
|
||||
using Speckle.Sdk.Credentials;
|
||||
using Speckle.Sdk.Helpers;
|
||||
using Speckle.Sdk.Logging;
|
||||
|
||||
namespace Speckle.Sdk.Api.Blob;
|
||||
|
||||
[GenerateAutoInterface]
|
||||
public sealed class BlobApiFactory(ISpeckleHttp speckleHttp, ISdkActivityFactory activityFactory) : IBlobApiFactory
|
||||
{
|
||||
public IBlobApi Create(Account account, int timeoutSeconds = BlobApi.DEFAULT_TIMEOUT_SECONDS) =>
|
||||
new BlobApi(speckleHttp, activityFactory, account, timeoutSeconds);
|
||||
}
|
||||
@@ -23,10 +23,15 @@ public class SpeckleGraphQLException : SpeckleException
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a "FORBIDDEN" or "UNAUTHORIZED" GraphQL error as an exception.
|
||||
/// Represents a "FORBIDDEN" or "UNAUTHENTICATED" or "UNAUTHORIZED" or "UNAUTHORIZED_ACCESS_ERROR" GraphQL error as an exception.
|
||||
/// https://www.apollographql.com/docs/apollo-server/v2/data/errors/#unauthenticated
|
||||
/// https://www.apollographql.com/docs/apollo-server/v2/data/errors/#forbidden
|
||||
/// https://github.com/specklesystems/speckle-server/blob/v2.23.18/packages/server/modules/shared/errors/index.ts#L34
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Server is a bit inconsistent with these error codes, hence there's 4 different codes that mean "auth no work"
|
||||
/// Apollo no longer considers "FORBIDDEN" or "UNAUTHENTICATED" as built in error codes, so everything is custom anyway.
|
||||
/// </remarks>
|
||||
public sealed class SpeckleGraphQLForbiddenException : SpeckleGraphQLException
|
||||
{
|
||||
public SpeckleGraphQLForbiddenException() { }
|
||||
|
||||
@@ -4,6 +4,7 @@ using GraphQL.Client.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Speckle.InterfaceGenerator;
|
||||
using Speckle.Newtonsoft.Json;
|
||||
using Speckle.Sdk.Api.Blob;
|
||||
using Speckle.Sdk.Api.GraphQL;
|
||||
using Speckle.Sdk.Api.GraphQL.Resources;
|
||||
using Speckle.Sdk.Credentials;
|
||||
@@ -33,6 +34,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
|
||||
public SubscriptionResource Subscription { get; }
|
||||
public WorkspaceResource Workspace { get; }
|
||||
public ServerResource Server { get; }
|
||||
public FileImportResource FileImport { get; }
|
||||
|
||||
public Uri ServerUrl => new(Account.serverInfo.url);
|
||||
|
||||
@@ -48,12 +50,15 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
|
||||
ILogger<Client> logger,
|
||||
ISdkActivityFactory activityFactory,
|
||||
IGraphQLClientFactory graphqlClientFactory,
|
||||
Account account
|
||||
IBlobApiFactory blobApiFactory,
|
||||
[NotNull] Account? account
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_activityFactory = activityFactory;
|
||||
|
||||
Account = account ?? throw new ArgumentException("Provided account is null.");
|
||||
GQLClient = graphqlClientFactory.CreateGraphQLClient(account);
|
||||
|
||||
Project = new(this);
|
||||
Model = new(this);
|
||||
@@ -65,8 +70,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
|
||||
Subscription = new(this);
|
||||
Workspace = new(this);
|
||||
Server = new(this);
|
||||
|
||||
GQLClient = graphqlClientFactory.CreateGraphQLClient(account);
|
||||
FileImport = new(this, blobApiFactory.Create(account));
|
||||
}
|
||||
|
||||
[AutoInterfaceIgnore]
|
||||
@@ -74,6 +78,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
|
||||
{
|
||||
try
|
||||
{
|
||||
FileImport.Dispose();
|
||||
Subscription.Dispose();
|
||||
GQLClient.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Speckle.InterfaceGenerator;
|
||||
using Speckle.Sdk.Api.Blob;
|
||||
using Speckle.Sdk.Credentials;
|
||||
using Speckle.Sdk.Logging;
|
||||
|
||||
@@ -9,9 +10,10 @@ namespace Speckle.Sdk.Api;
|
||||
public class ClientFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
ISdkActivityFactory activityFactory,
|
||||
IGraphQLClientFactory graphQLClientFactory
|
||||
IGraphQLClientFactory graphQLClientFactory,
|
||||
IBlobApiFactory blobApiFactory
|
||||
) : IClientFactory
|
||||
{
|
||||
public IClient Create(Account account) =>
|
||||
new Client(loggerFactory.CreateLogger<Client>(), activityFactory, graphQLClientFactory, account);
|
||||
new Client(loggerFactory.CreateLogger<Client>(), activityFactory, graphQLClientFactory, blobApiFactory, account);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ internal static class GraphQLErrorHandler
|
||||
var ex = code switch
|
||||
{
|
||||
"GRAPHQL_PARSE_FAILED" or "GRAPHQL_VALIDATION_FAILED" => new SpeckleGraphQLInvalidQueryException(message),
|
||||
"FORBIDDEN" or "UNAUTHENTICATED" => new SpeckleGraphQLForbiddenException(message),
|
||||
"FORBIDDEN" or "UNAUTHENTICATED" or "UNAUTHORIZED" or "UNAUTHORIZED_ACCESS_ERROR" =>
|
||||
new SpeckleGraphQLForbiddenException(message),
|
||||
"STREAM_NOT_FOUND" => new SpeckleGraphQLStreamNotFoundException(message),
|
||||
"BAD_USER_INPUT" => new SpeckleGraphQLBadInputException(message),
|
||||
"INTERNAL_SERVER_ERROR" => new SpeckleGraphQLInternalErrorException(message),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Speckle.Sdk.Api.GraphQL.Inputs;
|
||||
|
||||
public record GenerateFileUploadUrlInput(string projectId, string fileName);
|
||||
|
||||
public record StartFileImportInput(string projectId, string modelId, string fileId, string etag);
|
||||
|
||||
public record FileImportResult(
|
||||
double durationSeconds,
|
||||
double downloadDurationSeconds,
|
||||
double parseDurationSeconds,
|
||||
string parser,
|
||||
string? versionId
|
||||
);
|
||||
|
||||
public abstract class FileImportInputBase
|
||||
{
|
||||
public required string projectId { get; init; }
|
||||
public required string jobId { get; init; }
|
||||
public required IReadOnlyCollection<string> warnings { get; init; }
|
||||
public required FileImportResult result { get; init; }
|
||||
}
|
||||
|
||||
#pragma warning disable CA1822 //Mark members as static
|
||||
|
||||
public sealed class FileImportSuccessInput() : FileImportInputBase()
|
||||
{
|
||||
public const string TYPE_STATUS = "success";
|
||||
|
||||
public string status => TYPE_STATUS;
|
||||
}
|
||||
|
||||
public sealed class FileImportErrorInput() : FileImportInputBase()
|
||||
{
|
||||
public const string TYPE_STATUS = "error";
|
||||
|
||||
public string status => TYPE_STATUS;
|
||||
public required string reason { get; init; }
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public sealed class Comment
|
||||
public string rawText { get; init; }
|
||||
public ResourceCollection<Comment> replies { get; init; }
|
||||
public CommentReplyAuthorCollection replyAuthors { get; init; }
|
||||
public List<ResourceIdentifier> resources { get; init; }
|
||||
public List<ResourceIdentifier> resources { get; init; } //todo: add resourceIds/baseResourceIds
|
||||
public string? screenshot { get; init; }
|
||||
public DateTime updatedAt { get; init; }
|
||||
public DateTime? viewedAt { get; init; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Speckle.Sdk.Api.GraphQL.Models;
|
||||
|
||||
public sealed class FileImport
|
||||
{
|
||||
public string id { get; init; }
|
||||
public string projectId { get; init; }
|
||||
public string? convertedVersionId { get; init; }
|
||||
public string userId { get; init; }
|
||||
public int convertedStatus { get; init; }
|
||||
public string? convertedMessage { get; init; }
|
||||
public string? modelId { get; init; }
|
||||
public DateTime updatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Speckle.Sdk.Api.GraphQL.Models;
|
||||
|
||||
public sealed class FileUploadUrl
|
||||
{
|
||||
public Uri url { get; init; }
|
||||
public string fileId { get; init; }
|
||||
}
|
||||
@@ -19,6 +19,9 @@ public sealed class ServerInfo
|
||||
[Obsolete("Don't use")]
|
||||
public bool frontend2 { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// The URL that should be used to talk with the server
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field is not returned from the GQL API,
|
||||
/// it should be populated after construction.
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
namespace Speckle.Sdk.Api.GraphQL.Models;
|
||||
|
||||
public sealed class Workspace
|
||||
public class LimitedWorkspace
|
||||
{
|
||||
public string id { get; init; }
|
||||
public string name { get; init; }
|
||||
public string role { get; init; }
|
||||
public string? role { get; init; }
|
||||
public string slug { get; init; }
|
||||
public string? description { get; init; }
|
||||
public string? logo { get; init; }
|
||||
public DateTime? createdAt { get; init; }
|
||||
public DateTime? updatedAt { get; init; }
|
||||
public bool? readOnly { get; init; }
|
||||
public string? description { get; init; }
|
||||
}
|
||||
|
||||
public class Workspace : LimitedWorkspace
|
||||
{
|
||||
public DateTime createdAt { get; init; }
|
||||
public DateTime updatedAt { get; init; }
|
||||
public bool readOnly { get; init; }
|
||||
public WorkspacePermissionChecks permissions { get; init; }
|
||||
public WorkspaceCreationState? creationState { get; init; }
|
||||
}
|
||||
|
||||
@@ -313,10 +313,11 @@ public sealed class ActiveUserResource
|
||||
}
|
||||
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <returns>The active (last selected) workspace</returns>
|
||||
/// <remarks>note this returns a <see cref="LimitedWorkspace"/>, because it may be a workspace the user is not a member of</remarks>
|
||||
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
||||
/// <exception cref="SpeckleException">The ActiveUser could not be found (e.g. the client is not authenticated)</exception>
|
||||
public async Task<Workspace?> GetActiveWorkspace(CancellationToken cancellationToken = default)
|
||||
public async Task<LimitedWorkspace?> GetActiveWorkspace(CancellationToken cancellationToken = default)
|
||||
{
|
||||
//language=graphql
|
||||
const string QUERY = """
|
||||
@@ -328,21 +329,7 @@ public sealed class ActiveUserResource
|
||||
role
|
||||
slug
|
||||
logo
|
||||
createdAt
|
||||
updatedAt
|
||||
readOnly
|
||||
description
|
||||
creationState
|
||||
{
|
||||
completed
|
||||
}
|
||||
permissions {
|
||||
canCreateProject {
|
||||
authorized
|
||||
code
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +338,7 @@ public sealed class ActiveUserResource
|
||||
var request = new GraphQLRequest { Query = QUERY };
|
||||
|
||||
var response = await _client
|
||||
.ExecuteGraphQLRequest<NullableResponse<NullableResponse<Workspace?>?>>(request, cancellationToken)
|
||||
.ExecuteGraphQLRequest<NullableResponse<NullableResponse<LimitedWorkspace?>?>>(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.data is null)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Diagnostics;
|
||||
using GraphQL;
|
||||
using Speckle.Sdk.Api.Blob;
|
||||
using Speckle.Sdk.Api.GraphQL.Inputs;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Api.GraphQL.Models.Responses;
|
||||
using Speckle.Sdk.Transports;
|
||||
|
||||
namespace Speckle.Sdk.Api.GraphQL.Resources;
|
||||
|
||||
public sealed class FileImportResource : IDisposable
|
||||
{
|
||||
private readonly ISpeckleGraphQLClient _client;
|
||||
private readonly IBlobApi _blobApi;
|
||||
|
||||
internal FileImportResource(ISpeckleGraphQLClient client, IBlobApi blobApi)
|
||||
{
|
||||
_client = client;
|
||||
_blobApi = blobApi;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is mostly an internal api, that marks a file import job finished.
|
||||
/// </summary>
|
||||
/// <param name="input">Either <see cref="FileImportSuccessInput"/> or <see cref="FileImportErrorInput"/></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
||||
/// <remarks>
|
||||
/// Only use this if you are writing a file importer, that is responsible for
|
||||
/// processing file import jobs.
|
||||
/// Only works on servers version >=2.25.8
|
||||
/// </remarks>
|
||||
public async Task<bool> FinishFileImportJob(FileImportInputBase input, CancellationToken cancellationToken)
|
||||
{
|
||||
//language=graphql
|
||||
const string QUERY = """
|
||||
mutation FinishFileImport($input: FinishFileImportInput!) {
|
||||
data:fileUploadMutations {
|
||||
data:finishFileImport(input: $input)
|
||||
}
|
||||
}
|
||||
""";
|
||||
var request = new GraphQLRequest { Query = QUERY, Variables = new { input } };
|
||||
|
||||
var response = await _client
|
||||
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<bool>>>(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
||||
/// <remarks>Only works on servers version >=2.25.8</remarks>
|
||||
public async Task<FileImport> StartFileImportJob(
|
||||
StartFileImportInput input,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
//language=graphql
|
||||
const string QUERY = """
|
||||
mutation StartFileImport($input: StartFileImportInput!) {
|
||||
data:fileUploadMutations {
|
||||
data:startFileImport(input: $input) {
|
||||
id
|
||||
projectId
|
||||
convertedVersionId
|
||||
userId
|
||||
convertedStatus
|
||||
convertedMessage
|
||||
modelId
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var request = new GraphQLRequest { Query = QUERY, Variables = new { input } };
|
||||
|
||||
var response = await _client
|
||||
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<FileImport>>>(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a file upload url from the Speckle server.
|
||||
/// This method asks the server to create a pre-signed S3 url,
|
||||
/// which can be used as a short term authenticated route, to put a file to the server.
|
||||
/// </summary>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
||||
/// <remarks>Only works on servers version >=2.25.8</remarks>
|
||||
public async Task<FileUploadUrl> GenerateUploadUrl(
|
||||
GenerateFileUploadUrlInput input,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
//language=graphql
|
||||
const string QUERY = """
|
||||
mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {
|
||||
data:fileUploadMutations {
|
||||
data:generateUploadUrl(input: $input) {
|
||||
fileId
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var request = new GraphQLRequest { Query = QUERY, Variables = new { input } };
|
||||
|
||||
var response = await _client
|
||||
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<FileUploadUrl>>>(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="Blob.BlobApi.UploadFile"/>
|
||||
[DebuggerStepThrough]
|
||||
public Task<string> UploadFile(
|
||||
string filePath,
|
||||
Uri url,
|
||||
IProgress<ProgressArgs>? progress = null,
|
||||
CancellationToken cancellationToken = default
|
||||
) => _blobApi.UploadFile(filePath, url, progress, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="Blob.BlobApi.DownloadBlob"/>
|
||||
[DebuggerStepThrough]
|
||||
public Task DownloadFile(
|
||||
string projectId,
|
||||
string fileId,
|
||||
string targetFile,
|
||||
IProgress<ProgressArgs>? progress = null,
|
||||
CancellationToken cancellationToken = default
|
||||
) => _blobApi.DownloadBlob(projectId, fileId, targetFile, progress, cancellationToken);
|
||||
|
||||
/// <param name="projectId"></param>
|
||||
/// <param name="modelId"></param>
|
||||
/// <param name="limit"></param>
|
||||
/// <param name="cursor"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
||||
/// <remarks>Only works on servers version >=2.25.8</remarks>
|
||||
public async Task<ResourceCollection<FileImport>> GetModelFileImportJobs(
|
||||
string projectId,
|
||||
string modelId,
|
||||
int limit = ServerLimits.DEFAULT_PAGINATION_REQUEST,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
//language=graphql
|
||||
const string QUERY = """
|
||||
query ModelFileImportJobs(
|
||||
$projectId: String!,
|
||||
$modelId: String!,
|
||||
$input: GetModelUploadsInput
|
||||
) {
|
||||
data:project(id: $projectId) {
|
||||
data:model(id: $modelId) {
|
||||
data:uploads(input: $input) {
|
||||
totalCount
|
||||
cursor
|
||||
items {
|
||||
id
|
||||
projectId
|
||||
convertedVersionId
|
||||
userId
|
||||
convertedStatus
|
||||
convertedMessage
|
||||
modelId
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var request = new GraphQLRequest
|
||||
{
|
||||
Query = QUERY,
|
||||
Variables = new
|
||||
{
|
||||
projectId,
|
||||
modelId,
|
||||
input = new { limit, cursor },
|
||||
},
|
||||
};
|
||||
|
||||
var response = await _client
|
||||
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ResourceCollection<FileImport>>>>>(
|
||||
request,
|
||||
cancellationToken
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return response.data.data.data;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_blobApi.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Speckle.Sdk.Logging;
|
||||
using Speckle.Sdk.Models;
|
||||
using Speckle.Sdk.Serialisation;
|
||||
using Speckle.Sdk.Serialisation.V2.Receive;
|
||||
using Speckle.Sdk.Transports;
|
||||
|
||||
namespace Speckle.Sdk.Api;
|
||||
@@ -15,6 +16,7 @@ public partial class Operations
|
||||
/// <exception cref="ArgumentException">No transports were specified</exception>
|
||||
/// <exception cref="ArgumentNullException">The <paramref name="objectId"/> was <see langword="null"/></exception>
|
||||
/// <exception cref="SpeckleException">Serialization or Send operation was unsuccessful</exception>
|
||||
/// <exception cref="HttpRequestException">HTTP layer errors</exception>
|
||||
/// <exception cref="OperationCanceledException">The <paramref name="cancellationToken"/> requested cancellation</exception>
|
||||
public async Task<Base> Receive2(
|
||||
Uri url,
|
||||
@@ -22,7 +24,8 @@ public partial class Operations
|
||||
string objectId,
|
||||
string? authorizationToken,
|
||||
IProgress<ProgressArgs>? onProgressAction,
|
||||
CancellationToken cancellationToken
|
||||
CancellationToken cancellationToken,
|
||||
DeserializeProcessOptions? options = null
|
||||
)
|
||||
{
|
||||
using var receiveActivity = activityFactory.Start("Operations.Receive");
|
||||
@@ -36,7 +39,8 @@ public partial class Operations
|
||||
streamId,
|
||||
authorizationToken,
|
||||
onProgressAction,
|
||||
cancellationToken
|
||||
cancellationToken,
|
||||
options
|
||||
);
|
||||
try
|
||||
{
|
||||
@@ -44,6 +48,11 @@ public partial class Operations
|
||||
receiveActivity?.SetStatus(SdkActivityStatusCode.Ok);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//this is handled by the caller
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
receiveActivity?.SetStatus(SdkActivityStatusCode.Error);
|
||||
|
||||
@@ -25,7 +25,8 @@ public partial class Operations
|
||||
string? authorizationToken,
|
||||
Base value,
|
||||
IProgress<ProgressArgs>? onProgressAction,
|
||||
CancellationToken cancellationToken
|
||||
CancellationToken cancellationToken,
|
||||
SerializeProcessOptions? options = null
|
||||
)
|
||||
{
|
||||
using var receiveActivity = activityFactory.Start("Operations.Send");
|
||||
@@ -38,7 +39,8 @@ public partial class Operations
|
||||
streamId,
|
||||
authorizationToken,
|
||||
onProgressAction,
|
||||
cancellationToken
|
||||
cancellationToken,
|
||||
options
|
||||
);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -59,15 +59,20 @@ public class Account : IEquatable<Account>
|
||||
|
||||
#region public methods
|
||||
|
||||
|
||||
/// <remarks>The logic is aligned with <c>distinct_id</c> mixpanel property</remarks>
|
||||
/// <exception cref="ArgumentNullException">Thrown if <see name="userInfo.email"/> was <see langword="null"/></exception>
|
||||
public string GetHashedEmail()
|
||||
{
|
||||
string email = userInfo?.email ?? "unknown";
|
||||
string email = userInfo.email.NotNull();
|
||||
return "@" + Md5.GetString(email).ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <remarks>The logic is aligned with <c>server</c> mixpanel property</remarks>
|
||||
/// <exception cref="ArgumentNullException">Thrown if <see name="serverInfo.url"/> was <see langword="null"/></exception>
|
||||
public string GetHashedServer()
|
||||
{
|
||||
string url = serverInfo?.url ?? AccountManager.DEFAULT_SERVER_URL;
|
||||
string url = serverInfo.url.NotNull();
|
||||
return Md5.GetString(CleanURL(url)).ToUpperInvariant();
|
||||
}
|
||||
|
||||
@@ -97,6 +102,8 @@ public class Account : IEquatable<Account>
|
||||
|
||||
#endregion
|
||||
|
||||
internal const string LOCAL_IDENTIFIER_DEPRECATION_MESSAGE = "Local identifiers no longer nesseary";
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the local identifier for the current user.
|
||||
/// </summary>
|
||||
@@ -121,5 +128,6 @@ public class Account : IEquatable<Account>
|
||||
/// https://speckle.xyz?id=123
|
||||
/// </code>
|
||||
/// </example>
|
||||
[Obsolete(LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
|
||||
internal Uri GetLocalIdentifier() => new($"{serverInfo.url}?id={userInfo.id}");
|
||||
}
|
||||
|
||||
@@ -419,6 +419,7 @@ public sealed class AccountManager(
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="Account.GetLocalIdentifier"/>
|
||||
/// </remarks>
|
||||
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
|
||||
public Uri? GetLocalIdentifierForAccount(Account account)
|
||||
{
|
||||
var identifier = account.GetLocalIdentifier();
|
||||
@@ -440,6 +441,7 @@ public sealed class AccountManager(
|
||||
/// </summary>
|
||||
/// <param name="localIdentifier">The local identifier of the account.</param>
|
||||
/// <returns>The account that matches the local identifier, or null if no match is found.</returns>
|
||||
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
|
||||
public Account? GetAccountForLocalIdentifier(Uri localIdentifier)
|
||||
{
|
||||
var searchResult = GetAccounts()
|
||||
|
||||
@@ -9,12 +9,14 @@ public static class SpecklePathProvider
|
||||
{
|
||||
private const string APPLICATION_NAME = "Speckle";
|
||||
|
||||
private const string LOG_FOLDER_NAME = "Logs";
|
||||
|
||||
private const string BLOB_FOLDER_NAME = "Blobs";
|
||||
|
||||
private const string ACCOUNTS_FOLDER_NAME = "Accounts";
|
||||
|
||||
private static string UserDataPathEnvVar => "SPECKLE_USERDATA_PATH";
|
||||
private static string? Path => Environment.GetEnvironmentVariable(UserDataPathEnvVar);
|
||||
public const string USER_DATA_PATH_ENV_VAR = "SPECKLE_USERDATA_PATH";
|
||||
private static string? Path => Environment.GetEnvironmentVariable(USER_DATA_PATH_ENV_VAR);
|
||||
|
||||
/// <summary>
|
||||
/// Get the installation path.
|
||||
@@ -43,7 +45,7 @@ public static class SpecklePathProvider
|
||||
/// <see cref="Environment.SpecialFolder.ApplicationData"/> path usually maps to
|
||||
/// <ul>
|
||||
/// <li>win: <c>%appdata%/</c></li>
|
||||
/// <li>MacOS: <c>~/.config/</c></li>
|
||||
/// <li>MacOS: <c>~/Library/Application Support</c></li>
|
||||
/// <li>Linux: <c>~/.config/</c></li>
|
||||
/// </ul>
|
||||
/// </remarks>
|
||||
@@ -57,29 +59,18 @@ public static class SpecklePathProvider
|
||||
return pathOverride;
|
||||
}
|
||||
|
||||
// on desktop linux and macos we use the appdata.
|
||||
// but we might not have write access to the disk
|
||||
// so the catch falls back to the user profile
|
||||
try
|
||||
{
|
||||
return Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.ApplicationData,
|
||||
// if the folder doesn't exist, we get back an empty string on OSX,
|
||||
// which in turn, breaks other stuff down the line.
|
||||
// passing in the Create option ensures that this directory exists,
|
||||
// which is not a given on all OS-es.
|
||||
// It's not a given that the folder is already there on all OS-es, so we'll create it
|
||||
Environment.SpecialFolderOption.Create
|
||||
);
|
||||
}
|
||||
catch (SystemException ex) when (ex is PlatformNotSupportedException or ArgumentException)
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
//Adding this log just so we confidently know which Exception type to catch here.
|
||||
// TODO: Must re-add log call when (and if) this get's made as a service
|
||||
//SpeckleLog.Logger.Warning(ex, "Falling back to user profile path");
|
||||
|
||||
// on server linux, there might not be a user setup, things can run under root
|
||||
// in that case, the appdata variable is most probably not set up
|
||||
// we fall back to the value of the home folder
|
||||
// We might not have write access to the disk to create the folder,
|
||||
// so we'll fall back to the user profile
|
||||
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
}
|
||||
}
|
||||
@@ -96,4 +87,7 @@ public static class SpecklePathProvider
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public static string LogFolderPath(string applicationAndVersion) =>
|
||||
EnsureFolderExists(UserSpeckleFolderPath, LOG_FOLDER_NAME, applicationAndVersion);
|
||||
}
|
||||
|
||||
@@ -169,8 +169,7 @@ public class Base : DynamicBase, ISpeckleObject
|
||||
|
||||
return count;
|
||||
}
|
||||
case IEnumerable e
|
||||
and not string:
|
||||
case IEnumerable e and not string:
|
||||
{
|
||||
foreach (var arrValue in e)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Speckle.Sdk.Models.Data;
|
||||
|
||||
namespace Speckle.Sdk.Models.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// Root collection that represents the top-level commit object.
|
||||
/// Extends Collection to include model-wide properties that apply to the entire model.
|
||||
/// </summary>
|
||||
[SpeckleType("Speckle.Core.Models.Collections.RootCollection")]
|
||||
public class RootCollection : Collection, IProperties
|
||||
{
|
||||
public RootCollection() { }
|
||||
|
||||
public RootCollection(string name)
|
||||
: base(name) { }
|
||||
|
||||
/// <summary>
|
||||
/// Model-wide properties that apply to the entire model.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> properties { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Speckle.Sdk.Models.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies properties on objects to be used for data-based workflows.
|
||||
/// Can be applied to both objects and collections.
|
||||
/// </summary>
|
||||
public interface IProperties
|
||||
{
|
||||
Dictionary<string, object?> properties { get; }
|
||||
}
|
||||
@@ -109,9 +109,9 @@ public abstract class GraphTraversal<T>
|
||||
break;
|
||||
case IList list:
|
||||
{
|
||||
foreach (object? obj in list)
|
||||
for (int i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
TraverseMemberToStack(stack, obj, memberName, parent);
|
||||
TraverseMemberToStack(stack, list[i], memberName, parent);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -9,6 +9,7 @@ public class MemoryServerObjectManager(ConcurrentDictionary<string, string> obje
|
||||
{
|
||||
public virtual async IAsyncEnumerable<(string, string)> DownloadObjects(
|
||||
IReadOnlyCollection<string> objectIds,
|
||||
string? attributeMask,
|
||||
IProgress<ProgressArgs>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ using Speckle.Sdk.Transports;
|
||||
namespace Speckle.Sdk.Serialisation.V2.Receive;
|
||||
|
||||
public record DeserializeProcessOptions(
|
||||
bool SkipCache = false,
|
||||
bool SkipCache = false, //TODO: This appears to be bugged when set to `true`, `LoadId` depends on sqlite
|
||||
bool ThrowOnMissingReferences = true,
|
||||
bool SkipInvalidConverts = false,
|
||||
int? MaxParallelism = null,
|
||||
|
||||
@@ -19,9 +19,7 @@ public sealed class ObjectLoader(
|
||||
IProgress<ProgressArgs>? progress,
|
||||
ILogger<ObjectLoader> logger,
|
||||
CancellationToken cancellationToken
|
||||
#pragma warning disable CS9107 // Parameter is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
|
||||
) : ChannelLoader<BaseItem>(cancellationToken), IObjectLoader
|
||||
#pragma warning restore CS9107 // Parameter is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
|
||||
{
|
||||
private int? _allChildrenCount;
|
||||
private long _checkCache;
|
||||
@@ -29,6 +27,7 @@ public sealed class ObjectLoader(
|
||||
private long _downloaded;
|
||||
private long _totalToDownload;
|
||||
private DeserializeProcessOptions _options = new();
|
||||
private readonly CancellationToken _cancellationToken = cancellationToken;
|
||||
|
||||
[AutoInterfaceIgnore]
|
||||
public void Dispose() => sqLiteJsonCacheManager.Dispose();
|
||||
@@ -46,7 +45,7 @@ public sealed class ObjectLoader(
|
||||
{
|
||||
//assume everything exists as the root is there.
|
||||
var allChildren = ClosureParser
|
||||
.GetClosuresSorted(rootJson, cancellationToken)
|
||||
.GetClosuresSorted(rootJson, _cancellationToken)
|
||||
.Select(x => new Id(x.Item1))
|
||||
.ToList();
|
||||
//this probably yields away from the Main thread to let host apps update progress
|
||||
@@ -59,11 +58,11 @@ public sealed class ObjectLoader(
|
||||
if (!options.SkipServer)
|
||||
{
|
||||
rootJson = await serverObjectManager
|
||||
.DownloadSingleObject(rootId, progress, cancellationToken)
|
||||
.DownloadSingleObject(rootId, progress, _cancellationToken)
|
||||
.NotNull()
|
||||
.ConfigureAwait(false);
|
||||
IReadOnlyCollection<Id> allChildrenIds = ClosureParser
|
||||
.GetClosures(rootJson, cancellationToken)
|
||||
.GetClosures(rootJson, _cancellationToken)
|
||||
.OrderByDescending(x => x.Item2)
|
||||
.Select(x => new Id(x.Item1))
|
||||
.Where(x => !x.Value.StartsWith("blob", StringComparison.Ordinal))
|
||||
@@ -111,12 +110,13 @@ public sealed class ObjectLoader(
|
||||
await foreach (
|
||||
var (id, json) in serverObjectManager.DownloadObjects(
|
||||
ids.Select(x => x.NotNull()).ToList(),
|
||||
null, //TODO: Implement attribute masking in a safe way that will not poison SQLite DB.
|
||||
progress,
|
||||
cancellationToken
|
||||
_cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_cancellationToken.ThrowIfCancellationRequested();
|
||||
Interlocked.Increment(ref _downloaded);
|
||||
progress?.Report(new(ProgressEvent.DownloadObjects, _downloaded, _totalToDownload));
|
||||
toCache.Add(new(new(id), new(json), true, null));
|
||||
@@ -138,7 +138,7 @@ public sealed class ObjectLoader(
|
||||
{
|
||||
if (!_options.SkipCache)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_cancellationToken.ThrowIfCancellationRequested();
|
||||
sqLiteJsonCacheManager.SaveObjects(batch.Select(x => (x.Id.Value, x.Json.Value)));
|
||||
Interlocked.Exchange(ref _cached, _cached + batch.Count);
|
||||
progress?.Report(new(ProgressEvent.CachedToLocal, _cached, _allChildrenCount));
|
||||
@@ -168,7 +168,7 @@ public sealed class ObjectLoader(
|
||||
private void ThrowIfFailed()
|
||||
{
|
||||
//always check for cancellation first
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Exception is not null)
|
||||
{
|
||||
throw new SpeckleException($"Error while loading: {Exception.Message}", Exception);
|
||||
|
||||
@@ -21,12 +21,7 @@ public class BaseSerializer(
|
||||
public IReadOnlyDictionary<Id, ObjectReference> ObjectReferences => _objectReferences;
|
||||
|
||||
//leave this sync
|
||||
public IEnumerable<BaseItem> Serialise(
|
||||
Base obj,
|
||||
IReadOnlyDictionary<Id, NodeInfo> childInfo,
|
||||
bool skipCacheRead,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
public IEnumerable<BaseItem> Serialise(Base obj, bool skipCacheRead, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!skipCacheRead && obj.id != null)
|
||||
{
|
||||
@@ -38,7 +33,7 @@ public class BaseSerializer(
|
||||
}
|
||||
}
|
||||
|
||||
using var serializer2 = objectSerializerFactory.Create(childInfo, cancellationToken);
|
||||
using var serializer2 = objectSerializerFactory.Create(cancellationToken);
|
||||
var items = _pool.Get();
|
||||
try
|
||||
{
|
||||
|
||||
@@ -20,10 +20,10 @@ public sealed class ObjectSaver(
|
||||
ISqLiteJsonCacheManager sqLiteJsonCacheManager,
|
||||
IServerObjectManager serverObjectManager,
|
||||
ILogger<ObjectSaver> logger,
|
||||
CancellationToken cancellationToken,
|
||||
SerializeProcessOptions options,
|
||||
CancellationToken cancellationToken
|
||||
#pragma warning disable CS9107
|
||||
#pragma warning disable CA2254
|
||||
SerializeProcessOptions? options = null
|
||||
) : ChannelSaver<BaseItem>, IObjectSaver
|
||||
#pragma warning restore CA2254
|
||||
#pragma warning restore CS9107
|
||||
@@ -100,9 +100,9 @@ public sealed class ObjectSaver(
|
||||
{
|
||||
if (!_options.SkipCacheWrite && batch.Count != 0)
|
||||
{
|
||||
sqLiteJsonCacheManager.SaveObjects(batch.Select(x => (x.Id.Value, x.Json.Value)));
|
||||
Interlocked.Add(ref _cached, batch.Count);
|
||||
progress?.Report(new(ProgressEvent.CachedToLocal, _cached, _objectsSerialized));
|
||||
sqLiteJsonCacheManager.SaveObjects(batch.Select(x => (x.Id.Value, x.Json.Value)));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
@@ -26,8 +26,6 @@ public sealed class ObjectSerializer : IObjectSerializer
|
||||
{
|
||||
private HashSet<object> _parentObjects = new();
|
||||
|
||||
private readonly IReadOnlyDictionary<Id, NodeInfo> _childCache;
|
||||
|
||||
private readonly IBasePropertyGatherer _propertyGatherer;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
@@ -52,7 +50,6 @@ public sealed class ObjectSerializer : IObjectSerializer
|
||||
/// <param name="cancellationToken"></param>
|
||||
public ObjectSerializer(
|
||||
IBasePropertyGatherer propertyGatherer,
|
||||
IReadOnlyDictionary<Id, NodeInfo> childCache,
|
||||
Pool<List<(Id, Json, Closures)>> chunksPool,
|
||||
Pool<List<DataChunk>> chunks2Pool,
|
||||
Pool<List<object?>> chunks3Pool,
|
||||
@@ -60,7 +57,6 @@ public sealed class ObjectSerializer : IObjectSerializer
|
||||
)
|
||||
{
|
||||
_propertyGatherer = propertyGatherer;
|
||||
_childCache = childCache;
|
||||
_chunksPool = chunksPool;
|
||||
_chunks2Pool = chunks2Pool;
|
||||
_chunks3Pool = chunks3Pool;
|
||||
@@ -299,28 +295,14 @@ public sealed class ObjectSerializer : IObjectSerializer
|
||||
|
||||
private (Id, Json)? SerializeDetachedBase(Base baseObj, Closures closures)
|
||||
{
|
||||
Closures childClosures;
|
||||
Id id;
|
||||
Json json;
|
||||
//avoid multiple serialization to get closures
|
||||
if (baseObj.id != null && _childCache.TryGetValue(new(baseObj.id), out var info))
|
||||
{
|
||||
id = new Id(baseObj.id);
|
||||
childClosures = info.GetClosures(_cancellationToken);
|
||||
json = info.Json;
|
||||
closures.IncrementClosures(childClosures);
|
||||
}
|
||||
else
|
||||
{
|
||||
childClosures = [];
|
||||
var sb = Pools.StringBuilders.Get();
|
||||
using var writer = new StringWriter(sb);
|
||||
using var jsonWriter = SpeckleObjectSerializerPool.Instance.GetJsonTextWriter(writer);
|
||||
id = SerializeBaseWithClosures(baseObj, jsonWriter, childClosures, true);
|
||||
closures.IncrementClosures(childClosures);
|
||||
json = new Json(writer.ToString());
|
||||
Pools.StringBuilders.Return(sb);
|
||||
}
|
||||
Closures childClosures = [];
|
||||
var sb = Pools.StringBuilders.Get();
|
||||
using var writer = new StringWriter(sb);
|
||||
using var jsonWriter = SpeckleObjectSerializerPool.Instance.GetJsonTextWriter(writer);
|
||||
var id = SerializeBaseWithClosures(baseObj, jsonWriter, childClosures, true);
|
||||
var json = new Json(writer.ToString());
|
||||
Pools.StringBuilders.Return(sb);
|
||||
closures.IncrementClosures(childClosures);
|
||||
var json2 = ReferenceGenerator.CreateReference(id);
|
||||
closures.MergeClosure(id);
|
||||
// add to obj refs to return
|
||||
|
||||
@@ -12,6 +12,6 @@ public class ObjectSerializerFactory(IBasePropertyGatherer propertyGatherer) : I
|
||||
private readonly Pool<List<DataChunk>> _chunk2Pool = Pools.CreateListPool<DataChunk>();
|
||||
private readonly Pool<List<object?>> _chunk3Pool = Pools.CreateListPool<object?>();
|
||||
|
||||
public IObjectSerializer Create(IReadOnlyDictionary<Id, NodeInfo> baseCache, CancellationToken cancellationToken) =>
|
||||
new ObjectSerializer(propertyGatherer, baseCache, _chunkPool, _chunk2Pool, _chunk3Pool, cancellationToken);
|
||||
public IObjectSerializer Create(CancellationToken cancellationToken) =>
|
||||
new ObjectSerializer(propertyGatherer, _chunkPool, _chunk2Pool, _chunk3Pool, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Speckle.InterfaceGenerator;
|
||||
@@ -37,8 +36,8 @@ public sealed class SerializeProcess(
|
||||
IBaseChildFinder baseChildFinder,
|
||||
IBaseSerializer baseSerializer,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken,
|
||||
SerializeProcessOptions? options = null
|
||||
SerializeProcessOptions options,
|
||||
CancellationToken cancellationToken
|
||||
) : ISerializeProcess
|
||||
{
|
||||
private static readonly Dictionary<Id, NodeInfo> EMPTY_CLOSURES = new();
|
||||
@@ -64,13 +63,8 @@ public sealed class SerializeProcess(
|
||||
ThreadPriority.BelowNormal,
|
||||
Environment.ProcessorCount * 2
|
||||
);
|
||||
private readonly SerializeProcessOptions _options = options ?? new();
|
||||
|
||||
private readonly Pool<Dictionary<Id, NodeInfo>> _currentClosurePool = Pools.CreateDictionaryPool<Id, NodeInfo>();
|
||||
private readonly Pool<ConcurrentDictionary<Id, NodeInfo>> _childClosurePool = Pools.CreateConcurrentDictionaryPool<
|
||||
Id,
|
||||
NodeInfo
|
||||
>();
|
||||
|
||||
private readonly Pool<List<Task<Dictionary<Id, NodeInfo>>>> _taskResultPool = Pools.CreateListPool<
|
||||
Task<Dictionary<Id, NodeInfo>>
|
||||
@@ -113,13 +107,13 @@ public sealed class SerializeProcess(
|
||||
try
|
||||
{
|
||||
var channelTask = objectSaver.Start(
|
||||
options?.MaxParallelism,
|
||||
options?.MaxHttpSendBatchSize,
|
||||
options?.MaxCacheBatchSize,
|
||||
options.MaxParallelism,
|
||||
options.MaxHttpSendBatchSize,
|
||||
options.MaxCacheBatchSize,
|
||||
_processSource.Token
|
||||
);
|
||||
var findTotalObjectsTask = Task.CompletedTask;
|
||||
if (!_options.SkipFindTotalObjects)
|
||||
if (!options.SkipFindTotalObjects)
|
||||
{
|
||||
ThrowIfFailed();
|
||||
findTotalObjectsTask = Task.Factory.StartNew(
|
||||
@@ -225,7 +219,6 @@ public sealed class SerializeProcess(
|
||||
return EMPTY_CLOSURES;
|
||||
}
|
||||
|
||||
var childClosures = _childClosurePool.Get();
|
||||
foreach (var childClosure in taskClosures)
|
||||
{
|
||||
if (IsCancelled())
|
||||
@@ -234,7 +227,6 @@ public sealed class SerializeProcess(
|
||||
}
|
||||
foreach (var kvp in childClosure)
|
||||
{
|
||||
childClosures[kvp.Key] = kvp.Value;
|
||||
if (IsCancelled())
|
||||
{
|
||||
return EMPTY_CLOSURES;
|
||||
@@ -249,7 +241,7 @@ public sealed class SerializeProcess(
|
||||
return EMPTY_CLOSURES;
|
||||
}
|
||||
|
||||
var items = baseSerializer.Serialise(obj, childClosures, _options.SkipCacheRead, _processSource.Token);
|
||||
var items = baseSerializer.Serialise(obj, options.SkipCacheRead, _processSource.Token);
|
||||
|
||||
if (IsCancelled())
|
||||
{
|
||||
@@ -257,32 +249,26 @@ public sealed class SerializeProcess(
|
||||
}
|
||||
|
||||
var currentClosures = _currentClosurePool.Get();
|
||||
try
|
||||
|
||||
Interlocked.Increment(ref _objectCount);
|
||||
progress?.Report(new(ProgressEvent.FromCacheOrSerialized, _objectCount, Math.Max(_objectCount, _objectsFound)));
|
||||
foreach (var item in items)
|
||||
{
|
||||
Interlocked.Increment(ref _objectCount);
|
||||
progress?.Report(new(ProgressEvent.FromCacheOrSerialized, _objectCount, Math.Max(_objectCount, _objectsFound)));
|
||||
foreach (var item in items)
|
||||
if (IsCancelled())
|
||||
{
|
||||
if (IsCancelled())
|
||||
{
|
||||
return EMPTY_CLOSURES;
|
||||
}
|
||||
|
||||
if (item.NeedsStorage)
|
||||
{
|
||||
Interlocked.Increment(ref _objectsSerialized);
|
||||
await objectSaver.SaveAsync(item).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!currentClosures.ContainsKey(item.Id))
|
||||
{
|
||||
currentClosures.Add(item.Id, new NodeInfo(item.Json, item.Closures));
|
||||
}
|
||||
return EMPTY_CLOSURES;
|
||||
}
|
||||
|
||||
if (item.NeedsStorage)
|
||||
{
|
||||
Interlocked.Increment(ref _objectsSerialized);
|
||||
await objectSaver.SaveAsync(item).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!currentClosures.ContainsKey(item.Id))
|
||||
{
|
||||
currentClosures.Add(item.Id, new NodeInfo(item.Json, item.Closures));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_childClosurePool.Return(childClosures);
|
||||
}
|
||||
|
||||
return currentClosures;
|
||||
|
||||
@@ -44,13 +44,14 @@ public class SerializeProcessFactory(
|
||||
sqLiteJsonCacheManager,
|
||||
serverObjectManager,
|
||||
loggerFactory.CreateLogger<ObjectSaver>(),
|
||||
options ?? new SerializeProcessOptions(),
|
||||
cancellationToken
|
||||
),
|
||||
baseChildFinder,
|
||||
new BaseSerializer(sqLiteJsonCacheManager, objectSerializerFactory),
|
||||
loggerFactory,
|
||||
cancellationToken,
|
||||
options
|
||||
options ?? new SerializeProcessOptions(),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
public ISerializeProcess CreateSerializeProcess(
|
||||
|
||||
@@ -51,6 +51,7 @@ public class ServerObjectManager : IServerObjectManager
|
||||
|
||||
public async IAsyncEnumerable<(string, string)> DownloadObjects(
|
||||
IReadOnlyCollection<string> objectIds,
|
||||
string? attributeMask,
|
||||
IProgress<ProgressArgs>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
@@ -59,10 +60,14 @@ public class ServerObjectManager : IServerObjectManager
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var childrenHttpMessage = new HttpRequestMessage();
|
||||
childrenHttpMessage.RequestUri = new Uri($"/api/getobjects/{_streamId}", UriKind.Relative);
|
||||
childrenHttpMessage.RequestUri = new Uri($"/api/v2/projects/{_streamId}/object-stream/", UriKind.Relative);
|
||||
childrenHttpMessage.Method = HttpMethod.Post;
|
||||
|
||||
Dictionary<string, string> postParameters = new() { { "objects", JsonConvert.SerializeObject(objectIds) } };
|
||||
Dictionary<string, object> postParameters = new() { { "objectIds", objectIds } };
|
||||
if (!string.IsNullOrWhiteSpace(attributeMask))
|
||||
{
|
||||
postParameters.Add("attributeMask", attributeMask.NotNull());
|
||||
}
|
||||
string serializedPayload = JsonConvert.SerializeObject(postParameters);
|
||||
childrenHttpMessage.Content = new StringContent(serializedPayload, Encoding.UTF8, "application/json");
|
||||
childrenHttpMessage.Headers.Add("Accept", "text/plain");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Speckle.Sdk.Api;
|
||||
using Speckle.Sdk.Api.Blob;
|
||||
using Speckle.Sdk.Credentials;
|
||||
using Speckle.Sdk.Dependencies;
|
||||
using Speckle.Sdk.Host;
|
||||
@@ -86,6 +87,7 @@ public static class ServiceRegistration
|
||||
typeof(ServerApi),
|
||||
typeof(SqLiteJsonCacheManager),
|
||||
typeof(ServerObjectManager),
|
||||
typeof(BlobApi),
|
||||
typeof(BaseSerializer),
|
||||
typeof(SerializeProcess),
|
||||
typeof(ObjectSaver),
|
||||
|
||||
@@ -20,7 +20,7 @@ internal sealed class ProgressStream(
|
||||
{
|
||||
int n = _stream.Read(buffer, offset, count);
|
||||
_position += n;
|
||||
progress?.Report(new(ProgressEvent.DownloadBytes, _position, streamLength));
|
||||
progress?.Report(new(ProgressEvent.DownloadBytes, n, streamLength));
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ internal sealed class ProgressStream(
|
||||
{
|
||||
_stream.Write(buffer, offset, count);
|
||||
_position += count;
|
||||
progress?.Report(new(ProgressEvent.UploadBytes, _position, streamLength));
|
||||
progress?.Report(new(ProgressEvent.UploadBytes, count, streamLength));
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="altcover" />
|
||||
<PackageReference Include="AwesomeAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.assert" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Speckle.Sdk.Testing\Speckle.Sdk.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\src\Speckle.Automate.Sdk\Speckle.Automate.Sdk.csproj" />
|
||||
|
||||
@@ -25,7 +25,9 @@ public sealed class AutomationContextTest : IAsyncLifetime
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddAutomateSdk();
|
||||
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||
_account = await Fixtures.SeedUser().ConfigureAwait(false);
|
||||
_client = serviceProvider.GetRequiredService<IClientFactory>().Create(_account);
|
||||
_runner = serviceProvider.GetRequiredService<IAutomationRunner>();
|
||||
@@ -42,7 +44,7 @@ public sealed class AutomationContextTest : IAsyncLifetime
|
||||
private async Task<AutomationRunData> AutomationRunData(Base testObject)
|
||||
{
|
||||
Project project = await _client.Project.Create(new("Automate function e2e test", null, ProjectVisibility.Public));
|
||||
const string BRANCH_NAME = "main";
|
||||
const string BRANCH_NAME = "Trigger";
|
||||
|
||||
var model = await _client.Model.Create(new(BRANCH_NAME, null, project.id));
|
||||
string modelId = model.id;
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net8.0": {
|
||||
"altcover": {
|
||||
"type": "Direct",
|
||||
"requested": "[9.0.1, )",
|
||||
"resolved": "9.0.1",
|
||||
"contentHash": "aadciFNDT5bnylaYUkKal+s5hF7yU/lmZxImQWAlk1438iPqK1Uf79H5ylELpyLIU49HL5ql+tnWBihp3WVLCA=="
|
||||
},
|
||||
"AwesomeAssertions": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.1.0, )",
|
||||
"resolved": "8.1.0",
|
||||
"contentHash": "IfNC4cpXPi9tclWvuNO9lfkuIxJsUTLTS1NXto55jDrAUQJYl0zLI9ByISrfkbBE2Xtg+IWaAXQ6jnUx3anDuw=="
|
||||
},
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[17.13.0, )",
|
||||
"resolved": "17.13.0",
|
||||
"contentHash": "W19wCPizaIC9Zh47w8wWI/yxuqR7/dtABwOrc8r2jX/8mUNxM2vw4fXDh+DJTeogxV+KzKwg5jNNGQVwf3LXyA==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "17.13.0",
|
||||
"Microsoft.TestPlatform.TestHost": "17.13.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.SourceLink.GitHub": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.0, )",
|
||||
@@ -24,6 +46,18 @@
|
||||
"resolved": "0.9.6",
|
||||
"contentHash": "HKH7tYrYYlCK1ct483hgxERAdVdMtl7gUKW9ijWXxA1UsYR4Z+TrRHYmzZ9qmpu1NnTycSrp005NYM78GDKV1w=="
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.2, )",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow=="
|
||||
},
|
||||
"Argon": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.28.0",
|
||||
@@ -364,18 +398,6 @@
|
||||
"xunit.runner.visualstudio": "[3.0.2, )"
|
||||
}
|
||||
},
|
||||
"altcover": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[9.0.1, )",
|
||||
"resolved": "9.0.1",
|
||||
"contentHash": "aadciFNDT5bnylaYUkKal+s5hF7yU/lmZxImQWAlk1438iPqK1Uf79H5ylELpyLIU49HL5ql+tnWBihp3WVLCA=="
|
||||
},
|
||||
"AwesomeAssertions": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[8.1.0, )",
|
||||
"resolved": "8.1.0",
|
||||
"contentHash": "IfNC4cpXPi9tclWvuNO9lfkuIxJsUTLTS1NXto55jDrAUQJYl0zLI9ByISrfkbBE2Xtg+IWaAXQ6jnUx3anDuw=="
|
||||
},
|
||||
"GraphQL.Client": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[6.0.0, )",
|
||||
@@ -424,16 +446,6 @@
|
||||
"Microsoft.Extensions.Options": "2.2.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[17.13.0, )",
|
||||
"resolved": "17.13.0",
|
||||
"contentHash": "W19wCPizaIC9Zh47w8wWI/yxuqR7/dtABwOrc8r2jX/8mUNxM2vw4fXDh+DJTeogxV+KzKwg5jNNGQVwf3LXyA==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "17.13.0",
|
||||
"Microsoft.TestPlatform.TestHost": "17.13.0"
|
||||
}
|
||||
},
|
||||
"Moq": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.20.72, )",
|
||||
@@ -515,18 +527,6 @@
|
||||
"xunit.assert": "2.9.3",
|
||||
"xunit.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[3.0.2, )",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,7 @@ public sealed class ObjectsSerializationTest
|
||||
|
||||
private static IReadOnlyList<(Id, Json, Dictionary<Id, int>)> Serialize(Base data)
|
||||
{
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(
|
||||
new Dictionary<Id, NodeInfo>(),
|
||||
default
|
||||
);
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(default);
|
||||
return serializer.Serialize(data).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TestProjectAnalyserRules>true</TestProjectAnalyserRules>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Speckle.Sdk\Speckle.Sdk.csproj" />
|
||||
|
||||
@@ -41,7 +41,7 @@ public class DataObjectTests
|
||||
new DummyServerObjectManager(),
|
||||
null,
|
||||
default,
|
||||
new SerializeProcessOptions(true, true, false, true)
|
||||
new SerializeProcessOptions(false, false, true, true)
|
||||
);
|
||||
await serializeProcess.Serialize(x);
|
||||
await VerifyJson(json.Single().Value.Value).UseParameters(type);
|
||||
|
||||
@@ -41,7 +41,7 @@ public class DetachedTests
|
||||
objects,
|
||||
null,
|
||||
default,
|
||||
new SerializeProcessOptions(false, false, true, true)
|
||||
new SerializeProcessOptions(true, true, false, true)
|
||||
);
|
||||
await serializeProcess.Serialize(@base);
|
||||
|
||||
@@ -123,7 +123,7 @@ public class DetachedTests
|
||||
objects,
|
||||
null,
|
||||
default,
|
||||
new SerializeProcessOptions(false, false, true, true) { MaxParallelism = 1, MaxHttpSendBatchSize = 1 }
|
||||
new SerializeProcessOptions(true, true, false, true) { MaxParallelism = 1, MaxHttpSendBatchSize = 1 }
|
||||
);
|
||||
var results = await serializeProcess.Serialize(@base);
|
||||
|
||||
@@ -150,7 +150,7 @@ public class DetachedTests
|
||||
objects,
|
||||
null,
|
||||
default,
|
||||
new SerializeProcessOptions(false, false, true, true) { MaxParallelism = 1, MaxHttpSendBatchSize = 1 }
|
||||
new SerializeProcessOptions(true, true, false, true) { MaxParallelism = 1, MaxHttpSendBatchSize = 1 }
|
||||
);
|
||||
var results = await serializeProcess.Serialize(@base);
|
||||
|
||||
@@ -172,7 +172,7 @@ public class DetachedTests
|
||||
objects,
|
||||
null,
|
||||
default,
|
||||
new SerializeProcessOptions(false, false, true, true) { MaxParallelism = 1, MaxHttpSendBatchSize = 1 }
|
||||
new SerializeProcessOptions(true, true, false, true) { MaxParallelism = 1, MaxHttpSendBatchSize = 1 }
|
||||
);
|
||||
var results = await serializeProcess.Serialize(@base);
|
||||
|
||||
@@ -239,7 +239,7 @@ public class DetachedTests
|
||||
objects,
|
||||
null,
|
||||
default,
|
||||
new SerializeProcessOptions(false, false, true, true)
|
||||
new SerializeProcessOptions(true, true, false, true)
|
||||
);
|
||||
|
||||
var results = await serializeProcess.Serialize(@base);
|
||||
@@ -272,7 +272,7 @@ public class DetachedTests
|
||||
objects,
|
||||
null,
|
||||
default,
|
||||
new SerializeProcessOptions(false, false, true, true)
|
||||
new SerializeProcessOptions(true, true, false, true)
|
||||
);
|
||||
var results = await serializeProcess.Serialize(@base);
|
||||
await VerifyJsonDictionary(objects);
|
||||
@@ -338,6 +338,7 @@ public class DummyServerObjectManager : IServerObjectManager
|
||||
{
|
||||
public IAsyncEnumerable<(string, string)> DownloadObjects(
|
||||
IReadOnlyCollection<string> objectIds,
|
||||
string? attributeMask,
|
||||
IProgress<ProgressArgs>? progress,
|
||||
CancellationToken cancellationToken
|
||||
) => throw new NotImplementedException();
|
||||
|
||||
@@ -113,14 +113,14 @@ public class ExceptionTests
|
||||
new ExceptionServerObjectManager(),
|
||||
null,
|
||||
new NullLogger<ObjectLoader>(),
|
||||
default
|
||||
CancellationToken.None
|
||||
);
|
||||
await using var process = new DeserializeProcess(
|
||||
o,
|
||||
null,
|
||||
new BaseDeserializer(new ObjectDeserializerFactory()),
|
||||
new NullLoggerFactory(),
|
||||
default,
|
||||
CancellationToken.None,
|
||||
new(SkipCache: true, MaxParallelism: 1, SkipServer: true)
|
||||
);
|
||||
|
||||
@@ -144,7 +144,7 @@ public class ExceptionTests
|
||||
null,
|
||||
new BaseDeserializer(new ObjectDeserializerFactory()),
|
||||
new NullLoggerFactory(),
|
||||
default,
|
||||
CancellationToken.None,
|
||||
new(true, MaxParallelism: 1)
|
||||
);
|
||||
|
||||
@@ -169,7 +169,7 @@ public class ExceptionTests
|
||||
null,
|
||||
new BaseDeserializer(new ObjectDeserializerFactory()),
|
||||
new NullLoggerFactory(),
|
||||
default,
|
||||
CancellationToken.None,
|
||||
new(MaxParallelism: 1)
|
||||
);
|
||||
|
||||
@@ -194,16 +194,14 @@ public class ExceptionTests
|
||||
[SpeckleType("Objects.Geometry.BadBase")]
|
||||
public class BadBase : Base
|
||||
{
|
||||
#pragma warning disable CA1065
|
||||
public string BadProp => throw new NotImplementedException();
|
||||
#pragma warning restore CA1065
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SpeckleSerializerException()
|
||||
{
|
||||
var factory = new ObjectSerializerFactory(new BasePropertyGatherer());
|
||||
var serializer = factory.Create(new Dictionary<Id, NodeInfo>(), default);
|
||||
var serializer = factory.Create(default);
|
||||
Assert.Throws<SpeckleSerializeException>(() =>
|
||||
{
|
||||
var _ = serializer.Serialize(new BadBase()).ToList();
|
||||
|
||||
@@ -3,7 +3,6 @@ using Speckle.Objects.Primitive;
|
||||
using Speckle.Sdk.Host;
|
||||
using Speckle.Sdk.Models;
|
||||
using Speckle.Sdk.Models.Extensions;
|
||||
using Speckle.Sdk.Serialisation;
|
||||
using Speckle.Sdk.Serialisation.V2.Send;
|
||||
|
||||
namespace Speckle.Sdk.Serialization.Tests;
|
||||
@@ -20,10 +19,7 @@ public class ExternalIdTests
|
||||
public async Task ExternalIdTest_Detached()
|
||||
{
|
||||
var p = new Polyline() { units = "cm", value = [1, 2] };
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(
|
||||
new Dictionary<Id, NodeInfo>(),
|
||||
default
|
||||
);
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(default);
|
||||
var objects = serializer.Serialize(p).ToDictionary(x => x.Item1, x => x.Item2);
|
||||
|
||||
await VerifyJsonDictionary(objects);
|
||||
@@ -45,10 +41,7 @@ public class ExternalIdTests
|
||||
knots = [],
|
||||
weights = [],
|
||||
};
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(
|
||||
new Dictionary<Id, NodeInfo>(),
|
||||
default
|
||||
);
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(default);
|
||||
var objects = serializer.Serialize(curve).ToDictionary(x => x.Item1, x => x.Item2);
|
||||
|
||||
await VerifyJsonDictionary(objects);
|
||||
@@ -71,10 +64,7 @@ public class ExternalIdTests
|
||||
weights = [],
|
||||
};
|
||||
var polycurve = new Polycurve() { segments = [curve], units = "cm" };
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(
|
||||
new Dictionary<Id, NodeInfo>(),
|
||||
default
|
||||
);
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(default);
|
||||
var objects = serializer.Serialize(polycurve).ToDictionary(x => x.Item1, x => x.Item2);
|
||||
|
||||
await VerifyJsonDictionary(objects);
|
||||
@@ -99,10 +89,7 @@ public class ExternalIdTests
|
||||
var polycurve = new Polycurve() { segments = [curve], units = "cm" };
|
||||
var @base = new Base();
|
||||
@base.SetDetachedProp("profile", polycurve);
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(
|
||||
new Dictionary<Id, NodeInfo>(),
|
||||
default
|
||||
);
|
||||
using var serializer = new ObjectSerializerFactory(new BasePropertyGatherer()).Create(default);
|
||||
var objects = serializer.Serialize(@base).ToDictionary(x => x.Item1, x => x.Item2);
|
||||
await VerifyJsonDictionary(objects);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public class ExceptionServerObjectManager : IServerObjectManager
|
||||
{
|
||||
public IAsyncEnumerable<(string, string)> DownloadObjects(
|
||||
IReadOnlyCollection<string> objectIds,
|
||||
string? attributeMask,
|
||||
IProgress<ProgressArgs>? progress,
|
||||
CancellationToken cancellationToken
|
||||
) => throw new NotImplementedException();
|
||||
|
||||
@@ -150,7 +150,7 @@ public class SerializationTests
|
||||
id.Should().Be(newId.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Theory(Skip = "Takes too long")]
|
||||
[InlineData("RevitObject.json.gz", "3416d3fe01c9196115514c4a2f41617b", 7818)]
|
||||
public async Task Roundtrip_Test_Old(string fileName, string _, int count)
|
||||
{
|
||||
@@ -186,8 +186,6 @@ public class SerializationTests
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public async Task Roundtrip_Test_New(int concurrency)
|
||||
{
|
||||
@@ -205,7 +203,7 @@ public class SerializationTests
|
||||
new DummyReceiveServerObjectManager(closures),
|
||||
null,
|
||||
new NullLogger<ObjectLoader>(),
|
||||
default
|
||||
CancellationToken.None
|
||||
)
|
||||
)
|
||||
{
|
||||
|
||||
@@ -31,14 +31,17 @@ public class ServerObjectManagerTests : MoqTest
|
||||
var jObject = new JObject { { "id", id }, { "value", true } };
|
||||
var jObject2 = new JObject { { "id", id2 }, { "value", true } };
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
Dictionary<string, string> postParameters = new()
|
||||
Dictionary<string, object> postParameters = new()
|
||||
{
|
||||
{ "objects", JsonConvert.SerializeObject(new List<string> { id, id2 }) },
|
||||
{
|
||||
"objectIds",
|
||||
new List<string> { id, id2 }
|
||||
},
|
||||
};
|
||||
|
||||
string serializedPayload = JsonConvert.SerializeObject(postParameters);
|
||||
mockHttp
|
||||
.When(HttpMethod.Post, $"http://localhost/api/getobjects/{streamId}")
|
||||
.When(HttpMethod.Post, $"http://localhost/api/v2/projects/{streamId}/object-stream/")
|
||||
.WithContent(serializedPayload)
|
||||
.Respond(
|
||||
"application/json",
|
||||
@@ -59,7 +62,7 @@ public class ServerObjectManagerTests : MoqTest
|
||||
token,
|
||||
new(timeout: TimeSpan.FromSeconds(timeout))
|
||||
);
|
||||
var results = serverObjectManager.DownloadObjects(new List<string> { id, id2 }, null, ct);
|
||||
var results = serverObjectManager.DownloadObjects(new List<string> { id, id2 }, null, null, ct);
|
||||
var objects = new JObject();
|
||||
await foreach (var (x, json) in results)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ public class DummyReceiveServerObjectManager(IReadOnlyDictionary<string, string>
|
||||
{
|
||||
public async IAsyncEnumerable<(string, string)> DownloadObjects(
|
||||
IReadOnlyCollection<string> objectIds,
|
||||
string? attributeMask,
|
||||
IProgress<ProgressArgs>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ public class DummySendServerObjectManager(ConcurrentDictionary<string, string> s
|
||||
{
|
||||
public IAsyncEnumerable<(string, string)> DownloadObjects(
|
||||
IReadOnlyCollection<string> objectIds,
|
||||
string? attributeMask,
|
||||
IProgress<ProgressArgs>? progress,
|
||||
CancellationToken cancellationToken
|
||||
) => throw new NotImplementedException();
|
||||
|
||||
@@ -8,7 +8,19 @@ public abstract class MoqTest : IDisposable
|
||||
{
|
||||
protected MoqTest() => Repository = new(MockBehavior.Strict);
|
||||
|
||||
public void Dispose() => Repository.VerifyAll();
|
||||
protected virtual void Dispose(bool isDisposing)
|
||||
{
|
||||
if (isDisposing)
|
||||
{
|
||||
Repository.VerifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected MockRepository Repository { get; private set; } = new(MockBehavior.Strict);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TestProjectAnalyserRules>true</TestProjectAnalyserRules>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Data": {},
|
||||
"Message": "Response status code does not indicate success: 404 (Not Found).",
|
||||
"StatusCode": "NotFound",
|
||||
"Type": "HttpRequestException"
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Data": {},
|
||||
"Message": "Response status code does not indicate success: 404 (Not Found).",
|
||||
"StatusCode": "NotFound",
|
||||
"Type": "HttpRequestException"
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Data": {},
|
||||
"Message": "Response status code does not indicate success: 404 (Not Found).",
|
||||
"StatusCode": "NotFound",
|
||||
"Type": "HttpRequestException"
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Data": {},
|
||||
"Message": "Response status code does not indicate success: 404 (Not Found).",
|
||||
"StatusCode": "NotFound",
|
||||
"Type": "HttpRequestException"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Speckle.Sdk.Api;
|
||||
using Speckle.Sdk.Api.Blob;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Models;
|
||||
|
||||
namespace Speckle.Sdk.Tests.Integration.Api.GraphQL.Resources;
|
||||
|
||||
public class BlobApiExceptionalTests : IAsyncLifetime
|
||||
{
|
||||
private IBlobApi _sut;
|
||||
private IClient _client;
|
||||
private Project _project;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
||||
var account = await Fixtures.SeedUser().ConfigureAwait(false);
|
||||
_client = serviceProvider.GetRequiredService<IClientFactory>().Create(account);
|
||||
var factory = serviceProvider.GetRequiredService<IBlobApiFactory>();
|
||||
_project = await _client.Project.Create(new("test", null, null));
|
||||
_sut = factory.Create(account);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadBlob_Throws_NonExistentId()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
await _sut.DownloadBlob(_project.id, "non-existent-id", cancellationToken: CancellationToken.None)
|
||||
);
|
||||
await Verify(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadBlob_Throws_NonExistentProject()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
await _sut.DownloadBlob("non-existent-project", "non-existent-id", cancellationToken: CancellationToken.None)
|
||||
);
|
||||
await Verify(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadBlob_Throws_Cancellation()
|
||||
{
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await _sut.DownloadBlob(_project.id, "non-existent-id", cancellationToken: cancellationTokenSource.Token)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBlobs_Throws_NonExistentProject()
|
||||
{
|
||||
const string PAYLOAD = "Hello World!";
|
||||
string filePath = Path.GetTempFileName();
|
||||
await using (var writer = File.CreateText(filePath))
|
||||
{
|
||||
await writer.WriteLineAsync(PAYLOAD);
|
||||
}
|
||||
string id = HashUtility.HashFile(filePath);
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
await _sut.UploadBlobs("non-existent-project", [(id, filePath)], null, CancellationToken.None)
|
||||
);
|
||||
await Verify(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBlobs_Throws_Cancellation()
|
||||
{
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
await _sut.UploadBlobs(_project.id, [("id", "path")], null, cancellationTokenSource.Token)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasBlobs_Throws_Cancellation()
|
||||
{
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
await _sut.HasBlobs(_project.id, ["non-existent-id"], cancellationTokenSource.Token)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasBlobs_Throws_NonExistentProject()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
await _sut.HasBlobs("non-existent-project", ["non-existent-id"], CancellationToken.None)
|
||||
);
|
||||
await Verify(ex);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Speckle.Sdk.Api;
|
||||
using Speckle.Sdk.Api.Blob;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Logging;
|
||||
using Speckle.Sdk.Models;
|
||||
|
||||
namespace Speckle.Sdk.Tests.Integration.Api.Blob;
|
||||
|
||||
public class BlobApiTests : IAsyncLifetime
|
||||
{
|
||||
private IBlobApi _blobApi;
|
||||
private IClient _client;
|
||||
private Project _project;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
||||
var account = await Fixtures.SeedUser().ConfigureAwait(false);
|
||||
_client = serviceProvider.GetRequiredService<IClientFactory>().Create(account);
|
||||
var factory = serviceProvider.GetRequiredService<IBlobApiFactory>();
|
||||
_project = await _client.Project.Create(new("test", null, null));
|
||||
_blobApi = factory.Create(account);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Blob creation returns 201, but fetching the blob returns 404. Seems like a server regression")]
|
||||
public async Task BlobEndToEndTest()
|
||||
{
|
||||
//assemble
|
||||
const string PAYLOAD = "Hello World!";
|
||||
string filePath = Path.GetTempFileName();
|
||||
await using (var writer = File.CreateText(filePath))
|
||||
{
|
||||
await writer.WriteLineAsync(PAYLOAD);
|
||||
}
|
||||
string id = HashUtility.HashFile(filePath);
|
||||
|
||||
//act
|
||||
var preDiff = await _blobApi.HasBlobs(_project.id, [id], CancellationToken.None);
|
||||
await _blobApi.UploadBlobs(_project.id, [(id, filePath)], null, CancellationToken.None);
|
||||
var postDiff = await _blobApi.HasBlobs(_project.id, [id], CancellationToken.None);
|
||||
var res = await _blobApi.DownloadBlob(_project.id, id);
|
||||
|
||||
//assert
|
||||
preDiff.Should().BeEquivalentTo([id]);
|
||||
postDiff.Should().BeEquivalentTo([]);
|
||||
var file = new FileInfo(res);
|
||||
file.Name.Should().StartWith(id[..Models.Blob.LocalHashPrefixLength]);
|
||||
file.Directory?.FullName.Should().Be(SpecklePathProvider.BlobStoragePath());
|
||||
|
||||
string[] lines = await File.ReadAllLinesAsync(res);
|
||||
lines[0].Should().Be(PAYLOAD);
|
||||
lines.Length.Should().Be(1);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
|
||||
|
||||
public class CommentResourceTests : IAsyncLifetime
|
||||
{
|
||||
public const string SERVER_SKIP_MESSAGE =
|
||||
"comment creation started failing, server responds with 'Attempting to attach invalid blobs to comment', I cba to troubleshoot right now";
|
||||
private IClient _testUser;
|
||||
private CommentResource Sut;
|
||||
private Project _project;
|
||||
@@ -35,7 +37,7 @@ public class CommentResourceTests : IAsyncLifetime
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SERVER_SKIP_MESSAGE)]
|
||||
public async Task Get()
|
||||
{
|
||||
var comment = await Sut.Get(_comment.id, _project.id);
|
||||
@@ -45,7 +47,7 @@ public class CommentResourceTests : IAsyncLifetime
|
||||
comment.authorId.Should().Be(_testUser.Account.userInfo.id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SERVER_SKIP_MESSAGE)]
|
||||
public async Task GetProjectComments()
|
||||
{
|
||||
var comments = await Sut.GetProjectComments(_project.id);
|
||||
@@ -63,7 +65,7 @@ public class CommentResourceTests : IAsyncLifetime
|
||||
comment.createdAt.Should().Be(_comment.createdAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SERVER_SKIP_MESSAGE)]
|
||||
public async Task MarkViewed()
|
||||
{
|
||||
await Sut.MarkViewed(new(_comment.id, _project.id));
|
||||
@@ -72,7 +74,7 @@ public class CommentResourceTests : IAsyncLifetime
|
||||
res.viewedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SERVER_SKIP_MESSAGE)]
|
||||
public async Task Archive()
|
||||
{
|
||||
await Sut.Archive(new(_comment.id, _project.id, true));
|
||||
@@ -86,7 +88,7 @@ public class CommentResourceTests : IAsyncLifetime
|
||||
unarchived.archived.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SERVER_SKIP_MESSAGE)]
|
||||
public async Task Edit()
|
||||
{
|
||||
var blobs = await Fixtures.SendBlobData(_testUser.Account, _project.id);
|
||||
@@ -102,7 +104,7 @@ public class CommentResourceTests : IAsyncLifetime
|
||||
editedComment.updatedAt.Should().BeOnOrAfter(_comment.updatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SERVER_SKIP_MESSAGE)]
|
||||
public async Task Reply()
|
||||
{
|
||||
var blobs = await Fixtures.SendBlobData(_testUser.Account, _project.id);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Speckle.Sdk.Api;
|
||||
using Speckle.Sdk.Api.GraphQL.Inputs;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Api.GraphQL.Resources;
|
||||
|
||||
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
|
||||
|
||||
public class FileUploadResourceTests : IAsyncLifetime
|
||||
{
|
||||
private FileImportResource Sut => _client.FileImport;
|
||||
private IClient _client;
|
||||
private Project _project;
|
||||
private FileInfo _payload;
|
||||
private const string PAYLOAD_CONTENTS = "Hello World!";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
||||
var account = await Fixtures.SeedUser().ConfigureAwait(false);
|
||||
_client = serviceProvider.GetRequiredService<IClientFactory>().Create(account);
|
||||
_project = await _client.Project.Create(new("test", null, null));
|
||||
|
||||
string filePath = $"{Path.GetTempPath()}/{Guid.NewGuid()}.ifc";
|
||||
await using (var writer = File.CreateText(filePath))
|
||||
{
|
||||
await writer.WriteLineAsync(PAYLOAD_CONTENTS);
|
||||
}
|
||||
|
||||
_payload = new FileInfo(filePath);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
if (File.Exists(_payload.FullName))
|
||||
{
|
||||
File.Delete(_payload.FullName);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadUrl_CreatesUrl()
|
||||
{
|
||||
var input = new GenerateFileUploadUrlInput(_project.id, "foo.txt");
|
||||
|
||||
var res = await Sut.GenerateUploadUrl(input);
|
||||
res.fileId.Should().HaveLength(10);
|
||||
|
||||
//Just check the url path is expected. The query string will contain signatures and dates...
|
||||
var expectedUrlPath = new Uri(
|
||||
_client.ServerUrl,
|
||||
$"http://127.0.0.1:9000/speckle-server/assets/{_project.id}/{res.fileId}"
|
||||
);
|
||||
new Uri(res.url.GetLeftPart(UriPartial.Path)).Should().Be(expectedUrlPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadThenDownloadFile()
|
||||
{
|
||||
//act
|
||||
var input = new GenerateFileUploadUrlInput(_project.id, _payload.Name);
|
||||
var res = await Sut.GenerateUploadUrl(input);
|
||||
_ = await Sut.UploadFile(_payload.FullName, res.url);
|
||||
|
||||
string temp = Path.GetTempFileName();
|
||||
await Sut.DownloadFile(_project.id, res.fileId, temp);
|
||||
|
||||
//assert
|
||||
File.ReadAllLines(temp).Should().BeEquivalentTo([PAYLOAD_CONTENTS]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task StartAndFinishJobFail(bool testSuccessCase)
|
||||
{
|
||||
//assemble
|
||||
Model model = await _client.Model.Create(new("test model", null, _project.id));
|
||||
var uploadUrl = await Sut.GenerateUploadUrl(new GenerateFileUploadUrlInput(_project.id, _payload.Name));
|
||||
string etag = await Sut.UploadFile(_payload.FullName, uploadUrl.url);
|
||||
FileImportResult fakeResult = new(100, 100, 100, "integrationTests", "some value");
|
||||
|
||||
//act
|
||||
FileImport job = await Sut.StartFileImportJob(new(_project.id, model.id, uploadUrl.fileId, etag));
|
||||
var prePendingJobs = await Sut.GetModelFileImportJobs(_project.id, model.id);
|
||||
|
||||
FileImportInputBase input;
|
||||
if (testSuccessCase)
|
||||
{
|
||||
input = new FileImportSuccessInput()
|
||||
{
|
||||
projectId = _project.id,
|
||||
jobId = job.id,
|
||||
result = fakeResult,
|
||||
warnings = [],
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
input = new FileImportErrorInput()
|
||||
{
|
||||
projectId = _project.id,
|
||||
jobId = job.id,
|
||||
reason = "We're testing failure!",
|
||||
result = fakeResult,
|
||||
warnings = [],
|
||||
};
|
||||
}
|
||||
|
||||
bool res = await Sut.FinishFileImportJob(input, CancellationToken.None);
|
||||
|
||||
var postPendingJobs = await Sut.GetModelFileImportJobs(_project.id, model.id);
|
||||
|
||||
//assert
|
||||
prePendingJobs.items.Should().HaveCount(1);
|
||||
prePendingJobs.items.Where(x => x.convertedStatus == 0).Should().HaveCount(1);
|
||||
res.Should().BeTrue();
|
||||
postPendingJobs.items.Should().HaveCount(1);
|
||||
postPendingJobs.items.Where(x => x.convertedStatus == 0).Should().HaveCount(0);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -93,7 +93,7 @@ public class ProjectResourceExceptionalTests : IAsyncLifetime
|
||||
new(_testProject.id, "My new name", ProjectVisibility.Public, "NonExistentWorkspace")
|
||||
)
|
||||
);
|
||||
ex.InnerExceptions.Single().Should().BeOfType<SpeckleGraphQLException>();
|
||||
ex.InnerExceptions.Single().Should().BeOfType<SpeckleGraphQLForbiddenException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
+31
-30
@@ -10,7 +10,12 @@ namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
|
||||
|
||||
public class SubscriptionResourceTests : IAsyncLifetime
|
||||
{
|
||||
private const int WAIT_PERIOD = 300;
|
||||
#if DEBUG
|
||||
private const int WAIT_PERIOD = 3000; // WSL is slow AF, so for local runs, we're being extra generous
|
||||
#else
|
||||
private const int WAIT_PERIOD = 400; // For CI runs, a much smaller wait time is acceptable
|
||||
#endif
|
||||
private const int TIMEOUT = WAIT_PERIOD + WAIT_PERIOD + 400;
|
||||
private IClient _testUser;
|
||||
private Project _testProject;
|
||||
private Model _testModel;
|
||||
@@ -32,105 +37,101 @@ public class SubscriptionResourceTests : IAsyncLifetime
|
||||
_testVersion = await Fixtures.CreateVersion(_testUser, _testProject.id, _testModel.id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Timeout = TIMEOUT)]
|
||||
public async Task UserProjectsUpdated_SubscriptionIsCalled()
|
||||
{
|
||||
UserProjectsUpdatedMessage? subscriptionMessage = null;
|
||||
|
||||
TaskCompletionSource<UserProjectsUpdatedMessage> tcs = new();
|
||||
using var sub = Sut.CreateUserProjectsUpdatedSubscription();
|
||||
sub.Listeners += (_, message) => subscriptionMessage = message;
|
||||
sub.Listeners += (_, message) => tcs.SetResult(message);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
|
||||
|
||||
var created = await _testUser.Project.Create(new(null, null, null));
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
|
||||
var subscriptionMessage = await tcs.Task;
|
||||
|
||||
subscriptionMessage.Should().NotBeNull();
|
||||
subscriptionMessage!.id.Should().Be(created.id);
|
||||
subscriptionMessage.id.Should().Be(created.id);
|
||||
subscriptionMessage.type.Should().Be(UserProjectsUpdatedMessageType.ADDED);
|
||||
subscriptionMessage.project.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Timeout = TIMEOUT)]
|
||||
public async Task ProjectModelsUpdated_SubscriptionIsCalled()
|
||||
{
|
||||
ProjectModelsUpdatedMessage? subscriptionMessage = null;
|
||||
|
||||
TaskCompletionSource<ProjectModelsUpdatedMessage> tcs = new();
|
||||
using var sub = Sut.CreateProjectModelsUpdatedSubscription(_testProject.id);
|
||||
sub.Listeners += (_, message) => subscriptionMessage = message;
|
||||
sub.Listeners += (_, message) => tcs.SetResult(message);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
|
||||
|
||||
CreateModelInput input = new("my model", "myDescription", _testProject.id);
|
||||
var created = await _testUser.Model.Create(input);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
|
||||
var subscriptionMessage = await tcs.Task;
|
||||
|
||||
subscriptionMessage.Should().NotBeNull();
|
||||
subscriptionMessage!.id.Should().Be(created.id);
|
||||
subscriptionMessage.id.Should().Be(created.id);
|
||||
subscriptionMessage.type.Should().Be(ProjectModelsUpdatedMessageType.CREATED);
|
||||
subscriptionMessage.model.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Timeout = TIMEOUT)]
|
||||
public async Task ProjectUpdated_SubscriptionIsCalled()
|
||||
{
|
||||
ProjectUpdatedMessage? subscriptionMessage = null;
|
||||
|
||||
TaskCompletionSource<ProjectUpdatedMessage> tcs = new();
|
||||
using var sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
|
||||
sub.Listeners += (_, message) => subscriptionMessage = message;
|
||||
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);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
|
||||
var subscriptionMessage = await tcs.Task;
|
||||
|
||||
subscriptionMessage.Should().NotBeNull();
|
||||
subscriptionMessage!.id.Should().Be(created.id);
|
||||
subscriptionMessage.id.Should().Be(created.id);
|
||||
subscriptionMessage.type.Should().Be(ProjectUpdatedMessageType.UPDATED);
|
||||
subscriptionMessage.project.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Timeout = TIMEOUT)]
|
||||
public async Task ProjectVersionsUpdated_SubscriptionIsCalled()
|
||||
{
|
||||
ProjectVersionsUpdatedMessage? subscriptionMessage = null;
|
||||
|
||||
TaskCompletionSource<ProjectVersionsUpdatedMessage> tcs = new();
|
||||
using var sub = Sut.CreateProjectVersionsUpdatedSubscription(_testProject.id);
|
||||
sub.Listeners += (_, message) => subscriptionMessage = message;
|
||||
sub.Listeners += (_, message) => tcs.SetResult(message);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
|
||||
|
||||
var created = await Fixtures.CreateVersion(_testUser, _testProject.id, _testModel.id);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
|
||||
var subscriptionMessage = await tcs.Task;
|
||||
|
||||
subscriptionMessage.Should().NotBeNull();
|
||||
subscriptionMessage!.id.Should().Be(created.id);
|
||||
subscriptionMessage.id.Should().Be(created.id);
|
||||
subscriptionMessage.type.Should().Be(ProjectVersionsUpdatedMessageType.CREATED);
|
||||
subscriptionMessage.version.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = CommentResourceTests.SERVER_SKIP_MESSAGE, Timeout = TIMEOUT)]
|
||||
public async Task ProjectCommentsUpdated_SubscriptionIsCalled()
|
||||
{
|
||||
string resourceIdString = $"{_testProject.id},{_testModel.id},{_testVersion}";
|
||||
ProjectCommentsUpdatedMessage? subscriptionMessage = null;
|
||||
|
||||
TaskCompletionSource<ProjectCommentsUpdatedMessage> tcs = new();
|
||||
using var sub = Sut.CreateProjectCommentsUpdatedSubscription(new(_testProject.id, resourceIdString));
|
||||
sub.Listeners += (_, message) => subscriptionMessage = message;
|
||||
sub.Listeners += (_, message) => tcs.SetResult(message);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
|
||||
|
||||
var created = await Fixtures.CreateComment(_testUser, _testProject.id, _testModel.id, _testVersion.id);
|
||||
|
||||
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
|
||||
var subscriptionMessage = await tcs.Task;
|
||||
|
||||
subscriptionMessage.Should().NotBeNull();
|
||||
subscriptionMessage!.id.Should().Be(created.id);
|
||||
subscriptionMessage.id.Should().Be(created.id);
|
||||
subscriptionMessage.type.Should().Be(ProjectCommentsUpdatedMessageType.CREATED);
|
||||
subscriptionMessage.comment.Should().NotBeNull();
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using GraphQL.Client.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Credentials;
|
||||
|
||||
namespace Speckle.Sdk.Tests.Integration.Credentials;
|
||||
|
||||
public class UserServerInfoTests : IAsyncLifetime
|
||||
{
|
||||
private Account _acc;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_acc = await Fixtures.SeedUser();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsFrontEnd2True()
|
||||
{
|
||||
ServerInfo result = await Fixtures
|
||||
.ServiceProvider.GetRequiredService<IAccountManager>()
|
||||
.GetServerInfo(new("https://app.speckle.systems/"));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.frontend2.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetServerInfo_ExpectFail_NoServer()
|
||||
{
|
||||
Uri serverUrl = new("http://invalidserver.local");
|
||||
|
||||
await FluentActions
|
||||
.Invoking(async () =>
|
||||
await Fixtures.ServiceProvider.GetRequiredService<IAccountManager>().GetServerInfo(serverUrl)
|
||||
)
|
||||
.Should()
|
||||
.ThrowAsync<HttpRequestException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserInfo()
|
||||
{
|
||||
Uri serverUrl = new(_acc.serverInfo.url);
|
||||
UserInfo result = await Fixtures
|
||||
.ServiceProvider.GetRequiredService<IAccountManager>()
|
||||
.GetUserInfo(_acc.token, serverUrl);
|
||||
|
||||
result.id.Should().Be(_acc.userInfo.id);
|
||||
result.name.Should().Be(_acc.userInfo.name);
|
||||
result.email.Should().Be(_acc.userInfo.email);
|
||||
result.company.Should().Be(_acc.userInfo.company);
|
||||
result.avatar.Should().Be(_acc.userInfo.avatar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserInfo_ExpectFail_NoServer()
|
||||
{
|
||||
Uri serverUrl = new("http://invalidserver.local");
|
||||
|
||||
await FluentActions
|
||||
.Invoking(async () =>
|
||||
await Fixtures.ServiceProvider.GetRequiredService<IAccountManager>().GetUserInfo("", serverUrl)
|
||||
)
|
||||
.Should()
|
||||
.ThrowAsync<HttpRequestException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserInfo_ExpectFail_NoUser()
|
||||
{
|
||||
Uri serverUrl = new(_acc.serverInfo.url);
|
||||
await FluentActions
|
||||
.Invoking(async () =>
|
||||
await Fixtures
|
||||
.ServiceProvider.GetRequiredService<IAccountManager>()
|
||||
.GetUserInfo("Bearer 08913c3c1e7ac65d779d1e1f11b942a44ad9672ca9", serverUrl)
|
||||
)
|
||||
.Should()
|
||||
.ThrowAsync<GraphQLHttpRequestException>();
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,12 @@ using Speckle.Sdk.Common;
|
||||
using Speckle.Sdk.Credentials;
|
||||
using Speckle.Sdk.Host;
|
||||
using Speckle.Sdk.Models;
|
||||
using Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
|
||||
using Speckle.Sdk.Transports;
|
||||
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
|
||||
|
||||
[assembly: AssemblyTrait("Category", "Integration")]
|
||||
|
||||
namespace Speckle.Sdk.Tests.Integration;
|
||||
|
||||
public static class Fixtures
|
||||
@@ -142,6 +145,7 @@ public static class Fixtures
|
||||
return new Blob(filePath);
|
||||
}
|
||||
|
||||
[Obsolete(CommentResourceTests.SERVER_SKIP_MESSAGE)]
|
||||
internal static async Task<Comment> CreateComment(IClient client, string projectId, string modelId, string versionId)
|
||||
{
|
||||
var blobs = await SendBlobData(client.Account, projectId);
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Data": {},
|
||||
"Message": "Response status code does not indicate success: 404 (Not Found).",
|
||||
"StatusCode": "NotFound",
|
||||
"Type": "HttpRequestException"
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Data": {},
|
||||
"Message": "Response status code does not indicate success: 404 (Not Found).",
|
||||
"StatusCode": "NotFound",
|
||||
"Type": "HttpRequestException"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ConvertedReferences": {},
|
||||
"RootId": "5313a8f61e1fa7abe9bf716ddfc767bd"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Data": {},
|
||||
"InnerException": {
|
||||
"$type": "SpeckleSerializeException",
|
||||
"Data": {},
|
||||
"InnerException": {
|
||||
"$type": "ArgumentException",
|
||||
"Data": {},
|
||||
"Message": "Unsupported value in serialization: System.Text.StringBuilder",
|
||||
"ParamName": "obj",
|
||||
"Type": "ArgumentException"
|
||||
},
|
||||
"Message": "Failed to extract (pre-serialize) properties from the Speckle.Sdk.Models.Base",
|
||||
"Type": "SpeckleSerializeException"
|
||||
},
|
||||
"Message": "Error while sending: Failed to extract (pre-serialize) properties from the Speckle.Sdk.Models.Base",
|
||||
"Type": "SpeckleException"
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Reflection;
|
||||
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;
|
||||
|
||||
public sealed class SendReceiveTests : IAsyncLifetime
|
||||
{
|
||||
private Project _project;
|
||||
private IClient _client;
|
||||
private IOperations _operations;
|
||||
private const string NON_EXISTENT_OBJECT_ID = "0a480dfb7aa774f19a82bee9d6320abd";
|
||||
private const string NON_EXISTENT_PROJECT_ID = "8cdc651d13";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
TypeLoader.Reset();
|
||||
TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly());
|
||||
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
||||
_operations = serviceProvider.GetRequiredService<IOperations>();
|
||||
ClearCache();
|
||||
|
||||
_client = await Fixtures.SeedUserWithClient();
|
||||
_project = await _client.Project.Create(new("Blobber", "Flobber", ProjectVisibility.Private));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAndReceive()
|
||||
{
|
||||
var myObject = Fixtures.GenerateNestedObject();
|
||||
string expectedId = myObject.GetId(true);
|
||||
|
||||
//SEND
|
||||
var fistSend = await _operations.Send2(
|
||||
_client.ServerUrl,
|
||||
_project.id,
|
||||
_client.Account.token,
|
||||
myObject,
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(expectedId, fistSend.RootId);
|
||||
await Verify(fistSend);
|
||||
|
||||
//RECEIVE
|
||||
var received = await _operations.Receive2(
|
||||
_client.ServerUrl,
|
||||
_project.id,
|
||||
fistSend.RootId,
|
||||
_client.Account.token,
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(expectedId, received.id);
|
||||
|
||||
//SEND AGAIN!
|
||||
var secondSend = await _operations.Send2(
|
||||
_client.ServerUrl,
|
||||
_project.id,
|
||||
_client.Account.token,
|
||||
received,
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(expectedId, secondSend.RootId);
|
||||
|
||||
//RECEIVE AGAIN, but using cache
|
||||
ClearCache();
|
||||
var secondReceive = await _operations.Receive2(
|
||||
_client.ServerUrl,
|
||||
_project.id,
|
||||
fistSend.RootId,
|
||||
_client.Account.token,
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(expectedId, secondReceive.id);
|
||||
}
|
||||
|
||||
private void ClearCache() { }
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveNonExistentObjectThrows()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
_ = await _operations.Receive2(
|
||||
_client.ServerUrl,
|
||||
_project.id,
|
||||
NON_EXISTENT_OBJECT_ID,
|
||||
_client.Account.token,
|
||||
null,
|
||||
CancellationToken.None,
|
||||
new(true)
|
||||
);
|
||||
});
|
||||
await Verify(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveNonExistentProjectThrows()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
_ = await _operations.Receive2(
|
||||
_client.ServerUrl,
|
||||
NON_EXISTENT_PROJECT_ID,
|
||||
NON_EXISTENT_OBJECT_ID,
|
||||
_client.Account.token,
|
||||
null,
|
||||
CancellationToken.None,
|
||||
new(true)
|
||||
);
|
||||
});
|
||||
await Verify(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendInvalidData()
|
||||
{
|
||||
var myObject = Fixtures.GenerateNestedObject();
|
||||
myObject["invalidProp"] = new StringBuilder(); //Serializer does not support serializing this type
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpeckleException>(async () =>
|
||||
{
|
||||
_ = await _operations.Send2(
|
||||
_client.ServerUrl,
|
||||
_project.id,
|
||||
_client.Account.token,
|
||||
myObject,
|
||||
null,
|
||||
CancellationToken.None,
|
||||
new(SkipCacheRead: true, SkipCacheWrite: true)
|
||||
);
|
||||
});
|
||||
await Verify(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveNonAuthThrows()
|
||||
{
|
||||
using IClient unauthed = Fixtures.Unauthed;
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
_ = await _operations.Receive2(
|
||||
unauthed.ServerUrl,
|
||||
_project.id,
|
||||
NON_EXISTENT_OBJECT_ID,
|
||||
unauthed.Account.token,
|
||||
null,
|
||||
CancellationToken.None,
|
||||
new(true)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveCancellation()
|
||||
{
|
||||
using CancellationTokenSource ct = new();
|
||||
await ct.CancelAsync();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
_ = await _operations.Receive2(
|
||||
_client.ServerUrl,
|
||||
_project.id,
|
||||
NON_EXISTENT_OBJECT_ID,
|
||||
_client.Account.token,
|
||||
null,
|
||||
ct.Token,
|
||||
new(true)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TestProjectAnalyserRules>true</TestProjectAnalyserRules>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" />
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using RichardSzalay.MockHttp;
|
||||
using Speckle.Sdk.Api;
|
||||
using Speckle.Sdk.Api.Blob;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Api.GraphQL.Serializer;
|
||||
using Speckle.Sdk.Credentials;
|
||||
@@ -45,6 +46,7 @@ public class ClientTests : MoqTest
|
||||
Create<ILogger<Client>>(MockBehavior.Loose).Object,
|
||||
Create<ISdkActivityFactory>(MockBehavior.Loose).Object,
|
||||
graphqlClientFactory.Object,
|
||||
Create<IBlobApiFactory>(MockBehavior.Loose).Object,
|
||||
account
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ public class GraphQLErrorHandlerTests
|
||||
{
|
||||
yield return [typeof(SpeckleGraphQLForbiddenException), new Map { { "code", "FORBIDDEN" } }];
|
||||
yield return [typeof(SpeckleGraphQLForbiddenException), new Map { { "code", "UNAUTHENTICATED" } }];
|
||||
yield return [typeof(SpeckleGraphQLForbiddenException), new Map { { "code", "UNAUTHORIZED_ACCESS_ERROR" } }];
|
||||
yield return [typeof(SpeckleGraphQLInternalErrorException), new Map { { "code", "INTERNAL_SERVER_ERROR" } }];
|
||||
yield return [typeof(SpeckleGraphQLStreamNotFoundException), new Map { { "code", "STREAM_NOT_FOUND" } }];
|
||||
yield return [typeof(SpeckleGraphQLBadInputException), new Map { { "code", "BAD_USER_INPUT" } }];
|
||||
|
||||
@@ -9,7 +9,7 @@ using Speckle.Sdk.Testing;
|
||||
|
||||
namespace Speckle.Sdk.Tests.Unit.Credentials;
|
||||
|
||||
public class AccountManagerTests : MoqTest
|
||||
public sealed class AccountManagerTests : MoqTest
|
||||
{
|
||||
private class TestAccountFactory : IAccountFactory
|
||||
{
|
||||
@@ -36,7 +36,9 @@ public class AccountManagerTests : MoqTest
|
||||
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountStorage;
|
||||
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountAddLockStorage;
|
||||
|
||||
#pragma warning disable CA2213
|
||||
private readonly AccountManager _accountManager;
|
||||
#pragma warning restore CA2213
|
||||
|
||||
public AccountManagerTests()
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ public abstract class PrimitiveTestFixture
|
||||
}.Select(x => new object[] { x });
|
||||
|
||||
public static Half[] Float16TestCases { get; } =
|
||||
[default, Half.Epsilon, Half.MaxValue, Half.MinValue, Half.PositiveInfinity, Half.NegativeInfinity, Half.NaN];
|
||||
[default, Half.Epsilon, Half.MaxValue, Half.MinValue, Half.PositiveInfinity, Half.NegativeInfinity, Half.NaN];
|
||||
|
||||
public static float[] FloatIntegralTestCases { get; } = [0, 1, int.MaxValue, int.MinValue];
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ public class SerializeProcessRecordExceptionTests : MoqTest
|
||||
baseChildFinderMock.Object,
|
||||
baseSerializerMock.Object,
|
||||
loggerFactoryMock.Object,
|
||||
new(),
|
||||
cts.Token
|
||||
);
|
||||
var ex = new Exception("Test error");
|
||||
@@ -67,6 +68,7 @@ public class SerializeProcessRecordExceptionTests : MoqTest
|
||||
baseChildFinderMock.Object,
|
||||
baseSerializerMock.Object,
|
||||
loggerFactoryMock.Object,
|
||||
new(),
|
||||
cts.Token
|
||||
);
|
||||
var ex = new OperationCanceledException();
|
||||
@@ -98,6 +100,7 @@ public class SerializeProcessRecordExceptionTests : MoqTest
|
||||
baseChildFinderMock.Object,
|
||||
baseSerializerMock.Object,
|
||||
loggerFactoryMock.Object,
|
||||
new(),
|
||||
cts.Token
|
||||
);
|
||||
var ex = new AggregateException(new OperationCanceledException());
|
||||
|
||||
Reference in New Issue
Block a user