Skip to main content

better_fetch/
schema.rs

1//! Schema registry for endpoint metadata (requires `schema` feature).
2
3use http::Method;
4use schemars::schema::RootSchema;
5use schemars::JsonSchema;
6use std::collections::HashSet;
7
8use crate::error::Error;
9use crate::Result;
10
11/// Metadata describing a single endpoint for documentation and codegen.
12#[derive(Debug, Clone)]
13pub struct EndpointSchema {
14    /// Route path template.
15    pub path: String,
16    /// HTTP method.
17    pub method: Method,
18    /// JSON request body schema.
19    pub request_schema: Option<RootSchema>,
20    /// JSON response schema.
21    pub response_schema: Option<RootSchema>,
22    /// Query string schema.
23    pub query_schema: Option<RootSchema>,
24    /// Path parameter schema.
25    pub params_schema: Option<RootSchema>,
26}
27
28/// Registry of endpoint schemas.
29#[derive(Debug, Clone)]
30pub struct SchemaRegistry {
31    entries: Vec<EndpointSchema>,
32    strict: bool,
33    routes: HashSet<String>,
34}
35
36impl Default for SchemaRegistry {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl SchemaRegistry {
43    /// Creates an empty registry (non-strict by default).
44    pub fn new() -> Self {
45        Self {
46            entries: Vec::new(),
47            strict: false,
48            routes: HashSet::new(),
49        }
50    }
51
52    /// When `true`, [`Self::ensure_route`] rejects unregistered paths at request time.
53    pub fn strict(mut self, strict: bool) -> Self {
54        self.strict = strict;
55        self
56    }
57
58    /// Returns whether strict route validation is enabled.
59    pub fn is_strict(&self) -> bool {
60        self.strict
61    }
62
63    /// Registers path, method, and optional request/response JSON schemas.
64    pub fn register_endpoint(
65        &mut self,
66        path: impl Into<String>,
67        method: Method,
68        request_schema: Option<RootSchema>,
69        response_schema: Option<RootSchema>,
70    ) {
71        let path = path.into();
72        warn_duplicate_route(&mut self.routes, &path, &method);
73        self.entries.push(EndpointSchema {
74            path,
75            method,
76            request_schema,
77            response_schema,
78            query_schema: None,
79            params_schema: None,
80        });
81    }
82
83    /// Registers a route with request, response, query, and params schemas.
84    pub fn register_full(
85        &mut self,
86        path: impl Into<String>,
87        method: Method,
88        request_schema: Option<RootSchema>,
89        response_schema: Option<RootSchema>,
90        query_schema: Option<RootSchema>,
91        params_schema: Option<RootSchema>,
92    ) {
93        let path = path.into();
94        warn_duplicate_route(&mut self.routes, &path, &method);
95        self.entries.push(EndpointSchema {
96            path,
97            method,
98            request_schema,
99            response_schema,
100            query_schema,
101            params_schema,
102        });
103    }
104
105    /// Registers schemas derived from [`Endpoint`](crate::Endpoint) and `JsonSchema` types.
106    pub fn register_typed<E, Req, Res>(&mut self)
107    where
108        E: crate::Endpoint,
109        Req: JsonSchema + 'static,
110        Res: JsonSchema + 'static,
111        E::Params: JsonSchema,
112        E::Query: JsonSchema,
113    {
114        self.register_full(
115            E::PATH,
116            E::METHOD,
117            Some(schemars::schema_for!(Req)),
118            Some(schemars::schema_for!(Res)),
119            Some(schemars::schema_for!(E::Query)),
120            Some(schemars::schema_for!(E::Params)),
121        );
122    }
123
124    /// Returns an error if strict mode is enabled and the route is not registered.
125    pub fn ensure_route(&self, path: &str, method: &Method) -> Result<()> {
126        if !self.strict {
127            return Ok(());
128        }
129        let key = route_key(path, method);
130        if self.routes.contains(&key) {
131            Ok(())
132        } else {
133            Err(Error::SchemaRoute {
134                method: method.to_string(),
135                path: path.to_string(),
136            })
137        }
138    }
139
140    /// Returns all registered endpoint metadata.
141    pub fn entries(&self) -> &[EndpointSchema] {
142        &self.entries
143    }
144
145    /// Returns the request body schema for a route, if registered.
146    pub fn request_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
147        let key = route_key(path, method);
148        self.entries
149            .iter()
150            .find(|e| route_key(&e.path, &e.method) == key)
151            .and_then(|e| e.request_schema.as_ref())
152    }
153
154    /// Returns the response body schema for a route, if registered.
155    pub fn response_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
156        let key = route_key(path, method);
157        self.entries
158            .iter()
159            .find(|e| route_key(&e.path, &e.method) == key)
160            .and_then(|e| e.response_schema.as_ref())
161    }
162
163    /// Returns the query string schema for a route, if registered.
164    pub fn query_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
165        let key = route_key(path, method);
166        self.entries
167            .iter()
168            .find(|e| route_key(&e.path, &e.method) == key)
169            .and_then(|e| e.query_schema.as_ref())
170    }
171
172    /// Returns the path-parameter schema for a route, if registered.
173    pub fn params_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
174        let key = route_key(path, method);
175        self.entries
176            .iter()
177            .find(|e| route_key(&e.path, &e.method) == key)
178            .and_then(|e| e.params_schema.as_ref())
179    }
180}
181
182fn route_key(path: &str, method: &Method) -> String {
183    format!("{method}:{path}")
184}
185
186fn warn_duplicate_route(routes: &mut HashSet<String>, path: &str, method: &Method) {
187    let key = route_key(path, method);
188    if !routes.insert(key) {
189        tracing::warn!(
190            path,
191            method = %method,
192            "duplicate schema registration for route; lookups use the first matching entry"
193        );
194    }
195}