typst4ei 2.0.0

The Typst4EI building and serving tool/cli
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::Mutex;

use anyhow::Result;
use axum::Router;
use axum::body::Body;
use axum::extract::Request;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::header;
use axum::response::Response;
use axum::routing::get;
use axum::routing::post;
use itertools::Itertools;
use log::info;
use log::warn;
use rand::prelude::*;
use tokio::sync::Notify;

use crate::Config;
use crate::Source;
use crate::fetch_documents;
use crate::generate_html_error_pages;
use crate::generate_html_index_pages;
use crate::inventory::Inventory;

pub async fn serve(config: Config, address: String) -> Result<()> {
    let config = Arc::new(config);
    let bucket = Arc::new(Bucket::new());

    bucket.enqueue_many(config.auth.values().flatten().cloned().collect());

    #[rustfmt::skip]
    tokio::select! {
        result = listen_task(config.clone(), bucket.clone(), address) => result,
        result = build_task(config.clone(), bucket.clone()) => result,
    }
}

async fn listen_task(config: Arc<Config>, bucket: Arc<Bucket>, address: String) -> Result<()> {
    let listener = tokio::net::TcpListener::bind(&address).await?;

    info!("listening on `{}`", address);
    axum::serve(listener, create_app(config, bucket)?).await?;
    Ok(())
}

async fn build_task(config: Arc<Config>, bucket: Arc<Bucket>) -> Result<()> {
    loop {
        let sources = bucket.dequeue_many().await;
        let config = config.clone();

        tokio::task::spawn_blocking(move || {
            let mut documents = vec![];
            for source in sources {
                documents.extend(match fetch_documents(config.clone(), source.clone()) {
                    Ok(documents) => {
                        info!(
                            "fetched {} documents from `{}`",
                            documents.len(),
                            source.fetch_url().clone()
                        );
                        documents
                    }
                    Err(err) => {
                        warn!(
                            "unable to fetch documents from `{}`: {}",
                            source.fetch_url().clone(),
                            err
                        );
                        vec![]
                    }
                });
            }
            let lucky_logo_time = roll_the_dice_for_lucky_logo_time();
            generate_html_index_pages(config.clone(), documents, lucky_logo_time);
            generate_html_error_pages(config.clone(), lucky_logo_time);
        })
        .await?;
    }
}

fn roll_the_dice_for_lucky_logo_time() -> bool {
    let mut rng = rand::rng();
    // ~ 4.2 % probability
    if rng.random_range(1..24) == 12 {
        info!("It's lucky logo time!");
        true
    } else {
        false
    }
}

fn create_app(config: Arc<Config>, bucket: Arc<Bucket>) -> Result<Router> {
    Ok(Router::new()
        .route("/api/health", get(async || "healthy"))
        .route("/api/publish", post(handle_publish))
        .route("/api/validate-inventory", post(handle_validate_inventory))
        .with_state(Typst4eiState { bucket, config }))
}

#[derive(Clone)]
pub struct Typst4eiState {
    config: Arc<Config>,
    bucket: Arc<Bucket>,
}

async fn handle_publish(
    State(state): State<Typst4eiState>,
    request: Request,
) -> Result<Response, StatusCode> {
    let sources = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|token| state.config.auth.get(token.to_str().unwrap()))
        .ok_or(StatusCode::UNAUTHORIZED)?;
    state.bucket.enqueue_many(sources.to_vec());

    Ok(Response::builder()
        .status(StatusCode::ACCEPTED)
        .body(Body::from(
            "Your request has been accepted. The website should update in a moment.",
        ))
        .unwrap())
}

async fn handle_validate_inventory(
    State(state): State<Typst4eiState>,
    body: String,
) -> Result<Response, StatusCode> {
    match Inventory::load(body, state.config) {
        Ok(_) => Ok(Response::builder()
            .status(StatusCode::OK)
            .body(Body::from("your inventory is valid"))
            .unwrap()),
        Err(err) => Ok(Response::builder()
            .status(StatusCode::BAD_REQUEST)
            .body(Body::from(format!("unable to parse inventory: {}", err)))
            .unwrap()),
    }
}

