Skip to main content

alibi_core/
endpoint.rs

1//! Trusted endpoint calls keep logical input separate from an optional HTTP request.
2
3use crate::types::RequestExtensions;
4use crate::utils::json::JsValue;
5use crate::wire::{SessionView, UserView};
6use crate::{AuthError, AuthRequest, AuthResult, AuthSchema, Headers, HttpMethod};
7use serde::{Serialize, de::DeserializeOwned};
8use std::collections::HashMap;
9use std::marker::PhantomData;
10use std::sync::{Arc, Mutex};
11
12/// An operation registered by an installed plugin, independently of its HTTP routes.
13#[derive(Clone, Debug)]
14pub struct EndpointDefinition {
15    pub name: &'static str,
16    pub operation_id: &'static str,
17    pub path: Option<String>,
18    pub method: HttpMethod,
19}
20
21/// Typed operation input. Dispatch decodes the final input after before-hook patches.
22#[derive(Clone, Debug)]
23pub struct ServerEndpoint<T> {
24    plugin: &'static str,
25    name: &'static str,
26    body: Option<JsValue>,
27    query: Option<JsValue>,
28    output: PhantomData<fn() -> T>,
29}
30
31impl<T> ServerEndpoint<T> {
32    #[must_use]
33    pub const fn new(plugin: &'static str, name: &'static str) -> Self {
34        Self {
35            plugin,
36            name,
37            body: None,
38            query: None,
39            output: PhantomData,
40        }
41    }
42
43    /// # Errors
44    /// Returns an error if application input cannot be serialized.
45    pub fn with_body(mut self, body: &impl Serialize) -> AuthResult<Self> {
46        self.body = Some(crate::utils::json::parse_value(
47            &crate::utils::json::to_string(body)?,
48        )?);
49        Ok(self)
50    }
51
52    /// # Errors
53    /// Returns an error if application input cannot be serialized.
54    pub fn with_query(mut self, query: &impl Serialize) -> AuthResult<Self> {
55        self.query = Some(crate::utils::json::parse_value(
56            &crate::utils::json::to_string(query)?,
57        )?);
58        Ok(self)
59    }
60
61    #[must_use]
62    pub fn with_body_value(mut self, body: JsValue) -> Self {
63        self.body = Some(body);
64        self
65    }
66
67    #[must_use]
68    pub fn with_query_value(mut self, query: JsValue) -> Self {
69        self.query = Some(query);
70        self
71    }
72
73    #[must_use]
74    pub const fn plugin(&self) -> &'static str {
75        self.plugin
76    }
77
78    #[must_use]
79    pub const fn name(&self) -> &'static str {
80        self.name
81    }
82
83    #[must_use]
84    pub fn input(self) -> (Option<JsValue>, Option<JsValue>) {
85        (self.body, self.query)
86    }
87}
88
89/// Per-call options. Absent headers remain distinct from an explicitly empty collection.
90#[derive(Clone, Debug, Default)]
91pub struct EndpointOptions {
92    pub headers: Option<HashMap<String, String>>,
93    pub request: Option<AuthRequest>,
94    pub method: Option<HttpMethod>,
95}
96
97#[derive(Clone, Debug, Default)]
98struct EndpointState {
99    session: Option<SessionView>,
100    principal: Option<(UserView, SessionView)>,
101    response_headers: Headers,
102    session_observation: Option<(UserView, SessionView)>,
103}
104
105struct VerifiedEndpointUser<S: AuthSchema> {
106    user: S::User,
107    config: Arc<crate::AuthConfig>,
108    database: Arc<dyn crate::AuthStore<S>>,
109}
110
111/// Runtime normalization phase, retaining absence in original logical inputs.
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum EndpointPhase {
114    Raw,
115    Middleware,
116    Handler,
117}
118
119/// Genuine logical endpoint data available to hooks and application callbacks.
120///
121/// Clones share trusted session state, observed snapshots and queued headers.
122/// Every public dispatch constructs fresh state; supplied HTTP metadata cannot
123/// carry a caller-populated virtual principal into this context.
124#[derive(Clone, Debug)]
125pub struct EndpointCall {
126    operation_id: String,
127    path: Option<String>,
128    method: Option<HttpMethod>,
129    body: Option<JsValue>,
130    query: Option<JsValue>,
131    headers: Option<HashMap<String, String>>,
132    request: Option<AuthRequest>,
133    default_method: HttpMethod,
134    phase: EndpointPhase,
135    extensions: RequestExtensions,
136    state: Arc<Mutex<EndpointState>>,
137}
138
139impl EndpointCall {
140    #[must_use]
141    pub fn new(
142        definition: &EndpointDefinition,
143        body: Option<JsValue>,
144        query: Option<JsValue>,
145        options: EndpointOptions,
146    ) -> Self {
147        Self {
148            operation_id: definition.operation_id.into(),
149            path: definition.path.clone(),
150            method: options.method,
151            body,
152            query,
153            headers: options.headers.map(|headers| {
154                headers
155                    .into_iter()
156                    .map(|(name, value)| (name.to_ascii_lowercase(), value))
157                    .collect()
158            }),
159            request: options.request.map(fresh_request),
160            default_method: definition.method.clone(),
161            phase: EndpointPhase::Raw,
162            extensions: RequestExtensions::default(),
163            state: Arc::new(Mutex::new(EndpointState::default())),
164        }
165    }
166
167    /// Logical HTTP endpoint input after physical routing and body parsing.
168    /// The original physical request remains separate from normalized hook input.
169    #[must_use]
170    pub fn from_http_request(request: &AuthRequest, route: &crate::AuthRoute) -> Self {
171        let body = request
172            .extensions()
173            .get::<crate::types::ParsedRequestBody>()
174            .and_then(|body| match body.as_ref() {
175                crate::types::ParsedRequestBody::Value(value) => Some(value.clone()),
176                crate::types::ParsedRequestBody::Opaque(_) => None,
177            });
178        let query = JsValue::Object(
179            request
180                .query
181                .keys()
182                .map(|name| {
183                    let values = request.query_values(name).unwrap_or_default();
184                    let value = if values.len() > 1 {
185                        JsValue::Array(values.iter().cloned().map(JsValue::String).collect())
186                    } else {
187                        JsValue::String(request.query.get(name).cloned().unwrap_or_default())
188                    };
189                    (name.clone(), value)
190                })
191                .collect(),
192        );
193        let definition = EndpointDefinition {
194            name: "http",
195            operation_id: "http",
196            path: Some(route.context_path.as_ref().unwrap_or(&route.path).clone()),
197            method: route.method.clone(),
198        };
199        let mut call = Self::new(
200            &definition,
201            body,
202            Some(query),
203            EndpointOptions {
204                headers: Some(request.headers.clone()),
205                request: Some(
206                    crate::hooks::current_request_hook_context()
207                        .map_or_else(|| request.clone(), |context| context.request),
208                ),
209                method: Some(request.method.clone()),
210            },
211        );
212        call.operation_id.clone_from(&route.operation_id);
213        call
214    }
215
216    #[must_use]
217    pub fn operation_id(&self) -> &str {
218        &self.operation_id
219    }
220
221    #[must_use]
222    pub fn path(&self) -> Option<&str> {
223        self.path.as_deref()
224    }
225
226    #[must_use]
227    pub const fn method(&self) -> Option<&HttpMethod> {
228        self.method.as_ref()
229    }
230
231    #[must_use]
232    pub const fn body(&self) -> Option<&JsValue> {
233        self.body.as_ref()
234    }
235
236    #[must_use]
237    pub const fn query(&self) -> Option<&JsValue> {
238        self.query.as_ref()
239    }
240
241    #[must_use]
242    pub const fn headers(&self) -> Option<&HashMap<String, String>> {
243        self.headers.as_ref()
244    }
245
246    /// Replace logical headers after a physical/plugin header transformation.
247    /// Session state and the distinct physical Request are retained.
248    pub fn replace_headers(&mut self, headers: HashMap<String, String>) {
249        self.headers = Some(headers);
250    }
251
252    #[must_use]
253    pub const fn request(&self) -> Option<&AuthRequest> {
254        self.request.as_ref()
255    }
256
257    #[must_use]
258    pub const fn phase(&self) -> EndpointPhase {
259        self.phase
260    }
261
262    #[must_use]
263    pub fn has_body(&self) -> bool {
264        self.phase != EndpointPhase::Raw || self.body.is_some()
265    }
266
267    #[must_use]
268    pub fn has_query(&self) -> bool {
269        self.phase != EndpointPhase::Raw || self.query.is_some()
270    }
271
272    #[must_use]
273    pub fn has_method(&self) -> bool {
274        self.phase != EndpointPhase::Raw || self.method.is_some()
275    }
276
277    /// Middleware callbacks have their own path/query defaults; matchers retain raw input.
278    #[must_use]
279    pub fn middleware_context(&self) -> Self {
280        let mut context = self.clone();
281        context.phase = EndpointPhase::Middleware;
282        if context.path.as_ref().is_none_or(String::is_empty) {
283            context.path = Some("/".into());
284        }
285        context
286    }
287
288    /// A handler receives validated data and its registered method/path defaults.
289    #[must_use]
290    pub fn handler_context(&self, body: Option<JsValue>, query: Option<JsValue>) -> Self {
291        let mut context = self.clone();
292        context.phase = EndpointPhase::Handler;
293        if context.path.as_ref().is_none_or(String::is_empty) {
294            context.path = Some("virtual:".into());
295        }
296        if context.method.is_none() {
297            context.method = Some(context.default_method.clone());
298        }
299        context.body = body;
300        context.query = query;
301        context
302    }
303
304    /// The nested session getter preserves caller input and validates its own query.
305    pub(crate) fn session_read_context(&self) -> Self {
306        use crate::session::SessionRequest;
307        let mut context = self.clone();
308        context.phase = EndpointPhase::Handler;
309        if context.path.as_ref().is_none_or(String::is_empty) {
310            context.path = Some("virtual:".into());
311        }
312        context.method = Some(HttpMethod::Get);
313        let mut query = indexmap::IndexMap::new();
314        for name in ["disableCookieCache", "disableRefresh"] {
315            if self
316                .query
317                .as_ref()
318                .and_then(|value| value.get(name))
319                .is_some()
320            {
321                drop(query.insert(name.into(), JsValue::Bool(self.session_query_truthy(name))));
322            }
323        }
324        context.query = Some(JsValue::Object(query));
325        context
326    }
327
328    #[must_use]
329    pub const fn extensions(&self) -> &RequestExtensions {
330        &self.extensions
331    }
332
333    #[must_use]
334    pub const fn default_method(&self) -> &HttpMethod {
335        &self.default_method
336    }
337
338    #[must_use]
339    pub fn virtual_session(&self) -> Option<SessionView> {
340        self.state
341            .lock()
342            .unwrap_or_else(std::sync::PoisonError::into_inner)
343            .session
344            .clone()
345    }
346
347    /// Retain the actual model and snapshot after a trusted hook has authenticated them.
348    /// This is an application/plugin authority boundary, never client input.
349    pub fn establish_session<S: AuthSchema>(
350        &self,
351        user: S::User,
352        user_view: UserView,
353        session: SessionView,
354        context: &crate::AuthContext<S>,
355    ) {
356        self.extensions.insert(VerifiedEndpointUser::<S> {
357            user,
358            config: context.config.clone(),
359            database: context.database.clone(),
360        });
361        let mut state = self
362            .state
363            .lock()
364            .unwrap_or_else(std::sync::PoisonError::into_inner);
365        state.principal = Some((user_view, session.clone()));
366        state.session = Some(session);
367    }
368
369    /// Callback-visible authenticated data. This observation does not confer authority.
370    #[must_use]
371    pub fn session(&self) -> Option<(UserView, SessionView)> {
372        self.state
373            .lock()
374            .unwrap_or_else(std::sync::PoisonError::into_inner)
375            .principal
376            .clone()
377    }
378
379    pub fn record_authenticated_session(&self, user: UserView, session: SessionView) {
380        self.state
381            .lock()
382            .unwrap_or_else(std::sync::PoisonError::into_inner)
383            .principal = Some((user, session));
384    }
385
386    #[must_use]
387    pub fn authenticated_user<S: AuthSchema>(
388        &self,
389        context: &crate::AuthContext<S>,
390    ) -> Option<S::User> {
391        self.extensions
392            .get::<VerifiedEndpointUser<S>>()
393            .filter(|user| {
394                Arc::ptr_eq(&user.config, &context.config)
395                    && Arc::ptr_eq(&user.database, &context.database)
396            })
397            .map(|user| user.user.clone())
398    }
399
400    pub fn set_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
401        drop(
402            self.state
403                .lock()
404                .unwrap_or_else(std::sync::PoisonError::into_inner)
405                .response_headers
406                .insert(name, value),
407        );
408    }
409
410    pub fn queue_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
411        self.state
412            .lock()
413            .unwrap_or_else(std::sync::PoisonError::into_inner)
414            .response_headers
415            .append(name, value);
416    }
417
418    pub fn take_response_headers(&self) -> Headers {
419        std::mem::take(
420            &mut self
421                .state
422                .lock()
423                .unwrap_or_else(std::sync::PoisonError::into_inner)
424                .response_headers,
425        )
426    }
427
428    pub fn set_session_hook_snapshot(&self, user: UserView, session: SessionView) {
429        self.state
430            .lock()
431            .unwrap_or_else(std::sync::PoisonError::into_inner)
432            .session_observation = Some((user, session));
433    }
434
435    #[must_use]
436    pub fn session_hook_snapshot(&self) -> Option<(UserView, SessionView)> {
437        self.state
438            .lock()
439            .unwrap_or_else(std::sync::PoisonError::into_inner)
440            .session_observation
441            .clone()
442    }
443
444    /// # Errors
445    /// Returns a typed decoding error for the current, post-hook body.
446    pub fn body_as<T: DeserializeOwned + 'static>(&self) -> AuthResult<T> {
447        Ok(crate::utils::json::from_value(
448            self.body.clone().unwrap_or(JsValue::Null),
449        )?)
450    }
451
452    /// # Errors
453    /// Returns a typed decoding error for the current, post-hook query.
454    pub fn query_as<T: DeserializeOwned + 'static>(&self) -> AuthResult<T> {
455        Ok(crate::utils::json::from_value(
456            self.query.clone().unwrap_or(JsValue::Null),
457        )?)
458    }
459}
460
461/// Context patches accumulate without replacing the original input seen by later before hooks.
462#[derive(Clone, Debug, Default)]
463pub struct EndpointContextPatch {
464    pub method: Option<HttpMethod>,
465    pub request: Option<AuthRequest>,
466    pub path: Option<String>,
467    pub body: Option<JsValue>,
468    pub query: Option<JsValue>,
469    pub headers: Option<HashMap<String, String>>,
470}
471
472impl EndpointContextPatch {
473    pub fn merge(&mut self, patch: Self) {
474        if let Some(method) = patch.method {
475            self.method = Some(method);
476        }
477        if let Some(request) = patch.request {
478            self.request = Some(request);
479        }
480        if let Some(path) = patch.path {
481            self.path = Some(path);
482        }
483        merge_optional(&mut self.body, patch.body);
484        merge_optional(&mut self.query, patch.query);
485        if let Some(headers) = patch.headers {
486            self.headers.get_or_insert_with(HashMap::new).extend(
487                headers
488                    .into_iter()
489                    .map(|(name, value)| (name.to_ascii_lowercase(), value)),
490            );
491        }
492    }
493
494    pub fn apply(self, call: &mut EndpointCall) {
495        if let Some(method) = self.method {
496            call.method = Some(method);
497        }
498        if let Some(request) = self.request {
499            call.request = Some(fresh_request(request));
500        }
501        if let Some(path) = self.path {
502            call.path = Some(path);
503        }
504        merge_optional(&mut call.body, self.body);
505        merge_optional(&mut call.query, self.query);
506        if let Some(headers) = self.headers {
507            call.headers
508                .get_or_insert_with(HashMap::new)
509                .extend(headers);
510        }
511    }
512}
513
514fn merge_optional(target: &mut Option<JsValue>, patch: Option<JsValue>) {
515    if let Some(patch) = patch.filter(|value| !value.is_null()) {
516        if let Some(target) = target {
517            merge_value(target, patch);
518        } else {
519            *target = Some(patch);
520        }
521    }
522}
523
524fn merge_value(target: &mut JsValue, patch: JsValue) {
525    if let (JsValue::Object(target), JsValue::Object(patch)) = (&mut *target, &patch) {
526        for (name, value) in patch {
527            if value.is_null() || matches!(name.as_str(), "__proto__" | "constructor") {
528                continue;
529            }
530            if let Some(target) = target.get_mut(name) {
531                merge_value(target, value.clone());
532            } else {
533                drop(target.insert(name.clone(), value.clone()));
534            }
535        }
536    } else {
537        *target = patch;
538    }
539}
540
541/// A before hook can accumulate input changes or finish without reaching the handler/after hooks.
542#[derive(Debug)]
543pub enum BeforeEndpointAction {
544    Patch(Box<EndpointContextPatch>),
545    Respond(EndpointResponse),
546    /// Reject with an intentional API error and its complete public body.
547    Reject(EndpointResponse),
548}
549
550/// Validated handler input. The dispatcher retains raw patched data for after hooks.
551#[derive(Clone, Debug, Default)]
552pub struct EndpointInput {
553    pub body: Option<JsValue>,
554    pub query: Option<JsValue>,
555}
556
557/// A returned value or intentional API error, available to completed endpoint hooks.
558#[derive(Debug)]
559pub struct EndpointResponse {
560    result: Result<JsValue, AuthError>,
561    error_body: Option<JsValue>,
562    headers: Headers,
563    status: Option<u16>,
564}
565
566impl EndpointResponse {
567    #[must_use]
568    pub fn value(value: JsValue) -> Self {
569        Self {
570            result: Ok(value),
571            error_body: None,
572            headers: Headers::new(),
573            status: None,
574        }
575    }
576
577    /// # Errors
578    /// Returns an error if the endpoint value cannot be serialized.
579    pub fn json(value: &impl Serialize) -> AuthResult<Self> {
580        Ok(Self::value(crate::utils::json::parse_value(
581            &crate::utils::json::to_string(value)?,
582        )?))
583    }
584
585    #[must_use]
586    pub fn error(error: AuthError) -> Self {
587        let status = Some(error.status_code());
588        Self {
589            result: Err(error),
590            error_body: None,
591            headers: Headers::new(),
592            status,
593        }
594    }
595
596    #[must_use]
597    pub fn with_error_body(mut self, body: JsValue) -> Self {
598        self.error_body = Some(body);
599        self
600    }
601
602    #[must_use]
603    pub const fn error_body(&self) -> Option<&JsValue> {
604        self.error_body.as_ref()
605    }
606
607    pub const fn result(&self) -> &Result<JsValue, AuthError> {
608        &self.result
609    }
610
611    #[must_use]
612    pub const fn headers(&self) -> &Headers {
613        &self.headers
614    }
615
616    #[must_use]
617    pub const fn status(&self) -> Option<u16> {
618        self.status
619    }
620
621    pub fn set_status(&mut self, status: Option<u16>) {
622        self.status = status;
623    }
624
625    #[must_use]
626    pub fn with_status(mut self, status: u16) -> Self {
627        self.status = Some(status);
628        self
629    }
630
631    #[must_use]
632    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
633        merge_header(&mut self.headers, name.into(), value.into());
634        self
635    }
636
637    pub fn merge_headers(&mut self, headers: Headers) {
638        for (name, value) in headers {
639            merge_header(&mut self.headers, name, value);
640        }
641    }
642
643    /// Replace a returned value while retaining the actual handler status and headers.
644    pub fn replace(&mut self, value: JsValue) {
645        self.result = Ok(value);
646        self.error_body = None;
647    }
648
649    pub fn replace_error(&mut self, error: AuthError) {
650        self.result = Err(error);
651        self.error_body = None;
652    }
653
654    pub fn into_output<T>(self) -> Result<EndpointOutput<T>, EndpointError> {
655        match self.result {
656            Ok(value) => Ok(EndpointOutput {
657                value,
658                headers: self.headers,
659                status: self.status,
660                output: PhantomData,
661            }),
662            Err(error) => Err(EndpointError {
663                error,
664                headers: Some(self.headers),
665                body: self.error_body.map(Box::new),
666            }),
667        }
668    }
669}
670
671fn merge_header(headers: &mut Headers, name: String, value: String) {
672    if name.eq_ignore_ascii_case("set-cookie") {
673        headers.append(name, value);
674    } else {
675        drop(headers.insert(name, value));
676    }
677}
678
679/// Installed application middleware for logical endpoint calls.
680#[async_trait::async_trait]
681pub trait EndpointHook<S: AuthSchema>: Send + Sync {
682    /// Matchers observe raw logical context, before middleware normalization.
683    /// # Errors
684    /// Returns an application matcher error, which dispatch logs and masks.
685    fn matches_before(
686        &self,
687        _call: &EndpointCall,
688        _ctx: &crate::AuthContext<S>,
689    ) -> AuthResult<bool> {
690        Ok(true)
691    }
692
693    /// # Errors
694    /// Propagates actual callback failures without reaching later hooks or the handler.
695    async fn before(
696        &self,
697        _call: &EndpointCall,
698        _ctx: &crate::AuthContext<S>,
699    ) -> AuthResult<Option<BeforeEndpointAction>> {
700        Ok(None)
701    }
702
703    /// # Errors
704    /// Propagates actual after-matcher failures.
705    fn matches_after(
706        &self,
707        _call: &EndpointCall,
708        _ctx: &crate::AuthContext<S>,
709        _response: &EndpointResponse,
710    ) -> AuthResult<bool> {
711        Ok(true)
712    }
713
714    /// # Errors
715    /// API errors replace the result and continue; ordinary failures abort remaining hooks.
716    async fn after(
717        &self,
718        _call: &EndpointCall,
719        _ctx: &crate::AuthContext<S>,
720        response: EndpointResponse,
721    ) -> AuthResult<EndpointResponse> {
722        Ok(response)
723    }
724}
725
726/// A successful call retains the actual value, since after hooks can replace its shape.
727#[derive(Debug)]
728pub struct EndpointOutput<T> {
729    value: JsValue,
730    headers: Headers,
731    status: Option<u16>,
732    output: PhantomData<fn() -> T>,
733}
734
735impl<T> EndpointOutput<T> {
736    #[must_use]
737    pub const fn value(&self) -> &JsValue {
738        &self.value
739    }
740
741    #[must_use]
742    pub const fn headers(&self) -> &Headers {
743        &self.headers
744    }
745
746    #[must_use]
747    pub const fn status(&self) -> Option<u16> {
748        self.status
749    }
750}
751
752impl<T: DeserializeOwned + 'static> EndpointOutput<T> {
753    /// # Errors
754    /// Returns an error if an application hook replaced the expected output type.
755    pub fn decode(&self) -> AuthResult<T> {
756        Ok(crate::utils::json::from_value(self.value.clone())?)
757    }
758}
759
760/// Actual application/API failure, with headers accumulated before an API error.
761#[derive(Debug)]
762pub struct EndpointError {
763    pub error: AuthError,
764    pub headers: Option<Headers>,
765    pub body: Option<Box<JsValue>>,
766}
767
768impl From<AuthError> for EndpointError {
769    fn from(error: AuthError) -> Self {
770        Self {
771            error,
772            headers: None,
773            body: None,
774        }
775    }
776}
777
778impl std::fmt::Display for EndpointError {
779    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
780        self.error.fmt(formatter)
781    }
782}
783
784impl std::error::Error for EndpointError {
785    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
786        Some(&self.error)
787    }
788}
789
790/// Intentional endpoint/API errors continue through after hooks; ordinary failures propagate.
791#[must_use]
792pub fn is_endpoint_api_error(error: &AuthError) -> bool {
793    matches!(error, AuthError::Api { .. } | AuthError::Upstream { .. }) || error.status_code() < 500
794}
795
796fn fresh_request(request: AuthRequest) -> AuthRequest {
797    let pairs = request
798        .query
799        .keys()
800        .flat_map(|name| {
801            request
802                .query_values(name)
803                .into_iter()
804                .flatten()
805                .map(|value| (name.clone(), value.clone()))
806        })
807        .collect::<Vec<_>>();
808    let url = request.url().cloned();
809    let mut fresh = AuthRequest::from_parts(
810        request.method,
811        request.path,
812        request.headers,
813        request.body,
814        request.query,
815    );
816    if let Some(url) = url {
817        fresh = fresh.with_url(url);
818    }
819    fresh.set_query_pairs(pairs);
820    fresh
821}
822
823tokio::task_local! { static ENDPOINT_CALL_CONTEXT: Option<EndpointCall>; }
824
825#[must_use]
826pub fn current_endpoint_call_context() -> Option<EndpointCall> {
827    ENDPOINT_CALL_CONTEXT.try_with(Clone::clone).ok().flatten()
828}
829
830pub async fn with_endpoint_call_context<T>(
831    call: EndpointCall,
832    future: impl Future<Output = T>,
833) -> T {
834    let request = call
835        .request()
836        .map(crate::hooks::RequestHookContext::from_request);
837    crate::hooks::with_optional_request_hook_context(
838        request,
839        ENDPOINT_CALL_CONTEXT.scope(Some(call), future),
840    )
841    .await
842}
843
844/// Isolate an independent HTTP dispatch from an enclosing logical endpoint call.
845pub async fn without_endpoint_call_context<T>(future: impl Future<Output = T>) -> T {
846    ENDPOINT_CALL_CONTEXT.scope(None, future).await
847}