mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
feat: dreport-service + dreport-ffi + nuget packages
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:
436
dreport-ffi/tests/ffi.rs
Normal file
436
dreport-ffi/tests/ffi.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
//! Integration tests that drive the C ABI directly. These tests treat the FFI
|
||||
//! crate exactly the way a foreign-language host (NuGet, P/Invoke) would —
|
||||
//! through opaque pointers, byte buffers, and return codes. They are the
|
||||
//! contract test suite for non-Rust consumers.
|
||||
|
||||
use dreport_ffi::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
|
||||
const TEMPLATE: &str = r#"{
|
||||
"id": "ffi",
|
||||
"name": "FFI 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": "FFI"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
const DATA: &str = "{}";
|
||||
const NOTO_SANS_REGULAR: &[u8] =
|
||||
include_bytes!("../../dreport-service/assets/fonts/NotoSans-Regular.ttf");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small RAII wrappers around the raw FFI types so each test stays terse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Handle(*mut DreportHandle);
|
||||
impl Handle {
|
||||
fn new() -> Self {
|
||||
let h = dreport_new();
|
||||
assert!(!h.is_null(), "dreport_new must succeed");
|
||||
Self(h)
|
||||
}
|
||||
fn empty() -> Self {
|
||||
let h = dreport_new_empty();
|
||||
assert!(!h.is_null());
|
||||
Self(h)
|
||||
}
|
||||
}
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
unsafe { dreport_free(self.0) };
|
||||
}
|
||||
}
|
||||
// SAFETY: Underlying handle wraps an Arc<DreportService>; the service is Sync.
|
||||
unsafe impl Send for Handle {}
|
||||
unsafe impl Sync for Handle {}
|
||||
|
||||
struct OwnedBuffer(DreportBuffer);
|
||||
impl OwnedBuffer {
|
||||
fn empty() -> Self {
|
||||
Self(DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
})
|
||||
}
|
||||
fn as_slice(&self) -> &[u8] {
|
||||
if self.0.data.is_null() {
|
||||
&[]
|
||||
} else {
|
||||
unsafe { std::slice::from_raw_parts(self.0.data, self.0.len) }
|
||||
}
|
||||
}
|
||||
fn as_str(&self) -> &str {
|
||||
std::str::from_utf8(self.as_slice()).expect("valid utf8")
|
||||
}
|
||||
fn ptr(&mut self) -> *mut DreportBuffer {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl Drop for OwnedBuffer {
|
||||
fn drop(&mut self) {
|
||||
let buf = std::mem::replace(
|
||||
&mut self.0,
|
||||
DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
},
|
||||
);
|
||||
unsafe { dreport_buffer_free(buf) };
|
||||
}
|
||||
}
|
||||
|
||||
fn last_error() -> String {
|
||||
let mut buf = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_last_error(buf.ptr()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
buf.as_str().to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_and_free_round_trips() {
|
||||
let h = dreport_new();
|
||||
assert!(!h.is_null());
|
||||
unsafe { dreport_free(h) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_null_is_safe() {
|
||||
unsafe { dreport_free(std::ptr::null_mut()) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_free_null_is_safe() {
|
||||
unsafe {
|
||||
dreport_buffer_free(DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_returns_valid_c_string() {
|
||||
let ptr = dreport_version();
|
||||
assert!(!ptr.is_null());
|
||||
let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
|
||||
let s = cstr.to_str().unwrap();
|
||||
assert!(!s.is_empty());
|
||||
assert!(s.chars().next().unwrap().is_ascii_digit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_default_handle_has_fonts() {
|
||||
let h = Handle::new();
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_handle_has_no_fonts() {
|
||||
let h = Handle::empty();
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Null-handle guard rails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn null_handle_returns_null_handle_code() {
|
||||
let mut buf = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_list_fonts_json(std::ptr::null(), buf.ptr()) };
|
||||
assert_eq!(rc, error_code::NULL_HANDLE);
|
||||
assert!(!last_error().is_empty(), "error message must be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_handle_count_returns_negative() {
|
||||
let count = unsafe { dreport_font_family_count(std::ptr::null()) };
|
||||
assert!(count < 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_with_null_template_returns_null_pointer_code() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::NULL_POINTER);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn register_font_valid_bytes() {
|
||||
let h = Handle::empty();
|
||||
let rc = unsafe { dreport_register_font(h.0, NOTO_SANS_REGULAR.as_ptr(), NOTO_SANS_REGULAR.len()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(unsafe { dreport_font_family_count(h.0) } >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_font_invalid_bytes_returns_negative_service_code() {
|
||||
let h = Handle::empty();
|
||||
let garbage = b"not a font";
|
||||
let rc = unsafe { dreport_register_font(h.0, garbage.as_ptr(), garbage.len()) };
|
||||
assert_eq!(rc, -3, "ServiceError::FontParseFailed code is 3 → -3 over FFI");
|
||||
let msg = last_error();
|
||||
assert!(msg.to_lowercase().contains("font"), "error msg: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_dir_invalid_path_sets_error() {
|
||||
let h = Handle::empty();
|
||||
let path = "/zzz/no/such/dreport/path";
|
||||
let mut out_count: usize = 0;
|
||||
let rc = unsafe {
|
||||
dreport_register_fonts_dir(h.0, path.as_ptr(), path.len(), &mut out_count)
|
||||
};
|
||||
assert!(rc < 0);
|
||||
assert_eq!(out_count, 0);
|
||||
assert!(!last_error().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_dir_valid_path_loads_count() {
|
||||
let h = Handle::empty();
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../dreport-service/assets/fonts");
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mut out_count: usize = 0;
|
||||
let rc = unsafe {
|
||||
dreport_register_fonts_dir(h.0, path_str.as_ptr(), path_str.len(), &mut out_count)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
assert!(out_count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_fonts_json_is_valid_array() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_list_fonts_json(h.0, out.ptr()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
assert!(!parsed.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_existing_returns_data() {
|
||||
let h = Handle::new();
|
||||
let family = "Noto Sans";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||
};
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(out.as_slice().len() > 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_missing_returns_ok_with_empty_buffer() {
|
||||
let h = Handle::new();
|
||||
let family = "DoesNotExist";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||
};
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(out.as_slice().is_empty());
|
||||
assert!(out.0.data.is_null());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn compute_layout_round_trip() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_compute_layout(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||
assert!(parsed["pages"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pdf_returns_pdf_magic_header() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
let bytes = out.as_slice();
|
||||
assert!(bytes.starts_with(b"%PDF-"), "missing magic header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_with_invalid_template_json_sets_error() {
|
||||
let h = Handle::new();
|
||||
let bad = b"{not json";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
bad.as_ptr(),
|
||||
bad.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, -1, "ServiceError::InvalidTemplateJson → -1");
|
||||
assert!(!last_error().is_empty());
|
||||
assert!(out.as_slice().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn concurrent_independent_handles() {
|
||||
let success = Arc::new(AtomicUsize::new(0));
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..6 {
|
||||
let s = Arc::clone(&success);
|
||||
threads.push(thread::spawn(move || {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
if rc == error_code::OK && out.as_slice().starts_with(b"%PDF-") {
|
||||
s.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(success.load(Ordering::SeqCst), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_shared_handle() {
|
||||
// The handle itself is owned by one thread, but the underlying service is
|
||||
// an Arc<DreportService>, so internally a shared engine is fine. To test
|
||||
// the most realistic NuGet scenario (one process-wide engine) we instead
|
||||
// create per-thread handles backed by parallel `dreport_new` calls.
|
||||
let success = Arc::new(AtomicUsize::new(0));
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let s = Arc::clone(&success);
|
||||
threads.push(thread::spawn(move || {
|
||||
for _ in 0..4 {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
s.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(success.load(Ordering::SeqCst), 16);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last-error semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn successful_call_clears_previous_error() {
|
||||
let h = Handle::new();
|
||||
|
||||
// Provoke an error first.
|
||||
let rc = unsafe { dreport_register_font(h.0, b"x".as_ptr(), 1) };
|
||||
assert!(rc < 0);
|
||||
assert!(!last_error().is_empty());
|
||||
|
||||
// A subsequent successful call must clear it.
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert!(count >= 1);
|
||||
assert!(
|
||||
last_error().is_empty(),
|
||||
"successful call should clear last_error"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user