1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
//! Serves the committed pages in `fixtures/site` and speaks parallax's
//! handshake, so parallax's tests and demos run against static files.

use std::io;
use std::net::Ipv4Addr;
use std::path::Path;

use axum::Json;
use axum::Router;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::post;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use tokio::net::TcpListener;
use tower_http::services::ServeDir;

/// Baked in at compile time, so the binary serves the pages no matter
/// which directory `cargo run` starts it from.
const SITE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../site");

/// The item the handshake points flows at; `items/2.html` is its only
/// committed detail page.
const FEATURED: u32 = 2;

/// The id the echo route hands back, next after the five listed items.
const ECHOED_ID: u32 = 6;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
    let addr = listener.local_addr()?;
    let url = format!("http://{addr}");
    match std::env::var_os("PARALLAX_HANDSHAKE") {
        Some(path) => write_handshake(Path::new(&path), &url)?,
        None => println!("{url}"),
    }
    axum::serve(listener, router()).await?;
    Ok(())
}

/// Writes through a sibling temporary file and renames it, so a reader
/// polling for the path never sees a partial file.
fn write_handshake(path: &Path, url: &str) -> io::Result<()> {
    let body = json!({"url": url, "data": {"featured_item": FEATURED}});
    let mut tmp = path.as_os_str().to_owned();
    tmp.push(".tmp");
    fs_err::write(&tmp, body.to_string())?;
    fs_err::rename(&tmp, path)
}

fn router() -> Router {
    Router::new()
        .route("/__fixtures/items", post(create_item))
        .fallback_service(ServeDir::new(SITE))
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NewItem {
    title: String,
    #[serde(default)]
    tag: String,
    #[serde(default)]
    notes: String,
}

#[derive(Serialize)]
struct Item {
    id: u32,
    title: String,
    tag: String,
    notes: String,
    done: bool,
}

/// Stands in for the fixture routes a real app mounts for parallax's
/// `post` helper. Stateless, so every post echoes back with the same id.
async fn create_item(Json(new): Json<NewItem>) -> Response {
    if new.title.trim().is_empty() {
        return (
            StatusCode::UNPROCESSABLE_ENTITY,
            Json(json!({"error": "title must not be empty"})),
        )
            .into_response();
    }
    let item = Item {
        id: ECHOED_ID,
        title: new.title,
        tag: new.tag,
        notes: new.notes,
        done: false,
    };
    (StatusCode::CREATED, Json(item)).into_response()
}