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

@@ -5,12 +5,14 @@ edition = "2024"
publish = false
[dependencies]
dreport-core = { path = "../core" }
dreport-layout = { path = "../layout-engine" }
dreport-service = { path = "../dreport-service" }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
thiserror = "2"
anyhow = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

Binary file not shown.

Binary file not shown.

Binary file not shown.

18
backend/src/app.rs Normal file
View File

@@ -0,0 +1,18 @@
//! Application bootstrap. Builds a fully-configured `DreportService` for the
//! HTTP layer (and tests) to share.
use anyhow::Result;
use dreport_service::DreportService;
/// Construct the service used by the running server. Loads embedded fonts
/// (compile-time defaults) and any extra fonts in `DREPORT_FONTS_DIR`.
pub fn build_service() -> Result<DreportService> {
let svc = DreportService::new();
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
match svc.register_fonts_directory(&dir) {
Ok(n) => println!("DREPORT_FONTS_DIR'den {} font yüklendi: {}", n, dir),
Err(e) => eprintln!("DREPORT_FONTS_DIR yüklenemedi ({}): {}", dir, e),
}
}
Ok(svc)
}

View File

@@ -1,180 +0,0 @@
use dreport_layout::FontData;
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
use dreport_layout::font_provider::FontProvider;
use std::collections::HashMap;
/// Font registry — manages all available fonts from embedded defaults + external directory.
pub struct FontRegistry {
/// family_lower -> variant_key -> FontData
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
/// Original-case family names
family_names: HashMap<String, String>,
}
impl FontRegistry {
pub fn new() -> Self {
let mut registry = Self {
families: HashMap::new(),
family_names: HashMap::new(),
};
// Load embedded default fonts
registry.load_embedded_defaults();
// Load fonts from DREPORT_FONTS_DIR if set
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
registry.load_from_directory(&dir);
}
registry
}
fn load_embedded_defaults(&mut self) {
let embedded: &[(&str, &[u8])] = &[
(
"NotoSans-Regular",
include_bytes!("../fonts/NotoSans-Regular.ttf"),
),
(
"NotoSans-Bold",
include_bytes!("../fonts/NotoSans-Bold.ttf"),
),
(
"NotoSans-Italic",
include_bytes!("../fonts/NotoSans-Italic.ttf"),
),
(
"NotoSans-BoldItalic",
include_bytes!("../fonts/NotoSans-BoldItalic.ttf"),
),
(
"NotoSansMono-Regular",
include_bytes!("../fonts/NotoSansMono-Regular.ttf"),
),
];
for (_name, data) in embedded {
self.register_font(data.to_vec());
}
}
fn load_from_directory(&mut self, dir: &str) {
let path = std::path::Path::new(dir);
if !path.is_dir() {
eprintln!("DREPORT_FONTS_DIR dizini bulunamadı: {}", dir);
return;
}
let entries = match std::fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
eprintln!("DREPORT_FONTS_DIR okunamadı: {}", e);
return;
}
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().is_some_and(|e| e == "ttf" || e == "otf")
&& let Ok(data) = std::fs::read(&p)
{
if self.register_font(data) {
println!(" Font yüklendi: {}", p.display());
} else {
eprintln!(" Font parse edilemedi: {}", p.display());
}
}
}
}
/// Register a font from raw bytes. Returns true if successful.
fn register_font(&mut self, data: Vec<u8>) -> bool {
let Some(meta) = font_meta::parse_font_meta(&data) else {
return false;
};
let family_lower = meta.family.to_lowercase();
let variant_key = meta.variant_key();
self.family_names
.entry(family_lower.clone())
.or_insert_with(|| meta.family.clone());
let font_data = FontData::new(meta.family, meta.weight, meta.italic, data);
self.families
.entry(family_lower)
.or_default()
.insert(variant_key, font_data);
true
}
/// Get a specific font's raw bytes
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<&[u8]> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.map(|fd| fd.data.as_slice())
}
/// Get all FontData for given family names (for passing to layout engine)
pub fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
let mut result = Vec::new();
let mut loaded = std::collections::HashSet::new();
// Always include default family
let default_lower = "noto sans".to_string();
let mut to_load: Vec<String> = vec![default_lower.clone()];
for f in families {
let fl = f.to_lowercase();
if !to_load.contains(&fl) {
to_load.push(fl);
}
}
for family_lower in &to_load {
if loaded.contains(family_lower) {
continue;
}
if let Some(variants) = self.families.get(family_lower) {
for fd in variants.values() {
result.push(fd.clone());
}
loaded.insert(family_lower.clone());
}
}
result
}
}
impl FontProvider for FontRegistry {
fn list_families(&self) -> Vec<FontFamilyInfo> {
self.families
.iter()
.map(|(family_lower, variants)| {
let family = self
.family_names
.get(family_lower)
.cloned()
.unwrap_or_else(|| family_lower.clone());
FontFamilyInfo {
family,
variants: variants.keys().cloned().collect(),
}
})
.collect()
}
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
let family_lower = family.to_lowercase();
let key = FontVariantKey { weight, italic };
self.families
.get(&family_lower)
.and_then(|variants| variants.get(&key))
.cloned()
}
}

