WIP: v1.0.0
This commit is contained in:
parent
f834a8235e
commit
e9d12b2697
7 changed files with 2949 additions and 0 deletions
2717
Cargo.lock
generated
Normal file
2717
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal 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
29
src/config.rs
Normal 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
117
src/http_api/api_error.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
5
src/http_api/create_picture.rs
Normal file
5
src/http_api/create_picture.rs
Normal 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
31
src/http_api/mod.rs
Normal 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
28
src/main.rs
Normal 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(())
|
||||
}
|
Loading…
Add table
Reference in a new issue