dothttp 0.10.0

dothttp is a text-based scriptable HTTP client. It is a fork for dot-http. It is a simple language that resembles the actual HTTP protocol but with additional features to make it practical for someone who builds and tests APIs.
Documentation
use std::{
    collections::HashMap, future::IntoFuture, io, io::Write, net::SocketAddr, str::from_utf8,
};

use axum::{
    body::Bytes,
    extract::Query,
    http::request::Parts,
    response::IntoResponse,
    routing::{get, post},
    Extension, Router,
};
use dothttp::output::{parse_format, print::FormattedOutput};
use http::header::CONTENT_TYPE;
use serde_json::json;
use tokio::sync::mpsc::{channel, Receiver, Sender};

pub fn formatter() -> FormattedOutput<DebugWriter, DebugWriter> {
    let writer = DebugWriter(String::new());
    let writer_err = DebugWriter(String::new());
    FormattedOutput::new(
        writer,
        writer_err,
        parse_format("%R\n").unwrap(),
        parse_format("%R\n%H\n%B\n%T\n").unwrap(),
    )
}

pub struct DebugWriter(pub String);

impl Write for DebugWriter {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let DebugWriter(inner) = self;
        let buf = from_utf8(buf).unwrap();
        inner.push_str(buf);
        Ok(buf.len())
    }

    fn flush(&mut self) -> io::Result<()> {
        Ok(())
    }
}

pub struct MockHttpBin {
    pub addr: SocketAddr,
    handle: tokio::task::JoinHandle<Result<(), io::Error>>,
    requests: Receiver<(Parts, Bytes)>,
}

impl MockHttpBin {
    #[must_use]
    pub async fn start() -> Self {
        let (tx, requests) = channel(64);
        let router = Router::new()
            .route("/get", get(mock_get))
            .route("/post", post(mock_post))
            .layer(Extension(tx));

        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();

        let addr = listener.local_addr().unwrap();

        let handle = tokio::spawn(axum::serve(listener, router).into_future());

        MockHttpBin {
            handle,
            requests,
            addr,
        }
    }

    pub async fn requests(&mut self) -> Vec<(Parts, Bytes)> {
        let mut output = vec![];
        self.requests.recv_many(&mut output, 64).await;

        output
    }
}

impl Drop for MockHttpBin {
    fn drop(&mut self) {
        self.handle.abort();
    }
}

async fn mock_get(
    Extension(channel): Extension<Sender<(Parts, Bytes)>>,
    parts: Parts,
    Query(args): Query<HashMap<String, String>>,
    body: Bytes,
) -> impl IntoResponse {
    channel.send((parts.clone(), body)).await.unwrap();

    let headers = collect_headers(&parts);
    let url = parts.uri.to_string();

    axum::Json(json!({
        "args": args,
        "headers": headers ,
        "url": url
    }))
}

async fn mock_post(
    Extension(channel): Extension<Sender<(Parts, Bytes)>>,
    Query(args): Query<HashMap<String, String>>,
    parts: Parts,
    body: Bytes,
) -> impl IntoResponse {
    channel.send((parts.clone(), body.clone())).await.unwrap();

    let headers = collect_headers(&parts);
    let data = String::from_utf8_lossy(body.as_ref());
    let url = parts.uri.to_string();
    let json = collect_json(&parts, &body);

    axum::Json(json!({
        "args": args,
        "url": url,
        "headers": headers,
        "data" : data.as_ref(),
        "json": json
    }))
}

fn collect_headers(parts: &Parts) -> HashMap<String, String> {
    parts
        .headers
        .iter()
        .map(|(name, value)| {
            (
                name.as_str().to_owned(),
                String::from_utf8_lossy(value.as_bytes()).to_string(),
            )
        })
        .collect()
}

fn collect_json(parts: &Parts, body: &Bytes) -> serde_json::Value {
    let Some(content_type) = parts.headers.get(CONTENT_TYPE) else {
        return json!({});
    };
    if !content_type.as_bytes().starts_with(b"application/json") {
        return json!({});
    }

    serde_json::from_slice(body.as_ref()).unwrap_or_else(|_| json!({}))
}