diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f09a70e4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# StationAPI Repository Guidelines + +This guide explains how automation agents and human contributors should work with the StationAPI repository so releases stay predictable, auditable, and safe. Update this file whenever you change the workflow or behavior it documents. + +## Project Layout +- `stationapi/src/domain/` – Entity definitions and repository abstractions. The `entity/` module mirrors the gRPC schema, `repository/` provides `async_trait`-based interfaces, and `normalize.rs` contains text normalization for search. +- `stationapi/src/use_case/` – Application logic. `interactor/query.rs` implements the `QueryUseCase` contract defined in `traits/query.rs`. +- `stationapi/src/infrastructure/` – SQLx repositories for PostgreSQL. `My*Repository` types share an `Arc Result<(), Box> { "StopCondition", "#[derive(serde::Serialize, serde::Deserialize)]", ) + .type_attribute( + "StationMinimal", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + "LineMinimal", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + "RouteMinimal", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + "RouteMinimalResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) .protoc_arg("--experimental_allow_proto3_optional") .file_descriptor_set_path(out_dir.join("stationapi_descriptor.bin")) .compile_protos(&["proto/stationapi.proto"], &["proto"])?; diff --git a/stationapi/proto b/stationapi/proto index 771b3ba7..9960d525 160000 --- a/stationapi/proto +++ b/stationapi/proto @@ -1 +1 @@ -Subproject commit 771b3ba7f68baeb0544679ca2760dc94f352f3ee +Subproject commit 9960d5259686991d2bbad39d7f3f9aaf2bbf6485 diff --git a/stationapi/src/presentation/controller/grpc.rs b/stationapi/src/presentation/controller/grpc.rs index 34ece77f..2dafd0e5 100644 --- a/stationapi/src/presentation/controller/grpc.rs +++ b/stationapi/src/presentation/controller/grpc.rs @@ -5,13 +5,13 @@ use crate::{ }, presentation::error::PresentationalError, proto::{ - station_api_server::StationApi, GetConnectedStationsRequest, GetLineByIdRequest, - GetLinesByNameRequest, GetRouteRequest, GetStationByCoordinatesRequest, + station_api_server::StationApi, GetConnectedStationsRequest, GetLineByIdListRequest, + GetLineByIdRequest, GetLinesByNameRequest, GetRouteRequest, GetStationByCoordinatesRequest, GetStationByGroupIdRequest, GetStationByIdListRequest, GetStationByIdRequest, GetStationByLineIdRequest, GetStationsByLineGroupIdRequest, GetStationsByNameRequest, GetTrainTypesByStationIdRequest, MultipleLineResponse, MultipleStationResponse, - MultipleTrainTypeResponse, Route, RouteResponse, RouteTypeResponse, SingleLineResponse, - SingleStationResponse, + MultipleTrainTypeResponse, Route, RouteMinimalResponse, RouteResponse, RouteTypeResponse, + SingleLineResponse, SingleStationResponse, }, use_case::{interactor::query::QueryInteractor, traits::query::QueryUseCase}, }; @@ -214,6 +214,21 @@ impl StationApi for MyApi { } } + async fn get_routes_minimal( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let from_id = request.get_ref().from_station_group_id; + let to_id = request.get_ref().to_station_group_id; + + match self.query_use_case.get_routes_minimal(from_id, to_id).await { + Ok(response) => { + return Ok(Response::new(response)); + } + Err(err) => Err(PresentationalError::from(err).into()), + } + } + async fn get_route_types( &self, request: tonic::Request, @@ -256,6 +271,24 @@ impl StationApi for MyApi { })) } + async fn get_line_by_id_list( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let line_ids = &request.get_ref().line_ids; + + let lines = match self.query_use_case.get_lines_by_id_vec(line_ids).await { + Ok(lines) => lines, + Err(err) => { + return Err(PresentationalError::OtherError(anyhow::anyhow!(err).into()).into()) + } + }; + + Ok(Response::new(MultipleLineResponse { + lines: lines.into_iter().map(|line| line.into()).collect(), + })) + } + async fn get_lines_by_name( &self, request: tonic::Request, diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index e9423c9a..ccf296b6 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -526,17 +526,7 @@ where .get_route_stops(from_station_id, to_station_id) .await?; - let route_row_tree_map: BTreeMap> = stops.iter().fold( - BTreeMap::new(), - |mut acc: BTreeMap>, value| { - if let Some(line_group_cd) = value.line_group_cd { - acc.entry(line_group_cd).or_default().push(value.clone()); - } else { - acc.entry(value.line_cd).or_default().push(value.clone()); - }; - acc - }, - ); + let route_row_tree_map = self.build_route_tree_map(&stops); let mut routes: Vec = Vec::new(); @@ -551,34 +541,51 @@ where .get_by_line_group_id_vec_for_routes(&line_group_id_vec) .await?; + // Add line_symbols to all lines first + for line in tt_lines.iter_mut() { + line.line_symbols = self.get_line_symbols(line); + } + let stops = stops .iter() .map(|row| { let extracted_line = self.extract_line_from_station(row); - if let Some(tt_line) = - tt_lines.iter_mut().find(|line| line.line_cd == row.line_cd) + if let Some(tt_line) = tt_lines.iter().find(|line| line.line_cd == row.line_cd) { - tt_line.line_symbols = self.get_line_symbols(tt_line); - let train_type = match row.type_id.is_some() { - true => Some(Box::new(TrainType { - id: row.type_id, - station_cd: Some(row.station_cd), - type_cd: row.type_cd, - line_group_cd: row.line_group_cd, - pass: row.pass, - type_name: row.type_name.clone().unwrap_or_default(), - type_name_k: row.type_name_k.clone().unwrap_or_default(), - type_name_r: row.type_name_r.clone(), - type_name_zh: row.type_name_zh.clone(), - type_name_ko: row.type_name_ko.clone(), - color: row.color.clone().unwrap_or_default(), - direction: row.direction, - kind: row.kind, - line: Some(Box::new(tt_line.clone())), - lines: tt_lines.to_vec(), - })), + true => { + // Filter lines to only include those with matching line_group_cd + // and remove duplicates by line_cd + let mut seen_line_cds = std::collections::HashSet::new(); + let filtered_lines: Vec = tt_lines + .iter() + .filter(|line| { + row.line_group_cd.is_some() + && line.line_group_cd == row.line_group_cd + && seen_line_cds.insert(line.line_cd) + }) + .cloned() + .collect(); + + Some(Box::new(TrainType { + id: row.type_id, + station_cd: Some(row.station_cd), + type_cd: row.type_cd, + line_group_cd: row.line_group_cd, + pass: row.pass, + type_name: row.type_name.clone().unwrap_or_default(), + type_name_k: row.type_name_k.clone().unwrap_or_default(), + type_name_r: row.type_name_r.clone(), + type_name_zh: row.type_name_zh.clone(), + type_name_ko: row.type_name_ko.clone(), + color: row.color.clone().unwrap_or_default(), + direction: row.direction, + kind: row.kind, + line: Some(Box::new(tt_line.clone())), + lines: filtered_lines, + })) + } false => None, }; @@ -609,6 +616,108 @@ where Ok(routes) } + async fn get_routes_minimal( + &self, + from_station_id: u32, + to_station_id: u32, + ) -> Result { + let stops = self + .station_repository + .get_route_stops(from_station_id, to_station_id) + .await?; + + let route_row_tree_map = self.build_route_tree_map(&stops); + + let mut routes: Vec = Vec::new(); + let mut all_lines: std::collections::HashMap = + std::collections::HashMap::new(); + + for (id, stops) in route_row_tree_map.iter() { + let stops_minimal = stops + .iter() + .map(|row| { + let extracted_line = self.extract_line_from_station(row); + + // Add line to the lines collection + let line_symbols = self + .get_line_symbols(&extracted_line) + .into_iter() + .map(|ls| proto::LineSymbol { + symbol: ls.symbol, + color: ls.color, + shape: ls.shape, + }) + .collect(); + + let line_minimal = proto::LineMinimal { + id: extracted_line.line_cd as u32, + name_short: extracted_line.line_name, + color: extracted_line.line_color_c.unwrap_or_default(), + line_type: extracted_line.line_type.unwrap_or(0), + line_symbols, + }; + + // Update line: prefer entries with non-empty line_symbols + all_lines + .entry(line_minimal.id) + .and_modify(|existing| { + // Update if new line has symbols and existing doesn't + if !line_minimal.line_symbols.is_empty() + && existing.line_symbols.is_empty() + { + *existing = line_minimal.clone(); + } + }) + .or_insert(line_minimal); + + // Create station minimal + let station_numbers = self + .get_station_numbers(row) + .into_iter() + .map(|sn| proto::StationNumber { + line_symbol: sn.line_symbol, + line_symbol_color: sn.line_symbol_color, + line_symbol_shape: sn.line_symbol_shape, + station_number: sn.station_number, + }) + .collect(); + + proto::StationMinimal { + id: row.station_cd as u32, + group_id: row.station_g_cd as u32, + name: row.station_name.clone(), + name_katakana: row.station_name_k.clone(), + name_roman: row.station_name_r.clone(), + line_ids: vec![extracted_line.line_cd as u32], + station_numbers, + stop_condition: row.pass.unwrap_or(0), + has_train_types: Some(row.type_id.is_some()), + train_type_id: row.type_id.map(|id| id as u32), + } + }) + .collect::>(); + + // TODO: SQLで同等の処理を行う + let includes_requested_station = stops_minimal + .iter() + .any(|stop| stop.group_id == from_station_id || stop.group_id == to_station_id); + if !includes_requested_station { + continue; + } + + routes.push(proto::RouteMinimal { + id: *id as u32, + stops: stops_minimal, + }); + } + + Ok(proto::RouteMinimalResponse { + routes, + lines: all_lines.into_values().collect(), + next_page_token: "".to_string(), + }) + } + async fn get_train_types( &self, from_station_id: u32, @@ -633,11 +742,16 @@ where .get_by_line_group_id_vec(&line_group_id_vec) .await?; - let tt_lines = self + let mut tt_lines = self .line_repository .get_by_line_group_id_vec(&line_group_id_vec) .await?; + // Add line_symbols to all lines first + for line in tt_lines.iter_mut() { + line.line_symbols = self.get_line_symbols(line); + } + for mut train_type in train_types.clone() { if result .iter() @@ -646,9 +760,13 @@ where continue; } + let mut seen_line_cds = HashSet::new(); train_type.lines = tt_lines .iter() - .filter(|line| line.line_group_cd == train_type.line_group_cd) + .filter(|line| { + line.line_group_cd == train_type.line_group_cd + && seen_line_cds.insert(line.line_cd) + }) .map(|line| Line { line_cd: line.line_cd, company_cd: line.company_cd, @@ -688,6 +806,10 @@ where type_cd: line.type_cd, }) .collect::>(); + + // Set the line field to the first line in the lines vector + train_type.line = train_type.lines.first().map(|l| Box::new(l.clone())); + result.push(train_type); } Ok(result) @@ -698,6 +820,11 @@ where Ok(line) } + async fn get_lines_by_id_vec(&self, line_ids: &[u32]) -> Result, UseCaseError> { + let lines = self.line_repository.get_by_ids(line_ids).await?; + Ok(lines) + } + async fn get_lines_by_name( &self, line_name: String, @@ -727,6 +854,20 @@ where TR: TrainTypeRepository, CR: CompanyRepository, { + fn build_route_tree_map(&self, stops: &[Station]) -> BTreeMap> { + stops.iter().fold( + BTreeMap::new(), + |mut acc: BTreeMap>, value| { + if let Some(line_group_cd) = value.line_group_cd { + acc.entry(line_group_cd).or_default().push(value.clone()); + } else { + acc.entry(value.line_cd).or_default().push(value.clone()); + }; + acc + }, + ) + } + fn build_station_from_row( &self, row: &Station, diff --git a/stationapi/src/use_case/traits/query.rs b/stationapi/src/use_case/traits/query.rs index 20f51457..9c5a85a6 100644 --- a/stationapi/src/use_case/traits/query.rs +++ b/stationapi/src/use_case/traits/query.rs @@ -5,7 +5,7 @@ use crate::{ company::Company, line::Line, line_symbol::LineSymbol, station::Station, station_number::StationNumber, train_type::TrainType, }, - proto::Route, + proto::{Route, RouteMinimalResponse}, use_case::error::UseCaseError, }; @@ -79,12 +79,18 @@ pub trait QueryUseCase: Send + Sync + 'static { from_station_id: u32, to_station_id: u32, ) -> Result, UseCaseError>; + async fn get_routes_minimal( + &self, + from_station_id: u32, + to_station_id: u32, + ) -> Result; async fn get_train_types( &self, from_station_id: u32, to_station_id: u32, ) -> Result, UseCaseError>; async fn find_line_by_id(&self, line_id: u32) -> Result, UseCaseError>; + async fn get_lines_by_id_vec(&self, line_ids: &[u32]) -> Result, UseCaseError>; async fn get_lines_by_name( &self, line_name: String,