techbro 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +453 -2
  2. package/assets/baseline-rules.yml +60 -0
  3. package/assets/caller-workflow.yml +26 -0
  4. package/assets/review-action.yml +27 -0
  5. package/assets/review-config.json +4 -0
  6. package/assets/review-workflow.yml +443 -0
  7. package/assets/review.mjs +721 -0
  8. package/bin/techbro.js +9 -0
  9. package/package.json +23 -4
  10. package/src/cli.js +690 -0
  11. package/src/cluster.js +134 -0
  12. package/src/config.js +35 -0
  13. package/src/doctor.js +125 -0
  14. package/src/github.js +152 -0
  15. package/src/init.js +167 -0
  16. package/src/kube-project.js +468 -0
  17. package/src/kube.js +317 -0
  18. package/src/managed.js +63 -0
  19. package/src/project.js +202 -0
  20. package/src/review.js +165 -0
  21. package/src/secret.js +80 -0
  22. package/src/setup.js +91 -0
  23. package/src/team-workflow.js +106 -0
  24. package/src/team.js +145 -0
  25. package/src/templates.js +44 -0
  26. package/src/upgrade.js +94 -0
  27. package/templates/_default/.agents/skills/README.md +36 -0
  28. package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
  29. package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
  30. package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
  31. package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
  32. package/templates/_default/.editorconfig +15 -0
  33. package/templates/_default/.env.example +9 -0
  34. package/templates/_default/.github/workflows/ci.yml +60 -0
  35. package/templates/_default/.nvmrc +1 -0
  36. package/templates/_default/Cargo.lock +1834 -0
  37. package/templates/_default/Cargo.toml +3 -0
  38. package/templates/_default/README.md +257 -0
  39. package/templates/_default/TEMPLATE.md +29 -0
  40. package/templates/_default/_dockerignore +8 -0
  41. package/templates/_default/_gitignore +8 -0
  42. package/templates/_default/apps/backend/Cargo.toml +22 -0
  43. package/templates/_default/apps/backend/Dockerfile +34 -0
  44. package/templates/_default/apps/backend/README.md +13 -0
  45. package/templates/_default/apps/backend/src/config.rs +111 -0
  46. package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
  47. package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
  48. package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
  49. package/templates/_default/apps/backend/src/error.rs +115 -0
  50. package/templates/_default/apps/backend/src/lib.rs +7 -0
  51. package/templates/_default/apps/backend/src/main.rs +71 -0
  52. package/templates/_default/apps/backend/src/models/item.rs +94 -0
  53. package/templates/_default/apps/backend/src/models/mod.rs +1 -0
  54. package/templates/_default/apps/backend/src/routes.rs +94 -0
  55. package/templates/_default/apps/backend/src/state.rs +12 -0
  56. package/templates/_default/apps/backend/src/views/items.rs +18 -0
  57. package/templates/_default/apps/backend/src/views/mod.rs +1 -0
  58. package/templates/_default/apps/backend/tests/api.rs +234 -0
  59. package/templates/_default/apps/frontend/.env.example +2 -0
  60. package/templates/_default/apps/frontend/.prettierignore +3 -0
  61. package/templates/_default/apps/frontend/.prettierrc +1 -0
  62. package/templates/_default/apps/frontend/Dockerfile +15 -0
  63. package/templates/_default/apps/frontend/README.md +39 -0
  64. package/templates/_default/apps/frontend/_dockerignore +3 -0
  65. package/templates/_default/apps/frontend/_gitignore +24 -0
  66. package/templates/_default/apps/frontend/components.json +25 -0
  67. package/templates/_default/apps/frontend/eslint.config.js +37 -0
  68. package/templates/_default/apps/frontend/index.html +13 -0
  69. package/templates/_default/apps/frontend/jsconfig.json +7 -0
  70. package/templates/_default/apps/frontend/nginx.conf +30 -0
  71. package/templates/_default/apps/frontend/package-lock.json +4338 -0
  72. package/templates/_default/apps/frontend/package.json +49 -0
  73. package/templates/_default/apps/frontend/public/favicon.svg +1 -0
  74. package/templates/_default/apps/frontend/src/App.jsx +11 -0
  75. package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
  76. package/templates/_default/apps/frontend/src/api/client.js +50 -0
  77. package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
  78. package/templates/_default/apps/frontend/src/api/items.js +26 -0
  79. package/templates/_default/apps/frontend/src/api/system.js +11 -0
  80. package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
  81. package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
  82. package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
  83. package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
  84. package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
  85. package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
  86. package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
  87. package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
  88. package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
  89. package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
  90. package/templates/_default/apps/frontend/src/index.css +130 -0
  91. package/templates/_default/apps/frontend/src/lib/config.js +2 -0
  92. package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
  93. package/templates/_default/apps/frontend/src/main.jsx +11 -0
  94. package/templates/_default/apps/frontend/src/models/items.js +24 -0
  95. package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
  96. package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
  97. package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
  98. package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
  99. package/templates/_default/apps/frontend/src/routes.jsx +17 -0
  100. package/templates/_default/apps/frontend/src/shadcn.css +655 -0
  101. package/templates/_default/apps/frontend/src/test/setup.js +1 -0
  102. package/templates/_default/apps/frontend/vite.config.js +25 -0
  103. package/templates/_default/database/Dockerfile +9 -0
  104. package/templates/_default/database/README.md +15 -0
  105. package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
  106. package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
  107. package/templates/_default/docker-compose.prod.yml +53 -0
  108. package/templates/_default/docker-compose.yml +69 -0
  109. package/templates/_default/docs/api/README.md +41 -0
  110. package/templates/_default/docs/api/openapi.yaml +152 -0
  111. package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
  112. package/templates/_default/docs/decisions/README.md +6 -0
  113. package/templates/_default/docs/deployment.md +46 -0
  114. package/templates/_default/docs/docker.md +63 -0
  115. package/templates/_default/docs/getting-started.md +214 -0
  116. package/templates/_default/package.json +15 -0
  117. package/templates/_default/rust-toolchain.toml +3 -0
  118. package/templates/_default/scripts/check +10 -0
  119. package/templates/_default/scripts/db-migrate +25 -0
  120. package/templates/_default/scripts/install-hooks +5 -0
  121. package/templates/_default/scripts/pre-push +3 -0
  122. package/templates/_default/scripts/setup +26 -0
