betfair_xml_parser/
lib.rs

1#![warn(missing_docs, unreachable_pub, unused_crate_dependencies)]
2#![deny(unused_must_use, rust_2018_idioms)]
3#![allow(clippy::self_named_module_files)]
4#![doc(test(
5    no_crate_inject,
6    attr(deny(warnings, rust_2018_idioms), allow(dead_code, unused_variables))
7))]
8
9//! Betfair XML file parser.
10//! The intended use is to parse the XML files and generate Rust structs that
11//! can be used to generate code for the Betfair API-NG in Rust (or other languages?)
12//!
13//! Input: XML files from Betfair API-NG
14//! Output: Rust structs as representation of the XML files
15
16use common::Description;
17use log as _;
18use serde::{Deserialize, Serialize};
19
20pub mod common;
21pub mod data_type;
22pub mod exception_type;
23pub mod operation;
24pub mod simple_type;
25
26/// Top level representation of the XML file
27#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
28pub struct Interface {
29    /// The name of the interface
30    pub name: String,
31    /// The owner of the interface
32    pub owner: String,
33    /// The version of the interface
34    pub version: String,
35    /// The date of publication
36    pub date: String,
37    /// The namespace of the interface
38    pub namespace: String,
39    /// Vector of possible values enclosed within the interface
40    #[serde(rename = "$value")]
41    pub items: Vec<InterfaceItems>,
42}
43
44/// A child item of the <interface> tag
45#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "camelCase")]
47pub enum InterfaceItems {
48    /// The description of the interface
49    Description(Description),
50    /// A simple type tag
51    SimpleType(simple_type::SimpleType),
52    /// A data type tag
53    DataType(data_type::DataType),
54    /// An exception type tag
55    ExceptionType(exception_type::ExceptionType),
56    /// An operation tag
57    Operation(operation::Operation),
58}
59
60/// Parse the XML file into a Rust struct
61///
62/// # Arguments
63///
64/// * `xml` - The XML file as a string
65///
66/// # Returns
67///
68/// * `Result<Interface, serde_xml_rs::Error>` - The parsed interface or an error
69///
70/// # Example
71///
72/// ```ignore
73/// let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
74/// <interface name="HeartbeatAPING" owner="BDP" version="1.0.0" date="now()" namespace="com.betfair.heartbeat.api"
75///            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
76///     <description>Heartbeat</description>
77///     <operation name="heartbeat" since="1.0.0">
78///         <description>...</description>
79///         <parameters>...</parameters>
80///     </operation>
81/// </interface>"#;
82///
83/// let interface: Interface = xml.into();
84/// ```
85///
86/// # Errors
87///
88/// * `serde_xml_rs::Error` - If the XML file is not valid
89/// ```
90pub fn parse_interface(xml: &str) -> Result<Interface, serde_xml_rs::Error> {
91    serde_xml_rs::from_str(xml)
92}
93
94impl From<&str> for Interface {
95    fn from(val: &str) -> Self {
96        parse_interface(val).unwrap_or_else(|_| Into::into("Failed to parse XML file"))
97    }
98}
99
100#[cfg(test)]
101#[expect(clippy::indexing_slicing)]
102mod tests {
103    use rstest::rstest;
104
105    use super::*;
106
107    #[rstest]
108    fn interface_test() {
109        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
110<interface name="HeartbeatAPING" owner="BDP" version="1.0.0" date="now()" namespace="com.betfair.heartbeat.api"
111           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
112    <description>Heartbeat</description>
113
114    <operation name="heartbeat" since="1.0.0">
115        <description>
116            This heartbeat operation is provided to help customers have their positions managed automatically in the
117            event of their API clients losing connectivity with the Betfair API.
118
119            If a heartbeat request is not received within a prescribed time period, then Betfair will attempt to cancel
120            all 'LIMIT' type bets for the given customer on the given exchange.
121
122            There is no guarantee that this service will result in all bets being cancelled as there are a number of
123            circumstances where bets are unable to be cancelled. Manual intervention is strongly advised in the event of a loss of connectivity
124            to ensure that positions are correctly managed.
125
126            If this service becomes unavailable for any reason, then your heartbeat will be unregistered automatically to avoid bets being
127            inadvertently cancelled upon resumption of service.
128            you should manage your position manually until the service is resumed.
129
130            Heartbeat data may also be lost in the unlikely event of  nodes failing within the cluster, which
131            may result in your position not being managed until a subsequent heartbeat request is received.
132        </description>
133
134        <parameters>
135            <request>
136                <parameter mandatory="true" name="preferredTimeoutSeconds" type="i32">
137                    <description>
138                        Maximum period in seconds that may elapse (without a subsequent heartbeat request),
139                        before a cancellation request is automatically submitted on your behalf.
140
141                        Passing 0 will result in your heartbeat being unregistered (or ignored if you have no current
142                        heartbeat registered).
143
144                        You will still get an actionPerformed value returned when passing 0, so this may be used to
145                        determine if any action was performed since your last heartbeat, without actually registering a new heartbeat.
146
147                        Passing a negative value will result in an error being returned, INVALID_INPUT_DATA.
148
149                        Any errors while registering your heartbeat will result in a error being returned, UNEXPECTED_ERROR.
150
151                        Passing a value that is less than the minimum timeout will result in your heartbeat adopting the
152                        minimum timeout.
153
154                        Passing a value that is greater than the maximum timeout will result in your heartbeat adopting
155                        the maximum timeout.
156
157                        The minimum and maximum timeouts are subject to change, so your client should utilise the
158                        returned actualTimeoutSeconds to set an appropriate frequency for your subsequent heartbeat requests.
159                    </description>
160                </parameter>
161            </request>
162            <simpleResponse type="HeartbeatReport">
163                <description>Response from heartbeat operation</description>
164            </simpleResponse>
165            <exceptions>
166                <exception type="APINGException">
167                    <description>Thrown if the operation fails</description>
168                </exception>
169            </exceptions>
170        </parameters>
171    </operation>
172
173
174    <dataType name="HeartbeatReport">
175        <description>Response from heartbeat operation</description>
176        <parameter mandatory="true" name="actionPerformed" type="ActionPerformed">
177            <description>The action performed since your last heartbeat request.</description>
178        </parameter>
179        <parameter mandatory="true" name="actualTimeoutSeconds" type="i32">
180            <description>The actual timeout applied to your heartbeat request, see timeout request parameter description
181                for details.
182            </description>
183        </parameter>
184    </dataType>
185
186
187      <exceptionType name="APINGException" prefix="HBT">
188        <description>This exception is thrown when an operation fails</description>
189        <parameter name="errorCode" type="string">
190            <description>the unique code for this error</description>
191            <validValues>
192                <value id="1" name="INVALID_INPUT_DATA">
193                    <description>Invalid input data</description>
194                </value>
195                <value id="2" name="INVALID_SESSION_INFORMATION">
196                    <description>The session token passed is invalid</description>
197                </value>
198                <value id="3" name="NO_APP_KEY">
199                    <description>An application key is required for this operation</description>
200                </value>
201                <value id="4" name="NO_SESSION">
202                    <description>A session token is required for this operation</description>
203                </value>
204                <value id="5" name="INVALID_APP_KEY">
205                    <description>The application key passed is invalid</description>
206                </value>
207                <value id="6" name="UNEXPECTED_ERROR">
208                    <description>An unexpected internal error occurred that prevented successful request processing.</description>
209                </value>
210            </validValues>
211        </parameter>
212        <parameter name="errorDetails" type="string">
213            <description>Specific error details</description>
214        </parameter>
215        <parameter name="requestUUID" type="string">
216            <description/>
217        </parameter>
218    </exceptionType>
219
220    <simpleType name="ActionPerformed" type="string">
221        <validValues>
222            <value name="NONE">
223                <description>No action was performed since last heartbeat, or this is the first heartbeat</description>
224            </value>
225            <value name="CANCELLATION_REQUEST_SUBMITTED">
226                <description>A request to cancel all unmatched bets was submitted since last heartbeat</description>
227            </value>
228            <value name="ALL_BETS_CANCELLED">
229                <description>All unmatched bets were cancelled since last heartbeat</description>
230            </value>
231            <value name="SOME_BETS_NOT_CANCELLED">
232                <description>Not all unmatched bets were cancelled since last heartbeat</description>
233            </value>
234            <value name="CANCELLATION_REQUEST_ERROR">
235                <description>There was an error requesting cancellation, no bets have been cancelled</description>
236            </value>
237            <value name="CANCELLATION_STATUS_UNKNOWN">
238                <description>There was no response from requesting cancellation, cancellation status unknown</description>
239            </value>
240        </validValues>
241    </simpleType>
242
243</interface>
244        "#;
245
246        let interface: Interface = xml.into();
247        assert_eq!(interface.name, "HeartbeatAPING");
248        assert_eq!(interface.owner, "BDP");
249        assert_eq!(interface.version, "1.0.0");
250        assert_eq!(interface.date, "now()");
251        assert_eq!(interface.namespace, "com.betfair.heartbeat.api");
252        assert_eq!(interface.items.len(), 5);
253        assert!(matches!(interface.items[0], InterfaceItems::Description(_)));
254        assert!(matches!(interface.items[1], InterfaceItems::Operation(_)));
255        assert!(matches!(interface.items[2], InterfaceItems::DataType(_)));
256        assert!(matches!(
257            interface.items[3],
258            InterfaceItems::ExceptionType(_)
259        ));
260        assert!(matches!(interface.items[4], InterfaceItems::SimpleType(_)));
261    }
262}