WIP: v1.0.0

This commit is contained in:
Moritz Ruth 2025-04-20 00:24:12 +02:00
parent f834a8235e
commit e9d12b2697
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
7 changed files with 2949 additions and 0 deletions

2717
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

22
Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "minna-pima"
version = "0.1.0"
edition = "2024"
[profile.dev.package.sqlx-macros]
opt-level = 3
[dependencies]
axum = { version = "0.8.3", default-features = false, features = ["json", "http1", "tokio", "macros"] }
camino = { version = "1.1.9", features = ["serde1"] }
color-eyre = "0.6.3"
env_logger = "0.11.7"
figment = { version = "0.10.19", features = ["env", "toml", "parking_lot"] }
fstr = { version = "0.2.13", features = ["serde"] }
log = "0.4.26"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.3", features = ["tls-rustls-ring-native-roots", "sqlite", "runtime-tokio"] }
tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros", "parking_lot"] }
tokio-util = { version = "0.7.14", features = ["io"] }
validator = { version = "0.20.0", features = ["derive"] }

29
src/config.rs Normal file
View file

@ -0,0 +1,29 @@
use std::net::IpAddr;
use camino::Utf8PathBuf;
use color_eyre::eyre::WrapErr;
use color_eyre::Result;
use figment::Figment;
use figment::providers::Format;
use fstr::FStr;
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct Config {
pub http_address: IpAddr,
pub http_port: u16,
pub api_secret: FStr<64>,
pub database_file: Utf8PathBuf,
}
pub fn load_config() -> Result<Config> {
let figment = Figment::new()
.merge(figment::providers::Toml::file("config.toml"))
.merge(figment::providers::Env::prefixed("PIMA_").only(&["HTTP_ADDRESS", "HTTP_PORT", "API_SECRET"]));
let config = figment.extract::<Config>().wrap_err("Failed to load configuration.")?;
config.validate().wrap_err("Failed to validate configuration.")?;
Ok(config)
}

117
src/http_api/api_error.rs Normal file
View file

@ -0,0 +1,117 @@
use axum::http::{HeaderName, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use color_eyre::Report;
use serde::Serialize;
use serde_json::json;
use std::borrow::Cow;
use tokio_util::bytes::{BufMut, BytesMut};
pub struct ProblemJson<T>(pub T);
impl<T: Serialize> IntoResponse for ProblemJson<T> {
fn into_response(self) -> Response {
// Same as IntoResponse::into_response for Json, but the content type header is changed.
let mut buf = BytesMut::with_capacity(128).writer();
match serde_json::to_writer(&mut buf, &self.0) {
Ok(()) => (
[(header::CONTENT_TYPE, HeaderValue::from_static("application/problem+json"))],
buf.into_inner().freeze(),
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
err.to_string(),
)
.into_response(),
}
}
}
#[derive(Debug)]
pub enum ApiError {
Internal { report: Report },
Forbidden,
InvalidRequestHeader { name: HeaderName, message: Cow<'static, str> },
InvalidRequestContent { path: Cow<'static, str>, message: Cow<'static, str> },
UnknownResource { resource_type: Cow<'static, str>, id: Cow<'static, str> },
RequestBodyTooLong,
RequestBodyTooShort,
}
impl From<Report> for ApiError {
fn from(report: Report) -> Self {
ApiError::Internal { report }
}
}
impl From<std::io::Error> for ApiError {
fn from(error: std::io::Error) -> Self {
ApiError::Internal { report: Report::new(error) }
}
}
impl From<sqlx::Error> for ApiError {
fn from(error: sqlx::Error) -> Self {
ApiError::Internal { report: Report::new(error) }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::Internal { report } => {
log::error!("Internal error in request handler: {:#}", report);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
ApiError::Forbidden => StatusCode::FORBIDDEN.into_response(),
ApiError::InvalidRequestHeader { name, message } => (
StatusCode::UNPROCESSABLE_ENTITY,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/invalid-request-header",
"title": "A specific request header value is invalid.",
"detail": format!("The value of `{}` is invalid: {}", name.as_str(), message)
})),
)
.into_response(),
ApiError::InvalidRequestContent { path, message } => (
StatusCode::UNPROCESSABLE_ENTITY,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/invalid-request-content",
"title": "The request content is semantically invalid.",
"detail": format!("`{path}`: {message}"),
"path": path
})),
)
.into_response(),
ApiError::UnknownResource { resource_type, id } => (
StatusCode::NOT_FOUND,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/unknown-resource",
"title": "The requested resource is unknown.",
"detail": format!("There is no {resource_type} resource with this ID: {id}"),
"resource_type": resource_type,
"resource_id": id
})),
)
.into_response(),
ApiError::RequestBodyTooLong => (
StatusCode::BAD_REQUEST,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/request-body-too-long",
"title": "The received request body is longer than expected.",
})),
)
.into_response(),
ApiError::RequestBodyTooShort => (
StatusCode::BAD_REQUEST,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/request-body-too-short",
"title": "The received request body is shorter than expected.",
})),
)
.into_response(),
}
}
}

View file

@ -0,0 +1,5 @@
use crate::http_api::api_error::ApiError;
pub(super) async fn create_picture() -> Result<(), ApiError> {
todo!()
}

31
src/http_api/mod.rs Normal file
View file

@ -0,0 +1,31 @@
mod create_picture;
mod api_error;
use std::sync::Arc;
use axum::{routing, Router};
use color_eyre::Result;
use sqlx::SqlitePool;
use crate::config::Config;
use crate::http_api::create_picture::create_picture;
#[derive(Debug)]
struct ContextInner {
pub database: SqlitePool,
pub config: Arc<Config>,
}
type Context = Arc<ContextInner>;
pub async fn start_http_api_server(database: SqlitePool, config: Arc<Config>) -> Result<()> {
let listener = tokio::net::TcpListener::bind((config.http_address, config.http_port)).await?;
let router = Router::new()
.route("/pictures/", routing::post(create_picture))
.with_state(Arc::new(ContextInner {
database,
config,
}));
axum::serve(listener, router).await?;
Ok(())
}

28
src/main.rs Normal file
View file

@ -0,0 +1,28 @@
mod config;
mod http_api;
use std::sync::Arc;
use color_eyre::eyre::WrapErr;
use color_eyre::Result;
use crate::config::load_config;
use crate::http_api::start_http_api_server;
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install().unwrap();
env_logger::init();
let config = Arc::new(load_config()?);
log::debug!("Loaded configuration: {:#?}", config);
let database = sqlx::SqlitePool::connect(&format!("sqlite://{}", config.database_file))
.await
.wrap_err("Failed to open the database connection.")?;
log::info!("Initialization successful.");
start_http_api_server(database, Arc::clone(&config)).await?;
Ok(())
}