use crate::AuthContext;
use crate::session::SessionRequest;
use crate::types::RequestExtensions;
use crate::utils::LockUnpoisoned;
use crate::utils::json::JsValue;
use crate::wire::{SessionView, UserView};
use crate::{AuthError, AuthRequest, AuthResult, AuthSchema, Headers, HttpMethod};
use serde::{Serialize, de::DeserializeOwned};
use std::collections::HashMap;
use std::marker::PhantomData;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
pub struct EndpointDefinition {
pub name: &'static str,
pub operation_id: &'static str,
pub path: Option<String>,
pub method: HttpMethod,
}
#[derive(Clone, Debug)]
pub struct ServerEndpoint<T> {
plugin: &'static str,
name: &'static str,
body: Option<JsValue>,
query: Option<JsValue>,
output: PhantomData<fn() -> T>,
}
impl<T> ServerEndpoint<T> {
#[must_use]
pub const fn new(plugin: &'static str, name: &'static str) -> Self {
Self {
plugin,
name,
body: None,
query: None,
output: PhantomData,
}
}
pub fn with_body(mut self, body: &impl Serialize) -> AuthResult<Self> {
self.body = Some(crate::utils::json::parse_value(
&crate::utils::json::to_string(body)?,
)?);
Ok(self)
}
pub fn with_query(mut self, query: &impl Serialize) -> AuthResult<Self> {
self.query = Some(crate::utils::json::parse_value(
&crate::utils::json::to_string(query)?,
)?);
Ok(self)
}
#[must_use]
pub fn with_body_value(mut self, body: JsValue) -> Self {
self.body = Some(body);
self
}
#[must_use]
pub fn with_query_value(mut self, query: JsValue) -> Self {
self.query = Some(query);
self
}
#[must_use]
pub const fn plugin(&self) -> &'static str {
self.plugin
}
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub fn input(self) -> (Option<JsValue>, Option<JsValue>) {
(self.body, self.query)
}
}
#[derive(Clone, Debug, Default)]
pub struct EndpointOptions {
pub headers: Option<HashMap<String, String>>,
pub request: Option<AuthRequest>,
pub method: Option<HttpMethod>,
}
#[derive(Clone, Debug, Default)]
struct EndpointState {
session: Option<SessionView>,
principal: Option<(UserView, SessionView)>,
response_headers: Headers,
session_observation: Option<(UserView, SessionView)>,
}
struct VerifiedEndpointUser<S: AuthSchema> {
user: S::User,
config: Arc<crate::AuthConfig>,
database: Arc<dyn crate::AuthStore<S>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EndpointPhase {
Raw,
Middleware,
Handler,
}
#[derive(Clone, Debug)]
pub struct EndpointCall {
operation_id: String,
path: Option<String>,
method: Option<HttpMethod>,
body: Option<JsValue>,
query: Option<JsValue>,
headers: Option<HashMap<String, String>>,
request: Option<AuthRequest>,
default_method: HttpMethod,
phase: EndpointPhase,
extensions: RequestExtensions,
state: Arc<Mutex<EndpointState>>,
}
impl EndpointCall {
#[must_use]
pub fn new(
definition: &EndpointDefinition,
body: Option<JsValue>,
query: Option<JsValue>,
options: EndpointOptions,
) -> Self {
Self {
operation_id: definition.operation_id.into(),
path: definition.path.clone(),
method: options.method,
body,
query,
headers: options.headers.map(|headers| {
headers
.into_iter()
.map(|(name, value)| (name.to_ascii_lowercase(), value))
.collect()
}),
request: options.request.map(fresh_request),
default_method: definition.method.clone(),
phase: EndpointPhase::Raw,
extensions: RequestExtensions::default(),
state: Arc::new(Mutex::new(EndpointState::default())),
}
}
#[must_use]
pub fn from_http_request(request: &AuthRequest, route: &crate::AuthRoute) -> Self {
let body = request
.extensions()
.get::<crate::types::ParsedRequestBody>()
.and_then(|body| match body.as_ref() {
crate::types::ParsedRequestBody::Value(value) => Some(value.clone()),
crate::types::ParsedRequestBody::Opaque(_) => None,
});
let query = JsValue::Object(
request
.query
.keys()
.map(|name| {
let values = request.query_values(name).unwrap_or_default();
let value = if values.len() > 1 {
JsValue::Array(values.iter().cloned().map(JsValue::String).collect())
} else {
JsValue::String(request.query.get(name).cloned().unwrap_or_default())
};
(name.clone(), value)
})
.collect(),
);
let definition = EndpointDefinition {
name: "http",
operation_id: "http",
path: Some(route.context_path.as_ref().unwrap_or(&route.path).clone()),
method: route.method.clone(),
};
let mut call = Self::new(
&definition,
body,
Some(query),
EndpointOptions {
headers: Some(request.headers.clone()),
request: Some(
crate::hooks::current_request_hook_context()
.map_or_else(|| request.clone(), |context| context.request),
),
method: Some(request.method.clone()),
},
);
call.operation_id.clone_from(&route.operation_id);
call
}
#[must_use]
pub fn operation_id(&self) -> &str {
&self.operation_id
}
#[must_use]
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
#[must_use]
pub const fn method(&self) -> Option<&HttpMethod> {
self.method.as_ref()
}
#[must_use]
pub const fn body(&self) -> Option<&JsValue> {
self.body.as_ref()
}
#[must_use]
pub const fn query(&self) -> Option<&JsValue> {
self.query.as_ref()
}
#[must_use]
pub const fn headers(&self) -> Option<&HashMap<String, String>> {
self.headers.as_ref()
}
pub fn replace_headers(&mut self, headers: HashMap<String, String>) {
self.headers = Some(headers);
}
#[must_use]
pub const fn request(&self) -> Option<&AuthRequest> {
self.request.as_ref()
}
#[must_use]
pub const fn phase(&self) -> EndpointPhase {
self.phase
}
#[must_use]
pub fn has_body(&self) -> bool {
self.phase != EndpointPhase::Raw || self.body.is_some()
}
#[must_use]
pub fn has_query(&self) -> bool {
self.phase != EndpointPhase::Raw || self.query.is_some()
}
#[must_use]
pub fn has_method(&self) -> bool {
self.phase != EndpointPhase::Raw || self.method.is_some()
}
#[must_use]
pub fn middleware_context(&self) -> Self {
let mut context = self.clone();
context.phase = EndpointPhase::Middleware;
if context.path.as_ref().is_none_or(String::is_empty) {
context.path = Some("/".into());
}
context
}
#[must_use]
pub fn handler_context(&self, body: Option<JsValue>, query: Option<JsValue>) -> Self {
let mut context = self.clone();
context.phase = EndpointPhase::Handler;
if context.path.as_ref().is_none_or(String::is_empty) {
context.path = Some("virtual:".into());
}
if context.method.is_none() {
context.method = Some(context.default_method.clone());
}
context.body = body;
context.query = query;
context
}
pub(crate) fn session_read_context(&self) -> Self {
let mut context = self.clone();
context.phase = EndpointPhase::Handler;
if context.path.as_ref().is_none_or(String::is_empty) {
context.path = Some("virtual:".into());
}
context.method = Some(HttpMethod::Get);
let mut query = indexmap::IndexMap::new();
for name in ["disableCookieCache", "disableRefresh"] {
if self
.query
.as_ref()
.and_then(|value| value.get(name))
.is_some()
{
_ = query.insert(name.into(), JsValue::Bool(self.session_query_truthy(name)));
}
}
context.query = Some(JsValue::Object(query));
context
}
#[must_use]
pub const fn extensions(&self) -> &RequestExtensions {
&self.extensions
}
#[must_use]
pub const fn default_method(&self) -> &HttpMethod {
&self.default_method
}
#[must_use]
pub fn virtual_session(&self) -> Option<SessionView> {
self.state.lock_unpoisoned().session.clone()
}
pub fn establish_session<S: AuthSchema>(
&self,
user: S::User,
user_view: UserView,
session: SessionView,
context: &AuthContext<S>,
) {
self.extensions.insert(VerifiedEndpointUser::<S> {
user,
config: context.config.clone(),
database: context.database.clone(),
});
let mut state = self.state.lock_unpoisoned();
state.principal = Some((user_view, session.clone()));
state.session = Some(session);
}
#[must_use]
pub fn session(&self) -> Option<(UserView, SessionView)> {
self.state.lock_unpoisoned().principal.clone()
}
pub fn record_authenticated_session(&self, user: UserView, session: SessionView) {
self.state.lock_unpoisoned().principal = Some((user, session));
}
#[must_use]
pub fn authenticated_user<S: AuthSchema>(&self, context: &AuthContext<S>) -> Option<S::User> {
self.extensions
.get::<VerifiedEndpointUser<S>>()
.filter(|user| {
Arc::ptr_eq(&user.config, &context.config)
&& Arc::ptr_eq(&user.database, &context.database)
})
.map(|user| user.user.clone())
}
pub fn set_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
_ = self
.state
.lock_unpoisoned()
.response_headers
.insert(name, value);
}
pub fn queue_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
self.state
.lock_unpoisoned()
.response_headers
.append(name, value);
}
pub fn take_response_headers(&self) -> Headers {
std::mem::take(&mut self.state.lock_unpoisoned().response_headers)
}
pub fn set_session_hook_snapshot(&self, user: UserView, session: SessionView) {
self.state.lock_unpoisoned().session_observation = Some((user, session));
}
#[must_use]
pub fn session_hook_snapshot(&self) -> Option<(UserView, SessionView)> {
self.state.lock_unpoisoned().session_observation.clone()
}
pub fn body_as<T: DeserializeOwned + 'static>(&self) -> AuthResult<T> {
Ok(crate::utils::json::from_value(
self.body.clone().unwrap_or(JsValue::Null),
)?)
}
pub fn query_as<T: DeserializeOwned + 'static>(&self) -> AuthResult<T> {
Ok(crate::utils::json::from_value(
self.query.clone().unwrap_or(JsValue::Null),
)?)
}
}
#[derive(Clone, Debug, Default)]
pub struct EndpointContextPatch {
pub method: Option<HttpMethod>,
pub request: Option<AuthRequest>,
pub path: Option<String>,
pub body: Option<JsValue>,
pub query: Option<JsValue>,
pub headers: Option<HashMap<String, String>>,
}
impl EndpointContextPatch {
pub fn merge(&mut self, patch: Self) {
if let Some(method) = patch.method {
self.method = Some(method);
}
if let Some(request) = patch.request {
self.request = Some(request);
}
if let Some(path) = patch.path {
self.path = Some(path);
}
merge_optional(&mut self.body, patch.body);
merge_optional(&mut self.query, patch.query);
if let Some(headers) = patch.headers {
self.headers.get_or_insert_with(HashMap::new).extend(
headers
.into_iter()
.map(|(name, value)| (name.to_ascii_lowercase(), value)),
);
}
}
pub fn apply(self, call: &mut EndpointCall) {
if let Some(method) = self.method {
call.method = Some(method);
}
if let Some(request) = self.request {
call.request = Some(fresh_request(request));
}
if let Some(path) = self.path {
call.path = Some(path);
}
merge_optional(&mut call.body, self.body);
merge_optional(&mut call.query, self.query);
if let Some(headers) = self.headers {
call.headers
.get_or_insert_with(HashMap::new)
.extend(headers);
}
}
}
fn merge_optional(target: &mut Option<JsValue>, patch: Option<JsValue>) {
if let Some(patch) = patch.filter(|value| !value.is_null()) {
if let Some(target) = target {
merge_value(target, patch);
} else {
*target = Some(patch);
}
}
}
fn merge_value(target: &mut JsValue, patch: JsValue) {
if let (JsValue::Object(target), JsValue::Object(patch)) = (&mut *target, &patch) {
for (name, value) in patch {
if value.is_null() || matches!(name.as_str(), "__proto__" | "constructor") {
continue;
}
if let Some(target) = target.get_mut(name) {
merge_value(target, value.clone());
} else {
_ = target.insert(name.clone(), value.clone());
}
}
} else {
*target = patch;
}
}
#[derive(Debug)]
pub enum BeforeEndpointAction {
Patch(Box<EndpointContextPatch>),
Respond(EndpointResponse),
Reject(EndpointResponse),
}
#[derive(Clone, Debug, Default)]
pub struct EndpointInput {
pub body: Option<JsValue>,
pub query: Option<JsValue>,
}
#[derive(Debug)]
pub struct EndpointResponse {
result: Result<JsValue, AuthError>,
error_body: Option<JsValue>,
headers: Headers,
status: Option<u16>,
}
impl EndpointResponse {
#[must_use]
pub fn value(value: JsValue) -> Self {
Self {
result: Ok(value),
error_body: None,
headers: Headers::new(),
status: None,
}
}
pub fn json(value: &impl Serialize) -> AuthResult<Self> {
Ok(Self::value(crate::utils::json::parse_value(
&crate::utils::json::to_string(value)?,
)?))
}
#[must_use]
pub fn error(error: AuthError) -> Self {
let status = Some(error.status_code());
Self {
result: Err(error),
error_body: None,
headers: Headers::new(),
status,
}
}
#[must_use]
pub fn with_error_body(mut self, body: JsValue) -> Self {
self.error_body = Some(body);
self
}
#[must_use]
pub const fn error_body(&self) -> Option<&JsValue> {
self.error_body.as_ref()
}
pub const fn result(&self) -> &Result<JsValue, AuthError> {
&self.result
}
#[must_use]
pub const fn headers(&self) -> &Headers {
&self.headers
}
#[must_use]
pub const fn status(&self) -> Option<u16> {
self.status
}
pub fn set_status(&mut self, status: Option<u16>) {
self.status = status;
}
#[must_use]
pub fn with_status(mut self, status: u16) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
merge_header(&mut self.headers, name.into(), value.into());
self
}
pub fn merge_headers(&mut self, headers: Headers) {
for (name, value) in headers {
merge_header(&mut self.headers, name, value);
}
}
pub fn replace(&mut self, value: JsValue) {
self.result = Ok(value);
self.error_body = None;
}
pub fn replace_error(&mut self, error: AuthError) {
self.result = Err(error);
self.error_body = None;
}
pub fn into_output<T>(self) -> Result<EndpointOutput<T>, EndpointError> {
match self.result {
Ok(value) => Ok(EndpointOutput {
value,
headers: self.headers,
status: self.status,
output: PhantomData,
}),
Err(error) => Err(EndpointError {
error,
headers: Some(self.headers),
body: self.error_body.map(Box::new),
}),
}
}
}
fn merge_header(headers: &mut Headers, name: String, value: String) {
if name.eq_ignore_ascii_case("set-cookie") {
headers.append(name, value);
} else {
_ = headers.insert(name, value);
}
}
#[async_trait::async_trait]
pub trait EndpointHook<S: AuthSchema>: Send + Sync {
fn matches_before(&self, _call: &EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<bool> {
Ok(true)
}
async fn before(
&self,
_call: &EndpointCall,
_ctx: &AuthContext<S>,
) -> AuthResult<Option<BeforeEndpointAction>> {
Ok(None)
}
fn matches_after(
&self,
_call: &EndpointCall,
_ctx: &AuthContext<S>,
_response: &EndpointResponse,
) -> AuthResult<bool> {
Ok(true)
}
async fn after(
&self,
_call: &EndpointCall,
_ctx: &AuthContext<S>,
response: EndpointResponse,
) -> AuthResult<EndpointResponse> {
Ok(response)
}
}
#[derive(Debug)]
pub struct EndpointOutput<T> {
value: JsValue,
headers: Headers,
status: Option<u16>,
output: PhantomData<fn() -> T>,
}
impl<T> EndpointOutput<T> {
#[must_use]
pub const fn value(&self) -> &JsValue {
&self.value
}
#[must_use]
pub const fn headers(&self) -> &Headers {
&self.headers
}
#[must_use]
pub const fn status(&self) -> Option<u16> {
self.status
}
}
impl<T: DeserializeOwned + 'static> EndpointOutput<T> {
pub fn decode(&self) -> AuthResult<T> {
Ok(crate::utils::json::from_value(self.value.clone())?)
}
}
#[derive(Debug)]
pub struct EndpointError {
pub error: AuthError,
pub headers: Option<Headers>,
pub body: Option<Box<JsValue>>,
}
impl From<AuthError> for EndpointError {
fn from(error: AuthError) -> Self {
Self {
error,
headers: None,
body: None,
}
}
}
impl std::fmt::Display for EndpointError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.error.fmt(formatter)
}
}
impl std::error::Error for EndpointError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
#[must_use]
pub fn is_endpoint_api_error(error: &AuthError) -> bool {
matches!(error, AuthError::Api { .. } | AuthError::Upstream { .. }) || error.status_code() < 500
}
fn fresh_request(request: AuthRequest) -> AuthRequest {
let pairs = request
.query
.keys()
.flat_map(|name| {
request
.query_values(name)
.into_iter()
.flatten()
.map(|value| (name.clone(), value.clone()))
})
.collect::<Vec<_>>();
let url = request.url().cloned();
let mut fresh = AuthRequest::from_parts(
request.method,
request.path,
request.headers,
request.body,
request.query,
);
if let Some(url) = url {
fresh = fresh.with_url(url);
}
fresh.set_query_pairs(pairs);
fresh
}
tokio::task_local! { static ENDPOINT_CALL_CONTEXT: Option<EndpointCall>; }
#[must_use]
pub fn current_endpoint_call_context() -> Option<EndpointCall> {
ENDPOINT_CALL_CONTEXT.try_with(Clone::clone).ok().flatten()
}
pub async fn with_endpoint_call_context<T>(
call: EndpointCall,
future: impl Future<Output = T>,
) -> T {
let request = call
.request()
.map(crate::hooks::RequestHookContext::from_request);
crate::hooks::with_optional_request_hook_context(
request,
ENDPOINT_CALL_CONTEXT.scope(Some(call), future),
)
.await
}
pub async fn without_endpoint_call_context<T>(future: impl Future<Output = T>) -> T {
ENDPOINT_CALL_CONTEXT.scope(None, future).await
}