From a9c0c3bcdc1aa437a112916c4d9e0ed73332862b Mon Sep 17 00:00:00 2001 From: Christian Bergum Bergersen Date: Thu, 1 Oct 2026 22:03:31 +0200 Subject: [PATCH] Add Native AOT support and replace Newtonsoft.Json with System.Text.Json Make the client usable in trimmed and Native AOT apps. The package is now marked IsAotCompatible for .NET 8 and newer and builds without trim or AOT warnings. Library - Remove the Newtonsoft.Json dependency. Cookies, bookmarks and simple maps go through a source-generated JsonSerializerContext; loosely typed values (metadata, overlay options, extra headers) are written by JsonObjectWriter, which inspects runtime types instead of reflecting. - Annotate FacetBase with DynamicallyAccessedMembers so the [MultiFormHeader] properties survive trimming. - Rewrite GotenbergApiException.ToVerboseJson with Utf8JsonWriter. - Replace the [Description] reflection lookup for dimension units with a switch, and use the generic Enum.GetValues on .NET 5+. - Reference System.Text.Json 8.0.5 on the netstandard targets. Breaking changes - JObject is gone from the public API. PdfOutputOptions.MetaData, WriteMetadataRequest.Metadata, WatermarkOptions.Options, StampOptions.Options and HtmlConversionBehaviors.ExtraHeaders are now IDictionary; builder overloads take that or a System.Text.Json.Nodes.JsonObject. - Dictionary values must be primitives, dates, byte arrays, dictionaries, collections or JsonNode/JsonElement. Other object types throw NotSupportedException when passed to the builder. - Builders copy the supplied dictionary (shallow). - ReadPdfBookmarksAsync throws System.Text.Json.JsonException on malformed JSON; an empty body returns no bookmarks. - ToVerboseJson writes a fixed set of fields, and JSON form fields are sent compact rather than indented. Tests and CI - Add test/GotenbergSharpClient.AotSmokeTest, a Native AOT console app that exercises the client against an in-process stub and, when GOTENBERG_URL is set, a live Gotenberg. Trim and AOT warnings fail its publish. - Publish and run the smoke test in deploy.yml before Pack. - Move the test projects, examples and CI to .NET 10. - Convert test assertions to System.Text.Json.Nodes and add JsonValueHandlingTests for the new value handling. Examples and docs - Fix five examples that referenced namespaces removed in v3.0.0, and add all examples to the solution so CI builds them. - Correct the using statements in docs/getting-started.md. --- .github/workflows/deploy.yml | 13 +- CHANGES.MD | 13 + GotenbergSharpApiClient.sln | 109 +++++++ docs/getting-started.md | 7 +- examples/ChromiumFeatures/Program.cs | 2 +- examples/Directory.Build.props | 16 +- examples/EncryptPdf/Program.cs | 2 +- examples/LibreOfficeOptions/Program.cs | 3 +- examples/PdfEngineOperations/Program.cs | 7 +- examples/Screenshot/Program.cs | 4 +- examples/WatermarkAndRotate/Program.cs | 4 +- .../Application/Builders/PdfEngineBuilder.cs | 10 +- .../HtmlConversionBehaviorBuilder.cs | 28 +- .../Overlays/StampOptionsBuilder.cs | 11 +- .../Overlays/WatermarkOptionsBuilder.cs | 11 +- .../PdfOutput/PdfOutputOptionsBuilder.cs | 20 +- .../Domain/Bookmarks/Bookmark.cs | 16 +- .../Domain/Bookmarks/BookmarkSet.cs | 4 +- .../Domain/Cookies/Cookie.cs | 18 +- .../Domain/Dimensions/Dimension.cs | 8 +- .../Domain/ExtraHeaders/ExtraHttpHeaders.cs | 5 +- .../Domain/ExtraUrls/ExtraUrlResourceItem.cs | 6 +- .../HtmlBehavior/EmulatedMediaFeature.cs | 4 +- .../HtmlBehavior/HtmlConversionBehaviors.cs | 4 +- .../Domain/Overlays/StampOptions.cs | 4 +- .../Domain/Overlays/WatermarkOptions.cs | 4 +- .../Domain/PdfOutput/PdfOutputOptions.cs | 4 +- .../Domain/Requests/EmbedRequest.cs | 13 +- .../Domain/Requests/FacetBase.cs | 20 +- .../Domain/Requests/WriteMetadataRequest.cs | 6 +- .../Extensions/EnumExtensions.cs | 24 +- .../GlobalUsings.cs | 5 +- .../Gotenberg.Sharp.Api.Client.csproj | 4 +- .../GotenbergSharpClient.cs | 8 +- .../DynamicallyAccessedMembersAttribute.cs | 39 +++ .../Infrastructure/GotenbergApiException.cs | 82 +++-- .../Json/GotenbergJsonContext.cs | 49 +++ .../Infrastructure/Json/JsonObjectWriter.cs | 189 ++++++++++++ .../Infrastructure/MultiFormPropertyItem.cs | 4 +- .../GotenbergSharpClient.AotSmokeTest.csproj | 37 +++ .../Program.cs | 286 ++++++++++++++++++ .../BookmarkTests.cs | 29 +- .../ChromiumMissingFieldsTests.cs | 26 +- .../GotenbergSharpClient.Tests/CookieTests.cs | 93 +++--- .../GotenbergSharpClient.Tests.csproj | 6 +- .../HtmlConversionBehaviorBuilderTests.cs | 50 +-- .../JsonValueHandlingTests.cs | 218 +++++++++++++ .../PdfEngineOperationsTests.cs | 8 +- .../ValueObjects/EmulatedMediaFeatureTests.cs | 23 +- 49 files changed, 1311 insertions(+), 245 deletions(-) create mode 100644 src/Gotenberg.Sharp.Api.Client/Infrastructure/DynamicallyAccessedMembersAttribute.cs create mode 100644 src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/GotenbergJsonContext.cs create mode 100644 src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/JsonObjectWriter.cs create mode 100644 test/GotenbergSharpClient.AotSmokeTest/GotenbergSharpClient.AotSmokeTest.csproj create mode 100644 test/GotenbergSharpClient.AotSmokeTest/Program.cs create mode 100644 test/GotenbergSharpClient.Tests/JsonValueHandlingTests.cs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9784526..ed94c97 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - dotnet-version: [8.x] + dotnet-version: [10.x] steps: - name: Checkout @@ -60,6 +60,17 @@ jobs: GOTENBERG_USERNAME: testuser GOTENBERG_PASSWORD: testpass + # The project treats trim and AOT warnings as errors, so the publish fails if the client regresses. + - name: Publish AOT smoke test + run: dotnet publish test/GotenbergSharpClient.AotSmokeTest -c Release -r linux-x64 -o aot-smoke-test + + - name: Run AOT smoke test + run: ./aot-smoke-test/GotenbergSharpClient.AotSmokeTest + env: + GOTENBERG_URL: http://localhost:3000 + GOTENBERG_USERNAME: testuser + GOTENBERG_PASSWORD: testpass + - name: Pack run: dotnet pack src/Gotenberg.Sharp.Api.Client/Gotenberg.Sharp.Api.Client.csproj -c Release -p:PackageVersion=${{ steps.gitversion.outputs.FullSemVer }} -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg diff --git a/CHANGES.MD b/CHANGES.MD index 90bcbf3..045267d 100644 --- a/CHANGES.MD +++ b/CHANGES.MD @@ -1,5 +1,18 @@ # Release History +## Unreleased +- **Native AOT and Trimming Support:** + - The package is marked `IsAotCompatible` for .NET 8 and newer and builds without trim or AOT warnings + - JSON is now written and read with source-generated `System.Text.Json`; the `Newtonsoft.Json` dependency is removed + - New `test/GotenbergSharpClient.AotSmokeTest` project publishes the client with Native AOT and exercises it end to end +- **Breaking Changes:** + - `JObject` is gone from the public API. `PdfOutputOptions.MetaData`, `WriteMetadataRequest.Metadata`, `WatermarkOptions.Options`, `StampOptions.Options`, and `HtmlConversionBehaviors.ExtraHeaders` are now `IDictionary` + - Builder overloads that took a `JObject` (`SetMetadata`, `WriteMetadata`, `SetOptions`, `AddAdditionalHeaders`) now take an `IDictionary` or a `System.Text.Json.Nodes.JsonObject` + - Dictionary values must be strings, numbers, booleans, dates, dictionaries, collections, or `JsonNode`/`JsonElement`; other object types throw `NotSupportedException` instead of being serialized by reflection + - `ReadPdfBookmarksAsync()` throws `System.Text.Json.JsonException` on malformed JSON instead of a Newtonsoft exception + - `GotenbergApiException.ToVerboseJson()` writes a fixed set of response and request fields rather than a reflection dump of the underlying objects + - JSON form fields are sent compact rather than indented + ## v3.1.0 - **PDF Bookmarks (Issue #79):** - New `PdfEngineBuilders.ReadBookmarks()`, `WriteBookmarks()`, and `WriteBookmarksPerFile()` diff --git a/GotenbergSharpApiClient.sln b/GotenbergSharpApiClient.sln index d19cbde..5202d14 100644 --- a/GotenbergSharpApiClient.sln +++ b/GotenbergSharpApiClient.sln @@ -32,6 +32,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UrlsToMergedPdf", "examples EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Webhook", "examples\Webhook\Webhook.csproj", "{01C9F662-6378-3FC4-B629-9FD37A38033D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GotenbergSharpClient.AotSmokeTest", "test\GotenbergSharpClient.AotSmokeTest\GotenbergSharpClient.AotSmokeTest.csproj", "{64363F41-ACC1-48AA-A37A-43E2FA67C611}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChromiumFeatures", "examples\ChromiumFeatures\ChromiumFeatures.csproj", "{4867319B-585F-4ADD-95A1-FDCFB8017F11}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EncryptPdf", "examples\EncryptPdf\EncryptPdf.csproj", "{E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibreOfficeOptions", "examples\LibreOfficeOptions\LibreOfficeOptions.csproj", "{0AB269E4-CE02-4BC7-86E0-B0E653DF6006}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Screenshot", "examples\Screenshot\Screenshot.csproj", "{1B555B39-70B8-4DEB-9C12-757A5516A68B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WatermarkAndRotate", "examples\WatermarkAndRotate\WatermarkAndRotate.csproj", "{5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PdfEngineOperations", "examples\PdfEngineOperations\PdfEngineOperations.csproj", "{DB62A461-4823-4705-A7F3-302B72A0D767}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -174,6 +192,90 @@ Global {01C9F662-6378-3FC4-B629-9FD37A38033D}.Release|x64.Build.0 = Release|Any CPU {01C9F662-6378-3FC4-B629-9FD37A38033D}.Release|x86.ActiveCfg = Release|Any CPU {01C9F662-6378-3FC4-B629-9FD37A38033D}.Release|x86.Build.0 = Release|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Debug|x64.ActiveCfg = Debug|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Debug|x64.Build.0 = Debug|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Debug|x86.ActiveCfg = Debug|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Debug|x86.Build.0 = Debug|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Release|Any CPU.Build.0 = Release|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Release|x64.ActiveCfg = Release|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Release|x64.Build.0 = Release|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Release|x86.ActiveCfg = Release|Any CPU + {64363F41-ACC1-48AA-A37A-43E2FA67C611}.Release|x86.Build.0 = Release|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Debug|x64.ActiveCfg = Debug|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Debug|x64.Build.0 = Debug|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Debug|x86.ActiveCfg = Debug|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Debug|x86.Build.0 = Debug|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Release|Any CPU.Build.0 = Release|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Release|x64.ActiveCfg = Release|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Release|x64.Build.0 = Release|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Release|x86.ActiveCfg = Release|Any CPU + {4867319B-585F-4ADD-95A1-FDCFB8017F11}.Release|x86.Build.0 = Release|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Debug|x64.Build.0 = Debug|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Debug|x86.Build.0 = Debug|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Release|Any CPU.Build.0 = Release|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Release|x64.ActiveCfg = Release|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Release|x64.Build.0 = Release|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Release|x86.ActiveCfg = Release|Any CPU + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A}.Release|x86.Build.0 = Release|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Debug|x64.Build.0 = Debug|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Debug|x86.Build.0 = Debug|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Release|Any CPU.Build.0 = Release|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Release|x64.ActiveCfg = Release|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Release|x64.Build.0 = Release|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Release|x86.ActiveCfg = Release|Any CPU + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006}.Release|x86.Build.0 = Release|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Debug|x64.Build.0 = Debug|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Debug|x86.Build.0 = Debug|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Release|Any CPU.Build.0 = Release|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Release|x64.ActiveCfg = Release|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Release|x64.Build.0 = Release|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Release|x86.ActiveCfg = Release|Any CPU + {1B555B39-70B8-4DEB-9C12-757A5516A68B}.Release|x86.Build.0 = Release|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Debug|x64.ActiveCfg = Debug|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Debug|x64.Build.0 = Debug|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Debug|x86.ActiveCfg = Debug|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Debug|x86.Build.0 = Debug|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Release|Any CPU.Build.0 = Release|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Release|x64.ActiveCfg = Release|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Release|x64.Build.0 = Release|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Release|x86.ActiveCfg = Release|Any CPU + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48}.Release|x86.Build.0 = Release|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Debug|x64.Build.0 = Debug|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Debug|x86.Build.0 = Debug|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Release|Any CPU.Build.0 = Release|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Release|x64.ActiveCfg = Release|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Release|x64.Build.0 = Release|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Release|x86.ActiveCfg = Release|Any CPU + {DB62A461-4823-4705-A7F3-302B72A0D767}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -188,6 +290,13 @@ Global {9C18F2D1-E9E9-613A-DE10-4263A2EA8A17} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {A4D0B498-A934-2F11-2AA7-18D1D1C9E881} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {01C9F662-6378-3FC4-B629-9FD37A38033D} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {64363F41-ACC1-48AA-A37A-43E2FA67C611} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {4867319B-585F-4ADD-95A1-FDCFB8017F11} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {E4E3BD40-0FE4-4A5A-9D2D-5B626A68CC6A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {0AB269E4-CE02-4BC7-86E0-B0E653DF6006} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {1B555B39-70B8-4DEB-9C12-757A5516A68B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {5AE2A0D6-0D8A-414D-B850-E65FA3FBEB48} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {DB62A461-4823-4705-A7F3-302B72A0D767} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E732CE09-693A-4EB7-BC1C-34E0D4E2E19F} diff --git a/docs/getting-started.md b/docs/getting-started.md index 0c23c19..712be65 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -109,10 +109,9 @@ services.AddGotenbergSharpClient(); ```csharp using Gotenberg.Sharp.API.Client; -using Gotenberg.Sharp.API.Client.Domain.Builders; -using Gotenberg.Sharp.API.Client.Domain.Builders.Faceted; -using Gotenberg.Sharp.API.Client.Domain.Requests.Facets; -using Gotenberg.Sharp.API.Client.Domain.ValueObjects; +using Gotenberg.Sharp.API.Client.Application.Builders; +using Gotenberg.Sharp.API.Client.Domain.Settings; +using Gotenberg.Sharp.API.Client.Extensions; ``` ## Your First PDF diff --git a/examples/ChromiumFeatures/Program.cs b/examples/ChromiumFeatures/Program.cs index 702ba81..3a4ef8d 100644 --- a/examples/ChromiumFeatures/Program.cs +++ b/examples/ChromiumFeatures/Program.cs @@ -1,5 +1,5 @@ using Gotenberg.Sharp.API.Client; -using Gotenberg.Sharp.API.Client.Domain.Builders; +using Gotenberg.Sharp.API.Client.Application.Builders; using Gotenberg.Sharp.API.Client.Domain.Settings; using Gotenberg.Sharp.API.Client.Infrastructure.Pipeline; diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props index c117bba..219e163 100644 --- a/examples/Directory.Build.props +++ b/examples/Directory.Build.props @@ -1,7 +1,7 @@ Exe - net8.0 + net10.0 enable enable false @@ -12,13 +12,13 @@ - - - - - - - + + + + + + + diff --git a/examples/EncryptPdf/Program.cs b/examples/EncryptPdf/Program.cs index a82913c..d4dae97 100644 --- a/examples/EncryptPdf/Program.cs +++ b/examples/EncryptPdf/Program.cs @@ -1,5 +1,5 @@ using Gotenberg.Sharp.API.Client; -using Gotenberg.Sharp.API.Client.Domain.Builders; +using Gotenberg.Sharp.API.Client.Application.Builders; using Gotenberg.Sharp.API.Client.Domain.Settings; using Gotenberg.Sharp.API.Client.Infrastructure.Pipeline; diff --git a/examples/LibreOfficeOptions/Program.cs b/examples/LibreOfficeOptions/Program.cs index 14f099e..9ffea5e 100644 --- a/examples/LibreOfficeOptions/Program.cs +++ b/examples/LibreOfficeOptions/Program.cs @@ -1,5 +1,6 @@ using Gotenberg.Sharp.API.Client; -using Gotenberg.Sharp.API.Client.Domain.Builders; +using Gotenberg.Sharp.API.Client.Application.Builders; +using Gotenberg.Sharp.API.Client.Domain.PdfFormat; using Gotenberg.Sharp.API.Client.Domain.Settings; using Gotenberg.Sharp.API.Client.Infrastructure.Pipeline; diff --git a/examples/PdfEngineOperations/Program.cs b/examples/PdfEngineOperations/Program.cs index 6b98174..5ad1b7f 100644 --- a/examples/PdfEngineOperations/Program.cs +++ b/examples/PdfEngineOperations/Program.cs @@ -5,7 +5,7 @@ using Gotenberg.Sharp.API.Client.Infrastructure.Pipeline; using Microsoft.Extensions.Configuration; -using Newtonsoft.Json.Linq; +using System.Text.Json; var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) @@ -56,8 +56,9 @@ Console.WriteLine("Reading metadata..."); var metadataJson = await sharpClient.ReadPdfMetadataAsync( PdfEngineBuilders.ReadMetadata().WithPdfs(a => a.AddItem("test.pdf", pdfBytes))); -var parsed = JObject.Parse(metadataJson); -Console.WriteLine($"Metadata: {parsed.ToString(Newtonsoft.Json.Formatting.Indented)}"); +using var parsed = JsonDocument.Parse(metadataJson); +var indented = JsonSerializer.Serialize(parsed.RootElement, new JsonSerializerOptions { WriteIndented = true }); +Console.WriteLine($"Metadata: {indented}"); // Bookmarks require Gotenberg 8.28.0, so skip the demo rather than fail on older services. var version = await sharpClient.GetGotenbergVersionAsync(); diff --git a/examples/Screenshot/Program.cs b/examples/Screenshot/Program.cs index 9ea39b9..1b872cd 100644 --- a/examples/Screenshot/Program.cs +++ b/examples/Screenshot/Program.cs @@ -1,6 +1,6 @@ using Gotenberg.Sharp.API.Client; -using Gotenberg.Sharp.API.Client.Domain.Builders; -using Gotenberg.Sharp.API.Client.Domain.ValueObjects; +using Gotenberg.Sharp.API.Client.Application.Builders; +using Gotenberg.Sharp.API.Client.Domain.Screenshots; using Gotenberg.Sharp.API.Client.Domain.Settings; using Gotenberg.Sharp.API.Client.Infrastructure.Pipeline; diff --git a/examples/WatermarkAndRotate/Program.cs b/examples/WatermarkAndRotate/Program.cs index db1241c..60fa9a4 100644 --- a/examples/WatermarkAndRotate/Program.cs +++ b/examples/WatermarkAndRotate/Program.cs @@ -1,6 +1,6 @@ using Gotenberg.Sharp.API.Client; -using Gotenberg.Sharp.API.Client.Domain.Builders; -using Gotenberg.Sharp.API.Client.Domain.ValueObjects; +using Gotenberg.Sharp.API.Client.Application.Builders; +using Gotenberg.Sharp.API.Client.Domain.Rotation; using Gotenberg.Sharp.API.Client.Domain.Settings; using Gotenberg.Sharp.API.Client.Infrastructure.Pipeline; diff --git a/src/Gotenberg.Sharp.Api.Client/Application/Builders/PdfEngineBuilder.cs b/src/Gotenberg.Sharp.Api.Client/Application/Builders/PdfEngineBuilder.cs index f092f59..e751b71 100644 --- a/src/Gotenberg.Sharp.Api.Client/Application/Builders/PdfEngineBuilder.cs +++ b/src/Gotenberg.Sharp.Api.Client/Application/Builders/PdfEngineBuilder.cs @@ -21,7 +21,7 @@ using Gotenberg.Sharp.API.Client.Domain.Shared; using Gotenberg.Sharp.API.Client.Domain.Split; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Gotenberg.Sharp.API.Client.Application.Builders; @@ -139,11 +139,11 @@ public static PdfEngineBuilder ReadMetadata() /// /// Creates a builder for writing metadata to PDFs. /// - public static PdfEngineBuilder WriteMetadata(JObject metadata) + public static PdfEngineBuilder WriteMetadata(IDictionary metadata) { var request = new WriteMetadataRequest { - Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)) + Metadata = JsonObjectWriter.Snapshot(metadata ?? throw new ArgumentNullException(nameof(metadata))) }; return new PdfEngineBuilder(request); } @@ -151,9 +151,9 @@ public static PdfEngineBuilder WriteMetadata(JObject metad /// /// Creates a builder for writing metadata to PDFs. /// - public static PdfEngineBuilder WriteMetadata(IDictionary metadata) + public static PdfEngineBuilder WriteMetadata(JsonObject metadata) { - return WriteMetadata(JObject.FromObject(metadata)); + return WriteMetadata(JsonObjectWriter.ToDictionary(metadata)); } /// diff --git a/src/Gotenberg.Sharp.Api.Client/Application/HtmlBehavior/HtmlConversionBehaviorBuilder.cs b/src/Gotenberg.Sharp.Api.Client/Application/HtmlBehavior/HtmlConversionBehaviorBuilder.cs index c312d21..2992285 100644 --- a/src/Gotenberg.Sharp.Api.Client/Application/HtmlBehavior/HtmlConversionBehaviorBuilder.cs +++ b/src/Gotenberg.Sharp.Api.Client/Application/HtmlBehavior/HtmlConversionBehaviorBuilder.cs @@ -19,7 +19,7 @@ using Gotenberg.Sharp.API.Client.Domain.PdfFormat; using Gotenberg.Sharp.API.Client.Domain.Shared; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Gotenberg.Sharp.API.Client.Application.HtmlBehavior; @@ -102,9 +102,7 @@ public HtmlConversionBehaviorBuilder SetUserAgent(string userAgent) /// Thrown when header name or value is invalid. public HtmlConversionBehaviorBuilder AddAdditionalHeaders(string headerName, string headerValue) { - var header = string.Format("{0}{2}{1}", "{", "}", $"{'"'}{headerName}{'"'} : {'"'}{headerValue}{'"'}"); - - return AddAdditionalHeaders(JObject.Parse(header)); + return AddAdditionalHeaders(new Dictionary { [headerName] = headerValue }); } /// @@ -113,14 +111,30 @@ public HtmlConversionBehaviorBuilder AddAdditionalHeaders(string headerName, str /// JSON object containing header name-value pairs. /// The builder instance for method chaining. /// Thrown when extraHeaders is null. - public HtmlConversionBehaviorBuilder AddAdditionalHeaders(JObject extraHeaders) + public HtmlConversionBehaviorBuilder AddAdditionalHeaders(JsonObject extraHeaders) + { + if (extraHeaders == null) + { + throw new InvalidOperationException("extraHeaders is null"); + } + + return AddAdditionalHeaders(JsonObjectWriter.ToDictionary(extraHeaders)); + } + + /// + /// Adds multiple custom HTTP headers as name-value pairs. Useful for adding several headers at once. + /// + /// Dictionary containing header name-value pairs. + /// The builder instance for method chaining. + /// Thrown when extraHeaders is null. + public HtmlConversionBehaviorBuilder AddAdditionalHeaders(IDictionary extraHeaders) { if (extraHeaders == null) { throw new InvalidOperationException("extraHeaders is null"); } - _htmlConversionBehaviors.ExtraHeaders = extraHeaders; + _htmlConversionBehaviors.ExtraHeaders = JsonObjectWriter.Snapshot(extraHeaders); return this; } @@ -385,7 +399,7 @@ public HtmlConversionBehaviorBuilder SetMetadata(IDictionary dic /// Sets the document metadata. /// [Obsolete("Use SetPdfOutputOptions(o => o.SetMetadata(...)) on the builder instead")] - public HtmlConversionBehaviorBuilder SetMetadata(JObject metadata) + public HtmlConversionBehaviorBuilder SetMetadata(JsonObject metadata) { return this; } diff --git a/src/Gotenberg.Sharp.Api.Client/Application/Overlays/StampOptionsBuilder.cs b/src/Gotenberg.Sharp.Api.Client/Application/Overlays/StampOptionsBuilder.cs index 2cb1b5f..d32dffb 100644 --- a/src/Gotenberg.Sharp.Api.Client/Application/Overlays/StampOptionsBuilder.cs +++ b/src/Gotenberg.Sharp.Api.Client/Application/Overlays/StampOptionsBuilder.cs @@ -16,7 +16,7 @@ using Gotenberg.Sharp.API.Client.Domain.Overlays; using Gotenberg.Sharp.API.Client.Domain.Shared; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Gotenberg.Sharp.API.Client.Application.Overlays; @@ -55,12 +55,17 @@ public StampOptionsBuilder SetPages(string pages) return SetPages(PageRanges.Create(pages)); } - public StampOptionsBuilder SetOptions(JObject options) + public StampOptionsBuilder SetOptions(IDictionary options) { - _options.Options = options ?? throw new ArgumentNullException(nameof(options)); + _options.Options = JsonObjectWriter.Snapshot(options ?? throw new ArgumentNullException(nameof(options))); return this; } + public StampOptionsBuilder SetOptions(JsonObject options) + { + return SetOptions(JsonObjectWriter.ToDictionary(options)); + } + /// /// Convenience method for a text stamp. /// diff --git a/src/Gotenberg.Sharp.Api.Client/Application/Overlays/WatermarkOptionsBuilder.cs b/src/Gotenberg.Sharp.Api.Client/Application/Overlays/WatermarkOptionsBuilder.cs index dcc6ec4..dcc505b 100644 --- a/src/Gotenberg.Sharp.Api.Client/Application/Overlays/WatermarkOptionsBuilder.cs +++ b/src/Gotenberg.Sharp.Api.Client/Application/Overlays/WatermarkOptionsBuilder.cs @@ -16,7 +16,7 @@ using Gotenberg.Sharp.API.Client.Domain.Overlays; using Gotenberg.Sharp.API.Client.Domain.Shared; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Gotenberg.Sharp.API.Client.Application.Overlays; @@ -55,12 +55,17 @@ public WatermarkOptionsBuilder SetPages(string pages) return SetPages(PageRanges.Create(pages)); } - public WatermarkOptionsBuilder SetOptions(JObject options) + public WatermarkOptionsBuilder SetOptions(IDictionary options) { - _options.Options = options ?? throw new ArgumentNullException(nameof(options)); + _options.Options = JsonObjectWriter.Snapshot(options ?? throw new ArgumentNullException(nameof(options))); return this; } + public WatermarkOptionsBuilder SetOptions(JsonObject options) + { + return SetOptions(JsonObjectWriter.ToDictionary(options)); + } + /// /// Convenience method for a text watermark. /// diff --git a/src/Gotenberg.Sharp.Api.Client/Application/PdfOutput/PdfOutputOptionsBuilder.cs b/src/Gotenberg.Sharp.Api.Client/Application/PdfOutput/PdfOutputOptionsBuilder.cs index 0f45582..ed16278 100644 --- a/src/Gotenberg.Sharp.Api.Client/Application/PdfOutput/PdfOutputOptionsBuilder.cs +++ b/src/Gotenberg.Sharp.Api.Client/Application/PdfOutput/PdfOutputOptionsBuilder.cs @@ -17,7 +17,7 @@ using Gotenberg.Sharp.API.Client.Domain.PdfFormat; using Gotenberg.Sharp.API.Client.Domain.PdfOutput; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Gotenberg.Sharp.API.Client.Application.PdfOutput; @@ -94,27 +94,33 @@ public PdfOutputOptionsBuilder SetGenerateTaggedPdf(bool generateTaggedPdf = tru /// /// Metadata as a dictionary. /// The builder instance for method chaining. + /// Thrown when metadata is null. public PdfOutputOptionsBuilder SetMetadata(IDictionary metadata) { - return SetMetadata(JObject.FromObject(metadata)); + if (metadata == null) + { + throw new InvalidOperationException("metadata is null"); + } + + _options.MetaData = JsonObjectWriter.Snapshot(metadata); + + return this; } /// /// Sets the document metadata. /// - /// Metadata as a JObject. + /// Metadata as a . /// The builder instance for method chaining. /// Thrown when metadata is null. - public PdfOutputOptionsBuilder SetMetadata(JObject metadata) + public PdfOutputOptionsBuilder SetMetadata(JsonObject metadata) { if (metadata == null) { throw new InvalidOperationException("metadata is null"); } - _options.MetaData = metadata; - - return this; + return SetMetadata(JsonObjectWriter.ToDictionary(metadata)); } /// diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/Bookmark.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/Bookmark.cs index c5532ec..3163a62 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/Bookmark.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/Bookmark.cs @@ -38,20 +38,28 @@ public Bookmark(string title, int page, params Bookmark[] children) /// /// The text shown in the reader's outline pane. /// - [JsonProperty("title")] + [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; /// /// The 1-based page the bookmark jumps to. /// - [JsonProperty("page")] + [JsonPropertyName("page")] public int Page { get; set; } /// /// Nested bookmarks. Empty when the bookmark is a leaf. /// - [JsonProperty("children", NullValueHandling = NullValueHandling.Ignore)] - public IList Children { get; set; } = new List(); + /// Never null: assigning null, as Gotenberg's JSON may, leaves an empty list. + [JsonPropertyName("children")] + [System.Diagnostics.CodeAnalysis.AllowNull] + public IList Children + { + get => this._children; + set => this._children = value ?? new List(); + } + + private IList _children = new List(); internal void Validate() => this.Validate(new HashSet(BookmarkReferenceComparer.Instance)); diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/BookmarkSet.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/BookmarkSet.cs index 27504d2..a5b69e9 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/BookmarkSet.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Bookmarks/BookmarkSet.cs @@ -107,5 +107,7 @@ internal void Validate() /// shared outline, an object keyed by file name otherwise. /// internal string ToJson() => - JsonConvert.SerializeObject(this.IsPerFile ? (object)this.PerFile! : this.ForAllFiles!); + this.IsPerFile + ? JsonSerializer.Serialize(this.PerFile!, GotenbergJsonContext.Instance.BookmarksPerFile) + : JsonSerializer.Serialize(this.ForAllFiles!, GotenbergJsonContext.Instance.Bookmarks); } diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Cookies/Cookie.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Cookies/Cookie.cs index f6316fa..3c87253 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Cookies/Cookie.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Cookies/Cookie.cs @@ -24,43 +24,47 @@ public sealed record Cookie /// /// Cookie name (required) /// - [JsonProperty("name")] + [JsonPropertyName("name")] public required string Name { get; init; } /// /// Cookie value (required) /// - [JsonProperty("value")] + [JsonPropertyName("value")] public required string Value { get; init; } /// /// Cookie domain (required) /// - [JsonProperty("domain")] + [JsonPropertyName("domain")] public required string Domain { get; init; } /// /// Cookie path (optional) /// - [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("path")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Path { get; init; } /// /// Set cookie as secure (optional) /// - [JsonProperty("secure", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("secure")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Secure { get; init; } /// /// Set cookie as HTTP-only (optional) /// - [JsonProperty("httpOnly", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("httpOnly")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? HttpOnly { get; init; } /// /// SameSite attribute. Accepted values: "Strict", "Lax", or "None" (optional) /// - [JsonProperty("sameSite", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("sameSite")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SameSite { get; init; } public static void Validate(Cookie cookie) diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Dimensions/Dimension.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Dimensions/Dimension.cs index 2ef7def..68e5c25 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Dimensions/Dimension.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Dimensions/Dimension.cs @@ -121,9 +121,13 @@ public static bool TryParse(string parseDimension, out Dimension? dimensionOut) private static bool TryParseUnit(string unitStr, out DimensionUnitType unitType) { +#if NET5_0_OR_GREATER + foreach (var type in Enum.GetValues()) +#else foreach (DimensionUnitType type in Enum.GetValues(typeof(DimensionUnitType))) +#endif { - if (type.GetDescription() == unitStr) + if (type.ToUnitSuffix() == unitStr) { unitType = type; return true; @@ -168,7 +172,7 @@ public static Dimension FromPicas(double picas) public override string ToString() { // if it's inches, don't supply anything. - var unitType = UnitType == DimensionUnitType.Inches ? "" : UnitType.GetDescription(); + var unitType = UnitType == DimensionUnitType.Inches ? "" : UnitType.ToUnitSuffix(); return $"{Value.ToString("G", CultureInfo.InvariantCulture)}{unitType}"; } diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/ExtraHeaders/ExtraHttpHeaders.cs b/src/Gotenberg.Sharp.Api.Client/Domain/ExtraHeaders/ExtraHttpHeaders.cs index cf9e23b..98d76b7 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/ExtraHeaders/ExtraHttpHeaders.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/ExtraHeaders/ExtraHttpHeaders.cs @@ -42,9 +42,10 @@ public void Add(string name, IEnumerable values) internal string ToJson() { - return JsonConvert.SerializeObject( + return JsonSerializer.Serialize( this._headers.ToDictionary( entry => entry.Key, - entry => string.Join(",", entry.Value))); + entry => string.Join(",", entry.Value)), + GotenbergJsonContext.Instance.StringMap); } } \ No newline at end of file diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/ExtraUrls/ExtraUrlResourceItem.cs b/src/Gotenberg.Sharp.Api.Client/Domain/ExtraUrls/ExtraUrlResourceItem.cs index e92610c..74e2d38 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/ExtraUrls/ExtraUrlResourceItem.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/ExtraUrls/ExtraUrlResourceItem.cs @@ -73,8 +73,8 @@ public ExtraUrlResourceItem(Uri url, ExtraUrlResourceType itemType) internal string ToJson() { - return ItemType == ExtraUrlResourceType.ScriptTag - ? JsonConvert.SerializeObject(new { src = this.Url.ToString() }) - : JsonConvert.SerializeObject(new { href = this.Url.ToString() }); + var attributeName = ItemType == ExtraUrlResourceType.ScriptTag ? "src" : "href"; + + return JsonObjectWriter.ToJson(new Dictionary { [attributeName] = this.Url.ToString() }); } } \ No newline at end of file diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/EmulatedMediaFeature.cs b/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/EmulatedMediaFeature.cs index 783d67e..74f3c8e 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/EmulatedMediaFeature.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/EmulatedMediaFeature.cs @@ -25,13 +25,13 @@ public sealed record EmulatedMediaFeature /// /// The CSS media feature name (e.g., "prefers-color-scheme", "prefers-reduced-motion"). /// - [JsonProperty("name")] + [JsonPropertyName("name")] public string Name { get; } /// /// The value to set for the CSS media feature (e.g., "dark", "reduce"). /// - [JsonProperty("value")] + [JsonPropertyName("value")] public string Value { get; } private EmulatedMediaFeature(string name, string value) diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/HtmlConversionBehaviors.cs b/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/HtmlConversionBehaviors.cs index ee756cc..dda303d 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/HtmlConversionBehaviors.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/HtmlBehavior/HtmlConversionBehaviors.cs @@ -17,8 +17,6 @@ using Gotenberg.Sharp.API.Client.Domain.PdfOutput; using Gotenberg.Sharp.API.Client.Domain.Shared; -using Newtonsoft.Json.Linq; - namespace Gotenberg.Sharp.API.Client.Domain.HtmlBehavior; /// @@ -61,7 +59,7 @@ public class HtmlConversionBehaviors : FacetBase /// Sets extra HTTP headers that Chromium will send when loading the HTML /// [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.ExtraHttpHeaders)] - public JObject? ExtraHeaders { get; set; } + public IDictionary? ExtraHeaders { get; set; } /// /// Cookies to store in the Chromium cookie jar. diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/StampOptions.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/StampOptions.cs index ffdb990..9235c78 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/StampOptions.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/StampOptions.cs @@ -15,8 +15,6 @@ using Gotenberg.Sharp.API.Client.Domain.Shared; -using Newtonsoft.Json.Linq; - namespace Gotenberg.Sharp.API.Client.Domain.Overlays; /// @@ -35,5 +33,5 @@ public class StampOptions : FacetBase public PageRanges? Pages { get; set; } [MultiFormHeader(Constants.Gotenberg.CrossCuttingOptions.StampOptionsJson)] - public JObject? Options { get; set; } + public IDictionary? Options { get; set; } } diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/WatermarkOptions.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/WatermarkOptions.cs index e0e9cc9..552ba44 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/WatermarkOptions.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Overlays/WatermarkOptions.cs @@ -15,8 +15,6 @@ using Gotenberg.Sharp.API.Client.Domain.Shared; -using Newtonsoft.Json.Linq; - namespace Gotenberg.Sharp.API.Client.Domain.Overlays; /// @@ -34,5 +32,5 @@ public class WatermarkOptions : FacetBase public PageRanges? Pages { get; set; } [MultiFormHeader(Constants.Gotenberg.CrossCuttingOptions.WatermarkOptionsJson)] - public JObject? Options { get; set; } + public IDictionary? Options { get; set; } } diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/PdfOutput/PdfOutputOptions.cs b/src/Gotenberg.Sharp.Api.Client/Domain/PdfOutput/PdfOutputOptions.cs index f277e61..3c27f76 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/PdfOutput/PdfOutputOptions.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/PdfOutput/PdfOutputOptions.cs @@ -15,8 +15,6 @@ using Gotenberg.Sharp.API.Client.Domain.Authentication; -using Newtonsoft.Json.Linq; - namespace Gotenberg.Sharp.API.Client.Domain.PdfOutput; /// @@ -55,7 +53,7 @@ public class PdfOutputOptions : FacetBase /// Consider taking a look at https://exiftool.org/TagNames/XMP.html#pdf for an (exhaustive?) list of available metadata. /// [MultiFormHeader(Constants.Gotenberg.PdfOutput.MetaData)] - public JObject? MetaData { get; set; } + public IDictionary? MetaData { get; set; } /// /// The password required to open the PDF. diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/EmbedRequest.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/EmbedRequest.cs index f8af84a..055b15c 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/EmbedRequest.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/EmbedRequest.cs @@ -2,7 +2,6 @@ using Gotenberg.Sharp.API.Client.Domain.Embed; using Gotenberg.Sharp.API.Client.Extensions; using Gotenberg.Sharp.API.Client.Infrastructure; -using Newtonsoft.Json.Linq; namespace Gotenberg.Sharp.API.Client.Domain.Requests { @@ -30,17 +29,17 @@ protected override void Validate() /// protected override IEnumerable ToHttpContent() { - var metadataObject = new JObject(); + var metadataObject = new Dictionary(); foreach (var kvp in EmbedsData!) { - metadataObject.Add(kvp.Key, JObject.FromObject(new + metadataObject.Add(kvp.Key, new Dictionary { - kvp.Value.MimeType, - kvp.Value.Relationship - })); + [nameof(Entry.MimeType)] = kvp.Value.MimeType, + [nameof(Entry.Relationship)] = kvp.Value.Relationship + }); } - var metadataContent = new StringContent(metadataObject.ToString()); + var metadataContent = new StringContent(JsonObjectWriter.ToJson(metadataObject)); metadataContent.Headers.ContentType = new MediaTypeHeaderValue(Constants.HttpContent.MediaTypes.ApplicationJson); metadataContent.Headers.ContentDisposition = new ContentDispositionHeaderValue(Constants.HttpContent.Disposition.Types.FormData) { diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/FacetBase.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/FacetBase.cs index 40616c8..09ff56d 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/FacetBase.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/FacetBase.cs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Diagnostics.CodeAnalysis; using System.Globalization; using Gotenberg.Sharp.API.Client.Domain.Authentication; @@ -27,6 +28,9 @@ namespace Gotenberg.Sharp.API.Client.Domain.Requests; +// Form fields are discovered by reflecting over the [MultiFormHeader] properties of the concrete type; +// the annotation keeps those properties when the assembly is trimmed. +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public abstract class FacetBase : IConvertToHttpContent { public virtual IEnumerable ToHttpContent() @@ -88,12 +92,18 @@ public virtual IEnumerable ToHttpContent() LibrePdfFormats format => format.ToFormDataValue(), ConversionPdfFormats format => format.ToFormDataValue(), PdfPassword password => password.Value, - List cookies => JsonConvert.SerializeObject(cookies), + List cookies => JsonSerializer.Serialize(cookies, GotenbergJsonContext.Instance.Cookies), ScreenshotFormat screenshotFormat => screenshotFormat.ToFormValue(), - List features => JsonConvert.SerializeObject( - features.ToDictionary(f => f.Name, f => f.Value)), - List codes => JsonConvert.SerializeObject(codes.Select(c => c.Value)), - List domains => JsonConvert.SerializeObject(domains.Select(d => d.Value)), + List features => JsonSerializer.Serialize( + features.ToDictionary(f => f.Name, f => f.Value), + GotenbergJsonContext.Instance.StringMap), + List codes => JsonSerializer.Serialize( + codes.Select(c => c.Value).ToList(), + GotenbergJsonContext.Instance.Integers), + List domains => JsonSerializer.Serialize( + domains.Select(d => d.Value).ToList(), + GotenbergJsonContext.Instance.Strings), + IDictionary json => JsonObjectWriter.ToJson(json!), CssSelector selector => selector.Value, OverlaySource overlaySource => overlaySource.ToFormValue(), SplitMode splitMode => splitMode.ToFormValue(), diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/WriteMetadataRequest.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/WriteMetadataRequest.cs index 5952c11..9c2c48b 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/WriteMetadataRequest.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/WriteMetadataRequest.cs @@ -13,8 +13,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Newtonsoft.Json.Linq; - namespace Gotenberg.Sharp.API.Client.Domain.Requests; /// @@ -24,7 +22,7 @@ public sealed class WriteMetadataRequest : PdfEngineRequest { protected override string ApiPath => Constants.Gotenberg.PdfEngines.ApiPaths.WriteMetadata; - public JObject? Metadata { get; set; } + public IDictionary? Metadata { get; set; } protected override void Validate() { @@ -36,7 +34,7 @@ protected override void Validate() protected override IEnumerable ToHttpContent() { - var metadataContent = new StringContent(this.Metadata!.ToString()); + var metadataContent = new StringContent(JsonObjectWriter.ToJson(this.Metadata!)); metadataContent.Headers.ContentType = new MediaTypeHeaderValue(Constants.HttpContent.MediaTypes.ApplicationJson); metadataContent.Headers.ContentDisposition = new ContentDispositionHeaderValue(Constants.HttpContent.Disposition.Types.FormData) diff --git a/src/Gotenberg.Sharp.Api.Client/Extensions/EnumExtensions.cs b/src/Gotenberg.Sharp.Api.Client/Extensions/EnumExtensions.cs index 4aeee7f..d2991b3 100644 --- a/src/Gotenberg.Sharp.Api.Client/Extensions/EnumExtensions.cs +++ b/src/Gotenberg.Sharp.Api.Client/Extensions/EnumExtensions.cs @@ -13,10 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.ComponentModel; -using System.Globalization; -using System.Reflection; - +using Gotenberg.Sharp.API.Client.Domain.Dimensions; using Gotenberg.Sharp.API.Client.Domain.LibreOffice; using Gotenberg.Sharp.API.Client.Domain.PdfFormat; @@ -41,11 +38,20 @@ internal static string ToFormDataValue(this ConversionPdfFormats format) return format == default ? "None" : $"PDF/A-{format.ToString().Substring(1, 2)}"; } - public static string GetDescription(this Enum value) + /// + /// The CSS unit suffix Gotenberg expects; mirrors the [Description] on each member. + /// + internal static string ToUnitSuffix(this DimensionUnitType unitType) { - FieldInfo field = value.GetType().GetField(value.ToString())!; - DescriptionAttribute? attribute = field.GetCustomAttribute(); - - return attribute?.Description ?? value.ToString(); + return unitType switch + { + DimensionUnitType.Points => "pt", + DimensionUnitType.Pixels => "px", + DimensionUnitType.Inches => "in", + DimensionUnitType.Millimeters => "mm", + DimensionUnitType.Centimeters => "cm", + DimensionUnitType.Picas => "pc", + _ => unitType.ToString() + }; } } \ No newline at end of file diff --git a/src/Gotenberg.Sharp.Api.Client/GlobalUsings.cs b/src/Gotenberg.Sharp.Api.Client/GlobalUsings.cs index e5a7f04..fdcb90f 100644 --- a/src/Gotenberg.Sharp.Api.Client/GlobalUsings.cs +++ b/src/Gotenberg.Sharp.Api.Client/GlobalUsings.cs @@ -19,4 +19,7 @@ global using Gotenberg.Sharp.API.Client.Extensions; global using Gotenberg.Sharp.API.Client.Infrastructure; -global using Newtonsoft.Json; \ No newline at end of file +global using System.Text.Json; +global using System.Text.Json.Serialization; + +global using Gotenberg.Sharp.API.Client.Infrastructure.Json; \ No newline at end of file diff --git a/src/Gotenberg.Sharp.Api.Client/Gotenberg.Sharp.Api.Client.csproj b/src/Gotenberg.Sharp.Api.Client/Gotenberg.Sharp.Api.Client.csproj index 7622a22..489a809 100644 --- a/src/Gotenberg.Sharp.Api.Client/Gotenberg.Sharp.Api.Client.csproj +++ b/src/Gotenberg.Sharp.Api.Client/Gotenberg.Sharp.Api.Client.csproj @@ -7,6 +7,7 @@ Gotenberg.Sharp.API.Client enable enable + true @@ -60,7 +61,6 @@ - @@ -68,6 +68,7 @@ + @@ -75,6 +76,7 @@ + diff --git a/src/Gotenberg.Sharp.Api.Client/GotenbergSharpClient.cs b/src/Gotenberg.Sharp.Api.Client/GotenbergSharpClient.cs index 20c3e36..b89eb79 100644 --- a/src/Gotenberg.Sharp.Api.Client/GotenbergSharpClient.cs +++ b/src/Gotenberg.Sharp.Api.Client/GotenbergSharpClient.cs @@ -397,8 +397,12 @@ public virtual async Task>> var json = await this.ReadResponseAsStringAsync(request.CreateApiRequest(), cancelToken) .ConfigureAwait(false); - var parsed = JsonConvert.DeserializeObject?>>(json) - ?? new Dictionary?>(); + // An empty body is not valid JSON; treat it as "no bookmarks" rather than failing. + var parsed = string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, GotenbergJsonContext.Instance.BookmarksReadResponse); + + parsed ??= new Dictionary>(); return parsed.ToDictionary( entry => entry.Key, diff --git a/src/Gotenberg.Sharp.Api.Client/Infrastructure/DynamicallyAccessedMembersAttribute.cs b/src/Gotenberg.Sharp.Api.Client/Infrastructure/DynamicallyAccessedMembersAttribute.cs new file mode 100644 index 0000000..c0f8d3c --- /dev/null +++ b/src/Gotenberg.Sharp.Api.Client/Infrastructure/DynamicallyAccessedMembersAttribute.cs @@ -0,0 +1,39 @@ +// Copyright 2019-2026 Chris Mohan, Jaben Cargman +// and GotenbergSharpApiClient Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if !NET5_0_OR_GREATER + +// The trimming annotations only exist from .NET 5; these stand-ins let the netstandard targets compile +// the same source. They carry only the members this assembly uses. +namespace System.Diagnostics.CodeAnalysis; + +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface | AttributeTargets.Parameter + | AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Method | AttributeTargets.ReturnValue + | AttributeTargets.GenericParameter, + Inherited = false)] +internal sealed class DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) : Attribute +{ + public DynamicallyAccessedMemberTypes MemberTypes { get; } = memberTypes; +} + +[Flags] +internal enum DynamicallyAccessedMemberTypes +{ + None = 0, + PublicProperties = 0x0200 +} + +#endif diff --git a/src/Gotenberg.Sharp.Api.Client/Infrastructure/GotenbergApiException.cs b/src/Gotenberg.Sharp.Api.Client/Infrastructure/GotenbergApiException.cs index 14061f7..f1accc0 100644 --- a/src/Gotenberg.Sharp.Api.Client/Infrastructure/GotenbergApiException.cs +++ b/src/Gotenberg.Sharp.Api.Client/Infrastructure/GotenbergApiException.cs @@ -93,29 +93,77 @@ public string ToVerboseJson( { using (_response) { - IEnumerable? clientRequestFormContent = null; + using var stream = new MemoryStream(); - if (includeRequestContent - && this._request is IConvertToHttpContent convertToHttpContent) + using (var writer = new Utf8JsonWriter( + stream, + new JsonWriterOptions { Indented = indentJson, Encoder = GotenbergJsonContext.Encoder })) { - clientRequestFormContent = convertToHttpContent.IfNullEmptyContent() - .ToDumpFriendlyFormat(false); - } + writer.WriteStartObject(); + writer.WriteString("GotenbergMessage", Message); - return JsonConvert.SerializeObject( - new + if (includeGotenbergResponse) { - GotenbergMessage = Message, - GotenbergResponseReceived = includeGotenbergResponse ? _response : null, - ClientRequestSent = _request, - ClientRequestFormContent = clientRequestFormContent - }, - new JsonSerializerSettings + writer.WriteStartObject("GotenbergResponseReceived"); + writer.WriteNumber("StatusCode", (int)_response.StatusCode); + if (_response.ReasonPhrase != null) + writer.WriteString("ReasonPhrase", _response.ReasonPhrase); + writer.WriteString("Version", _response.Version.ToString()); + writer.WriteBoolean("IsSuccessStatusCode", _response.IsSuccessStatusCode); + if (_response.RequestMessage?.RequestUri != null) + writer.WriteString("RequestUri", _response.RequestMessage.RequestUri.ToString()); + WriteHeaders(writer, "Headers", _response.Headers); + if (_response.Content != null) + WriteHeaders(writer, "ContentHeaders", _response.Content.Headers); + writer.WriteEndObject(); + } + + writer.WriteStartObject("ClientRequestSent"); + writer.WriteString("Type", _request.GetType().Name); + writer.WriteBoolean("IsWebhookRequest", _request.IsWebhookRequest); + writer.WriteEndObject(); + + if (includeRequestContent + && this._request is IConvertToHttpContent convertToHttpContent) { - NullValueHandling = NullValueHandling.Ignore, - Formatting = indentJson ? Formatting.Indented : Formatting.None - }); + writer.WriteStartArray("ClientRequestFormContent"); + + foreach (var content in convertToHttpContent.IfNullEmptyContent()) + { + var contentType = content.Headers.ContentType?.ToString(); + var isText = contentType?.StartsWith("text", StringComparison.Ordinal) ?? false; + + writer.WriteStartObject(); + writer.WriteStartObject("Headers"); + writer.WriteString("ContentType", contentType ?? string.Empty); + writer.WriteString( + "Disposition", + content.Headers.ContentDisposition?.ToString() ?? string.Empty); + writer.WriteEndObject(); + writer.WriteString( + "Content", + isText ? content.ReadAsStringAsync().Result : "-its not text-"); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + return System.Text.Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); } } + + static void WriteHeaders(Utf8JsonWriter writer, string name, HttpHeaders headers) + { + writer.WriteStartObject(name); + + foreach (var header in headers) + writer.WriteString(header.Key, string.Join(", ", header.Value)); + + writer.WriteEndObject(); + } } } \ No newline at end of file diff --git a/src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/GotenbergJsonContext.cs b/src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/GotenbergJsonContext.cs new file mode 100644 index 0000000..2b71dbd --- /dev/null +++ b/src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/GotenbergJsonContext.cs @@ -0,0 +1,49 @@ +// Copyright 2019-2026 Chris Mohan, Jaben Cargman +// and GotenbergSharpApiClient Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Encodings.Web; + +using Gotenberg.Sharp.API.Client.Domain.Bookmarks; +using Gotenberg.Sharp.API.Client.Domain.Cookies; + +namespace Gotenberg.Sharp.API.Client.Infrastructure.Json; + +/// +/// Source-generated serialization metadata for the JSON the client exchanges with Gotenberg. +/// Keeps serialization free of runtime reflection so the client works when trimmed or compiled +/// with Native AOT. +/// +[JsonSerializable(typeof(List), TypeInfoPropertyName = "Cookies")] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "StringMap")] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "Integers")] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "Strings")] +[JsonSerializable(typeof(IReadOnlyList), TypeInfoPropertyName = "Bookmarks")] +[JsonSerializable( + typeof(IReadOnlyDictionary>), + TypeInfoPropertyName = "BookmarksPerFile")] +[JsonSerializable( + typeof(Dictionary>), + TypeInfoPropertyName = "BookmarksReadResponse")] +internal sealed partial class GotenbergJsonContext : JsonSerializerContext +{ + /// + /// The encoder used for everything sent to Gotenberg. Leaves non-ASCII and HTML-sensitive + /// characters as they are rather than writing them as \uXXXX escapes. + /// + internal static readonly JavaScriptEncoder Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + + internal static GotenbergJsonContext Instance { get; } = new( + new JsonSerializerOptions { Encoder = Encoder, PropertyNameCaseInsensitive = true }); +} diff --git a/src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/JsonObjectWriter.cs b/src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/JsonObjectWriter.cs new file mode 100644 index 0000000..08161fc --- /dev/null +++ b/src/Gotenberg.Sharp.Api.Client/Infrastructure/Json/JsonObjectWriter.cs @@ -0,0 +1,189 @@ +// Copyright 2019-2026 Chris Mohan, Jaben Cargman +// and GotenbergSharpApiClient Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections; +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; + +namespace Gotenberg.Sharp.API.Client.Infrastructure.Json; + +/// +/// Writes loosely typed values (metadata, extra headers, watermark/stamp options) as JSON by +/// inspecting each value's runtime type, so no reflection-based serializer is needed. +/// +internal static class JsonObjectWriter +{ + private static readonly JsonWriterOptions _writerOptions = new() { Encoder = GotenbergJsonContext.Encoder }; + + internal static string ToJson(IEnumerable> values) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream, _writerOptions)) + { + WriteObject(writer, values!); + } + + return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); + } + + /// + /// Copies the caller's dictionary so later changes to it do not leak into the request, and writes it + /// once so an unsupported value fails where it is supplied rather than when the request is sent. + /// + /// Thrown when a value cannot be written as JSON. + internal static IDictionary Snapshot(IDictionary values) + { + var copy = new Dictionary(values); + + ToJson(copy); + + return copy; + } + + /// + /// Copies a into the dictionary shape the request types store. + /// + internal static IDictionary ToDictionary(JsonObject jsonObject) + { + if (jsonObject == null) throw new ArgumentNullException(nameof(jsonObject)); + + var result = new Dictionary(); + + // A JSON null stays a null entry; it is written back out as null. + foreach (var property in jsonObject) + result[property.Key] = property.Value!; + + return result; + } + + internal static void WriteValue(Utf8JsonWriter writer, object? value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string stringValue: + writer.WriteStringValue(stringValue); + break; + case bool boolValue: + writer.WriteBooleanValue(boolValue); + break; + case int intValue: + writer.WriteNumberValue(intValue); + break; + case long longValue: + writer.WriteNumberValue(longValue); + break; + case double doubleValue: + writer.WriteNumberValue(doubleValue); + break; + case decimal decimalValue: + writer.WriteNumberValue(decimalValue); + break; + case float floatValue: + writer.WriteNumberValue(floatValue); + break; + case uint uintValue: + writer.WriteNumberValue(uintValue); + break; + case ulong ulongValue: + writer.WriteNumberValue(ulongValue); + break; + case short shortValue: + writer.WriteNumberValue(shortValue); + break; + case ushort ushortValue: + writer.WriteNumberValue(ushortValue); + break; + case byte byteValue: + writer.WriteNumberValue(byteValue); + break; + case sbyte sbyteValue: + writer.WriteNumberValue(sbyteValue); + break; + case char charValue: + writer.WriteStringValue(charValue.ToString()); + break; + case DateTime dateTimeValue: + writer.WriteStringValue(dateTimeValue); + break; + case DateTimeOffset dateTimeOffsetValue: + writer.WriteStringValue(dateTimeOffsetValue); + break; + case Guid guidValue: + writer.WriteStringValue(guidValue); + break; + case Uri uriValue: + writer.WriteStringValue(uriValue.ToString()); + break; + case byte[] bytesValue: + writer.WriteBase64StringValue(bytesValue); + break; + case Enum enumValue: + writer.WriteNumberValue(Convert.ToInt64(enumValue, CultureInfo.InvariantCulture)); + break; + case JsonNode nodeValue: + nodeValue.WriteTo(writer); + break; + case JsonElement elementValue: + elementValue.WriteTo(writer); + break; + case JsonDocument documentValue: + documentValue.RootElement.WriteTo(writer); + break; + case IEnumerable> objectValue: + WriteObject(writer, objectValue); + break; + case IDictionary dictionaryValue: + writer.WriteStartObject(); + foreach (DictionaryEntry entry in dictionaryValue) + { + writer.WritePropertyName(Convert.ToString(entry.Key, CultureInfo.InvariantCulture) ?? string.Empty); + WriteValue(writer, entry.Value); + } + + writer.WriteEndObject(); + break; + case IEnumerable enumerableValue: + writer.WriteStartArray(); + foreach (var item in enumerableValue) + WriteValue(writer, item); + writer.WriteEndArray(); + break; + default: + throw new NotSupportedException( + $"Values of type '{value.GetType()}' cannot be written as JSON. Use strings, numbers, booleans, " + + "dates, dictionaries, collections, or a System.Text.Json JsonNode/JsonElement."); + } + } + + private static void WriteObject(Utf8JsonWriter writer, IEnumerable> values) + { + writer.WriteStartObject(); + + foreach (var entry in values) + { + writer.WritePropertyName(entry.Key); + WriteValue(writer, entry.Value); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Gotenberg.Sharp.Api.Client/Infrastructure/MultiFormPropertyItem.cs b/src/Gotenberg.Sharp.Api.Client/Infrastructure/MultiFormPropertyItem.cs index 361324d..28f3b71 100644 --- a/src/Gotenberg.Sharp.Api.Client/Infrastructure/MultiFormPropertyItem.cs +++ b/src/Gotenberg.Sharp.Api.Client/Infrastructure/MultiFormPropertyItem.cs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace Gotenberg.Sharp.API.Client.Infrastructure; @@ -27,7 +28,8 @@ internal class MultiFormPropertyItem(PropertyInfo property, MultiFormHeaderAttri public MultiFormHeaderAttribute Attribute { get; } = attribute ?? throw new ArgumentNullException(nameof(attribute)); - internal static IEnumerable FromType(Type instanceType) + internal static IEnumerable FromType( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type instanceType) { if (instanceType == null) throw new ArgumentNullException(nameof(instanceType)); diff --git a/test/GotenbergSharpClient.AotSmokeTest/GotenbergSharpClient.AotSmokeTest.csproj b/test/GotenbergSharpClient.AotSmokeTest/GotenbergSharpClient.AotSmokeTest.csproj new file mode 100644 index 0000000..49fa0b5 --- /dev/null +++ b/test/GotenbergSharpClient.AotSmokeTest/GotenbergSharpClient.AotSmokeTest.csproj @@ -0,0 +1,37 @@ + + + + + + Exe + net10.0 + latest + enable + enable + false + true + true + + false + true + + + + + + + + + diff --git a/test/GotenbergSharpClient.AotSmokeTest/Program.cs b/test/GotenbergSharpClient.AotSmokeTest/Program.cs new file mode 100644 index 0000000..b53d2b8 --- /dev/null +++ b/test/GotenbergSharpClient.AotSmokeTest/Program.cs @@ -0,0 +1,286 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +using Gotenberg.Sharp.API.Client; +using Gotenberg.Sharp.API.Client.Application.Builders; +using Gotenberg.Sharp.API.Client.Domain.Bookmarks; +using Cookie = Gotenberg.Sharp.API.Client.Domain.Cookies.Cookie; +using Gotenberg.Sharp.API.Client.Domain.Dimensions; +using Gotenberg.Sharp.API.Client.Domain.Settings; +using Gotenberg.Sharp.API.Client.Extensions; +using Gotenberg.Sharp.API.Client.Infrastructure; + +using Microsoft.Extensions.DependencyInjection; + +// Exercises the reflection- and JSON-driven paths of the client from a Native AOT binary. +// Runs against an in-process stub by default; set GOTENBERG_URL (and optionally +// GOTENBERG_USERNAME / GOTENBERG_PASSWORD) to also convert against a real Gotenberg. + +var failures = 0; + +await Check("Form fields are built from [MultiFormHeader] properties", async () => +{ + var (client, stub) = CreateStubbedClient(); + + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody("

