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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ src/UltimatePDF_ExternalLogic.E2ETests/appsettings.local.json

# IDE launch profiles
src/**/Properties/launchSettings.json

# AI working folder
specs/
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions generate_upload_package.ps1
Original file line number Diff line number Diff line change
@@ -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
Compress-Archive -Path .\src\UltimatePDF_ExternalLogic\bin\Release\net10.0\linux-x64\publish\* -Update -DestinationPath UltimatePDF_ExternalLogic.zip
Binary file modified oml/Ultimate PDF Tests.oml
Binary file not shown.
Binary file modified oml/Ultimate PDF.oml
Binary file not shown.
55 changes: 40 additions & 15 deletions src/UltimatePDF_ExternalLogic.E2ETests/Fixtures/OdcTenantFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -86,13 +96,13 @@ public async ValueTask InitializeAsync() {
new AuthenticationHeaderValue("Bearer", secret);
}

private static async Task<string?> CheckTestAppIsDeployed(string tenantEndpoint, HttpClient portalClient, string? environmentKey) {
private static async Task<string?> 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<JsonElement>(appsJson);
Expand All @@ -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<OdcUploadUrlResponse>(
await uploadsResp.Content.ReadAsStringAsync())!;
Expand All @@ -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 {
Expand All @@ -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<OdcAssetRevisionResponse>(
await assetsResp.Content.ReadAsStringAsync())!;
Expand All @@ -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<OdcPublishOperationResponse>(
await publishOmlResp.Content.ReadAsStringAsync())!;
Expand All @@ -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<OdcPublishOperationResponse>(
await pollResp.Content.ReadAsStringAsync())!;
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -227,7 +252,7 @@ private static async Task PushSecretConfiguration(string tenantEndpoint, HttpCli
}
}

private static async Task<OdcPublishOperationResponse> TriggerApplyConfigs(string tenantEndpoint, HttpClient portalClient, string? environmentKey, string applicationKey, int revisionBase) {
private static async Task<OdcPublishOperationResponse> 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",
Expand All @@ -244,7 +269,7 @@ private static async Task<OdcPublishOperationResponse> TriggerApplyConfigs(strin
return applyOp;
}

private static async Task<string> GetAppHostname(string tenantEndpoint, HttpClient portalClient, string? environmentKey) {
private static async Task<string> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="PDFsharp" Version="6.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
</ItemGroup>

<ItemGroup>
Expand All @@ -30,6 +30,9 @@
</ItemGroup>

<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.template.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand All @@ -12,7 +12,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="PDFsharp" Version="6.2.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="xunit.v3.assert" Version="3.2.2" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion src/UltimatePDF_ExternalLogic.UnitTests/PipelineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ private static Mock<IPage> BuildPageMock(Pipeline.LayoutPrint[] layouts) {
mock.Setup(p => p.EvaluateFunctionAsync<Pipeline.LayoutPrint[]>(It.IsAny<string>(), It.IsAny<object[]>()))
.ReturnsAsync(layouts);
mock.Setup(p => p.EvaluateFunctionAsync(It.IsAny<string>(), It.IsAny<object[]>()))
.ReturnsAsync(JValue.CreateNull());
.Returns(Task.CompletedTask);
mock.Setup(p => p.PdfDataAsync(It.IsAny<PdfOptions>()))
.ReturnsAsync(PdfFactory.CreateMinimal());
return mock;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
Loading