Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Src/Notion.Client/Api/ApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
}
32 changes: 32 additions & 0 deletions Src/Notion.Client/Api/FileUploads/Complete/FileUploads.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Notion.Client
{
public sealed partial class FileUploadsClient
{
public async Task<CompleteFileUploadResponse> 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<CompleteFileUploadResponse>(
path,
body: null,
cancellationToken: cancellationToken
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Notion.Client
{
public class CompleteFileUploadRequest : ICompleteFileUploadPathParameters
{
public string FileUploadId { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Notion.Client
{
public interface ICompleteFileUploadPathParameters
{
public string FileUploadId { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Notion.Client
{
public class CompleteFileUploadResponse : FileObjectResponse
{
}
}
11 changes: 11 additions & 0 deletions Src/Notion.Client/Api/FileUploads/IFileUploadsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,16 @@ Task<SendFileUploadResponse> SendAsync(
SendFileUploadRequest sendFileUploadRequest,
CancellationToken cancellationToken = default
);

/// <summary>
/// After uploading all parts of a file (mode=multi_part), call this endpoint to complete the upload process.
/// </summary>
/// <param name="completeFileUploadRequest"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<CompleteFileUploadResponse> CompleteAsync(
CompleteFileUploadRequest completeFileUploadRequest,
CancellationToken cancellationToken = default
);
}
}
9 changes: 7 additions & 2 deletions Src/Notion.Client/RestClient/RestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ public async Task<T> PostAsync<T>(
{
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(
Expand Down
60 changes: 60 additions & 0 deletions Test/Notion.IntegrationTests/FileUploadsClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
74 changes: 74 additions & 0 deletions Test/Notion.IntegrationTests/StreamSplitExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace Notion.IntegrationTests
{
public static class StreamSplitExtensions
{
public static IEnumerable<Stream> 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<T>(this IEnumerable<T> source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}

int index = 0;
foreach (var item in source)
{
yield return (item, index++);
}
}
}
}
Loading