feat: dreport-service + dreport-ffi + nuget packages
Some checks failed
CI / rust (push) Failing after 40s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m45s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped

Extract orchestration (font registry + render pipeline) from the Axum
backend into a standalone dreport-service crate. Backend becomes a thin
HTTP adapter on top.

Add dreport-ffi (cdylib) exposing the service through a stable C ABI
with opaque handles, byte buffers, and thread-local error reporting.

Build Dreport.Service + Dreport.AspNetCore NuGet packages under
bindings/dotnet/, packing the host RID native binary via a generated
nuspec. justfile recipes (nuget-publish, nuget-publish-all) build,
pack, and push to the Gitea NuGet registry in one shot.

Test coverage: 47 Rust + 38 C# (xUnit + WebApplicationFactory).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 16:19:47 +03:00
parent 92583141c9
commit 2db5929e39
44 changed files with 3377 additions and 252 deletions

View File

@@ -0,0 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Dreport.AspNetCore/Dreport.AspNetCore.csproj" />
<Project Path="src/Dreport.Service/Dreport.Service.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Dreport.AspNetCore.Tests/Dreport.AspNetCore.Tests.csproj" />
<Project Path="tests/Dreport.Service.Tests/Dreport.Service.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Dreport.Service\Dreport.Service.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using System.Text.Json;
using Dreport.Service;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace Dreport.AspNetCore;
/// <summary>
/// Optional sugar for hosts that just want the editor's stock HTTP API.
/// Mirrors the original Rust/Axum backend endpoint contract 1:1, so the Vue
/// editor frontend does not need any code changes.
///
/// Skip this entirely if you prefer to wire endpoints by hand — the
/// <see cref="LayoutEngine"/> registered by <c>AddDreport()</c> is fully
/// usable from your own controllers / minimal API handlers.
/// </summary>
public static class DreportEndpointRouteBuilderExtensions
{
/// <summary>
/// Mount the dreport HTTP API under the given prefix (defaults to <c>/api</c>).
/// Routes added:
/// <list type="bullet">
/// <item><description><c>GET {prefix}/health</c></description></item>
/// <item><description><c>POST {prefix}/render</c> — body <c>{ template, data }</c> → <c>application/pdf</c></description></item>
/// <item><description><c>POST {prefix}/layout</c> — body <c>{ template, data }</c> → LayoutResult JSON</description></item>
/// <item><description><c>GET {prefix}/fonts</c> — registered families</description></item>
/// <item><description><c>GET {prefix}/fonts/{family}/{weight}/{italic}</c> — raw font bytes</description></item>
/// </list>
/// </summary>
public static IEndpointRouteBuilder MapDreportEndpoints(
this IEndpointRouteBuilder builder,
string prefix = "/api")
{
var p = prefix.TrimEnd('/');
builder.MapGet($"{p}/health", () => Results.Json(new { status = "ok", version = typeof(LayoutEngine).Assembly.GetName().Version?.ToString() ?? "unknown" }));
builder.MapPost($"{p}/render", async (HttpContext ctx, LayoutEngine engine) =>
{
var (template, data) = await ReadBodyAsync(ctx);
try
{
var pdf = await Task.Run(() => engine.RenderPdf(template, data));
return Results.File(pdf, "application/pdf");
}
catch (DreportException ex)
{
return MapError(ex);
}
});
builder.MapPost($"{p}/layout", async (HttpContext ctx, LayoutEngine engine) =>
{
var (template, data) = await ReadBodyAsync(ctx);
try
{
var layoutJson = await Task.Run(() => engine.ComputeLayout(template, data));
return Results.Content(layoutJson, "application/json");
}
catch (DreportException ex)
{
return MapError(ex);
}
});
builder.MapGet($"{p}/fonts", (LayoutEngine engine) =>
Results.Json(engine.ListFontFamilies().Select(f => new
{
family = f.Family,
variants = f.Variants.Select(v => new { weight = v.Weight, italic = v.Italic }),
})));
builder.MapGet($"{p}/fonts/{{family}}/{{weight}}/{{italic}}",
(string family, ushort weight, string italic, LayoutEngine engine) =>
{
var isItalic = italic.Equals("true", StringComparison.OrdinalIgnoreCase) || italic == "1";
var bytes = engine.GetFontBytes(family, weight, isItalic);
return bytes is null
? Results.NotFound($"Font bulunamadı: {family} weight={weight} italic={isItalic}")
: Results.File(bytes, "font/ttf");
});
return builder;
}
private static async Task<(string Template, string Data)> ReadBodyAsync(HttpContext ctx)
{
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
var root = doc.RootElement;
var template = root.GetProperty("template").GetRawText();
var data = root.TryGetProperty("data", out var d) ? d.GetRawText() : "{}";
return (template, data);
}
private static IResult MapError(DreportException ex) => ex switch
{
InvalidTemplateException or Dreport.Service.InvalidDataException => Results.BadRequest(ex.Message),
_ => Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError),
};
}

