Skip to content

Commit 2c809b5

Browse files
committed
client
1 parent 3ec4bfa commit 2c809b5

File tree

10 files changed

+179
-9
lines changed

10 files changed

+179
-9
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,12 @@ If no new rule is detected → do not update the file.
8181
### Documentation (ALL TASKS)
8282

8383
- Docs live in `docs/` and `README.md`
84+
- Keep a GitHub Pages docs site in sync with `docs/`, using `DOCS-EXAMPLE/` as the reference template for structure and CI/pipeline
8485
- Update docs when behaviour changes
8586
- Update configuration examples when required
8687
- When adding new projects/providers, ensure `README.md` clearly documents installation, DI wiring, and basic usage examples
8788
- Where feasible, prefer provider options that can build vendor SDK clients from credentials (to reduce consumer boilerplate) while still allowing client injection for advanced scenarios
89+
- Avoid "ownership flags" like `ownsClient`; prefer a clear swap point (wrapper/factory) so lifetime and disposal rules stay simple and predictable
8890
- For providers that rely on vendor SDK clients (Graph/Drive/Dropbox/etc.), document how to obtain credentials/keys/tokens and include a minimal code snippet that builds the required SDK client instance
8991

9092
### Testing (ALL TASKS)

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,19 +225,30 @@ var tenantStorage = app.Services.GetRequiredKeyedService<IStorage>("tenant-a");
225225
var refreshToken = auth.RefreshToken; // store securely if you requested offline access
226226
```
227227

228-
5. Create the Dropbox client and register Dropbox storage with a root path (use `/` for full access apps or `/Apps/<your-app>` for app folders):
228+
5. Register Dropbox storage with a root path (use `/` for full access apps or `/Apps/<your-app>` for app folders). You can let the provider create the SDK client from credentials:
229229

230230
```csharp
231-
using Dropbox.Api;
232231
builder.Services.AddDropboxStorageAsDefault(options =>
233232
{
234233
var accessToken = configuration["Dropbox:AccessToken"]!;
235-
options.DropboxClient = new DropboxClient(accessToken);
234+
options.AccessToken = accessToken;
236235
options.RootPath = "/apps/my-app";
237236
options.CreateContainerIfNotExists = true;
238237
});
239238
```
240239

240+
Or, for production, prefer refresh tokens (offline access):
241+
242+
```csharp
243+
builder.Services.AddDropboxStorageAsDefault(options =>
244+
{
245+
options.RefreshToken = configuration["Dropbox:RefreshToken"]!;
246+
options.AppKey = configuration["Dropbox:AppKey"]!;
247+
options.AppSecret = configuration["Dropbox:AppSecret"]; // optional when using PKCE
248+
options.RootPath = "/apps/my-app";
249+
});
250+
```
251+
241252
6. Store tokens in user secrets or environment variables; never commit them to source control.
242253

243254
**CloudKit (iCloud app data)**

Storages/ManagedCode.Storage.Dropbox/Clients/DropboxClientWrapper.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
namespace ManagedCode.Storage.Dropbox.Clients;
1212

13-
public class DropboxClientWrapper : IDropboxClientWrapper
13+
public class DropboxClientWrapper : IDropboxClientWrapper, IDisposable
1414
{
1515
private readonly DropboxClient _client;
1616

@@ -19,6 +19,11 @@ public DropboxClientWrapper(DropboxClient client)
1919
_client = client ?? throw new ArgumentNullException(nameof(client));
2020
}
2121

22+
public void Dispose()
23+
{
24+
_client.Dispose();
25+
}
26+
2227
public async Task EnsureRootAsync(string rootPath, bool createIfNotExists, CancellationToken cancellationToken)
2328
{
2429
if (string.IsNullOrWhiteSpace(rootPath))

Storages/ManagedCode.Storage.Dropbox/DropboxStorage.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,47 @@ protected override IDropboxClientWrapper CreateStorageClient()
3636
return new DropboxClientWrapper(StorageOptions.DropboxClient);
3737
}
3838

39+
if (!string.IsNullOrWhiteSpace(StorageOptions.AccessToken))
40+
{
41+
var client = StorageOptions.DropboxClientConfig == null
42+
? new global::Dropbox.Api.DropboxClient(StorageOptions.AccessToken)
43+
: new global::Dropbox.Api.DropboxClient(StorageOptions.AccessToken, StorageOptions.DropboxClientConfig);
44+
45+
return new DropboxClientWrapper(client);
46+
}
47+
48+
if (!string.IsNullOrWhiteSpace(StorageOptions.RefreshToken))
49+
{
50+
if (string.IsNullOrWhiteSpace(StorageOptions.AppKey))
51+
{
52+
throw new InvalidOperationException("Dropbox AppKey is required when configuring the storage with a refresh token.");
53+
}
54+
55+
var client = CreateDropboxClientFromRefreshToken(StorageOptions.RefreshToken, StorageOptions.AppKey, StorageOptions.AppSecret, StorageOptions.DropboxClientConfig);
56+
return new DropboxClientWrapper(client);
57+
}
58+
3959
throw new InvalidOperationException("Dropbox client is not configured for storage.");
4060
}
4161

62+
private static global::Dropbox.Api.DropboxClient CreateDropboxClientFromRefreshToken(
63+
string refreshToken,
64+
string appKey,
65+
string? appSecret,
66+
global::Dropbox.Api.DropboxClientConfig? config)
67+
{
68+
if (!string.IsNullOrWhiteSpace(appSecret))
69+
{
70+
return config == null
71+
? new global::Dropbox.Api.DropboxClient(refreshToken, appKey, appSecret)
72+
: new global::Dropbox.Api.DropboxClient(refreshToken, appKey, appSecret, config);
73+
}
74+
75+
return config == null
76+
? new global::Dropbox.Api.DropboxClient(refreshToken, appKey)
77+
: new global::Dropbox.Api.DropboxClient(refreshToken, appKey, config);
78+
}
79+
4280
protected override async Task<Result> CreateContainerInternalAsync(CancellationToken cancellationToken = default)
4381
{
4482
try

Storages/ManagedCode.Storage.Dropbox/DropboxStorageProvider.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ public IStorageOptions GetDefaultOptions()
3232
RootPath = defaultOptions.RootPath,
3333
DropboxClient = defaultOptions.DropboxClient,
3434
Client = defaultOptions.Client,
35+
AccessToken = defaultOptions.AccessToken,
36+
RefreshToken = defaultOptions.RefreshToken,
37+
AppKey = defaultOptions.AppKey,
38+
AppSecret = defaultOptions.AppSecret,
39+
DropboxClientConfig = defaultOptions.DropboxClientConfig,
3540
CreateContainerIfNotExists = defaultOptions.CreateContainerIfNotExists
3641
};
3742
}

Storages/ManagedCode.Storage.Dropbox/Extensions/ServiceCollectionExtensions.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,17 @@ public static IServiceCollection AddDropboxStorageAsDefault(this IServiceCollect
8585

8686
private static void CheckConfiguration(DropboxStorageOptions options)
8787
{
88-
if (options.Client == null && options.DropboxClient == null)
88+
if (!string.IsNullOrWhiteSpace(options.RefreshToken) && string.IsNullOrWhiteSpace(options.AppKey))
8989
{
90-
throw new BadConfigurationException("Dropbox storage requires either a configured DropboxClient or a custom IDropboxClientWrapper.");
90+
throw new BadConfigurationException("Dropbox storage configuration with a refresh token requires AppKey (and optionally AppSecret).");
91+
}
92+
93+
if (options.Client == null
94+
&& options.DropboxClient == null
95+
&& string.IsNullOrWhiteSpace(options.AccessToken)
96+
&& string.IsNullOrWhiteSpace(options.RefreshToken))
97+
{
98+
throw new BadConfigurationException("Dropbox storage requires either a configured DropboxClient, a custom IDropboxClientWrapper, or credentials (AccessToken / RefreshToken + AppKey).");
9199
}
92100
}
93101
}

Storages/ManagedCode.Storage.Dropbox/Options/DropboxStorageOptions.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ public class DropboxStorageOptions : IStorageOptions
1010

1111
public DropboxClient? DropboxClient { get; set; }
1212

13+
/// <summary>
14+
/// OAuth2 access token (short-lived or long-lived) used to create a <see cref="global::Dropbox.Api.DropboxClient"/> when <see cref="DropboxClient"/> is not provided.
15+
/// </summary>
16+
public string? AccessToken { get; set; }
17+
18+
/// <summary>
19+
/// OAuth2 refresh token used to create a <see cref="global::Dropbox.Api.DropboxClient"/> when <see cref="DropboxClient"/> is not provided.
20+
/// </summary>
21+
public string? RefreshToken { get; set; }
22+
23+
/// <summary>
24+
/// Dropbox app key (required when using <see cref="RefreshToken"/>).
25+
/// </summary>
26+
public string? AppKey { get; set; }
27+
28+
/// <summary>
29+
/// Dropbox app secret (optional when using PKCE refresh tokens).
30+
/// </summary>
31+
public string? AppSecret { get; set; }
32+
33+
public DropboxClientConfig? DropboxClientConfig { get; set; }
34+
1335
public string RootPath { get; set; } = string.Empty;
1436

1537
public bool CreateContainerIfNotExists { get; set; } = true;

Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UploadTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ public async Task UploadAsync_AsText_WithoutOptions()
2525

2626
// Act
2727
var result = await Storage.UploadAsync(uploadContent);
28-
var downloadedResult = await Storage.DownloadAsync(result.Value!.Name);
2928

3029
// Assert
3130
result.IsSuccess
3231
.ShouldBeTrue();
32+
33+
var downloadedResult = await Storage.DownloadAsync(result.Value!.Name);
3334
downloadedResult.IsSuccess
3435
.ShouldBeTrue();
3536
}
@@ -44,11 +45,12 @@ public async Task UploadAsync_AsStream_WithoutOptions()
4445

4546
// Act
4647
var result = await Storage.UploadAsync(stream);
47-
var downloadedResult = await Storage.DownloadAsync(result.Value!.Name);
4848

4949
// Assert
5050
result.IsSuccess
5151
.ShouldBeTrue();
52+
53+
var downloadedResult = await Storage.DownloadAsync(result.Value!.Name);
5254
downloadedResult.IsSuccess
5355
.ShouldBeTrue();
5456
}

Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveDependencyInjectionTests.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,47 @@ public void Dropbox_WhenClientNotConfigured_ShouldThrow()
6868
services.AddDropboxStorage(options => options.RootPath = "/apps/demo"));
6969
}
7070

71+
[Fact]
72+
public void Dropbox_WhenAccessTokenConfigured_ShouldResolve()
73+
{
74+
var services = new ServiceCollection();
75+
services.AddDropboxStorage(options =>
76+
{
77+
options.RootPath = "/apps/demo";
78+
options.AccessToken = "test-token";
79+
});
80+
81+
using var provider = services.BuildServiceProvider();
82+
provider.GetRequiredService<IDropboxStorage>().ShouldNotBeNull();
83+
}
84+
85+
[Fact]
86+
public void Dropbox_WhenRefreshTokenMissingAppKey_ShouldThrow()
87+
{
88+
var services = new ServiceCollection();
89+
Should.Throw<BadConfigurationException>(() =>
90+
services.AddDropboxStorage(options =>
91+
{
92+
options.RootPath = "/apps/demo";
93+
options.RefreshToken = "refresh-token";
94+
}));
95+
}
96+
97+
[Fact]
98+
public void Dropbox_WhenRefreshTokenConfigured_ShouldResolve()
99+
{
100+
var services = new ServiceCollection();
101+
services.AddDropboxStorage(options =>
102+
{
103+
options.RootPath = "/apps/demo";
104+
options.RefreshToken = "refresh-token";
105+
options.AppKey = "app-key";
106+
});
107+
108+
using var provider = services.BuildServiceProvider();
109+
provider.GetRequiredService<IDropboxStorage>().ShouldNotBeNull();
110+
}
111+
71112
[Fact]
72113
public void GoogleDrive_AddAsDefault_ShouldResolveIStorage()
73114
{

Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/DropboxClientWrapperHttpTests.cs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
using System.Threading;
1010
using System.Threading.Tasks;
1111
using Dropbox.Api;
12+
using ManagedCode.Storage.Dropbox;
1213
using ManagedCode.Storage.Dropbox.Clients;
14+
using ManagedCode.Storage.Dropbox.Options;
1315
using Shouldly;
1416
using Xunit;
1517

@@ -24,7 +26,8 @@ public async Task DropboxClientWrapper_WithHttpHandler_RoundTrip()
2426
var httpClient = new HttpClient(handler);
2527
var config = new DropboxClientConfig("ManagedCode.Storage.Tests")
2628
{
27-
HttpClient = httpClient
29+
HttpClient = httpClient,
30+
LongPollHttpClient = httpClient
2831
};
2932

3033
using var dropboxClient = new DropboxClient("test-token", config);
@@ -59,6 +62,39 @@ public async Task DropboxClientWrapper_WithHttpHandler_RoundTrip()
5962
(await wrapper.DeleteAsync("/apps/demo", "file.json", CancellationToken.None)).ShouldBeFalse();
6063
}
6164

65+
[Fact]
66+
public async Task DropboxStorage_WithAccessTokenAndHttpHandler_RoundTrip()
67+
{
68+
var handler = new FakeDropboxHttpHandler();
69+
var httpClient = new HttpClient(handler);
70+
var config = new DropboxClientConfig("ManagedCode.Storage.Tests")
71+
{
72+
HttpClient = httpClient,
73+
LongPollHttpClient = httpClient
74+
};
75+
76+
using var storage = new DropboxStorage(new DropboxStorageOptions
77+
{
78+
RootPath = "/apps/demo",
79+
AccessToken = "test-token",
80+
DropboxClientConfig = config,
81+
CreateContainerIfNotExists = true
82+
});
83+
84+
(await storage.UploadAsync("dropbox payload", options => options.FileName = "file.json")).IsSuccess.ShouldBeTrue();
85+
86+
var exists = await storage.ExistsAsync("file.json");
87+
exists.IsSuccess.ShouldBeTrue();
88+
exists.Value.ShouldBeTrue();
89+
90+
var download = await storage.DownloadAsync("file.json");
91+
download.IsSuccess.ShouldBeTrue();
92+
using (var reader = new StreamReader(download.Value.FileStream, Encoding.UTF8))
93+
{
94+
(await reader.ReadToEndAsync()).ShouldBe("dropbox payload");
95+
}
96+
}
97+
6298
private sealed class FakeDropboxHttpHandler : HttpMessageHandler
6399
{
64100
private const string ApiHost = "api.dropboxapi.com";

0 commit comments

Comments
 (0)