Line data Source code
1 : #![deny(
2 : clippy::expect_used,
3 : clippy::panic,
4 : clippy::print_stdout,
5 : clippy::todo,
6 : clippy::unimplemented,
7 : clippy::unwrap_used
8 : )]
9 :
10 : use std::sync::Arc;
11 :
12 : use async_graphql::{EmptySubscription, Schema};
13 : use axum::{
14 : http::{header::HeaderValue, Method, StatusCode},
15 : middleware,
16 : response::IntoResponse,
17 : routing::get,
18 : Extension, Router,
19 : };
20 : use std::net::SocketAddr;
21 : use tinycongress_api::{
22 : build_info::BuildInfoProvider,
23 : config::Config,
24 : db::setup_database,
25 : graphql::{graphql_handler, graphql_playground, MutationRoot, QueryRoot},
26 : http::{build_security_headers, security_headers_middleware},
27 : identity::{self, repo::PgAccountRepo},
28 : rest::{self, ApiDoc},
29 : };
30 : use tower_http::cors::{AllowOrigin, Any, CorsLayer};
31 : use utoipa::OpenApi;
32 : use utoipa_swagger_ui::SwaggerUi;
33 :
34 : // Health check handler
35 0 : async fn health_check() -> impl IntoResponse {
36 0 : StatusCode::OK
37 0 : }
38 :
39 : #[tokio::main]
40 0 : async fn main() -> Result<(), anyhow::Error> {
41 : // Load and validate configuration first (fail-fast)
42 0 : let config = Config::load().map_err(|e| anyhow::anyhow!("{e}"))?;
43 :
44 : // Set up logging from config
45 0 : std::env::set_var("RUST_LOG", &config.logging.level);
46 0 : tracing_subscriber::fmt::init();
47 :
48 : // Init banner so container logs clearly show startup
49 0 : tracing::info!(
50 : version = env!("CARGO_PKG_VERSION"),
51 0 : "tinycongress-api starting up"
52 : );
53 :
54 : // Database connection
55 0 : tracing::info!("Connecting to database...");
56 0 : let pool = setup_database(&config.database).await?;
57 :
58 0 : let build_info = BuildInfoProvider::from_env();
59 0 : let build_info_snapshot = build_info.build_info();
60 0 : tracing::info!(
61 : version = %build_info_snapshot.version,
62 : git_sha = %build_info_snapshot.git_sha,
63 : build_time = %build_info_snapshot.build_time,
64 0 : "resolved build metadata"
65 : );
66 :
67 : // Create the GraphQL schema
68 0 : let schema = Schema::build(QueryRoot, MutationRoot, EmptySubscription)
69 0 : .data(pool.clone()) // Pass the database pool to the schema
70 0 : .data(build_info.clone())
71 0 : .finish();
72 :
73 : // Create repositories
74 0 : let account_repo: Arc<dyn identity::repo::AccountRepo> =
75 0 : Arc::new(PgAccountRepo::new(pool.clone()));
76 :
77 : // Build CORS layer from config
78 0 : let cors_origins = &config.cors.allowed_origins;
79 0 : let allow_origin: AllowOrigin = if cors_origins.iter().any(|o| o == "*") {
80 0 : tracing::warn!("CORS configured to allow any origin - not recommended for production");
81 0 : AllowOrigin::any()
82 0 : } else if cors_origins.is_empty() {
83 0 : tracing::info!(
84 0 : "CORS allowed origins not configured - cross-origin requests will be blocked"
85 : );
86 0 : AllowOrigin::list(Vec::<HeaderValue>::new())
87 : } else {
88 0 : let origins: Vec<HeaderValue> = cors_origins
89 0 : .iter()
90 0 : .filter_map(|origin| origin.parse().ok())
91 0 : .collect();
92 0 : tracing::info!(origins = ?cors_origins, "CORS allowed origins configured");
93 0 : AllowOrigin::list(origins)
94 : };
95 :
96 : // Build security headers layer if enabled
97 0 : let security_headers = if config.security_headers.enabled {
98 0 : tracing::info!("Security headers enabled");
99 0 : Some(build_security_headers(&config.security_headers))
100 : } else {
101 0 : tracing::info!("Security headers disabled");
102 0 : None
103 : };
104 :
105 : // REST API v1 routes
106 0 : let rest_v1 = Router::new().route("/build-info", get(rest::get_build_info));
107 :
108 : // Build the API
109 0 : let mut app = Router::new()
110 : // GraphQL endpoint - POST always enabled, GET (playground) is conditional
111 0 : .route("/graphql", {
112 0 : let route = axum::routing::post(graphql_handler);
113 0 : if config.graphql.playground_enabled {
114 0 : tracing::info!("GraphQL Playground enabled at /graphql");
115 0 : route.get(graphql_playground)
116 : } else {
117 0 : tracing::info!(
118 0 : "GraphQL Playground disabled (enable via TC_GRAPHQL__PLAYGROUND_ENABLED=true)"
119 : );
120 0 : route
121 : }
122 : })
123 : // REST API v1
124 0 : .nest("/api/v1", rest_v1)
125 : // Identity routes
126 0 : .merge(identity::http::router())
127 : // Health check route
128 0 : .route("/health", get(health_check))
129 : // Add the schema to the extension
130 0 : .layer(Extension(schema))
131 0 : .layer(Extension(pool.clone()))
132 0 : .layer(Extension(account_repo))
133 0 : .layer(Extension(build_info))
134 0 : .layer(
135 0 : CorsLayer::new()
136 0 : .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
137 0 : .allow_headers(Any)
138 0 : .allow_origin(allow_origin),
139 : );
140 :
141 : // Add security headers middleware if enabled
142 0 : if let Some(headers) = security_headers {
143 0 : app = app
144 0 : .layer(middleware::from_fn(security_headers_middleware))
145 0 : .layer(Extension(headers));
146 0 : }
147 :
148 : // Add Swagger UI if enabled (disabled by default for security)
149 0 : if config.swagger.enabled {
150 0 : tracing::info!("Swagger UI enabled at /swagger-ui");
151 0 : app = app
152 0 : .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
153 : } else {
154 0 : tracing::info!("Swagger UI disabled (enable via TC_SWAGGER__ENABLED=true)");
155 : }
156 :
157 : // Start the server
158 0 : let addr = SocketAddr::from(([0, 0, 0, 0], config.server.port));
159 0 : tracing::info!(
160 0 : graphql = %format!("http://{}/graphql", addr),
161 0 : rest = %format!("http://{}/api/v1", addr),
162 0 : "Starting server"
163 : );
164 :
165 0 : let listener = tokio::net::TcpListener::bind(addr).await?;
166 0 : axum::serve(listener, app).await?;
167 :
168 0 : Ok(())
169 0 : }
|