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:
1
backend/src/presentation/api/mod.rs
Normal file
1
backend/src/presentation/api/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod relay_api;
|
||||
259
backend/src/presentation/api/relay_api.rs
Normal file
259
backend/src/presentation/api/relay_api.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user