Skip to main content

lucy_types/
endpoint.rs

1//! Endpoint metadata types.
2//!
3//! This module defines the core data structures used to describe an
4//! endpoint registered with the Lucy documentation framework, regardless
5//! of the underlying transport protocol.
6
7use serde::{Deserialize, Serialize};
8
9/// Transport protocol used by an endpoint.
10///
11/// The variants are serialized using their Rust identifier as a JSON
12/// string (e.g. [`Protocol::Http`] becomes `"Http"`) so that the
13/// on-the-wire representation stays stable and human-readable.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub enum Protocol {
16    /// Classic synchronous HTTP/1.1 or HTTP/2 request-response endpoint.
17    Http,
18    /// Bidirectional WebSocket stream, typically used for real-time updates.
19    WebSocket,
20    /// MQTT topic-based publish/subscribe channel for IoT-style workloads.
21    Mqtt,
22}
23
24/// Fully-qualified description of an endpoint exposed by the application.
25///
26/// An [`EndpointMeta`] carries everything required to generate documentation
27/// for a single endpoint: its human-readable name, network location, protocol
28/// and optional request/response JSON schemas.
29///
30/// Schemas are stored as raw [`serde_json::Value`] instances to avoid a hard
31/// dependency on a specific schema-generation crate in the public API.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct EndpointMeta {
34    /// Display name of the endpoint, used as a title in generated docs.
35    pub name: String,
36    /// URL path (for HTTP/WebSocket) or topic string (for MQTT).
37    pub path: String,
38    /// Transport protocol used by this endpoint.
39    pub protocol: Protocol,
40    /// Optional long-form, human-readable description.
41    // Skip serializing None so the JSON output omits the key entirely.
42    // Without this, serde emits `"description": null` which TypeScript's
43    // optional field syntax (`description?: string`) does not handle
44    // correctly — null passes an `!== undefined` check and renders as "null".
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub description: Option<String>,
47    /// HTTP verb (`GET`, `POST`, ...). `None` for WebSocket and MQTT endpoints.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub method: Option<String>,
50    /// JSON Schema describing the expected request payload, when applicable.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub request_schema: Option<serde_json::Value>,
53    /// JSON Schema describing the response payload, when applicable.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub response_schema: Option<serde_json::Value>,
56    /// Tags used to group the endpoint in the documentation UI.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub tags: Vec<String>,
59}
60
61/// Static-lifetime version of [`EndpointMeta`] used for compile-time
62/// registration via the `inventory` crate.
63///
64/// Proc-macro generated code emits `inventory::submit!` blocks containing
65/// this type (all fields are `&'static str`, which is const-constructible).
66/// At runtime, [`EndpointMetaStatic::into_endpoint_meta`] converts each
67/// entry into a heap-allocated [`EndpointMeta`].
68pub struct EndpointMetaStatic {
69    /// Display name of the endpoint.
70    pub name: &'static str,
71    /// URL path or MQTT topic string.
72    pub path: &'static str,
73    /// Transport protocol.
74    pub protocol: Protocol,
75    /// Optional human-readable description.
76    pub description: Option<&'static str>,
77    /// HTTP verb, if applicable.
78    pub method: Option<&'static str>,
79    /// Tags for grouping, as a static string slice.
80    pub tags: &'static [&'static str],
81    /// Called once at startup to generate the request JSON Schema.
82    /// `None` for endpoints with no request body.
83    pub request_schema_fn: Option<fn() -> serde_json::Value>,
84    /// Called once at startup to generate the response JSON Schema.
85    /// `None` for endpoints with no response schema.
86    pub response_schema_fn: Option<fn() -> serde_json::Value>,
87}
88
89impl EndpointMetaStatic {
90    /// Converts the static reference into an owned [`EndpointMeta`].
91    pub fn into_endpoint_meta(&self) -> EndpointMeta {
92        EndpointMeta {
93            name: self.name.to_owned(),
94            path: self.path.to_owned(),
95            protocol: self.protocol.clone(),
96            description: self.description.map(|s| s.to_owned()),
97            method: self.method.map(|s| s.to_owned()),
98            request_schema: self.request_schema_fn.map(|f| f()),
99            response_schema: self.response_schema_fn.map(|f| f()),
100            tags: self.tags.iter().map(|s| s.to_string()).collect(),
101        }
102    }
103}
104
105// Declare EndpointMetaStatic as an inventory-collectable type.
106// Must appear exactly once across the entire binary.
107// Proc-macro generated code calls `::inventory::submit! { EndpointMetaStatic { ... } }`
108// and lucy-core drains `::inventory::iter::<EndpointMetaStatic>()` on first registry access.
109inventory::collect!(EndpointMetaStatic);
110
111impl EndpointMeta {
112    /// Create a new [`EndpointMeta`] with only the mandatory fields populated.
113    ///
114    /// Optional fields (`description`, `method`, `request_schema`,
115    /// `response_schema`) are initialised to `None` and can be filled in
116    /// afterwards by mutating the returned value.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use lucy_types::endpoint::{EndpointMeta, Protocol};
122    ///
123    /// let meta = EndpointMeta::new("health", "/health", Protocol::Http);
124    /// assert_eq!(meta.name, "health");
125    /// assert_eq!(meta.path, "/health");
126    /// assert_eq!(meta.protocol, Protocol::Http);
127    /// ```
128    pub fn new(name: impl Into<String>, path: impl Into<String>, protocol: Protocol) -> Self {
129        Self {
130            name: name.into(),
131            path: path.into(),
132            protocol,
133            description: None,
134            method: None,
135            request_schema: None,
136            response_schema: None,
137            tags: Vec::new(),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // Named constants avoid duplicating magic strings across tests and keep
147    // the expected serde representation documented in a single place.
148    const EXPECTED_HTTP_JSON: &str = "\"Http\"";
149    const EXPECTED_WEBSOCKET_JSON: &str = "\"WebSocket\"";
150    const EXPECTED_MQTT_JSON: &str = "\"Mqtt\"";
151
152    const HEALTH_NAME: &str = "health";
153    const HEALTH_PATH: &str = "/health";
154
155    #[test]
156    fn protocol_http_serializes_to_http_string() {
157        let json = serde_json::to_string(&Protocol::Http)
158            .expect("serializing Protocol::Http should never fail");
159        assert_eq!(json, EXPECTED_HTTP_JSON);
160    }
161
162    #[test]
163    fn protocol_websocket_serializes_to_websocket_string() {
164        let json = serde_json::to_string(&Protocol::WebSocket)
165            .expect("serializing Protocol::WebSocket should never fail");
166        assert_eq!(json, EXPECTED_WEBSOCKET_JSON);
167    }
168
169    #[test]
170    fn protocol_mqtt_serializes_to_mqtt_string() {
171        let json = serde_json::to_string(&Protocol::Mqtt)
172            .expect("serializing Protocol::Mqtt should never fail");
173        assert_eq!(json, EXPECTED_MQTT_JSON);
174    }
175
176    #[test]
177    fn endpoint_meta_round_trips_through_serde_json() {
178        let original = EndpointMeta::new(HEALTH_NAME, HEALTH_PATH, Protocol::Http);
179
180        // Serialize then deserialize: both directions must succeed and the
181        // resulting value must be structurally identical to the input.
182        let serialized =
183            serde_json::to_string(&original).expect("serialization of EndpointMeta must succeed");
184        let deserialized: EndpointMeta = serde_json::from_str(&serialized)
185            .expect("deserialization of EndpointMeta must succeed");
186
187        assert_eq!(deserialized.name, HEALTH_NAME);
188        assert_eq!(deserialized.path, HEALTH_PATH);
189        assert_eq!(deserialized.protocol, Protocol::Http);
190        assert!(deserialized.description.is_none());
191        assert!(deserialized.method.is_none());
192        assert!(deserialized.request_schema.is_none());
193        assert!(deserialized.response_schema.is_none());
194        assert!(deserialized.tags.is_empty());
195    }
196}