struct Bucket {
    sources: Mutex<VecDeque<Source>>,
    notify: Notify,
}

impl Bucket {
    fn new() -> Self {
        Bucket {
            sources: Mutex::new(VecDeque::new()),
            notify: Notify::new(),
        }
    }

    #[cfg(test)]
    fn len(&self) -> usize {
        let sources = self.sources.lock().unwrap();
        sources.len()
    }

    fn enqueue_many(&self, mut items: Vec<Source>) {
        let mut sources = self.sources.lock().unwrap();
        items = items
            .into_iter()
            .unique()
            .filter(|item| !sources.contains(item))
            .collect();
        sources.extend(items);
        self.notify.notify_one();
    }

    async fn dequeue_many(&self) -> Vec<Source> {
        self.notify.notified().await;
        let mut sources = self.sources.lock().unwrap();
        sources.drain(..).collect()
    }
}

#[cfg(test)]
mod test {
    use axum_test::TestServer;
    use parameterized::parameterized;
    use url::Url;

    use super::*;
    use crate::config::Provider;
    use crate::config::test::example_config;
    use crate::inventory::test::raw_example_inventory;

    #[tokio::test]
    async fn test_health_200_ok_response() {
        let config = Arc::new(example_config());
        let bucket = Arc::new(Bucket::new());

        let server = TestServer::new(create_app(config.clone(), bucket.clone()).unwrap());
        let response = server.get("/api/health").await;

        assert_eq!(StatusCode::OK, response.status_code());
        assert_eq!(0, bucket.len());
    }

    #[tokio::test]
    async fn test_validate_inventory_200_ok_response() {
        let config = Arc::new(example_config());
        let bucket = Arc::new(Bucket::new());

        let server = TestServer::new(create_app(config.clone(), bucket.clone()).unwrap());
        let response = server
            .post("/api/validate-inventory")
            .text(raw_example_inventory())
            .await;

        assert_eq!(StatusCode::OK, response.status_code());
    }

    #[tokio::test]
    async fn test_validate_inventory_400_bad_request_response() {
        let config = Arc::new(example_config());
        let bucket = Arc::new(Bucket::new());

        let server = TestServer::new(create_app(config.clone(), bucket.clone()).unwrap());
        let response = server
            .post("/api/validate-inventory")
            .text("inventory: 64x cobbelstone, 4x torches, 1x iron sword")
            .await;

        assert_eq!(StatusCode::BAD_REQUEST, response.status_code());
    }

