feat: wire relay API with dependency injection

- split settings module into per-struct files
- add DatabaseSettings with default in-memory SQLite path
- implement RelayApi struct with GET /relays and POST
  /relays/{id}/toggle
- wire create_relay_controller and create_label_repository into
  Application::build() with mock/real selection via cfg!(test) || CI
- register RelayApi in OpenApiService alongside existing APIs
This commit is contained in:
2026-03-04 12:47:21 +01:00
parent fd00d1925b
commit 2eebc52f17
30 changed files with 1170 additions and 670 deletions

View File

@@ -0,0 +1 @@
pub mod relay_api;

View File

@@ -0,0 +1,259 @@
use std::sync::Arc;
use poem::Result;
use poem_openapi::{ApiResponse, OpenApi, param::Path, payload::Json};
use crate::{
application::use_cases::{GetAllRelaysUseCase, ToggleRelayUseCase},
domain::relay::{
Relay, controller::RelayController, repository::RelayLabelRepository, types::RelayId,
},
presentation::{dto::relay_dto::RelayDto, error::ApiError},
route::ApiCategory
};
#[derive(ApiResponse)]
enum GetAllRelaysResponse {
#[oai(status = 200)]
Ok(Json<Vec<RelayDto>>),
}
#[derive(ApiResponse)]
enum ToggleRelayResponse {
#[oai(status = 200)]
Ok(Json<RelayDto>),
}
pub struct RelayApi {
relay_controller: Arc<dyn RelayController>,
label_repository: Arc<dyn RelayLabelRepository>,
}
impl RelayApi {
pub fn new(
relay_controller: Arc<dyn RelayController>,
label_repository: Arc<dyn RelayLabelRepository>,
) -> Self {
Self {
relay_controller,
label_repository,
}
}
}
// -- Endpoints ---
#[OpenApi(tag = "ApiCategory::Relays")]
impl RelayApi {
#[oai(path = "/relays", method = "get")]
async fn get_all_relays(&self) -> Result<GetAllRelaysResponse> {
let use_case =
GetAllRelaysUseCase::new(self.relay_controller.clone(), self.label_repository.clone());
let relays = use_case
.execute()
.await
.map_err(|e| poem::Error::from(ApiError::from(e)))?;
let dtos: Vec<_> = relays
.into_iter()
.map(|r| {
let domain_relay =
Relay::with_label(r.id(), r.state(), r.label().unwrap_or_default());
RelayDto::from(domain_relay)
})
.collect();
Ok(GetAllRelaysResponse::Ok(Json(dtos)))
}
#[oai(path = "/relays/:id/toggle", method = "post")]
async fn toggle_relay(&self, id: Path<u8>) -> Result<ToggleRelayResponse> {
let relay_id =
RelayId::new(*id).map_err(|_| poem::Error::from(ApiError::RelayNotFound(*id)))?;
let use_case =
ToggleRelayUseCase::new(self.relay_controller.clone(), self.label_repository.clone());
let relay = use_case
.execute(relay_id)
.await
.map_err(|e| poem::Error::from(ApiError::from(e)))?;
let domain_relay =
Relay::with_label(relay.id(), relay.state(), relay.label().unwrap_or_default());
Ok(ToggleRelayResponse::Ok(Json(RelayDto::from(domain_relay))))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use poem::http::StatusCode;
use poem_openapi::OpenApiService;
use crate::{
domain::relay::{
controller::RelayController,
repository::RelayLabelRepository,
types::{RelayId, RelayState},
},
infrastructure::{
modbus::mock_controller::MockRelayController,
persistence::label_repository::MockRelayLabelRepository,
},
};
use super::RelayApi;
fn make_relay_api(controller: Arc<MockRelayController>) -> poem::test::TestClient<impl poem::Endpoint> {
let repo = Arc::new(MockRelayLabelRepository::new());
let relay_api = RelayApi::new(controller, repo);
let api_service = OpenApiService::new(relay_api, "test", "1.0");
let app = poem::Route::new().nest("/api", api_service);
poem::test::TestClient::new(app)
}
// -- GET /api/relays --
#[tokio::test]
async fn get_all_relays_returns_200() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn get_all_relays_returns_empty_array_when_no_states() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert!(body.is_empty());
}
#[tokio::test]
async fn get_all_relays_returns_all_initialized_relays() {
let controller = Arc::new(MockRelayController::new());
for i in 1u8..=8 {
controller
.write_relay_state(RelayId::new(i).unwrap(), RelayState::Off)
.await
.unwrap();
}
controller
.write_relay_state(RelayId::new(1).unwrap(), RelayState::On)
.await
.unwrap();
let cli = make_relay_api(controller);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert_eq!(body.len(), 8);
assert_eq!(body[0]["id"], 1);
assert_eq!(body[0]["state"], "on");
assert_eq!(body[1]["id"], 2);
assert_eq!(body[1]["state"], "off");
}
// -- POST /api/relays/{id}/toggle --
#[tokio::test]
async fn toggle_relay_with_out_of_range_id_9_returns_404() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/9/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn toggle_relay_with_id_0_returns_404() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/0/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn toggle_relay_toggles_off_to_on_and_returns_200() {
let controller = Arc::new(MockRelayController::new());
controller
.write_relay_state(RelayId::new(1).unwrap(), RelayState::Off)
.await
.unwrap();
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/1/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["id"], 1);
assert_eq!(body["state"], "on");
}
#[tokio::test]
async fn toggle_relay_toggles_on_to_off_and_returns_200() {
let controller = Arc::new(MockRelayController::new());
controller
.write_relay_state(RelayId::new(3).unwrap(), RelayState::On)
.await
.unwrap();
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/3/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["id"], 3);
assert_eq!(body["state"], "off");
}
#[tokio::test]
async fn toggle_relay_includes_label_in_response() {
use crate::domain::relay::types::RelayLabel;
let controller = Arc::new(MockRelayController::new());
controller
.write_relay_state(RelayId::new(2).unwrap(), RelayState::Off)
.await
.unwrap();
let repo = Arc::new(MockRelayLabelRepository::new());
repo.save_label(RelayId::new(2).unwrap(), RelayLabel::new("Pump".to_string()).unwrap())
.await
.unwrap();
let relay_api = RelayApi::new(controller, repo);
let api_service = OpenApiService::new(relay_api, "test", "1.0");
let app = poem::Route::new().nest("/api", api_service);
let cli = poem::test::TestClient::new(app);
let resp = cli.post("/api/relays/2/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["label"], "Pump");
}
// -- Integration tests via get_test_app() --
#[tokio::test]
async fn get_all_relays_endpoint_reachable_via_full_app() {
let app = crate::get_test_app().await;
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn toggle_relay_invalid_id_returns_404_via_full_app() {
let app = crate::get_test_app().await;
let cli = poem::test::TestClient::new(app);
let resp = cli.post("/api/relays/9/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
// Posting to a valid relay ID on an empty mock should hit the handler (route found)
// and return 500 because the mock controller has no relay state initialised.
#[tokio::test]
async fn toggle_relay_valid_id_empty_mock_returns_500_via_full_app() {
let app = crate::get_test_app().await;
let cli = poem::test::TestClient::new(app);
let resp = cli.post("/api/relays/1/toggle").send().await;
resp.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
}
}

View File

@@ -126,10 +126,7 @@ mod tests {
let dto = RelayDto::from(relay);
let json = serde_json::to_string(&dto).unwrap();
assert_eq!(
json,
r#"{"id":7,"state":"on","label":"Test Relay"}"#
);
assert_eq!(json, r#"{"id":7,"state":"on","label":"Test Relay"}"#);
}
#[test]

View File

@@ -5,7 +5,12 @@
use poem::{error::ResponseError, http::StatusCode};
use crate::{application::use_cases::{get_all_relays::GetAllRelaysError, toggle_relay::ToggleRelayError}, domain::relay::{controller::ControllerError, repository::RepositoryError, types::RelayLabelError}};
use crate::{
application::use_cases::{get_all_relays::GetAllRelaysError, toggle_relay::ToggleRelayError},
domain::relay::{
controller::ControllerError, repository::RepositoryError, types::RelayLabelError,
},
};
/// Unified error type for all API handlers.
///
@@ -77,8 +82,7 @@ mod tests {
use crate::{
application::use_cases::{
get_all_relays::GetAllRelaysError,
toggle_relay::ToggleRelayError,
get_all_relays::GetAllRelaysError, toggle_relay::ToggleRelayError,
},
domain::relay::{
controller::ControllerError,
@@ -109,13 +113,16 @@ mod tests {
#[test]
fn test_controller_connection_error_returns_503() {
let error = ApiError::ControllerError(ControllerError::ConnectionError("refused".to_string()));
let error =
ApiError::ControllerError(ControllerError::ConnectionError("refused".to_string()));
assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn test_controller_modbus_exception_returns_503() {
let error = ApiError::ControllerError(ControllerError::ModbusException("illegal function".to_string()));
let error = ApiError::ControllerError(ControllerError::ModbusException(
"illegal function".to_string(),
));
assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
}
@@ -127,13 +134,15 @@ mod tests {
#[test]
fn test_controller_invalid_input_returns_500() {
let error = ApiError::ControllerError(ControllerError::InvalidInput("bad input".to_string()));
let error =
ApiError::ControllerError(ControllerError::InvalidInput("bad input".to_string()));
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_repository_error_returns_500() {
let error = ApiError::RepositoryError(RepositoryError::DatabaseError("db failed".to_string()));
let error =
ApiError::RepositoryError(RepositoryError::DatabaseError("db failed".to_string()));
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
@@ -162,7 +171,8 @@ mod tests {
#[test]
fn test_from_get_all_relays_repository_error_produces_repository_error() {
let source = GetAllRelaysError::Repository(RepositoryError::DatabaseError("err".to_string()));
let source =
GetAllRelaysError::Repository(RepositoryError::DatabaseError("err".to_string()));
let api_error = ApiError::from(source);
assert!(matches!(api_error, ApiError::RepositoryError(_)));
}

View File

@@ -100,6 +100,6 @@
/// This module contains DTO structures that are used to serialize domain
/// objects for API responses, providing a clean separation between internal
/// domain models and external API contracts.
pub mod api;
pub mod dto;
pub mod error;