28
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,28 @@
//! dreport-backend
//!
//! Thin Axum HTTP adapter on top of `dreport-service`. The HTTP layer holds
//! no business logic — it only translates JSON requests into service calls
//! and maps `ServiceError` into HTTP status codes.
pub mod app;
mod routes;
use axum::Router;
use dreport_service::DreportService;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
pub use routes::AppState;
/// Build the full Axum `Router` with CORS, state and all `/api/*` endpoints.
pub fn build_router(service: Arc<DreportService>) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.merge(routes::router())
.layer(cors)
.with_state(service)
}

View File

@@ -1,36 +1,19 @@
use axum::{Router, serve};
use dreport_backend::{app, build_router};
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
mod font_registry;
mod models;
mod routes;
use font_registry::FontRegistry;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("Font registry başlatılıyor...");
let registry = Arc::new(FontRegistry::new());
let family_count =
dreport_layout::font_provider::FontProvider::list_families(registry.as_ref()).len();
println!("Font registry hazır ({} font ailesi)", family_count);
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.merge(routes::router())
.layer(cors)
.with_state(registry);
let service = Arc::new(app::build_service()?);
println!(
"dreport-service hazır ({} font ailesi)",
service.font_family_count()
);
let app = build_router(service);
let listener = TcpListener::bind("0.0.0.0:3001").await?;
println!("dreport backend listening on http://localhost:3001");
serve(listener, app).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -1 +0,0 @@
pub use dreport_core::models::*;

View File

