Skip to main content

faucet_source_rest/
odata.rs

1//! OData `$metadata` (EDMX / CSDL) parsing → dataset discovery (#512).
2//!
3//! Pure, network-free: [`parse_edmx`] turns a `$metadata` XML document into the
4//! entity types + sets it declares, and [`descriptors_from_edmx`] maps those
5//! onto [`DatasetDescriptor`]s (one per entity set, each with a typed schema and
6//! a `config_patch` that selects the entity).
7
8use faucet_core::FaucetError;
9use faucet_core::discover::{DatasetDescriptor, columns_to_schema, nullable_type};
10use quick_xml::Reader;
11use quick_xml::events::{BytesStart, Event};
12use serde_json::{Value, json};
13use std::collections::HashMap;
14
15/// One EDM property (column) of an entity type.
16#[derive(Debug, Clone, PartialEq)]
17pub struct EdmProperty {
18    /// Property name.
19    pub name: String,
20    /// EDM type (e.g. `Edm.String`, `Edm.Int32`).
21    pub edm_type: String,
22    /// Whether the property may be null (EDM default is `true`).
23    pub nullable: bool,
24}
25
26/// An EDM entity type: its key columns + properties.
27#[derive(Debug, Clone, PartialEq, Default)]
28pub struct EdmEntityType {
29    /// Type name (local, namespace-stripped).
30    pub name: String,
31    /// Key property names.
32    pub keys: Vec<String>,
33    /// Properties in declaration order.
34    pub properties: Vec<EdmProperty>,
35}
36
37/// An EDM entity set: the queryable collection name + the type it holds.
38#[derive(Debug, Clone, PartialEq)]
39pub struct EdmEntitySet {
40    /// Entity-set name (the path segment you query).
41    pub name: String,
42    /// Local name of the entity type backing this set.
43    pub type_name: String,
44}
45
46/// Map an EDM primitive type to a JSON-Schema type fragment.
47pub fn edm_type_to_json(edm_type: &str) -> Value {
48    let t = edm_type.strip_prefix("Edm.").unwrap_or(edm_type);
49    let ty = match t {
50        "Boolean" => "boolean",
51        "Byte" | "SByte" | "Int16" | "Int32" | "Int64" => "integer",
52        "Decimal" | "Double" | "Single" => "number",
53        // String, Guid, DateTimeOffset, Date, TimeOfDay, Duration, Binary,
54        // Stream, Geography*, … all serialize as JSON strings.
55        _ => "string",
56    };
57    json!({ "type": ty })
58}
59
60/// Namespace-strip a possibly-prefixed XML name (`edm:Property` → `Property`).
61fn local_name(qname: &[u8]) -> String {
62    let s = String::from_utf8_lossy(qname);
63    s.rsplit(':').next().unwrap_or(&s).to_string()
64}
65
66/// Read one attribute of an element by (namespace-stripped) name.
67fn attr(e: &BytesStart, key: &str) -> Option<String> {
68    e.attributes()
69        .flatten()
70        .find(|a| local_name(a.key.as_ref()) == key)
71        .and_then(|a| a.unescape_value().ok().map(|v| v.to_string()))
72}
73
74/// Parse an EDMX / CSDL `$metadata` document into its entity types + sets.
75pub fn parse_edmx(xml: &str) -> Result<(Vec<EdmEntityType>, Vec<EdmEntitySet>), FaucetError> {
76    let mut reader = Reader::from_str(xml);
77    let mut types: Vec<EdmEntityType> = Vec::new();
78    let mut sets: Vec<EdmEntitySet> = Vec::new();
79    let mut current: Option<EdmEntityType> = None;
80    let mut in_key = false;
81
82    // Handle a start/empty element's attributes; `is_empty` self-closing tags
83    // (`<Property .../>`, `<EntitySet .../>`) never get a matching `End`.
84    let open = |e: &BytesStart,
85                is_empty: bool,
86                current: &mut Option<EdmEntityType>,
87                in_key: &mut bool,
88                types: &mut Vec<EdmEntityType>,
89                sets: &mut Vec<EdmEntitySet>| {
90        match local_name(e.name().as_ref()).as_str() {
91            "EntityType" => {
92                let t = EdmEntityType {
93                    name: attr(e, "Name").unwrap_or_default(),
94                    ..Default::default()
95                };
96                if is_empty {
97                    types.push(t);
98                } else {
99                    *current = Some(t);
100                }
101            }
102            "Key" => {
103                if !is_empty {
104                    *in_key = true;
105                }
106            }
107            "PropertyRef" => {
108                if *in_key && let (Some(cur), Some(n)) = (current.as_mut(), attr(e, "Name")) {
109                    cur.keys.push(n);
110                }
111            }
112            "Property" => {
113                if let Some(cur) = current.as_mut() {
114                    let name = attr(e, "Name").unwrap_or_default();
115                    if !name.is_empty() {
116                        cur.properties.push(EdmProperty {
117                            name,
118                            edm_type: attr(e, "Type").unwrap_or_else(|| "Edm.String".to_owned()),
119                            // EDM `Nullable` defaults to true when absent.
120                            nullable: attr(e, "Nullable").map(|v| v != "false").unwrap_or(true),
121                        });
122                    }
123                }
124            }
125            "EntitySet" => {
126                if let (Some(name), Some(ty)) = (attr(e, "Name"), attr(e, "EntityType")) {
127                    let type_name = ty.rsplit('.').next().unwrap_or(&ty).to_owned();
128                    sets.push(EdmEntitySet { name, type_name });
129                }
130            }
131            _ => {}
132        }
133    };
134
135    loop {
136        match reader
137            .read_event()
138            .map_err(|e| FaucetError::Source(format!("odata: invalid $metadata XML: {e}")))?
139        {
140            Event::Eof => break,
141            Event::Start(e) => open(&e, false, &mut current, &mut in_key, &mut types, &mut sets),
142            Event::Empty(e) => open(&e, true, &mut current, &mut in_key, &mut types, &mut sets),
143            Event::End(e) => match local_name(e.name().as_ref()).as_str() {
144                "EntityType" => {
145                    if let Some(t) = current.take() {
146                        types.push(t);
147                    }
148                }
149                "Key" => in_key = false,
150                _ => {}
151            },
152            _ => {}
153        }
154    }
155    Ok((types, sets))
156}
157
158/// Parse `$metadata` and produce one [`DatasetDescriptor`] per entity set,
159/// each carrying a typed schema and a `config_patch` selecting the entity.
160pub fn descriptors_from_edmx(xml: &str) -> Result<Vec<DatasetDescriptor>, FaucetError> {
161    let (types, sets) = parse_edmx(xml)?;
162    let by_name: HashMap<&str, &EdmEntityType> =
163        types.iter().map(|t| (t.name.as_str(), t)).collect();
164    let mut out = Vec::with_capacity(sets.len());
165    for set in &sets {
166        let schema = by_name.get(set.type_name.as_str()).map(|t| {
167            let cols = t.properties.iter().map(|p| {
168                let frag = edm_type_to_json(&p.edm_type);
169                let frag = if p.nullable {
170                    nullable_type(frag)
171                } else {
172                    frag
173                };
174                (p.name.clone(), frag)
175            });
176            columns_to_schema(cols)
177        });
178        let mut d = DatasetDescriptor::new(
179            set.name.clone(),
180            "entity",
181            json!({ "odata": { "entity": set.name.clone() } }),
182        );
183        if let Some(s) = schema {
184            d = d.with_schema(s);
185        }
186        out.push(d);
187    }
188    Ok(out)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    const SAMPLE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
196<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
197  <edmx:DataServices>
198    <Schema Namespace="Sales" xmlns="http://docs.oasis-open.org/odata/ns/edm">
199      <EntityType Name="Order">
200        <Key><PropertyRef Name="DocEntry"/></Key>
201        <Property Name="DocEntry" Type="Edm.Int32" Nullable="false"/>
202        <Property Name="DocDate" Type="Edm.DateTimeOffset"/>
203        <Property Name="Total" Type="Edm.Decimal" Nullable="false"/>
204        <Property Name="Posted" Type="Edm.Boolean"/>
205      </EntityType>
206      <EntityContainer Name="Container">
207        <EntitySet Name="Orders" EntityType="Sales.Order"/>
208      </EntityContainer>
209    </Schema>
210  </edmx:DataServices>
211</edmx:Edmx>"#;
212
213    #[test]
214    fn edm_types_map_to_json_types() {
215        assert_eq!(edm_type_to_json("Edm.Boolean"), json!({"type": "boolean"}));
216        assert_eq!(edm_type_to_json("Edm.Int64"), json!({"type": "integer"}));
217        assert_eq!(edm_type_to_json("Edm.Decimal"), json!({"type": "number"}));
218        assert_eq!(edm_type_to_json("Edm.String"), json!({"type": "string"}));
219        assert_eq!(
220            edm_type_to_json("Edm.DateTimeOffset"),
221            json!({"type": "string"})
222        );
223        assert_eq!(
224            edm_type_to_json("Something.Custom"),
225            json!({"type": "string"})
226        );
227    }
228
229    #[test]
230    fn parse_edmx_extracts_types_and_sets() {
231        let (types, sets) = parse_edmx(SAMPLE).unwrap();
232        assert_eq!(types.len(), 1);
233        assert_eq!(types[0].name, "Order");
234        assert_eq!(types[0].keys, vec!["DocEntry".to_string()]);
235        assert_eq!(types[0].properties.len(), 4);
236        assert_eq!(types[0].properties[0].name, "DocEntry");
237        assert!(!types[0].properties[0].nullable);
238        assert!(types[0].properties[1].nullable); // DocDate has no Nullable attr
239        assert_eq!(sets.len(), 1);
240        assert_eq!(sets[0].name, "Orders");
241        assert_eq!(sets[0].type_name, "Order");
242    }
243
244    #[test]
245    fn descriptors_carry_schema_and_config_patch() {
246        let ds = descriptors_from_edmx(SAMPLE).unwrap();
247        assert_eq!(ds.len(), 1);
248        let d = &ds[0];
249        assert_eq!(d.name, "Orders");
250        assert_eq!(d.kind, "entity");
251        assert_eq!(d.config_patch, json!({ "odata": { "entity": "Orders" } }));
252        let schema = d.schema.as_ref().unwrap();
253        assert_eq!(schema["type"], "object");
254        assert_eq!(schema["properties"]["DocEntry"]["type"], "integer");
255        assert_eq!(schema["properties"]["Total"]["type"], "number");
256        assert_eq!(schema["properties"]["Posted"]["type"][0], "boolean");
257        assert_eq!(schema["properties"]["Posted"]["type"][1], "null");
258    }
259
260    #[test]
261    fn empty_entity_type_and_missing_type_are_tolerated() {
262        let xml = r#"<Schema>
263            <EntityType Name="Empty"/>
264            <EntitySet Name="Ghosts" EntityType="ns.NotDeclared"/>
265        </Schema>"#;
266        let ds = descriptors_from_edmx(xml).unwrap();
267        assert_eq!(ds.len(), 1);
268        // No matching type → no schema, but the set is still discoverable.
269        assert!(ds[0].schema.is_none());
270        assert_eq!(ds[0].name, "Ghosts");
271    }
272
273    #[test]
274    fn invalid_xml_errors() {
275        assert!(parse_edmx("<Schema><EntityType Name=").is_err());
276    }
277
278    #[test]
279    fn property_without_name_is_skipped() {
280        // A `<Property>` with no `Name` is skipped (not added as an empty-named
281        // column); an explicit `Nullable="true"` is honoured.
282        let xml = r#"<Schema>
283          <EntityType Name="T">
284            <Property Type="Edm.String"/>
285            <Property Name="ok" Type="Edm.String" Nullable="true"/>
286          </EntityType>
287        </Schema>"#;
288        let (types, _sets) = parse_edmx(xml).unwrap();
289        assert_eq!(types[0].properties.len(), 1);
290        assert_eq!(types[0].properties[0].name, "ok");
291        assert!(types[0].properties[0].nullable);
292    }
293}