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();
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)
}
}