Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
59 changes: 59 additions & 0 deletions Test/Notion.IntegrationTests/FileUploadsClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
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;

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<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