diff --git a/iad/todos/.gitignore b/iad/todos/.gitignore new file mode 100644 index 00000000..ffd57120 --- /dev/null +++ b/iad/todos/.gitignore @@ -0,0 +1,4 @@ + +/target +.shuttle* +Secrets*.toml diff --git a/iad/todos/Cargo.toml b/iad/todos/Cargo.toml new file mode 100644 index 00000000..e8611c39 --- /dev/null +++ b/iad/todos/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "todos" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +serde = { version = "1", features = ["derive"] } +shuttle-axum = "0.57.0" +shuttle-runtime = "0.57.0" +shuttle-shared-db = { version = "0.57.0", features = ["postgres", "sqlx"] } +sqlx = "0.8" +tokio = "1.28.2" diff --git a/iad/todos/README.md b/iad/todos/README.md new file mode 100644 index 00000000..39205d54 --- /dev/null +++ b/iad/todos/README.md @@ -0,0 +1,36 @@ +# Todo API - Infrastructure as Data with Shuttle + +A Rust-based Todo API demonstrating **Shuttle's Infrastructure as Data (IaD)** approach. This project showcases how Shuttle provisions and manages infrastructure resources through simple function annotations, eliminating the need for complex configuration files or manual cloud setup. + +## What is Infrastructure as Data? + +**Infrastructure as Data (IaD)** is Shuttle's approach to infrastructure provisioning where you declare your infrastructure needs directly in your code using Rust attributes. Instead of writing YAML files, Terraform scripts, or clicking through cloud consoles, you simply annotate your function parameters, and Shuttle handles the rest. + +### Key Benefits + +- **No Configuration Files**: Infrastructure defined in code, not YAML or JSON +- **Type-Safe**: Leverage Rust's type system for infrastructure +- **Automatic Provisioning**: Resources created on deployment +- **Zero DevOps**: No manual cloud console configuration +- **Instant Local Development**: Same code works locally and in production + +## Infrastructure as Data in Action + +This project demonstrates IaD with a PostgreSQL database: + +```rust +#[shuttle_runtime::main] +async fn main( + #[shuttle_shared_db::Postgres] pool: PgPool +) -> shuttle_axum::ShuttleAxum { + // Your database is ready to use! +} +``` + +**That's it!** With just one annotation (`#[shuttle_shared_db::Postgres]`), Shuttle: +1. ✅ Provisions a PostgreSQL database +2. ✅ Configures connection credentials +3. ✅ Injects a connection pool into your app +4. ✅ Manages the database lifecycle + +No environment variables, no connection strings, no manual setup. diff --git a/iad/todos/migrations/0001_init.sql b/iad/todos/migrations/0001_init.sql new file mode 100644 index 00000000..c395e9f6 --- /dev/null +++ b/iad/todos/migrations/0001_init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS todos ( + id serial PRIMARY KEY, + note TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + completed BOOLEAN NOT NULL DEFAULT FALSE +); \ No newline at end of file diff --git a/iad/todos/src/main.rs b/iad/todos/src/main.rs new file mode 100644 index 00000000..782d7a8f --- /dev/null +++ b/iad/todos/src/main.rs @@ -0,0 +1,77 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; + +async fn retrieve( + Path(id): Path, + State(state): State, +) -> Result { + match sqlx::query_as::<_, Todo>("SELECT * FROM todos WHERE id = $1") + .bind(id) + .fetch_one(&state.pool) + .await + { + Ok(todo) => Ok((StatusCode::OK, Json(todo))), + Err(e) => Err((StatusCode::BAD_REQUEST, e.to_string())), + } +} + +async fn add( + State(state): State, + Json(data): Json, +) -> Result { + match sqlx::query_as::<_, Todo>( + "INSERT INTO todos (note) VALUES ($1) RETURNING id, note, description, completed", + ) + .bind(&data.note) + .bind(&data.description) + .bind(data.completed) + .fetch_one(&state.pool) + .await + { + Ok(todo) => Ok((StatusCode::CREATED, Json(todo))), + Err(e) => Err((StatusCode::BAD_REQUEST, e.to_string())), + } +} + +#[derive(Clone)] +struct MyState { + pool: PgPool, +} + +#[shuttle_runtime::main] +async fn main(#[shuttle_shared_db::Postgres] pool: PgPool) -> shuttle_axum::ShuttleAxum { + sqlx::migrate!() + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = MyState { pool }; + let router = Router::new() + .route("/todos", post(add)) + .route("/todos/{id}", get(retrieve)) + .with_state(state); + + Ok(router.into()) +} + +#[derive(Deserialize)] +struct TodoNew { + pub note: String, + pub description: String, + pub completed: bool, +} + +#[derive(Serialize, FromRow)] +struct Todo { + pub id: i32, + pub note: String, + pub description: String, + pub completed: bool, +} diff --git a/iad/upload-manager/.gitignore b/iad/upload-manager/.gitignore new file mode 100644 index 00000000..ffd57120 --- /dev/null +++ b/iad/upload-manager/.gitignore @@ -0,0 +1,4 @@ + +/target +.shuttle* +Secrets*.toml diff --git a/iad/upload-manager/Cargo.toml b/iad/upload-manager/Cargo.toml new file mode 100644 index 00000000..1240c989 --- /dev/null +++ b/iad/upload-manager/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "upload-manager" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +shuttle-axum = "0.57.0" +shuttle-runtime = "0.57.0" +shuttle-aws-rds = { version = "0.57.0", features = ["postgres"] } +tokio = { version = "1.28.2", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.0", features = ["serde", "v4"] } diff --git a/iad/upload-manager/README.md b/iad/upload-manager/README.md new file mode 100644 index 00000000..a560655d --- /dev/null +++ b/iad/upload-manager/README.md @@ -0,0 +1,252 @@ +# S3 Upload Manager API + +A Rust-based API for managing S3 upload metadata with AWS RDS PostgreSQL database storage. This API stores details about files uploaded to S3 (assuming S3 upload is handled separately). + +## Features + +- **Create Upload Records**: Store metadata about S3 uploads in PostgreSQL +- **Retrieve Upload Records**: Get all uploads or specific upload by ID +- **AWS RDS Database**: Uses dedicated AWS RDS PostgreSQL database via Shuttle +- **UUID-based**: Each upload record gets a unique UUID identifier +- **Metadata Tracking**: Track filename, S3 key, bucket, URL, file size, content type, and timestamp + +## Prerequisites + +- Rust (latest stable version) +- Shuttle account (for deployment) +- S3 upload mechanism (handled separately) + +## AWS RDS Configuration + +This application uses **AWS RDS PostgreSQL** as its database backend. When you deploy with Shuttle, it will automatically provision a dedicated AWS RDS PostgreSQL instance for your application. + +### How It Works + +- Shuttle's `shuttle-aws-rds` resource automatically provisions an AWS RDS PostgreSQL database +- The database connection is injected into your application at runtime +- Database credentials and connection details are managed securely by Shuttle +- The database is dedicated to your application (not shared with other projects) + +### Database Provisioning + +On first deployment, Shuttle will: +1. Create a new AWS RDS PostgreSQL instance +2. Configure security groups and networking +3. Provide the connection pool to your application +4. Run the table creation migration automatically + +**Note**: AWS RDS provisioning may take a few minutes on first deployment. + +## Database Schema + +The application automatically creates the following table on startup: + +```sql +CREATE TABLE s3_uploads ( + id UUID PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + s3_key VARCHAR(512) NOT NULL, + s3_bucket VARCHAR(255) NOT NULL, + s3_url VARCHAR(512) NOT NULL, + file_size BIGINT NOT NULL, + content_type VARCHAR(100), + uploaded_at TIMESTAMPTZ NOT NULL +); +``` + +## API Endpoints + +### 1. Health Check +``` +GET / +``` +Returns a simple health check message. + +**Response:** +``` +S3 Upload Manager API is running! +``` + +### 2. Create Upload Record +``` +POST /uploads +``` +Store metadata about an S3 upload in the database. + +**Request Body:** +```json +{ + "filename": "document.pdf", + "s3_key": "uploads/2024/document.pdf", + "s3_bucket": "my-bucket", + "s3_url": "https://my-bucket.s3.amazonaws.com/uploads/2024/document.pdf", + "file_size": 1048576, + "content_type": "application/pdf" +} +``` + +**Example using curl:** +```bash +curl -X POST http://localhost:8000/uploads \ + -H "Content-Type: application/json" \ + -d '{ + "filename": "document.pdf", + "s3_key": "uploads/2024/document.pdf", + "s3_bucket": "my-bucket", + "s3_url": "https://my-bucket.s3.amazonaws.com/uploads/2024/document.pdf", + "file_size": 1048576, + "content_type": "application/pdf" + }' +``` + +**Response (201 Created):** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "filename": "document.pdf", + "s3_url": "https://my-bucket.s3.amazonaws.com/uploads/2024/document.pdf", + "uploaded_at": "2024-01-20T10:30:00Z" +} +``` + +### 3. Get All Upload Records +``` +GET /uploads +``` +Retrieve a list of all upload records, ordered by most recent first. + +**Response:** +```json +{ + "uploads": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "filename": "document.pdf", + "s3_key": "uploads/2024/document.pdf", + "s3_bucket": "my-bucket", + "s3_url": "https://my-bucket.s3.amazonaws.com/uploads/2024/document.pdf", + "file_size": 1048576, + "content_type": "application/pdf", + "uploaded_at": "2024-01-20T10:30:00Z" + } + ], + "count": 1 +} +``` + +### 4. Get Upload Record by ID +``` +GET /uploads/:id +``` +Retrieve metadata for a specific upload by its UUID. + +**Example:** +```bash +curl http://localhost:8000/uploads/550e8400-e29b-41d4-a716-446655440000 +``` + +**Response:** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "filename": "document.pdf", + "s3_key": "uploads/2024/document.pdf", + "s3_bucket": "my-bucket", + "s3_url": "https://my-bucket.s3.amazonaws.com/uploads/2024/document.pdf", + "file_size": 1048576, + "content_type": "application/pdf", + "uploaded_at": "2024-01-20T10:30:00Z" +} +``` + +## Request Validation + +The create endpoint validates: +- **filename**: Cannot be empty +- **s3_key**: Cannot be empty +- **file_size**: Must be greater than 0 + +## Error Responses + +The API returns appropriate HTTP status codes: + +- `200 OK`: Successful GET request +- `201 Created`: Upload record successfully created +- `400 Bad Request`: Invalid request (validation errors) +- `404 Not Found`: Upload record not found +- `500 Internal Server Error`: Server-side errors + +Error responses follow this format: +```json +{ + "error": "Error message description" +} +``` + +## Local Development + +1. Install dependencies: +```bash +cargo build +``` + +2. Run locally with Shuttle: +```bash +cargo shuttle run +``` + +The API will be available at `http://localhost:8000` + +## Deployment with Shuttle + +### Using Shuttle CLI + +1. Login to Shuttle: +```bash +cargo shuttle login +``` + +2. Deploy the application: +```bash +cargo shuttle deploy +``` + +### Using Shuttle MCP Server + +You can also use the Shuttle MCP server tools to deploy: + +1. List your projects: +```bash +# Use mcp0_project_list tool +``` + +2. Create a new project (if needed): +```bash +# Use mcp0_project_create tool with project name +``` + +3. Deploy: +```bash +# Use mcp0_deploy tool with project_id and working directory +``` + +4. Check deployment status: +```bash +# Use mcp0_deployment_status tool +``` + +## Integration Example + +This API is designed to work with a separate S3 upload mechanism. Here's a typical workflow: + +1. **Client uploads file to S3** (using pre-signed URLs, direct upload, etc.) +2. **After successful S3 upload**, client calls `POST /uploads` with the file metadata +3. **API stores the metadata** in PostgreSQL and returns the record ID +4. **Client can retrieve upload records** using `GET /uploads` or `GET /uploads/:id` + +## Technology Stack + +- **Framework**: Axum (Rust web framework) +- **Database**: PostgreSQL (via AWS RDS) +- **ORM**: SQLx +- **Deployment**: Shuttle diff --git a/iad/upload-manager/src/main.rs b/iad/upload-manager/src/main.rs new file mode 100644 index 00000000..0a1b04d9 --- /dev/null +++ b/iad/upload-manager/src/main.rs @@ -0,0 +1,239 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; + +// Application state +#[derive(Clone)] +struct AppState { + db: PgPool, +} + +// Database model for S3 upload records +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +struct S3Upload { + id: Uuid, + filename: String, + s3_key: String, + s3_bucket: String, + s3_url: String, + file_size: i64, + content_type: Option, + uploaded_at: DateTime, +} + +// Request model for creating upload record +#[derive(Debug, Deserialize)] +struct CreateUploadRequest { + filename: String, + s3_key: String, + s3_bucket: String, + s3_url: String, + file_size: i64, + content_type: Option, +} + +// Response models +#[derive(Debug, Serialize)] +struct CreateUploadResponse { + id: Uuid, + filename: String, + s3_url: String, + uploaded_at: DateTime, +} + +#[derive(Debug, Serialize)] +struct UploadListResponse { + uploads: Vec, + count: usize, +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +// Health check endpoint +async fn health_check() -> &'static str { + "S3 Upload Manager API is running!" +} + +// Create upload record - stores S3 upload details in database +async fn create_upload( + State(state): State>, + Json(payload): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + // Validate input + if payload.filename.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Filename cannot be empty".to_string(), + }), + )); + } + + if payload.s3_key.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "S3 key cannot be empty".to_string(), + }), + )); + } + + if payload.file_size <= 0 { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "File size must be greater than 0".to_string(), + }), + )); + } + + let upload_id = Uuid::new_v4(); + let uploaded_at = Utc::now(); + + // Insert upload record into database + sqlx::query( + r#" + INSERT INTO s3_uploads (id, filename, s3_key, s3_bucket, s3_url, file_size, content_type, uploaded_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(upload_id) + .bind(&payload.filename) + .bind(&payload.s3_key) + .bind(&payload.s3_bucket) + .bind(&payload.s3_url) + .bind(payload.file_size) + .bind(&payload.content_type) + .bind(uploaded_at) + .execute(&state.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to save upload record: {}", e), + }), + ) + })?; + + Ok(( + StatusCode::CREATED, + Json(CreateUploadResponse { + id: upload_id, + filename: payload.filename, + s3_url: payload.s3_url, + uploaded_at, + }), + )) +} + +// Get all upload records +async fn get_uploads( + State(state): State>, +) -> Result, (StatusCode, Json)> { + let uploads = sqlx::query_as::<_, S3Upload>( + r#" + SELECT id, filename, s3_key, s3_bucket, s3_url, file_size, content_type, uploaded_at + FROM s3_uploads + ORDER BY uploaded_at DESC + "#, + ) + .fetch_all(&state.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to fetch uploads: {}", e), + }), + ) + })?; + + let count = uploads.len(); + + Ok(Json(UploadListResponse { uploads, count })) +} + +// Get a specific upload record by ID +async fn get_upload_by_id( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let upload = sqlx::query_as::<_, S3Upload>( + r#" + SELECT id, filename, s3_key, s3_bucket, s3_url, file_size, content_type, uploaded_at + FROM s3_uploads + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Database error: {}", e), + }), + ) + })?; + + match upload { + Some(u) => Ok(Json(u)), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Upload record not found".to_string(), + }), + )), + } +} + +#[shuttle_runtime::main] +async fn main( + #[shuttle_aws_rds::Postgres] db: PgPool, +) -> shuttle_axum::ShuttleAxum { + // Run database migrations - create table if it doesn't exist + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS s3_uploads ( + id UUID PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + s3_key VARCHAR(512) NOT NULL, + s3_bucket VARCHAR(255) NOT NULL, + s3_url VARCHAR(512) NOT NULL, + file_size BIGINT NOT NULL, + content_type VARCHAR(100), + uploaded_at TIMESTAMPTZ NOT NULL + ) + "#, + ) + .execute(&db) + .await + .expect("Failed to create s3_uploads table"); + + // Create application state + let state = Arc::new(AppState { db }); + + // Build router with API endpoints + let router = Router::new() + .route("/", get(health_check)) + .route("/uploads", post(create_upload)) + .route("/uploads", get(get_uploads)) + .route("/uploads/:id", get(get_upload_by_id)) + .with_state(state); + + Ok(router.into()) +} diff --git a/templates.toml b/templates.toml index 126d5cb5..ed0a1863 100644 --- a/templates.toml +++ b/templates.toml @@ -390,6 +390,19 @@ path = "mcp/mcp-sse-oauth" use_cases = ["MCP", "AI", "AI Agents"] tags = ["axum", "mcp", "sse", "oauth"] +[templates.iad-todos] +title = "Rust Todo List with IaD Postgres" +description = "Todo list with a Postgres database provisioned by Infrastructure as Data" +path = "iad/todos" +use_cases = ["Web app", "IaD", "Storage"] +tags = ["axum", "postgres", "database", "iad"] + +[templates.iad-upload-manager] +title = "Rust API using AWS RDS Postgres for IaD" +description = "Upload manager API to manage S3 artifacts with AWS RDS Postgres database" +path = "iad/upload-manager" +use_cases = ["Web app", "IaD", "Storage"] +tags = ["axum", "postgres", "database", "iad"] [templates.mcp-http-stream] title = "Streamable HTTP MCP Server" description = "Model Context Protocol server with HTTP streaming transport"