diff --git a/Src/Notion.Client/Api/ApiEndpoints.cs b/Src/Notion.Client/Api/ApiEndpoints.cs index 9b3f663c..54de4e10 100644 --- a/Src/Notion.Client/Api/ApiEndpoints.cs +++ b/Src/Notion.Client/Api/ApiEndpoints.cs @@ -144,6 +144,7 @@ public static class FileUploadsApiUrls { public static string Create() => "/v1/file_uploads"; public static string Send(string fileUploadId) => $"/v1/file_uploads/{fileUploadId}/send"; + public static string Complete(string fileUploadId) => $"/v1/file_uploads/{fileUploadId}/complete"; } } } diff --git a/Src/Notion.Client/Api/FileUploads/Complete/FileUploads.cs b/Src/Notion.Client/Api/FileUploads/Complete/FileUploads.cs new file mode 100644 index 00000000..b1fb108d --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Complete/FileUploads.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Notion.Client +{ + public sealed partial class FileUploadsClient + { + public async Task CompleteAsync( + CompleteFileUploadRequest completeFileUploadRequest, + CancellationToken cancellationToken = default) + { + if (completeFileUploadRequest == null) + { + throw new ArgumentNullException(nameof(completeFileUploadRequest)); + } + + if (string.IsNullOrEmpty(completeFileUploadRequest.FileUploadId)) + { + throw new ArgumentException("FileUploadId cannot be null or empty.", nameof(completeFileUploadRequest.FileUploadId)); + } + + var path = ApiEndpoints.FileUploadsApiUrls.Complete(completeFileUploadRequest.FileUploadId); + + return await _restClient.PostAsync( + path, + body: null, + cancellationToken: cancellationToken + ); + } + } +} diff --git a/Src/Notion.Client/Api/FileUploads/Complete/Request/CompleteFileUploadRequest.cs b/Src/Notion.Client/Api/FileUploads/Complete/Request/CompleteFileUploadRequest.cs new file mode 100644 index 00000000..7fa72016 --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Complete/Request/CompleteFileUploadRequest.cs @@ -0,0 +1,7 @@ +namespace Notion.Client +{ + public class CompleteFileUploadRequest : ICompleteFileUploadPathParameters + { + public string FileUploadId { get; set; } + } +} diff --git a/Src/Notion.Client/Api/FileUploads/Complete/Request/ICompleteFileUploadPathParameters.cs b/Src/Notion.Client/Api/FileUploads/Complete/Request/ICompleteFileUploadPathParameters.cs new file mode 100644 index 00000000..cb59f0a8 --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Complete/Request/ICompleteFileUploadPathParameters.cs @@ -0,0 +1,7 @@ +namespace Notion.Client +{ + public interface ICompleteFileUploadPathParameters + { + public string FileUploadId { get; set; } + } +} diff --git a/Src/Notion.Client/Api/FileUploads/Complete/Response/CompleteFileUploadResponse.cs b/Src/Notion.Client/Api/FileUploads/Complete/Response/CompleteFileUploadResponse.cs new file mode 100644 index 00000000..317642e9 --- /dev/null +++ b/Src/Notion.Client/Api/FileUploads/Complete/Response/CompleteFileUploadResponse.cs @@ -0,0 +1,6 @@ +namespace Notion.Client +{ + public class CompleteFileUploadResponse : FileObjectResponse + { + } +} diff --git a/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs b/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs index 48bd817b..660665c8 100644 --- a/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs +++ b/Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs @@ -29,5 +29,16 @@ Task SendAsync( SendFileUploadRequest sendFileUploadRequest, CancellationToken cancellationToken = default ); + + /// + /// After uploading all parts of a file (mode=multi_part), call this endpoint to complete the upload process. + /// + /// + /// + /// + Task CompleteAsync( + CompleteFileUploadRequest completeFileUploadRequest, + CancellationToken cancellationToken = default + ); } } \ No newline at end of file diff --git a/Src/Notion.Client/RestClient/RestClient.cs b/Src/Notion.Client/RestClient/RestClient.cs index a1b0aace..92aed875 100644 --- a/Src/Notion.Client/RestClient/RestClient.cs +++ b/Src/Notion.Client/RestClient/RestClient.cs @@ -53,8 +53,13 @@ public async Task PostAsync( { void AttachContent(HttpRequestMessage httpRequest) { - httpRequest.Content = new StringContent(JsonConvert.SerializeObject(body, DefaultSerializerSettings), - Encoding.UTF8, "application/json"); + if (body == null) + { + return; + } + + var jsonObjectString = JsonConvert.SerializeObject(body, DefaultSerializerSettings); + httpRequest.Content = new StringContent(jsonObjectString, Encoding.UTF8, "application/json"); } var response = await SendAsync( diff --git a/Test/Notion.IntegrationTests/FileUploadsClientTests.cs b/Test/Notion.IntegrationTests/FileUploadsClientTests.cs index ca7e73ef..e0e7776f 100644 --- a/Test/Notion.IntegrationTests/FileUploadsClientTests.cs +++ b/Test/Notion.IntegrationTests/FileUploadsClientTests.cs @@ -61,5 +61,65 @@ public async Task Verify_file_upload_flow() Assert.Equal("uploaded", sendResponse.Status); } } + + [Fact] + public async Task Verify_multi_part_file_upload_flow() + { + // Create file upload + var createRequest = new CreateFileUploadRequest + { + Mode = FileUploadMode.MultiPart, + FileName = "notion-logo.png", + NumberOfParts = 2 + }; + + var createResponse = await Client.FileUploads.CreateAsync(createRequest); + + Assert.NotNull(createResponse); + Assert.NotNull(createResponse.Id); + Assert.Equal("notion-logo.png", createResponse.FileName); + Assert.Equal("image/png", createResponse.ContentType); + Assert.Equal("pending", createResponse.Status); + + // Send file parts + using (var fileStream = File.OpenRead("assets/notion-logo.png")) + { + var splitStreams = StreamSplitExtensions.Split(fileStream, 2); + + foreach (var (partStream, index) in splitStreams.WithIndex()) + { + var partSendRequest = SendFileUploadRequest.Create( + createResponse.Id, + new FileData + { + FileName = "notion-logo.png", + Data = partStream, + ContentType = createResponse.ContentType + }, + + partNumber: (index + 1).ToString() + ); + + var partSendResponse = await Client.FileUploads.SendAsync(partSendRequest); + + Assert.NotNull(partSendResponse); + Assert.Equal(createResponse.Id, partSendResponse.Id); + Assert.Equal("notion-logo.png", partSendResponse.FileName); + } + + // Complete file upload + var completeRequest = new CompleteFileUploadRequest + { + FileUploadId = createResponse.Id + }; + + var completeResponse = await Client.FileUploads.CompleteAsync(completeRequest); + + Assert.NotNull(completeResponse); + Assert.Equal(createResponse.Id, completeResponse.Id); + Assert.Equal("notion-logo.png", completeResponse.FileName); + Assert.Equal("completed", completeResponse.Status); + } + } } } \ No newline at end of file diff --git a/Test/Notion.IntegrationTests/StreamSplitExtensions.cs b/Test/Notion.IntegrationTests/StreamSplitExtensions.cs new file mode 100644 index 00000000..ceb415da --- /dev/null +++ b/Test/Notion.IntegrationTests/StreamSplitExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Notion.IntegrationTests +{ + public static class StreamSplitExtensions + { + public static IEnumerable Split(Stream inputStream, int numberOfParts) + { + if (numberOfParts <= 0) + { + throw new ArgumentException("Number of parts must be greater than zero.", nameof(numberOfParts)); + } + + if (inputStream == null) + { + throw new ArgumentNullException(nameof(inputStream)); + } + + MemoryStream buffer = new(); + inputStream.CopyTo(buffer); + + buffer.Position = 0; + + long totalSize = buffer.Length; + long baseSize = totalSize / numberOfParts; + long remainder = totalSize % numberOfParts; + + for (int i = 0; i < numberOfParts; i++) + { + long currentPartSize = i == numberOfParts - 1 ? baseSize + remainder : baseSize; + + var partStream = new MemoryStream(); + CopyStream(buffer, partStream, currentPartSize); + partStream.Position = 0; + yield return partStream; + } + } + + private static void CopyStream(Stream buffer, MemoryStream partStream, long bytesToCopy) + { + byte[] tempBuffer = new byte[81920]; // 80 KB buffer + + while (bytesToCopy > 0) + { + int bytesToRead = (int)Math.Min(tempBuffer.Length, bytesToCopy); + int bytesRead = buffer.Read(tempBuffer, 0, bytesToRead); + if (bytesRead == 0) + { + break; // End of stream + } + + partStream.Write(tempBuffer, 0, bytesRead); + bytesToCopy -= bytesRead; + } + } + + // enumerate with index + public static IEnumerable<(T item, int index)> WithIndex(this IEnumerable source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + int index = 0; + foreach (var item in source) + { + yield return (item, index++); + } + } + } +} \ No newline at end of file