Skip to main content

endpoint_libs/model/
endpoint.rs

1use crate::model::{Field, MetaMap, Type};
2use convert_case::{Case, Casing};
3use eyre::{ContextCompat, Result};
4use serde::de::{Error, Unexpected};
5use serde::ser::SerializeStruct;
6use serde::*;
7use std::fmt::Write;
8
9/// `EndpointSchema` is a struct that represents a single endpoint in the API.
10#[derive(Debug, Serialize, Deserialize, Default, Clone)]
11#[non_exhaustive]
12pub struct EndpointSchema {
13    /// The name of the endpoint (e.g. `UserListSymbols`)
14    pub name: String,
15
16    /// The method code of the endpoint (e.g. `10020`)
17    pub code: u32,
18
19    /// A list of parameters that the endpoint accepts (e.g. "symbol" of type `String`)
20    pub parameters: Vec<Field>,
21
22    /// A list of fields that the endpoint returns
23    pub returns: Vec<Field>,
24
25    /// The type of the stream response (if any)
26    #[serde(default)]
27    pub stream_response: Option<Type>,
28
29    /// A description of the endpoint added by `with_description` method
30    #[serde(default)]
31    pub description: String,
32
33    /// The JSON schema of the endpoint (`Default::default()`)
34    #[serde(default)]
35    pub json_schema: serde_json::Value,
36
37    // Allowed roles for this endpoint ["EnumRole::EnumVariant"]
38    pub roles: Vec<String>,
39
40    /// Public error variants that handlers may return for this endpoint.
41    #[serde(default)]
42    pub errors: Vec<EndpointErrorSchema>,
43
44    /// Emitter annotations — see [`MetaMap`](crate::model::MetaMap). Empty in 2.0;
45    /// consumed by the OpenAPI/AsyncAPI emitters in 2.1.
46    #[serde(default, skip_serializing_if = "MetaMap::is_empty")]
47    pub meta: MetaMap,
48}
49
50impl EndpointSchema {
51    /// Creates a new `EndpointSchema` with the given name, method code, parameters and returns.
52    pub fn new(
53        name: impl Into<String>,
54        code: u32,
55        parameters: Vec<Field>,
56        returns: Vec<Field>,
57    ) -> Self {
58        Self {
59            name: name.into(),
60            code,
61            parameters,
62            returns,
63            stream_response: None,
64            description: "".to_string(),
65            json_schema: Default::default(),
66            roles: Vec::new(),
67            errors: Vec::new(),
68            meta: MetaMap::default(),
69        }
70    }
71
72    /// Attach emitter annotations. See [`MetaMap`](crate::model::MetaMap).
73    #[must_use]
74    pub fn with_meta(mut self, meta: MetaMap) -> Self {
75        self.meta = meta;
76        self
77    }
78
79    /// Adds a stream response type field to the endpoint.
80    pub fn with_stream_response_type(mut self, stream_response: Type) -> Self {
81        self.stream_response = Some(stream_response);
82        self
83    }
84
85    /// Adds a description field to the endpoint.
86    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
87        self.description = desc.into();
88        self
89    }
90
91    /// Adds allowed roles to the endpoint.
92    pub fn with_roles(mut self, roles: Vec<String>) -> Self {
93        self.roles = roles;
94        self
95    }
96
97    /// Adds public error variants to the endpoint.
98    pub fn with_errors(mut self, errors: Vec<EndpointErrorSchema>) -> Self {
99        self.errors = errors;
100        self
101    }
102}
103
104#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, PartialOrd, Eq, Ord)]
105#[non_exhaustive]
106pub struct EndpointErrorSchema {
107    pub name: String,
108    pub code: EndpointErrorCodeRef,
109    #[serde(default)]
110    pub message: String,
111    #[serde(default)]
112    pub fields: Vec<Field>,
113}
114
115impl EndpointErrorSchema {
116    /// Creates an error schema with no message and no fields.
117    ///
118    /// This type is `#[non_exhaustive]`, so out-of-crate callers must build it here
119    /// rather than with a struct literal.
120    pub fn new(name: impl Into<String>, code: EndpointErrorCodeRef) -> Self {
121        Self {
122            name: name.into(),
123            code,
124            message: String::new(),
125            fields: Vec::new(),
126        }
127    }
128
129    /// Sets the human-readable message.
130    #[must_use]
131    pub fn with_message(mut self, message: impl Into<String>) -> Self {
132        self.message = message.into();
133        self
134    }
135
136    /// Sets the structured fields carried by this error.
137    #[must_use]
138    pub fn with_fields(mut self, fields: Vec<Field>) -> Self {
139        self.fields = fields;
140        self
141    }
142}
143
144#[derive(Clone, Debug, Hash, PartialEq, PartialOrd, Eq, Ord)]
145pub struct EndpointErrorCodeRef {
146    pub ty: Type,
147    pub variant: String,
148}
149
150impl EndpointErrorCodeRef {
151    pub const ENUM_NAME: &'static str = "ErrorCode";
152
153    pub fn new(variant: impl Into<String>) -> Self {
154        Self {
155            ty: Type::enum_ref(Self::ENUM_NAME, true),
156            variant: variant.into(),
157        }
158    }
159
160    pub fn variant(&self) -> &str {
161        &self.variant
162    }
163
164    pub fn path(&self) -> String {
165        format!("{}::{}", Self::ENUM_NAME, self.variant)
166    }
167
168    fn validate_ty(ty: Type) -> std::result::Result<Type, String> {
169        match &ty {
170            Type::EnumRef { name, .. } if name == Self::ENUM_NAME => Ok(ty),
171            Type::EnumRef { name, .. } => {
172                Err(format!("expected {} enum ref, got {name}", Self::ENUM_NAME))
173            }
174            _ => Err(format!("expected {} enum ref", Self::ENUM_NAME)),
175        }
176    }
177
178    fn parse_path(path: &str) -> std::result::Result<Self, String> {
179        let (enum_name, variant) = path
180            .split_once("::")
181            .ok_or_else(|| format!("expected {}::Variant", Self::ENUM_NAME))?;
182
183        if enum_name != Self::ENUM_NAME {
184            return Err(format!(
185                "expected {} enum path, got {enum_name}",
186                Self::ENUM_NAME
187            ));
188        }
189
190        if variant.is_empty() || variant.contains("::") {
191            return Err(format!("expected {}::Variant", Self::ENUM_NAME));
192        }
193
194        Ok(Self::new(variant))
195    }
196}
197
198impl std::fmt::Display for EndpointErrorCodeRef {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.write_str(&self.path())
201    }
202}
203
204impl Serialize for EndpointErrorCodeRef {
205    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
206    where
207        S: Serializer,
208    {
209        let mut state = serializer.serialize_struct("EndpointErrorCodeRef", 2)?;
210        state.serialize_field("ty", &self.ty)?;
211        state.serialize_field("variant", &self.variant)?;
212        state.end()
213    }
214}
215
216impl<'de> Deserialize<'de> for EndpointErrorCodeRef {
217    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
218    where
219        D: Deserializer<'de>,
220    {
221        #[derive(Deserialize)]
222        #[serde(untagged)]
223        enum Helper {
224            Path(String),
225            Structured { ty: Type, variant: String },
226        }
227
228        match Helper::deserialize(deserializer)? {
229            Helper::Path(path) => Self::parse_path(&path)
230                .map_err(|err| D::Error::invalid_value(Unexpected::Str(&path), &err.as_str())),
231            Helper::Structured { ty, variant } => {
232                let ty = Self::validate_ty(ty).map_err(D::Error::custom)?;
233                Ok(Self { ty, variant })
234            }
235        }
236    }
237}
238
239pub fn encode_header<T: Serialize>(v: T, schema: EndpointSchema) -> Result<String> {
240    let mut s = String::new();
241    write!(s, "0{}", schema.name.to_ascii_lowercase())?;
242    let v = serde_json::to_value(&v)?;
243
244    for (i, f) in schema.parameters.iter().enumerate() {
245        let key = f.name.to_case(Case::Camel);
246        let value = v.get(&key).with_context(|| format!("key: {key}"))?;
247        if value.is_null() {
248            continue;
249        }
250        write!(
251            s,
252            ", {}{}",
253            i + 1,
254            urlencoding::encode(&value.to_string().replace('\"', ""))
255        )?;
256    }
257    Ok(s)
258}
259
260#[cfg(test)]
261mod forward_compat_tests {
262    use super::*;
263    use crate::model::MetaMap;
264
265    /// The contract 2.1 depends on: generated code deserializes committed schema JSON
266    /// (`serde_json::from_str`, see endpointgen's rust.rs emitter), so a *newer*
267    /// endpointgen emitting fields this version does not know about must not break an
268    /// older endpoint-libs. If this test ever fails, adding anything to the schema
269    /// model has become a breaking change and OpenAPI support becomes a 3.0.
270    #[test]
271    fn unknown_future_fields_are_ignored() {
272        let json = r#"{
273            "name": "UserListSymbols",
274            "code": 10020,
275            "parameters": [],
276            "returns": [],
277            "roles": ["Admin"],
278            "x_future_openapi_binding": {"path": "/rpc/UserListSymbols"},
279            "some_field_from_2_5": 42
280        }"#;
281        let schema: EndpointSchema =
282            serde_json::from_str(json).expect("unknown fields must not break deserialization");
283        assert_eq!(schema.name, "UserListSymbols");
284        assert_eq!(schema.code, 10020);
285        assert!(schema.meta.is_empty());
286    }
287
288    /// Old committed schema JSON (no `meta` key at all) must still load.
289    #[test]
290    fn absent_meta_defaults_empty() {
291        let json = r#"{"name":"A","code":1,"parameters":[],"returns":[],"roles":[]}"#;
292        let schema: EndpointSchema = serde_json::from_str(json).unwrap();
293        assert!(schema.meta.is_empty());
294    }
295
296    /// `meta` must survive a serialize → deserialize cycle byte-for-byte, including
297    /// keys this version assigns no meaning to. The 2.1 emitters read these.
298    #[test]
299    fn meta_round_trips_including_unknown_keys() {
300        let mut meta = MetaMap::default();
301        meta.insert("example", serde_json::json!({"symbol": "BTC"}));
302        meta.insert("x-openapi-tags", serde_json::json!(["trading"]));
303        meta.insert("deprecated", serde_json::json!(true));
304
305        let schema = EndpointSchema::new("A", 1, vec![], vec![]).with_meta(meta.clone());
306        let text = serde_json::to_string(&schema).unwrap();
307        let back: EndpointSchema = serde_json::from_str(&text).unwrap();
308
309        assert_eq!(back.meta, meta);
310        assert_eq!(
311            back.meta.get("x-openapi-tags"),
312            Some(&serde_json::json!(["trading"]))
313        );
314    }
315
316    /// Empty `meta` must not appear in the output at all, so 2.0 artifacts stay
317    /// byte-identical to 1.9 ones and `endpointgen --check` sees no spurious drift.
318    #[test]
319    fn empty_meta_is_not_serialized() {
320        let schema = EndpointSchema::new("A", 1, vec![], vec![]);
321        let text = serde_json::to_string(&schema).unwrap();
322        assert!(
323            !text.contains("meta"),
324            "empty meta leaked into output: {text}"
325        );
326
327        let field = Field::new("x", Type::String);
328        let text = serde_json::to_string(&field).unwrap();
329        assert!(!text.contains("meta"), "empty field meta leaked: {text}");
330    }
331
332    /// Field-level meta is what carries per-parameter OpenAPI enrichment.
333    #[test]
334    fn field_meta_round_trips_and_preserves_derives() {
335        let mut meta = MetaMap::default();
336        meta.insert("minimum", serde_json::json!(0));
337        let field = Field::new("amount", Type::Int64).with_meta(meta);
338
339        let back: Field = serde_json::from_str(&serde_json::to_string(&field).unwrap()).unwrap();
340        assert_eq!(back.meta.get("minimum"), Some(&serde_json::json!(0)));
341
342        // Field derives Hash/Ord/Eq; MetaMap's manual impls must keep that working.
343        let mut set = std::collections::BTreeSet::new();
344        set.insert(field.clone());
345        assert!(set.contains(&field));
346        assert_ne!(field, Field::new("amount", Type::Int64));
347    }
348}