use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
response::Response,
};
use postrust_engine::error_response;
use std::sync::Arc;
pub async fn handle_request(State(state): State<Arc<AppState>>, request: Request) -> Response {
#[cfg(not(feature = "pg-socket"))]
let response = postrust_engine::handle(state.as_ref(), request).await;
#[cfg(feature = "pg-socket")]
let response = postrust_engine::handle(&via_socket::ViaSocket(state), request).await;
response.map(Body::from)
}
#[cfg(feature = "pg-socket")]
mod via_socket {
use crate::state::AppState;
use postrust_auth::{JwtCache, JwtConfig};
use postrust_core::db::tokio_postgres::config::{Host, SslMode};
use postrust_core::db::tokio_postgres::{Config, NoTls};
use postrust_core::db::{BoxFuture, Connector, PgSocketDb};
use postrust_core::{AppConfig, SchemaCache};
use std::sync::{Arc, Mutex};
use tokio::sync::RwLockReadGuard;
pub struct Tcp(Result<(String, u16), String>);
impl Connector for Tcp {
type Stream = tokio::net::TcpStream;
type Tls = NoTls;
async fn connect(&self) -> std::io::Result<tokio::net::TcpStream> {
let (host, port) = self
.0
.as_ref()
.map_err(|e| std::io::Error::other(e.clone()))?;
let stream = tokio::net::TcpStream::connect((host.as_str(), *port)).await?;
stream.set_nodelay(true)?;
Ok(stream)
}
fn tls(&self) -> NoTls {
NoTls
}
fn spawn(&self, connection: BoxFuture<'static, ()>) {
tokio::spawn(connection);
}
async fn sleep(&self, duration: std::time::Duration) {
tokio::time::sleep(duration).await
}
}
pub(super) fn database(uri: &str) -> PgSocketDb<Tcp> {
let parsed = uri
.parse::<Config>()
.map_err(|e| format!("pg-socket: the database URI does not parse: {e}"));
let (config, target) = match parsed {
Ok(config) => {
let target = match (config.get_hosts().first(), config.get_ports().first()) {
_ if config.get_ssl_mode() == SslMode::Require => Err(
"pg-socket: this build connects without TLS, and the database URI \
says sslmode=require"
.to_string(),
),
(Some(Host::Tcp(host)), port) => {
Ok((host.clone(), port.copied().unwrap_or(5432)))
}
_ => Err("pg-socket: the database URI needs a TCP host".to_string()),
};
(config, target)
}
Err(e) => (Config::new(), Err(e)),
};
match &target {
Ok((host, port)) => tracing::warn!(
"pg-socket: REST requests run through the socket driver, one connection \
per request to {host}:{port}. This build is for testing."
),
Err(e) => tracing::error!("{e}"),
}
PgSocketDb::new(Tcp(target), config)
}
static DBS: Mutex<Vec<(String, &'static PgSocketDb<Tcp>)>> = Mutex::new(Vec::new());
fn database_for(uri: &str) -> &'static PgSocketDb<Tcp> {
let mut known = DBS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some((_, db)) = known.iter().find(|(known, _)| known == uri) {
return db;
}
let db: &'static PgSocketDb<Tcp> = Box::leak(Box::new(database(uri)));
known.push((uri.to_string(), db));
db
}
pub struct ViaSocket(pub Arc<AppState>);
impl postrust_engine::State for ViaSocket {
type Db = PgSocketDb<Tcp>;
type SchemaCache<'a>
= RwLockReadGuard<'a, SchemaCache>
where
Self: 'a;
fn db(&self) -> &PgSocketDb<Tcp> {
database_for(&self.0.config.db_uri)
}
async fn schema_cache(&self) -> RwLockReadGuard<'_, SchemaCache> {
self.0.schema_cache().await
}
fn config(&self) -> &AppConfig {
&self.0.config
}
fn jwt_config(&self) -> &JwtConfig {
&self.0.jwt_config
}
fn jwt_cache(&self) -> Option<&JwtCache> {
self.0.jwt_cache.as_ref()
}
}
}
pub async fn options_allow(
State(state): State<Arc<AppState>>,
request: Request,
next: axum::middleware::Next,
) -> Response {
if request.method() != http::Method::OPTIONS {
return next.run(request).await;
}
let Some(path) = api_path(request.uri().path(), state.config.compat_mode) else {
return next.run(request).await;
};
let verbatim_db_errors = state.config.compat_mode;
let probe = probe_request(&request, path);
let allow = match probe {
Ok(probe) => postrust_engine::allow(state.as_ref(), probe).await,
Err(error) => Err(error),
};
let mut response = next.run(request).await;
match allow {
Ok(allow) => {
if let Ok(value) = http::HeaderValue::from_str(&allow) {
response.headers_mut().insert(http::header::ALLOW, value);
}
response
}
Err(error) => {
let mut refusal = error_response(error, verbatim_db_errors).map(Body::from);
for (name, value) in response.headers() {
if name.as_str().starts_with("access-control-") || name == http::header::VARY {
refusal.headers_mut().append(name, value.clone());
}
}
refusal
}
}
}
fn api_path(path: &str, compat_mode: bool) -> Option<&str> {
fn under(path: &str, mount: &str) -> bool {
path == mount || path.strip_prefix(mount).is_some_and(|r| r.starts_with('/'))
}
for mount in [
"/admin",
"/_",
"/api/graphql",
"/healthz",
"/v1/version",
"/v1/graphql",
"/v1alpha1/graphql",
] {
if under(path, mount) {
return None;
}
}
if under(path, "/api") {
return Some(match path.strip_prefix("/api") {
None | Some("") => "/",
Some(rest) => rest,
});
}
compat_mode.then_some(path)
}
fn probe_request(
request: &Request,
path: &str,
) -> Result<http::Request<bytes::Bytes>, postrust_core::Error> {
let target = match request.uri().query() {
Some(query) => format!("{path}?{query}"),
None => path.to_string(),
};
let mut builder = http::Request::builder()
.method(request.method().clone())
.uri(target);
for (name, value) in request.headers() {
builder = builder.header(name, value);
}
builder
.body(bytes::Bytes::new())
.map_err(|e| postrust_core::Error::Internal(e.to_string()))
}
#[cfg(test)]
mod tests {
#[test]
fn the_api_path_is_the_one_below_the_mount() {
assert_eq!(super::api_path("/api/items", false), Some("/items"));
assert_eq!(super::api_path("/api/rpc/f", false), Some("/rpc/f"));
assert_eq!(super::api_path("/api", false), Some("/"));
assert_eq!(super::api_path("/api/", false), Some("/"));
}
#[test]
fn a_route_that_is_not_the_api_is_left_alone() {
for path in [
"/admin",
"/admin/swagger",
"/_",
"/_/health",
"/api/graphql",
"/healthz",
"/v1/version",
"/v1/graphql",
"/v1/graphql/ws",
"/v1alpha1/graphql",
] {
assert_eq!(super::api_path(path, false), None, "{path}");
assert_eq!(super::api_path(path, true), None, "{path}");
}
}
#[test]
fn a_hasura_mount_does_not_swallow_a_table_beside_it() {
assert_eq!(super::api_path("/v1", true), Some("/v1"));
assert_eq!(super::api_path("/healthzz", true), Some("/healthzz"));
assert_eq!(super::api_path("/v1alpha1", true), Some("/v1alpha1"));
}
#[test]
fn the_root_is_the_api_only_in_compatibility_mode() {
assert_eq!(super::api_path("/items", true), Some("/items"));
assert_eq!(super::api_path("/", true), Some("/"));
assert_eq!(super::api_path("/items", false), None);
assert_eq!(super::api_path("/", false), None);
}
#[test]
fn a_mount_matches_whole_segments_only() {
assert_eq!(super::api_path("/apiary", true), Some("/apiary"));
assert_eq!(
super::api_path("/administrators", true),
Some("/administrators")
);
}
#[cfg(feature = "pg-socket")]
#[tokio::test]
async fn an_unusable_database_uri_fails_requests_rather_than_the_process() {
use postrust_core::db::Db;
for (uri, says) in [
("postgres://u@:what/db", "does not parse"),
(
"postgres://u@localhost/db?sslmode=require",
"sslmode=require",
),
("host=/var/run/postgresql dbname=db", "TCP host"),
] {
let db = super::via_socket::database(uri);
match db.begin().await {
Err(e) => assert!(e.to_string().contains(says), "{uri}: {e}"),
Ok(_) => panic!("{uri}: connected"),
}
}
}
}