diff --git a/src/Client/Backblaze.Client.csproj b/src/Client/Backblaze.Client.csproj
index 640a322..e4f968c 100644
--- a/src/Client/Backblaze.Client.csproj
+++ b/src/Client/Backblaze.Client.csproj
@@ -10,7 +10,7 @@
Microcompiler
Bytewizer Inc.
Backblaze B2 Cloud Storage
- 7.1
+ 8.0
Bytewizer.Backblaze
1.1.0
$(VersionPrefix)-$(VersionSuffix)
diff --git a/src/Client/Client/ApiRest.Endpoints.cs b/src/Client/Client/ApiRest.Endpoints.cs
index e7a91a8..d82c08c 100644
--- a/src/Client/Client/ApiRest.Endpoints.cs
+++ b/src/Client/Client/ApiRest.Endpoints.cs
@@ -13,6 +13,8 @@
using Bytewizer.Backblaze.Models;
using Bytewizer.Backblaze.Extensions;
+using Bytewizer.Backblaze.Client.Internal;
+
namespace Bytewizer.Backblaze.Client
{
public abstract partial class ApiRest : DisposableObject
@@ -47,7 +49,9 @@ public async Task> AuthorizeAccountAync
using (var results = await _policy.InvokeClient.ExecuteAsync(async () =>
{ return await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); }))
{
- return await HandleResponseAsync(results).ConfigureAwait(false);
+ var rawResult = await HandleResponseAsync(results).ConfigureAwait(false);
+
+ return new ApiResults(rawResult.HttpResponse, rawResult.Response.ToAuthorizedAccountResponse());
}
}
diff --git a/src/Client/Client/Internal/AllowedRaw.cs b/src/Client/Client/Internal/AllowedRaw.cs
new file mode 100644
index 0000000..d96d3d4
--- /dev/null
+++ b/src/Client/Client/Internal/AllowedRaw.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+using Newtonsoft.Json;
+
+using Bytewizer.Backblaze.Models;
+
+namespace Bytewizer.Backblaze.Client.Internal
+{
+ ///
+ /// Represents information related to an allowed authorization.
+ ///
+ [DebuggerDisplay("{DebuggerDisplay, nq}")]
+ internal class AllowedRaw
+ {
+ ///
+ /// Gets or sets a list of allowed.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public List Capabilities { get; set; }
+
+ ///
+ /// Gets or sets restricted access only to this bucket id.
+ ///
+ public string BucketId { get; set; }
+
+ ///
+ /// When bucket id is set and it is a valid bucket that has not been deleted this field is set to the name of the
+ /// bucket. It's possible that bucket id is set to a bucket that no longer exists in which case this field will be
+ /// null. It's also null when bucket id is null.
+ ///
+ public string BucketName { get; set; }
+
+ ///
+ /// Gets or sets restricted access to files whose names start with the prefix.
+ ///
+ public string NamePrefix { get; set; }
+
+ ///
+ /// Debugger display for this object.
+ ///
+ [DebuggerBrowsable(DebuggerBrowsableState.Never)]
+ private string DebuggerDisplay
+ {
+ get { return $"{{{nameof(Capabilities)}: {string.Join(", ", Capabilities)}, {nameof(NamePrefix)}: {NamePrefix}}}"; }
+ }
+
+ ///
+ /// Translates this instance to an instance of ,
+ /// parsing capabilities into the and
+ /// properties.
+ ///
+ /// An instance of .
+ public Allowed ParseCapabilities()
+ {
+ Allowed parsed = new Allowed();
+
+ parsed.BucketId = this.BucketId;
+ parsed.BucketName = this.BucketName;
+ parsed.NamePrefix = this.NamePrefix;
+
+ parsed.Capabilities = new Capabilities();
+ parsed.UnknownCapabilities = new List();
+
+ foreach (string capabilityName in this.Capabilities)
+ {
+ if (Enum.TryParse(capabilityName, out var parsedCapability))
+ parsed.Capabilities.Add(parsedCapability);
+ else
+ parsed.UnknownCapabilities.Add(capabilityName);
+ }
+
+ return parsed;
+ }
+ }
+}
diff --git a/src/Client/Client/Internal/AuthorizeAccountResponseRaw.cs b/src/Client/Client/Internal/AuthorizeAccountResponseRaw.cs
new file mode 100644
index 0000000..550909f
--- /dev/null
+++ b/src/Client/Client/Internal/AuthorizeAccountResponseRaw.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Diagnostics;
+
+using Newtonsoft.Json;
+
+using Bytewizer.Backblaze.Models;
+
+namespace Bytewizer.Backblaze.Client.Internal
+{
+ ///
+ /// Contains the results of authorize account request operation.
+ ///
+ [DebuggerDisplay("{DebuggerDisplay, nq}")]
+ internal class AuthorizeAccountResponseRaw : IResponse
+ {
+ ///
+ /// The identifier for the account.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public string AccountId { get; internal set; }
+
+ ///
+ /// An authorization token to use with all calls other than authorize account that need an authorization header.
+ /// This authorization token is valid for at most 24 hours.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public string AuthorizationToken { get; internal set; }
+
+ ///
+ /// The capabilities of this auth token and any restrictions on using it.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public AllowedRaw Allowed { get; internal set; }
+
+ ///
+ /// The base url to use for all API calls except for uploading and downloading files.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public Uri ApiUrl { get; internal set; }
+
+ ///
+ /// The base url to use for downloading files.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public Uri DownloadUrl { get; internal set; }
+
+ ///
+ /// The recommended size for each part of a large file. We recommend using this part size for optimal upload performance.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public long RecommendedPartSize { get; internal set; }
+
+ ///
+ /// The smallest possible size of a part of a large file (except the last one). This is smaller than the .
+ /// If you use it you may find that it takes longer overall to upload a large file.
+ ///
+ [JsonProperty(Required = Required.Always)]
+ public long AbsoluteMinimumPartSize { get; internal set; }
+
+ ///
+ /// OBSOLETE: This field will always have the same value as .
+ ///
+ [Obsolete("This field will always have the same value as 'RecommendedPartSize'.")]
+ public long MinimumPartSize { get; internal set; }
+
+ ///
+ /// Debugger display for this object.
+ ///
+ [DebuggerBrowsable(DebuggerBrowsableState.Never)]
+ private string DebuggerDisplay
+ {
+ get { return $"{{{nameof(AccountId)}: {AccountId}, {nameof(AuthorizationToken)}: {AuthorizationToken}}}"; }
+ }
+
+ internal AuthorizeAccountResponse ToAuthorizedAccountResponse()
+ {
+ var response = new AuthorizeAccountResponse();
+
+ response.AccountId = this.AccountId;
+ response.AuthorizationToken = this.AuthorizationToken;
+ response.Allowed = this.Allowed.ParseCapabilities();
+ response.ApiUrl = this.ApiUrl;
+ response.DownloadUrl = this.DownloadUrl;
+ response.RecommendedPartSize = this.RecommendedPartSize;
+ response.AbsoluteMinimumPartSize = this.AbsoluteMinimumPartSize;
+
+ #pragma warning disable 618
+ response.MinimumPartSize = this.MinimumPartSize;
+ #pragma warning restore 618
+
+ return response;
+ }
+ }
+}
diff --git a/src/Client/Models/Allowed.cs b/src/Client/Models/Allowed.cs
index 3caa1c5..f077983 100644
--- a/src/Client/Models/Allowed.cs
+++ b/src/Client/Models/Allowed.cs
@@ -1,4 +1,5 @@
-using System.Diagnostics;
+using System.Collections.Generic;
+using System.Diagnostics;
using Newtonsoft.Json;
@@ -16,6 +17,11 @@ public class Allowed
[JsonProperty(Required = Required.Always)]
public Capabilities Capabilities { get; set; }
+ ///
+ /// Gets or sets a list of capabilities that couldn't be parsed into values.
+ ///
+ public List UnknownCapabilities { get; set; }
+
///
/// Gets or sets restricted access only to this bucket id.
///
@@ -39,7 +45,17 @@ public class Allowed
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay
{
- get { return $"{{{nameof(Capabilities)}: {string.Join(", ", Capabilities)}, {nameof(NamePrefix)}: {NamePrefix}}}"; }
+ get
+ {
+ string unknownCapabilitiesString = "";
+
+ if ((this.UnknownCapabilities != null) && (this.UnknownCapabilities.Count > 0))
+ {
+ unknownCapabilitiesString = " (+ " + string.Join(", ", UnknownCapabilities) + ")";
+ }
+
+ return $"{{{nameof(Capabilities)}: {string.Join(", ", Capabilities)}{unknownCapabilitiesString}, {nameof(NamePrefix)}: {NamePrefix}}}";
+ }
}
}
}
diff --git a/src/Client/Properties/AssemblyInfo.cs b/src/Client/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..ee0cd3a
--- /dev/null
+++ b/src/Client/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Backblaze.Tests.Unit")]
diff --git a/test/Unit/ApiClientFixture.cs b/test/Unit/ApiClientFixture.cs
new file mode 100644
index 0000000..a8e60db
--- /dev/null
+++ b/test/Unit/ApiClientFixture.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+
+using Bytewizer.Backblaze.Client;
+using Bytewizer.Backblaze.Client.Internal;
+
+using Xunit;
+
+using Bogus;
+
+using NSubstitute;
+
+using FluentAssertions;
+
+using Bytewizer.Backblaze.Models;
+
+namespace Backblaze.Tests.Unit
+{
+ public class ApiClientFixture
+ {
+ [Fact]
+ public async Task AuthorizeAccountAync_should_parse_capabilities()
+ {
+ // Arrange
+ var faker = new Faker();
+
+ var dummyKeyId = faker.Random.Hash();
+ var dummyApplicationKey = faker.Random.Hash();
+ var dummyAccountId = faker.Random.Hash();
+ var dummyAuthorizationToken = faker.Random.Hash();
+ var dummyApiUrl = new Uri(faker.Internet.Url());
+ var dummyDownloadUrl = new Uri(faker.Internet.Url());
+ var dummyCapabilities = faker.PickRandom(Enum.GetValues(typeof(Capability)).OfType(), 5).ToList();
+ var dummyCapabilityStrings = dummyCapabilities.Select(c => c.ToString()).ToList();
+
+ var mockHttpClient = new HttpClient();
+
+ var clientOptions = new ClientOptions();
+
+ clientOptions.RequestMaxParallel = 1;
+ clientOptions.DownloadMaxParallel = 1;
+ clientOptions.UploadMaxParallel = 1;
+ clientOptions.TestMode = "";
+
+ var mockLogger = Substitute.For>();
+
+ var mockMemoryCache = Substitute.For();
+
+ using (var testServer = new TestServer())
+ {
+ testServer
+ .When("/b2_authorize_account")
+ .Then(
+ new AuthorizeAccountResponseRaw()
+ {
+ AccountId = dummyAccountId,
+ AuthorizationToken = dummyAuthorizationToken,
+ ApiUrl = dummyApiUrl,
+ DownloadUrl = dummyDownloadUrl,
+ Allowed =
+ new AllowedRaw()
+ {
+ Capabilities = dummyCapabilityStrings
+ }
+ });
+
+ var sut = new ApiClient(mockHttpClient, clientOptions, mockLogger, mockMemoryCache);
+
+ sut.AccountInfo.AuthUrl = testServer.BaseUrl;
+
+ // Act
+ var result = await sut.AuthorizeAccountAync(dummyKeyId, dummyApplicationKey, CancellationToken.None);
+
+ // Assert
+ result.IsSuccessStatusCode.Should().BeTrue();
+ result.Response.Should().NotBeNull();
+ result.Response.Allowed.Should().NotBeNull();
+ result.Response.Allowed.Capabilities.Should().BeEquivalentTo(dummyCapabilities);
+ result.Response.Allowed.UnknownCapabilities.Should().BeEmpty();
+ }
+ }
+
+ [Fact]
+ public async Task AuthorizeAccountAync_should_parse_unknown_capabilities()
+ {
+ // Arrange
+ var faker = new Faker();
+
+ var dummyKeyId = faker.Random.Hash();
+ var dummyApplicationKey = faker.Random.Hash();
+ var dummyAccountId = faker.Random.Hash();
+ var dummyAuthorizationToken = faker.Random.Hash();
+ var dummyApiUrl = new Uri(faker.Internet.Url());
+ var dummyDownloadUrl = new Uri(faker.Internet.Url());
+ var dummyCapabilities = faker.PickRandom(Enum.GetValues(typeof(Capability)).OfType(), 5).ToList();
+ var dummyCapabilityStrings = dummyCapabilities.Select(c => c.ToString()).ToList();
+
+ var unknownDummyCapabilities =
+ new[]
+ {
+ "UnknownCapability1",
+ "UnknownCapability2",
+ };
+
+ dummyCapabilityStrings.AddRange(unknownDummyCapabilities);
+
+ var mockHttpClient = new HttpClient();
+
+ var clientOptions = new ClientOptions();
+
+ clientOptions.RequestMaxParallel = 1;
+ clientOptions.DownloadMaxParallel = 1;
+ clientOptions.UploadMaxParallel = 1;
+ clientOptions.TestMode = "";
+
+ var mockLogger = Substitute.For>();
+
+ var mockMemoryCache = Substitute.For();
+
+ using (var testServer = new TestServer())
+ {
+ testServer
+ .When("/b2_authorize_account")
+ .Then(
+ new AuthorizeAccountResponseRaw()
+ {
+ AccountId = dummyAccountId,
+ AuthorizationToken = dummyAuthorizationToken,
+ ApiUrl = dummyApiUrl,
+ DownloadUrl = dummyDownloadUrl,
+ Allowed =
+ new AllowedRaw()
+ {
+ Capabilities = dummyCapabilityStrings
+ }
+ });
+
+ var sut = new ApiClient(mockHttpClient, clientOptions, mockLogger, mockMemoryCache);
+
+ sut.AccountInfo.AuthUrl = testServer.BaseUrl;
+
+ // Act
+ var result = await sut.AuthorizeAccountAync(dummyKeyId, dummyApplicationKey, CancellationToken.None);
+
+ // Assert
+ result.IsSuccessStatusCode.Should().BeTrue();
+ result.Response.Should().NotBeNull();
+ result.Response.Allowed.Should().NotBeNull();
+ result.Response.Allowed.Capabilities.Should().BeEquivalentTo(dummyCapabilities);
+ result.Response.Allowed.UnknownCapabilities.Should().BeEquivalentTo(unknownDummyCapabilities);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Unit/Backblaze.Tests.Unit.csproj b/test/Unit/Backblaze.Tests.Unit.csproj
index 321dd10..d044dc4 100644
--- a/test/Unit/Backblaze.Tests.Unit.csproj
+++ b/test/Unit/Backblaze.Tests.Unit.csproj
@@ -25,7 +25,14 @@
+
+
+
+
+
+
+
all
diff --git a/test/Unit/TestServer.cs b/test/Unit/TestServer.cs
new file mode 100644
index 0000000..4168b94
--- /dev/null
+++ b/test/Unit/TestServer.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+
+using Microsoft.Extensions.Hosting;
+
+namespace Backblaze.Tests.Unit
+{
+ class TestServer : IDisposable
+ {
+ WebApplication _application;
+
+ public WebApplication Application => _application;
+
+ public Uri BaseUrl => new Uri(_application.Urls.First());
+
+ public TestServer()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ _application = builder.Build();
+
+ // Without this route on the root, routes on subpaths seem to always return 404.
+ _application.MapGet("/", () => "Test server");
+
+ var lifetime = (IHostApplicationLifetime)_application.Services.GetService(typeof(IHostApplicationLifetime));
+
+ var started = new ManualResetEvent(initialState: false);
+
+ lifetime.ApplicationStarted.Register(() => started.Set());
+
+ Task.Run(() => _application.Run());
+
+ started.WaitOne();
+ }
+
+ public RouteBuilder When(string route) => When(HttpMethod.Get, route);
+ public RouteBuilder When(HttpMethod method, string route) => new RouteBuilder(this, method, route);
+
+ public class RouteBuilder
+ {
+ TestServer _server;
+ HttpMethod _method;
+ string _route;
+
+ public RouteBuilder(TestServer server, HttpMethod method, string route)
+ {
+ _server = server;
+ _method = method;
+ _route = route;
+ }
+
+ public void Then(object result)
+ => Then(() => Results.Json(result));
+
+ public void Then(Delegate action)
+ {
+ if (_method == HttpMethod.Get)
+ _server.Application.MapGet(_route, action);
+ else if (_method == HttpMethod.Post)
+ _server.Application.MapPost(_route, action);
+ else
+ throw new NotSupportedException("Not supported in TestServer: HTTP method " + _method);
+ }
+ }
+
+ public void Dispose()
+ {
+ var task = _application.StopAsync();
+
+ task.ConfigureAwait(false);
+ task.Wait();
+ }
+ }
+}
\ No newline at end of file