1use crate::config::ProjectConfig;
7
8fn to_pascal_case(s: &str) -> String {
10 s.split(|c| c == '-' || c == '_')
11 .map(|word| {
12 let mut chars = word.chars();
13 match chars.next() {
14 None => String::new(),
15 Some(first) => first.to_uppercase().chain(chars).collect(),
16 }
17 })
18 .collect()
19}
20
21pub fn cargo_toml(config: &ProjectConfig) -> String {
23 let bff = config.bff.as_ref().unwrap();
24 let name = &config.name;
25
26 let graphql_deps = if bff.graphql_enabled {
27 r#"
28# GraphQL
29async-graphql = { version = "7.0", features = ["dataloader", "uuid", "chrono"] }
30async-graphql-axum = "7.0""#
31 } else {
32 ""
33 };
34
35 format!(
36 r#"[package]
37name = "{name}"
38version = "0.1.0"
39edition = "2021"
40rust-version = "1.89"
41description = "{display_name}"
42
43[dependencies]
44# AllFrame
45allframe-core = {{ version = "0.1", features = ["resilience", "otel"] }}
46
47# Web Framework
48axum = "0.7"
49tower = "0.5"
50tower-http = {{ version = "0.6", features = ["trace", "cors", "compression-gzip"] }}
51{graphql_deps}
52
53# HTTP Client
54reqwest = {{ version = "0.12", features = ["json", "rustls-tls"] }}
55
56# Async
57tokio = {{ version = "1", features = ["full"] }}
58async-trait = "0.1"
59futures = "0.3"
60
61# Serialization
62serde = {{ version = "1.0", features = ["derive"] }}
63serde_json = "1.0"
64
65# Caching
66moka = {{ version = "0.12", features = ["future"] }}
67
68# Error handling
69thiserror = "2.0"
70anyhow = "1.0"
71
72# Tracing & Metrics
73tracing = "0.1"
74tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
75opentelemetry = {{ version = "0.27", features = ["metrics"] }}
76
77# Utilities
78chrono = {{ version = "0.4", features = ["serde"] }}
79uuid = {{ version = "1.0", features = ["v4", "serde"] }}
80dotenvy = "0.15"
81
82[dev-dependencies]
83tokio-test = "0.4"
84mockall = "0.13"
85wiremock = "0.6"
86
87[[bin]]
88name = "{name}"
89path = "src/main.rs"
90"#,
91 name = name,
92 display_name = bff.display_name,
93 graphql_deps = graphql_deps,
94 )
95}
96
97pub fn main_rs(config: &ProjectConfig) -> String {
99 let bff = config.bff.as_ref().unwrap();
100 let pascal_name = to_pascal_case(&bff.service_name);
101
102 let graphql_setup = if bff.graphql_enabled {
103 r#"
104 // Create GraphQL schema
105 let schema = presentation::create_graphql_schema(aggregator.clone());
106"#
107 } else {
108 ""
109 };
110
111 let graphql_routes = if bff.graphql_enabled {
112 r#"
113 .nest("/graphql", presentation::graphql_routes(schema))"#
114 } else {
115 ""
116 };
117
118 format!(
119 r#"//! {display_name}
120//!
121//! A Backend for Frontend service that aggregates multiple backend APIs
122//! into a unified interface optimized for {frontend_type:?} clients.
123
124use std::sync::Arc;
125use tracing::info;
126
127mod config;
128mod error;
129mod domain;
130mod application;
131mod infrastructure;
132mod presentation;
133
134use config::Config;
135use application::{pascal_name}Aggregator;
136use infrastructure::{{
137 BackendClients,
138 CacheService,
139 HealthServer,
140}};
141
142#[tokio::main]
143async fn main() -> anyhow::Result<()> {{
144 // Load environment variables
145 dotenvy::dotenv().ok();
146
147 // Initialize tracing
148 tracing_subscriber::fmt()
149 .with_env_filter(
150 tracing_subscriber::EnvFilter::from_default_env()
151 .add_directive(tracing::Level::INFO.into()),
152 )
153 .init();
154
155 // Load configuration
156 let config = Config::from_env();
157 info!("Starting {display_name}");
158 info!("Backend services: {{:?}}", config.backends.keys().collect::<Vec<_>>());
159
160 // Create cache service
161 let cache = Arc::new(CacheService::new(&config.cache));
162
163 // Create backend clients
164 let clients = Arc::new(BackendClients::new(&config.backends).await?);
165
166 // Create aggregator service
167 let aggregator = Arc::new({pascal_name}Aggregator::new(
168 clients.clone(),
169 cache.clone(),
170 ));
171{graphql_setup}
172 // Start health server in background
173 let health_port = config.server.health_port;
174 let health_handle = tokio::spawn(async move {{
175 let health_server = HealthServer::new(health_port);
176 health_server.run().await
177 }});
178
179 // Create router
180 let app = presentation::create_router(aggregator.clone()){graphql_routes};
181
182 // Start API server
183 info!("Starting API server on port {{}}", config.server.port);
184 let listener = tokio::net::TcpListener::bind(
185 format!("0.0.0.0:{{}}", config.server.port)
186 ).await?;
187 axum::serve(listener, app).await?;
188
189 health_handle.abort();
190 info!("BFF shutdown complete");
191 Ok(())
192}}
193"#,
194 pascal_name = pascal_name,
195 display_name = bff.display_name,
196 frontend_type = bff.frontend_type,
197 graphql_setup = graphql_setup,
198 graphql_routes = graphql_routes,
199 )
200}
201
202pub fn config_rs(config: &ProjectConfig) -> String {
204 let bff = config.bff.as_ref().unwrap();
205
206 format!(
207 r#"//! Service configuration
208
209use std::collections::HashMap;
210use std::env;
211
212/// Main configuration
213#[derive(Debug, Clone)]
214pub struct Config {{
215 pub server: ServerConfig,
216 pub backends: HashMap<String, BackendConfig>,
217 pub cache: CacheConfig,
218}}
219
220/// Server configuration
221#[derive(Debug, Clone)]
222pub struct ServerConfig {{
223 pub port: u16,
224 pub health_port: u16,
225}}
226
227/// Backend service configuration
228#[derive(Debug, Clone)]
229pub struct BackendConfig {{
230 pub base_url: String,
231 pub timeout_ms: u64,
232 pub circuit_breaker: bool,
233}}
234
235/// Cache configuration
236#[derive(Debug, Clone)]
237pub struct CacheConfig {{
238 pub max_capacity: u64,
239 pub ttl_secs: u64,
240}}
241
242impl Config {{
243 pub fn from_env() -> Self {{
244 let mut backends = HashMap::new();
245
246 // Default backend configuration
247 backends.insert(
248 "api".to_string(),
249 BackendConfig {{
250 base_url: env::var("API_BASE_URL")
251 .unwrap_or_else(|_| "{default_backend_url}".to_string()),
252 timeout_ms: env::var("API_TIMEOUT_MS")
253 .unwrap_or_else(|_| "{default_timeout}".to_string())
254 .parse()
255 .expect("API_TIMEOUT_MS must be a number"),
256 circuit_breaker: true,
257 }},
258 );
259
260 Self {{
261 server: ServerConfig {{
262 port: env::var("PORT")
263 .unwrap_or_else(|_| "{port}".to_string())
264 .parse()
265 .expect("PORT must be a number"),
266 health_port: env::var("HEALTH_PORT")
267 .unwrap_or_else(|_| "{health_port}".to_string())
268 .parse()
269 .expect("HEALTH_PORT must be a number"),
270 }},
271 backends,
272 cache: CacheConfig {{
273 max_capacity: env::var("CACHE_MAX_CAPACITY")
274 .unwrap_or_else(|_| "10000".to_string())
275 .parse()
276 .expect("CACHE_MAX_CAPACITY must be a number"),
277 ttl_secs: env::var("CACHE_TTL_SECS")
278 .unwrap_or_else(|_| "{cache_ttl}".to_string())
279 .parse()
280 .expect("CACHE_TTL_SECS must be a number"),
281 }},
282 }}
283 }}
284}}
285"#,
286 port = bff.server.http_port,
287 health_port = bff.server.health_port,
288 default_backend_url = bff
289 .backends
290 .first()
291 .map(|b| b.base_url.as_str())
292 .unwrap_or("http://localhost:8080"),
293 default_timeout = bff.backends.first().map(|b| b.timeout_ms).unwrap_or(5000),
294 cache_ttl = bff.cache.public_ttl_secs,
295 )
296}
297
298pub fn error_rs(config: &ProjectConfig) -> String {
300 let bff = config.bff.as_ref().unwrap();
301 let pascal_name = to_pascal_case(&bff.service_name);
302
303 format!(
304 r#"//! Error types
305
306use thiserror::Error;
307
308/// BFF errors
309#[derive(Error, Debug)]
310pub enum {pascal_name}Error {{
311 #[error("Backend service error: {{0}}")]
312 BackendError(String),
313
314 #[error("Aggregation error: {{0}}")]
315 AggregationError(String),
316
317 #[error("Validation error: {{0}}")]
318 Validation(String),
319
320 #[error("Not found: {{0}}")]
321 NotFound(String),
322
323 #[error("HTTP client error: {{0}}")]
324 HttpClient(#[from] reqwest::Error),
325
326 #[error("Serialization error: {{0}}")]
327 Serialization(#[from] serde_json::Error),
328
329 #[error("Internal error: {{0}}")]
330 Internal(String),
331}}
332
333impl {pascal_name}Error {{
334 pub fn status_code(&self) -> axum::http::StatusCode {{
335 use axum::http::StatusCode;
336 match self {{
337 Self::BackendError(_) => StatusCode::BAD_GATEWAY,
338 Self::AggregationError(_) => StatusCode::INTERNAL_SERVER_ERROR,
339 Self::Validation(_) => StatusCode::BAD_REQUEST,
340 Self::NotFound(_) => StatusCode::NOT_FOUND,
341 Self::HttpClient(_) => StatusCode::BAD_GATEWAY,
342 Self::Serialization(_) => StatusCode::BAD_REQUEST,
343 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
344 }}
345 }}
346}}
347
348impl axum::response::IntoResponse for {pascal_name}Error {{
349 fn into_response(self) -> axum::response::Response {{
350 let status = self.status_code();
351 let body = axum::Json(serde_json::json!({{
352 "error": self.to_string()
353 }}));
354 (status, body).into_response()
355 }}
356}}
357"#,
358 pascal_name = pascal_name,
359 )
360}
361
362pub fn domain_mod(_config: &ProjectConfig) -> String {
364 r#"//! Domain layer
365
366pub mod models;
367pub mod aggregates;
368
369pub use models::*;
370pub use aggregates::*;
371"#
372 .to_string()
373}
374
375pub fn domain_models(config: &ProjectConfig) -> String {
377 let bff = config.bff.as_ref().unwrap();
378 let pascal_name = to_pascal_case(&bff.service_name);
379
380 format!(
381 r#"//! Domain models
382
383use chrono::{{DateTime, Utc}};
384use serde::{{Deserialize, Serialize}};
385use uuid::Uuid;
386
387/// User model (aggregated from user service)
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct User {{
390 pub id: Uuid,
391 pub name: String,
392 pub email: String,
393 pub created_at: DateTime<Utc>,
394}}
395
396/// Resource model (example domain model)
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct {pascal_name}Resource {{
399 pub id: Uuid,
400 pub name: String,
401 pub data: serde_json::Value,
402 pub owner: Option<User>,
403 pub created_at: DateTime<Utc>,
404 pub updated_at: DateTime<Utc>,
405}}
406
407/// API response wrapper
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ApiResponse<T> {{
410 pub data: T,
411 pub meta: Option<ResponseMeta>,
412}}
413
414/// Response metadata
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct ResponseMeta {{
417 pub total: Option<i64>,
418 pub page: Option<i32>,
419 pub per_page: Option<i32>,
420}}
421
422/// Paginated list response
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct PaginatedResponse<T> {{
425 pub items: Vec<T>,
426 pub total: i64,
427 pub page: i32,
428 pub per_page: i32,
429 pub total_pages: i32,
430}}
431"#,
432 pascal_name = pascal_name,
433 )
434}
435
436pub fn domain_aggregates(config: &ProjectConfig) -> String {
438 let bff = config.bff.as_ref().unwrap();
439 let pascal_name = to_pascal_case(&bff.service_name);
440
441 format!(
442 r#"//! Aggregated views for frontend
443
444use chrono::{{DateTime, Utc}};
445use serde::{{Deserialize, Serialize}};
446use uuid::Uuid;
447
448use super::{{User, {pascal_name}Resource}};
449
450/// Dashboard aggregate - combines multiple data sources
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct DashboardAggregate {{
453 pub user: User,
454 pub recent_resources: Vec<{pascal_name}Resource>,
455 pub stats: DashboardStats,
456 pub generated_at: DateTime<Utc>,
457}}
458
459/// Dashboard statistics
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct DashboardStats {{
462 pub total_resources: i64,
463 pub resources_this_week: i64,
464 pub active_users: i64,
465}}
466
467/// Detail view aggregate
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct ResourceDetailAggregate {{
470 pub resource: {pascal_name}Resource,
471 pub owner: Option<User>,
472 pub related_resources: Vec<{pascal_name}Resource>,
473}}
474
475/// Search results aggregate
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct SearchResultsAggregate {{
478 pub resources: Vec<{pascal_name}Resource>,
479 pub users: Vec<User>,
480 pub total_results: i64,
481 pub query: String,
482}}
483"#,
484 pascal_name = pascal_name,
485 )
486}
487
488pub fn application_mod(_config: &ProjectConfig) -> String {
490 r#"//! Application layer
491
492pub mod aggregator;
493
494pub use aggregator::*;
495"#
496 .to_string()
497}
498
499pub fn application_aggregator(config: &ProjectConfig) -> String {
501 let bff = config.bff.as_ref().unwrap();
502 let pascal_name = to_pascal_case(&bff.service_name);
503
504 format!(
505 r#"//! Aggregator service
506
507use std::sync::Arc;
508use tracing::{{info, instrument}};
509use uuid::Uuid;
510use chrono::Utc;
511
512use crate::domain::{{
513 User,
514 {pascal_name}Resource,
515 DashboardAggregate,
516 DashboardStats,
517 ResourceDetailAggregate,
518 SearchResultsAggregate,
519 PaginatedResponse,
520}};
521use crate::infrastructure::{{BackendClients, CacheService}};
522use crate::error::{pascal_name}Error;
523
524/// Aggregator service that combines data from multiple backends
525pub struct {pascal_name}Aggregator {{
526 clients: Arc<BackendClients>,
527 cache: Arc<CacheService>,
528}}
529
530impl {pascal_name}Aggregator {{
531 pub fn new(clients: Arc<BackendClients>, cache: Arc<CacheService>) -> Self {{
532 Self {{ clients, cache }}
533 }}
534
535 /// Get dashboard aggregate for a user
536 #[instrument(skip(self))]
537 pub async fn get_dashboard(&self, user_id: Uuid) -> Result<DashboardAggregate, {pascal_name}Error> {{
538 // Check cache first
539 let cache_key = format!("dashboard:{{}}", user_id);
540 if let Some(cached) = self.cache.get::<DashboardAggregate>(&cache_key).await {{
541 info!("Dashboard cache hit for user {{}}", user_id);
542 return Ok(cached);
543 }}
544
545 // Fetch user
546 let user = self.clients.get_user(user_id).await?;
547
548 // Fetch recent resources in parallel
549 let (resources_result, stats_result) = tokio::join!(
550 self.clients.get_user_resources(user_id, 10),
551 self.clients.get_stats()
552 );
553
554 let recent_resources = resources_result?;
555 let stats = stats_result?;
556
557 let aggregate = DashboardAggregate {{
558 user,
559 recent_resources,
560 stats,
561 generated_at: Utc::now(),
562 }};
563
564 // Cache the result
565 self.cache.set(&cache_key, &aggregate).await;
566
567 Ok(aggregate)
568 }}
569
570 /// Get resource detail with related data
571 #[instrument(skip(self))]
572 pub async fn get_resource_detail(&self, resource_id: Uuid) -> Result<ResourceDetailAggregate, {pascal_name}Error> {{
573 let cache_key = format!("resource:{{}}", resource_id);
574 if let Some(cached) = self.cache.get::<ResourceDetailAggregate>(&cache_key).await {{
575 return Ok(cached);
576 }}
577
578 let resource = self.clients.get_resource(resource_id).await?;
579
580 let (owner_result, related_result) = tokio::join!(
581 async {{
582 if let Some(ref owner) = resource.owner {{
583 self.clients.get_user(owner.id).await.ok()
584 }} else {{
585 None
586 }}
587 }},
588 self.clients.get_related_resources(resource_id, 5)
589 );
590
591 let aggregate = ResourceDetailAggregate {{
592 resource,
593 owner: owner_result,
594 related_resources: related_result.unwrap_or_default(),
595 }};
596
597 self.cache.set(&cache_key, &aggregate).await;
598 Ok(aggregate)
599 }}
600
601 /// Search across all resources
602 #[instrument(skip(self))]
603 pub async fn search(&self, query: &str) -> Result<SearchResultsAggregate, {pascal_name}Error> {{
604 let (resources_result, users_result) = tokio::join!(
605 self.clients.search_resources(query),
606 self.clients.search_users(query)
607 );
608
609 let resources = resources_result?;
610 let users = users_result?;
611 let total_results = (resources.len() + users.len()) as i64;
612
613 Ok(SearchResultsAggregate {{
614 resources,
615 users,
616 total_results,
617 query: query.to_string(),
618 }})
619 }}
620
621 /// List resources with pagination
622 #[instrument(skip(self))]
623 pub async fn list_resources(
624 &self,
625 page: i32,
626 per_page: i32,
627 ) -> Result<PaginatedResponse<{pascal_name}Resource>, {pascal_name}Error> {{
628 self.clients.list_resources(page, per_page).await
629 }}
630}}
631"#,
632 pascal_name = pascal_name,
633 )
634}
635
636pub fn infrastructure_mod(_config: &ProjectConfig) -> String {
638 r#"//! Infrastructure layer
639
640pub mod clients;
641pub mod cache;
642pub mod health;
643
644pub use clients::*;
645pub use cache::*;
646pub use health::*;
647"#
648 .to_string()
649}
650
651pub fn infrastructure_clients(config: &ProjectConfig) -> String {
653 let bff = config.bff.as_ref().unwrap();
654 let pascal_name = to_pascal_case(&bff.service_name);
655
656 format!(
657 r#"//! Backend HTTP clients
658
659use std::collections::HashMap;
660use std::time::Duration;
661use reqwest::Client;
662use uuid::Uuid;
663
664use crate::config::BackendConfig;
665use crate::domain::{{
666 User,
667 {pascal_name}Resource,
668 DashboardStats,
669 PaginatedResponse,
670}};
671use crate::error::{pascal_name}Error;
672
673/// Backend clients for all external services
674pub struct BackendClients {{
675 clients: HashMap<String, BackendClient>,
676}}
677
678/// Individual backend client
679struct BackendClient {{
680 client: Client,
681 base_url: String,
682}}
683
684impl BackendClients {{
685 pub async fn new(configs: &HashMap<String, BackendConfig>) -> Result<Self, {pascal_name}Error> {{
686 let mut clients = HashMap::new();
687
688 for (name, config) in configs {{
689 let client = Client::builder()
690 .timeout(Duration::from_millis(config.timeout_ms))
691 .build()
692 .map_err(|e| {pascal_name}Error::Internal(e.to_string()))?;
693
694 clients.insert(name.clone(), BackendClient {{
695 client,
696 base_url: config.base_url.clone(),
697 }});
698 }}
699
700 Ok(Self {{ clients }})
701 }}
702
703 fn get_client(&self, name: &str) -> Result<&BackendClient, {pascal_name}Error> {{
704 self.clients
705 .get(name)
706 .ok_or_else(|| {pascal_name}Error::BackendError(format!("Backend '{{}}' not configured", name)))
707 }}
708
709 pub async fn get_user(&self, user_id: Uuid) -> Result<User, {pascal_name}Error> {{
710 let client = self.get_client("api")?;
711 let url = format!("{{}}/users/{{}}", client.base_url, user_id);
712
713 let response = client.client
714 .get(&url)
715 .send()
716 .await?
717 .error_for_status()
718 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
719
720 response.json().await.map_err(Into::into)
721 }}
722
723 pub async fn get_user_resources(&self, user_id: Uuid, limit: i32) -> Result<Vec<{pascal_name}Resource>, {pascal_name}Error> {{
724 let client = self.get_client("api")?;
725 let url = format!("{{}}/users/{{}}/resources?limit={{}}", client.base_url, user_id, limit);
726
727 let response = client.client
728 .get(&url)
729 .send()
730 .await?
731 .error_for_status()
732 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
733
734 response.json().await.map_err(Into::into)
735 }}
736
737 pub async fn get_resource(&self, resource_id: Uuid) -> Result<{pascal_name}Resource, {pascal_name}Error> {{
738 let client = self.get_client("api")?;
739 let url = format!("{{}}/resources/{{}}", client.base_url, resource_id);
740
741 let response = client.client
742 .get(&url)
743 .send()
744 .await?
745 .error_for_status()
746 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
747
748 response.json().await.map_err(Into::into)
749 }}
750
751 pub async fn get_related_resources(&self, resource_id: Uuid, limit: i32) -> Result<Vec<{pascal_name}Resource>, {pascal_name}Error> {{
752 let client = self.get_client("api")?;
753 let url = format!("{{}}/resources/{{}}/related?limit={{}}", client.base_url, resource_id, limit);
754
755 let response = client.client
756 .get(&url)
757 .send()
758 .await?
759 .error_for_status()
760 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
761
762 response.json().await.map_err(Into::into)
763 }}
764
765 pub async fn get_stats(&self) -> Result<DashboardStats, {pascal_name}Error> {{
766 let client = self.get_client("api")?;
767 let url = format!("{{}}/stats", client.base_url);
768
769 let response = client.client
770 .get(&url)
771 .send()
772 .await?
773 .error_for_status()
774 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
775
776 response.json().await.map_err(Into::into)
777 }}
778
779 pub async fn search_resources(&self, query: &str) -> Result<Vec<{pascal_name}Resource>, {pascal_name}Error> {{
780 let client = self.get_client("api")?;
781 let url = format!("{{}}/resources/search?q={{}}", client.base_url, query);
782
783 let response = client.client
784 .get(&url)
785 .send()
786 .await?
787 .error_for_status()
788 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
789
790 response.json().await.map_err(Into::into)
791 }}
792
793 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, {pascal_name}Error> {{
794 let client = self.get_client("api")?;
795 let url = format!("{{}}/users/search?q={{}}", client.base_url, query);
796
797 let response = client.client
798 .get(&url)
799 .send()
800 .await?
801 .error_for_status()
802 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
803
804 response.json().await.map_err(Into::into)
805 }}
806
807 pub async fn list_resources(&self, page: i32, per_page: i32) -> Result<PaginatedResponse<{pascal_name}Resource>, {pascal_name}Error> {{
808 let client = self.get_client("api")?;
809 let url = format!("{{}}/resources?page={{}}&per_page={{}}", client.base_url, page, per_page);
810
811 let response = client.client
812 .get(&url)
813 .send()
814 .await?
815 .error_for_status()
816 .map_err(|e| {pascal_name}Error::BackendError(e.to_string()))?;
817
818 response.json().await.map_err(Into::into)
819 }}
820}}
821"#,
822 pascal_name = pascal_name,
823 )
824}
825
826pub fn infrastructure_cache(_config: &ProjectConfig) -> String {
828 r#"//! Cache service
829
830use moka::future::Cache;
831use serde::{de::DeserializeOwned, Serialize};
832use std::time::Duration;
833
834use crate::config::CacheConfig;
835
836/// Cache service using moka
837pub struct CacheService {
838 cache: Cache<String, String>,
839}
840
841impl CacheService {
842 pub fn new(config: &CacheConfig) -> Self {
843 let cache = Cache::builder()
844 .max_capacity(config.max_capacity)
845 .time_to_live(Duration::from_secs(config.ttl_secs))
846 .build();
847
848 Self { cache }
849 }
850
851 pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
852 let value = self.cache.get(key).await?;
853 serde_json::from_str(&value).ok()
854 }
855
856 pub async fn set<T: Serialize>(&self, key: &str, value: &T) {
857 if let Ok(serialized) = serde_json::to_string(value) {
858 self.cache.insert(key.to_string(), serialized).await;
859 }
860 }
861
862 pub async fn invalidate(&self, key: &str) {
863 self.cache.invalidate(key).await;
864 }
865
866 pub async fn invalidate_prefix(&self, prefix: &str) {
867 // Note: This is a simple implementation. For production,
868 // consider using a more efficient invalidation strategy.
869 self.cache.invalidate_all();
870 let _ = prefix; // Suppress unused warning
871 }
872}
873"#
874 .to_string()
875}
876
877pub fn infrastructure_health(_config: &ProjectConfig) -> String {
879 r#"//! Health check server
880
881use axum::{routing::get, Router};
882use tracing::info;
883
884/// Health check server for Kubernetes probes
885pub struct HealthServer {
886 port: u16,
887}
888
889impl HealthServer {
890 pub fn new(port: u16) -> Self {
891 Self { port }
892 }
893
894 pub async fn run(&self) -> Result<(), std::io::Error> {
895 let app = Router::new()
896 .route("/health", get(health))
897 .route("/ready", get(ready));
898
899 let addr = format!("0.0.0.0:{}", self.port);
900 info!("Health server listening on {}", addr);
901
902 let listener = tokio::net::TcpListener::bind(&addr).await?;
903 axum::serve(listener, app).await
904 }
905}
906
907async fn health() -> &'static str {
908 "OK"
909}
910
911async fn ready() -> &'static str {
912 "OK"
913}
914"#
915 .to_string()
916}
917
918pub fn presentation_mod(config: &ProjectConfig) -> String {
920 let bff = config.bff.as_ref().unwrap();
921
922 if bff.graphql_enabled {
923 r#"//! Presentation layer (HTTP API + GraphQL)
924
925pub mod handlers;
926pub mod graphql;
927
928pub use handlers::*;
929pub use graphql::*;
930"#
931 .to_string()
932 } else {
933 r#"//! Presentation layer (HTTP API)
934
935pub mod handlers;
936
937pub use handlers::*;
938"#
939 .to_string()
940 }
941}
942
943pub fn presentation_handlers(config: &ProjectConfig) -> String {
945 let bff = config.bff.as_ref().unwrap();
946 let pascal_name = to_pascal_case(&bff.service_name);
947
948 format!(
949 r#"//! HTTP API handlers
950
951use std::sync::Arc;
952use axum::{{
953 extract::{{Path, Query, State}},
954 routing::get,
955 Json, Router,
956}};
957use serde::Deserialize;
958use uuid::Uuid;
959
960use crate::application::{pascal_name}Aggregator;
961use crate::domain::{{
962 DashboardAggregate,
963 ResourceDetailAggregate,
964 SearchResultsAggregate,
965 PaginatedResponse,
966 {pascal_name}Resource,
967}};
968use crate::error::{pascal_name}Error;
969
970type AppState = Arc<{pascal_name}Aggregator>;
971
972/// Create the REST API router
973pub fn create_router(aggregator: Arc<{pascal_name}Aggregator>) -> Router {{
974 Router::new()
975 .route("/api/dashboard/:user_id", get(get_dashboard))
976 .route("/api/resources", get(list_resources))
977 .route("/api/resources/:id", get(get_resource_detail))
978 .route("/api/search", get(search))
979 .with_state(aggregator)
980}}
981
982#[derive(Debug, Deserialize)]
983struct PaginationQuery {{
984 page: Option<i32>,
985 per_page: Option<i32>,
986}}
987
988#[derive(Debug, Deserialize)]
989struct SearchQuery {{
990 q: String,
991}}
992
993async fn get_dashboard(
994 State(aggregator): State<AppState>,
995 Path(user_id): Path<Uuid>,
996) -> Result<Json<DashboardAggregate>, {pascal_name}Error> {{
997 let dashboard = aggregator.get_dashboard(user_id).await?;
998 Ok(Json(dashboard))
999}}
1000
1001async fn list_resources(
1002 State(aggregator): State<AppState>,
1003 Query(query): Query<PaginationQuery>,
1004) -> Result<Json<PaginatedResponse<{pascal_name}Resource>>, {pascal_name}Error> {{
1005 let page = query.page.unwrap_or(1);
1006 let per_page = query.per_page.unwrap_or(20);
1007 let resources = aggregator.list_resources(page, per_page).await?;
1008 Ok(Json(resources))
1009}}
1010
1011async fn get_resource_detail(
1012 State(aggregator): State<AppState>,
1013 Path(id): Path<Uuid>,
1014) -> Result<Json<ResourceDetailAggregate>, {pascal_name}Error> {{
1015 let detail = aggregator.get_resource_detail(id).await?;
1016 Ok(Json(detail))
1017}}
1018
1019async fn search(
1020 State(aggregator): State<AppState>,
1021 Query(query): Query<SearchQuery>,
1022) -> Result<Json<SearchResultsAggregate>, {pascal_name}Error> {{
1023 let results = aggregator.search(&query.q).await?;
1024 Ok(Json(results))
1025}}
1026"#,
1027 pascal_name = pascal_name,
1028 )
1029}
1030
1031pub fn presentation_graphql(config: &ProjectConfig) -> String {
1033 let bff = config.bff.as_ref().unwrap();
1034 let pascal_name = to_pascal_case(&bff.service_name);
1035
1036 format!(
1037 r#"//! GraphQL API
1038
1039use std::sync::Arc;
1040use async_graphql::{{Context, EmptySubscription, Object, Schema, SimpleObject}};
1041use async_graphql::http::GraphiQLSource;
1042use async_graphql_axum::GraphQL;
1043use axum::{{
1044 routing::get,
1045 response::{{Html, IntoResponse}},
1046 Router,
1047}};
1048use uuid::Uuid;
1049
1050use crate::application::{pascal_name}Aggregator;
1051use crate::domain::{{
1052 User as DomainUser,
1053 {pascal_name}Resource as DomainResource,
1054 DashboardAggregate as DomainDashboard,
1055}};
1056
1057/// GraphQL User type
1058#[derive(SimpleObject)]
1059struct User {{
1060 id: Uuid,
1061 name: String,
1062 email: String,
1063}}
1064
1065impl From<DomainUser> for User {{
1066 fn from(u: DomainUser) -> Self {{
1067 Self {{
1068 id: u.id,
1069 name: u.name,
1070 email: u.email,
1071 }}
1072 }}
1073}}
1074
1075/// GraphQL Resource type
1076#[derive(SimpleObject)]
1077struct Resource {{
1078 id: Uuid,
1079 name: String,
1080}}
1081
1082impl From<DomainResource> for Resource {{
1083 fn from(r: DomainResource) -> Self {{
1084 Self {{
1085 id: r.id,
1086 name: r.name,
1087 }}
1088 }}
1089}}
1090
1091/// GraphQL Dashboard type
1092#[derive(SimpleObject)]
1093struct Dashboard {{
1094 user: User,
1095 recent_resources: Vec<Resource>,
1096 total_resources: i64,
1097}}
1098
1099impl From<DomainDashboard> for Dashboard {{
1100 fn from(d: DomainDashboard) -> Self {{
1101 Self {{
1102 user: d.user.into(),
1103 recent_resources: d.recent_resources.into_iter().map(Into::into).collect(),
1104 total_resources: d.stats.total_resources,
1105 }}
1106 }}
1107}}
1108
1109/// GraphQL Query root
1110pub struct QueryRoot;
1111
1112#[Object]
1113impl QueryRoot {{
1114 async fn dashboard(&self, ctx: &Context<'_>, user_id: Uuid) -> async_graphql::Result<Dashboard> {{
1115 let aggregator = ctx.data::<Arc<{pascal_name}Aggregator>>()?;
1116 let dashboard = aggregator.get_dashboard(user_id).await?;
1117 Ok(dashboard.into())
1118 }}
1119
1120 async fn resource(&self, ctx: &Context<'_>, id: Uuid) -> async_graphql::Result<Resource> {{
1121 let aggregator = ctx.data::<Arc<{pascal_name}Aggregator>>()?;
1122 let detail = aggregator.get_resource_detail(id).await?;
1123 Ok(detail.resource.into())
1124 }}
1125}}
1126
1127/// GraphQL Mutation root
1128pub struct MutationRoot;
1129
1130#[Object]
1131impl MutationRoot {{
1132 async fn invalidate_cache(&self, _key: String) -> bool {{
1133 // Placeholder for cache invalidation
1134 true
1135 }}
1136}}
1137
1138pub type {pascal_name}Schema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
1139
1140/// Create the GraphQL schema
1141pub fn create_graphql_schema(aggregator: Arc<{pascal_name}Aggregator>) -> {pascal_name}Schema {{
1142 Schema::build(QueryRoot, MutationRoot, EmptySubscription)
1143 .data(aggregator)
1144 .finish()
1145}}
1146
1147/// Create GraphQL routes
1148pub fn graphql_routes(schema: {pascal_name}Schema) -> Router {{
1149 Router::new()
1150 .route("/", get(graphiql).post_service(GraphQL::new(schema)))
1151}}
1152
1153async fn graphiql() -> impl IntoResponse {{
1154 Html(GraphiQLSource::build().endpoint("/graphql").finish())
1155}}
1156"#,
1157 pascal_name = pascal_name,
1158 )
1159}
1160
1161pub fn readme(config: &ProjectConfig) -> String {
1163 let bff = config.bff.as_ref().unwrap();
1164 let name = &config.name;
1165
1166 let graphql_section = if bff.graphql_enabled {
1167 r#"
1168## GraphQL API
1169
1170Access the GraphQL playground at `http://localhost:8080/graphql`
1171
1172### Example Query
1173
1174```graphql
1175query {
1176 dashboard(userId: "550e8400-e29b-41d4-a716-446655440000") {
1177 user {
1178 name
1179 email
1180 }
1181 recentResources {
1182 id
1183 name
1184 }
1185 totalResources
1186 }
1187}
1188```
1189"#
1190 } else {
1191 ""
1192 };
1193
1194 format!(
1195 r#"# {display_name}
1196
1197A Backend for Frontend (BFF) service built with AllFrame that aggregates multiple backend APIs into a unified interface optimized for {frontend_type:?} clients.
1198
1199## Features
1200
1201- **API Aggregation**: Combines multiple backend services into unified endpoints
1202- **Caching**: In-memory caching with Moka for improved performance
1203- **Circuit Breaker**: Resilient backend communication
1204- **REST API**: Clean REST endpoints for frontend consumption{graphql_feature}
1205- **Health Checks**: Kubernetes-ready liveness and readiness probes
1206- **OpenTelemetry**: Distributed tracing and metrics
1207
1208## Prerequisites
1209
1210- Rust 1.75+
1211- Backend services running
1212
1213## Configuration
1214
1215Set the following environment variables:
1216
1217```bash
1218# Server
1219PORT=8080
1220HEALTH_PORT=8081
1221
1222# Backend Services
1223API_BASE_URL=http://localhost:8000
1224API_TIMEOUT_MS=5000
1225
1226# Cache
1227CACHE_MAX_CAPACITY=10000
1228CACHE_TTL_SECS=300
1229```
1230
1231## Running
1232
1233```bash
1234# Development
1235cargo run
1236
1237# Production
1238cargo build --release
1239./target/release/{name}
1240```
1241
1242## REST API Endpoints
1243
1244| Method | Endpoint | Description |
1245|--------|----------|-------------|
1246| GET | /api/dashboard/:user_id | Get aggregated dashboard data |
1247| GET | /api/resources | List resources with pagination |
1248| GET | /api/resources/:id | Get resource detail with related data |
1249| GET | /api/search?q=query | Search across resources and users |
1250{graphql_section}
1251## Architecture
1252
1253```
1254┌──────────────────────────────────────────────────────────────┐
1255│ BFF Service │
1256│ ┌──────────┐ ┌─────────────┐ ┌────────────────────┐ │
1257│ │ Handlers │───▶│ Aggregator │───▶│ Backend Clients │ │
1258│ └──────────┘ └─────────────┘ └────────────────────┘ │
1259│ │ │ │ │
1260│ │ ▼ │ │
1261│ │ ┌─────────────┐ │ │
1262│ │ │ Cache │ │ │
1263│ │ └─────────────┘ │ │
1264└───────│──────────────────────────────────────│──────────────┘
1265 │ │
1266 ▼ ▼
1267 ┌─────────┐ ┌─────────────┐
1268 │ Frontend│ │ Backend │
1269 │ Client │ │ Services │
1270 └─────────┘ └─────────────┘
1271```
1272
1273## License
1274
1275MIT
1276"#,
1277 display_name = bff.display_name,
1278 name = name,
1279 frontend_type = bff.frontend_type,
1280 graphql_feature = if bff.graphql_enabled {
1281 "\n- **GraphQL**: Full GraphQL API with GraphiQL playground"
1282 } else {
1283 ""
1284 },
1285 graphql_section = graphql_section,
1286 )
1287}
1288
1289pub fn dockerfile(config: &ProjectConfig) -> String {
1291 let name = &config.name;
1292
1293 format!(
1294 r#"FROM rust:1.75-slim as builder
1295
1296WORKDIR /app
1297
1298# Install dependencies
1299RUN apt-get update && apt-get install -y \
1300 pkg-config \
1301 libssl-dev \
1302 && rm -rf /var/lib/apt/lists/*
1303
1304# Copy manifests
1305COPY Cargo.toml Cargo.lock ./
1306
1307# Create dummy main to cache dependencies
1308RUN mkdir src && echo "fn main() {{}}" > src/main.rs
1309RUN cargo build --release
1310RUN rm -rf src
1311
1312# Copy source
1313COPY src ./src
1314
1315# Build
1316RUN touch src/main.rs && cargo build --release
1317
1318# Runtime image
1319FROM debian:bookworm-slim
1320
1321RUN apt-get update && apt-get install -y \
1322 ca-certificates \
1323 libssl3 \
1324 && rm -rf /var/lib/apt/lists/*
1325
1326WORKDIR /app
1327
1328COPY --from=builder /app/target/release/{name} /app/
1329
1330ENV PORT=8080
1331ENV HEALTH_PORT=8081
1332EXPOSE 8080 8081
1333
1334CMD ["/app/{name}"]
1335"#,
1336 name = name,
1337 )
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343
1344 #[test]
1345 fn test_to_pascal_case() {
1346 assert_eq!(to_pascal_case("web_bff"), "WebBff");
1347 assert_eq!(to_pascal_case("mobile-bff"), "MobileBff");
1348 assert_eq!(to_pascal_case("simple"), "Simple");
1349 }
1350}