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