From b5d9b6f363c600a34f82c2d1291fa4ff4b74f244 Mon Sep 17 00:00:00 2001 From: Vedant Koditkar <18693839+KoditkarVedant@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:19:20 +0530 Subject: [PATCH 1/3] Add support for Complete file upload api endpoint --- Src/Notion.Client/Api/ApiEndpoints.cs | 1 + .../Api/FileUploads/Complete/FileUploads.cs | 32 ++++++++ .../Request/CompleteFileUploadRequest.cs | 7 ++ .../ICompleteFileUploadPathParameters.cs | 7 ++ .../Response/CompleteFileUploadResponse.cs | 6 ++ .../Api/FileUploads/IFileUploadsClient.cs | 11 +++ Src/Notion.Client/RestClient/RestClient.cs | 9 ++- .../FileUploadsClientTests.cs | 59 +++++++++++++++ .../StreamSplitExtensions.cs | 74 +++++++++++++++++++ 9 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 Src/Notion.Client/Api/FileUploads/Complete/FileUploads.cs create mode 100644 Src/Notion.Client/Api/FileUploads/Complete/Request/CompleteFileUploadRequest.cs create mode 100644 Src/Notion.Client/Api/FileUploads/Complete/Request/ICompleteFileUploadPathParameters.cs create mode 100644 Src/Notion.Client/Api/FileUploads/Complete/Response/CompleteFileUploadResponse.cs create mode 100644 Test/Notion.IntegrationTests/StreamSplitExtensions.cs 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..c983218f 100644 --- a/Test/Notion.IntegrationTests/FileUploadsClientTests.cs +++ b/Test/Notion.IntegrationTests/FileUploadsClientTests.cs @@ -61,5 +61,64 @@ 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 + var fileStream = File.OpenRead("assets/notion-logo.png"); + var splitStreams = StreamSplitExtensions.Split(fileStream, 2); + fileStream.Close(); + + 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..0e695977 --- /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; + + while (numberOfParts-- > 0) + { + long currentPartSize = numberOfParts == 0 ? 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 From 3cd6b9b8f31e7b98a93ddf3acecb3605df8b1e14 Mon Sep 17 00:00:00 2001 From: Vedant Koditkar <18693839+KoditkarVedant@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:04:18 +0530 Subject: [PATCH 2/3] Simplify the loop --- Test/Notion.IntegrationTests/StreamSplitExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Test/Notion.IntegrationTests/StreamSplitExtensions.cs b/Test/Notion.IntegrationTests/StreamSplitExtensions.cs index 0e695977..ceb415da 100644 --- a/Test/Notion.IntegrationTests/StreamSplitExtensions.cs +++ b/Test/Notion.IntegrationTests/StreamSplitExtensions.cs @@ -27,9 +27,9 @@ public static IEnumerable Split(Stream inputStream, int numberOfParts) long baseSize = totalSize / numberOfParts; long remainder = totalSize % numberOfParts; - while (numberOfParts-- > 0) + for (int i = 0; i < numberOfParts; i++) { - long currentPartSize = numberOfParts == 0 ? baseSize + remainder : baseSize; + long currentPartSize = i == numberOfParts - 1 ? baseSize + remainder : baseSize; var partStream = new MemoryStream(); CopyStream(buffer, partStream, currentPartSize); From 339b7258c00573487761faeeeed6031b1ef2b6e2 Mon Sep 17 00:00:00 2001 From: Vedant Koditkar <18693839+KoditkarVedant@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:06:50 +0530 Subject: [PATCH 3/3] Use using statement to dispose the stream --- .../FileUploadsClientTests.cs | 71 ++++++++++--------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/Test/Notion.IntegrationTests/FileUploadsClientTests.cs b/Test/Notion.IntegrationTests/FileUploadsClientTests.cs index c983218f..e0e7776f 100644 --- a/Test/Notion.IntegrationTests/FileUploadsClientTests.cs +++ b/Test/Notion.IntegrationTests/FileUploadsClientTests.cs @@ -82,43 +82,44 @@ public async Task Verify_multi_part_file_upload_flow() Assert.Equal("pending", createResponse.Status); // Send file parts - var fileStream = File.OpenRead("assets/notion-logo.png"); - var splitStreams = StreamSplitExtensions.Split(fileStream, 2); - fileStream.Close(); - - foreach (var (partStream, index) in splitStreams.WithIndex()) + using (var fileStream = File.OpenRead("assets/notion-logo.png")) { - 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); + 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); } - - // 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