httpman 0.1.5

A fast, modern HTTP client with tab management and request persistence
Documentation
use std::{io::Write, path::PathBuf, sync::mpsc::Receiver};

use thousands::Separable;
use ulid::Ulid;

use crate::{
    models::{DispatchedRequest, DispatchedResponse, HttpMethod, Request, ResponseBody},
    TabCtx,
};

pub struct Tab {
    pub id: Ulid,
    request: Request,
    ctx: TabCtx,
    pending_response: Option<Receiver<DispatchedResponse>>,
    response: Option<DispatchedResponse>,
}

#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub(crate) struct PersistedTab {
    pub id: Ulid,
    pub request: Request,
}

impl Tab {
    pub fn new(ctx: TabCtx) -> Self {
        Self {
            id: Ulid::new(),
            ctx,
            request: <_>::default(),
            response: <_>::default(),
            pending_response: None,
        }
    }

    pub fn title(&self) -> String {
        // get the domain and part of the last part of the url
        let url = &self.request.url;
        let url = url
            .trim_start_matches("http://")
            .trim_start_matches("https://");
        let url = url.trim_start_matches("www.");
        let url = url.split('/').next().unwrap_or(url);

        format!("{} {}", self.request.method.as_str(), url)
    }

    pub fn request(&self) -> &Request {
        &self.request
    }

    pub fn file_name(&self) -> PathBuf {
        PathBuf::from(format!("{}.httpmand", self.id.to_string()))
    }

    pub fn new_from_file(path: &std::path::Path, ctx: TabCtx) -> anyhow::Result<Self> {
        let file = std::fs::File::open(path)?;
        let persisted: PersistedTab = rmp_serde::from_read(file)?;

        Ok(Self::new_from_persisted(persisted, ctx))
    }

    pub fn save_to_file(&self, path: &std::path::Path) -> anyhow::Result<()> {
        let mut file = std::fs::File::create(path)?;
        let bytes = rmp_serde::to_vec(&self.into_persisted())?;
        file.write_all(&bytes)?;
        Ok(())
    }

    fn new_from_persisted(p: PersistedTab, ctx: TabCtx) -> Self {
        Self {
            id: p.id,
            request: p.request,
            ctx,
            pending_response: <_>::default(),
            response: <_>::default(),
        }
    }

    pub fn new_bitcoin_api_example(ctx: TabCtx) -> Self {
        let mut request = Request::default();
        request.method = HttpMethod::Get;
        request.url = "https://api.coingecko.com/api/v3/coins/bitcoin".to_string();
        request
            .headers
            .push(("Accept".to_string(), "application/json".to_string()));

        Self {
            id: Ulid::new(),
            request,
            ctx,
            pending_response: None,
            response: None,
        }
    }

    pub fn new_image_example(ctx: TabCtx) -> Self {
        let mut request = Request::default();
        request.method = HttpMethod::Get;
        request.url = "https://picsum.photos/800".to_string();

        Self {
            id: Ulid::new(),
            request,
            ctx,
            pending_response: None,
            response: None,
        }
    }

    fn into_persisted(&self) -> PersistedTab {
        PersistedTab {
            id: self.id,
            request: self.request.clone(),
        }
    }

