chore: bootstrap housing research platform
This commit is contained in:
39
apps/api/src/config.rs
Normal file
39
apps/api/src/config.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub run_migrations: bool,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL is required")?;
|
||||
let host = env::var("API_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port = env::var("API_PORT")
|
||||
.unwrap_or_else(|_| "8080".to_string())
|
||||
.parse::<u16>()
|
||||
.context("API_PORT must be a valid port")?;
|
||||
let run_migrations = env::var("RUN_MIGRATIONS")
|
||||
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
host,
|
||||
port,
|
||||
run_migrations,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bind_addr(&self) -> SocketAddr {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
.parse()
|
||||
.expect("validated API host and port")
|
||||
}
|
||||
}
|
||||
13
apps/api/src/db.rs
Normal file
13
apps/api/src/db.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
PgPoolOptions::new()
|
||||
.max_connections(8)
|
||||
.acquire_timeout(Duration::from_secs(5))
|
||||
.connect(database_url)
|
||||
.await
|
||||
.context("failed to connect to PostgreSQL")
|
||||
}
|
||||
33
apps/api/src/error.rs
Normal file
33
apps/api/src/error.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("database error")]
|
||||
Database(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let body = Json(ErrorBody {
|
||||
error: self.to_string(),
|
||||
});
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
151
apps/api/src/handlers.rs
Normal file
151
apps/api/src/handlers.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{AreaScore, AreaScoreSummary, MarketOverview, MonthQuery};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: &'static str,
|
||||
pub service: &'static str,
|
||||
}
|
||||
|
||||
pub async fn health() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok",
|
||||
service: "shanghai-housing-api",
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<HealthResponse>> {
|
||||
sqlx::query("select 1").execute(&state.pool).await?;
|
||||
Ok(Json(HealthResponse {
|
||||
status: "ok",
|
||||
service: "shanghai-housing-api",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_area_scores(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<MonthQuery>,
|
||||
) -> ApiResult<Json<Vec<AreaScore>>> {
|
||||
validate_month(&query.month)?;
|
||||
let scores = fetch_area_scores(&state, &query.month).await?;
|
||||
Ok(Json(scores))
|
||||
}
|
||||
|
||||
pub async fn market_overview(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<MonthQuery>,
|
||||
) -> ApiResult<Json<MarketOverview>> {
|
||||
validate_month(&query.month)?;
|
||||
let scores = fetch_area_scores(&state, &query.month).await?;
|
||||
let area_count = scores.len();
|
||||
let average_investment_score = average(scores.iter().map(|score| score.investment_score));
|
||||
let average_rent_yield_pct = average(scores.iter().map(|score| score.annual_rent_yield_pct));
|
||||
let top_area = scores.first().map(AreaScoreSummary::from);
|
||||
let weakest_area = scores.last().map(AreaScoreSummary::from);
|
||||
let highest_supply_risk_area = scores
|
||||
.iter()
|
||||
.max_by(|left, right| {
|
||||
left.supply_risk_score
|
||||
.partial_cmp(&right.supply_risk_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(AreaScoreSummary::from);
|
||||
|
||||
Ok(Json(MarketOverview {
|
||||
month: query.month,
|
||||
area_count,
|
||||
average_investment_score,
|
||||
average_rent_yield_pct,
|
||||
top_area,
|
||||
weakest_area,
|
||||
highest_supply_risk_area,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
|
||||
sqlx::query_as::<_, AreaScore>(
|
||||
r#"
|
||||
SELECT
|
||||
area_id,
|
||||
name,
|
||||
district,
|
||||
segment,
|
||||
month,
|
||||
investment_score,
|
||||
recommendation,
|
||||
liquidity_score,
|
||||
momentum_score,
|
||||
rent_support_score,
|
||||
safety_margin_score,
|
||||
credit_support_score,
|
||||
supply_risk_score,
|
||||
valuation_pressure_score,
|
||||
transaction_count,
|
||||
transaction_price_psm,
|
||||
listing_count,
|
||||
listing_price_psm,
|
||||
rent_price_psm,
|
||||
median_days_on_market,
|
||||
annual_rent_yield_pct,
|
||||
listing_pressure_ratio,
|
||||
discount_pct,
|
||||
price_momentum_pct,
|
||||
volume_momentum_pct
|
||||
FROM gold.area_scores
|
||||
WHERE month = $1
|
||||
ORDER BY investment_score DESC, area_id ASC
|
||||
"#,
|
||||
)
|
||||
.bind(month)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
fn average(values: impl Iterator<Item = f64>) -> f64 {
|
||||
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
|
||||
(sum + value, count + 1)
|
||||
});
|
||||
if count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(sum / count as f64 * 10.0).round() / 10.0
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_month(month: &str) -> ApiResult<()> {
|
||||
let valid = month.len() == 7
|
||||
&& month.as_bytes()[4] == b'-'
|
||||
&& month[..4].chars().all(|item| item.is_ascii_digit())
|
||||
&& month[5..].chars().all(|item| item.is_ascii_digit())
|
||||
&& matches!(month[5..].parse::<u8>(), Ok(value) if (1..=12).contains(&value));
|
||||
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::BadRequest(
|
||||
"month must use YYYY-MM format".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_month;
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_month() {
|
||||
assert!(validate_month("2026-05").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_month() {
|
||||
assert!(validate_month("2026-5").is_err());
|
||||
assert!(validate_month("2026-13").is_err());
|
||||
assert!(validate_month("abcd-05").is_err());
|
||||
}
|
||||
}
|
||||
75
apps/api/src/main.rs
Normal file
75
apps/api/src/main.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::db::create_pool;
|
||||
use crate::routes::build_router;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "shanghai_housing_api=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
let pool = create_pool(&config.database_url).await?;
|
||||
|
||||
if config.run_migrations {
|
||||
info!("running database migrations");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.context("failed to run database migrations")?;
|
||||
}
|
||||
|
||||
let state = AppState { pool };
|
||||
let app = build_router(state);
|
||||
let listener = TcpListener::bind(config.bind_addr())
|
||||
.await
|
||||
.context("failed to bind API listener")?;
|
||||
|
||||
info!("listening on http://{}", listener.local_addr()?);
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.context("server failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
68
apps/api/src/models.rs
Normal file
68
apps/api/src/models.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MonthQuery {
|
||||
pub month: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct AreaScore {
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub district: String,
|
||||
pub segment: String,
|
||||
pub month: String,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
pub liquidity_score: f64,
|
||||
pub momentum_score: f64,
|
||||
pub rent_support_score: f64,
|
||||
pub safety_margin_score: f64,
|
||||
pub credit_support_score: f64,
|
||||
pub supply_risk_score: f64,
|
||||
pub valuation_pressure_score: f64,
|
||||
pub transaction_count: i32,
|
||||
pub transaction_price_psm: f64,
|
||||
pub listing_count: i32,
|
||||
pub listing_price_psm: f64,
|
||||
pub rent_price_psm: f64,
|
||||
pub median_days_on_market: f64,
|
||||
pub annual_rent_yield_pct: f64,
|
||||
pub listing_pressure_ratio: f64,
|
||||
pub discount_pct: f64,
|
||||
pub price_momentum_pct: f64,
|
||||
pub volume_momentum_pct: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaScoreSummary {
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub district: String,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
}
|
||||
|
||||
impl From<&AreaScore> for AreaScoreSummary {
|
||||
fn from(score: &AreaScore) -> Self {
|
||||
Self {
|
||||
area_id: score.area_id.clone(),
|
||||
name: score.name.clone(),
|
||||
district: score.district.clone(),
|
||||
investment_score: score.investment_score,
|
||||
recommendation: score.recommendation.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MarketOverview {
|
||||
pub month: String,
|
||||
pub area_count: usize,
|
||||
pub average_investment_score: f64,
|
||||
pub average_rent_yield_pct: f64,
|
||||
pub top_area: Option<AreaScoreSummary>,
|
||||
pub weakest_area: Option<AreaScoreSummary>,
|
||||
pub highest_supply_risk_area: Option<AreaScoreSummary>,
|
||||
}
|
||||
22
apps/api/src/routes.rs
Normal file
22
apps/api/src/routes.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::handlers::{health, list_area_scores, market_overview, ready};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/ready", get(ready))
|
||||
.nest(
|
||||
"/api/v1",
|
||||
Router::new()
|
||||
.route("/areas/scores", get(list_area_scores))
|
||||
.route("/market/overview", get(market_overview)),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state)
|
||||
}
|
||||
6
apps/api/src/state.rs
Normal file
6
apps/api/src/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
Reference in New Issue
Block a user