@@ -5,11 +5,9 @@ use axum::{
response::IntoResponse,
routing::get,
};
use dreport_layout::font_provider::FontProvider;
use serde::Serialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
use super::AppState;
#[derive(Serialize)]
struct FontFamilyResponse {
@@ -24,9 +22,9 @@ struct FontVariantResponse {
}
/// GET /api/fonts — list all available font families
async fn list_fonts(State(registry): State<Arc<FontRegistry>>) -> Json<Vec<FontFamilyResponse>> {
let families = registry.list_families();
let response: Vec<FontFamilyResponse> = families
async fn list_fonts(State(service): State<AppState>) -> Json<Vec<FontFamilyResponse>> {
let response: Vec<FontFamilyResponse> = service
.list_font_families()
.into_iter()
.map(|f| FontFamilyResponse {
family: f.family,
@@ -45,16 +43,16 @@ async fn list_fonts(State(registry): State<Arc<FontRegistry>>) -> Json<Vec<FontF
/// GET /api/fonts/:family/:weight/:italic — serve font binary
async fn get_font(
State(registry): State<Arc<FontRegistry>>,
State(service): State<AppState>,
Path((family, weight, italic)): Path<(String, u16, String)>,
) -> impl IntoResponse {
let is_italic = italic == "true" || italic == "1";
match registry.get_font_bytes(&family, weight, is_italic) {
match service.get_font_bytes(&family, weight, is_italic) {
Some(data) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "font/ttf")],
data.to_vec(),
data,
)
.into_response(),
None => (
@@ -68,7 +66,7 @@ async fn get_font(
}
}
pub fn router() -> Router<Arc<FontRegistry>> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/fonts", get(list_fonts))
.route("/api/fonts/{family}/{weight}/{italic}", get(get_font))

View File

@@ -1,8 +1,7 @@
use axum::{Json, Router, routing::get};
use serde::Serialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
use super::AppState;
#[derive(Serialize)]
struct HealthResponse {
@@ -17,6 +16,6 @@ async fn health() -> Json<HealthResponse> {
})
}
pub fn router() -> Router<Arc<FontRegistry>> {
pub fn router() -> Router<AppState> {
Router::new().route("/api/health", get(health))
}

View File

@@ -3,11 +3,12 @@ mod health;
mod render;
use axum::Router;
use dreport_service::DreportService;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
pub type AppState = Arc<DreportService>;
pub fn router() -> Router<Arc<FontRegistry>> {
pub fn router() -> Router<AppState> {
Router::new()
.merge(health::router())
.merge(render::router())

View File

@@ -5,11 +5,10 @@ use axum::{
response::IntoResponse,
routing::post,
};
use dreport_service::{ServiceError, Template};
use serde::Deserialize;
use std::sync::Arc;
use crate::font_registry::FontRegistry;
use crate::models::Template;
use super::AppState;
#[derive(Deserialize)]
pub struct RenderRequest {
@@ -19,18 +18,13 @@ pub struct RenderRequest {
/// POST /api/render — Template + Data → PDF
pub async fn render(
State(registry): State<Arc<FontRegistry>>,
State(service): State<AppState>,
Json(payload): Json<RenderRequest>,
) -> impl IntoResponse {
// CPU-intensive layout + PDF render'ı blocking thread'de çalıştır
let result = tokio::task::spawn_blocking(move || {
// Template'in fonts alanına göre sadece gerekli fontları yükle
let fonts = registry.fonts_for_families(&payload.template.fonts);
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts)
.map_err(|e| format!("Layout error: {}", e))?;
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
})
.await;
let result =
tokio::task::spawn_blocking(move || service.render_pdf(&payload.template, &payload.data))
.await;
match result {
Ok(Ok(pdf_bytes)) => (
@@ -39,11 +33,7 @@ pub async fn render(
pdf_bytes,
)
.into_response(),
Ok(Err(err)) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("PDF render hatası: {}", err),
)
.into_response(),
Ok(Err(err)) => (status_for(&err), err.to_string()).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Task hatası: {}", err),
@@ -52,6 +42,15 @@ pub async fn render(
}
}
pub fn router() -> Router<Arc<FontRegistry>> {
fn status_for(err: &ServiceError) -> StatusCode {
match err {
ServiceError::InvalidTemplateJson(_) | ServiceError::InvalidDataJson(_) => {
StatusCode::BAD_REQUEST
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn router() -> Router<AppState> {
Router::new().route("/api/render", post(render))
}

179
backend/tests/api.rs Normal file
View File

@@ -0,0 +1,179 @@
//! End-to-end HTTP tests for the backend. Drives the real `Router` via
//! `tower::ServiceExt::oneshot`, so anything covered here protects the
//! contract that the editor and external clients rely on.
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use dreport_backend::build_router;
use dreport_service::DreportService;
use http_body_util::BodyExt;
use std::sync::Arc;
use tower::ServiceExt;
const TEMPLATE: &str = r#"{
"id": "test",
"name": "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"
}
]
}
}"#;
fn router() -> axum::Router {
build_router(Arc::new(DreportService::new()))
}
async fn body_bytes(resp: axum::response::Response) -> Vec<u8> {
resp.into_body().collect().await.unwrap().to_bytes().to_vec()
}
#[tokio::test]
async fn health_returns_ok() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert!(json["version"].is_string());
}
#[tokio::test]
async fn list_fonts_includes_noto_sans() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/fonts")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let families: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert!(
families
.iter()
.any(|f| f["family"].as_str().unwrap_or("").to_lowercase().contains("noto")),
"Noto Sans family should be listed: {:?}",
families
);
}
#[tokio::test]
async fn get_font_bytes_for_known_variant() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/fonts/Noto%20Sans/400/false")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap()),
Some("font/ttf")
);
let body = body_bytes(resp).await;
assert!(body.len() > 1000, "TTF body should be substantial");
}
#[tokio::test]
async fn get_font_unknown_returns_404() {
let resp = router()
.oneshot(
Request::builder()
.uri("/api/fonts/DoesNotExist/400/false")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn render_returns_pdf_bytes() {
let payload = serde_json::json!({
"template": serde_json::from_str::<serde_json::Value>(TEMPLATE).unwrap(),
"data": {}
});
let resp = router()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/render")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap()),
Some("application/pdf")
);
let body = body_bytes(resp).await;
assert!(body.starts_with(b"%PDF-"), "PDF magic header missing");
}
#[tokio::test]
async fn render_with_invalid_template_field_returns_4xx_or_500() {
// Axum's Json extractor rejects malformed payloads with 4xx; a structurally
// valid but semantically invalid template would surface as 500. Either is
// acceptable, but the server must not panic and must produce a body.
let payload = serde_json::json!({ "template": "not an object", "data": {} });
let resp = router()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/render")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_client_error() || resp.status().is_server_error(),
"got unexpected status {}",
resp.status()
);
}