    pub fn render(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> {
        ui.vertical(|ui| {
            let request = &mut self.request;

            render_method_and_url(ui, request);

            ui.separator();

            ui.label("Headers");
            render_headers(request, ui);

            if request.method.can_have_body() {
                ui.separator();
                ui.label("Body");
                egui::TextEdit::multiline(&mut request.body)
                    .code_editor()
                    .show(ui);
                ui.separator();
            }

            if self.pending_response.is_some() {
                let _ = self.fetch_response(ui);
            }
        });

        self.render_reponse(ui);

        Ok(())
    }

    pub fn follow_redirects(&self) -> bool {
        self.request.follow_redirects
    }

    pub fn set_follow_redirects(&mut self, follow: bool) {
        self.request.follow_redirects = follow;
    }

    pub fn is_sending(&self) -> bool {
        self.pending_response.is_some()
    }

    pub fn send_request_public(&mut self) {
        self.send_request();
    }

    fn render_reponse(&mut self, ui: &mut egui::Ui) {
        match &self.response {
            Some(DispatchedResponse::Success(http)) => {
                ui.separator();
                ui.label(format!("HTTP {}", http.status_code));
                ui.spacing();

                egui::ScrollArea::vertical().hscroll(true).show(ui, |ui| {
                    ui.vertical_centered_justified(|ui| {
                        egui::CollapsingHeader::new(format!("Headers ({})", http.headers.len()))
                            .default_open(false)
                            .show(ui, |ui| {
                                for (k, v) in http.headers.iter() {
                                    ui.horizontal(|ui| {
                                        ui.label(k);
                                        ui.label(v);
                                    });
                                }
                            });

                        ui.spacing();

                        let content_length = http.content_length().unwrap_or(0);

                        egui::CollapsingHeader::new(format!(
                            "Body ({} bytes)",
                            content_length.separate_with_commas()
                        ))
                        .default_open(true)
                        .show(ui, |ui| {
                            match &http.body {
                                ResponseBody::Text(text) => {
                                    ui.monospace(text);
                                }
                                ResponseBody::Json {
                                    value: json,
                                    length: _,
                                } => {
                                    egui_json_tree::JsonTree::new(
                                        format!("{}_response", self.id),
                                        json,
                                    )
                                    .default_expand(egui_json_tree::DefaultExpand::ToLevel(0))
                                    .show(ui);
                                }
                                ResponseBody::Image(f) => {
                                    let path = format!("file://{}", f.to_string_lossy());
                                    ui.vertical_centered_justified(|ui| ui.image(path));
                                }
                                ResponseBody::Binary(_f) => {
                                    if ui
                                        .button(format!("Saved {} bytes", content_length))
                                        .clicked()
                                    {
                                        // open file
                                    }
                                }
                                ResponseBody::Empty => {
                                    if http.is_redirect() {
                                        if ui.button("Rety and follow redirect").clicked() {
                                            // follow redirect
                                        }
                                    } else {
                                        ui.label("Empty response");
                                    }
                                }
                            };
                        });
                    });
                });
            }
            Some(DispatchedResponse::Error(_e)) => {}
            None => {}
        }
    }

    fn fetch_response(&mut self, ui: &mut egui::Ui) -> anyhow::Result<()> {
        if let Some(rx) = &self.pending_response {
            if let Ok(response) = rx.try_recv() {
                self.response = Some(response);
                self.pending_response = None;

                // save the tab
                self.ctx
                    .storage_read()?
                    .save_tab(self)
                    .expect("save tab failed");
                ui.ctx()
                    .request_repaint_after(std::time::Duration::from_millis(999));
            }
        }

        Ok(())
    }

    fn send_request(&mut self) {
        let (sender, receiver) = std::sync::mpsc::channel::<DispatchedResponse>();
        let dispatched_request = DispatchedRequest {
            respone_sender: sender,
            request: self.request.clone(),
        };
        self.pending_response = Some(receiver);
        self.ctx
            .request_dispatcher
            .send(dispatched_request)
            .unwrap();

        // Save tab state immediately after sending request
        _ = self
            .ctx
            .storage_read()
            .and_then(|storage| storage.save_tab(self))
            .map_err(|e| eprintln!("Failed to save tab after sending request: {}", e));
    }
}

fn render_method_and_url(ui: &mut egui::Ui, request: &mut Request) {
    ui.horizontal(|ui| {
        let mut selected_method = request.method;
        egui::ComboBox::from_label(" ")
            .selected_text(selected_method.as_str())
            .show_ui(ui, |ui| {
                for method in HttpMethod::iter() {
                    ui.selectable_value(&mut selected_method, method, method.as_str());
                }
            });
        request.method = selected_method;

        ui.text_edit_singleline(&mut request.url);
    });
}

fn render_headers(request: &mut Request, ui: &mut egui::Ui) {
    let mut idx = 0;
    let mut to_remove = None;
    ui.vertical(|ui| {
        for (k, v) in request.headers.iter_mut() {
            ui.horizontal_top(|ui| {
                ui.style_mut().wrap = Some(true);

                ui.text_edit_singleline(k);
                ui.text_edit_singleline(v);
                if ui.link("Delete").clicked() {
                    to_remove = Some(idx);
                }
            });

            idx += 1;
        }
    });
    if let Some(idx) = to_remove {
        request.headers.remove(idx);
    }

    if ui.link("+ New").clicked() {
        request.headers.push(("".to_string(), "".to_string()));
    }
}