First commit
This commit is contained in:
180
crates/mirakurun-client/src/lib.rs
Normal file
180
crates/mirakurun-client/src/lib.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright 2026 Mirakurun contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use http::{Request, StatusCode};
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper::client::conn::http1;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::net::{TcpStream, UnixStream};
|
||||
|
||||
pub type ByteStream =
|
||||
Pin<Box<dyn Stream<Item = std::result::Result<Bytes, ClientError>> + Send + 'static>>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Hyper(#[from] hyper::Error),
|
||||
#[error("failed to connect to {endpoint}: {source}")]
|
||||
Connect {
|
||||
endpoint: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("request returned HTTP {0}")]
|
||||
Status(StatusCode),
|
||||
#[error("invalid response JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("invalid HTTP request: {0}")]
|
||||
Request(#[from] http::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ClientError>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Endpoint {
|
||||
Http { host: String, port: u16 },
|
||||
Unix(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MirakurunClient {
|
||||
endpoint: Endpoint,
|
||||
}
|
||||
|
||||
impl MirakurunClient {
|
||||
#[must_use]
|
||||
pub fn http(host: &str, port: u16) -> Self {
|
||||
Self {
|
||||
endpoint: Endpoint::Http {
|
||||
host: host.to_owned(),
|
||||
port,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unix(path: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
endpoint: Endpoint::Unix(path.as_ref().to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches and deserializes a JSON response.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the connection, HTTP status, body read, or JSON
|
||||
/// deserialization fails.
|
||||
pub async fn get_json<T>(&self, path: &str) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let response = self.get(path).await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ClientError::Status(response.status()));
|
||||
}
|
||||
let bytes = response.into_body().collect().await?.to_bytes();
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
/// Opens an HTTP response body as an asynchronous byte stream.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the connection fails or the server returns a
|
||||
/// non-success status.
|
||||
pub async fn get_stream(&self, path: &str) -> Result<ByteStream> {
|
||||
let response = self.get(path).await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ClientError::Status(response.status()));
|
||||
}
|
||||
Ok(Box::pin(
|
||||
response
|
||||
.into_body()
|
||||
.into_data_stream()
|
||||
.map(|result| result.map_err(ClientError::Hyper)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<http::Response<hyper::body::Incoming>> {
|
||||
match &self.endpoint {
|
||||
Endpoint::Http { host, port } => tcp_get(host, *port, path).await,
|
||||
Endpoint::Unix(socket) => unix_get(socket, path).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn unix_get(socket: &Path, path: &str) -> Result<http::Response<hyper::body::Incoming>> {
|
||||
let stream = UnixStream::connect(socket)
|
||||
.await
|
||||
.map_err(|source| ClientError::Connect {
|
||||
endpoint: socket.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
send_request(stream, path, "localhost").await
|
||||
}
|
||||
|
||||
async fn tcp_get(
|
||||
host: &str,
|
||||
port: u16,
|
||||
path: &str,
|
||||
) -> Result<http::Response<hyper::body::Incoming>> {
|
||||
let stream = TcpStream::connect((host, port))
|
||||
.await
|
||||
.map_err(|source| ClientError::Connect {
|
||||
endpoint: format!("{host}:{port}"),
|
||||
source,
|
||||
})?;
|
||||
send_request(stream, path, &format!("{host}:{port}")).await
|
||||
}
|
||||
|
||||
async fn send_request<T>(
|
||||
stream: T,
|
||||
path: &str,
|
||||
host: &str,
|
||||
) -> Result<http::Response<hyper::body::Incoming>>
|
||||
where
|
||||
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let (mut sender, connection) = http1::handshake(TokioIo::new(stream)).await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = connection.await {
|
||||
tracing::debug!(%error, "Unix socket HTTP connection closed");
|
||||
}
|
||||
});
|
||||
let request = Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(normalize_path(path))
|
||||
.header(http::header::HOST, host)
|
||||
.body(Empty::<Bytes>::new())?;
|
||||
Ok(sender.send_request(request).await?)
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
if path.starts_with('/') {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_path;
|
||||
|
||||
#[test]
|
||||
fn request_path_is_absolute() {
|
||||
assert_eq!(normalize_path("api/status"), "/api/status");
|
||||
assert_eq!(normalize_path("/api/status"), "/api/status");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user