Skip to main content

yeti_types/resource/
entry.rs

1//! `ResourceEntry` — the registration shape for a resource (ADR-015).
2//!
3//! A resource is a single `impl Resource` (the public name for
4//! [`ResourceMetadata`]): one trait carrying name, routing flags,
5//! permission predicates, the realtime `subscribe`/`connect` methods, and
6//! verb dispatch via [`ResourceMetadata::handle`]. There is no separate
7//! `tower::Service<Context>` impl — ADR-015 merged it in, so the router
8//! holds one `Arc<dyn Resource>` and calls `handle(ctx)` directly with no
9//! per-request service clone and no `&mut` borrow.
10//!
11//! `handle` takes an owned [`Context`] (not `http::Request`): the host
12//! threads it straight in, avoiding the `TypeId` lookup that
13//! `http::Request::extensions` imposes across compilation units.
14
15use std::sync::Arc;
16
17use super::metadata::ResourceMetadata;
18
19/// A registered resource — one `Arc<dyn Resource>`. The router consults it
20/// for the "before-dispatch" surface (auth, permission, realtime) and calls
21/// [`ResourceMetadata::handle`] for verb dispatch, all on the shared `Arc`.
22#[derive(Clone)]
23pub struct ResourceEntry {
24    resource: Arc<dyn ResourceMetadata>,
25}
26
27impl std::fmt::Debug for ResourceEntry {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("ResourceEntry")
30            .field("name", &self.resource.name())
31            .finish_non_exhaustive()
32    }
33}
34
35impl ResourceEntry {
36    /// Register a resource from any `impl Resource`. No `Clone`, no tower
37    /// bounds — just the trait.
38    #[must_use]
39    pub fn new<R>(resource: R) -> Self
40    where
41        R: ResourceMetadata + 'static,
42    {
43        Self {
44            resource: Arc::new(resource),
45        }
46    }
47
48    /// Register from an already-boxed resource (e.g. one wrapped by an
49    /// adapter such as `ExtendedTableResource`).
50    #[must_use]
51    pub fn from_arc(resource: Arc<dyn ResourceMetadata>) -> Self {
52        Self { resource }
53    }
54
55    /// Borrow the resource for the auth / permission / realtime pipeline.
56    #[must_use]
57    pub fn metadata(&self) -> &(dyn ResourceMetadata + 'static) {
58        &*self.resource
59    }
60
61    /// The shared resource handle — clone it (one `Arc` bump) to hold for a
62    /// request's dispatch, then call [`ResourceMetadata::handle`].
63    #[must_use]
64    pub fn resource(&self) -> Arc<dyn ResourceMetadata> {
65        Arc::clone(&self.resource)
66    }
67
68    /// Resource name — the routing key.
69    #[must_use]
70    pub fn name(&self) -> &str {
71        self.resource.name()
72    }
73}
74
75// ============================================================================
76// Tests — registration / dispatch round-trip through the merged trait.
77// ============================================================================
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::resource::context::Context;
83    use crate::resource::{ResourceFuture, ResponseBody};
84    use bytes::Bytes;
85    use http::{HeaderMap, Method, Response};
86
87    /// Trivial resource: 200 with the verb name for GET / POST, 405 otherwise
88    /// — exercised through the single `Resource` trait's `handle`.
89    struct EchoVerb;
90
91    impl ResourceMetadata for EchoVerb {
92        fn name(&self) -> &'static str {
93            "echo"
94        }
95
96        fn handle(&self, ctx: Context) -> ResourceFuture {
97            let method = ctx.method().clone();
98            Box::pin(async move {
99                #[allow(clippy::expect_used)]
100                let resp = match method {
101                    Method::GET => Response::builder()
102                        .status(200)
103                        .body(ResponseBody::complete(b"GET".to_vec()))
104                        .expect("static response"),
105                    Method::POST => Response::builder()
106                        .status(200)
107                        .body(ResponseBody::complete(b"POST".to_vec()))
108                        .expect("static response"),
109                    _ => Response::builder()
110                        .status(405)
111                        .body(ResponseBody::complete(b"Method Not Allowed".to_vec()))
112                        .expect("static response"),
113                };
114                Ok(resp)
115            })
116        }
117    }
118
119    fn ctx_for(method: Method) -> Context {
120        Context::from_request(method, "/echo".to_owned(), Bytes::new(), HeaderMap::new())
121    }
122
123    #[tokio::test]
124    async fn entry_dispatches_get_through_handle() {
125        let entry = ResourceEntry::new(EchoVerb);
126        assert_eq!(entry.name(), "echo");
127        let resp = entry
128            .resource()
129            .handle(ctx_for(Method::GET))
130            .await
131            .expect("call");
132        assert_eq!(resp.status(), 200);
133        assert_eq!(resp.body().as_bytes(), Some(&b"GET"[..]));
134    }
135
136    #[tokio::test]
137    async fn entry_metadata_resolves_name() {
138        let entry = ResourceEntry::new(EchoVerb);
139        assert_eq!(entry.metadata().name(), "echo");
140    }
141
142    #[tokio::test]
143    async fn entry_unknown_verb_returns_405() {
144        let entry = ResourceEntry::new(EchoVerb);
145        let resp = entry
146            .resource()
147            .handle(ctx_for(Method::DELETE))
148            .await
149            .expect("call");
150        assert_eq!(resp.status(), 405);
151    }
152
153    /// A metadata-only resource (no `handle` override) inherits the default
154    /// 405 — the permission-declaring table-extender shape.
155    #[tokio::test]
156    async fn metadata_only_resource_defaults_to_405() {
157        struct PermsOnly;
158        impl ResourceMetadata for PermsOnly {
159            fn name(&self) -> &'static str {
160                "perms"
161            }
162        }
163        let entry = ResourceEntry::new(PermsOnly);
164        let resp = entry
165            .resource()
166            .handle(ctx_for(Method::GET))
167            .await
168            .expect("call");
169        assert_eq!(resp.status(), 405);
170    }
171}