AOT

")) + .WithPageProperties(p => p.SetPaperWidth(Dimension.FromMillimeters(210)).SetScale(0.9)) + .SetConversionBehaviors(b => b + .AddAdditionalHeaders("hello", "from-earth") + .AddCookie(new Cookie { Name = "sid", Value = "abc", Domain = "example.com" }) + .AddEmulatedMediaFeature("prefers-color-scheme", "dark") + .SetFailOnHttpStatusCodes(499)) + .SetPdfOutputOptions(o => o + .SetMetadata(new Dictionary { ["Author"] = "AotSmokeTest" })); + + await using var _ = await client.HtmlToPdfAsync(builder); + + var fields = stub.LastFormFields; + Expect(fields.ContainsKey("index.html"), "index.html part is missing"); + ExpectField(fields, "paperWidth", "210mm"); + ExpectField(fields, "scale", "0.9"); + ExpectJsonField(fields, "extraHttpHeaders", "{\"hello\":\"from-earth\"}"); + ExpectField(fields, "cookies", "[{\"name\":\"sid\",\"value\":\"abc\",\"domain\":\"example.com\"}]"); + ExpectField(fields, "emulatedMediaFeatures", "{\"prefers-color-scheme\":\"dark\"}"); + ExpectField(fields, "failOnHttpStatusCodes", "[499]"); + ExpectJsonField(fields, "metadata", "{\"Author\":\"AotSmokeTest\"}"); +}); + +await Check("Bookmarks are serialized into the request", async () => +{ + var (client, stub) = CreateStubbedClient(); + + var builder = PdfEngineBuilders + .WriteBookmarks(new[] { new Bookmark("Chapter 1", 1, new Bookmark("Section 1.1", 2)) }) + .WithPdfs(pdfs => pdfs.AddItem("doc.pdf", StubHandler.FakePdf)); + + await using var _ = await client.ExecutePdfEngineAsync(builder); + + ExpectField( + stub.LastFormFields, + "bookmarks", + "[{\"title\":\"Chapter 1\",\"page\":1,\"children\":[{\"title\":\"Section 1.1\",\"page\":2,\"children\":[]}]}]"); +}); + +await Check("Bookmarks are deserialized from the response", async () => +{ + var (client, _) = CreateStubbedClient(); + + var builder = PdfEngineBuilders.ReadBookmarks() + .WithPdfs(pdfs => pdfs.AddItem("doc.pdf", StubHandler.FakePdf)); + + var bookmarks = await client.ReadPdfBookmarksAsync(builder); + + Expect(bookmarks.ContainsKey("doc.pdf"), "doc.pdf key is missing"); + var root = bookmarks["doc.pdf"].Single(); + Expect(root.Title == "Chapter 1" && root.Page == 1, $"unexpected root '{root.Title}' p{root.Page}"); + Expect(root.Children.Single().Title == "Section 1.1", "child bookmark was not deserialized"); + Expect(root.Children.Single().Children.Count == 0, "null children should become an empty list"); +}); + +await Check("Loosely typed JSON values are written without reflection", async () => +{ + var (client, stub) = CreateStubbedClient(); + + var metadata = new Dictionary + { + ["Author"] = "AotSmokeTest", + ["Pages"] = 3, + ["Keywords"] = new[] { "aot", "trim" }, + ["Nested"] = new JsonObject { ["flag"] = true } + }; + + var builder = PdfEngineBuilders.WriteMetadata(metadata) + .WithPdfs(pdfs => pdfs.AddItem("doc.pdf", StubHandler.FakePdf)); + + await using var _ = await client.ExecutePdfEngineAsync(builder); + + ExpectField( + stub.LastFormFields, + "metadata", + "{\"Author\":\"AotSmokeTest\",\"Pages\":3,\"Keywords\":[\"aot\",\"trim\"],\"Nested\":{\"flag\":true}}"); +}); + +await Check("API errors can be dumped as verbose JSON", async () => +{ + var (client, stub) = CreateStubbedClient(); + stub.FailWith = HttpStatusCode.BadRequest; + + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody("

AOT

")); + + try + { + await using var _ = await client.HtmlToPdfAsync(builder); + Expect(false, "expected a GotenbergApiException"); + } + catch (GotenbergApiException e) + { + using var dump = JsonDocument.Parse(e.ToVerboseJson()); + var root = dump.RootElement; + Expect(root.GetProperty("GotenbergMessage").GetString() == "stub failure", "message is missing"); + Expect( + root.GetProperty("GotenbergResponseReceived").GetProperty("StatusCode").GetInt32() == 400, + "status code is missing"); + Expect(root.GetProperty("ClientRequestFormContent").GetArrayLength() > 0, "form content is missing"); + } +}); + +await Check("Dimension units round-trip through their descriptions", () => +{ + foreach (var text in new[] { "210mm", "21cm", "8.5in", "72pt", "96px", "6pc" }) + { + var parsed = Dimension.Parse(text); + var expected = text.EndsWith("in", StringComparison.Ordinal) ? text[..^2] : text; + Expect(parsed.ToString() == expected, $"'{text}' round-tripped to '{parsed}'"); + } + + return Task.CompletedTask; +}); + +var gotenbergUrl = Environment.GetEnvironmentVariable("GOTENBERG_URL"); + +if (string.IsNullOrWhiteSpace(gotenbergUrl)) +{ + Console.WriteLine("SKIP Live conversion (GOTENBERG_URL is not set)"); +} +else +{ + await Check($"Live conversion against {gotenbergUrl}", async () => + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(options => + { + options.ServiceUrl = new Uri(gotenbergUrl); + options.BasicAuthUsername = Environment.GetEnvironmentVariable("GOTENBERG_USERNAME"); + options.BasicAuthPassword = Environment.GetEnvironmentVariable("GOTENBERG_PASSWORD"); + }); + services.AddGotenbergSharpClient(); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody("

Native AOT

")) + .WithPageProperties(p => p.UseChromeDefaults()) + .SetConversionBehaviors(b => b.AddAdditionalHeaders("hello", "from-earth")); + + await using var pdf = await client.HtmlToPdfAsync(builder); + + var header = new byte[4]; + var read = await pdf.ReadAtLeastAsync(header, header.Length, throwOnEndOfStream: false); + Expect(read == 4 && Encoding.ASCII.GetString(header) == "%PDF", "response is not a PDF"); + }); +} + +Console.WriteLine(failures == 0 ? "All checks passed." : $"{failures} check(s) failed."); +return failures == 0 ? 0 : 1; + +async Task Check(string name, Func check) +{ + try + { + await check(); + Console.WriteLine($"PASS {name}"); + } + catch (Exception e) + { + failures++; + Console.WriteLine($"FAIL {name}"); + Console.WriteLine($" {e.GetType().Name}: {e.Message}"); + } +} + +static void Expect(bool condition, string message) +{ + if (!condition) throw new InvalidOperationException(message); +} + +static void ExpectField(IReadOnlyDictionary fields, string name, string expected) +{ + Expect(fields.TryGetValue(name, out var actual), $"form field '{name}' is missing"); + Expect(actual == expected, $"form field '{name}' was '{actual}', expected '{expected}'"); +} + +// Some fields are written indented; compare them with the whitespace removed. +static void ExpectJsonField(IReadOnlyDictionary fields, string name, string expected) +{ + Expect(fields.TryGetValue(name, out var actual), $"form field '{name}' is missing"); + var compact = string.Concat(actual!.Where(c => !char.IsWhiteSpace(c))); + Expect(compact == expected, $"form field '{name}' was '{compact}', expected '{expected}'"); +} + +// Resolved through DI so the typed-client registration and the Polly pipeline are exercised too. +static (GotenbergSharpClient Client, StubHandler Stub) CreateStubbedClient() +{ + var stub = new StubHandler(); + + var services = new ServiceCollection(); + services.AddOptions() + .Configure(options => options.ServiceUrl = new Uri("http://gotenberg.invalid")); + services.AddGotenbergSharpClient().ConfigurePrimaryHttpMessageHandler(() => stub); + + var client = services.BuildServiceProvider().GetRequiredService(); + + return (client, stub); +} + +/// +/// Stands in for Gotenberg: records the multipart form fields it is sent and answers each route +/// with a canned response. +/// +internal sealed class StubHandler : HttpMessageHandler +{ + internal static readonly byte[] FakePdf = Encoding.ASCII.GetBytes("%PDF-1.7\n%%EOF"); + + internal Dictionary LastFormFields { get; private set; } = new(); + + internal HttpStatusCode? FailWith { get; set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + + if (path.EndsWith("/version", StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("8.30.0") }; + } + + if (request.Content is MultipartFormDataContent form) + { + var fields = new Dictionary(); + + foreach (var part in form) + { + var disposition = part.Headers.ContentDisposition; + var name = (disposition?.FileName ?? disposition?.Name ?? string.Empty).Trim('"'); + fields[name] = await part.ReadAsStringAsync(cancellationToken); + } + + this.LastFormFields = fields; + } + + if (this.FailWith is { } status) + { + return new HttpResponseMessage(status) { Content = new StringContent("stub failure") }; + } + + if (path.EndsWith("/bookmarks/read", StringComparison.Ordinal)) + { + const string json = + "{\"doc.pdf\":[{\"title\":\"Chapter 1\",\"page\":1,\"children\":[{\"title\":\"Section 1.1\",\"page\":2,\"children\":null}]}]}"; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(FakePdf) }; + } +} diff --git a/test/GotenbergSharpClient.Tests/BookmarkTests.cs b/test/GotenbergSharpClient.Tests/BookmarkTests.cs index ae2bd73..92db916 100644 --- a/test/GotenbergSharpClient.Tests/BookmarkTests.cs +++ b/test/GotenbergSharpClient.Tests/BookmarkTests.cs @@ -2,7 +2,8 @@ using Gotenberg.Sharp.API.Client.Domain.Bookmarks; using Gotenberg.Sharp.API.Client.Domain.Requests; -using Newtonsoft.Json.Linq; +using Gotenberg.Sharp.API.Client.Infrastructure.Json; +using System.Text.Json.Nodes; namespace GotenbergSharpClient.Tests; @@ -235,12 +236,12 @@ public async Task WriteBookmarks_SerializesFlatListForm() var json = await GetBookmarksFormField(request); - var parsed = JArray.Parse(json); + var parsed = JsonNode.Parse(json)!.AsArray(); parsed.Should().HaveCount(2); - parsed[0]["title"]!.Value().Should().Be("Introduction"); - parsed[0]["page"]!.Value().Should().Be(1); - parsed[0]["children"].Should().NotBeNull(); - parsed[1]["children"]![0]!["title"]!.Value().Should().Be("Section 1.1"); + parsed[0]!["title"]!.GetValue().Should().Be("Introduction"); + parsed[0]!["page"]!.GetValue().Should().Be(1); + parsed[0]!["children"].Should().NotBeNull(); + parsed[1]!["children"]![0]!["title"]!.GetValue().Should().Be("Section 1.1"); } [Test] @@ -256,9 +257,9 @@ public async Task WriteBookmarks_SerializesFileKeyedMapForm() var json = await GetBookmarksFormField(request); - var parsed = JObject.Parse(json); - parsed["first.pdf"]![0]!["title"]!.Value().Should().Be("Summary"); - parsed["second.pdf"]![0]!["page"]!.Value().Should().Be(4); + var parsed = JsonNode.Parse(json)!.AsObject(); + parsed["first.pdf"]![0]!["title"]!.GetValue().Should().Be("Summary"); + parsed["second.pdf"]![0]!["page"]!.GetValue().Should().Be(4); } [Test] @@ -291,8 +292,9 @@ public void ReadBookmarks_RoundTripsThroughJson() } """; - var parsed = Newtonsoft.Json.JsonConvert - .DeserializeObject>>(response)!; + var parsed = System.Text.Json.JsonSerializer.Deserialize( + response, + GotenbergJsonContext.Instance.BookmarksReadResponse)!; parsed["empty.pdf"].Should().BeEmpty(); parsed["doc.pdf"].Should().HaveCount(2); @@ -305,8 +307,9 @@ public void ReadBookmarks_TolerartesNullChildren() { const string response = """{ "doc.pdf": [ { "title": "Only", "page": 1, "children": null } ] }"""; - var parsed = Newtonsoft.Json.JsonConvert - .DeserializeObject>>(response)!; + var parsed = System.Text.Json.JsonSerializer.Deserialize( + response, + GotenbergJsonContext.Instance.BookmarksReadResponse)!; parsed["doc.pdf"].Single().Children.Should().NotBeNull().And.BeEmpty(); } diff --git a/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsTests.cs b/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsTests.cs index d6ddd86..1917e4c 100644 --- a/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsTests.cs +++ b/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsTests.cs @@ -3,7 +3,7 @@ using Gotenberg.Sharp.API.Client.Domain.Requests; using Gotenberg.Sharp.API.Client.Domain.Shared; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace GotenbergSharpClient.Tests; @@ -177,11 +177,11 @@ public async Task EmulatedMediaFeatures_SerializesToCorrectJsonObject() content.Should().NotBeNull(); var json = await content!.ReadAsStringAsync(); - var jObject = JObject.Parse(json); + var jObject = JsonNode.Parse(json)!.AsObject(); jObject.Should().HaveCount(2); - jObject["prefers-color-scheme"]!.Value().Should().Be("dark"); - jObject["prefers-reduced-motion"]!.Value().Should().Be("reduce"); + jObject["prefers-color-scheme"]!.GetValue().Should().Be("dark"); + jObject["prefers-reduced-motion"]!.GetValue().Should().Be("reduce"); } [Test] @@ -202,11 +202,11 @@ public async Task FailOnHttpStatusCodes_SerializesToIntArray() content.Should().NotBeNull(); var json = await content!.ReadAsStringAsync(); - var jArray = JArray.Parse(json); + var jArray = JsonNode.Parse(json)!.AsArray(); jArray.Should().HaveCount(2); - jArray[0].Value().Should().Be(499); - jArray[1].Value().Should().Be(599); + jArray[0]!.GetValue().Should().Be(499); + jArray[1]!.GetValue().Should().Be(599); } [Test] @@ -227,11 +227,11 @@ public async Task FailOnResourceHttpStatusCodes_SerializesToIntArray() content.Should().NotBeNull(); var json = await content!.ReadAsStringAsync(); - var jArray = JArray.Parse(json); + var jArray = JsonNode.Parse(json)!.AsArray(); jArray.Should().HaveCount(2); - jArray[0].Value().Should().Be(400); - jArray[1].Value().Should().Be(500); + jArray[0]!.GetValue().Should().Be(400); + jArray[1]!.GetValue().Should().Be(500); } [Test] @@ -252,11 +252,11 @@ public async Task IgnoreResourceHttpStatusDomains_SerializesToStringArray() content.Should().NotBeNull(); var json = await content!.ReadAsStringAsync(); - var jArray = JArray.Parse(json); + var jArray = JsonNode.Parse(json)!.AsArray(); jArray.Should().HaveCount(2); - jArray[0].Value().Should().Be("cdn.example.com"); - jArray[1].Value().Should().Be("fonts.googleapis.com"); + jArray[0]!.GetValue().Should().Be("cdn.example.com"); + jArray[1]!.GetValue().Should().Be("fonts.googleapis.com"); } [Test] diff --git a/test/GotenbergSharpClient.Tests/CookieTests.cs b/test/GotenbergSharpClient.Tests/CookieTests.cs index 303a38a..f8d49a3 100644 --- a/test/GotenbergSharpClient.Tests/CookieTests.cs +++ b/test/GotenbergSharpClient.Tests/CookieTests.cs @@ -1,8 +1,7 @@ using Gotenberg.Sharp.API.Client.Domain.Cookies; using Gotenberg.Sharp.API.Client.Domain.HtmlBehavior; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace GotenbergSharpClient.Tests; @@ -21,13 +20,13 @@ public void Cookie_WithRequiredFieldsOnly_SerializesToCorrectJson() }; // Act - var json = JsonConvert.SerializeObject(cookie); - var jObject = JObject.Parse(json); + var json = System.Text.Json.JsonSerializer.Serialize(cookie); + var jObject = JsonNode.Parse(json)!.AsObject(); // Assert - jObject["name"]!.Value().Should().Be("yummy_cookie"); - jObject["value"]!.Value().Should().Be("choco"); - jObject["domain"]!.Value().Should().Be("theyummycookie.com"); + jObject["name"]!.GetValue().Should().Be("yummy_cookie"); + jObject["value"]!.GetValue().Should().Be("choco"); + jObject["domain"]!.GetValue().Should().Be("theyummycookie.com"); jObject.ContainsKey("path").Should().BeFalse(); jObject.ContainsKey("secure").Should().BeFalse(); jObject.ContainsKey("httpOnly").Should().BeFalse(); @@ -50,17 +49,17 @@ public void Cookie_WithAllFields_SerializesToCorrectJson() }; // Act - var json = JsonConvert.SerializeObject(cookie); - var jObject = JObject.Parse(json); + var json = System.Text.Json.JsonSerializer.Serialize(cookie); + var jObject = JsonNode.Parse(json)!.AsObject(); // Assert - jObject["name"]!.Value().Should().Be("session_token"); - jObject["value"]!.Value().Should().Be("abc123"); - jObject["domain"]!.Value().Should().Be("example.com"); - jObject["path"]!.Value().Should().Be("/api"); - jObject["secure"]!.Value().Should().BeTrue(); - jObject["httpOnly"]!.Value().Should().BeTrue(); - jObject["sameSite"]!.Value().Should().Be("Strict"); + jObject["name"]!.GetValue().Should().Be("session_token"); + jObject["value"]!.GetValue().Should().Be("abc123"); + jObject["domain"]!.GetValue().Should().Be("example.com"); + jObject["path"]!.GetValue().Should().Be("/api"); + jObject["secure"]!.GetValue().Should().BeTrue(); + jObject["httpOnly"]!.GetValue().Should().BeTrue(); + jObject["sameSite"]!.GetValue().Should().Be("Strict"); } [Test] @@ -88,25 +87,25 @@ public void CookieList_SerializesToCorrectJsonArray() }; // Act - var json = JsonConvert.SerializeObject(cookies); - var jArray = JArray.Parse(json); + var json = System.Text.Json.JsonSerializer.Serialize(cookies); + var jArray = JsonNode.Parse(json)!.AsArray(); // Assert jArray.Should().HaveCount(2); - var first = (JObject)jArray[0]; - first["name"]!.Value().Should().Be("yummy_cookie"); - first["value"]!.Value().Should().Be("choco"); - first["domain"]!.Value().Should().Be("theyummycookie.com"); - - var second = (JObject)jArray[1]; - second["name"]!.Value().Should().Be("session_token"); - second["value"]!.Value().Should().Be("abc123"); - second["domain"]!.Value().Should().Be("example.com"); - second["path"]!.Value().Should().Be("/"); - second["secure"]!.Value().Should().BeTrue(); - second["httpOnly"]!.Value().Should().BeTrue(); - second["sameSite"]!.Value().Should().Be("Lax"); + var first = jArray[0]!.AsObject(); + first["name"]!.GetValue().Should().Be("yummy_cookie"); + first["value"]!.GetValue().Should().Be("choco"); + first["domain"]!.GetValue().Should().Be("theyummycookie.com"); + + var second = jArray[1]!.AsObject(); + second["name"]!.GetValue().Should().Be("session_token"); + second["value"]!.GetValue().Should().Be("abc123"); + second["domain"]!.GetValue().Should().Be("example.com"); + second["path"]!.GetValue().Should().Be("/"); + second["secure"]!.GetValue().Should().BeTrue(); + second["httpOnly"]!.GetValue().Should().BeTrue(); + second["sameSite"]!.GetValue().Should().Be("Lax"); } [Test] @@ -136,13 +135,13 @@ public void HtmlConversionBehaviors_WithCookies_ConvertsToCorrectHttpContent() cookieContent.Should().NotBeNull("Cookie content should be present in HTTP content"); var contentString = cookieContent!.ReadAsStringAsync().Result; - var jArray = JArray.Parse(contentString); + var jArray = JsonNode.Parse(contentString)!.AsArray(); jArray.Should().HaveCount(1); - var cookie = (JObject)jArray[0]; - cookie["name"]!.Value().Should().Be("test_cookie"); - cookie["value"]!.Value().Should().Be("test_value"); - cookie["domain"]!.Value().Should().Be("test.com"); + var cookie = jArray[0]!.AsObject(); + cookie["name"]!.GetValue().Should().Be("test_cookie"); + cookie["value"]!.GetValue().Should().Be("test_value"); + cookie["domain"]!.GetValue().Should().Be("test.com"); } [Test] @@ -165,23 +164,23 @@ public void HtmlConversionBehaviors_WithMultipleCookies_ConvertsToCorrectJsonArr c.Headers.ContentDisposition?.Name == "cookies"); var contentString = cookieContent!.ReadAsStringAsync().Result; - var jArray = JArray.Parse(contentString); + var jArray = JsonNode.Parse(contentString)!.AsArray(); // Assert jArray.Should().HaveCount(3); - var first = (JObject)jArray[0]; - first["name"]!.Value().Should().Be("cookie1"); + var first = jArray[0]!.AsObject(); + first["name"]!.GetValue().Should().Be("cookie1"); first.ContainsKey("secure").Should().BeFalse(); - var second = (JObject)jArray[1]; - second["name"]!.Value().Should().Be("cookie2"); - second["secure"]!.Value().Should().BeTrue(); + var second = jArray[1]!.AsObject(); + second["name"]!.GetValue().Should().Be("cookie2"); + second["secure"]!.GetValue().Should().BeTrue(); - var third = (JObject)jArray[2]; - third["name"]!.Value().Should().Be("cookie3"); - third["httpOnly"]!.Value().Should().BeTrue(); - third["sameSite"]!.Value().Should().Be("None"); + var third = jArray[2]!.AsObject(); + third["name"]!.GetValue().Should().Be("cookie3"); + third["httpOnly"]!.GetValue().Should().BeTrue(); + third["sameSite"]!.GetValue().Should().Be("None"); } [Test] @@ -218,7 +217,7 @@ public void HtmlConversionBehaviors_WithEmptyCookieList_IncludesEmptyArray() c.Headers.ContentDisposition?.Name == "cookies"); var contentString = cookieContent!.ReadAsStringAsync().Result; - var jArray = JArray.Parse(contentString); + var jArray = JsonNode.Parse(contentString)!.AsArray(); // Assert jArray.Should().HaveCount(0, "Empty cookie list should serialize to empty JSON array"); diff --git a/test/GotenbergSharpClient.Tests/GotenbergSharpClient.Tests.csproj b/test/GotenbergSharpClient.Tests/GotenbergSharpClient.Tests.csproj index 14b9b1f..306cca6 100644 --- a/test/GotenbergSharpClient.Tests/GotenbergSharpClient.Tests.csproj +++ b/test/GotenbergSharpClient.Tests/GotenbergSharpClient.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 latest enable enable @@ -9,8 +9,8 @@ - - + + diff --git a/test/GotenbergSharpClient.Tests/HtmlConversionBehaviorBuilderTests.cs b/test/GotenbergSharpClient.Tests/HtmlConversionBehaviorBuilderTests.cs index 7498d94..0ff56bd 100644 --- a/test/GotenbergSharpClient.Tests/HtmlConversionBehaviorBuilderTests.cs +++ b/test/GotenbergSharpClient.Tests/HtmlConversionBehaviorBuilderTests.cs @@ -3,7 +3,7 @@ using Gotenberg.Sharp.API.Client.Domain.Cookies; using Gotenberg.Sharp.API.Client.Domain.Requests; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace GotenbergSharpClient.Tests; @@ -112,20 +112,20 @@ public void AddCookies_WithEnumerable_AddsAllCookies() cookieContent.Should().NotBeNull("Cookie content should be present in HTTP request"); var contentString = cookieContent.ReadAsStringAsync().Result; - var jArray = JArray.Parse(contentString); + var jArray = JsonNode.Parse(contentString)!.AsArray(); jArray.Should().HaveCount(3); - jArray[0]["name"]!.Value().Should().Be("cookie1"); - jArray[0]["value"]!.Value().Should().Be("value1"); - jArray[0]["domain"]!.Value().Should().Be("domain1.com"); + jArray[0]!["name"]!.GetValue().Should().Be("cookie1"); + jArray[0]!["value"]!.GetValue().Should().Be("value1"); + jArray[0]!["domain"]!.GetValue().Should().Be("domain1.com"); - jArray[1]["name"]!.Value().Should().Be("cookie2"); - jArray[1]["value"]!.Value().Should().Be("value2"); - jArray[1]["domain"]!.Value().Should().Be("domain2.com"); + jArray[1]!["name"]!.GetValue().Should().Be("cookie2"); + jArray[1]!["value"]!.GetValue().Should().Be("value2"); + jArray[1]!["domain"]!.GetValue().Should().Be("domain2.com"); - jArray[2]["name"]!.Value().Should().Be("cookie3"); - jArray[2]["value"]!.Value().Should().Be("value3"); - jArray[2]["domain"]!.Value().Should().Be("domain3.com"); + jArray[2]!["name"]!.GetValue().Should().Be("cookie3"); + jArray[2]!["value"]!.GetValue().Should().Be("value3"); + jArray[2]!["domain"]!.GetValue().Should().Be("domain3.com"); } [Test] @@ -188,22 +188,22 @@ public void Builder_WithCookies_ProducesCorrectJsonInHttpContent() // Assert cookieContent.Should().NotBeNull(); var contentString = cookieContent.ReadAsStringAsync().Result; - var jArray = JArray.Parse(contentString); + var jArray = JsonNode.Parse(contentString)!.AsArray(); jArray.Should().HaveCount(2); - var first = (JObject)jArray[0]; - first["name"]!.Value().Should().Be("yummy_cookie"); - first["value"]!.Value().Should().Be("choco"); - first["domain"]!.Value().Should().Be("theyummycookie.com"); - - var second = (JObject)jArray[1]; - second["name"]!.Value().Should().Be("session"); - second["value"]!.Value().Should().Be("token123"); - second["domain"]!.Value().Should().Be("secure.com"); - second["path"]!.Value().Should().Be("/"); - second["secure"]!.Value().Should().BeTrue(); - second["httpOnly"]!.Value().Should().BeTrue(); - second["sameSite"]!.Value().Should().Be("Lax"); + var first = jArray[0]!.AsObject(); + first["name"]!.GetValue().Should().Be("yummy_cookie"); + first["value"]!.GetValue().Should().Be("choco"); + first["domain"]!.GetValue().Should().Be("theyummycookie.com"); + + var second = jArray[1]!.AsObject(); + second["name"]!.GetValue().Should().Be("session"); + second["value"]!.GetValue().Should().Be("token123"); + second["domain"]!.GetValue().Should().Be("secure.com"); + second["path"]!.GetValue().Should().Be("/"); + second["secure"]!.GetValue().Should().BeTrue(); + second["httpOnly"]!.GetValue().Should().BeTrue(); + second["sameSite"]!.GetValue().Should().Be("Lax"); } } diff --git a/test/GotenbergSharpClient.Tests/JsonValueHandlingTests.cs b/test/GotenbergSharpClient.Tests/JsonValueHandlingTests.cs new file mode 100644 index 0000000..c8b37f5 --- /dev/null +++ b/test/GotenbergSharpClient.Tests/JsonValueHandlingTests.cs @@ -0,0 +1,218 @@ +using System.Net; +using System.Text.Json.Nodes; + +using Gotenberg.Sharp.API.Client.Application.Builders; +using Gotenberg.Sharp.API.Client.Domain.Overlays; +using Gotenberg.Sharp.API.Client.Domain.PdfOutput; +using Gotenberg.Sharp.API.Client.Domain.Requests; +using Gotenberg.Sharp.API.Client.Infrastructure; + +namespace GotenbergSharpClient.Tests; + +/// +/// Covers how loosely typed dictionary values (metadata, overlay options, extra headers) are +/// accepted and written, and how an empty bookmark response is read. +/// +[TestFixture] +public class JsonValueHandlingTests +{ + #region Unsupported values fail when supplied + + private static readonly Dictionary UnsupportedValues = new() + { + ["Info"] = new { Author = "x" } + }; + + [Test] + public void WriteMetadata_WithUnsupportedValue_ThrowsWhenSupplied() + { + var act = () => PdfEngineBuilders.WriteMetadata(UnsupportedValues); + + act.Should().Throw(); + } + + [Test] + public void SetMetadata_WithUnsupportedValue_ThrowsWhenSupplied() + { + var act = () => new HtmlRequestBuilder() + .SetPdfOutputOptions(o => o.SetMetadata(UnsupportedValues)); + + act.Should().Throw(); + } + + [Test] + public void SetWatermarkOptions_WithUnsupportedValue_ThrowsWhenSupplied() + { + var act = () => new HtmlRequestBuilder() + .SetWatermarkOptions(o => o.SetOptions(UnsupportedValues)); + + act.Should().Throw(); + } + + [Test] + public void SetStampOptions_WithUnsupportedValue_ThrowsWhenSupplied() + { + var act = () => new HtmlRequestBuilder() + .SetStampOptions(o => o.SetOptions(UnsupportedValues)); + + act.Should().Throw(); + } + + [Test] + public void AddAdditionalHeaders_WithUnsupportedValue_ThrowsWhenSupplied() + { + var act = () => new HtmlRequestBuilder() + .SetConversionBehaviors(b => b.AddAdditionalHeaders(UnsupportedValues)); + + act.Should().Throw(); + } + + [Test] + public void WriteMetadata_WithNestedSupportedValues_IsAccepted() + { + var metadata = new Dictionary + { + ["Title"] = "Report", + ["Pages"] = 3, + ["Keywords"] = new[] { "a", "b" }, + ["Nested"] = new Dictionary { ["Flag"] = true }, + ["Node"] = new JsonObject { ["x"] = 1 } + }; + + var act = () => PdfEngineBuilders.WriteMetadata(metadata); + + act.Should().NotThrow(); + } + + #endregion + + #region Byte arrays are written as base64 + + [Test] + public async Task ByteArrayValue_IsWrittenAsBase64String() + { + var options = new PdfOutputOptions + { + MetaData = new Dictionary { ["Data"] = new byte[] { 1, 2, 3 } } + }; + + var json = await ReadFormField(options, "metadata"); + + JsonNode.Parse(json)!["Data"]!.GetValue().Should().Be("AQID"); + } + + #endregion + + #region The caller's dictionary is copied + + [Test] + public void WriteMetadata_CopiesTheDictionary() + { + var metadata = new Dictionary { ["Title"] = "First" }; + + var request = PdfEngineBuilders.WriteMetadata(metadata) + .WithPdfs(a => a.AddItem("test.pdf", new byte[] { 1, 2, 3 })) + .Build(); + + metadata["Title"] = "Second"; + metadata["Added"] = "later"; + + request.Metadata.Should().NotBeSameAs(metadata); + request.Metadata!["Title"].Should().Be("First"); + request.Metadata.Should().NotContainKey("Added"); + } + + [Test] + public async Task SetWatermarkOptions_CopiesTheDictionary() + { + var values = new Dictionary { ["opacity"] = 0.5 }; + var options = new WatermarkOptions(); + + new Gotenberg.Sharp.API.Client.Application.Overlays.WatermarkOptionsBuilder(options).SetOptions(values); + values["opacity"] = 0.9; + + var json = await ReadFormField(options, Constants.Gotenberg.CrossCuttingOptions.WatermarkOptionsJson); + + JsonNode.Parse(json)!["opacity"]!.GetValue().Should().Be(0.5); + } + + [Test] + public void SameDictionaryReusedAcrossRequests_KeepsEachRequestsValues() + { + var metadata = new Dictionary(); + var requests = new List(); + + foreach (var title in new[] { "One", "Two", "Three" }) + { + metadata["Title"] = title; + + requests.Add( + PdfEngineBuilders.WriteMetadata(metadata) + .WithPdfs(a => a.AddItem("test.pdf", new byte[] { 1, 2, 3 })) + .Build()); + } + + requests.Select(r => r.Metadata!["Title"]).Should().Equal("One", "Two", "Three"); + } + + #endregion + + #region Empty bookmark responses + + [TestCase("")] + [TestCase(" ")] + public async Task ReadPdfBookmarks_WithEmptyBody_ReturnsNoBookmarks(string body) + { + var client = CreateClientReturning(body); + + var bookmarks = await client.ReadPdfBookmarksAsync( + PdfEngineBuilders.ReadBookmarks() + .WithPdfs(a => a.AddItem("test.pdf", new byte[] { 1, 2, 3 }))); + + bookmarks.Should().BeEmpty(); + } + + [Test] + public async Task ReadPdfBookmarks_WithMalformedBody_Throws() + { + var client = CreateClientReturning("{ not json"); + + var act = () => client.ReadPdfBookmarksAsync( + PdfEngineBuilders.ReadBookmarks() + .WithPdfs(a => a.AddItem("test.pdf", new byte[] { 1, 2, 3 }))); + + await act.Should().ThrowAsync(); + } + + #endregion + + private static async Task ReadFormField(FacetBase facet, string name) + { + var content = facet.ToHttpContent() + .Single(c => c.Headers.ContentDisposition?.Name == name); + + return await content.ReadAsStringAsync(); + } + + private static Gotenberg.Sharp.API.Client.GotenbergSharpClient CreateClientReturning(string body) + { + var httpClient = new HttpClient(new FixedBodyHandler(body)) + { + BaseAddress = new Uri("http://gotenberg.invalid:3000") + }; + + // The version route is not stubbed, so skip the minimum-version check. + return new Gotenberg.Sharp.API.Client.GotenbergSharpClient(httpClient) { EnforceMinimumVersion = false }; + } + + private sealed class FixedBodyHandler(string body) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); + } + } +} diff --git a/test/GotenbergSharpClient.Tests/PdfEngineOperationsTests.cs b/test/GotenbergSharpClient.Tests/PdfEngineOperationsTests.cs index 7b816f4..c84591a 100644 --- a/test/GotenbergSharpClient.Tests/PdfEngineOperationsTests.cs +++ b/test/GotenbergSharpClient.Tests/PdfEngineOperationsTests.cs @@ -9,7 +9,7 @@ using Gotenberg.Sharp.API.Client.Infrastructure; using Microsoft.Extensions.DependencyInjection; using MimeMapping; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace GotenbergSharpClient.Tests; @@ -95,7 +95,7 @@ public void PdfEngineBuilders_WriteMetadata_CreatesRequest() var request = builder.Build(); var writeRequest = (WriteMetadataRequest)request; - writeRequest.Metadata!["Author"]!.Value().Should().Be("Test"); + writeRequest.Metadata!["Author"].Should().Be("Test"); } [Test] @@ -226,7 +226,7 @@ public async Task ReadMetadata_ReturnsJson() var json = await client.ReadPdfMetadataAsync(builder); json.Should().NotBeNullOrEmpty(); - var parsed = JObject.Parse(json); + var parsed = JsonNode.Parse(json)!.AsObject(); parsed.Should().NotBeEmpty(); } @@ -329,7 +329,7 @@ public async Task ReadBookmarks_ReturnsRawJsonKeyedByFilename() .WithPdfs(a => a.AddItem("plain.pdf", pdfBytes))); json.Should().NotBeNullOrEmpty(); - JObject.Parse(json).Should().ContainKey("plain.pdf"); + JsonNode.Parse(json)!.AsObject().Should().ContainKey("plain.pdf"); } [Category("Integration")] diff --git a/test/GotenbergSharpClient.Tests/ValueObjects/EmulatedMediaFeatureTests.cs b/test/GotenbergSharpClient.Tests/ValueObjects/EmulatedMediaFeatureTests.cs index 26ee652..c85a9f3 100644 --- a/test/GotenbergSharpClient.Tests/ValueObjects/EmulatedMediaFeatureTests.cs +++ b/test/GotenbergSharpClient.Tests/ValueObjects/EmulatedMediaFeatureTests.cs @@ -1,7 +1,6 @@ using Gotenberg.Sharp.API.Client.Domain.HtmlBehavior; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace GotenbergSharpClient.Tests.ValueObjects; @@ -53,11 +52,11 @@ public void Serialization_ProducesCorrectJson() { var feature = EmulatedMediaFeature.Create("prefers-color-scheme", "dark"); - var json = JsonConvert.SerializeObject(feature); - var jObject = JObject.Parse(json); + var json = System.Text.Json.JsonSerializer.Serialize(feature); + var jObject = JsonNode.Parse(json)!.AsObject(); - jObject["name"]!.Value().Should().Be("prefers-color-scheme"); - jObject["value"]!.Value().Should().Be("dark"); + jObject["name"]!.GetValue().Should().Be("prefers-color-scheme"); + jObject["value"]!.GetValue().Should().Be("dark"); } [Test] @@ -69,13 +68,13 @@ public void ListSerialization_ProducesCorrectJsonArray() EmulatedMediaFeature.PrefersReducedMotion("reduce") }; - var json = JsonConvert.SerializeObject(features); - var jArray = JArray.Parse(json); + var json = System.Text.Json.JsonSerializer.Serialize(features); + var jArray = JsonNode.Parse(json)!.AsArray(); jArray.Should().HaveCount(2); - jArray[0]["name"]!.Value().Should().Be("prefers-color-scheme"); - jArray[0]["value"]!.Value().Should().Be("dark"); - jArray[1]["name"]!.Value().Should().Be("prefers-reduced-motion"); - jArray[1]["value"]!.Value().Should().Be("reduce"); + jArray[0]!["name"]!.GetValue().Should().Be("prefers-color-scheme"); + jArray[0]!["value"]!.GetValue().Should().Be("dark"); + jArray[1]!["name"]!.GetValue().Should().Be("prefers-reduced-motion"); + jArray[1]!["value"]!.GetValue().Should().Be("reduce"); } }