scufflecloud_core/
services.rs

1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use axum::http::{HeaderName, StatusCode};
5use axum::{Extension, Json};
6use geo_ip::maxminddb;
7use geo_ip::middleware::IpAddressInfo;
8use reqwest::header::CONTENT_TYPE;
9use scuffle_http::http::Method;
10use tinc::TincService;
11use tinc::openapi::Server;
12use tower_http::cors::{AllowHeaders, CorsLayer, ExposeHeaders};
13use tower_http::trace::TraceLayer;
14
15use crate::middleware;
16
17mod organization_invitations;
18mod organizations;
19mod sessions;
20mod users;
21
22#[derive(Debug)]
23pub struct CoreSvc<G> {
24    _phantom: std::marker::PhantomData<G>,
25}
26
27impl<G> Default for CoreSvc<G> {
28    fn default() -> Self {
29        Self {
30            _phantom: std::marker::PhantomData,
31        }
32    }
33}
34
35fn rest_cors_layer() -> CorsLayer {
36    CorsLayer::new()
37        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
38        .allow_origin(tower_http::cors::Any)
39        .allow_headers(tower_http::cors::Any)
40}
41
42fn grpc_web_cors_layer() -> CorsLayer {
43    // https://github.com/timostamm/protobuf-ts/blob/main/MANUAL.md#grpc-web-transport
44    let allow_headers = [
45        CONTENT_TYPE,
46        HeaderName::from_static("x-grpc-web"),
47        HeaderName::from_static("grpc-timeout"),
48    ]
49    .into_iter()
50    .chain(middleware::auth_headers());
51
52    let expose_headers = [
53        HeaderName::from_static("grpc-encoding"),
54        HeaderName::from_static("grpc-status"),
55        HeaderName::from_static("grpc-message"),
56    ];
57
58    CorsLayer::new()
59        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
60        .allow_headers(AllowHeaders::list(allow_headers))
61        .expose_headers(ExposeHeaders::list(expose_headers))
62        .allow_origin(tower_http::cors::Any)
63        .allow_headers(tower_http::cors::Any)
64}
65
66#[derive(serde::Serialize)]
67struct RootGeoResponse {
68    city: Option<String>,
69    country_code: Option<String>,
70}
71
72#[derive(serde::Serialize)]
73struct RootResponse {
74    version: &'static str,
75    ip_address: std::net::IpAddr,
76    geo_location: RootGeoResponse,
77    branch: Option<&'static str>,
78    commit: Option<&'static str>,
79}
80
81async fn root<G: geo_ip::GeoIpInterface>(
82    Extension(global): Extension<Arc<G>>,
83    Extension(ip_address_info): Extension<IpAddressInfo>,
84) -> Json<RootResponse> {
85    let geo_city = global
86        .geo_ip_resolver()
87        .lookup::<maxminddb::geoip2::City>(ip_address_info.ip_address)
88        .ok()
89        .flatten();
90
91    let city = geo_city
92        .as_ref()
93        .and_then(|c| c.city.as_ref())
94        .and_then(|c| c.names.as_ref())
95        .and_then(|c| c.get("en").map(|s| s.to_string()));
96
97    let country_code = geo_city
98        .as_ref()
99        .and_then(|c| c.country.as_ref())
100        .and_then(|c| c.iso_code.map(|s| s.to_string()));
101
102    let resp = RootResponse {
103        version: env!("CARGO_PKG_VERSION"),
104        ip_address: ip_address_info.ip_address,
105        geo_location: RootGeoResponse { city, country_code },
106        branch: option_env!("GIT_BRANCH"),
107        commit: option_env!("COMMIT_SHA"),
108    };
109
110    Json(resp)
111}
112
113impl<G: core_traits::Global> scuffle_bootstrap::Service<G> for CoreSvc<G> {
114    async fn run(self, global: Arc<G>, ctx: scuffle_context::Context) -> anyhow::Result<()> {
115        // REST
116        let organization_invitations_svc_tinc =
117            pb::scufflecloud::core::v1::organization_invitations_service_tinc::OrganizationInvitationsServiceTinc::new(
118                CoreSvc::<G>::default(),
119            );
120        let organizations_svc_tinc =
121            pb::scufflecloud::core::v1::organizations_service_tinc::OrganizationsServiceTinc::new(CoreSvc::<G>::default());
122        let sessions_svc_tinc =
123            pb::scufflecloud::core::v1::sessions_service_tinc::SessionsServiceTinc::new(CoreSvc::<G>::default());
124        let users_svc_tinc = pb::scufflecloud::core::v1::users_service_tinc::UsersServiceTinc::new(CoreSvc::<G>::default());
125
126        let mut openapi_schema = organization_invitations_svc_tinc.openapi_schema();
127        openapi_schema.merge(organizations_svc_tinc.openapi_schema());
128        openapi_schema.merge(sessions_svc_tinc.openapi_schema());
129        openapi_schema.merge(users_svc_tinc.openapi_schema());
130        openapi_schema.info.title = "Scuffle Cloud Core API".to_string();
131        openapi_schema.info.version = "v1".to_string();
132        openapi_schema.servers = Some(vec![Server::new("/v1")]);
133
134        let v1_rest_router = axum::Router::new()
135            .route("/openapi.json", axum::routing::get(Json(openapi_schema)))
136            .merge(organization_invitations_svc_tinc.into_router())
137            .merge(organizations_svc_tinc.into_router())
138            .merge(sessions_svc_tinc.into_router())
139            .merge(users_svc_tinc.into_router())
140            .layer(rest_cors_layer());
141
142        // gRPC
143        let organization_invitations_svc =
144            pb::scufflecloud::core::v1::organization_invitations_service_server::OrganizationInvitationsServiceServer::new(
145                CoreSvc::<G>::default(),
146            );
147        let organizations_svc = pb::scufflecloud::core::v1::organizations_service_server::OrganizationsServiceServer::new(
148            CoreSvc::<G>::default(),
149        );
150        let sessions_svc =
151            pb::scufflecloud::core::v1::sessions_service_server::SessionsServiceServer::new(CoreSvc::<G>::default());
152        let users_svc = pb::scufflecloud::core::v1::users_service_server::UsersServiceServer::new(CoreSvc::<G>::default());
153
154        let reflection_v1_svc = tonic_reflection::server::Builder::configure()
155            .register_encoded_file_descriptor_set(pb::ANNOTATIONS_PB)
156            .build_v1()?;
157        let reflection_v1alpha_svc = tonic_reflection::server::Builder::configure()
158            .register_encoded_file_descriptor_set(pb::ANNOTATIONS_PB)
159            .build_v1alpha()?;
160
161        let mut builder = tonic::service::Routes::builder();
162        builder.add_service(organization_invitations_svc);
163        builder.add_service(organizations_svc);
164        builder.add_service(sessions_svc);
165        builder.add_service(users_svc);
166        builder.add_service(reflection_v1_svc);
167        builder.add_service(reflection_v1alpha_svc);
168
169        let grpc_router = builder
170            .routes()
171            .prepare()
172            .into_axum_router()
173            .layer(tonic_web::GrpcWebLayer::new())
174            .layer(grpc_web_cors_layer());
175
176        let mut router = axum::Router::new()
177            .route("/", axum::routing::get(root::<G>))
178            .nest("/v1", v1_rest_router)
179            .merge(grpc_router)
180            .route_layer(axum::middleware::from_fn(crate::middleware::auth::<G>))
181            .layer(geo_ip::middleware::middleware::<G>())
182            .layer(TraceLayer::new_for_http())
183            .layer(Extension(Arc::clone(&global)))
184            .fallback(StatusCode::NOT_FOUND);
185
186        if global.swagger_ui_enabled() {
187            router = router.merge(swagger_ui_dist::generate_routes(swagger_ui_dist::ApiDefinition {
188                uri_prefix: "/v1/docs",
189                api_definition: swagger_ui_dist::OpenApiSource::Uri("/v1/openapi.json"),
190                title: Some("Scuffle Core v1 Api Docs"),
191            }));
192        }
193
194        scuffle_http::HttpServer::builder()
195            .tower_make_service_with_addr(router.into_make_service_with_connect_info::<SocketAddr>())
196            .bind(global.service_bind())
197            .ctx(ctx)
198            .build()
199            .run()
200            .await?;
201
202        Ok(())
203    }
204}