View File

@@ -0,0 +1,20 @@
namespace Dreport.AspNetCore;
/// <summary>
/// Configuration for the dreport ASP.NET Core integration.
/// </summary>
public sealed class DreportOptions
{
/// <summary>
/// Optional directory whose <c>.ttf</c> / <c>.otf</c> files are loaded into the
/// engine on startup, in addition to the embedded default fonts.
/// </summary>
public string? FontsDirectory { get; set; }
/// <summary>
/// When <c>true</c> (default), embedded default fonts (Noto Sans, Noto Sans Mono)
/// are registered. Set to <c>false</c> to start with an empty registry — useful
/// when the host wants to provide a fully custom font set.
/// </summary>
public bool LoadEmbeddedFonts { get; set; } = true;
}

View File

@@ -0,0 +1,41 @@
using Dreport.Service;
using Microsoft.Extensions.DependencyInjection;
namespace Dreport.AspNetCore;
/// <summary>
/// DI registration for <see cref="LayoutEngine"/>. Registers the engine as a
/// process-wide singleton so consumers can inject it into controllers,
/// endpoint handlers, background services, or test fixtures.
/// </summary>
public static class DreportServiceCollectionExtensions
{
/// <summary>
/// Registers a singleton <see cref="LayoutEngine"/>. Once added, you can:
/// <list type="bullet">
/// <item><description>Inject <see cref="LayoutEngine"/> into your own MVC controllers, minimal API handlers, or background services.</description></item>
/// <item><description>Call <c>app.MapDreportEndpoints()</c> to also mount the ready-made HTTP API the editor talks to.</description></item>
/// </list>
/// </summary>
public static IServiceCollection AddDreport(
this IServiceCollection services,
Action<DreportOptions>? configure = null)
{
var options = new DreportOptions();
configure?.Invoke(options);
services.AddSingleton(options);
services.AddSingleton(_ => CreateEngine(options));
return services;
}
private static LayoutEngine CreateEngine(DreportOptions options)
{
var engine = options.LoadEmbeddedFonts ? new LayoutEngine() : LayoutEngine.CreateEmpty();
if (!string.IsNullOrEmpty(options.FontsDirectory) && Directory.Exists(options.FontsDirectory))
{
engine.RegisterFontsDirectory(options.FontsDirectory);
}
return engine;
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<InternalsVisibleTo Include="Dreport.Service.Tests" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Packaging is driven from a hand-rolled .nuspec next to this csproj
(see pack.nuspec). MSBuild's pack pipeline silently drops the runtimes/
folder under several scenarios we hit during development; hand-feeding
nuget pack a nuspec sidesteps the issue and is what the just recipe uses. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<!-- Local-dev consumer copy: drop the host RID native binary next to the
referencing assembly so xUnit / dotnet run can dlopen it without going
through a published NuGet package. -->
<PropertyGroup>
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('OSX'))">.dylib</_DrHostExt>
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Linux'))">.so</_DrHostExt>
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Windows'))">.dll</_DrHostExt>
<_DrHostPrefix Condition="!$([MSBuild]::IsOSPlatform('Windows'))">lib</_DrHostPrefix>
</PropertyGroup>
<ItemGroup>
<Content Include="$(MSBuildThisFileDirectory)runtimes/**/native/$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
Link="$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
CopyToOutputDirectory="PreserveNewest"
Visible="false"
Pack="false" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
namespace Dreport.Service;
/// <summary>
/// Thrown when the underlying dreport service returns an error. The numeric
/// <see cref="Code"/> mirrors the FFI return code (negative values).
/// </summary>
public class DreportException : Exception
{
public int Code { get; }
public DreportException(int code, string message) : base(message)
{
Code = code;
}
internal static DreportException FromCode(int code, string fallbackMessage)
{
var nativeMessage = Native.GetLastError();
var message = string.IsNullOrEmpty(nativeMessage) ? fallbackMessage : nativeMessage;
return code switch
{
Native.ERR_INVALID_TEMPLATE_JSON => new InvalidTemplateException(message),
Native.ERR_INVALID_DATA_JSON => new InvalidDataException(message),
Native.ERR_FONT_PARSE_FAILED => new FontParseException(message),
Native.ERR_FONT_DIR_NOT_FOUND => new FontDirectoryNotFoundException(message),
Native.ERR_FONT_DIR_READ => new FontDirectoryReadException(message),
Native.ERR_LAYOUT_FAILED => new LayoutException(message),
Native.ERR_PDF_FAILED => new PdfRenderException(message),
_ => new DreportException(code, message),
};
}
}
public sealed class InvalidTemplateException : DreportException
{
public InvalidTemplateException(string message) : base(Native.ERR_INVALID_TEMPLATE_JSON, message) { }
}
public sealed class InvalidDataException : DreportException
{
public InvalidDataException(string message) : base(Native.ERR_INVALID_DATA_JSON, message) { }
}
public sealed class FontParseException : DreportException
{
public FontParseException(string message) : base(Native.ERR_FONT_PARSE_FAILED, message) { }
}
public sealed class FontDirectoryNotFoundException : DreportException
{
public FontDirectoryNotFoundException(string message) : base(Native.ERR_FONT_DIR_NOT_FOUND, message) { }
}
public sealed class FontDirectoryReadException : DreportException
{
public FontDirectoryReadException(string message) : base(Native.ERR_FONT_DIR_READ, message) { }
}
public sealed class LayoutException : DreportException
{
public LayoutException(string message) : base(Native.ERR_LAYOUT_FAILED, message) { }
}
public sealed class PdfRenderException : DreportException
{
public PdfRenderException(string message) : base(Native.ERR_PDF_FAILED, message) { }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Dreport.Service;
/// <summary>One font family with its registered variants.</summary>
public sealed record FontFamily(
[property: JsonPropertyName("family")] string Family,
[property: JsonPropertyName("variants")] IReadOnlyList<FontVariant> Variants);
/// <summary>One weight/italic combination within a family.</summary>
public sealed record FontVariant(
[property: JsonPropertyName("weight")] ushort Weight,
[property: JsonPropertyName("italic")] bool Italic);

View File

@@ -0,0 +1,220 @@
using System.Text;
using System.Text.Json;
namespace Dreport.Service;
/// <summary>
/// Managed wrapper around a single dreport native engine handle.
///
/// Thread-safe: every operation goes through the underlying Rust service which
/// uses internal locking. You can keep one process-wide instance and call
/// concurrent <see cref="RenderPdf"/> from any number of threads.
/// </summary>
public sealed class LayoutEngine : IDisposable
{
private IntPtr _handle;
private readonly object _disposeLock = new();
private bool _disposed;
/// <summary>Create an engine with the embedded default fonts loaded.</summary>
public LayoutEngine() : this(Native.dreport_new())
{
}
private LayoutEngine(IntPtr handle)
{
if (handle == IntPtr.Zero)
{
throw new InvalidOperationException("dreport_new returned a null handle");
}
_handle = handle;
}
/// <summary>Create an engine with no fonts pre-loaded.</summary>
public static LayoutEngine CreateEmpty() => new(Native.dreport_new_empty());
/// <summary>Native crate version, e.g. "0.2.0".</summary>
public static string NativeVersion
{
get
{
var ptr = Native.dreport_version();
return ptr == IntPtr.Zero ? string.Empty : System.Runtime.InteropServices.Marshal.PtrToStringAnsi(ptr) ?? string.Empty;
}
}
// ---------------------------------------------------------------------
// Font registry
// ---------------------------------------------------------------------
/// <summary>Number of distinct font families currently registered.</summary>
public int FontFamilyCount
{
get
{
EnsureNotDisposed();
var count = Native.dreport_font_family_count(_handle);
if (count < 0)
{
throw DreportException.FromCode((int)count, "dreport_font_family_count failed");
}
return (int)count;
}
}
/// <summary>Register a font from raw TTF/OTF bytes.</summary>
public unsafe void RegisterFont(ReadOnlySpan<byte> data)
{
EnsureNotDisposed();
if (data.IsEmpty)
{
throw new ArgumentException("font bytes empty", nameof(data));
}
fixed (byte* ptr = data)
{
var rc = Native.dreport_register_font(_handle, ptr, (nuint)data.Length);
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_register_font failed");
}
}
}
/// <summary>Register every <c>.ttf</c>/<c>.otf</c> file in <paramref name="directory"/>.</summary>
/// <returns>Number of fonts that registered successfully.</returns>
public unsafe int RegisterFontsDirectory(string directory)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(directory);
var bytes = Encoding.UTF8.GetBytes(directory);
nuint count;
int rc;
fixed (byte* ptr = bytes)
{
rc = Native.dreport_register_fonts_dir(_handle, ptr, (nuint)bytes.Length, out count);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, $"dreport_register_fonts_dir failed for '{directory}'");
}
return (int)count;
}
/// <summary>Get raw bytes for a specific font variant. Returns null when unknown.</summary>
public unsafe byte[]? GetFontBytes(string family, ushort weight, bool italic)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(family);
var bytes = Encoding.UTF8.GetBytes(family);
Native.DreportBuffer buffer;
int rc;
fixed (byte* ptr = bytes)
{
rc = Native.dreport_get_font_bytes(_handle, ptr, (nuint)bytes.Length, weight, italic, out buffer);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_get_font_bytes failed");
}
var data = Native.ConsumeBuffer(buffer);
return data.Length == 0 ? null : data;
}
/// <summary>List every registered font family with its variants.</summary>
public IReadOnlyList<FontFamily> ListFontFamilies()
{
EnsureNotDisposed();
var rc = Native.dreport_list_fonts_json(_handle, out var buffer);
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_list_fonts_json failed");
}
var json = Native.ConsumeBuffer(buffer);
if (json.Length == 0)
{
return Array.Empty<FontFamily>();
}
var families = JsonSerializer.Deserialize<List<FontFamily>>(json);
return families ?? new List<FontFamily>();
}
// ---------------------------------------------------------------------
// Render pipeline
// ---------------------------------------------------------------------
/// <summary>Compute layout from JSON inputs. Returns the LayoutResult JSON string.</summary>
public unsafe string ComputeLayout(string templateJson, string dataJson)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(templateJson);
ArgumentNullException.ThrowIfNull(dataJson);
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
Native.DreportBuffer buffer;
int rc;
fixed (byte* tplPtr = tplBytes)
fixed (byte* dataPtr = dataBytes)
{
rc = Native.dreport_compute_layout(
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_compute_layout failed");
}
return Encoding.UTF8.GetString(Native.ConsumeBuffer(buffer));
}
/// <summary>Render a PDF document. Returns the raw PDF bytes.</summary>
public unsafe byte[] RenderPdf(string templateJson, string dataJson)
{
EnsureNotDisposed();
ArgumentException.ThrowIfNullOrEmpty(templateJson);
ArgumentNullException.ThrowIfNull(dataJson);
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
Native.DreportBuffer buffer;
int rc;
fixed (byte* tplPtr = tplBytes)
fixed (byte* dataPtr = dataBytes)
{
rc = Native.dreport_render_pdf(
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
}
if (rc != Native.OK)
{
throw DreportException.FromCode(rc, "dreport_render_pdf failed");
}
return Native.ConsumeBuffer(buffer);
}
// ---------------------------------------------------------------------
// Disposal
// ---------------------------------------------------------------------
public void Dispose()
{
lock (_disposeLock)
{
if (_disposed) return;
_disposed = true;
if (_handle != IntPtr.Zero)
{
Native.dreport_free(_handle);
_handle = IntPtr.Zero;
}
}
}
private void EnsureNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(LayoutEngine));
}
}
}

