use std::sync::Arc;
use axum::{Router, http::Method, routing::MethodRouter};
use http::header::{
ACCEPT_ENCODING, AUTHORIZATION, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, DNT, HOST, ORIGIN,
REFERER,
};
use pib_service_facade::Service;
use tower_http::{
auth::AsyncRequireAuthorizationLayer,
cors::{AllowOrigin, CorsLayer},
};
use crate::Auth;
mod body;
mod config;
mod get;
mod me;
pub(super) async fn router(auth: Auth, service: Arc<dyn Service>) -> Router {
let cors = CorsLayer::new()
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::PATCH,
])
.allow_headers([
AUTHORIZATION,
CONTENT_TYPE,
ACCEPT_ENCODING,
CONTENT_LENGTH,
ORIGIN,
REFERER,
CONNECTION,
DNT,
HOST,
])
.allow_origin(AllowOrigin::mirror_request());
let public = Router::new()
.route("/", MethodRouter::new().get(get::handle))
.route("/config", MethodRouter::new().get(config::get::handle));
let private = Router::new()
.route(
"/me/profile",
MethodRouter::new()
.get(me::profile::get::handle)
.patch(me::profile::patch::handle),
)
.route(
"/body",
MethodRouter::new()
.get(body::get::handle)
.post(body::post::handle),
)
.route(
"/body/{body_id}",
MethodRouter::new()
.get(body::by_id::get::handle)
.patch(body::by_id::patch::handle),
)
.route(
"/body/{body_id}/organization",
MethodRouter::new()
.get(body::by_id::organization::get::handle)
.post(body::by_id::organization::post::handle),
)
.route(
"/body/{body_id}/meeting",
MethodRouter::new()
.get(body::by_id::meeting::get::handle)
.post(body::by_id::meeting::post::handle),
)
.route(
"/body/{body_id}/meeting/{meeting_id}",
MethodRouter::new().get(body::by_id::meeting::by_id::get::handle),
)
.layer(AsyncRequireAuthorizationLayer::new(auth));
Router::new()
.merge(public)
.merge(private)
.layer(cors)
.with_state(service)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum_test::TestServer;
use mockall::predicate::eq;
use pib_service_api_auth::{
MockApiAuth,
user::{Issuer, OidcSub, UserInfo},
};
use pib_service_api_types::{self as types, config::oidc::OidcConfig};
use pib_service_core_types::{BodyId, MeetingId, OrganizationId, UserId};
use pib_service_facade::MockService;
use crate::auth::Auth;
use super::router;
fn example_user_info() -> UserInfo {
UserInfo {
id: UserId::from_u128(0x1111),
issuer: Issuer("https://auth.example.com/".to_string()),
sub: OidcSub("max".to_string()),
display_name: None,
}
}
#[tokio::test]
async fn success_get() {
let api_auth = MockApiAuth::new();
let mut service = MockService::new();
service
.expect_handle_get()
.times(1)
.return_once(|| Ok(types::get::ResponseBody {}));
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
let response = server
.get("/")
.await
.assert_status_success()
.json::<types::get::ResponseBody>();
assert_eq!(response, types::get::ResponseBody {})
}
#[tokio::test]
async fn success_config_get() {
let api_auth = MockApiAuth::new();
let mut service = MockService::new();
let oidc = OidcConfig {
issuer: "https://auth.example.com/".to_string(),
client_id: "the-client-id".to_string(),
client_secret: "the-client-secret".to_string(),
};
{
let oidc = oidc.clone();
service
.expect_handle_config_get()
.times(1)
.return_once(|| Ok(types::config::get::ResponseBody { oidc }));
}
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
let response = server
.get("/config")
.await
.assert_status_success()
.json::<types::config::get::ResponseBody>();
assert_eq!(response, types::config::get::ResponseBody { oidc })
}
#[tokio::test]
async fn success_me_profile_get() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
service
.expect_handle_profile_me_get()
.times(1)
.return_once(|_| {
Ok(types::me::profile::get::ResponseBody {
display_name: Some("Max".to_string()),
})
});
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
let response = server
.get("/me/profile")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_success()
.json::<types::me::profile::get::ResponseBody>();
assert_eq!(
response,
types::me::profile::get::ResponseBody {
display_name: Some("Max".to_string())
}
)
}
#[tokio::test]
async fn success_me_profile_patch() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
service
.expect_handle_profile_me_patch()
.times(1)
.return_once(|_, _| Ok(()));
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
server
.patch("/me/profile")
.json(&types::me::profile::patch::RequestBody {
display_name: Some(Some("hello".to_string())),
})
.add_header("Authorization", "xxyyzz")
.await
.assert_status_see_other()
.assert_header("Location", "/me/profile");
}
#[tokio::test]
async fn success_body_get() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
service
.expect_handle_body_get()
.times(1)
.return_once(|_| Ok(types::body::get::ResponseBody { data: vec![] }));
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
let response = server
.get("/body")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_success()
.json::<types::body::get::ResponseBody>();
assert_eq!(response, types::body::get::ResponseBody { data: vec![] })
}
#[tokio::test]
async fn success_body_post() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
service
.expect_handle_body_post()
.times(1)
.return_once(|_, _| Ok(BodyId::from_u128(0x94949494)));
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
server
.post("/body")
.json(&types::body::post::RequestBody {
short_name: None,
name: "Hintertupfing".parse().unwrap(),
website: None,
license: None,
license_valid_since: None,
oparl_since: None,
ags: "12345".to_string(),
rgs: None,
contact_email: None,
contact_name: None,
classification: None,
is_public: true,
})
.add_header("Authorization", "xxyyzz")
.await
.assert_status_see_other()
.assert_header("Location", "/body/00000000-0000-0000-0000-000094949494");
}
#[tokio::test]
async fn success_body_by_id_get() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
let response_body = types::body::by_id::get::ResponseBody(types::body::Body {
id: BodyId::from_u128(0x17593122_2260_450e_a869_fe160e69ceb2),
created: "2026-04-07T05:06:07+01:00".parse().unwrap(),
modified: "2026-04-08T05:06:07+01:00".parse().unwrap(),
short_name: None,
name: "Hintertupfing".parse().unwrap(),
website: None,
license: None,
license_valid_since: None,
oparl_since: None,
ags: "12345".to_string(),
rgs: None,
contact_email: None,
contact_name: None,
classification: None,
is_public: true,
});
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
{
let response_body = response_body.clone();
service
.expect_handle_body_by_id_get()
.times(1)
.return_once(|_, _| Ok(response_body));
}
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
let response = server
.get("/body/17593122-2260-450e-a869-fe160e69ceb2")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_success()
.json::<types::body::by_id::get::ResponseBody>();
assert_eq!(response, response_body);
}
#[tokio::test]
async fn success_body_by_id_meeting_get() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
service
.expect_handle_body_by_id_meeting_get()
.times(1)
.return_once(|_, _| {
Ok(types::body::by_id::meeting::get::ResponseBody { data: vec![] })
});
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
let response = server
.get("/body/152cf529-df96-45ba-833a-54509a26d064/meeting")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_success()
.json::<types::body::by_id::meeting::get::ResponseBody>();
assert_eq!(
response,
types::body::by_id::meeting::get::ResponseBody { data: vec![] }
)
}
#[tokio::test]
async fn success_body_by_id_meeting_post() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
service
.expect_handle_body_by_id_meeting_post()
.times(1)
.return_once(|_, _, _| Ok(MeetingId::from_u128(0x131313)));
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
server
.post("/body/152cf529-df96-45ba-833a-54509a26d064/meeting")
.json(&types::body::by_id::meeting::post::RequestBody {
organization_id: OrganizationId::from_u128(0x999999999),
name: Some("Hintertupfing".parse().unwrap()),
license: None,
meeting_state: "planned".to_string(),
keyword: None,
start: None,
end: None,
web: None,
})
.add_header("Authorization", "xxyyzz")
.await
.assert_status_see_other()
.assert_header(
"Location",
"/body/152cf529-df96-45ba-833a-54509a26d064/meeting/00000000-0000-0000-0000-000000131313",
);
}
#[tokio::test]
async fn success_body_by_id_meeting_by_id_get() {
let mut api_auth = MockApiAuth::new();
let mut service = MockService::new();
let response_body = types::body::by_id::meeting::by_id::get::ResponseBody(
types::body::by_id::meeting::Meeting {
id: MeetingId::from_u128(0xcf4cb545_4e10_4205_8cb4_7ddcbba0e0dd),
body_id: BodyId::from_u128(0x17593122_2260_450e_a869_fe160e69ceb2),
organization_id: OrganizationId::from_u128(0xb991c4e7_1283_4997_b042_f962d19895cf),
created: "2026-04-07T05:06:07+01:00".parse().unwrap(),
modified: "2026-04-08T05:06:07+01:00".parse().unwrap(),
name: None,
meeting_state: None,
start: None,
end: None,
license: None,
web: None,
},
);
api_auth
.expect_authorize()
.times(1)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.return_once(|_| Ok(Some(example_user_info())));
{
let response_body = response_body.clone();
service
.expect_handle_body_by_id_meeting_by_id_get()
.times(1)
.return_once(|_, _, _| Ok(response_body));
}
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
let response = server
.get("/body/17593122-2260-450e-a869-fe160e69ceb2/meeting/cf4cb545-4e10-4205-8cb4-7ddcbba0e0dd")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_success()
.json::<types::body::by_id::meeting::by_id::get::ResponseBody>();
assert_eq!(response, response_body);
}
#[tokio::test]
async fn forbid() {
let mut api_auth = MockApiAuth::new();
let service = MockService::new();
api_auth
.expect_authorize()
.times(1..)
.with(eq(http::HeaderValue::from_static("xxyyzz")))
.returning(|_| Ok(None));
let app = router(Auth::new(Arc::new(api_auth)), Arc::new(service)).await;
let server = TestServer::new(app);
server
.get("/me/profile")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
server
.patch("/me/profile")
.json(&types::me::profile::patch::RequestBody {
display_name: Some(Some("hello".to_string())),
})
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
server
.get("/body")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
server
.post("/body")
.json(&types::body::post::RequestBody {
short_name: None,
name: "hello".parse().unwrap(),
website: None,
license: None,
license_valid_since: None,
oparl_since: None,
ags: "12345".to_string(),
rgs: None,
contact_email: None,
contact_name: None,
classification: None,
is_public: true,
})
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
server
.get("/body/190c9a32-6993-462c-afcb-9f11ad7e1f33")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
server
.get("/body/190c9a32-6993-462c-afcb-9f11ad7e1f33/meeting")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
server
.post("/body/190c9a32-6993-462c-afcb-9f11ad7e1f33/meeting")
.json(&types::body::by_id::meeting::post::RequestBody {
organization_id: OrganizationId::from_u128(0x114488),
name: None,
license: None,
meeting_state: "planned".to_string(),
keyword: None,
start: None,
end: None,
web: None,
})
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
server
.get("/body/190c9a32-6993-462c-afcb-9f11ad7e1f33/meeting/0cec81a2-d5d9-4c61-a970-e752bbca7783")
.add_header("Authorization", "xxyyzz")
.await
.assert_status_unauthorized();
}
}