First commit

This commit is contained in:
CyberRex
2026-07-31 14:39:33 +09:00
commit 38670efd46
103 changed files with 22514 additions and 0 deletions

View 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");
}
}