diff --git a/Src/Notion.Client/Api/ApiEndpoints.cs b/Src/Notion.Client/Api/ApiEndpoints.cs index fea18493..9b3f663c 100644 --- a/Src/Notion.Client/Api/ApiEndpoints.cs +++ b/Src/Notion.Client/Api/ApiEndpoints.cs @@ -143,6 +143,7 @@ public static class AuthenticationUrls public static class FileUploadsApiUrls { public static string Create() => "/v1/file_uploads"; + public static string Send(string fileUploadId) => $"/v1/file_uploads/{fileUploadId}/send"; } } } diff --git a/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs b/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs index cca82a9f..48bd817b 100644 --- a/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs +++ b/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs @@ -15,5 +15,19 @@ Task CreateAsync( CreateFileUploadRequest fileUploadObjectRequest, CancellationToken cancellationToken = default ); + + /// + /// Send a file upload + /// + /// Requires a `file_upload_id`, obtained from the `id` of the Create File Upload API response. + /// + /// + /// + /// + /// + Task SendAsync( + SendFileUploadRequest sendFileUploadRequest, + CancellationToken cancellationToken = default + ); } } \ No newline at end of file diff --git a/Src/Notion.Client/Api/FileUploads/Send/FileUploadsClient.cs b/Src/Notion.Client/Api/FileUploads/Send/FileUploadsClient.cs new file mode 100644 index 00000000..73a4de79 --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Send/FileUploadsClient.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Notion.Client +{ + public sealed partial class FileUploadsClient + { + public async Task SendAsync( + SendFileUploadRequest sendFileUploadRequest, + CancellationToken cancellationToken = default) + { + if (sendFileUploadRequest == null) + { + throw new ArgumentNullException(nameof(sendFileUploadRequest)); + } + + if (string.IsNullOrWhiteSpace(sendFileUploadRequest.FileUploadId)) + { + throw new ArgumentNullException(nameof(sendFileUploadRequest.FileUploadId)); + } + + if (sendFileUploadRequest.PartNumber != null) + { + if (!int.TryParse(sendFileUploadRequest.PartNumber, out int partNumberValue) || partNumberValue < 1 || partNumberValue > 1000) + { + throw new ArgumentOutOfRangeException(nameof(sendFileUploadRequest.PartNumber), "PartNumber must be between 1 and 1000."); + } + } + + var path = ApiEndpoints.FileUploadsApiUrls.Send(sendFileUploadRequest.FileUploadId); + + return await _restClient.PostAsync( + path, + formData: sendFileUploadRequest, + cancellationToken: cancellationToken + ); + } + } +} \ No newline at end of file diff --git a/Src/Notion.Client/Api/FileUploads/Send/Request/FileData.cs b/Src/Notion.Client/Api/FileUploads/Send/Request/FileData.cs new file mode 100644 index 00000000..a0825e0b --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Send/Request/FileData.cs @@ -0,0 +1,22 @@ +using System.IO; + +namespace Notion.Client +{ + public class FileData + { + /// + /// The name of the file being uploaded. + /// + public string FileName { get; set; } + + /// + /// The content of the file being uploaded. + /// + public Stream Data { get; set; } + + /// + /// The MIME type of the file being uploaded. + /// + public string ContentType { get; set; } + } +} diff --git a/Src/Notion.Client/Api/FileUploads/Send/Request/ISendFileUploadFormDataParameters.cs b/Src/Notion.Client/Api/FileUploads/Send/Request/ISendFileUploadFormDataParameters.cs new file mode 100644 index 00000000..c4fe2b74 --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Send/Request/ISendFileUploadFormDataParameters.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Notion.Client +{ + public interface ISendFileUploadFormDataParameters + { + /// + /// The raw binary file contents to upload. + /// + FileData File { get; } + + /// + /// When using a mode=multi_part File Upload to send files greater than 20 MB in parts, this is the current part number. + /// Must be an integer between 1 and 1000 provided as a string form field. + /// + string PartNumber { get; } + } +} diff --git a/Src/Notion.Client/Api/FileUploads/Send/Request/ISendFileUploadPathParameters.cs b/Src/Notion.Client/Api/FileUploads/Send/Request/ISendFileUploadPathParameters.cs new file mode 100644 index 00000000..53290575 --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Send/Request/ISendFileUploadPathParameters.cs @@ -0,0 +1,10 @@ +namespace Notion.Client +{ + public interface ISendFileUploadPathParameters + { + /// + /// The `file_upload_id` obtained from the `id` of the Create File Upload API response. + /// + string FileUploadId { get; } + } +} diff --git a/Src/Notion.Client/Api/FileUploads/Send/Request/SendFileUploadRequest.cs b/Src/Notion.Client/Api/FileUploads/Send/Request/SendFileUploadRequest.cs new file mode 100644 index 00000000..0e7c8b01 --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Send/Request/SendFileUploadRequest.cs @@ -0,0 +1,21 @@ +namespace Notion.Client +{ + public class SendFileUploadRequest : ISendFileUploadFormDataParameters, ISendFileUploadPathParameters + { + public FileData File { get; private set; } + public string PartNumber { get; private set; } + public string FileUploadId { get; private set; } + + private SendFileUploadRequest() { } + + public static SendFileUploadRequest Create(string fileUploadId, FileData file, string partNumber = null) + { + return new SendFileUploadRequest + { + FileUploadId = fileUploadId, + File = file, + PartNumber = partNumber + }; + } + } +} diff --git a/Src/Notion.Client/Api/FileUploads/Send/Response/SendFileUploadResponse.cs b/Src/Notion.Client/Api/FileUploads/Send/Response/SendFileUploadResponse.cs new file mode 100644 index 00000000..1061683d --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Send/Response/SendFileUploadResponse.cs @@ -0,0 +1,6 @@ +namespace Notion.Client +{ + public class SendFileUploadResponse : FileObjectResponse + { + } +} diff --git a/Src/Notion.Client/RestClient/IRestClient.cs b/Src/Notion.Client/RestClient/IRestClient.cs index 2e6fe960..dc288d91 100644 --- a/Src/Notion.Client/RestClient/IRestClient.cs +++ b/Src/Notion.Client/RestClient/IRestClient.cs @@ -23,6 +23,15 @@ Task PostAsync( IBasicAuthenticationParameters basicAuthenticationParameters = null, CancellationToken cancellationToken = default); + Task PostAsync( + string uri, + ISendFileUploadFormDataParameters formData, + IEnumerable> queryParams = null, + IDictionary headers = null, + JsonSerializerSettings serializerSettings = null, + IBasicAuthenticationParameters basicAuthenticationParameters = null, + CancellationToken cancellationToken = default); + Task PatchAsync( string uri, object body, diff --git a/Src/Notion.Client/RestClient/RestClient.cs b/Src/Notion.Client/RestClient/RestClient.cs index fc15f38d..a1b0aace 100644 --- a/Src/Notion.Client/RestClient/RestClient.cs +++ b/Src/Notion.Client/RestClient/RestClient.cs @@ -70,6 +70,46 @@ void AttachContent(HttpRequestMessage httpRequest) return await response.ParseStreamAsync(serializerSettings); } + public async Task PostAsync( + string uri, + ISendFileUploadFormDataParameters formData, + IEnumerable> queryParams = null, + IDictionary headers = null, + JsonSerializerSettings serializerSettings = null, + IBasicAuthenticationParameters basicAuthenticationParameters = null, + CancellationToken cancellationToken = default) + { + void AttachContent(HttpRequestMessage httpRequest) + { + var fileContent = new StreamContent(formData.File.Data); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(formData.File.ContentType); + + var form = new MultipartFormDataContent + { + { fileContent, "file", formData.File.FileName } + }; + + if (!string.IsNullOrEmpty(formData.PartNumber)) + { + form.Add(new StringContent(formData.PartNumber), "part_number"); + } + + httpRequest.Content = form; + } + + var response = await SendAsync( + uri, + HttpMethod.Post, + queryParams, + headers, + AttachContent, + basicAuthenticationParameters, + cancellationToken + ); + + return await response.ParseStreamAsync(serializerSettings); + } + public async Task PatchAsync( string uri, object body, diff --git a/Test/Notion.IntegrationTests/FIleUploadsClientTests.cs b/Test/Notion.IntegrationTests/FIleUploadsClientTests.cs deleted file mode 100644 index 17972f7c..00000000 --- a/Test/Notion.IntegrationTests/FIleUploadsClientTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Threading.Tasks; -using Notion.Client; -using Xunit; - -namespace Notion.IntegrationTests -{ - public class FileUploadsClientTests : IntegrationTestBase - { - [Fact] - public async Task CreateAsync() - { - // Arrange - var request = new CreateFileUploadRequest - { - Mode = FileUploadMode.ExternalUrl, - ExternalUrl = "https://unsplash.com/photos/hOhlYhAiizc/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzYwMTkxNzc3fA&force=true", - FileName = "sample-image.jpg", - }; - - // Act - var response = await Client.FileUploads.CreateAsync(request); - - // Assert - Assert.NotNull(response); - Assert.NotNull(response.Status); - Assert.Equal("sample-image.jpg", response.FileName); - } - } -} \ No newline at end of file diff --git a/Test/Notion.IntegrationTests/FileUploadsClientTests.cs b/Test/Notion.IntegrationTests/FileUploadsClientTests.cs new file mode 100644 index 00000000..ca7e73ef --- /dev/null +++ b/Test/Notion.IntegrationTests/FileUploadsClientTests.cs @@ -0,0 +1,65 @@ +using System.IO; +using System.Threading.Tasks; +using Notion.Client; +using Xunit; + +namespace Notion.IntegrationTests +{ + public class FileUploadsClientTests : IntegrationTestBase + { + [Fact] + public async Task CreateAsync() + { + // Arrange + var request = new CreateFileUploadRequest + { + Mode = FileUploadMode.ExternalUrl, + ExternalUrl = "https://unsplash.com/photos/hOhlYhAiizc/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzYwMTkxNzc3fA&force=true", + FileName = "sample-image.jpg", + }; + + // Act + var response = await Client.FileUploads.CreateAsync(request); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Status); + Assert.Equal("sample-image.jpg", response.FileName); + } + + [Fact] + public async Task Verify_file_upload_flow() + { + // Arrange + var createRequest = new CreateFileUploadRequest + { + Mode = FileUploadMode.SinglePart, + FileName = "notion-logo.png", + }; + + var createResponse = await Client.FileUploads.CreateAsync(createRequest); + + using (var fileStream = File.OpenRead("assets/notion-logo.png")) + { + var sendRequest = SendFileUploadRequest.Create( + createResponse.Id, + new FileData + { + FileName = "notion-logo.png", + Data = fileStream, + ContentType = createResponse.ContentType + } + ); + + // Act + var sendResponse = await Client.FileUploads.SendAsync(sendRequest); + + // Assert + Assert.NotNull(sendResponse); + Assert.Equal(createResponse.Id, sendResponse.Id); + Assert.Equal("notion-logo.png", sendResponse.FileName); + Assert.Equal("uploaded", sendResponse.Status); + } + } + } +} \ No newline at end of file diff --git a/Test/Notion.IntegrationTests/Notion.IntegrationTests.csproj b/Test/Notion.IntegrationTests/Notion.IntegrationTests.csproj index 1284ee3f..65f2c257 100644 --- a/Test/Notion.IntegrationTests/Notion.IntegrationTests.csproj +++ b/Test/Notion.IntegrationTests/Notion.IntegrationTests.csproj @@ -11,6 +11,12 @@ + + + PreserveNewest + + + diff --git a/Test/Notion.IntegrationTests/assets/notion-logo.png b/Test/Notion.IntegrationTests/assets/notion-logo.png new file mode 100644 index 00000000..6d04ee79 Binary files /dev/null and b/Test/Notion.IntegrationTests/assets/notion-logo.png differ diff --git a/Test/Notion.UnitTests/FileUploadClientTests.cs b/Test/Notion.UnitTests/FileUploadClientTests.cs deleted file mode 100644 index 53ebaa59..00000000 --- a/Test/Notion.UnitTests/FileUploadClientTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Moq; -using Moq.AutoMock; -using Newtonsoft.Json; -using Notion.Client; -using Xunit; - -namespace Notion.UnitTests; - -public class FileUploadClientTests -{ - private readonly AutoMocker _mocker = new(); - private readonly FileUploadsClient _fileUploadClient; - private readonly Mock _restClientMock; - - public FileUploadClientTests() - { - _restClientMock = _mocker.GetMock(); - _fileUploadClient = _mocker.CreateInstance(); - } - - [Fact] - public async Task CreateAsync_ThrowsArgumentNullException_WhenRequestIsNull() - { - // Act & Assert - var exception = await Assert.ThrowsAsync(() => _fileUploadClient.CreateAsync(null)); - Assert.Equal("fileUploadObjectRequest", exception.ParamName); - Assert.Equal("Value cannot be null. (Parameter 'fileUploadObjectRequest')", exception.Message); - } - - [Fact] - public async Task CreateAsync_CallsRestClientPostAsync_WithCorrectParameters() - { - // Arrange - var request = new CreateFileUploadRequest - { - FileName = "testfile.txt", - Mode = FileUploadMode.SinglePart, - }; - - var expectedResponse = new CreateFileUploadResponse - { - UploadUrl = "https://example.com/upload", - Id = Guid.NewGuid().ToString(), - }; - - _restClientMock - .Setup(client => client.PostAsync( - It.Is(url => url == ApiEndpoints.FileUploadsApiUrls.Create()), - It.IsAny(), - It.IsAny>>(), - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(expectedResponse); - - // Act - var response = await _fileUploadClient.CreateAsync(request); - - // Assert - Assert.Equal(expectedResponse.UploadUrl, response.UploadUrl); - Assert.Equal(expectedResponse.Id, response.Id); - - _restClientMock.VerifyAll(); - } -} \ No newline at end of file diff --git a/Test/Notion.UnitTests/FileUploadsClientTests.cs b/Test/Notion.UnitTests/FileUploadsClientTests.cs new file mode 100644 index 00000000..d1f0c3a7 --- /dev/null +++ b/Test/Notion.UnitTests/FileUploadsClientTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.AutoMock; +using Newtonsoft.Json; +using Notion.Client; +using Xunit; + +namespace Notion.UnitTests; + +public class FileUploadsClientTests +{ + private readonly AutoMocker _mocker = new(); + private readonly FileUploadsClient _fileUploadClient; + private readonly Mock _restClientMock; + + public FileUploadsClientTests() + { + _restClientMock = _mocker.GetMock(); + _fileUploadClient = _mocker.CreateInstance(); + } + + [Fact] + public async Task CreateAsync_ThrowsArgumentNullException_WhenRequestIsNull() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _fileUploadClient.CreateAsync(null)); + Assert.Equal("fileUploadObjectRequest", exception.ParamName); + Assert.Equal("Value cannot be null. (Parameter 'fileUploadObjectRequest')", exception.Message); + } + + [Fact] + public async Task CreateAsync_CallsRestClientPostAsync_WithCorrectParameters() + { + // Arrange + var request = new CreateFileUploadRequest + { + FileName = "testfile.txt", + Mode = FileUploadMode.SinglePart, + }; + + var expectedResponse = new CreateFileUploadResponse + { + UploadUrl = "https://example.com/upload", + Id = Guid.NewGuid().ToString(), + }; + + _restClientMock + .Setup(client => client.PostAsync( + It.Is(url => url == ApiEndpoints.FileUploadsApiUrls.Create()), + It.IsAny(), + It.IsAny>>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + // Act + var response = await _fileUploadClient.CreateAsync(request); + + // Assert + Assert.Equal(expectedResponse.UploadUrl, response.UploadUrl); + Assert.Equal(expectedResponse.Id, response.Id); + + _restClientMock.VerifyAll(); + } + + [Fact] + public async Task SendAsync_ThrowsArgumentNullException_WhenRequestIsNull() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _fileUploadClient.SendAsync(null)); + Assert.Equal("sendFileUploadRequest", exception.ParamName); + Assert.Equal("Value cannot be null. (Parameter 'sendFileUploadRequest')", exception.Message); + } + + [Fact] + public async Task SendAsync_ThrowsArgumentNullException_WhenFileUploadIdIsNullOrEmpty() + { + // Arrange + var request = SendFileUploadRequest.Create(fileUploadId: null, file: new FileData { FileName = "testfile.txt", Data = new System.IO.MemoryStream(), ContentType = "text/plain" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _fileUploadClient.SendAsync(request)); + Assert.Equal("FileUploadId", exception.ParamName); + Assert.Equal("Value cannot be null. (Parameter 'FileUploadId')", exception.Message); + } + + [Theory] + [InlineData("0")] + [InlineData("1001")] + [InlineData("-5")] + [InlineData("abc")] + public async Task SendAsync_ThrowsArgumentOutOfRangeException_WhenPartNumberIsInvalid(string partNumber) + { + // Arrange + var request = SendFileUploadRequest.Create(fileUploadId: "valid-id", file: new FileData { FileName = "testfile.txt", Data = new System.IO.MemoryStream(), ContentType = "text/plain" }, partNumber: partNumber); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _fileUploadClient.SendAsync(request)); + Assert.Equal("PartNumber", exception.ParamName); + Assert.Contains("PartNumber must be between 1 and 1000.", exception.Message); + } + + [Theory] + [InlineData("1")] + [InlineData("500")] + [InlineData("1000")] + public async Task SendAsync_DoesNotThrow_WhenPartNumberIsValid(string partNumber) + { + // Arrange + var request = SendFileUploadRequest.Create(fileUploadId: "valid-id", file: new FileData { FileName = "testfile.txt", Data = new System.IO.MemoryStream(), ContentType = "text/plain" }, partNumber: partNumber); + + var expectedResponse = new SendFileUploadResponse + { + Id = "valid-id", + Status = "uploaded", + }; + + _restClientMock + .Setup(client => client.PostAsync( + It.Is(url => url == ApiEndpoints.FileUploadsApiUrls.Send("valid-id")), + It.IsAny(), + It.IsAny>>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + // Act + var exception = await Record.ExceptionAsync(() => _fileUploadClient.SendAsync(request)); + + // Assert + Assert.Null(exception); + _restClientMock.VerifyAll(); + } + + [Fact] + public async Task SendAsync_CallsRestClientPostAsync_WithCorrectParameters() + { + // Arrange + var fileUploadId = Guid.NewGuid().ToString(); + var request = SendFileUploadRequest.Create( + fileUploadId: fileUploadId, + file: new FileData + { + FileName = "testfile.txt", + Data = new System.IO.MemoryStream(), + ContentType = "text/plain" + } + ); + + var expectedResponse = new SendFileUploadResponse + { + Id = fileUploadId.ToString(), + Status = "uploaded", + }; + + _restClientMock + .Setup(client => client.PostAsync( + It.Is(url => url == ApiEndpoints.FileUploadsApiUrls.Send(fileUploadId)), + It.IsAny(), + It.IsAny>>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + // Act + var response = await _fileUploadClient.SendAsync(request); + + // Assert + Assert.Equal(expectedResponse.Status, response.Status); + Assert.Equal(expectedResponse.Id, response.Id); + _restClientMock.VerifyAll(); + } +}