Skip to main content

yeti_types/resource/
metadata.rs

1//! `ResourceMetadata` — name, routing flags, and permission predicates.
2//!
3//! this is the read-only view of a resource that
4//! the router needs **before** dispatching the request to the resource's
5//! `Service<Context>`. Splitting it off the `Resource` trait lets the
6//! router store
7//!
8//! ```ignore
9//! struct ResourceEntry {
10//!     metadata: Arc<dyn ResourceMetadata>,
11//!     service:  BoxCloneSyncService<Context, Response<ResponseBody>, YetiError>,
12//! }
13//! ```
14//!
15//! and consult `metadata` for permissions, table-extender wiring, and
16//! routing flags without going through any verb-method virtual call.
17//!
18//! Resources are *internal* services — the dispatch path threads the
19//! owned `Context` directly into `Service::call`, avoiding the
20//! `TypeId` mismatch that `http::Request::extensions` would impose.
21
22use std::sync::Arc;
23
24use http::Response;
25
26use super::context::Context;
27use super::permission::MethodOverrides;
28use super::response_body::ResponseBody;
29use super::{ConnectionFuture, ResourceFuture, SubscriptionFuture};
30use crate::error::YetiError;
31
32/// The non-verb-dispatch surface of a resource. Read-only metadata
33/// (name, routing flags, schema-backed attribute list) plus per-verb
34/// permission predicates.
35///
36/// All methods are sync, take `&self`, and read from a borrowed
37/// `Context` where they need request state. They never touch the
38/// request body or perform I/O.
39pub trait ResourceMetadata: Send + Sync + 'static {
40    /// Resource name (used in routing).
41    fn name(&self) -> &str;
42
43    /// Whether this resource is a default/catch-all handler.
44    fn is_default(&self) -> bool {
45        false
46    }
47
48    /// Return the attribute/field names for this resource (if schema-backed).
49    fn attribute_names(&self) -> Option<Arc<Vec<String>>> {
50        None
51    }
52
53    /// If this resource extends a table, return the table name.
54    fn extends_table(&self) -> Option<&str> {
55        None
56    }
57
58    /// Declare which methods this resource overrides (for table extenders).
59    ///
60    /// Default: no overrides. Override this in table extender resources to
61    /// specify which methods delegate to the custom resource vs. the table.
62    fn method_overrides(&self) -> MethodOverrides {
63        MethodOverrides::default()
64    }
65
66    /// Whether this resource handles the given HTTP method.
67    ///
68    /// Default: accept all methods (legacy behavior — native Rust resources
69    /// match on `ctx.method()` inside their own `Service::call` and return 405
70    /// themselves for unsupported verbs). WASM resources override this to
71    /// fast-reject routes that don't match their declared `methods` catalog,
72    /// avoiding the dispatch round-trip when the verb isn't supported.
73    fn supports_method(&self, _method: &http::Method) -> bool {
74        true
75    }
76
77    // ========================================================================
78    // Authorization — reads from Context
79    // ========================================================================
80
81    /// Check if this request is allowed to read.
82    fn allow_read(&self, ctx: &Context) -> bool {
83        if !ctx.access().is_authenticated() {
84            return false;
85        }
86        if ctx.access().is_super_user() {
87            return true;
88        }
89        ctx.access()
90            .can_read_table(ctx.database_str(), ctx.table_name_str())
91    }
92
93    /// Check if this request is allowed to create.
94    fn allow_create(&self, ctx: &Context) -> bool {
95        if !ctx.access().is_authenticated() {
96            return false;
97        }
98        if ctx.access().is_super_user() {
99            return true;
100        }
101        let (db, tbl) = ctx.table_context();
102
103        if ctx.is_collection() {
104            if !ctx.access().can_insert_table(db, tbl) {
105                return false;
106            }
107            // Check field-level write permissions
108            if let Some(obj) = ctx.json_body().and_then(|v| v.as_object()) {
109                for key in obj.keys() {
110                    if !ctx.access().can_write_attribute(db, tbl, key) {
111                        return false;
112                    }
113                }
114            }
115            true
116        } else {
117            self.allow_update(ctx)
118        }
119    }
120
121    /// Check if this request is allowed to update.
122    fn allow_update(&self, ctx: &Context) -> bool {
123        if !ctx.access().is_authenticated() {
124            return false;
125        }
126        if ctx.access().is_super_user() {
127            return true;
128        }
129        let (db, tbl) = ctx.table_context();
130
131        if !ctx.access().can_update_table(db, tbl) {
132            return false;
133        }
134        if let Some(obj) = ctx.json_body().and_then(|v| v.as_object()) {
135            for key in obj.keys() {
136                if !ctx.access().can_write_attribute(db, tbl, key) {
137                    return false;
138                }
139            }
140        }
141        true
142    }
143
144    /// Check if this request is allowed to delete.
145    fn allow_delete(&self, ctx: &Context) -> bool {
146        if !ctx.access().is_authenticated() {
147            return false;
148        }
149        if ctx.access().is_super_user() {
150            return true;
151        }
152        ctx.access()
153            .can_delete_table(ctx.database_str(), ctx.table_name_str())
154    }
155
156    /// Check if this request is allowed to subscribe.
157    fn allow_subscribe(&self, ctx: &Context) -> bool {
158        self.allow_read(ctx)
159    }
160
161    /// Check if this request is allowed to connect (WebSocket/SSE).
162    fn allow_connect(&self, ctx: &Context) -> bool {
163        self.allow_read(ctx)
164    }
165
166    /// Whether this resource allows unauthenticated WebSocket/SSE connections.
167    ///
168    /// This is the no-Context equivalent of `allow_connect` for the public-access
169    /// case. The router calls this before constructing a per-request Context so it
170    /// can answer "is this a public endpoint?" without a dummy empty-auth Context.
171    ///
172    /// Default: `false`. Schema-backed resources (`TableResource`) override this
173    /// to reflect the `@export(public: [connect])` / `@export(public: [read])`
174    /// directive. Custom resources override it when they intend to allow anonymous
175    /// WebSocket connections.
176    fn is_public_connect(&self) -> bool {
177        false
178    }
179
180    /// Dispatch an HTTP verb request — the single verb-dispatch entry point.
181    ///
182    /// ADR-015 merged the former `tower::Service<Context>` impl into this
183    /// trait: permissions, realtime, and verb dispatch now live on one
184    /// `Resource`. `&self` (not tower's `&mut self`), so the router holds one
185    /// `Arc<dyn Resource>` and never clones a boxed service per request.
186    ///
187    /// Default returns 405 — a metadata-only resource (e.g. a permission-
188    /// declaring table extender) inherits it unchanged; resources with verb
189    /// bodies (`TableResource`, `WasmResource`, macro output) override it.
190    fn handle(&self, _ctx: Context) -> ResourceFuture {
191        Box::pin(async {
192            #[allow(clippy::expect_used)]
193            let resp = Response::builder()
194                .status(405)
195                .body(ResponseBody::complete(b"Method Not Allowed".to_vec()))
196                .expect("static 405 cannot fail to build");
197            Ok(resp)
198        })
199    }
200
201    // ========================================================================
202    // Real-time methods — subscribe (SSE) and connect (WebSocket).
203    //
204    // These take a `Context` rather than a `YetiRequest` because their
205    // outputs (`Subscription`, `Connection`) aren't `http::Response`-shaped
206    // — they're per-stream / per-socket handles the router holds onto for
207    // the lifetime of the connection. Sitting them on `ResourceMetadata`
208    // (rather than a separate `Realtime` trait) keeps every resource
209    // exposing one interface; defaults return `YetiError::Internal` so
210    // resources that don't override appear as "subscribe/connect not
211    // supported" rather than silently 200ing.
212    // ========================================================================
213
214    /// Subscribe to real-time updates (SSE handler entrypoint).
215    fn subscribe(&self, _ctx: Context) -> SubscriptionFuture {
216        Box::pin(async {
217            Err(YetiError::Internal(
218                "subscribe not implemented for this resource".to_owned(),
219            ))
220        })
221    }
222
223    /// Handle WebSocket or SSE connection.
224    fn connect(&self, _ctx: Context) -> ConnectionFuture {
225        Box::pin(async {
226            Err(YetiError::Internal(
227                "connect not implemented for this resource".to_owned(),
228            ))
229        })
230    }
231}