Skip to main content

nest_rs_http/
controller.rs

1use std::sync::Arc;
2
3use nest_rs_core::Container;
4use poem::Route;
5
6/// Implemented automatically by the `#[routes]` macro. Each controller
7/// mounts its routes (prefixed with the controller's `PATH`) onto a parent
8/// [`Route`].
9pub trait Controller: 'static {
10    fn mount(container: &Container, route: Route) -> Route;
11}
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum HttpVerb {
15    Get,
16    Post,
17    Put,
18    Delete,
19    Patch,
20}
21
22impl HttpVerb {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::Get => "GET",
26            Self::Post => "POST",
27            Self::Put => "PUT",
28            Self::Delete => "DELETE",
29            Self::Patch => "PATCH",
30        }
31    }
32}
33
34/// Builds the schema for a `Json<T>` request body or response, recording
35/// named component schemas in the shared generator. `#[routes]` emits one per
36/// JSON payload it finds; a non-`Json<…>` body/return carries `None` and
37/// imposes no `JsonSchema` bound.
38pub type SchemaFn = fn(&mut schemars::SchemaGenerator) -> schemars::Schema;
39
40/// Kept here so `#[routes]` emits `::nest_rs_http::schema_of::<T>` and never
41/// names `schemars`' generator API itself.
42pub fn schema_of<T: schemars::JsonSchema>(
43    generator: &mut schemars::SchemaGenerator,
44) -> schemars::Schema {
45    generator.subschema_for::<T>()
46}
47
48/// Declarative description of a handler in a controller — verb/path/name plus
49/// the OpenAPI facets `#[routes]` extracts, so a doc generator (nestrs-openapi)
50/// builds a spec from discovery alone.
51#[derive(Clone)]
52pub struct HttpRouteMeta {
53    pub verb: HttpVerb,
54    pub path: &'static str,
55    pub handler: &'static str,
56    pub summary: Option<&'static str>,
57    pub description: Option<&'static str>,
58    /// `#[api(tags(...))]`, else a single-element slice holding the controller
59    /// struct name — so routes group by controller in the docs by default.
60    pub tags: &'static [&'static str],
61    pub request_body: Option<SchemaFn>,
62    pub response: Option<SchemaFn>,
63}
64
65type MountFn = dyn Fn(&Container, Route) -> Route + Send + Sync;
66
67/// Discovery metadata attached to every `#[controller]` + `#[routes]` type.
68/// [`crate::HttpTransport`] iterates these at boot via
69/// [`nest_rs_core::DiscoveryService::meta`]; apps can read the same metadata
70/// to drive secondary concerns (OpenAPI rendering, route listings).
71pub struct HttpControllerMeta {
72    pub path: &'static str,
73    pub version: Option<&'static str>,
74    pub routes: Vec<HttpRouteMeta>,
75    mount: Arc<MountFn>,
76}
77
78impl HttpControllerMeta {
79    pub fn new<F>(
80        path: &'static str,
81        version: Option<&'static str>,
82        routes: Vec<HttpRouteMeta>,
83        mount: F,
84    ) -> Self
85    where
86        F: Fn(&Container, Route) -> Route + Send + Sync + 'static,
87    {
88        Self {
89            path,
90            version,
91            routes,
92            mount: Arc::new(mount),
93        }
94    }
95
96    /// Mount prefix with URI versioning applied (`/v1/users` when versioned).
97    /// Readers composing full route paths (boot log, OpenAPI doc) join each
98    /// route onto this so they match what [`mount`](Self::mount) serves.
99    pub fn effective_prefix(&self) -> String {
100        crate::version_path(self.version, self.path)
101    }
102
103    pub fn mount(&self, container: &Container, route: Route) -> Route {
104        (self.mount)(container, route)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::sync::atomic::{AtomicUsize, Ordering};
112
113    #[test]
114    fn http_verb_as_str_renders_each_method_name() {
115        assert_eq!(HttpVerb::Get.as_str(), "GET");
116        assert_eq!(HttpVerb::Post.as_str(), "POST");
117        assert_eq!(HttpVerb::Put.as_str(), "PUT");
118        assert_eq!(HttpVerb::Delete.as_str(), "DELETE");
119        assert_eq!(HttpVerb::Patch.as_str(), "PATCH");
120    }
121
122    #[test]
123    fn http_verb_is_value_type_for_equality_and_clone() {
124        // The derives are part of the public surface (`#[routes]` clones the
125        // verb into discovery metadata); pin them.
126        let a = HttpVerb::Get;
127        let b = a;
128        assert_eq!(a, b);
129        assert_eq!(format!("{a:?}"), "Get");
130    }
131
132    #[test]
133    fn schema_of_records_a_subschema_for_the_payload_type() {
134        let mut generator = schemars::SchemaGenerator::default();
135        let schema = schema_of::<String>(&mut generator);
136        // The subschema is a JSON-schema object whose serialization round-trips.
137        let value: serde_json::Value = serde_json::to_value(&schema).expect("schema serializes");
138        assert!(value.is_object(), "schema serializes to a JSON object");
139    }
140
141    #[test]
142    fn effective_prefix_returns_path_unchanged_without_a_version() {
143        let meta = HttpControllerMeta::new("/users", None, Vec::new(), |_c, r| r);
144        assert_eq!(meta.effective_prefix(), "/users");
145    }
146
147    #[test]
148    fn effective_prefix_prepends_the_uri_version_when_present() {
149        // `version_path` joins `/v<v>` ahead of the controller path — the
150        // single place URI versioning lives, so this is the contract.
151        let meta = HttpControllerMeta::new("/users", Some("1"), Vec::new(), |_c, r| r);
152        assert_eq!(meta.effective_prefix(), "/v1/users");
153    }
154
155    #[test]
156    fn new_stores_the_path_version_and_routes_verbatim() {
157        let routes = vec![HttpRouteMeta {
158            verb: HttpVerb::Get,
159            path: "/:id",
160            handler: "show",
161            summary: Some("Fetch one"),
162            description: None,
163            tags: &["Users"],
164            request_body: None,
165            response: None,
166        }];
167        let meta = HttpControllerMeta::new("/users", Some("2"), routes, |_c, r| r);
168        assert_eq!(meta.path, "/users");
169        assert_eq!(meta.version, Some("2"));
170        assert_eq!(meta.routes.len(), 1);
171        assert_eq!(meta.routes[0].handler, "show");
172        assert_eq!(meta.routes[0].tags, &["Users"]);
173    }
174
175    #[test]
176    fn mount_invokes_the_closure_with_the_container_and_route() {
177        // The mount closure is the seam `#[routes]` emits; assert it's called
178        // exactly once per `mount` invocation and receives the same container.
179        static CALLS: AtomicUsize = AtomicUsize::new(0);
180        let meta = HttpControllerMeta::new("/health", None, Vec::new(), |_c, r| {
181            CALLS.fetch_add(1, Ordering::SeqCst);
182            r
183        });
184        let container = Container::builder().build();
185        let route = Route::new();
186
187        let _routed = meta.mount(&container, route);
188        assert_eq!(CALLS.load(Ordering::SeqCst), 1);
189        let _ = meta.mount(&container, Route::new());
190        assert_eq!(CALLS.load(Ordering::SeqCst), 2);
191    }
192}