mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49: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:
18
backend/src/app.rs
Normal file
18
backend/src/app.rs
Normal 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)
|
||||
}
|
||||
@@ -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
28
backend/src/lib.rs
Normal 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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub use dreport_core::models::*;
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user