From 5541a2593e6341d3a6eb7ebefeea2aa84df9a01e Mon Sep 17 00:00:00 2001 From: Mark Akampurira Date: Tue, 16 Dec 2025 14:08:28 +0100 Subject: [PATCH 1/3] Shared/Team drive folder support for Google Drive --- README.md | 9 ++- .../Clients/GoogleDriveClient.cs | 76 +++++++++++-------- .../Clients/IGoogleDriveClient.cs | 12 +-- .../GoogleDriveStorage.cs | 33 ++++---- .../Options/GoogleDriveStorageOptions.cs | 2 + .../CloudDriveDependencyInjectionTests.cs | 23 +++--- .../CloudDriveStorageProviderTests.cs | 24 +++--- .../CloudDrive/CloudDriveStorageTests.cs | 25 +++--- .../CloudDrive/GoogleDriveClientHttpTests.cs | 44 +++++------ 9 files changed, 131 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 2d83146..e8a2b2b 100644 --- a/README.md +++ b/README.md @@ -255,8 +255,9 @@ var tenantStorage = app.Services.GetRequiredKeyedService("tenant-a"); builder.Services.AddGoogleDriveStorageAsDefault(options => { options.DriveService = driveService; - options.RootFolderId = "root"; // or a specific folder id you control + options.RootFolderId = "root"; // or a specific folder id you control / shared team drive folder id options.CreateContainerIfNotExists = true; + options.SupportsAllDrives = true; // To support shared/team drives }); ``` @@ -723,7 +724,7 @@ Using in default mode: public class MyService { private readonly IStorage _storage; - + public MyService(IStorage storage) { _storage = storage; @@ -797,7 +798,7 @@ Using in default mode: public class MyService { private readonly IStorage _storage; - + public MyService(IStorage storage) { _storage = storage; @@ -858,7 +859,7 @@ Using in default mode: public class MyService { private readonly IStorage _storage; - + public MyService(IStorage storage) { _storage = storage; diff --git a/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs b/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs index 150cc5f..3e10cd0 100644 --- a/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs +++ b/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs @@ -1,12 +1,12 @@ +using Google.Apis.Drive.v3; using System; using System.Collections.Generic; using System.IO; +using System.IO.Pipelines; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using System.IO.Pipelines; -using Google.Apis.Drive.v3; using DriveFile = Google.Apis.Drive.v3.Data.File; namespace ManagedCode.Storage.GoogleDrive.Clients; @@ -27,9 +27,9 @@ public Task EnsureRootAsync(string rootFolderId, bool createIfNotExists, Cancell return Task.CompletedTask; } - public async Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, CancellationToken cancellationToken) + public async Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, bool supportsAllDrives, CancellationToken cancellationToken) { - var (parentId, fileName) = await EnsureParentFolderAsync(rootFolderId, path, cancellationToken); + var (parentId, fileName) = await EnsureParentFolderAsync(rootFolderId, path, supportsAllDrives, cancellationToken); var fileMetadata = new DriveFile { @@ -39,20 +39,24 @@ public async Task UploadAsync(string rootFolderId, string path, Strea var request = _driveService.Files.Create(fileMetadata, content, contentType ?? "application/octet-stream"); request.Fields = "id,name,parents,createdTime,modifiedTime,md5Checksum,size"; + request.SupportsAllDrives = supportsAllDrives; await request.UploadAsync(cancellationToken); return request.ResponseBody ?? throw new InvalidOperationException("Google Drive upload returned no metadata."); } - public async Task DownloadAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public async Task DownloadAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { - var file = await FindFileByPathAsync(rootFolderId, path, cancellationToken) ?? throw new FileNotFoundException(path); + var file = await FindFileByPathAsync(rootFolderId, path, supportsAllDrives, cancellationToken) ?? throw new FileNotFoundException(path); var pipe = new Pipe(); + var getRequest = _driveService.Files.Get(file.Id); + getRequest.SupportsAllDrives = supportsAllDrives; + _ = Task.Run(async () => { try { await using var destination = pipe.Writer.AsStream(leaveOpen: true); - await _driveService.Files.Get(file.Id).DownloadAsync(destination, cancellationToken); + await getRequest.DownloadAsync(destination, cancellationToken); pipe.Writer.Complete(); } catch (Exception ex) @@ -64,29 +68,29 @@ public async Task DownloadAsync(string rootFolderId, string path, Cancel return pipe.Reader.AsStream(); } - public async Task DeleteAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public async Task DeleteAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { - var file = await FindFileByPathAsync(rootFolderId, path, cancellationToken); + var file = await FindFileByPathAsync(rootFolderId, path, supportsAllDrives, cancellationToken); if (file == null) { return false; } - await DeleteRecursiveAsync(file.Id, file.MimeType, cancellationToken); + await DeleteRecursiveAsync(file.Id, file.MimeType, supportsAllDrives, cancellationToken); return true; } - public async Task ExistsAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public async Task ExistsAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { - return await FindFileByPathAsync(rootFolderId, path, cancellationToken) != null; + return await FindFileByPathAsync(rootFolderId, path, supportsAllDrives, cancellationToken) != null; } - public Task GetMetadataAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public Task GetMetadataAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { - return FindFileByPathAsync(rootFolderId, path, cancellationToken); + return FindFileByPathAsync(rootFolderId, path, supportsAllDrives, cancellationToken); } - public async IAsyncEnumerable ListAsync(string rootFolderId, string? directory, [EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ListAsync(string rootFolderId, string? directory, bool supportsAllDrives, [EnumeratorCancellation] CancellationToken cancellationToken) { string parentId; if (string.IsNullOrWhiteSpace(directory)) @@ -95,7 +99,7 @@ public async IAsyncEnumerable ListAsync(string rootFolderId, string? } else { - parentId = await EnsureFolderPathAsync(rootFolderId, directory!, false, cancellationToken) ?? string.Empty; + parentId = await EnsureFolderPathAsync(rootFolderId, directory!, false, supportsAllDrives, cancellationToken) ?? string.Empty; if (string.IsNullOrWhiteSpace(parentId)) { yield break; @@ -103,7 +107,11 @@ public async IAsyncEnumerable ListAsync(string rootFolderId, string? } var request = _driveService.Files.List(); + request.SupportsAllDrives = supportsAllDrives; + request.IncludeItemsFromAllDrives = supportsAllDrives; + request.Q = $"'{parentId}' in parents and trashed=false"; + request.Fields = "nextPageToken,files(id,name,parents,createdTime,modifiedTime,md5Checksum,size,mimeType)"; do @@ -119,7 +127,7 @@ public async IAsyncEnumerable ListAsync(string rootFolderId, string? } while (!string.IsNullOrEmpty(request.PageToken)); } - private async Task<(string ParentId, string Name)> EnsureParentFolderAsync(string rootFolderId, string fullPath, CancellationToken cancellationToken) + private async Task<(string ParentId, string Name)> EnsureParentFolderAsync(string rootFolderId, string fullPath, bool supportsAllDrives, CancellationToken cancellationToken) { var normalizedPath = fullPath.Replace("\\", "/").Trim('/'); var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries); @@ -129,16 +137,16 @@ public async IAsyncEnumerable ListAsync(string rootFolderId, string? } var parentPath = string.Join('/', segments.Take(segments.Length - 1)); - var parentId = await EnsureFolderPathAsync(rootFolderId, parentPath, true, cancellationToken) ?? rootFolderId; + var parentId = await EnsureFolderPathAsync(rootFolderId, parentPath, true, supportsAllDrives, cancellationToken) ?? rootFolderId; return (parentId, segments.Last()); } - private async Task EnsureFolderPathAsync(string rootFolderId, string path, bool createIfMissing, CancellationToken cancellationToken) + private async Task EnsureFolderPathAsync(string rootFolderId, string path, bool createIfMissing, bool supportsAllDrives, CancellationToken cancellationToken) { var currentId = rootFolderId; foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { - var folder = await FindChildAsync(currentId, segment, cancellationToken); + var folder = await FindChildAsync(currentId, segment, supportsAllDrives, cancellationToken); if (folder == null) { if (!createIfMissing) @@ -147,7 +155,9 @@ public async IAsyncEnumerable ListAsync(string rootFolderId, string? } var metadata = new DriveFile { Name = segment, MimeType = "application/vnd.google-apps.folder", Parents = new List { currentId } }; - folder = await _driveService.Files.Create(metadata).ExecuteAsync(cancellationToken); + var createRequest = _driveService.Files.Create(metadata); + createRequest.SupportsAllDrives = supportsAllDrives; + folder = await createRequest.ExecuteAsync(cancellationToken); } currentId = folder.Id; @@ -156,32 +166,38 @@ public async IAsyncEnumerable ListAsync(string rootFolderId, string? return currentId; } - private async Task FindChildAsync(string parentId, string name, CancellationToken cancellationToken) + private async Task FindChildAsync(string parentId, string name, bool supportsAllDrives, CancellationToken cancellationToken) { var request = _driveService.Files.List(); request.Q = $"'{parentId}' in parents and name='{name}' and trashed=false"; request.Fields = "files(id,name,parents,createdTime,modifiedTime,md5Checksum,size,mimeType)"; + request.SupportsAllDrives = supportsAllDrives; + request.IncludeItemsFromAllDrives = supportsAllDrives; var response = await request.ExecuteAsync(cancellationToken); return response.Files?.FirstOrDefault(); } - private async Task DeleteRecursiveAsync(string fileId, string? mimeType, CancellationToken cancellationToken) + private async Task DeleteRecursiveAsync(string fileId, string? mimeType, bool supportsAllDrives, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (string.Equals(mimeType, FolderMimeType, StringComparison.OrdinalIgnoreCase)) { - await DeleteFolderChildrenAsync(fileId, cancellationToken); + await DeleteFolderChildrenAsync(fileId, supportsAllDrives, cancellationToken); } - await _driveService.Files.Delete(fileId).ExecuteAsync(cancellationToken); + var deleteRequest = _driveService.Files.Delete(fileId); + deleteRequest.SupportsAllDrives = supportsAllDrives; + await deleteRequest.ExecuteAsync(cancellationToken); } - private async Task DeleteFolderChildrenAsync(string folderId, CancellationToken cancellationToken) + private async Task DeleteFolderChildrenAsync(string folderId, bool supportsAllDrives, CancellationToken cancellationToken) { var request = _driveService.Files.List(); request.Q = $"'{folderId}' in parents and trashed=false"; request.Fields = "nextPageToken,files(id,mimeType)"; + request.SupportsAllDrives = supportsAllDrives; + request.IncludeItemsFromAllDrives = supportsAllDrives; do { @@ -194,14 +210,14 @@ private async Task DeleteFolderChildrenAsync(string folderId, CancellationToken continue; } - await DeleteRecursiveAsync(entry.Id, entry.MimeType, cancellationToken); + await DeleteRecursiveAsync(entry.Id, entry.MimeType, supportsAllDrives, cancellationToken); } request.PageToken = response.NextPageToken; } while (!string.IsNullOrWhiteSpace(request.PageToken)); } - private async Task FindFileByPathAsync(string rootFolderId, string path, CancellationToken cancellationToken) + private async Task FindFileByPathAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { var normalizedPath = path.Replace("\\", "/").Trim('/'); var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries); @@ -212,12 +228,12 @@ private async Task DeleteFolderChildrenAsync(string folderId, CancellationToken var parentPath = string.Join('/', segments.Take(segments.Length - 1)); var fileName = segments.Last(); - var parentId = await EnsureFolderPathAsync(rootFolderId, parentPath, false, cancellationToken); + var parentId = await EnsureFolderPathAsync(rootFolderId, parentPath, false, supportsAllDrives, cancellationToken); if (parentId == null) { return null; } - return await FindChildAsync(parentId, fileName, cancellationToken); + return await FindChildAsync(parentId, fileName, supportsAllDrives, cancellationToken); } } diff --git a/Storages/ManagedCode.Storage.GoogleDrive/Clients/IGoogleDriveClient.cs b/Storages/ManagedCode.Storage.GoogleDrive/Clients/IGoogleDriveClient.cs index 5ac2632..b7bad8d 100644 --- a/Storages/ManagedCode.Storage.GoogleDrive/Clients/IGoogleDriveClient.cs +++ b/Storages/ManagedCode.Storage.GoogleDrive/Clients/IGoogleDriveClient.cs @@ -10,15 +10,15 @@ public interface IGoogleDriveClient { Task EnsureRootAsync(string rootFolderId, bool createIfNotExists, CancellationToken cancellationToken); - Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, CancellationToken cancellationToken); + Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, bool supportsAllDrives, CancellationToken cancellationToken); - Task DownloadAsync(string rootFolderId, string path, CancellationToken cancellationToken); + Task DownloadAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken); - Task DeleteAsync(string rootFolderId, string path, CancellationToken cancellationToken); + Task DeleteAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken); - Task ExistsAsync(string rootFolderId, string path, CancellationToken cancellationToken); + Task ExistsAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken); - Task GetMetadataAsync(string rootFolderId, string path, CancellationToken cancellationToken); + Task GetMetadataAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken); - IAsyncEnumerable ListAsync(string rootFolderId, string? directory, CancellationToken cancellationToken); + IAsyncEnumerable ListAsync(string rootFolderId, string? directory, bool supportsAllDrives, CancellationToken cancellationToken); } diff --git a/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorage.cs b/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorage.cs index ecad8d5..21ed842 100644 --- a/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorage.cs +++ b/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorage.cs @@ -1,16 +1,15 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; using ManagedCode.Communication; using ManagedCode.Storage.Core; using ManagedCode.Storage.Core.Models; using ManagedCode.Storage.GoogleDrive.Clients; using ManagedCode.Storage.GoogleDrive.Options; using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using File = Google.Apis.Drive.v3.Data.File; namespace ManagedCode.Storage.GoogleDrive; @@ -69,18 +68,18 @@ protected override async Task DeleteDirectoryInternalAsync(string direct if (!string.IsNullOrWhiteSpace(normalizedDirectory)) { - _ = await StorageClient.DeleteAsync(StorageOptions.RootFolderId, normalizedDirectory, cancellationToken); + _ = await StorageClient.DeleteAsync(StorageOptions.RootFolderId, normalizedDirectory, StorageOptions.SupportsAllDrives, cancellationToken); return Result.Succeed(); } - await foreach (var item in StorageClient.ListAsync(StorageOptions.RootFolderId, null, cancellationToken)) + await foreach (var item in StorageClient.ListAsync(StorageOptions.RootFolderId, null, StorageOptions.SupportsAllDrives, cancellationToken)) { if (string.IsNullOrWhiteSpace(item.Name)) { continue; } - _ = await StorageClient.DeleteAsync(StorageOptions.RootFolderId, item.Name, cancellationToken); + _ = await StorageClient.DeleteAsync(StorageOptions.RootFolderId, item.Name, StorageOptions.SupportsAllDrives, cancellationToken); } return Result.Succeed(); @@ -98,7 +97,7 @@ protected override async Task> UploadInternalAsync(Stream s { await EnsureContainerExist(cancellationToken); var path = BuildFullPath(options.FullPath); - var uploaded = await StorageClient.UploadAsync(StorageOptions.RootFolderId, path, stream, options.MimeType, cancellationToken); + var uploaded = await StorageClient.UploadAsync(StorageOptions.RootFolderId, path, stream, options.MimeType, StorageOptions.SupportsAllDrives, cancellationToken); return Result.Succeed(ToBlobMetadata(uploaded, path)); } catch (Exception ex) @@ -114,7 +113,7 @@ protected override async Task> DownloadInternalAsync(LocalFile { await EnsureContainerExist(cancellationToken); var path = BuildFullPath(options.FullPath); - var remoteStream = await StorageClient.DownloadAsync(StorageOptions.RootFolderId, path, cancellationToken); + var remoteStream = await StorageClient.DownloadAsync(StorageOptions.RootFolderId, path, StorageOptions.SupportsAllDrives, cancellationToken); await using (remoteStream) await using (var fileStream = localFile.FileStream) @@ -138,7 +137,7 @@ protected override async Task> DeleteInternalAsync(DeleteOptions op { await EnsureContainerExist(cancellationToken); var path = BuildFullPath(options.FullPath); - var deleted = await StorageClient.DeleteAsync(StorageOptions.RootFolderId, path, cancellationToken); + var deleted = await StorageClient.DeleteAsync(StorageOptions.RootFolderId, path, StorageOptions.SupportsAllDrives, cancellationToken); return Result.Succeed(deleted); } catch (Exception ex) @@ -154,7 +153,7 @@ protected override async Task> ExistsInternalAsync(ExistOptions opt { await EnsureContainerExist(cancellationToken); var path = BuildFullPath(options.FullPath); - var exists = await StorageClient.ExistsAsync(StorageOptions.RootFolderId, path, cancellationToken); + var exists = await StorageClient.ExistsAsync(StorageOptions.RootFolderId, path, StorageOptions.SupportsAllDrives, cancellationToken); return Result.Succeed(exists); } catch (Exception ex) @@ -170,7 +169,7 @@ protected override async Task> GetBlobMetadataInternalAsync { await EnsureContainerExist(cancellationToken); var path = BuildFullPath(options.FullPath); - var item = await StorageClient.GetMetadataAsync(StorageOptions.RootFolderId, path, cancellationToken); + var item = await StorageClient.GetMetadataAsync(StorageOptions.RootFolderId, path, StorageOptions.SupportsAllDrives, cancellationToken); return item == null ? Result.Fail(new FileNotFoundException($"File '{path}' not found in Google Drive.")) : Result.Succeed(ToBlobMetadata(item, path)); @@ -187,7 +186,7 @@ public override async IAsyncEnumerable GetBlobMetadataListAsync(st await EnsureContainerExist(cancellationToken); var normalizedDirectory = string.IsNullOrWhiteSpace(directory) ? null : NormalizeRelativePath(directory!); - await foreach (var item in StorageClient.ListAsync(StorageOptions.RootFolderId, normalizedDirectory, cancellationToken)) + await foreach (var item in StorageClient.ListAsync(StorageOptions.RootFolderId, normalizedDirectory, StorageOptions.SupportsAllDrives, cancellationToken)) { if (item.MimeType == "application/vnd.google-apps.folder") { @@ -205,7 +204,7 @@ public override async Task> GetStreamAsync(string fileName, Cance { await EnsureContainerExist(cancellationToken); var path = BuildFullPath(fileName); - var stream = await StorageClient.DownloadAsync(StorageOptions.RootFolderId, path, cancellationToken); + var stream = await StorageClient.DownloadAsync(StorageOptions.RootFolderId, path, StorageOptions.SupportsAllDrives, cancellationToken); return Result.Succeed(stream); } catch (Exception ex) diff --git a/Storages/ManagedCode.Storage.GoogleDrive/Options/GoogleDriveStorageOptions.cs b/Storages/ManagedCode.Storage.GoogleDrive/Options/GoogleDriveStorageOptions.cs index 91c3b15..19a2f78 100644 --- a/Storages/ManagedCode.Storage.GoogleDrive/Options/GoogleDriveStorageOptions.cs +++ b/Storages/ManagedCode.Storage.GoogleDrive/Options/GoogleDriveStorageOptions.cs @@ -13,4 +13,6 @@ public class GoogleDriveStorageOptions : IStorageOptions public string RootFolderId { get; set; } = "root"; public bool CreateContainerIfNotExists { get; set; } = true; + + public bool SupportsAllDrives { get; set; } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveDependencyInjectionTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveDependencyInjectionTests.cs index 5984687..54d256b 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveDependencyInjectionTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveDependencyInjectionTests.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Google.Apis.Drive.v3.Data; using ManagedCode.Storage.Core; using ManagedCode.Storage.Core.Exceptions; using ManagedCode.Storage.Dropbox; @@ -18,6 +12,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Graph.Models; using Shouldly; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; using Xunit; using DriveFile = Google.Apis.Drive.v3.Data.File; @@ -221,18 +220,18 @@ private sealed class StubGoogleDriveClient : IGoogleDriveClient { public Task EnsureRootAsync(string rootFolderId, bool createIfNotExists, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, CancellationToken cancellationToken) + public Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task DownloadAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task DownloadAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task DeleteAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task DeleteAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task ExistsAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task ExistsAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task GetMetadataAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task GetMetadataAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public IAsyncEnumerable ListAsync(string rootFolderId, string? directory, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable ListAsync(string rootFolderId, string? directory, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); } private sealed class StubOneDriveClient : IOneDriveClient diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageProviderTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageProviderTests.cs index 69c789d..d4301a7 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageProviderTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageProviderTests.cs @@ -1,10 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Google.Apis.Drive.v3.Data; -using ManagedCode.Storage.Core; using ManagedCode.Storage.Dropbox; using ManagedCode.Storage.Dropbox.Clients; using ManagedCode.Storage.Dropbox.Options; @@ -17,6 +10,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Graph.Models; using Shouldly; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; using Xunit; using DriveFile = Google.Apis.Drive.v3.Data.File; @@ -150,18 +148,18 @@ private sealed class StubGoogleDriveClient : IGoogleDriveClient { public Task EnsureRootAsync(string rootFolderId, bool createIfNotExists, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, CancellationToken cancellationToken) + public Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task DownloadAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task DownloadAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task DeleteAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task DeleteAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task ExistsAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task ExistsAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task GetMetadataAsync(string rootFolderId, string path, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task GetMetadataAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); - public IAsyncEnumerable ListAsync(string rootFolderId, string? directory, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable ListAsync(string rootFolderId, string? directory, bool supportsAllDrives, CancellationToken cancellationToken) => throw new NotImplementedException(); } private sealed class StubOneDriveClient : IOneDriveClient diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs index 7c00886..07dea6a 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs @@ -1,10 +1,3 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Google.Apis.Drive.v3.Data; using ManagedCode.Storage.Core.Models; using ManagedCode.Storage.Dropbox; using ManagedCode.Storage.Dropbox.Clients; @@ -17,6 +10,12 @@ using ManagedCode.Storage.OneDrive.Options; using Microsoft.Graph.Models; using Shouldly; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using Xunit; using File = Google.Apis.Drive.v3.Data.File; @@ -346,33 +345,33 @@ public Task EnsureRootAsync(string rootFolderId, bool createIfNotExists, Cancell return Task.CompletedTask; } - public Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, CancellationToken cancellationToken) + public Task UploadAsync(string rootFolderId, string path, Stream content, string? contentType, bool supportsAllDrives, CancellationToken cancellationToken) { var entry = _drive.Save(path, content, contentType); return Task.FromResult(entry.ToGoogleFile(path)); } - public Task DownloadAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public Task DownloadAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { return Task.FromResult(_drive.Download(path)); } - public Task DeleteAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public Task DeleteAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { return Task.FromResult(_drive.Delete(path)); } - public Task ExistsAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public Task ExistsAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { return Task.FromResult(_drive.Exists(path)); } - public Task GetMetadataAsync(string rootFolderId, string path, CancellationToken cancellationToken) + public Task GetMetadataAsync(string rootFolderId, string path, bool supportsAllDrives, CancellationToken cancellationToken) { return Task.FromResult(_drive.Get(path)?.ToGoogleFile(path)); } - public async IAsyncEnumerable ListAsync(string rootFolderId, string? directory, [EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable ListAsync(string rootFolderId, string? directory, bool supportsAllDrives, [EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var entry in _drive.List(directory, cancellationToken)) { diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs index adfeb50..f1ae6d9 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs @@ -1,3 +1,9 @@ +using Google.Apis.Drive.v3; +using Google.Apis.Http; +using Google.Apis.Services; +using ManagedCode.Storage.GoogleDrive.Clients; +using ManagedCode.Storage.GoogleDrive.Options; +using Shouldly; using System; using System.Collections.Generic; using System.IO; @@ -8,12 +14,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Google.Apis.Drive.v3; -using Google.Apis.Drive.v3.Data; -using Google.Apis.Http; -using Google.Apis.Services; -using ManagedCode.Storage.GoogleDrive.Clients; -using Shouldly; using Xunit; using DriveFile = Google.Apis.Drive.v3.Data.File; @@ -21,7 +21,7 @@ namespace ManagedCode.Storage.Tests.Storages.CloudDrive; public class GoogleDriveClientHttpTests { - private const string RootFolderId = "root"; + private static readonly GoogleDriveStorageOptions TestOptions = new() { RootFolderId = "root", SupportsAllDrives = false }; [Fact] public async Task GoogleDriveClient_WithHttpHandler_RoundTrip() @@ -30,50 +30,50 @@ public async Task GoogleDriveClient_WithHttpHandler_RoundTrip() var driveService = CreateDriveService(handler); var client = new GoogleDriveClient(driveService); - await client.EnsureRootAsync(RootFolderId, true, CancellationToken.None); + await client.EnsureRootAsync(TestOptions.RootFolderId, true, CancellationToken.None); await using (var uploadStream = new MemoryStream(Encoding.UTF8.GetBytes("google payload"))) { - var uploaded = await client.UploadAsync(RootFolderId, "dir/file.txt", uploadStream, "text/plain", CancellationToken.None); + var uploaded = await client.UploadAsync(TestOptions.RootFolderId, "dir/file.txt", uploadStream, "text/plain", TestOptions.SupportsAllDrives, CancellationToken.None); uploaded.Name.ShouldBe("file.txt"); uploaded.Size.ShouldBe("google payload".Length); } await using (var nestedStream = new MemoryStream(Encoding.UTF8.GetBytes("nested payload"))) { - var uploaded = await client.UploadAsync(RootFolderId, "dir/sub/inner.txt", nestedStream, "text/plain", CancellationToken.None); + var uploaded = await client.UploadAsync(TestOptions.RootFolderId, "dir/sub/inner.txt", nestedStream, "text/plain", TestOptions.SupportsAllDrives, CancellationToken.None); uploaded.Name.ShouldBe("inner.txt"); } - (await client.ExistsAsync(RootFolderId, "dir/file.txt", CancellationToken.None)).ShouldBeTrue(); - (await client.ExistsAsync(RootFolderId, "dir/sub/inner.txt", CancellationToken.None)).ShouldBeTrue(); + (await client.ExistsAsync(TestOptions.RootFolderId, "dir/file.txt", TestOptions.SupportsAllDrives, CancellationToken.None)).ShouldBeTrue(); + (await client.ExistsAsync(TestOptions.RootFolderId, "dir/sub/inner.txt", TestOptions.SupportsAllDrives, CancellationToken.None)).ShouldBeTrue(); - await using (var downloaded = await client.DownloadAsync(RootFolderId, "dir/file.txt", CancellationToken.None)) - using (var reader = new StreamReader(downloaded, Encoding.UTF8)) + await using (var downloaded = await client.DownloadAsync(TestOptions.RootFolderId, "dir/file.txt", TestOptions.SupportsAllDrives, CancellationToken.None)) + using (var reader = new StreamReader(downloaded)) { (await reader.ReadToEndAsync()).ShouldBe("google payload"); } var listed = new List(); - await foreach (var item in client.ListAsync(RootFolderId, "dir", CancellationToken.None)) + await foreach (var item in client.ListAsync(TestOptions.RootFolderId, "dir", TestOptions.SupportsAllDrives, CancellationToken.None)) { listed.Add(item); } listed.ShouldContain(f => f.Name == "file.txt"); - (await client.DeleteAsync(RootFolderId, "dir", CancellationToken.None)).ShouldBeTrue(); - (await client.ExistsAsync(RootFolderId, "dir/file.txt", CancellationToken.None)).ShouldBeFalse(); - (await client.ExistsAsync(RootFolderId, "dir/sub/inner.txt", CancellationToken.None)).ShouldBeFalse(); + (await client.DeleteAsync(TestOptions.RootFolderId, "dir", TestOptions.SupportsAllDrives, CancellationToken.None)).ShouldBeTrue(); + (await client.ExistsAsync(TestOptions.RootFolderId, "dir/file.txt", TestOptions.SupportsAllDrives, CancellationToken.None)).ShouldBeFalse(); + (await client.ExistsAsync(TestOptions.RootFolderId, "dir/sub/inner.txt", TestOptions.SupportsAllDrives, CancellationToken.None)).ShouldBeFalse(); var afterDelete = new List(); - await foreach (var item in client.ListAsync(RootFolderId, "dir", CancellationToken.None)) + await foreach (var item in client.ListAsync(TestOptions.RootFolderId, "dir", TestOptions.SupportsAllDrives, CancellationToken.None)) { afterDelete.Add(item); } afterDelete.ShouldBeEmpty(); - (await client.DeleteAsync(RootFolderId, "dir", CancellationToken.None)).ShouldBeFalse(); + (await client.DeleteAsync(TestOptions.RootFolderId, "dir", TestOptions.SupportsAllDrives, CancellationToken.None)).ShouldBeFalse(); } private static DriveService CreateDriveService(HttpMessageHandler handler) @@ -126,7 +126,7 @@ protected override async Task SendAsync(HttpRequestMessage var model = JsonSerializer.Deserialize(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("Create request body is missing."); - var parentId = model.Parents?.FirstOrDefault() ?? RootFolderId; + var parentId = model.Parents?.FirstOrDefault() ?? TestOptions.RootFolderId; var mimeType = string.IsNullOrWhiteSpace(model.MimeType) ? "application/octet-stream" : model.MimeType; var created = CreateEntry(name: model.Name ?? Guid.NewGuid().ToString("N"), parentId: parentId, mimeType: mimeType, content: Array.Empty()); return JsonResponse(ToResponse(created)); @@ -144,7 +144,7 @@ protected override async Task SendAsync(HttpRequestMessage var uploadId = "upload-" + Interlocked.Increment(ref _counter); _pendingUploads[uploadId] = new PendingUpload( Name: model.Name ?? Guid.NewGuid().ToString("N"), - ParentId: model.Parents?.FirstOrDefault() ?? RootFolderId, + ParentId: model.Parents?.FirstOrDefault() ?? TestOptions.RootFolderId, MimeType: model.MimeType ?? "application/octet-stream"); return new HttpResponseMessage(HttpStatusCode.OK) From 2ed94d480fba95a0d28e1f8b33854fe26d2ee067 Mon Sep 17 00:00:00 2001 From: Mark Akampurira Date: Tue, 16 Dec 2025 14:44:33 +0100 Subject: [PATCH 2/3] Soft delete Google Drive files --- .../Clients/GoogleDriveClient.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs b/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs index 3e10cd0..a316f34 100644 --- a/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs +++ b/Storages/ManagedCode.Storage.GoogleDrive/Clients/GoogleDriveClient.cs @@ -186,9 +186,9 @@ private async Task DeleteRecursiveAsync(string fileId, string? mimeType, bool su await DeleteFolderChildrenAsync(fileId, supportsAllDrives, cancellationToken); } - var deleteRequest = _driveService.Files.Delete(fileId); - deleteRequest.SupportsAllDrives = supportsAllDrives; - await deleteRequest.ExecuteAsync(cancellationToken); + var trashRequest = _driveService.Files.Update(new DriveFile { Trashed = true }, fileId); + trashRequest.SupportsAllDrives = supportsAllDrives; + await trashRequest.ExecuteAsync(cancellationToken); } private async Task DeleteFolderChildrenAsync(string folderId, bool supportsAllDrives, CancellationToken cancellationToken) From 4e4a90b13965ad09c7e105b645952f5041e7ea23 Mon Sep 17 00:00:00 2001 From: Mark Akampurira Date: Tue, 16 Dec 2025 15:25:47 +0100 Subject: [PATCH 3/3] Fix for failing Google Drive tests --- .../CloudDrive/GoogleDriveClientHttpTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs index f1ae6d9..c65ad91 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs @@ -170,6 +170,22 @@ protected override async Task SendAsync(HttpRequestMessage if (path.StartsWith("/drive/v3/files/", StringComparison.OrdinalIgnoreCase)) { var fileId = path["/drive/v3/files/".Length..]; + + // Handle PATCH for trash operation (soft delete) + if (request.Method == HttpMethod.Patch) + { + if (!_entriesById.TryGetValue(fileId, out var entry)) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + // Remove the entry (simulates trashing) + _entriesById.Remove(fileId); + _idByParentAndName.Remove((entry.ParentId, entry.Name)); + + return JsonResponse(new { id = fileId, name = entry.Name, trashed = true }); + } + if (request.Method == HttpMethod.Delete) { if (!_entriesById.Remove(fileId))