View File

@@ -0,0 +1,145 @@
// P/Invoke surface for libdreport_ffi. Mirrors dreport-ffi/include/dreport.h
// 1:1. Higher-level wrappers live in LayoutEngine.cs.
using System.Runtime.InteropServices;
namespace Dreport.Service;
internal static class Native
{
// The shared library is named libdreport_ffi.{dylib,so} or dreport_ffi.dll.
// .NET's runtime resolves it via the runtimes/<rid>/native/ pattern when the
// package is consumed; for local development the file lives next to the test
// assembly under bin/<config>/<tfm>/runtimes/<rid>/native/.
internal const string Lib = "dreport_ffi";
// ----- Return codes (mirror dreport_ffi::error_code) -------------------
public const int OK = 0;
public const int NULL_HANDLE = -100;
public const int NULL_POINTER = -101;
public const int INVALID_UTF8 = -102;
public const int PANIC = -103;
// Service-level error codes are returned as the negation of
// ServiceError::code(), e.g. FontParseFailed (3) → -3.
public const int ERR_INVALID_TEMPLATE_JSON = -1;
public const int ERR_INVALID_DATA_JSON = -2;
public const int ERR_FONT_PARSE_FAILED = -3;
public const int ERR_FONT_DIR_NOT_FOUND = -4;
public const int ERR_FONT_DIR_READ = -5;
public const int ERR_LAYOUT_FAILED = -6;
public const int ERR_PDF_FAILED = -7;
public const int ERR_SERIALIZATION_FAILED = -8;
// ----- ByteBuffer ------------------------------------------------------
[StructLayout(LayoutKind.Sequential)]
public struct DreportBuffer
{
public IntPtr Data;
public nuint Len;
public nuint Cap;
public static DreportBuffer Empty => default;
}
// ----- Lifecycle -------------------------------------------------------
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr dreport_new();
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr dreport_new_empty();
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern void dreport_free(IntPtr handle);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern void dreport_buffer_free(DreportBuffer buffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr dreport_version();
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int dreport_last_error(out DreportBuffer buffer);
// ----- Font registry ---------------------------------------------------
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_register_font(IntPtr handle, byte* data, nuint len);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_register_fonts_dir(
IntPtr handle,
byte* path,
nuint pathLen,
out nuint outCount);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int dreport_list_fonts_json(IntPtr handle, out DreportBuffer outBuffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_get_font_bytes(
IntPtr handle,
byte* family,
nuint familyLen,
ushort weight,
[MarshalAs(UnmanagedType.U1)] bool italic,
out DreportBuffer outBuffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern nint dreport_font_family_count(IntPtr handle);
// ----- Render pipeline -------------------------------------------------
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_compute_layout(
IntPtr handle,
byte* template_,
nuint templateLen,
byte* data,
nuint dataLen,
out DreportBuffer outBuffer);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int dreport_render_pdf(
IntPtr handle,
byte* template_,
nuint templateLen,
byte* data,
nuint dataLen,
out DreportBuffer outBuffer);
// ----- Helpers ---------------------------------------------------------
/// <summary>Copy a native buffer into a managed byte[] and free the native side.</summary>
public static byte[] ConsumeBuffer(DreportBuffer buffer)
{
if (buffer.Data == IntPtr.Zero || buffer.Len == 0)
{
// Still free the buffer in case cap > 0 (defensive — current FFI never returns this).
if (buffer.Cap > 0)
{
dreport_buffer_free(buffer);
}
return Array.Empty<byte>();
}
var bytes = new byte[buffer.Len];
Marshal.Copy(buffer.Data, bytes, 0, (int)buffer.Len);
dreport_buffer_free(buffer);
return bytes;
}
/// <summary>Read the most recent FFI error message for the current thread.</summary>
public static string GetLastError()
{
if (dreport_last_error(out var buffer) != OK)
{
return string.Empty;
}
var bytes = ConsumeBuffer(buffer);
return bytes.Length == 0 ? string.Empty : System.Text.Encoding.UTF8.GetString(bytes);
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Dreport.AspNetCore\Dreport.AspNetCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,173 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Dreport.AspNetCore;
using Dreport.Service;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace Dreport.AspNetCore.Tests;
/// <summary>
/// Spins up an in-memory ASP.NET Core host for each test using TestServer
/// directly, so we don't need a Program.cs entry point. Verifies the
/// stock /api endpoints behave the same as the original Axum backend.
/// </summary>
public class EndpointTests
{
private const string Template = """
{
"id": "aspnet-test",
"name": "AspNet Test",
"page": { "width": 210, "height": 297 },
"fonts": ["Noto Sans"],
"root": {
"id": "root",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"direction": "column",
"gap": 5,
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
"align": "stretch",
"justify": "start",
"style": {},
"children": [
{
"id": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 14, "fontWeight": "bold" },
"content": "Hello"
}
]
}
}
""";
private static HttpClient Build(string prefix = "/api")
{
var builder = new WebHostBuilder()
.ConfigureServices(s => s.AddRouting().AddDreport())
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => e.MapDreportEndpoints(prefix));
});
var server = new TestServer(builder);
return server.CreateClient();
}
[Fact]
public async Task Health_Returns_Ok()
{
var client = Build();
var resp = await client.GetAsync("/api/health");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("ok", json.GetProperty("status").GetString());
}
[Fact]
public async Task Render_ReturnsPdf()
{
var client = Build();
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
var resp = await client.PostAsJsonAsync("/api/render", payload);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("application/pdf", resp.Content.Headers.ContentType?.MediaType);
var bytes = await resp.Content.ReadAsByteArrayAsync();
Assert.True(bytes.Length > 100);
Assert.Equal((byte)'%', bytes[0]);
}
[Fact]
public async Task Render_InvalidTemplate_Returns400()
{
var client = Build();
var payload = new { template = "not a template", data = new { } };
var resp = await client.PostAsJsonAsync("/api/render", payload);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Layout_ReturnsJson()
{
var client = Build();
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
var resp = await client.PostAsJsonAsync("/api/layout", payload);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("pages", out var pages));
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
}
[Fact]
public async Task ListFonts_IncludesNotoSans()
{
var client = Build();
var resp = await client.GetAsync("/api/fonts");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var families = await resp.Content.ReadFromJsonAsync<JsonElement[]>();
Assert.NotNull(families);
Assert.Contains(families!, f => f.GetProperty("family").GetString()!.ToLowerInvariant().Contains("noto"));
}
[Fact]
public async Task GetFontBytes_KnownVariant_ReturnsTtf()
{
var client = Build();
var resp = await client.GetAsync("/api/fonts/Noto%20Sans/400/false");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("font/ttf", resp.Content.Headers.ContentType?.MediaType);
var bytes = await resp.Content.ReadAsByteArrayAsync();
Assert.True(bytes.Length > 1000);
}
[Fact]
public async Task GetFontBytes_Unknown_Returns404()
{
var client = Build();
var resp = await client.GetAsync("/api/fonts/DoesNotExist/400/false");
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
[Fact]
public async Task CustomPrefix_RemapsAllEndpoints()
{
var client = Build("/dreport/api");
var resp = await client.GetAsync("/dreport/api/health");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var oldRoute = await client.GetAsync("/api/health");
Assert.Equal(HttpStatusCode.NotFound, oldRoute.StatusCode);
}
[Fact]
public async Task ManualUsage_LayoutEngine_FromDi()
{
// Sanity: AddDreport without MapDreportEndpoints still hands the engine
// out via DI so users can plug it into their own controllers.
var builder = new WebHostBuilder()
.ConfigureServices(s => s.AddRouting().AddDreport())
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => e.MapGet("/custom",
(LayoutEngine engine) => Results.Json(new { count = engine.FontFamilyCount })));
});
using var server = new TestServer(builder);
var client = server.CreateClient();
var resp = await client.GetAsync("/custom");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.GetProperty("count").GetInt32() >= 1);
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- Tests run on the host RID; ensure native dylibs ship next to the test asm. -->
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Dreport.Service\Dreport.Service.csproj" />
</ItemGroup>
<!-- Sample TTF for FontParseException tests; copied from the workspace assets dir. -->
<ItemGroup>
<None Include="..\..\..\..\dreport-service\assets\fonts\NotoSans-Regular.ttf"
Link="fixtures/NotoSans-Regular.ttf"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,236 @@
using System.Text.Json;
using Dreport.Service;
using Xunit;
namespace Dreport.Service.Tests;
public class LayoutEngineTests
{
private const string Template = """
{
"id": "csharp",
"name": "C# Test",
"page": { "width": 210, "height": 297 },
"fonts": ["Noto Sans"],
"root": {
"id": "root",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"direction": "column",
"gap": 5,
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
"align": "stretch",
"justify": "start",
"style": {},
"children": [
{
"id": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 14, "fontWeight": "bold" },
"content": "Hello from C#"
}
]
}
}
""";
private const string Data = "{}";
private static byte[] LoadFixtureFont() =>
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "NotoSans-Regular.ttf"));
// ---------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------
[Fact]
public void Construct_DefaultEngine_HasEmbeddedFonts()
{
using var engine = new LayoutEngine();
Assert.True(engine.FontFamilyCount >= 1);
}
[Fact]
public void CreateEmpty_StartsWithoutFonts()
{
using var engine = LayoutEngine.CreateEmpty();
Assert.Equal(0, engine.FontFamilyCount);
}
[Fact]
public void NativeVersion_ReturnsNonEmpty()
{
var v = LayoutEngine.NativeVersion;
Assert.False(string.IsNullOrEmpty(v));
Assert.Contains('.', v);
}
[Fact]
public void Dispose_TwiceIsSafe()
{
var engine = new LayoutEngine();
engine.Dispose();
engine.Dispose();
}
[Fact]
public void Operations_AfterDispose_Throw()
{
var engine = new LayoutEngine();
engine.Dispose();
Assert.Throws<ObjectDisposedException>(() => engine.RenderPdf(Template, Data));
Assert.Throws<ObjectDisposedException>(() => engine.ListFontFamilies());
}
// ---------------------------------------------------------------------
// Font registry
// ---------------------------------------------------------------------
[Fact]
public void RegisterFont_ValidBytes_IncreasesCount()
{
using var engine = LayoutEngine.CreateEmpty();
engine.RegisterFont(LoadFixtureFont());
Assert.Equal(1, engine.FontFamilyCount);
}
[Fact]
public void RegisterFont_InvalidBytes_ThrowsFontParseException()
{
using var engine = LayoutEngine.CreateEmpty();
var ex = Assert.Throws<FontParseException>(() =>
engine.RegisterFont(new byte[] { 1, 2, 3, 4 }));
Assert.Equal(Native.ERR_FONT_PARSE_FAILED, ex.Code);
}
[Fact]
public void RegisterFontsDirectory_NonExisting_ThrowsFontDirectoryNotFound()
{
using var engine = LayoutEngine.CreateEmpty();
Assert.Throws<FontDirectoryNotFoundException>(() =>
engine.RegisterFontsDirectory("/no/such/dreport/test/path/xyz"));
}
[Fact]
public void RegisterFontsDirectory_ValidDir_LoadsCount()
{
var fixturesDir = Path.Combine(AppContext.BaseDirectory, "fixtures");
Assert.True(Directory.Exists(fixturesDir));
using var engine = LayoutEngine.CreateEmpty();
var count = engine.RegisterFontsDirectory(fixturesDir);
Assert.True(count >= 1);
}
[Fact]
public void GetFontBytes_KnownVariant_ReturnsBytes()
{
using var engine = new LayoutEngine();
var bytes = engine.GetFontBytes("Noto Sans", 400, false);
Assert.NotNull(bytes);
Assert.True(bytes!.Length > 1000);
}
[Fact]
public void GetFontBytes_UnknownVariant_ReturnsNull()
{
using var engine = new LayoutEngine();
Assert.Null(engine.GetFontBytes("DoesNotExist", 400, false));
}
[Fact]
public void ListFontFamilies_ContainsNotoSans()
{
using var engine = new LayoutEngine();
var families = engine.ListFontFamilies();
Assert.Contains(families, f => f.Family.ToLowerInvariant().Contains("noto"));
}
// ---------------------------------------------------------------------
// Render pipeline
// ---------------------------------------------------------------------
[Fact]
public void ComputeLayout_ValidInputs_ReturnsLayoutJson()
{
using var engine = new LayoutEngine();
var json = engine.ComputeLayout(Template, Data);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("pages", out var pages));
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
Assert.True(pages.GetArrayLength() >= 1);
}
[Fact]
public void ComputeLayout_InvalidTemplate_ThrowsInvalidTemplate()
{
using var engine = new LayoutEngine();
Assert.Throws<InvalidTemplateException>(() => engine.ComputeLayout("{not json", Data));
}
[Fact]
public void ComputeLayout_InvalidData_ThrowsInvalidData()
{
using var engine = new LayoutEngine();
Assert.Throws<InvalidDataException>(() => engine.ComputeLayout(Template, "{not json"));
}
[Fact]
public void RenderPdf_ValidInputs_ReturnsPdfBytes()
{
using var engine = new LayoutEngine();
var pdf = engine.RenderPdf(Template, Data);
Assert.True(pdf.Length > 100);
Assert.Equal((byte)'%', pdf[0]);
Assert.Equal((byte)'P', pdf[1]);
Assert.Equal((byte)'D', pdf[2]);
Assert.Equal((byte)'F', pdf[3]);
}
[Fact]
public void RenderPdf_InvalidTemplate_ThrowsInvalidTemplate()
{
using var engine = new LayoutEngine();
Assert.Throws<InvalidTemplateException>(() => engine.RenderPdf("{not json", Data));
}
// ---------------------------------------------------------------------
// Concurrency
// ---------------------------------------------------------------------
[Fact]
public void RenderPdf_Parallel_ProducesPdfs()
{
using var engine = new LayoutEngine();
var success = 0;
Parallel.For(0, 16, _ =>
{
var pdf = engine.RenderPdf(Template, Data);
if (pdf.Length > 100 && pdf[0] == (byte)'%')
{
Interlocked.Increment(ref success);
}
});
Assert.Equal(16, success);
}
// ---------------------------------------------------------------------
// Error code stability (matches Rust ServiceError::code() contract)
// ---------------------------------------------------------------------
[Fact]
public void ErrorCode_InvalidTemplate_IsMinusOne()
{
var ex = new InvalidTemplateException("x");
Assert.Equal(-1, ex.Code);
}
[Fact]
public void ErrorCode_FontParseFailed_IsMinusThree()
{
var ex = new FontParseException("x");
Assert.Equal(-3, ex.Code);
}
}