Skip to main content

allframe_forge/templates/
bff.rs

1//! BFF (Backend for Frontend) archetype templates
2//!
3//! Templates for generating API aggregation services that combine
4//! multiple backend services into a unified API for specific frontends.
5
6use crate::config::ProjectConfig;
7
8/// Convert a string to PascalCase
9fn 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
21/// Generate Cargo.toml for BFF project
22pub 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
97/// Generate main.rs
98pub 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
202/// Generate config.rs
203pub 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
298/// Generate error.rs
299pub 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
362/// Generate domain/mod.rs
363pub 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
375/// Generate domain/models.rs
376pub 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
436/// Generate domain/aggregates.rs
437pub 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
488/// Generate application/mod.rs
489pub fn application_mod(_config: &ProjectConfig) -> String {
490    r#"//! Application layer
491
492pub mod aggregator;
493
494pub use aggregator::*;
495"#
496    .to_string()
497}
498
499/// Generate application/aggregator.rs
500pub 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
636/// Generate infrastructure/mod.rs
637pub 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
651/// Generate infrastructure/clients.rs
652pub 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
826/// Generate infrastructure/cache.rs
827pub 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
877/// Generate infrastructure/health.rs
878pub 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
918/// Generate presentation/mod.rs
919pub 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
943/// Generate presentation/handlers.rs
944pub 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
1031/// Generate presentation/graphql.rs (if GraphQL is enabled)
1032pub 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
1161/// Generate README.md
1162pub 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
1289/// Generate Dockerfile
1290pub 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}