Skip to content

Commit 760fabd

Browse files
committed
implement onedrive graph client and docs
1 parent 942a058 commit 760fabd

File tree

5 files changed

+585
-9
lines changed

5 files changed

+585
-9
lines changed

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,66 @@ Cloud storage vendors expose distinct SDKs, option models, and authentication pa
5252
| [ManagedCode.Storage.FileSystem](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.FileSystem.svg)](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | Local file system implementation for hybrid or on-premises workloads. |
5353
| [ManagedCode.Storage.Sftp](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Sftp.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | SFTP provider powered by SSH.NET for legacy and air-gapped environments. |
5454

55+
### Configuring OneDrive, Google Drive, and Dropbox
56+
57+
> iCloud does not expose a public file API suitable for server-side integrations, so only Microsoft, Google, and Dropbox cloud drives are covered here.
58+
59+
**OneDrive / Microsoft Graph**
60+
61+
1. Create an app registration in Azure Active Directory (Entra ID) and record the **Application (client) ID**, **Directory (tenant) ID**, and a **client secret**.
62+
2. Add the Microsoft Graph **Files.ReadWrite.All** delegated permission (or **Sites.ReadWrite.All** if you target SharePoint drives) and grant admin consent.
63+
3. In your ASP.NET app, acquire a token via `ClientSecretCredential` or another `TokenCredential` and pass it to `new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" })`.
64+
4. Register OneDrive storage with the Graph client and the drive/root you want to scope to:
65+
66+
```csharp
67+
builder.Services.AddOneDriveStorageAsDefault(options =>
68+
{
69+
options.GraphClient = graphClient; // from step 3
70+
options.DriveId = "me"; // or a specific drive ID
71+
options.RootPath = "app-data"; // folder will be created when CreateContainerIfNotExists is true
72+
options.CreateContainerIfNotExists = true;
73+
});
74+
```
75+
76+
5. If you need to impersonate a specific drive item, swap `DriveId` for the drive GUID returned by Graph.
77+
78+
**Google Drive**
79+
80+
1. In [Google Cloud Console](https://console.cloud.google.com/), create a project and enable the **Google Drive API**.
81+
2. Configure an OAuth consent screen and create an **OAuth 2.0 Client ID** (Desktop or Web). Record the client ID and secret.
82+
3. Exchange the OAuth code for a refresh token with the `https://www.googleapis.com/auth/drive.file` scope (or broader if necessary).
83+
4. Add the Google Drive provider and feed the credentials to the options:
84+
85+
```csharp
86+
builder.Services.AddGoogleDriveStorage(options =>
87+
{
88+
options.ClientId = configuration["GoogleDrive:ClientId"]!;
89+
options.ClientSecret = configuration["GoogleDrive:ClientSecret"]!;
90+
options.RefreshToken = configuration["GoogleDrive:RefreshToken"]!;
91+
options.RootFolderId = "root"; // or a shared drive folder id
92+
});
93+
```
94+
95+
5. Store tokens in user secrets or environment variables; never commit them to source control.
96+
97+
**Dropbox**
98+
99+
1. Create an app in the [Dropbox App Console](https://www.dropbox.com/developers/apps) and choose **Scoped access** with the **Full Dropbox** or **App folder** type.
100+
2. Under **Permissions**, enable `files.content.write`, `files.content.read`, and `files.metadata.write` and generate a refresh token via OAuth.
101+
3. Register Dropbox storage with the access credentials and a root path (use `/` for full access apps or `/Apps/<your-app>` for app folders):
102+
103+
```csharp
104+
builder.Services.AddDropboxStorage(options =>
105+
{
106+
options.AppKey = configuration["Dropbox:AppKey"]!;
107+
options.AppSecret = configuration["Dropbox:AppSecret"]!;
108+
options.RefreshToken = configuration["Dropbox:RefreshToken"]!;
109+
options.RootPath = "/apps/my-app";
110+
});
111+
```
112+
113+
4. Dropbox issues short-lived access tokens from refresh tokens; the SDK handles the exchange automatically once configured.
114+
55115
### ASP.NET & Clients
56116

57117
| Package | Latest | Description |

Storages/ManagedCode.Storage.OneDrive/Clients/GraphOneDriveClient.cs

Lines changed: 170 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,37 +22,199 @@ public GraphOneDriveClient(GraphServiceClient graphServiceClient)
2222

2323
public Task EnsureRootAsync(string driveId, string rootPath, bool createIfNotExists, CancellationToken cancellationToken)
2424
{
25-
// Graph-backed provisioning is not executed in this offline wrapper.
26-
return Task.CompletedTask;
25+
return EnsureRootInternalAsync(driveId, rootPath, createIfNotExists, cancellationToken);
2726
}
2827

2928
public Task<DriveItem> UploadAsync(string driveId, string path, Stream content, string? contentType, CancellationToken cancellationToken)
3029
{
31-
throw new NotSupportedException("Graph upload requires a configured OneDrive runtime environment.");
30+
return UploadInternalAsync(driveId, path, content, contentType, cancellationToken);
3231
}
3332

3433
public Task<Stream> DownloadAsync(string driveId, string path, CancellationToken cancellationToken)
3534
{
36-
throw new NotSupportedException("Graph download requires a configured OneDrive runtime environment.");
35+
return DownloadInternalAsync(driveId, path, cancellationToken);
3736
}
3837

3938
public Task<bool> DeleteAsync(string driveId, string path, CancellationToken cancellationToken)
4039
{
41-
throw new NotSupportedException("Graph deletion requires a configured OneDrive runtime environment.");
40+
return DeleteInternalAsync(driveId, path, cancellationToken);
4241
}
4342

4443
public Task<bool> ExistsAsync(string driveId, string path, CancellationToken cancellationToken)
4544
{
46-
return Task.FromResult(false);
45+
return ExistsInternalAsync(driveId, path, cancellationToken);
4746
}
4847

4948
public Task<DriveItem?> GetMetadataAsync(string driveId, string path, CancellationToken cancellationToken)
5049
{
51-
return Task.FromResult<DriveItem?>(null);
50+
return GetMetadataInternalAsync(driveId, path, cancellationToken);
5251
}
5352

5453
public IAsyncEnumerable<DriveItem> ListAsync(string driveId, string? directory, CancellationToken cancellationToken)
5554
{
56-
return AsyncEnumerable.Empty<DriveItem>();
55+
return ListInternalAsync(driveId, directory, cancellationToken);
56+
}
57+
58+
private async Task EnsureRootInternalAsync(string driveId, string rootPath, bool createIfNotExists, CancellationToken cancellationToken)
59+
{
60+
var normalizedRoot = NormalizePath(rootPath);
61+
if (string.IsNullOrWhiteSpace(normalizedRoot) || normalizedRoot == "/")
62+
{
63+
return;
64+
}
65+
66+
var root = await GetRootDriveItemAsync(driveId, cancellationToken).ConfigureAwait(false);
67+
var parentId = root.Id ?? throw new InvalidOperationException("Drive root is unavailable for the configured account.");
68+
var segments = normalizedRoot.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
69+
foreach (var segment in segments)
70+
{
71+
cancellationToken.ThrowIfCancellationRequested();
72+
73+
var existing = await FindChildAsync(driveId, parentId, segment, cancellationToken).ConfigureAwait(false);
74+
if (existing != null)
75+
{
76+
parentId = existing.Id!;
77+
continue;
78+
}
79+
80+
if (!createIfNotExists)
81+
{
82+
throw new DirectoryNotFoundException($"Folder '{normalizedRoot}' is missing in the configured drive.");
83+
}
84+
85+
var childrenBuilder = await GetChildrenBuilderAsync(driveId, parentId, cancellationToken).ConfigureAwait(false);
86+
var created = await childrenBuilder.PostAsync(new DriveItem
87+
{
88+
Name = segment,
89+
Folder = new Folder()
90+
}, cancellationToken: cancellationToken).ConfigureAwait(false);
91+
92+
parentId = created?.Id ?? throw new InvalidOperationException($"Failed to create OneDrive folder '{segment}'.");
93+
}
94+
}
95+
96+
private async Task<DriveItem> UploadInternalAsync(string driveId, string path, Stream content, string? contentType, CancellationToken cancellationToken)
97+
{
98+
var rootBuilder = await GetRootItemBuilderAsync(driveId, cancellationToken).ConfigureAwait(false);
99+
var request = rootBuilder.ItemWithPath(NormalizePath(path)).Content;
100+
var response = await request.PutAsync(content, cancellationToken: cancellationToken).ConfigureAwait(false);
101+
102+
return response ?? throw new InvalidOperationException("Graph upload returned no item.");
103+
}
104+
105+
private async Task<Stream> DownloadInternalAsync(string driveId, string path, CancellationToken cancellationToken)
106+
{
107+
var rootBuilder = await GetRootItemBuilderAsync(driveId, cancellationToken).ConfigureAwait(false);
108+
var request = rootBuilder.ItemWithPath(NormalizePath(path)).Content;
109+
var stream = await request.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
110+
return stream ?? throw new FileNotFoundException($"File '{path}' not found in OneDrive.");
111+
}
112+
113+
private async Task<bool> DeleteInternalAsync(string driveId, string path, CancellationToken cancellationToken)
114+
{
115+
try
116+
{
117+
var rootBuilder = await GetRootItemBuilderAsync(driveId, cancellationToken).ConfigureAwait(false);
118+
await rootBuilder.ItemWithPath(NormalizePath(path)).DeleteAsync(cancellationToken: cancellationToken)
119+
.ConfigureAwait(false);
120+
return true;
121+
}
122+
catch (ODataError ex) when (ex.ResponseStatusCode == 404)
123+
{
124+
return false;
125+
}
126+
}
127+
128+
private async Task<bool> ExistsInternalAsync(string driveId, string path, CancellationToken cancellationToken)
129+
{
130+
var item = await GetMetadataInternalAsync(driveId, path, cancellationToken).ConfigureAwait(false);
131+
return item != null;
132+
}
133+
134+
private async Task<DriveItem?> GetMetadataInternalAsync(string driveId, string path, CancellationToken cancellationToken)
135+
{
136+
try
137+
{
138+
var rootBuilder = await GetRootItemBuilderAsync(driveId, cancellationToken).ConfigureAwait(false);
139+
return await rootBuilder.ItemWithPath(NormalizePath(path)).GetAsync(cancellationToken: cancellationToken)
140+
.ConfigureAwait(false);
141+
}
142+
catch (ODataError ex) when (ex.ResponseStatusCode == 404)
143+
{
144+
return null;
145+
}
146+
}
147+
148+
private async IAsyncEnumerable<DriveItem> ListInternalAsync(string driveId, string? directory, [EnumeratorCancellation] CancellationToken cancellationToken)
149+
{
150+
var normalized = string.IsNullOrWhiteSpace(directory) ? null : NormalizePath(directory!);
151+
var resolvedDriveId = await ResolveDriveIdAsync(driveId, cancellationToken).ConfigureAwait(false);
152+
var parent = normalized == null
153+
? await _graphServiceClient.Drives[resolvedDriveId].Root.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false)
154+
: await _graphServiceClient.Drives[resolvedDriveId].Root.ItemWithPath(normalized).GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
155+
156+
if (parent?.Id == null)
157+
{
158+
yield break;
159+
}
160+
161+
var builder = _graphServiceClient.Drives[resolvedDriveId].Items[parent.Id].Children;
162+
var page = await builder.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
163+
if (page?.Value == null)
164+
{
165+
yield break;
166+
}
167+
168+
foreach (var item in page.Value)
169+
{
170+
cancellationToken.ThrowIfCancellationRequested();
171+
if (item != null)
172+
{
173+
yield return item;
174+
}
175+
}
176+
}
177+
178+
private async Task<Microsoft.Graph.Drives.Item.Root.RootRequestBuilder> GetRootItemBuilderAsync(string driveId, CancellationToken cancellationToken)
179+
{
180+
var resolvedDriveId = await ResolveDriveIdAsync(driveId, cancellationToken).ConfigureAwait(false);
181+
return _graphServiceClient.Drives[resolvedDriveId].Root;
182+
}
183+
184+
private async Task<Microsoft.Graph.Drives.Item.Items.Item.Children.ChildrenRequestBuilder> GetChildrenBuilderAsync(string driveId, string parentId, CancellationToken cancellationToken)
185+
{
186+
var resolvedDriveId = await ResolveDriveIdAsync(driveId, cancellationToken).ConfigureAwait(false);
187+
return _graphServiceClient.Drives[resolvedDriveId].Items[parentId].Children;
188+
}
189+
190+
private async Task<DriveItem> GetRootDriveItemAsync(string driveId, CancellationToken cancellationToken)
191+
{
192+
var resolvedDriveId = await ResolveDriveIdAsync(driveId, cancellationToken).ConfigureAwait(false);
193+
var root = await _graphServiceClient.Drives[resolvedDriveId].Root.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
194+
return root ?? throw new InvalidOperationException("Drive root is unavailable for the configured account.");
195+
}
196+
197+
private async Task<string> ResolveDriveIdAsync(string driveId, CancellationToken cancellationToken)
198+
{
199+
if (!driveId.Equals("me", StringComparison.OrdinalIgnoreCase))
200+
{
201+
return driveId;
202+
}
203+
204+
var drive = await _graphServiceClient.Me.Drive.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
205+
return drive?.Id ?? throw new InvalidOperationException("Unable to resolve the current user's drive id.");
206+
}
207+
208+
private async Task<DriveItem?> FindChildAsync(string driveId, string parentId, string name, CancellationToken cancellationToken)
209+
{
210+
var childrenBuilder = await GetChildrenBuilderAsync(driveId, parentId, cancellationToken).ConfigureAwait(false);
211+
var children = await childrenBuilder.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
212+
213+
return children?.Value?.FirstOrDefault(c => string.Equals(c?.Name, name, StringComparison.OrdinalIgnoreCase));
214+
}
215+
216+
private static string NormalizePath(string path)
217+
{
218+
return path.Replace("\\", "/").Trim('/');
57219
}
58220
}

Storages/ManagedCode.Storage.OneDrive/OneDriveStorage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ protected override async Task<Result> CreateContainerInternalAsync(CancellationT
5757
public override Task<Result> RemoveContainerAsync(CancellationToken cancellationToken = default)
5858
{
5959
// OneDrive containers map to drives or root folders that are typically managed by the account owner.
60-
return Task.FromResult(Result.Succeed());
60+
return Task.FromResult(Result.Fail(new NotSupportedException("Deleting a OneDrive container is not supported.")));
6161
}
6262

6363
protected override async Task<Result> DeleteDirectoryInternalAsync(string directory, CancellationToken cancellationToken = default)

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ public async Task OneDrive_FakeClient_RoundTrip()
5757
listed.ShouldContain(m => m.FullName.EndsWith("text.txt"));
5858
}
5959

60+
[Fact]
61+
public async Task OneDrive_RemoveContainer_NotSupported()
62+
{
63+
var fakeClient = new FakeOneDriveClient();
64+
var storage = new OneDriveStorage(new OneDriveStorageOptions
65+
{
66+
Client = fakeClient,
67+
DriveId = "drive",
68+
RootPath = "root"
69+
});
70+
71+
var result = await storage.RemoveContainerAsync();
72+
result.IsSuccess.ShouldBeFalse();
73+
}
74+
6075
[Fact]
6176
public async Task GoogleDrive_FakeClient_RoundTrip()
6277
{

0 commit comments

Comments
 (0)