diff --git a/.gitignore b/.gitignore index cd59838..6f0d0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ src/UltimatePDF_ExternalLogic.E2ETests/appsettings.local.json # IDE launch profiles src/**/Properties/launchSettings.json + +# AI working folder +specs/ \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bffba74..127aa36 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -117,7 +117,7 @@ This component operates within ODC's external logic environment, subject to: ## Deployment -The library is packaged as a ZIP file containing compiled .NET 8.0 binaries for linux-x64 runtime. Generate the package by running: +The library is packaged as a ZIP file containing compiled .NET 10.0 binaries for linux-x64 runtime. Generate the package by running: ```bash .\generate_upload_package.ps1 @@ -132,7 +132,7 @@ Companion OutSystems modules in the `oml/` directory provide client-side acceler ## Technology Stack -- **.NET 8.0:** Target framework for ODC external logic +- **.NET 10.0:** Target framework for ODC external logic - **PuppeteerSharp:** Browser automation library wrapping Chromium DevTools Protocol - **HeadlessChromium.Puppeteer.Lambda.Dotnet:** Provides Chromium binaries compatible with AWS Lambda (ODC infrastructure) - **PDFsharp 6.2.0:** PDF manipulation for merging layers (backgrounds, headers, footers) diff --git a/CLAUDE.md b/CLAUDE.md index 2d1a94b..eada4e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -UltimatePDF-ExternalLogic is an OutSystems Developer Cloud (ODC) external logic component that generates PDFs from web pages using Chromium's rendering engine. The code is written in C# targeting .NET 8.0 and runs within ODC's managed infrastructure. +UltimatePDF-ExternalLogic is an OutSystems Developer Cloud (ODC) external logic component that generates PDFs from web pages using Chromium's rendering engine. The code is written in C# targeting .NET 10.0 and runs within ODC's managed infrastructure. For system architecture and design patterns, see [ARCHITECTURE.md](./ARCHITECTURE.md). For development workflow and contribution guidelines, see [CONTRIBUTING.md](./CONTRIBUTING.md). @@ -143,10 +143,10 @@ See `LayoutPrintPipeline/Pipeline.cs` methods (`MergeBackground`, `MergeHeaders` ## Technology Stack -- **.NET 8.0** - Target framework for ODC external logic +- **.NET 10.0** - Target framework for ODC external logic - **PuppeteerSharp** - Browser automation via Chrome DevTools Protocol -- **HeadlessChromium.Puppeteer.Lambda.Dotnet** (v1.1.0.97) - Chromium binaries for AWS Lambda (ODC infrastructure) -- **PDFsharp** (v6.2.0) - PDF layer composition +- **HeadlessChromium.Puppeteer.Dotnet.OS.Fork** (v1.1.1.1) - Chromium binaries for AWS Lambda (ODC infrastructure) +- **PDFsharp** (v6.2.4) - PDF layer composition - **OutSystems.ExternalLibraries.SDK** (v1.5.0) - ODC external logic framework ## Additional Resources diff --git a/generate_upload_package.ps1 b/generate_upload_package.ps1 index 054c799..490dae1 100644 --- a/generate_upload_package.ps1 +++ b/generate_upload_package.ps1 @@ -1,4 +1,6 @@ -Remove-Item -Path .\UltimatePDF_ExternalLogic.zip -Force +if (Test-Path -Path .\UltimatePDF_ExternalLogic.zip -PathType Leaf) { + Remove-Item -Path .\UltimatePDF_ExternalLogic.zip -Force +} Set-ExecutionPolicy -Scope CurrentUser Unrestricted dotnet publish src\UltimatePDF_ExternalLogic.sln -c Release -r linux-x64 --self-contained false -Compress-Archive -Path .\src\UltimatePDF_ExternalLogic\bin\Release\net8.0\linux-x64\publish\* -Update -DestinationPath UltimatePDF_ExternalLogic.zip \ No newline at end of file +Compress-Archive -Path .\src\UltimatePDF_ExternalLogic\bin\Release\net10.0\linux-x64\publish\* -Update -DestinationPath UltimatePDF_ExternalLogic.zip \ No newline at end of file diff --git a/oml/Ultimate PDF Tests.oml b/oml/Ultimate PDF Tests.oml index 33b8eda..1fd6fe4 100644 Binary files a/oml/Ultimate PDF Tests.oml and b/oml/Ultimate PDF Tests.oml differ diff --git a/oml/Ultimate PDF.oml b/oml/Ultimate PDF.oml index ce5d286..99c18cc 100644 Binary files a/oml/Ultimate PDF.oml and b/oml/Ultimate PDF.oml differ diff --git a/src/UltimatePDF_ExternalLogic.E2ETests/Fixtures/OdcTenantFixture.cs b/src/UltimatePDF_ExternalLogic.E2ETests/Fixtures/OdcTenantFixture.cs index 202cd7d..bc940ed 100644 --- a/src/UltimatePDF_ExternalLogic.E2ETests/Fixtures/OdcTenantFixture.cs +++ b/src/UltimatePDF_ExternalLogic.E2ETests/Fixtures/OdcTenantFixture.cs @@ -35,8 +35,18 @@ public async ValueTask InitializeAsync() { // Step 2 — Discover EnvironmentKey via Portfolios API string? environmentKey = await GetEnvironmentKey(tenantEndpoint, portalClient); + if (environmentKey is null) { + throw new InvalidOperationException( + "Failed to get the environment key. Make sure the tenant endpoint is correctly configured in the settings."); + } + // Step 3 — Check if "Ultimate PDF Tests" is already deployed in the Development environment string? applicationKey = await CheckTestAppIsDeployed(tenantEndpoint, portalClient, environmentKey); + + if(applicationKey is null) { + throw new InvalidOperationException( + "Failed to deploy 'Ultimate PDF Tests' application."); + } // Step 4 — Generate secret var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); @@ -86,13 +96,13 @@ public async ValueTask InitializeAsync() { new AuthenticationHeaderValue("Bearer", secret); } - private static async Task CheckTestAppIsDeployed(string tenantEndpoint, HttpClient portalClient, string? environmentKey) { + private static async Task CheckTestAppIsDeployed(string tenantEndpoint, HttpClient portalClient, string environmentKey) { var appsUrl = $"{tenantEndpoint}/api/portfolios/v1/deployed-assets?nameContains={Uri.EscapeDataString("Ultimate PDF Tests")}"; var appsResp = await portalClient.GetAsync(appsUrl); if (!appsResp.IsSuccessStatusCode) { var errBody = await appsResp.Content.ReadAsStringAsync(); throw new InvalidOperationException( - $"Test Setup Failed: Could not retrieve deployed applications list. Details: {errBody}"); + $"Test Setup Failed: Could not retrieve deployed applications list. Details: {errBody} \n\n"); } var appsJson = await appsResp.Content.ReadAsStringAsync(); var appsDoc = JsonSerializer.Deserialize(appsJson); @@ -105,12 +115,17 @@ public async ValueTask InitializeAsync() { if (deployNeeded) { // Step 3b-i — Create upload slot - var uploadsUrl = $"{tenantEndpoint}/api/deployments/v1/uploads"; + var uploadsUrl = $"{tenantEndpoint}/api/asset-repository/v1/uploads"; var uploadsResp = await portalClient.PostAsync(uploadsUrl, content: null); if (!uploadsResp.IsSuccessStatusCode) { var errBody = await uploadsResp.Content.ReadAsStringAsync(); throw new InvalidOperationException( - $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody}"); + $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed.\n" + + $"Status code: {uploadsResp.StatusCode}\n"+ + $"Reason phrase: {uploadsResp.ReasonPhrase}\n"+ + $"Details: {errBody}\n\n" + + $"---\n" + + $"If the error persists deploy the ‘Ultimate PDF Tests.oml’ from the repo."); } var uploadSlot = JsonSerializer.Deserialize( await uploadsResp.Content.ReadAsStringAsync())!; @@ -126,11 +141,13 @@ public async ValueTask InitializeAsync() { if (!s3Resp.IsSuccessStatusCode) { var errBody = await s3Resp.Content.ReadAsStringAsync(); throw new InvalidOperationException( - $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody}"); + $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody}\n\n" + + $"---\n" + + $"If the error persists deploy the ‘Ultimate PDF Tests.oml’ from the repo."); } // Step 3b-iii — Create asset revision (sets applicationKey) - var assetsUrl = $"{tenantEndpoint}/api/deployments/v1/assets"; + var assetsUrl = $"{tenantEndpoint}/api/asset-repository/v1/assets"; var assetPayload = new OdcAssetCreationRequest { FileUri = uploadSlot.UploadUrl, AssetCreationDetails = new OdcAssetCreationDetails { @@ -142,7 +159,7 @@ public async ValueTask InitializeAsync() { if (!assetsResp.IsSuccessStatusCode) { var errBody = await assetsResp.Content.ReadAsStringAsync(); throw new InvalidOperationException( - $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody}"); + $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody} \n\n"); } var assetRevision = JsonSerializer.Deserialize( await assetsResp.Content.ReadAsStringAsync())!; @@ -159,7 +176,9 @@ public async ValueTask InitializeAsync() { if (!publishOmlResp.IsSuccessStatusCode) { var errBody = await publishOmlResp.Content.ReadAsStringAsync(); throw new InvalidOperationException( - $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody}"); + $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody} \n\n" + + $"---\n" + + $"If the error persists deploy the ‘Ultimate PDF Tests.oml’ from the repo."); } var publishOp = JsonSerializer.Deserialize( await publishOmlResp.Content.ReadAsStringAsync())!; @@ -174,7 +193,9 @@ public async ValueTask InitializeAsync() { if (!pollResp.IsSuccessStatusCode) { var errBody = await pollResp.Content.ReadAsStringAsync(); throw new InvalidOperationException( - $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody}"); + $"Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. Details: {errBody} \n\n" + + $"---\n" + + $"If the error persists deploy the ‘Ultimate PDF Tests.oml’ from the repo."); } var pollStatus = JsonSerializer.Deserialize( await pollResp.Content.ReadAsStringAsync())!; @@ -185,20 +206,24 @@ public async ValueTask InitializeAsync() { if (string.Equals(pollStatus.Status, "Failed", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( "Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. " + - "Details: Publish operation failed."); + "Details: Publish operation failed.\n\n" + + $"---\n" + + $"If the error persists deploy the ‘Ultimate PDF Tests.oml’ from the repo."); } } if (!published) { throw new InvalidOperationException( "Test Setup Failed: Deployment of 'Ultimate PDF Tests.oml' failed. " + - "Details: Deployment timed out after 10 minutes."); + "Details: Deployment timed out after 10 minutes.\n\n" + + $"---\n" + + $"If the error persists deploy the ‘Ultimate PDF Tests.oml’ from the repo."); } } // end if (deployNeeded) return applicationKey; } - private static async Task<(string configKey, int revisionBase, string cicdSettingKey)> FetchTestAppConfiguration(string tenantEndpoint, HttpClient portalClient, string? environmentKey, string? applicationKey) { + private static async Task<(string configKey, int revisionBase, string cicdSettingKey)> FetchTestAppConfiguration(string tenantEndpoint, HttpClient portalClient, string environmentKey, string applicationKey) { var getConfigUrl = $"{tenantEndpoint}/api/asset-configurations/v1/environments/{environmentKey}/applications/{applicationKey!}/revisions/deployed/configurations"; var getResp = await portalClient.GetAsync(getConfigUrl); if (!getResp.IsSuccessStatusCode) { @@ -214,7 +239,7 @@ public async ValueTask InitializeAsync() { return (configKey, revisionBase, cicdSettingKey); } - private static async Task PushSecretConfiguration(string tenantEndpoint, HttpClient portalClient, string? environmentKey, string? applicationKey, string secret, string configKey, int revisionBase, string cicdSettingKey) { + private static async Task PushSecretConfiguration(string tenantEndpoint, HttpClient portalClient, string environmentKey, string applicationKey, string secret, string configKey, int revisionBase, string cicdSettingKey) { var patchConfigUrl = $"{tenantEndpoint}/api/asset-configurations/v1/environments/{environmentKey}/applications/{applicationKey!}/configurations"; var payload = new OdcConfigurationPayload( Key: configKey, @@ -227,7 +252,7 @@ private static async Task PushSecretConfiguration(string tenantEndpoint, HttpCli } } - private static async Task TriggerApplyConfigs(string tenantEndpoint, HttpClient portalClient, string? environmentKey, string applicationKey, int revisionBase) { + private static async Task TriggerApplyConfigs(string tenantEndpoint, HttpClient portalClient, string environmentKey, string applicationKey, int revisionBase) { var publishUrl = $"{tenantEndpoint}/api/deployments/v1/deployment-operations"; var publishPayload = new PublishOperationRequest( Operation: "ApplyConfigs", @@ -244,7 +269,7 @@ private static async Task TriggerApplyConfigs(strin return applyOp; } - private static async Task GetAppHostname(string tenantEndpoint, HttpClient portalClient, string? environmentKey) { + private static async Task GetAppHostname(string tenantEndpoint, HttpClient portalClient, string environmentKey) { var domainsUrl = $"{tenantEndpoint}/api/environment-configurations/v1/environments/{environmentKey}/domains"; var domainsResp = await portalClient.GetAsync(domainsUrl); if (!domainsResp.IsSuccessStatusCode) { diff --git a/src/UltimatePDF_ExternalLogic.E2ETests/UltimatePDF_ExternalLogic.E2ETests.csproj b/src/UltimatePDF_ExternalLogic.E2ETests/UltimatePDF_ExternalLogic.E2ETests.csproj index e294bf3..bf17159 100644 --- a/src/UltimatePDF_ExternalLogic.E2ETests/UltimatePDF_ExternalLogic.E2ETests.csproj +++ b/src/UltimatePDF_ExternalLogic.E2ETests/UltimatePDF_ExternalLogic.E2ETests.csproj @@ -1,24 +1,24 @@ - net8.0 + net10.0 enable enable false - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + @@ -30,6 +30,9 @@ + + PreserveNewest + PreserveNewest diff --git a/src/UltimatePDF_ExternalLogic.IntegrationTests/UltimatePDF_ExternalLogic.IntegrationTests.csproj b/src/UltimatePDF_ExternalLogic.IntegrationTests/UltimatePDF_ExternalLogic.IntegrationTests.csproj index ee03e23..8bd4225 100644 --- a/src/UltimatePDF_ExternalLogic.IntegrationTests/UltimatePDF_ExternalLogic.IntegrationTests.csproj +++ b/src/UltimatePDF_ExternalLogic.IntegrationTests/UltimatePDF_ExternalLogic.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable false diff --git a/src/UltimatePDF_ExternalLogic.Test.Helpers/UltimatePDF_ExternalLogic.Test.Helpers.csproj b/src/UltimatePDF_ExternalLogic.Test.Helpers/UltimatePDF_ExternalLogic.Test.Helpers.csproj index fe47456..a42d6f6 100644 --- a/src/UltimatePDF_ExternalLogic.Test.Helpers/UltimatePDF_ExternalLogic.Test.Helpers.csproj +++ b/src/UltimatePDF_ExternalLogic.Test.Helpers/UltimatePDF_ExternalLogic.Test.Helpers.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable false @@ -12,7 +12,7 @@ - + diff --git a/src/UltimatePDF_ExternalLogic.UnitTests/PipelineTests.cs b/src/UltimatePDF_ExternalLogic.UnitTests/PipelineTests.cs index f6441c4..849abc1 100644 --- a/src/UltimatePDF_ExternalLogic.UnitTests/PipelineTests.cs +++ b/src/UltimatePDF_ExternalLogic.UnitTests/PipelineTests.cs @@ -19,7 +19,7 @@ private static Mock BuildPageMock(Pipeline.LayoutPrint[] layouts) { mock.Setup(p => p.EvaluateFunctionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(layouts); mock.Setup(p => p.EvaluateFunctionAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(JValue.CreateNull()); + .Returns(Task.CompletedTask); mock.Setup(p => p.PdfDataAsync(It.IsAny())) .ReturnsAsync(PdfFactory.CreateMinimal()); return mock; diff --git a/src/UltimatePDF_ExternalLogic.UnitTests/UltimatePDF_ExternalLogic.UnitTests.csproj b/src/UltimatePDF_ExternalLogic.UnitTests/UltimatePDF_ExternalLogic.UnitTests.csproj index 13de9d0..fc91750 100644 --- a/src/UltimatePDF_ExternalLogic.UnitTests/UltimatePDF_ExternalLogic.UnitTests.csproj +++ b/src/UltimatePDF_ExternalLogic.UnitTests/UltimatePDF_ExternalLogic.UnitTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable false diff --git a/src/UltimatePDF_ExternalLogic/Management/Troubleshooting/Logger.cs b/src/UltimatePDF_ExternalLogic/Management/Troubleshooting/Logger.cs index 226d808..874dd3f 100644 --- a/src/UltimatePDF_ExternalLogic/Management/Troubleshooting/Logger.cs +++ b/src/UltimatePDF_ExternalLogic/Management/Troubleshooting/Logger.cs @@ -12,12 +12,14 @@ public class Logger { private readonly ILogger logger; private readonly ICollection attachments; private readonly ICollection loggerFactories; + private readonly StringBuilder executionLog; private readonly bool attachFilesLogs; private Logger() { logger = NullLogger.Instance; attachments = new List(6); loggerFactories = new List(1); + executionLog = new StringBuilder(); } protected Logger(ILogger logger, bool attachFilesLogs) : this() { @@ -42,25 +44,39 @@ public void Log(string message) { } public virtual void Error(Exception? e, string? message, params object?[] args) { + AppendToExecutionLog(LogLevel.Error, message, e); logger.LogError(e, message, args); } public virtual void Error(string message) { + AppendToExecutionLog(LogLevel.Error, message); logger.LogError(message); } public virtual void Warning(string message) { + AppendToExecutionLog(LogLevel.Warning, message); logger.LogWarning(message); } public virtual void Warning(string? message, params object?[] args) { + AppendToExecutionLog(LogLevel.Warning, message); logger.LogWarning(message, args); } public virtual void Log(LogLevel level, string? message, params object?[] args) { + AppendToExecutionLog(level, message); logger.Log(level, message, args); } + private void AppendToExecutionLog(LogLevel level, string? message, Exception? exception = null) { + lock (executionLog) { + executionLog.AppendLine($"[{DateTime.UtcNow:o}] [{level}] - {message}"); + if (exception != null) { + executionLog.AppendLine(exception.ToString()); + } + } + } + public void Log(string message, bool condition) { if (condition) { Log(message); @@ -89,6 +105,7 @@ public virtual byte[] GetZipFile() { using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true)) { + AddExecutionLogToZip(zip); AddAttachmentsToZip(zip); AddCustomLoggersToZip(zip); } @@ -96,6 +113,17 @@ public virtual byte[] GetZipFile() { return stream.ToArray(); } + private void AddExecutionLogToZip(ZipArchive zip) { + if (executionLog.Length == 0) { + return; + } + + var entry = zip.CreateEntry("execution.txt"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(executionLog.ToString()); + } + private void AddAttachmentsToZip(ZipArchive zip) { foreach (var attachment in attachments) { var attachmentEntry = zip.CreateEntry(attachment.filename); diff --git a/src/UltimatePDF_ExternalLogic/UltimatePDF_ExternalLogic.csproj b/src/UltimatePDF_ExternalLogic/UltimatePDF_ExternalLogic.csproj index a6bf964..b3c3b84 100644 --- a/src/UltimatePDF_ExternalLogic/UltimatePDF_ExternalLogic.csproj +++ b/src/UltimatePDF_ExternalLogic/UltimatePDF_ExternalLogic.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable @@ -10,9 +10,9 @@ - + - +