    #[parameterized(token = {
	"acedcbf5977a64f6ca03deb8de08f388fadd5d87",
	"9cedcbf5977a64f6ca03deb8de08f388fadd5d87",
    }, number_of_sources = {
	1,
	2,
    })]
    #[parameterized_macro(tokio::test)]
    async fn test_publish_202_accepted_response(token: &str, number_of_sources: usize) {
        let config = Arc::new(example_config());
        let bucket = Arc::new(Bucket::new());

        let server = TestServer::new(create_app(config.clone(), bucket.clone()).unwrap());
        let response = server
            .post("/api/publish")
            .add_header(header::AUTHORIZATION, token)
            .await;

        assert_eq!(StatusCode::ACCEPTED, response.status_code());
        assert_eq!(number_of_sources, bucket.len());
    }

    #[parameterized(token = {
	"acedcbf5977a64f6ca03deb8de08f388fadd5d87",
	"9cedcbf5977a64f6ca03deb8de08f388fadd5d87",
    }, number_of_sources = {
	1,
	2,
    })]
    #[parameterized_macro(tokio::test)]
    async fn test_publish_multiple_202_accepted_responses(token: &str, number_of_sources: usize) {
        let config = Arc::new(example_config());
        let bucket = Arc::new(Bucket::new());

        let server = TestServer::new(create_app(config.clone(), bucket.clone()).unwrap());
        for _ in 0..3 {
            let response = server
                .post("/api/publish")
                .add_header(header::AUTHORIZATION, token)
                .await;
            assert_eq!(StatusCode::ACCEPTED, response.status_code());
        }
        assert_eq!(number_of_sources, bucket.len());
    }

    #[parameterized(token = {
	"76368053519e497441c73de641f6f9a3ae62f04b",
	"b125d6925022348e06ded0f3f5b537e8f8d0d043",
    })]
    #[parameterized_macro(tokio::test)]
    async fn test_publish_401_unauthorized_response(token: &str) {
        let config = Arc::new(example_config());
        let bucket = Arc::new(Bucket::new());

        let server = TestServer::new(create_app(config.clone(), bucket.clone()).unwrap());
        let response = server
            .post("/api/publish")
            .add_header(header::AUTHORIZATION, token)
            .await;

        assert_eq!(StatusCode::UNAUTHORIZED, response.status_code());
        assert_eq!(0, bucket.len());
    }

    #[parameterized(path = {
	"/static",
	"/files",
	"/teapot",
    })]
    #[parameterized_macro(tokio::test)]
    async fn test_404_not_found_response(path: &str) {
        let config = Arc::new(example_config());
        let bucket = Arc::new(Bucket::new());

        let server = TestServer::new(create_app(config.clone(), bucket.clone()).unwrap());
        let response = server.post(path).await;

        assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
        assert_eq!(0, bucket.len());
    }

    #[tokio::test]
    async fn test_bucket_enqueue_and_dequeue_many() {
        let (source1, source2, source3) = example_sources();
        let bucket = Bucket::new();
        assert_eq!(0, bucket.len());

        bucket.enqueue_many(vec![source1.clone()]);
        assert_eq!(1, bucket.len());
        pretty_assertions::assert_eq!(vec![source1.clone()], bucket.dequeue_many().await);
        assert_eq!(0, bucket.len());

        bucket.enqueue_many(vec![source1.clone(), source2.clone()]);
        assert_eq!(2, bucket.len());
        pretty_assertions::assert_eq!(
            vec![source1.clone(), source2.clone()],
            bucket.dequeue_many().await
        );
        assert_eq!(0, bucket.len());

        bucket.enqueue_many(vec![
            source1.clone(),
            source1.clone(),
            source2.clone(),
            source3.clone(),
            source3.clone(),
        ]);
        assert_eq!(3, bucket.len());
        pretty_assertions::assert_eq!(
            vec![source1.clone(), source2.clone(), source3.clone()],
            bucket.dequeue_many().await
        );
        assert_eq!(0, bucket.len());
    }

    fn example_sources() -> (Source, Source, Source) {
        let source1 = Source {
            provider: Provider::Codeberg,
            owner: "guemax".to_string(),
            repo: "kleine-kochbuecher-der-elektrotechnik".to_string(),
            custom_display_url: None,
            custom_fetch_url: None,
            hide_urls: false,
        };
        let source2 = Source {
            provider: Provider::Gitea,
            owner: "alexander".to_string(),
            repo: "TUM-Formelsammlungen".to_string(),
            custom_display_url: Some(Url::parse("https:/gitea.mintcalc.com/").unwrap()),
            custom_fetch_url: Some(Url::parse("https:/gitea.localhost/").unwrap()),
            hide_urls: false,
        };
        let source3 = Source {
            provider: Provider::Gitea,
            owner: "alexander".to_string(),
            repo: "RAW-Formelsammlungen".to_string(),
            custom_display_url: Some(Url::parse("https:/gitea.mintcalc.com/").unwrap()),
            custom_fetch_url: Some(Url::parse("https:/gitea.localhost/").unwrap()),
            hide_urls: true,
        };

        (source1, source2, source3)
    }
}