Merge pull request #40 from stevedylandev/feat/posts-add-api f2cb4d05
feat: add read only api to posts
Steve Simkins · 2026-04-30 13:42 5 file(s) · +146 −1
Cargo.lock +1 −0
3217 3217
 "serde_rusqlite",
3218 3218
 "subtle",
3219 3219
 "tokio",
3220 +
 "tower-http",
3220 3221
 "tracing",
3221 3222
 "tracing-subscriber",
3222 3223
 "zip",
apps/posts/Cargo.toml +1 −0
29 29
pulldown-cmark = "0.12"
30 30
chrono = "0.4"
31 31
zip = { workspace = true }
32 +
tower-http = { version = "0.6.8", features = ["cors"] }
apps/posts/src/server/handlers/api.rs (added) +128 −0
1 +
use axum::{
2 +
    Json,
3 +
    extract::{Path, State},
4 +
    http::StatusCode,
5 +
    response::{IntoResponse, Response},
6 +
};
7 +
use serde::Serialize;
8 +
use serde_json::json;
9 +
use std::sync::Arc;
10 +
11 +
use super::super::*;
12 +
use crate::db;
13 +
14 +
#[derive(Serialize)]
15 +
struct ApiPostSummary {
16 +
    short_id: String,
17 +
    title: String,
18 +
    slug: String,
19 +
    published_date: Option<String>,
20 +
    meta_description: Option<String>,
21 +
    meta_image: Option<String>,
22 +
    canonical_url: Option<String>,
23 +
    lang: String,
24 +
    tags: Option<String>,
25 +
    created_at: String,
26 +
    updated_at: String,
27 +
}
28 +
29 +
#[derive(Serialize)]
30 +
struct ApiPostDetail {
31 +
    short_id: String,
32 +
    title: String,
33 +
    slug: String,
34 +
    alias: Option<String>,
35 +
    canonical_url: Option<String>,
36 +
    published_date: Option<String>,
37 +
    meta_description: Option<String>,
38 +
    meta_image: Option<String>,
39 +
    lang: String,
40 +
    tags: Option<String>,
41 +
    content: String,
42 +
    created_at: String,
43 +
    updated_at: String,
44 +
}
45 +
46 +
#[derive(Serialize)]
47 +
struct ApiPostsList {
48 +
    posts: Vec<ApiPostSummary>,
49 +
}
50 +
51 +
impl From<Post> for ApiPostSummary {
52 +
    fn from(p: Post) -> Self {
53 +
        Self {
54 +
            short_id: p.short_id,
55 +
            title: p.title,
56 +
            slug: p.slug,
57 +
            published_date: p.published_date,
58 +
            meta_description: p.meta_description,
59 +
            meta_image: p.meta_image,
60 +
            canonical_url: p.canonical_url,
61 +
            lang: p.lang,
62 +
            tags: p.tags,
63 +
            created_at: p.created_at,
64 +
            updated_at: p.updated_at,
65 +
        }
66 +
    }
67 +
}
68 +
69 +
impl From<Post> for ApiPostDetail {
70 +
    fn from(p: Post) -> Self {
71 +
        Self {
72 +
            short_id: p.short_id,
73 +
            title: p.title,
74 +
            slug: p.slug,
75 +
            alias: p.alias,
76 +
            canonical_url: p.canonical_url,
77 +
            published_date: p.published_date,
78 +
            meta_description: p.meta_description,
79 +
            meta_image: p.meta_image,
80 +
            lang: p.lang,
81 +
            tags: p.tags,
82 +
            content: p.content,
83 +
            created_at: p.created_at,
84 +
            updated_at: p.updated_at,
85 +
        }
86 +
    }
87 +
}
88 +
89 +
pub async fn list_posts(State(state): State<Arc<AppState>>) -> Response {
90 +
    match db::get_published_posts(&state.db) {
91 +
        Ok(posts) => {
92 +
            let posts = posts.into_iter().map(ApiPostSummary::from).collect();
93 +
            Json(ApiPostsList { posts }).into_response()
94 +
        }
95 +
        Err(e) => {
96 +
            tracing::error!("Failed to list posts for API: {}", e);
97 +
            (
98 +
                StatusCode::INTERNAL_SERVER_ERROR,
99 +
                Json(json!({ "error": "internal server error" })),
100 +
            )
101 +
                .into_response()
102 +
        }
103 +
    }
104 +
}
105 +
106 +
pub async fn get_post(
107 +
    State(state): State<Arc<AppState>>,
108 +
    Path(slug): Path<String>,
109 +
) -> Response {
110 +
    match db::get_post_by_slug(&state.db, &slug) {
111 +
        Ok(Some(post)) if post.status == "published" => {
112 +
            Json(ApiPostDetail::from(post)).into_response()
113 +
        }
114 +
        Ok(_) => (
115 +
            StatusCode::NOT_FOUND,
116 +
            Json(json!({ "error": "not found" })),
117 +
        )
118 +
            .into_response(),
119 +
        Err(e) => {
120 +
            tracing::error!("Failed to get post for API: {}", e);
121 +
            (
122 +
                StatusCode::INTERNAL_SERVER_ERROR,
123 +
                Json(json!({ "error": "internal server error" })),
124 +
            )
125 +
                .into_response()
126 +
        }
127 +
    }
128 +
}
apps/posts/src/server/handlers/mod.rs +1 −0
1 1
pub mod admin;
2 +
pub mod api;
2 3
pub mod public;
apps/posts/src/server/mod.rs +15 −1
1 1
use askama::Template;
2 2
use axum::{
3 3
    extract::DefaultBodyLimit,
4 +
    http::Method,
4 5
    routing::{get, post},
5 6
    Router,
6 7
};
7 8
use pulldown_cmark::{Options, Parser, html};
8 9
use rust_embed::Embed;
9 10
use std::sync::Arc;
11 +
use tower_http::cors::{Any, CorsLayer};
10 12
11 13
use crate::db::{self, Db, Page, Post, UploadedFile};
12 14
501 503
// --- Router ---
502 504
503 505
pub async fn run(host: String, port: u16) {
504 -
    use handlers::{admin, public};
506 +
    use handlers::{admin, api, public};
505 507
506 508
    dotenvy::dotenv().ok();
507 509
538 540
        site_url,
539 541
    });
540 542
543 +
    let api_router = Router::new()
544 +
        .route("/api/posts", get(api::list_posts))
545 +
        .route("/api/posts/{slug}", get(api::get_post))
546 +
        .layer(
547 +
            CorsLayer::new()
548 +
                .allow_origin(Any)
549 +
                .allow_methods([Method::GET])
550 +
                .allow_headers(Any),
551 +
        );
552 +
541 553
    let app = Router::new()
542 554
        // Public routes
543 555
        .route("/", get(public::public_index))
546 558
        .route("/custom-styles.css", get(public::serve_custom_css))
547 559
        .route("/{slug}", get(public::public_page))
548 560
        .route("/feed.xml", get(public::rss_feed))
561 +
        // Public JSON API
562 +
        .merge(api_router)
549 563
        // Admin auth
550 564
        .route("/admin/login", get(admin::get_login).post(admin::post_login))
551 565
        .route("/admin/logout", get(admin::get_logout))