@@ -0,0 +1,115 @@
1
+ use axum::{
2
+ Json,
3
+ http::StatusCode,
4
+ response::{IntoResponse, Response},
5
+ };
6
+ use serde::Serialize;
7
+ use serde_json::json;
8
+
9
+ /// Request id stored in request extensions and in a task-local for error bodies.
10
+ #[derive(Debug, Clone)]
11
+ pub struct RequestId(pub String);
12
+
13
+ tokio::task_local! {
14
+ pub static CURRENT_REQUEST_ID: String;
15
+ }
16
+
17
+ #[derive(Debug, Clone, Serialize)]
18
+ pub struct FieldError {
19
+ pub field: String,
20
+ pub message: String,
21
+ }
22
+
23
+ #[derive(Debug, thiserror::Error)]
24
+ pub enum AppError {
25
+ #[error("not found")]
26
+ NotFound,
27
+ #[error("validation failed: {message}")]
28
+ Validation {
29
+ message: String,
30
+ details: Vec<FieldError>,
31
+ },
32
+ #[error("database error: {0}")]
33
+ Database(#[from] sqlx::Error),
34
+ #[error("internal error: {0}")]
35
+ Internal(String),
36
+ #[error("service unavailable")]
37
+ Unavailable,
38
+ #[error("draining")]
39
+ Draining,
40
+ }
41
+
42
+ impl AppError {
43
+ pub fn validation(field: &str, message: &str) -> Self {
44
+ Self::Validation {
45
+ message: "Invalid input".into(),
46
+ details: vec![FieldError {
47
+ field: field.into(),
48
+ message: message.into(),
49
+ }],
50
+ }
51
+ }
52
+ }
53
+
54
+ impl IntoResponse for AppError {
55
+ fn into_response(self) -> Response {
56
+ let (status, code, message, details) = match &self {
57
+ AppError::NotFound => (
58
+ StatusCode::NOT_FOUND,
59
+ "NOT_FOUND",
60
+ "Resource not found".to_string(),
61
+ None,
62
+ ),
63
+ AppError::Validation { message, details } => (
64
+ StatusCode::BAD_REQUEST,
65
+ "VALIDATION_ERROR",
66
+ message.clone(),
67
+ Some(details.clone()),
68
+ ),
69
+ AppError::Draining => (
70
+ StatusCode::SERVICE_UNAVAILABLE,
71
+ "DRAINING",
72
+ "Server is shutting down".to_string(),
73
+ None,
74
+ ),
75
+ AppError::Unavailable => (
76
+ StatusCode::SERVICE_UNAVAILABLE,
77
+ "SERVICE_UNAVAILABLE",
78
+ "Service not ready".to_string(),
79
+ None,
80
+ ),
81
+ AppError::Database(e) => {
82
+ tracing::error!(error = %e, "database error");
83
+ (
84
+ StatusCode::INTERNAL_SERVER_ERROR,
85
+ "INTERNAL_ERROR",
86
+ "Internal server error".to_string(),
87
+ None,
88
+ )
89
+ }
90
+ AppError::Internal(e) => {
91
+ tracing::error!(error = %e, "internal error");
92
+ (
93
+ StatusCode::INTERNAL_SERVER_ERROR,
94
+ "INTERNAL_ERROR",
95
+ "Internal server error".to_string(),
96
+ None,
97
+ )
98
+ }
99
+ };
100
+ let request_id = CURRENT_REQUEST_ID
101
+ .try_with(Clone::clone)
102
+ .unwrap_or_default();
103
+ let mut error = json!({ "code": code, "message": message });
104
+ if let Some(d) = details
105
+ && !d.is_empty()
106
+ {
107
+ error["details"] = json!(d);
108
+ }
109
+ (
110
+ status,
111
+ Json(json!({ "error": error, "requestId": request_id })),
112
+ )
113
+ .into_response()
114
+ }
115
+ }
@@ -0,0 +1,7 @@
1
+ pub mod config;
2
+ pub mod controllers;
3
+ pub mod error;
4
+ pub mod models;
5
+ pub mod routes;
6
+ pub mod state;
7
+ pub mod views;
@@ -0,0 +1,71 @@
1
+ use std::{
2
+ error::Error,
3
+ sync::{
4
+ Arc,
5
+ atomic::{AtomicBool, Ordering},
6
+ },
7
+ time::Duration,
8
+ };
9
+
10
+ use backend::{config::Config, routes, state::AppState};
11
+ use sqlx::any::AnyPoolOptions;
12
+ use tracing_subscriber::EnvFilter;
13
+
14
+ #[tokio::main]
15
+ async fn main() -> Result<(), Box<dyn Error>> {
16
+ dotenvy::dotenv().ok();
17
+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
18
+ tracing_subscriber::fmt()
19
+ .json()
20
+ .with_env_filter(filter)
21
+ .with_current_span(true)
22
+ .with_span_list(false)
23
+ .init();
24
+
25
+ let config = Config::from_env()?;
26
+ sqlx::any::install_default_drivers();
27
+ let pool = AnyPoolOptions::new()
28
+ .max_connections(5)
29
+ .connect(&config.database_url)
30
+ .await?;
31
+
32
+ let addr = format!("0.0.0.0:{}", config.port);
33
+ let app_name = config.app_name.clone();
34
+ let drain = Duration::from_secs(config.shutdown_drain_seconds);
35
+ let draining = Arc::new(AtomicBool::new(false));
36
+ let app = routes::router(AppState {
37
+ pool: pool.clone(),
38
+ config: Arc::new(config),
39
+ draining: draining.clone(),
40
+ });
41
+ let listener = tokio::net::TcpListener::bind(&addr).await?;
42
+ tracing::info!(app = %app_name, %addr, "server listening");
43
+ axum::serve(listener, app)
44
+ .with_graceful_shutdown(shutdown_signal(draining, drain))
45
+ .await?;
46
+ pool.close().await;
47
+ tracing::info!("shutdown complete");
48
+ Ok(())
49
+ }
50
+
51
+ /// Flags the server as draining so /ready fails, then waits before the listener closes.
52
+ async fn shutdown_signal(draining: Arc<AtomicBool>, drain: Duration) {
53
+ let ctrl_c = async {
54
+ let _ = tokio::signal::ctrl_c().await;
55
+ };
56
+ #[cfg(unix)]
57
+ let term = async {
58
+ match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
59
+ Ok(mut s) => {
60
+ s.recv().await;
61
+ }
62
+ Err(_) => std::future::pending::<()>().await,
63
+ }
64
+ };
65
+ #[cfg(not(unix))]
66
+ let term = std::future::pending::<()>();
67
+ tokio::select! { _ = ctrl_c => {}, _ = term => {} }
68
+ tracing::info!("shutdown signal received, draining");
69
+ draining.store(true, Ordering::SeqCst);
70
+ tokio::time::sleep(drain).await;
71
+ }
@@ -0,0 +1,94 @@
1
+ use serde::Serialize;
2
+ use sqlx::{AnyPool, FromRow};
3
+
4
+ use crate::error::AppError;
5
+
6
+ const MAX_NAME_LEN: usize = 200;
7
+
8
+ #[derive(Debug, Clone, Serialize, FromRow)]
9
+ #[serde(rename_all = "camelCase")]
10
+ pub struct Item {
11
+ pub id: i64,
12
+ pub name: String,
13
+ pub created_at: String,
14
+ }
15
+
16
+ /// Returns the trimmed name, or a validation error.
17
+ pub fn validate_name(raw: &str) -> Result<String, AppError> {
18
+ let name = raw.trim();
19
+ if name.is_empty() {
20
+ return Err(AppError::validation("name", "Must not be empty"));
21
+ }
22
+ if name.chars().count() > MAX_NAME_LEN {
23
+ return Err(AppError::validation(
24
+ "name",
25
+ "Must be at most 200 characters",
26
+ ));
27
+ }
28
+ Ok(name.to_string())
29
+ }
30
+
31
+ // CAST keeps created_at decodable as text on both SQLite and PostgreSQL.
32
+ macro_rules! columns {
33
+ () => {
34
+ "id, name, CAST(created_at AS TEXT) AS created_at"
35
+ };
36
+ }
37
+
38
+ pub async fn list(
39
+ pool: &AnyPool,
40
+ limit: i64,
41
+ offset: i64,
42
+ ) -> Result<(Vec<Item>, i64), sqlx::Error> {
43
+ let items = sqlx::query_as::<_, Item>(concat!(
44
+ "SELECT ",
45
+ columns!(),
46
+ " FROM items ORDER BY id LIMIT $1 OFFSET $2"
47
+ ))
48
+ .bind(limit)
49
+ .bind(offset)
50
+ .fetch_all(pool)
51
+ .await?;
52
+ let total = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items")
53
+ .fetch_one(pool)
54
+ .await?;
55
+ Ok((items, total))
56
+ }
57
+
58
+ pub async fn create(pool: &AnyPool, name: &str) -> Result<Item, sqlx::Error> {
59
+ sqlx::query_as::<_, Item>(concat!(
60
+ "INSERT INTO items (name) VALUES ($1) RETURNING ",
61
+ columns!()
62
+ ))
63
+ .bind(name)
64
+ .fetch_one(pool)
65
+ .await
66
+ }
67
+
68
+ pub async fn find(pool: &AnyPool, id: i64) -> Result<Option<Item>, sqlx::Error> {
69
+ sqlx::query_as::<_, Item>(concat!("SELECT ", columns!(), " FROM items WHERE id = $1"))
70
+ .bind(id)
71
+ .fetch_optional(pool)
72
+ .await
73
+ }
74
+
75
+ #[cfg(test)]
76
+ mod tests {
77
+ use super::validate_name;
78
+
79
+ #[test]
80
+ fn trims_valid_name() {
81
+ assert_eq!(validate_name(" box ").unwrap(), "box");
82
+ }
83
+
84
+ #[test]
85
+ fn rejects_blank_name() {
86
+ assert!(validate_name(" ").is_err());
87
+ }
88
+
89
+ #[test]
90
+ fn rejects_long_name() {
91
+ assert!(validate_name(&"a".repeat(201)).is_err());
92
+ assert!(validate_name(&"a".repeat(200)).is_ok());
93
+ }
94
+ }
@@ -0,0 +1 @@
1
+ pub mod item;
@@ -0,0 +1,94 @@
1
+ use axum::{
2
+ Router,
3
+ extract::Request,
4
+ http::{HeaderName, HeaderValue, Method, header},
5
+ middleware::{self, Next},
6
+ response::Response,
7
+ routing::get,
8
+ };
9
+ use tower_http::{cors::CorsLayer, trace::TraceLayer};
10
+
11
+ use crate::{
12
+ controllers::{health, items},
13
+ error::{CURRENT_REQUEST_ID, RequestId},
14
+ state::AppState,
15
+ };
16
+
17
+ const REQUEST_ID_HEADER: &str = "x-request-id";
18
+ const TRACE_ID_HEADER: &str = "x-trace-id";
19
+
20
+ #[derive(Clone)]
21
+ struct TraceId(String);
22
+
23
+ async fn trace_id(mut req: Request, next: Next) -> Response {
24
+ let id = req
25
+ .headers()
26
+ .get(TRACE_ID_HEADER)
27
+ .and_then(|v| v.to_str().ok())
28
+ .filter(|v| uuid::Uuid::parse_str(v).is_ok())
29
+ .map(str::to_string)
30
+ .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
31
+ req.extensions_mut().insert(TraceId(id.clone()));
32
+ let mut res = next.run(req).await;
33
+ if let Ok(value) = HeaderValue::from_str(&id) {
34
+ res.headers_mut()
35
+ .insert(HeaderName::from_static(TRACE_ID_HEADER), value);
36
+ }
37
+ res
38
+ }
39
+
40
+ async fn request_id(mut req: Request, next: Next) -> Response {
41
+ let id = req
42
+ .headers()
43
+ .get(REQUEST_ID_HEADER)
44
+ .and_then(|v| v.to_str().ok())
45
+ .filter(|v| !v.is_empty() && v.len() <= 128)
46
+ .map(str::to_string)
47
+ .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
48
+ req.extensions_mut().insert(RequestId(id.clone()));
49
+ let mut res = CURRENT_REQUEST_ID.scope(id.clone(), next.run(req)).await;
50
+ if let Ok(v) = HeaderValue::from_str(&id) {
51
+ res.headers_mut()
52
+ .insert(HeaderName::from_static(REQUEST_ID_HEADER), v);
53
+ }
54
+ res
55
+ }
56
+
57
+ pub fn router(state: AppState) -> Router {
58
+ let cors = CorsLayer::new()
59
+ .allow_origin(state.config.cors_allowed_origins.clone())
60
+ .allow_methods([Method::GET, Method::POST])
61
+ .allow_headers([
62
+ header::CONTENT_TYPE,
63
+ HeaderName::from_static(REQUEST_ID_HEADER),
64
+ HeaderName::from_static(TRACE_ID_HEADER),
65
+ ])
66
+ .expose_headers([HeaderName::from_static(TRACE_ID_HEADER)]);
67
+ let app_name = state.config.app_name.clone();
68
+ let trace = TraceLayer::new_for_http().make_span_with(move |req: &Request| {
69
+ let rid = req
70
+ .extensions()
71
+ .get::<RequestId>()
72
+ .map(|r| r.0.as_str())
73
+ .unwrap_or("");
74
+ tracing::info_span!(
75
+ "request",
76
+ app = %app_name,
77
+ method = %req.method(),
78
+ path = %req.uri().path(),
79
+ request_id = %rid,
80
+ trace_id = %req.extensions().get::<TraceId>().map(|id| id.0.as_str()).unwrap_or(""),
81
+ )
82
+ });
83
+
84
+ Router::new()
85
+ .route("/health", get(health::health))
86
+ .route("/ready", get(health::ready))
87
+ .route("/api/v1/items", get(items::list).post(items::create))
88
+ .route("/api/v1/items/{id}", get(items::get))
89
+ .with_state(state)
90
+ .layer(trace)
91
+ .layer(middleware::from_fn(request_id))
92
+ .layer(cors)
93
+ .layer(middleware::from_fn(trace_id))
94
+ }
@@ -0,0 +1,12 @@
1
+ use std::sync::{Arc, atomic::AtomicBool};
2
+
3
+ use sqlx::AnyPool;
4
+
5
+ use crate::config::Config;
6
+
7
+ #[derive(Clone)]
8
+ pub struct AppState {
9
+ pub pool: AnyPool,
10
+ pub config: Arc<Config>,
11
+ pub draining: Arc<AtomicBool>,
12
+ }
@@ -0,0 +1,18 @@
1
+ use serde::Serialize;
2
+
3
+ use crate::models::item::Item;
4
+
5
+ #[derive(Debug, Serialize)]
6
+ #[serde(rename_all = "camelCase")]
7
+ pub struct Pagination {
8
+ pub page: i64,
9
+ pub per_page: i64,
10
+ pub total: i64,
11
+ }
12
+
13
+ #[derive(Debug, Serialize)]
14
+ #[serde(rename_all = "camelCase")]
15
+ pub struct ItemListResponse {
16
+ pub items: Vec<Item>,
17
+ pub pagination: Pagination,
18
+ }
@@ -0,0 +1 @@
1
+ pub mod items;
@@ -0,0 +1,234 @@
1
+ use std::sync::{
2
+ Arc,
3
+ atomic::{AtomicBool, Ordering},
4
+ };
5
+
6
+ use axum::{
7
+ Router,
8
+ body::Body,
9
+ http::{Request, StatusCode, header},
10
+ };
11
+ use backend::{config::Config, routes, state::AppState};
12
+ use http_body_util::BodyExt;
13
+ use serde_json::{Value, json};
14
+ use sqlx::any::AnyPoolOptions;
15
+ use tower::ServiceExt;
16
+
17
+ async fn app() -> Router {
18
+ app_with_flag().await.0
19
+ }
20
+
21
+ async fn app_with_flag() -> (Router, Arc<AtomicBool>) {
22
+ sqlx::any::install_default_drivers();
23
+ let pool = AnyPoolOptions::new()
24
+ .max_connections(1)
25
+ .connect("sqlite::memory:")
26
+ .await
27
+ .expect("connect sqlite");
28
+ sqlx::raw_sql(include_str!(
29
+ "../../../database/migrations/sqlite/20261008120000_create_items.sql"
30
+ ))
31
+ .execute(&pool)
32
+ .await
33
+ .expect("apply migration");
34
+ let config = Config {
35
+ app_name: "test".into(),
36
+ port: 0,
37
+ database_url: "sqlite::memory:".into(),
38
+ cors_allowed_origins: vec!["http://localhost:5173".parse().expect("origin")],
39
+ shutdown_drain_seconds: 0,
40
+ };
41
+ let draining = Arc::new(AtomicBool::new(false));
42
+ let router = routes::router(AppState {
43
+ pool,
44
+ config: Arc::new(config),
45
+ draining: draining.clone(),
46
+ });
47
+ (router, draining)
48
+ }
49
+
50
+ async fn send(app: &Router, req: Request<Body>) -> (StatusCode, Value) {
51
+ let res = app.clone().oneshot(req).await.expect("response");
52
+ let status = res.status();
53
+ let bytes = res.into_body().collect().await.expect("body").to_bytes();
54
+ (
55
+ status,
56
+ serde_json::from_slice(&bytes).unwrap_or(Value::Null),
57
+ )
58
+ }
59
+
60
+ fn get(uri: &str) -> Request<Body> {
61
+ Request::get(uri).body(Body::empty()).expect("request")
62
+ }
63
+
64
+ const TRACE_ID: &str = "5d632031-f1ac-4a8f-8837-b4b2bef63596";
65
+
66
+ #[tokio::test]
67
+ async fn trace_id_echo_generation_and_errors() {
68
+ let app = app().await;
69
+ for (path, supplied) in [
70
+ ("/health", Some(TRACE_ID)),
71
+ ("/health", None),
72
+ ("/health", Some("not-a-uuid")),
73
+ ("/api/v1/items/999", Some(TRACE_ID)),
74
+ ("/unknown", Some(TRACE_ID)),
75
+ ("/api/v1/items/not-an-integer", Some(TRACE_ID)),
76
+ ] {
77
+ let mut req = Request::get(path).header("x-request-id", "existing-request");
78
+ if let Some(id) = supplied {
79
+ req = req.header("x-trace-id", id);
80
+ }
81
+ let res = app
82
+ .clone()
83
+ .oneshot(req.body(Body::empty()).expect("request"))
84
+ .await
85
+ .expect("response");
86
+ let trace = res
87
+ .headers()
88
+ .get("x-trace-id")
89
+ .expect("trace header")
90
+ .to_str()
91
+ .expect("header text");
92
+ assert!(uuid::Uuid::parse_str(trace).is_ok());
93
+ if supplied == Some(TRACE_ID) {
94
+ assert_eq!(trace, TRACE_ID);
95
+ }
96
+ assert_eq!(res.headers()["x-request-id"], "existing-request");
97
+ if path == "/api/v1/items/999" {
98
+ let bytes = res.into_body().collect().await.expect("body").to_bytes();
99
+ let body: Value = serde_json::from_slice(&bytes).expect("json");
100
+ assert_eq!(body["requestId"], "existing-request");
101
+ assert!(body.get("traceId").is_none());
102
+ }
103
+ }
104
+ }
105
+
106
+ #[tokio::test]
107
+ async fn cors_allows_and_exposes_trace_only_for_configured_origins() {
108
+ let app = app().await;
109
+ for origin in ["http://localhost:5173", "https://untrusted.example"] {
110
+ let res = app
111
+ .clone()
112
+ .oneshot(
113
+ Request::builder()
114
+ .method("OPTIONS")
115
+ .uri("/health")
116
+ .header("origin", origin)
117
+ .header("access-control-request-method", "GET")
118
+ .header(
119
+ "access-control-request-headers",
120
+ "x-trace-id,x-request-id,content-type",
121
+ )
122
+ .body(Body::empty())
123
+ .expect("request"),
124
+ )
125
+ .await
126
+ .expect("response");
127
+ assert!(
128
+ res.headers()["access-control-allow-headers"]
129
+ .to_str()
130
+ .expect("headers")
131
+ .contains("x-trace-id")
132
+ );
133
+ assert_eq!(
134
+ res.headers().get("access-control-allow-origin").is_some(),
135
+ origin == "http://localhost:5173"
136
+ );
137
+ assert!(res.headers().contains_key("x-trace-id"));
138
+ }
139
+ let res = app
140
+ .oneshot(
141
+ Request::get("/health")
142
+ .header("origin", "http://localhost:5173")
143
+ .body(Body::empty())
144
+ .expect("request"),
145
+ )
146
+ .await
147
+ .expect("response");
148
+ assert!(
149
+ res.headers()["access-control-expose-headers"]
150
+ .to_str()
151
+ .expect("headers")
152
+ .contains("x-trace-id")
153
+ );
154
+ assert!(
155
+ !res.headers()
156
+ .contains_key("access-control-allow-credentials")
157
+ );
158
+ }
159
+
160
+ fn post(uri: &str, body: Value) -> Request<Body> {
161
+ Request::post(uri)
162
+ .header(header::CONTENT_TYPE, "application/json")
163
+ .body(Body::from(body.to_string()))
164
+ .expect("request")
165
+ }
166
+
167
+ #[tokio::test]
168
+ async fn health_and_ready() {
169
+ let app = app().await;
170
+ let (s, b) = send(&app, get("/health")).await;
171
+ assert_eq!((s, b["status"].as_str()), (StatusCode::OK, Some("ok")));
172
+ let (s, _) = send(&app, get("/ready")).await;
173
+ assert_eq!(s, StatusCode::OK);
174
+ }
175
+
176
+ #[tokio::test]
177
+ async fn create_then_list_and_get() {
178
+ let app = app().await;
179
+ let (s, created) = send(&app, post("/api/v1/items", json!({"name": " widget "}))).await;
180
+ assert_eq!(s, StatusCode::CREATED);
181
+ assert_eq!(created["name"], "widget");
182
+ assert!(created["createdAt"].is_string());
183
+
184
+ let (s, list) = send(&app, get("/api/v1/items")).await;
185
+ assert_eq!(s, StatusCode::OK);
186
+ assert_eq!(list["items"].as_array().map(Vec::len), Some(1));
187
+ assert_eq!(list["pagination"]["page"], 1);
188
+ assert_eq!(list["pagination"]["perPage"], 20);
189
+ assert_eq!(list["pagination"]["total"], 1);
190
+
191
+ let uri = format!("/api/v1/items/{}", created["id"]);
192
+ let (s, one) = send(&app, get(&uri)).await;
193
+ assert_eq!(s, StatusCode::OK);
194
+ assert_eq!(one["name"], "widget");
195
+ }
196
+
197
+ #[tokio::test]
198
+ async fn missing_item_is_404_with_error_shape() {
199
+ let app = app().await;
200
+ let (s, b) = send(&app, get("/api/v1/items/999")).await;
201
+ assert_eq!(s, StatusCode::NOT_FOUND);
202
+ assert_eq!(b["error"]["code"], "NOT_FOUND");
203
+ assert!(b["requestId"].as_str().is_some_and(|r| !r.is_empty()));
204
+ }
205
+
206
+ #[tokio::test]
207
+ async fn blank_name_is_validation_error() {
208
+ let app = app().await;
209
+ let (s, b) = send(&app, post("/api/v1/items", json!({"name": " "}))).await;
210
+ assert_eq!(s, StatusCode::BAD_REQUEST);
211
+ assert_eq!(b["error"]["code"], "VALIDATION_ERROR");
212
+ assert_eq!(b["error"]["details"][0]["field"], "name");
213
+ assert!(b["requestId"].is_string());
214
+ }
215
+
216
+ #[tokio::test]
217
+ async fn per_page_over_max_is_rejected() {
218
+ let app = app().await;
219
+ let (s, b) = send(&app, get("/api/v1/items?perPage=101")).await;
220
+ assert_eq!(s, StatusCode::BAD_REQUEST);
221
+ assert_eq!(b["error"]["code"], "VALIDATION_ERROR");
222
+ }
223
+
224
+ #[tokio::test]
225
+ async fn ready_is_503_when_draining() {
226
+ let (app, flag) = app_with_flag().await;
227
+ flag.store(true, Ordering::SeqCst);
228
+ let (s, b) = send(&app, get("/ready")).await;
229
+ assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
230
+ assert_eq!(b["error"]["code"], "DRAINING");
231
+ let (s, b) = send(&app, get("/health")).await;
232
+ assert_eq!(s, StatusCode::OK);
233
+ assert!(b["version"].is_string() && b["build"].is_string());
234
+ }
@@ -0,0 +1,2 @@
1
+ VITE_API_BASE_URL=
2
+ VITE_APP_NAME=
@@ -0,0 +1,3 @@
1
+ dist
2
+ node_modules
3
+ package-lock.json
@@ -0,0 +1 @@
1
+ { "semi": true, "singleQuote": false, "trailingComma": "all", "printWidth": 100 }
@@ -0,0 +1,15 @@
1
+ FROM node:22-alpine AS build
2
+ WORKDIR /app
3
+ COPY package.json package-lock.json ./
4
+ RUN npm ci
5
+ COPY . .
6
+ ARG VITE_API_BASE_URL=""
7
+ ARG VITE_APP_NAME=""
8
+ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
9
+ VITE_APP_NAME=$VITE_APP_NAME
10
+ RUN npm run build
11
+
12
+ FROM nginxinc/nginx-unprivileged:stable-alpine
13
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
14
+ COPY --from=build /app/dist /usr/share/nginx/html
15
+ EXPOSE 8080