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::HashMap;
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    /// `route_key` → index of the first registered entry (O(1) lookups).
34    index: HashMap<String, usize>,
35}
36
37impl Default for SchemaRegistry {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl SchemaRegistry {
44    /// Creates an empty registry (non-strict by default).
45    pub fn new() -> Self {
46        Self {
47            entries: Vec::new(),
48            strict: false,
49            index: HashMap::new(),
50        }
51    }
52
53    /// When `true`, [`Self::ensure_route`] rejects unregistered paths at request time.
54    pub fn strict(mut self, strict: bool) -> Self {
55        self.strict = strict;
56        self
57    }
58
59    /// Returns whether strict route validation is enabled.
60    pub fn is_strict(&self) -> bool {
61        self.strict
62    }
63
64    /// Registers path, method, and optional request/response JSON schemas.
65    pub fn register_endpoint(
66        &mut self,
67        path: impl Into<String>,
68        method: Method,
69        request_schema: Option<RootSchema>,
70        response_schema: Option<RootSchema>,
71    ) {
72        self.push_entry(EndpointSchema {
73            path: path.into(),
74            method,
75            request_schema,
76            response_schema,
77            query_schema: None,
78            params_schema: None,
79        });
80    }
81
82    /// Registers a route with request, response, query, and params schemas.
83    pub fn register_full(
84        &mut self,
85        path: impl Into<String>,
86        method: Method,
87        request_schema: Option<RootSchema>,
88        response_schema: Option<RootSchema>,
89        query_schema: Option<RootSchema>,
90        params_schema: Option<RootSchema>,
91    ) {
92        self.push_entry(EndpointSchema {
93            path: path.into(),
94            method,
95            request_schema,
96            response_schema,
97            query_schema,
98            params_schema,
99        });
100    }
101
102    /// Appends an entry and indexes its route (keeping the first entry per route).
103    fn push_entry(&mut self, entry: EndpointSchema) {
104        let key = route_key(&entry.path, &entry.method);
105        if self.index.contains_key(&key) {
106            tracing::warn!(
107                path = %entry.path,
108                method = %entry.method,
109                "duplicate schema registration for route; lookups use the first matching entry"
110            );
111        }
112        let idx = self.entries.len();
113        self.index.entry(key).or_insert(idx);
114        self.entries.push(entry);
115    }
116
117    /// Registers schemas derived from [`Endpoint`](crate::Endpoint) and `JsonSchema` types.
118    pub fn register_typed<E, Req, Res>(&mut self)
119    where
120        E: crate::Endpoint,
121        Req: JsonSchema + 'static,
122        Res: JsonSchema + 'static,
123        E::Params: JsonSchema,
124        E::Query: JsonSchema,
125    {
126        self.register_full(
127            E::PATH,
128            E::METHOD,
129            Some(schemars::schema_for!(Req)),
130            Some(schemars::schema_for!(Res)),
131            Some(schemars::schema_for!(E::Query)),
132            Some(schemars::schema_for!(E::Params)),
133        );
134    }
135
136    /// Returns an error if strict mode is enabled and the route is not registered.
137    pub fn ensure_route(&self, path: &str, method: &Method) -> Result<()> {
138        if !self.strict {
139            return Ok(());
140        }
141        if self.index.contains_key(&route_key(path, method)) {
142            Ok(())
143        } else {
144            Err(Error::SchemaRoute {
145                method: method.to_string(),
146                path: path.to_string(),
147            })
148        }
149    }
150
151    /// Returns all registered endpoint metadata.
152    pub fn entries(&self) -> &[EndpointSchema] {
153        &self.entries
154    }
155
156    /// Returns the first registered entry for a route, if any.
157    fn find(&self, path: &str, method: &Method) -> Option<&EndpointSchema> {
158        self.index
159            .get(&route_key(path, method))
160            .map(|&i| &self.entries[i])
161    }
162
163    /// Returns the request body schema for a route, if registered.
164    pub fn request_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
165        self.find(path, method)
166            .and_then(|e| e.request_schema.as_ref())
167    }
168
169    /// Returns the response body schema for a route, if registered.
170    pub fn response_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
171        self.find(path, method)
172            .and_then(|e| e.response_schema.as_ref())
173    }
174
175    /// Returns the query string schema for a route, if registered.
176    pub fn query_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
177        self.find(path, method)
178            .and_then(|e| e.query_schema.as_ref())
179    }
180
181    /// Returns the path-parameter schema for a route, if registered.
182    pub fn params_schema(&self, path: &str, method: &Method) -> Option<&RootSchema> {
183        self.find(path, method)
184            .and_then(|e| e.params_schema.as_ref())
185    }
186}
187
188fn route_key(path: &str, method: &Method) -> String {
189    format!("{method}:{path}")
190}