Compare commits

..

4 Commits

Author SHA1 Message Date
Jedd Morgan 719581bc12 Record activity exceptions (#467)
.NET Build and Publish / build (push) Has been cancelled
2026-04-02 12:36:02 +01:00
Jedd Morgan 74d40e40a9 Optimization for the disk store string writing to avoid memory allocations (#466)
.NET Build and Publish / build (push) Has been cancelled
2026-03-31 10:07:08 +00:00
dependabot[bot] c81692ee5a chore(deps): bump codecov/codecov-action from 5 to 6 (#465)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 21:42:44 +01:00
Jedd Morgan 7f50987201 Auth flow updates (#464)
.NET Build and Publish / build (push) Has been cancelled
* Auth flow updates

* remove obsolete flag on refresh
2026-03-26 14:42:21 +01:00
7 changed files with 113 additions and 111 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
run: dotnet test ${{ env.Solution }} --filter "(Category=Integration)&(Server!=Public)" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
continue-on-error: true
with:
fail_ci_if_error: true
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
continue-on-error: true
with:
fail_ci_if_error: true
+2 -2
View File
@@ -154,10 +154,10 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
activity?.SetStatus(SdkActivityStatusCode.Ok);
return ret;
}
catch (Exception)
catch (Exception ex)
{
activity?.SetStatus(SdkActivityStatusCode.Error);
// Don't record exception as it's rethrown.
activity?.RecordException(ex);
throw;
}
}
+8 -41
View File
@@ -142,59 +142,26 @@ public sealed class AccountManager(
/// <summary>
/// Refetches the <paramref name="account"/> information, including <see cref="ServerInfo"/> and <see cref="UserInfo"/>
/// If the <see cref="Account.token"/> looks to be expired, this function will also attempt to use the <see cref="Account.refreshToken"/> to refresh it.
///
/// Will only mutate <paramref name="account"/> in memory only, and only if successful.
/// </summary>
/// <seealso cref="UpdateAccount"/>
/// <param name="account"></param>
/// <param name="cancellationToken"></param>
/// <exception cref="AggregateException">Thrown if</exception>
/// <exception cref="GraphQLHttpRequestException"></exception>
public async Task UpdateAccountInMemory(Account account, CancellationToken cancellationToken = default)
{
Uri url = account.serverInfo.migration?.movedTo ?? new(account.serverInfo.url);
ActiveUserServerInfoResponse userServerInfo;
try
ActiveUserServerInfoResponse userServerInfo = await accountFactory
.GetUserServerInfo(url, account.token, cancellationToken)
.ConfigureAwait(false);
if (userServerInfo.activeUser == null)
{
userServerInfo = await accountFactory
.GetUserServerInfo(url, account.token, cancellationToken)
.ConfigureAwait(false);
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
}
catch (GraphQLHttpRequestException ex)
{
// Failed to fetch info, perhaps the token is expired?
// Attempt to refresh it
TokenExchangeResponse refreshTokenResponse;
try
{
refreshTokenResponse = await authFlow
.GetRefreshedToken(
account.refreshToken.NotNull("No refresh token provided"),
url,
AuthApp.ConnectorsV3,
cancellationToken
)
.ConfigureAwait(false);
userServerInfo = await accountFactory
.GetUserServerInfo(url, refreshTokenResponse.token, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex2)
{
throw new AggregateException("Failed to update account information", ex, ex2);
}
account.token = refreshTokenResponse.token;
account.refreshToken = refreshTokenResponse.refreshToken;
logger.LogInformation(ex, "Account token has been refreshed");
}
account.userInfo = userServerInfo.activeUser.NotNull();
account.userInfo = userServerInfo.activeUser;
account.serverInfo = userServerInfo.serverInfo;
//This is a bit gross, since id is not marked nullable
//but this will force re-generate the id (e.g. if the user's email, or servers url has changed)
+30 -27
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Diagnostics.Contracts;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
@@ -42,34 +43,45 @@ public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp s
Uri tokenEndpoint = new(serverUrl, "/oauth/token");
string codeVerifier = GenerateCodeVerifier();
string challenge;
string codeChallengeMethod;
var req = await client.GetAsync(tokenEndpoint, cancellationToken).ConfigureAwait(false);
Uri authnVerify;
using var req = await client.GetAsync(tokenEndpoint, cancellationToken).ConfigureAwait(false);
bool useLegacyEndpoint = req.StatusCode != HttpStatusCode.OK;
if (useLegacyEndpoint)
{
challenge = codeVerifier;
string challenge = codeVerifier; // Old endpoint only supports PKCE "plain" method
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}", UriKind.Relative);
tokenEndpoint = new(serverUrl, "/auth/token");
codeChallengeMethod = "";
}
else
{
challenge = GenerateCodeChallenge(codeVerifier);
codeChallengeMethod = "?code_challenge_method=S256";
string challenge = GenerateCodeChallenge(codeVerifier);
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}?code_challenge_method=S256", UriKind.Relative);
}
Uri endpoint = new(serverUrl, $"/authn/verify/{authApp.AppId}/{challenge}{codeChallengeMethod}");
Uri endpoint = new(serverUrl, authnVerify);
_ = Process.Start(new ProcessStartInfo(endpoint.ToString()) { UseShellExecute = true });
string accessCode = await RunListenerWithTimeout(authApp.CallbackUrl, timeout, cancellationToken)
.ConfigureAwait(false);
object body = useLegacyEndpoint
? new
{
appId = authApp.AppId,
appSecret = authApp.AppSecret,
accessCode = accessCode,
challenge = codeVerifier,
}
: new
{
appId = authApp.AppId,
accessCode = accessCode,
codeVerifier = codeVerifier,
};
return await ExchangeAccessCodeForToken(
client,
accessCode,
authApp,
useLegacyEndpoint ? challenge : null,
!useLegacyEndpoint ? codeVerifier : null,
JsonConvert.SerializeObject(body, _serializerSettings),
tokenEndpoint,
cancellationToken
)
@@ -141,7 +153,7 @@ public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp s
refreshToken = refreshToken,
};
using var content = new StringContent(JsonConvert.SerializeObject(body));
using var content = new StringContent(JsonConvert.SerializeObject(body, _serializerSettings));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await client
.PostAsync(new Uri(serverUrl, "/auth/token"), content, cancellationToken)
@@ -248,24 +260,12 @@ public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp s
private async Task<TokenExchangeResponse> ExchangeAccessCodeForToken(
HttpClient client,
string accessCode,
AuthApp authApp,
string? challenge,
string? codeVerifier,
string jsonContent,
Uri tokenEndpoint,
CancellationToken cancellationToken
)
{
var body = new
{
appId = authApp.AppId,
appSecret = authApp.AppSecret,
accessCode = accessCode,
challenge = challenge,
codeVerifier = codeVerifier,
};
using StringContent content = new(JsonConvert.SerializeObject(body, _serializerSettings));
using StringContent content = new(jsonContent);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using HttpResponseMessage response = await client
@@ -282,6 +282,7 @@ public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp s
return JsonConvert.DeserializeObject<TokenExchangeResponse>(read, _serializerSettings).NotNull();
}
[Pure]
public static string GenerateCodeVerifier()
{
#if NET8_0_OR_GREATER
@@ -296,6 +297,7 @@ public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp s
return Base64UrlEncode(codeVerifierData);
}
[Pure]
public static string GenerateCodeChallenge(string codeVerifier)
{
#if NET8_0_OR_GREATER
@@ -312,6 +314,7 @@ public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp s
return Base64UrlEncode(challengeData);
}
[Pure]
private static string Base64UrlEncode(
#if NET8_0_OR_GREATER
ReadOnlySpan<byte> bytes
+6 -1
View File
@@ -65,7 +65,12 @@ public sealed class DiskStore
await foreach (var item in _channel.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
{
await writer.WriteLineAsync($"{item.Id}\t{item.SpeckleType}\t{item.Json}").ConfigureAwait(false);
await writer.WriteAsync(item.Id).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.SpeckleType).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.Json.Value).ConfigureAwait(false);
await writer.WriteLineAsync().ConfigureAwait(false);
}
#if NET8_0_OR_GREATER
await writer.FlushAsync(_cancellationToken).ConfigureAwait(false);
+65 -38
View File
@@ -65,63 +65,90 @@ public sealed class Uploader : IDisposable
{
using var a = _activity.Start("Get Presigned Url");
var signUri = new Uri($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/sign", UriKind.Relative);
try
{
var signUri = new Uri($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/sign", UriKind.Relative);
using var signResponse = await _speckleClient.PostAsync(signUri, null, _cancellationToken).ConfigureAwait(false);
signResponse.EnsureSuccessStatusCode();
using var signResponse = await _speckleClient.PostAsync(signUri, null, _cancellationToken).ConfigureAwait(false);
signResponse.EnsureSuccessStatusCode();
#if NET5_0_OR_GREATER
string signResponseString = await signResponse.Content.ReadAsStringAsync(_cancellationToken).ConfigureAwait(false);
string signResponseString = await signResponse
.Content.ReadAsStringAsync(_cancellationToken)
.ConfigureAwait(false);
#else
string signResponseString = await signResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
string signResponseString = await signResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
PresignedUploadResponse presignedUpload =
JsonConvert.DeserializeObject<PresignedUploadResponse>(signResponseString)
?? throw new InvalidOperationException("Failed to get presigned upload URL");
return presignedUpload;
PresignedUploadResponse presignedUpload =
JsonConvert.DeserializeObject<PresignedUploadResponse>(signResponseString)
?? throw new InvalidOperationException("Failed to get presigned upload URL");
return presignedUpload;
}
catch (Exception ex)
{
a?.SetStatus(SdkActivityStatusCode.Error);
a?.RecordException(ex);
throw;
}
}
private async Task<string> UploadToS3(Stream fileStream, PresignedUploadResponse presignedUploadResponse)
{
using var a = _activity.Start("Uploading file to pre-signed url");
Stream progressStream = new ProgressStream(fileStream, _progress);
using var streamContent = new StreamContent(progressStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
streamContent.Headers.ContentLength = fileStream.Length;
using var uploadRequest = new HttpRequestMessage(HttpMethod.Put, presignedUploadResponse.Url);
foreach (var kvp in presignedUploadResponse.AdditionalRequestHeaders)
try
{
uploadRequest.Headers.Add(kvp.Key, kvp.Value);
Stream progressStream = new ProgressStream(fileStream, _progress);
using var streamContent = new StreamContent(progressStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
streamContent.Headers.ContentLength = fileStream.Length;
using var uploadRequest = new HttpRequestMessage(HttpMethod.Put, presignedUploadResponse.Url);
foreach (var kvp in presignedUploadResponse.AdditionalRequestHeaders)
{
uploadRequest.Headers.Add(kvp.Key, kvp.Value);
}
uploadRequest.Content = streamContent;
using var uploadResponse = await _s3Client
.SendAsync(uploadRequest, HttpCompletionOption.ResponseHeadersRead, _cancellationToken)
.ConfigureAwait(false);
uploadResponse.EnsureSuccessStatusCode();
return BlobApiHelpers.ParseEtagHeader(uploadResponse.Headers);
}
catch (Exception ex)
{
a?.SetStatus(SdkActivityStatusCode.Error);
a?.RecordException(ex);
throw;
}
uploadRequest.Content = streamContent;
using var uploadResponse = await _s3Client
.SendAsync(uploadRequest, HttpCompletionOption.ResponseHeadersRead, _cancellationToken)
.ConfigureAwait(false);
uploadResponse.EnsureSuccessStatusCode();
return BlobApiHelpers.ParseEtagHeader(uploadResponse.Headers);
}
private async Task TriggerProcessing(TriggerUploadRequest request)
{
using var a = _activity.Start("Triggering Processing");
try
{
Uri processUri = new($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/process", UriKind.Relative);
string requestBody = JsonConvert.SerializeObject(request);
using var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
Uri processUri = new($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/process", UriKind.Relative);
string requestBody = JsonConvert.SerializeObject(request);
using var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
using HttpResponseMessage processResponse = await _speckleClient
.PostAsync(processUri, content, _cancellationToken)
.ConfigureAwait(false);
using HttpResponseMessage processResponse = await _speckleClient
.PostAsync(processUri, content, _cancellationToken)
.ConfigureAwait(false);
string body = await processResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
processResponse.EnsureSuccessStatusCode();
string body = await processResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
processResponse.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
a?.SetStatus(SdkActivityStatusCode.Error);
a?.RecordException(ex);
throw;
}
}
public void Dispose()