diff --git a/axum/code-snippet-sharing-app/.sqlx/query-40f717379ce15156cabe7bd200bacf5ddf9c87a872574afeb20289627a897e7a.json b/axum/code-snippet-sharing-app/.sqlx/query-40f717379ce15156cabe7bd200bacf5ddf9c87a872574afeb20289627a897e7a.json new file mode 100644 index 00000000..61f62aa5 --- /dev/null +++ b/axum/code-snippet-sharing-app/.sqlx/query-40f717379ce15156cabe7bd200bacf5ddf9c87a872574afeb20289627a897e7a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM snippets WHERE expires_at IS NOT NULL AND expires_at <= $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "40f717379ce15156cabe7bd200bacf5ddf9c87a872574afeb20289627a897e7a" +} diff --git a/axum/code-snippet-sharing-app/.sqlx/query-49969fd63526646bb069216573be8ad247d633fa8a073bdc0c6648089b5b0d4e.json b/axum/code-snippet-sharing-app/.sqlx/query-49969fd63526646bb069216573be8ad247d633fa8a073bdc0c6648089b5b0d4e.json new file mode 100644 index 00000000..9303ddfc --- /dev/null +++ b/axum/code-snippet-sharing-app/.sqlx/query-49969fd63526646bb069216573be8ad247d633fa8a073bdc0c6648089b5b0d4e.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n title,\n description,\n language,\n created_at,\n expires_at,\n view_count,\n LENGTH(content) as \"char_count!\"\n FROM snippets\n WHERE is_public = true\n AND (expires_at IS NULL OR expires_at > $1)\n AND language = $2\n ORDER BY created_at DESC\n LIMIT $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "language", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "view_count", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "char_count!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Timestamptz", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + false, + null + ] + }, + "hash": "49969fd63526646bb069216573be8ad247d633fa8a073bdc0c6648089b5b0d4e" +} diff --git a/axum/code-snippet-sharing-app/.sqlx/query-4d8ecc221df32f11c2b6143999f130ff18e38de22b685489bb6f8eb0abacf281.json b/axum/code-snippet-sharing-app/.sqlx/query-4d8ecc221df32f11c2b6143999f130ff18e38de22b685489bb6f8eb0abacf281.json new file mode 100644 index 00000000..76897346 --- /dev/null +++ b/axum/code-snippet-sharing-app/.sqlx/query-4d8ecc221df32f11c2b6143999f130ff18e38de22b685489bb6f8eb0abacf281.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE snippets SET view_count = view_count + 1 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "4d8ecc221df32f11c2b6143999f130ff18e38de22b685489bb6f8eb0abacf281" +} diff --git a/axum/code-snippet-sharing-app/.sqlx/query-89b5d73a044dbac1f3463535b7d6eb431b51deb808b8b92c547acca53bb6fe55.json b/axum/code-snippet-sharing-app/.sqlx/query-89b5d73a044dbac1f3463535b7d6eb431b51deb808b8b92c547acca53bb6fe55.json new file mode 100644 index 00000000..b79fed91 --- /dev/null +++ b/axum/code-snippet-sharing-app/.sqlx/query-89b5d73a044dbac1f3463535b7d6eb431b51deb808b8b92c547acca53bb6fe55.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO snippets (id, content, language, title, description, created_at, expires_at, view_count, is_public)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Varchar", + "Varchar", + "Text", + "Timestamptz", + "Timestamptz", + "Int4", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "89b5d73a044dbac1f3463535b7d6eb431b51deb808b8b92c547acca53bb6fe55" +} diff --git a/axum/code-snippet-sharing-app/.sqlx/query-9bed2880002e53cc7187b8b68256138694ecbe8df8f3618210232ad45d3d7a0c.json b/axum/code-snippet-sharing-app/.sqlx/query-9bed2880002e53cc7187b8b68256138694ecbe8df8f3618210232ad45d3d7a0c.json new file mode 100644 index 00000000..eaff4244 --- /dev/null +++ b/axum/code-snippet-sharing-app/.sqlx/query-9bed2880002e53cc7187b8b68256138694ecbe8df8f3618210232ad45d3d7a0c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM snippets WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "9bed2880002e53cc7187b8b68256138694ecbe8df8f3618210232ad45d3d7a0c" +} diff --git a/axum/code-snippet-sharing-app/.sqlx/query-a409ebc9ab67f4cbd9daa9e0209afd5c04ba143a352ab0dcea6cfe980721cd5f.json b/axum/code-snippet-sharing-app/.sqlx/query-a409ebc9ab67f4cbd9daa9e0209afd5c04ba143a352ab0dcea6cfe980721cd5f.json new file mode 100644 index 00000000..20fa0d1b --- /dev/null +++ b/axum/code-snippet-sharing-app/.sqlx/query-a409ebc9ab67f4cbd9daa9e0209afd5c04ba143a352ab0dcea6cfe980721cd5f.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, content, language, title, description, created_at, expires_at, view_count, is_public\n FROM snippets\n WHERE id = $1 AND (expires_at IS NULL OR expires_at > $2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "language", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "view_count", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "is_public", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "a409ebc9ab67f4cbd9daa9e0209afd5c04ba143a352ab0dcea6cfe980721cd5f" +} diff --git a/axum/code-snippet-sharing-app/.sqlx/query-b8207efb0a5e77f676e17f0c68e30e58455d4f915bc0a0c481ce77a1aff2828c.json b/axum/code-snippet-sharing-app/.sqlx/query-b8207efb0a5e77f676e17f0c68e30e58455d4f915bc0a0c481ce77a1aff2828c.json new file mode 100644 index 00000000..1f79bb19 --- /dev/null +++ b/axum/code-snippet-sharing-app/.sqlx/query-b8207efb0a5e77f676e17f0c68e30e58455d4f915bc0a0c481ce77a1aff2828c.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n title,\n description,\n language,\n created_at,\n expires_at,\n view_count,\n LENGTH(content) as \"char_count!\"\n FROM snippets\n WHERE is_public = true\n AND (expires_at IS NULL OR expires_at > $1)\n ORDER BY created_at DESC\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "language", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "view_count", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "char_count!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Timestamptz", + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + false, + null + ] + }, + "hash": "b8207efb0a5e77f676e17f0c68e30e58455d4f915bc0a0c481ce77a1aff2828c" +} diff --git a/axum/code-snippet-sharing-app/Cargo.toml b/axum/code-snippet-sharing-app/Cargo.toml new file mode 100644 index 00000000..74fc7c30 --- /dev/null +++ b/axum/code-snippet-sharing-app/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "code-snippet-sharing-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +shuttle-axum = "0.57.0" +shuttle-runtime = "0.57.0" +shuttle-shared-db = { version = "0.57.0", features = ["postgres", "sqlx"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +nanoid = "0.4" +sqlx = { version = "0.8", features = [ + "runtime-tokio-rustls", + "postgres", + "chrono", + "uuid", +] } diff --git a/axum/code-snippet-sharing-app/README.md b/axum/code-snippet-sharing-app/README.md new file mode 100644 index 00000000..a54e0982 --- /dev/null +++ b/axum/code-snippet-sharing-app/README.md @@ -0,0 +1,58 @@ +# Code Snippet Share API + +A lightweight API for sharing code snippets, built with **Rust**, **Axum**, and **Shuttle**. This hands-on tutorial demonstrates how to build, deploy, and manage a snippet-sharing service in the cloud. + +--- + +## Features + +- Create, list, view, and delete code snippets. +- Public and private snippet support. +- Tracks view count for each snippet. +- Filter snippets by programming language. +- Lightweight in-memory storage (ideal for tutorials and demos). + +--- + +## Tech Stack + +- **Rust** - Fast, safe, and expressive. +- **Axum** - Web framework for building async HTTP APIs. +- **Shuttle** - Rust serverless hosting platform. + +--- + +## Getting Started + +### Prerequisites + +- Rust (stable) +- Cargo +- Shuttle CLI: [Install Shuttle](https://docs.shuttle.dev/getting-started/installation) + +### Run Locally + +```bash +shuttle run +``` + +The API will start on `http://127.0.0.1:8000` + +### Deploy to Shuttle + +```bash +shuttle deploy +``` + +Your service will be live with a public URL provided by Shuttle. + +### Project Structure + +```bash +. +├── Cargo.lock +├── Cargo.toml +├── README.md +└── src + └── main.rs +``` diff --git a/axum/code-snippet-sharing-app/migrations/20240101000000_create_snippets_table.sql b/axum/code-snippet-sharing-app/migrations/20240101000000_create_snippets_table.sql new file mode 100644 index 00000000..0f8e60f6 --- /dev/null +++ b/axum/code-snippet-sharing-app/migrations/20240101000000_create_snippets_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS snippets ( + id VARCHAR PRIMARY KEY NOT NULL, + content TEXT NOT NULL, + language VARCHAR NOT NULL, + title VARCHAR, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ, + view_count INTEGER NOT NULL DEFAULT 0, + is_public BOOLEAN NOT NULL DEFAULT true +); + +CREATE INDEX IF NOT EXISTS idx_snippets_created_at ON snippets(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_snippets_language ON snippets(language); +CREATE INDEX IF NOT EXISTS idx_snippets_is_public ON snippets(is_public); +CREATE INDEX IF NOT EXISTS idx_snippets_expires_at ON snippets(expires_at); \ No newline at end of file diff --git a/axum/code-snippet-sharing-app/src/main.rs b/axum/code-snippet-sharing-app/src/main.rs new file mode 100644 index 00000000..9345f26a --- /dev/null +++ b/axum/code-snippet-sharing-app/src/main.rs @@ -0,0 +1,292 @@ +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use shuttle_axum::axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use sqlx::PgPool; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +struct Snippet { + id: String, + content: String, + language: String, + title: Option, + description: Option, + created_at: DateTime, + expires_at: Option>, + view_count: i32, + is_public: bool, +} + +#[derive(Deserialize)] +struct CreateSnippet { + content: String, + language: String, + title: Option, + description: Option, + expires_in_hours: Option, + is_public: Option, +} + +#[derive(Deserialize)] +struct ListQuery { + language: Option, + limit: Option, +} + +#[derive(Serialize)] +struct SnippetFull { + id: String, + title: Option, + description: Option, + language: String, + created_at: DateTime, + expires_at: Option>, + view_count: i32, + char_count: usize, + content: String, +} + +#[derive(Serialize)] +struct SnippetInfo { + id: String, + title: Option, + description: Option, + language: String, + created_at: DateTime, + expires_at: Option>, + view_count: i32, + char_count: i32, +} + +#[derive(Serialize)] +struct CreateSnippetResponse { + id: String, + url: String, +} + +// Generate a short, URL-friendly ID +fn generate_snippet_id() -> String { + nanoid::nanoid!(8) // Generates 8-character ID like "V1StGXR8" +} + +async fn health() -> Json { + Json(json!({ + "status": "healthy", + "service": "code-snippet-share", + "timestamp": Utc::now() + })) +} + +async fn create_snippet( + State(pool): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let id = generate_snippet_id(); + let now = Utc::now(); + + // Calculate expiration time if specified + let expires_at = payload + .expires_in_hours + .map(|hours| now + Duration::hours(hours)); + + let result = sqlx::query!( + r#" + INSERT INTO snippets (id, content, language, title, description, created_at, expires_at, view_count, is_public) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + id, + payload.content, + payload.language, + payload.title, + payload.description, + now, + expires_at, + 0i32, + payload.is_public.unwrap_or(true) + ) + .execute(&pool) + .await; + + match result { + Ok(_) => { + let response = CreateSnippetResponse { + id: id.clone(), + url: format!("/snippets/{}", id), + }; + Ok((StatusCode::CREATED, Json(response))) + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn get_snippet_full( + State(pool): State, + Path(id): Path, +) -> Result, StatusCode> { + let now = Utc::now(); + + // First, check if snippet exists and is not expired + let snippet = sqlx::query_as!( + Snippet, + r#" + SELECT id, content, language, title, description, created_at, expires_at, view_count, is_public + FROM snippets + WHERE id = $1 AND (expires_at IS NULL OR expires_at > $2) + "#, + id, + now + ) + .fetch_optional(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match snippet { + Some(mut snippet) => { + // Increment view count + let result = sqlx::query!( + "UPDATE snippets SET view_count = view_count + 1 WHERE id = $1", + id + ) + .execute(&pool) + .await; + + if result.is_ok() { + snippet.view_count += 1; + } + + let full = SnippetFull { + id: snippet.id, + title: snippet.title, + description: snippet.description, + language: snippet.language, + created_at: snippet.created_at, + expires_at: snippet.expires_at, + view_count: snippet.view_count, + char_count: snippet.content.len(), + content: snippet.content, + }; + + Ok(Json(full)) + } + None => { + // Clean up expired snippets + let _ = sqlx::query!( + "DELETE FROM snippets WHERE expires_at IS NOT NULL AND expires_at <= $1", + now + ) + .execute(&pool) + .await; + + Err(StatusCode::NOT_FOUND) + } + } +} + +async fn list_snippets( + State(pool): State, + Query(params): Query, +) -> Json> { + let now = Utc::now(); + let limit = params.limit.unwrap_or(20).min(100) as i64; + + let query = match params.language { + Some(language) => { + sqlx::query_as!( + SnippetInfo, + r#" + SELECT + id, + title, + description, + language, + created_at, + expires_at, + view_count, + LENGTH(content) as "char_count!" + FROM snippets + WHERE is_public = true + AND (expires_at IS NULL OR expires_at > $1) + AND language = $2 + ORDER BY created_at DESC + LIMIT $3 + "#, + now, + language, + limit + ) + .fetch_all(&pool) + .await + } + None => { + sqlx::query_as!( + SnippetInfo, + r#" + SELECT + id, + title, + description, + language, + created_at, + expires_at, + view_count, + LENGTH(content) as "char_count!" + FROM snippets + WHERE is_public = true + AND (expires_at IS NULL OR expires_at > $1) + ORDER BY created_at DESC + LIMIT $2 + "#, + now, + limit + ) + .fetch_all(&pool) + .await + } + }; + + let snippet_infos = query.unwrap_or_else(|_| vec![]); + Json(snippet_infos) +} + +async fn delete_snippet( + State(pool): State, + Path(id): Path, +) -> Result { + let result = sqlx::query!("DELETE FROM snippets WHERE id = $1", id) + .execute(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if result.rows_affected() > 0 { + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +#[shuttle_runtime::main] +async fn main(#[shuttle_shared_db::Postgres] pool: PgPool) -> shuttle_axum::ShuttleAxum { + // Run database migrations + sqlx::migrate!() + .run(&pool) + .await + .expect("Failed to run migrations"); + + // Build the router + let router = Router::new() + .route("/health", get(health)) + .route("/snippets", post(create_snippet).get(list_snippets)) + .route( + "/snippets/{id}", + get(get_snippet_full).delete(delete_snippet), + ) + .with_state(pool); + + Ok(router.into()) +} diff --git a/templates.toml b/templates.toml index 6798b9ba..20ff33b0 100644 --- a/templates.toml +++ b/templates.toml @@ -248,6 +248,13 @@ path = "axum/turso" use_cases = ["Web app", "Storage"] tags = ["turso", "axum", "database", "libsql", "sqlite"] +[templates.axum-code-snippet-sharing-app] +title = "Code Snippet Sharing" +description = "Share and manage code snippets with expiration and view tracking" +path = "axum/code-snippet-sharing-app" +use_cases = ["Web app", "Storage"] +tags = ["axum", "postgres", "database"] + [templates.axum-websocket] title = "Websockets" description = "Health monitoring service using websockets"