graphile_worker_admin_ui_client 0.1.1

Leptos WASM client for the embedded graphile_worker admin UI
Documentation
use gloo_net::http::{Request, RequestBuilder};
use leptos::prelude::*;
use serde::de::DeserializeOwned;
use serde::Serialize;
use wasm_bindgen_futures::spawn_local;
use web_sys::RequestCredentials;

use super::browser::show_toast;
use super::types::{
    AddJobRequest, AdminClientConfig, AuthMode, ErrorResponse, JobActionRequest, JobActionResponse,
    ListJobsResponse, ListedJob, MaintenanceRequest, MessageResponse, Modal, OverviewResponse,
    RemoveJobByKeyRequest,
};

pub(super) fn refresh_data(
    config: AdminClientConfig,
    token: RwSignal<String>,
    limit: RwSignal<i64>,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) {
    if refreshing.get_untracked() {
        refresh_pending.set(true);
        return;
    }
    refreshing.set(true);
    spawn_local(async move {
        loop {
            refresh_pending.set(false);
            let token = token.get_untracked();
            let limit_value = limit.get_untracked();
            let jobs_path = format!("/api/jobs?state=all&limit={limit_value}");
            let overview_request = api_get::<OverviewResponse>("/api/overview", &config, &token);
            let jobs_request = api_get::<ListJobsResponse>(&jobs_path, &config, &token);
            let (overview_result, jobs_result) = futures::join!(overview_request, jobs_request);
            match (overview_result, jobs_result) {
                (Ok(next_overview), Ok(next_jobs)) => {
                    let next_ids = next_jobs.jobs.iter().map(|job| job.id).collect::<Vec<_>>();
                    overview.set(next_overview);
                    jobs.set(next_jobs.jobs);
                    selected_jobs.update(|selected| selected.retain(|id| next_ids.contains(id)));
                }
                (Err(error), _) | (_, Err(error)) => show_toast(toast, error),
            }
            if !refresh_pending.get_untracked() {
                break;
            }
        }
        refreshing.set(false);
    });
}

pub(super) fn post_add_job(
    config: AdminClientConfig,
    token: RwSignal<String>,
    request: AddJobRequest,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    modal: RwSignal<Option<Modal>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) {
    spawn_local(async move {
        match api_post::<_, JobActionResponse>(
            "/api/jobs",
            &request,
            &config,
            &token.get_untracked(),
        )
        .await
        {
            Ok(response) => {
                show_toast(toast, response.message);
                modal.set(None);
                refresh_data(
                    config,
                    token,
                    limit,
                    overview,
                    jobs,
                    selected_jobs,
                    toast,
                    refreshing,
                    refresh_pending,
                );
            }
            Err(error) => show_toast(toast, error),
        }
    });
}

pub(super) fn post_job_action(
    config: AdminClientConfig,
    token: RwSignal<String>,
    request: JobActionRequest,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    modal: Option<RwSignal<Option<Modal>>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) {
    spawn_local(async move {
        match api_post::<_, JobActionResponse>(
            "/api/jobs/action",
            &request,
            &config,
            &token.get_untracked(),
        )
        .await
        {
            Ok(response) => {
                show_toast(toast, response.message);
                if let Some(modal) = modal {
                    modal.set(None);
                }
                refresh_data(
                    config,
                    token,
                    limit,
                    overview,
                    jobs,
                    selected_jobs,
                    toast,
                    refreshing,
                    refresh_pending,
                );
            }
            Err(error) => show_toast(toast, error),
        }
    });
}

pub(super) fn post_remove_key(
    config: AdminClientConfig,
    token: RwSignal<String>,
    request: RemoveJobByKeyRequest,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    modal: RwSignal<Option<Modal>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) {
    spawn_local(async move {
        match api_post::<_, MessageResponse>(
            "/api/jobs/remove-by-key",
            &request,
            &config,
            &token.get_untracked(),
        )
        .await
        {
            Ok(response) => {
                show_toast(toast, response.message);
                modal.set(None);
                refresh_data(
                    config,
                    token,
                    limit,
                    overview,
                    jobs,
                    selected_jobs,
                    toast,
                    refreshing,
                    refresh_pending,
                );
            }
            Err(error) => show_toast(toast, error),
        }
    });
}

pub(super) fn post_maintenance(
    config: AdminClientConfig,
    token: RwSignal<String>,
    request: MaintenanceRequest,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) {
    spawn_local(async move {
        match api_post::<_, MessageResponse>(
            "/api/maintenance",
            &request,
            &config,
            &token.get_untracked(),
        )
        .await
        {
            Ok(response) => {
                show_toast(toast, response.message);
                refresh_data(
                    config,
                    token,
                    limit,
                    overview,
                    jobs,
                    selected_jobs,
                    toast,
                    refreshing,
                    refresh_pending,
                );
            }
            Err(error) => show_toast(toast, error),
        }
    });
}

pub(super) async fn api_get<T>(
    path: &str,
    config: &AdminClientConfig,
    token: &str,
) -> Result<T, String>
where
    T: DeserializeOwned,
{
    let response = with_api_headers(Request::get(path), config, token, false)
        .send()
        .await
        .map_err(|error| error.to_string())?;
    parse_response(response).await
}

pub(super) async fn api_post<B, T>(
    path: &str,
    body: &B,
    config: &AdminClientConfig,
    token: &str,
) -> Result<T, String>
where
    B: Serialize + ?Sized,
    T: DeserializeOwned,
{
    let request = with_api_headers(Request::post(path), config, token, true)
        .json(body)
        .map_err(|error| error.to_string())?;
    let response = request.send().await.map_err(|error| error.to_string())?;
    parse_response(response).await
}

pub(super) fn with_api_headers(
    builder: RequestBuilder,
    config: &AdminClientConfig,
    token: &str,
    writes: bool,
) -> RequestBuilder {
    let mut builder = builder
        .credentials(RequestCredentials::SameOrigin)
        .header("Accept", "application/json");

    if writes {
        builder = builder.header(&config.csrf_header, &config.csrf);
    }
    if token.is_empty() {
        return builder;
    }

    match config.auth_mode {
        AuthMode::Bearer => {
            builder = builder.header("Authorization", &format!("Bearer {token}"));
        }
        AuthMode::Header if !config.auth_header.is_empty() => {
            builder = builder.header(&config.auth_header, token);
        }
        _ => {}
    }
    builder
}

pub(super) async fn parse_response<T>(response: gloo_net::http::Response) -> Result<T, String>
where
    T: DeserializeOwned,
{
    let status = response.status();
    let text = response.text().await.map_err(|error| error.to_string())?;
    if !(200..300).contains(&status) {
        return Err(serde_json::from_str::<ErrorResponse>(&text)
            .map(|error| error.error)
            .unwrap_or_else(|_| format!("{status}: {text}")));
    }
    serde_json::from_str(&text).map_err(|error| error.to_string())
}