From 40fbef33f1084ce69cfd842a978cefe3bb825ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Ku=CC=88pper?= Date: Thu, 18 Sep 2025 08:45:09 -0400 Subject: [PATCH 1/5] implement uploading of files --- .../Models/File/FileObjectWithName.cs | 1 + .../Models/File/FileUploadWithName.cs | 20 +++++++ Src/Notion.Client/RestClient/IRestClient.cs | 2 + Src/Notion.Client/RestClient/RestClient.cs | 47 ++++++++++++++++ .../PageClientTests.cs | 55 +++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 Src/Notion.Client/Models/File/FileUploadWithName.cs diff --git a/Src/Notion.Client/Models/File/FileObjectWithName.cs b/Src/Notion.Client/Models/File/FileObjectWithName.cs index 855a5e0f..c1261b59 100644 --- a/Src/Notion.Client/Models/File/FileObjectWithName.cs +++ b/Src/Notion.Client/Models/File/FileObjectWithName.cs @@ -6,6 +6,7 @@ namespace Notion.Client [JsonConverter(typeof(JsonSubtypes), "type")] [JsonSubtypes.KnownSubTypeAttribute(typeof(UploadedFileWithName), "file")] [JsonSubtypes.KnownSubTypeAttribute(typeof(ExternalFileWithName), "external")] + [JsonSubtypes.KnownSubTypeAttribute(typeof(FileUploadWithName), "file_upload")] public abstract class FileObjectWithName { [JsonProperty("type")] diff --git a/Src/Notion.Client/Models/File/FileUploadWithName.cs b/Src/Notion.Client/Models/File/FileUploadWithName.cs new file mode 100644 index 00000000..6f5e547f --- /dev/null +++ b/Src/Notion.Client/Models/File/FileUploadWithName.cs @@ -0,0 +1,20 @@ +using System; +using Newtonsoft.Json; + +namespace Notion.Client +{ + public class FileUploadWithName : FileObjectWithName + { + public override string Type => "file_upload"; + + [JsonProperty("file_upload")] + public Info FileUpload { get; set; } + + public class Info + { + [JsonProperty("id")] + public Guid Id { get; set; } + } + + } +} diff --git a/Src/Notion.Client/RestClient/IRestClient.cs b/Src/Notion.Client/RestClient/IRestClient.cs index b8ffe5b3..e87429a6 100644 --- a/Src/Notion.Client/RestClient/IRestClient.cs +++ b/Src/Notion.Client/RestClient/IRestClient.cs @@ -35,5 +35,7 @@ Task DeleteAsync( IDictionary queryParams = null, IDictionary headers = null, CancellationToken cancellationToken = default); + + Task Upload(string filePath, JsonSerializerSettings serializerSettings = null); } } diff --git a/Src/Notion.Client/RestClient/RestClient.cs b/Src/Notion.Client/RestClient/RestClient.cs index 59a848f7..164059ed 100644 --- a/Src/Notion.Client/RestClient/RestClient.cs +++ b/Src/Notion.Client/RestClient/RestClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Text; @@ -91,6 +92,52 @@ public async Task DeleteAsync( await SendAsync(uri, HttpMethod.Delete, queryParams, headers, null, cancellationToken); } + public async Task Upload(string filePath, JsonSerializerSettings serializerSettings = null) + { + var response = await this.PostAsync("https://api.notion.com/v1/file_uploads", + new UploadRequest {Mode = "single_part"}); + + using (var formData = new MultipartFormDataContent()) + { + var fileStream = File.OpenRead(filePath); + var fileContent = new StreamContent(fileStream); + formData.Add(fileContent, "file", Path.GetFileName(filePath)); + + var uploadResponse = await this.SendAsync(response.UploadUrl, HttpMethod.Post, attachContent: message => + { + message.Content = formData; + }); + return await uploadResponse.ParseStreamAsync(serializerSettings); + } + } + + public class UploadRequest + { + public string Mode { get; set; } + } + + public class UploadResponse + { + public Guid Id { get; set; } + public string Object { get; set; } + [JsonProperty("created_time")] + public DateTime CreatedTime { get; set; } + [JsonProperty("last_edited_time")] + public DateTime LastEditedTime { get; set; } + [JsonProperty("expiry_time")] + public DateTime ExpiryTime { get; set; } + [JsonProperty("upload_url")] + public string UploadUrl { get; set; } + public bool Archived { get; set; } + public string Status { get; set; } + public string Filename { get; set; } + [JsonProperty("content_type")] + public string ContentType { get; set; } + [JsonProperty("request_id")] + public Guid RequestId { get; set; } + } + + private static ClientOptions MergeOptions(ClientOptions options) { return new ClientOptions diff --git a/Test/Notion.IntegrationTests/PageClientTests.cs b/Test/Notion.IntegrationTests/PageClientTests.cs index 4938bcfa..391b561f 100644 --- a/Test/Notion.IntegrationTests/PageClientTests.cs +++ b/Test/Notion.IntegrationTests/PageClientTests.cs @@ -54,6 +54,7 @@ public async Task InitializeAsync() } }, { "Number", new NumberPropertySchema { Number = new Number { Format = "number" } } } + {"Profile picture", new FilePropertySchema() { Files = new Dictionary()}} }, Parent = new ParentPageInput { PageId = _page.Id } }; @@ -179,6 +180,60 @@ public async Task Test_RetrievePagePropertyItemAsync() titleProperty.Title.PlainText.Should().Be("Test Page Title"); }); } + + [Fact] + public async Task Test_CanUploadAndSetProfilePicture() + { + // Arrange + var upload = await Client.RestClient.Upload("/Users/kkuepper/Pictures/Scan.jpeg"); + + // Act + var pagesCreateParameters = PagesCreateParametersBuilder + .Create(new DatabaseParentInput {DatabaseId = _database.Id}) + .AddProperty("Name", + new TitlePropertyValue + { + Title = new List + { + new RichTextText {Text = new Text {Content = "Test Page Title"}} + } + }) + .AddProperty("Number", new NumberPropertyValue() {Number = 123}) + .AddProperty("Profile picture", + new FilesPropertyValue + { + Files = new List + { + new FileUploadWithName {FileUpload = new FileUploadWithName.Info {Id = upload.Id}} + } + } + ) + .Build(); + + var page = await Client.Pages.CreateAsync(pagesCreateParameters); + + var property = await Client.Pages.RetrievePagePropertyItemAsync(new RetrievePropertyItemParameters + { + PageId = page.Id, + PropertyId = "Profile picture" + }); + + // Assert + property.Should().NotBeNull(); + property.Should().BeOfType(); + + var listProperty = (FilesPropertyItem)property; + + listProperty.Type.Should().NotBeNull(); + + listProperty.Files.Should().SatisfyRespectively(p => + { + p.Should().BeOfType(); + var fileWithName = (UploadedFileWithName)p; + + fileWithName.Name.Should().Be("Scan.jpeg"); + }); + } [Fact] public async Task Test_UpdatePageProperty_with_date_as_null() From 21138a8e3eb6c0724fe431c6063807d9a4bc4f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Ku=CC=88pper?= Date: Thu, 18 Sep 2025 09:04:54 -0400 Subject: [PATCH 2/5] fix --- Test/Notion.IntegrationTests/PageClientTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Test/Notion.IntegrationTests/PageClientTests.cs b/Test/Notion.IntegrationTests/PageClientTests.cs index 391b561f..fce29faa 100644 --- a/Test/Notion.IntegrationTests/PageClientTests.cs +++ b/Test/Notion.IntegrationTests/PageClientTests.cs @@ -53,7 +53,7 @@ public async Task InitializeAsync() } } }, - { "Number", new NumberPropertySchema { Number = new Number { Format = "number" } } } + { "Number", new NumberPropertySchema { Number = new Number { Format = "number" } } }, {"Profile picture", new FilePropertySchema() { Files = new Dictionary()}} }, Parent = new ParentPageInput { PageId = _page.Id } From db8363195d5f3ff5e73d2e753784abc3fd3f6eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Ku=CC=88pper?= Date: Thu, 18 Sep 2025 09:13:48 -0400 Subject: [PATCH 3/5] fix CodeFactore issue --- Src/Notion.Client/Models/File/FileUploadWithName.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Src/Notion.Client/Models/File/FileUploadWithName.cs b/Src/Notion.Client/Models/File/FileUploadWithName.cs index 6f5e547f..a3e93a8f 100644 --- a/Src/Notion.Client/Models/File/FileUploadWithName.cs +++ b/Src/Notion.Client/Models/File/FileUploadWithName.cs @@ -15,6 +15,5 @@ public class Info [JsonProperty("id")] public Guid Id { get; set; } } - } } From ba4a9e788bef65dd678f604daddbb72f3f593fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Ku=CC=88pper?= Date: Thu, 18 Sep 2025 10:10:42 -0400 Subject: [PATCH 4/5] also allow uploading from Stream --- Src/Notion.Client/RestClient/RestClient.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Src/Notion.Client/RestClient/RestClient.cs b/Src/Notion.Client/RestClient/RestClient.cs index 164059ed..e29f8212 100644 --- a/Src/Notion.Client/RestClient/RestClient.cs +++ b/Src/Notion.Client/RestClient/RestClient.cs @@ -93,15 +93,20 @@ public async Task DeleteAsync( } public async Task Upload(string filePath, JsonSerializerSettings serializerSettings = null) + { + var fileStream = File.OpenRead(filePath); + return await Upload(fileStream, Path.GetFileName(filePath), serializerSettings); + } + + public async Task Upload(Stream stream, string filename, JsonSerializerSettings serializerSettings = null) { var response = await this.PostAsync("https://api.notion.com/v1/file_uploads", new UploadRequest {Mode = "single_part"}); using (var formData = new MultipartFormDataContent()) { - var fileStream = File.OpenRead(filePath); - var fileContent = new StreamContent(fileStream); - formData.Add(fileContent, "file", Path.GetFileName(filePath)); + var fileContent = new StreamContent(stream); + formData.Add(fileContent, "file", filename); var uploadResponse = await this.SendAsync(response.UploadUrl, HttpMethod.Post, attachContent: message => { From b40da857351c6e9eaef25cb5fa3a44f49516f734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Ku=CC=88pper?= Date: Thu, 18 Sep 2025 14:50:06 -0400 Subject: [PATCH 5/5] set correct mime type and add uploading content to ImageBlock --- Src/Notion.Client/Models/File/FileObject.cs | 1 + .../Models/File/UploadingFile.cs | 19 ++++++++++++++ Src/Notion.Client/RestClient/IRestClient.cs | 2 ++ Src/Notion.Client/RestClient/RestClient.cs | 25 ++++++++++++++++--- .../PageClientTests.cs | 6 ++++- 5 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 Src/Notion.Client/Models/File/UploadingFile.cs diff --git a/Src/Notion.Client/Models/File/FileObject.cs b/Src/Notion.Client/Models/File/FileObject.cs index 0ac30a6c..1b51e35a 100644 --- a/Src/Notion.Client/Models/File/FileObject.cs +++ b/Src/Notion.Client/Models/File/FileObject.cs @@ -6,6 +6,7 @@ namespace Notion.Client { [JsonConverter(typeof(JsonSubtypes), "type")] [JsonSubtypes.KnownSubTypeAttribute(typeof(UploadedFile), "file")] + [JsonSubtypes.KnownSubTypeAttribute(typeof(UploadingFile), "file_upload")] [JsonSubtypes.KnownSubTypeAttribute(typeof(ExternalFile), "external")] public abstract class FileObject : IPageIcon { diff --git a/Src/Notion.Client/Models/File/UploadingFile.cs b/Src/Notion.Client/Models/File/UploadingFile.cs new file mode 100644 index 00000000..a78b421c --- /dev/null +++ b/Src/Notion.Client/Models/File/UploadingFile.cs @@ -0,0 +1,19 @@ +using System; +using Newtonsoft.Json; + +namespace Notion.Client +{ + public class UploadingFile : FileObject + { + public override string Type => "file_upload"; + + [JsonProperty("file_upload")] + public Info FileUpload { get; set; } + + public class Info + { + [JsonProperty("id")] + public Guid Id { get; set; } + } + } +} diff --git a/Src/Notion.Client/RestClient/IRestClient.cs b/Src/Notion.Client/RestClient/IRestClient.cs index e87429a6..f675340c 100644 --- a/Src/Notion.Client/RestClient/IRestClient.cs +++ b/Src/Notion.Client/RestClient/IRestClient.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; @@ -37,5 +38,6 @@ Task DeleteAsync( CancellationToken cancellationToken = default); Task Upload(string filePath, JsonSerializerSettings serializerSettings = null); + Task Upload(Stream stream, string filename, JsonSerializerSettings serializerSettings = null); } } diff --git a/Src/Notion.Client/RestClient/RestClient.cs b/Src/Notion.Client/RestClient/RestClient.cs index e29f8212..f8850fd5 100644 --- a/Src/Notion.Client/RestClient/RestClient.cs +++ b/Src/Notion.Client/RestClient/RestClient.cs @@ -106,12 +106,29 @@ public async Task Upload(Stream stream, string filename, JsonSer using (var formData = new MultipartFormDataContent()) { var fileContent = new StreamContent(stream); - formData.Add(fileContent, "file", filename); - var uploadResponse = await this.SendAsync(response.UploadUrl, HttpMethod.Post, attachContent: message => + var mimeMapping = new Dictionary + { + {".jpg", "image/jpeg"}, + {".jpeg", "image/jpeg"}, + {".png", "image/png"}, + {".tif", "image/tiff"}, + {".tiff", "image/tiff"}, + {".gif", "image/gif"}, + {".svg", "image/svg+xml"} + }; + + if (mimeMapping.TryGetValue(Path.GetExtension(filename).ToLower(), out var mapping)) { - message.Content = formData; - }); + fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(mapping); + } + formData.Add(fileContent, "file", filename); + + var uploadResponse = await this.SendAsync(response.UploadUrl, HttpMethod.Post, + attachContent: message => + { + message.Content = formData; + }); return await uploadResponse.ParseStreamAsync(serializerSettings); } } diff --git a/Test/Notion.IntegrationTests/PageClientTests.cs b/Test/Notion.IntegrationTests/PageClientTests.cs index fce29faa..fab980ce 100644 --- a/Test/Notion.IntegrationTests/PageClientTests.cs +++ b/Test/Notion.IntegrationTests/PageClientTests.cs @@ -198,7 +198,7 @@ public async Task Test_CanUploadAndSetProfilePicture() new RichTextText {Text = new Text {Content = "Test Page Title"}} } }) - .AddProperty("Number", new NumberPropertyValue() {Number = 123}) + .AddProperty("Number", new NumberPropertyValue {Number = 123}) .AddProperty("Profile picture", new FilesPropertyValue { @@ -208,6 +208,10 @@ public async Task Test_CanUploadAndSetProfilePicture() } } ) + .AddPageContent(new ImageBlock + { + Image = new UploadingFile {FileUpload = new UploadingFile.Info {Id = upload.Id}} + }) .Build(); var page = await Client.Pages.CreateAsync(pagesCreateParameters);