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}