git-gemini-forge 0.5.2

A simple Gemini server that serves a read-only view of public repositories from a Git forge.
use super::error;
use isahc::{http::StatusCode, prelude::*, Body, Response};
use secrecy::{ExposeSecret, SecretString};
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use url::Url;

pub enum HttpMethod {
	Get,
}

pub type Headers<'a> = HashMap<String, &'a SecretString>;

/// A REST endpoint from which to request data of type `Value`.
pub trait ApiEndpoint<Value>
where
	Value: DeserializeOwned,
{
	/// The method to use when requesting at the URL.
	/// Defaults to `GET` if not defined on the endpoint.
	fn method(&self) -> HttpMethod {
		return HttpMethod::Get;
	}

	/// A map of headers that should be sent with this request.
	/// Defaults to an empty map if not defined on the endpoint.
	fn headers(&self) -> Headers {
		return HashMap::new();
	}

	/// The URL to which to send HTTP requests.
	fn url(&self) -> Url;
}

/// Fetches data from the given API endpoint. Results in `Err`
/// if the result fails for any reason.
pub fn call<V>(endpoint: &dyn ApiEndpoint<V>) -> Result<V, error::Error>
where
	V: DeserializeOwned,
{
	let mut response = match endpoint.method() {
		HttpMethod::Get => get(&endpoint.url(), &endpoint.headers())?,
	};
	let status = response.status();

	// No permission. We may need to provide some kind of token in a header somewhere
	if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
		return Err(error::Error::Unauthorized);
	}

	// No result found; don't attempt to deserialize, since the message shape may differ
	if status == StatusCode::NOT_FOUND {
		return Err(error::Error::ResourceNotFound);
	}

	return match response.json() {
		Ok(obj) => Ok(obj),
		Err(err) => {
			let data = response
				.text()
				.map_err(|_| error::Error::UnexpectedResponse)?;
			println!("Failed to parse value due to error: {err}: {data}");
			return Err(error::Error::UnexpectedResponse);
		}
	};
}

/// Fetches data from the given URL.
fn get(url: &Url, headers: &Headers) -> Result<Response<Body>, error::Error> {
	// GET URI
	let mut req = isahc::Request::get(url.as_str());

	// Apply headers
	for (key, value) in headers.iter() {
		req = req.header(key, value.expose_secret().clone());
	}

	if headers.len() > 0 {
		println!("GET {url} (headers)");
	} else {
		println!("GET {url}");
	}

	match isahc::send(req.body(()).unwrap()) {
		Err(err) => {
			println!("Request failed due to error: {err}");
			return Err(error::Error::NetworkFailure);
		}
		Ok(response) => return Ok(response),
	};
}