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}