ocpi_tariffs/
cdr.rs

1//! Parse a CDR and price the result with a tariff.
2
3use std::fmt;
4
5use chrono_tz::Tz;
6
7use crate::{guess, json, price, ParseError, Version};
8
9/// Parse a `&str` into a [`Versioned`] CDR using a schema for the given [`Version`][^spec-v211][^spec-v221] to check for
10/// any unexpected fields.
11///
12/// # Example
13///
14/// ```rust
15/// # use ocpi_tariffs::{cdr, Version, ParseError};
16/// #
17/// # const CDR_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time/cdr.json");
18///
19/// let report = cdr::parse_with_version(CDR_JSON, Version::V211)?;
20/// let cdr::ParseReport {
21///     cdr,
22///     unexpected_fields,
23/// } = report;
24///
25/// if !unexpected_fields.is_empty() {
26///     eprintln!("Strange... there are fields in the CDR that are not defined in the spec.");
27///
28///     for path in &unexpected_fields {
29///         eprintln!("{path}");
30///     }
31/// }
32///
33/// # Ok::<(), ParseError>(())
34/// ```
35///
36/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md>
37/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>
38pub fn parse_with_version(source: &str, version: Version) -> Result<ParseReport<'_>, ParseError> {
39    match version {
40        Version::V221 => {
41            let schema = &*crate::v221::CDR_SCHEMA;
42            let report =
43                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
44
45            let json::ParseReport {
46                element,
47                unexpected_fields,
48            } = report;
49            Ok(ParseReport {
50                cdr: Versioned {
51                    source,
52                    element,
53                    version: Version::V221,
54                },
55                unexpected_fields,
56            })
57        }
58        Version::V211 => {
59            let schema = &*crate::v211::CDR_SCHEMA;
60            let report =
61                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
62            let json::ParseReport {
63                element,
64                unexpected_fields,
65            } = report;
66            Ok(ParseReport {
67                cdr: Versioned {
68                    source,
69                    element,
70                    version,
71                },
72                unexpected_fields,
73            })
74        }
75    }
76}
77
78/// A report of calling [`parse_with_version`].  
79#[derive(Debug)]
80pub struct ParseReport<'buf> {
81    /// The root JSON [`Element`](json::Element).
82    pub cdr: Versioned<'buf>,
83
84    /// A list of fields that were not expected: The schema did not define them.
85    pub unexpected_fields: json::UnexpectedFields<'buf>,
86}
87
88/// Parse the JSON and try guess the [`Version`] based on fields defined in the OCPI v2.1.1[^spec-v211] and v2.2.1[^spec-v221] CDR spec.
89///
90/// The parser is forgiving and will not complain if the CDR JSON is missing required fields.
91/// The parser will also not complain if unexpected fields are present in the JSON.
92/// The [`Version`] guess is based on fields that exist.
93///
94/// # Example
95///
96/// ```rust
97/// # use ocpi_tariffs::{cdr, guess, ParseError, Version, Versioned as _};
98/// #
99/// # const CDR_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time/cdr.json");
100/// let cdr = cdr::parse(CDR_JSON)?;
101///
102/// match cdr {
103///     guess::Version::Certain(cdr) => {
104///         println!("The CDR version is `{}`", cdr.version());
105///     },
106///     guess::Version::Uncertain(_cdr) => {
107///         eprintln!("Unable to guess the version of given CDR JSON.");
108///     }
109/// }
110///
111/// # Ok::<(), ParseError>(())
112/// ```
113///
114/// [^ocpi-spec-v211-cdr]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md>
115/// [^ocpi-spec-v221-cdr]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>
116pub fn parse(cdr_json: &str) -> Result<guess::CdrVersion<'_>, ParseError> {
117    guess::cdr_version(cdr_json)
118}
119
120/// Guess the [`Version`][^spec-v211][^spec-v221] of the given CDR JSON and report on any unexpected fields.
121///
122/// The parser is forgiving and will not complain if the CDR JSON is missing required fields.
123/// The parser will also not complain if unexpected fields are present in the JSON.
124/// The [`Version`] guess is based on fields that exist.
125///
126/// # Example
127///
128/// ```rust
129/// # use ocpi_tariffs::{cdr, guess, ParseError, Version, Versioned};
130/// #
131/// # const CDR_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time/cdr.json");
132///
133/// let report = cdr::parse_and_report(CDR_JSON)?;
134/// let guess::CdrReport {
135///     version,
136///     unexpected_fields,
137/// } = report;
138///
139/// if !unexpected_fields.is_empty() {
140///     eprintln!("Strange... there are fields in the CDR that are not defined in the spec.");
141///
142///     for path in &unexpected_fields {
143///         eprintln!("{path}");
144///     }
145/// }
146///
147/// match version {
148///     guess::Version::Certain(cdr) => {
149///         println!("The CDR version is `{}`", cdr.version());
150///     },
151///     guess::Version::Uncertain(_cdr) => {
152///         eprintln!("Unable to guess the version of given CDR JSON.");
153///     }
154/// }
155///
156/// # Ok::<(), ParseError>(())
157/// ```
158///
159/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md>
160/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>
161pub fn parse_and_report(cdr_json: &str) -> Result<guess::CdrReport<'_>, ParseError> {
162    guess::cdr_version_and_report(cdr_json)
163}
164
165/// Price a single `CDR` and return a [`Report`](price::Report).
166///
167/// The `CDR` is checked for internal consistency before being priced. As pricing a `CDR` with
168/// contradictory data will lead to a difficult to debug [`Report`](price::Report).
169/// An [`Error`](price::Error) is returned if the `CDR` is deemed to be internally inconsistent.
170///
171/// > **_Note_** Pricing the CDR does not require a spec compliant CDR or tariff.
172/// > A best effort is made to parse the given CDR and tariff JSON.
173///
174/// The [`Report`](price::Report) contains the charge session priced according to the specified
175/// tariff and a selection of fields from the source `CDR` that can be used for comparing the
176/// source `CDR` totals with the calculated totals. The [`Report`](price::Report) also contains
177/// a list of unknown fields to help spot misspelled fields.
178///
179/// The source of the tariffs can be controlled using the [`TariffSource`](price::TariffSource).
180/// The timezone can be found or inferred using the [`timezone::find_or_infer`](crate::timezone::find_or_infer) fn.
181///
182/// # Example
183///
184/// ```rust
185/// # use ocpi_tariffs::{cdr, price, Version, ParseError};
186/// #
187/// # const CDR_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time/cdr.json");
188///
189/// let report = cdr::parse_with_version(CDR_JSON, Version::V211)?;
190/// let cdr::ParseReport {
191///     cdr,
192///     unexpected_fields,
193/// } = report;
194///
195/// # if !unexpected_fields.is_empty() {
196/// #     eprintln!("Strange... there are fields in the CDR that are not defined in the spec.");
197/// #
198/// #     for path in &unexpected_fields {
199/// #         eprintln!("{path}");
200/// #     }
201/// # }
202///
203/// let report = cdr::price(cdr, price::TariffSource::UseCdr, chrono_tz::Tz::Europe__Amsterdam)?;
204///
205/// if !report.warnings.is_empty() {
206///     eprintln!("Pricing the CDR resulted in `{}` warnings", report.warnings.len());
207///
208///     for price::WarningReport { kind } in &report.warnings {
209///         eprintln!("  - {kind}");
210///     }
211/// }
212///
213/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
214/// ```
215pub fn price(
216    cdr: Versioned<'_>,
217    tariff_source: price::TariffSource,
218    timezone: Tz,
219) -> Result<price::Report, price::Error> {
220    price::cdr(cdr, tariff_source, timezone)
221}
222
223/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
224/// and was determined to be one of the supported [`Version`]s.
225pub struct Versioned<'buf> {
226    /// The source JSON as string.
227    source: &'buf str,
228
229    /// The parsed JSON as structured [`Element`](crate::json::Element)s.
230    element: json::Element<'buf>,
231
232    /// The `Version` of the CDR, determined during parsing.
233    version: Version,
234}
235
236impl fmt::Debug for Versioned<'_> {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        if f.alternate() {
239            fmt::Debug::fmt(&self.element, f)
240        } else {
241            match self.version {
242                Version::V211 => f.write_str("V211"),
243                Version::V221 => f.write_str("V221"),
244            }
245        }
246    }
247}
248
249impl crate::Versioned for Versioned<'_> {
250    fn version(&self) -> Version {
251        self.version
252    }
253}
254
255impl<'buf> Versioned<'buf> {
256    pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
257        Self {
258            source,
259            element,
260            version,
261        }
262    }
263
264    /// Return the inner [`json::Element`] and discard the version info.
265    pub fn into_element(self) -> json::Element<'buf> {
266        self.element
267    }
268
269    /// Return the inner [`json::Element`] and discard the version info.
270    pub fn as_element(&self) -> &json::Element<'buf> {
271        &self.element
272    }
273
274    /// Return the inner JSON `str` and discard the version info.
275    pub fn as_json_str(&self) -> &'buf str {
276        self.source
277    }
278}
279
280/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
281/// and was determined to not be one of the supported [`Version`]s.
282#[derive(Debug)]
283pub struct Unversioned<'buf> {
284    source: &'buf str,
285    element: json::Element<'buf>,
286}
287
288impl<'buf> Unversioned<'buf> {
289    /// Create an unversioned [`json::Element`].
290    pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
291        Self {
292            source,
293            element: elem,
294        }
295    }
296
297    /// Return the inner [`json::Element`] and discard the version info.
298    pub fn into_element(self) -> json::Element<'buf> {
299        self.element
300    }
301
302    /// Return the inner [`json::Element`] and discard the version info.
303    pub fn as_element(&self) -> &json::Element<'buf> {
304        &self.element
305    }
306
307    /// Return the inner `str` and discard the version info.
308    pub fn as_json_str(&self) -> &'buf str {
309        self.source
310    }
311}
312
313impl<'buf> crate::Unversioned for Unversioned<'buf> {
314    type Versioned = Versioned<'buf>;
315
316    fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
317        let Self { source, element } = self;
318        Versioned {
319            source,
320            element,
321            version,
322        }
323    }
324}
325
326#[cfg(test)]
327mod test_every_field_set {
328    use crate::{cdr, test, Version, Versioned as _};
329
330    #[test]
331    fn should_parse_v221_cdr_as_v211_with_unexpected_fields() {
332        const JSON: &str = include_str!("../test_data/v221/lint/every_field_set/cdr.json");
333
334        test::setup();
335
336        let cdr::ParseReport {
337            cdr,
338            unexpected_fields,
339        } = cdr::parse_with_version(JSON, Version::V211).unwrap();
340
341        assert_eq!(
342            cdr.version(),
343            Version::V211,
344            "The v221 CDR was forced to be parsed as v211"
345        );
346
347        let mut unexpected_fields = unexpected_fields.to_strings();
348        unexpected_fields.sort();
349
350        assert_eq!(
351            unexpected_fields,
352            vec![
353                "$.cdr_location",
354                "$.cdr_location.address",
355                "$.cdr_location.city",
356                "$.cdr_location.connector_format",
357                "$.cdr_location.connector_id",
358                "$.cdr_location.connector_power_type",
359                "$.cdr_location.connector_standard",
360                "$.cdr_location.coordinates.latitude",
361                "$.cdr_location.coordinates.longitude",
362                "$.cdr_location.country",
363                "$.cdr_location.evse_id",
364                "$.cdr_location.evse_uid",
365                "$.cdr_location.id",
366                "$.cdr_location.name",
367                "$.cdr_location.postal_code",
368                "$.cdr_token",
369                "$.cdr_token.contract_id",
370                "$.cdr_token.type",
371                "$.cdr_token.uid",
372                "$.charging_periods.0.tariff_id",
373                "$.charging_periods.1.tariff_id",
374                "$.country_code",
375                "$.end_date_time",
376                "$.party_id",
377                "$.session_id",
378                "$.tariffs.0.country_code",
379                "$.tariffs.0.elements.0.price_components.0.vat",
380                "$.tariffs.0.elements.0.restrictions.max_current",
381                "$.tariffs.0.elements.0.restrictions.min_current",
382                "$.tariffs.0.elements.0.restrictions.reservation",
383                "$.tariffs.0.elements.1.price_components.0.vat",
384                "$.tariffs.0.elements.2.price_components.0.vat",
385                "$.tariffs.0.end_date_time",
386                "$.tariffs.0.energy_mix.energy_sources.0.percentage",
387                "$.tariffs.0.energy_mix.energy_sources.0.source",
388                "$.tariffs.0.energy_mix.energy_sources.1.percentage",
389                "$.tariffs.0.energy_mix.energy_sources.1.source",
390                "$.tariffs.0.energy_mix.energy_sources.2.percentage",
391                "$.tariffs.0.energy_mix.energy_sources.2.source",
392                "$.tariffs.0.energy_mix.energy_sources.3.percentage",
393                "$.tariffs.0.energy_mix.energy_sources.3.source",
394                "$.tariffs.0.energy_mix.energy_sources.4.percentage",
395                "$.tariffs.0.energy_mix.energy_sources.4.source",
396                "$.tariffs.0.energy_mix.environ_impact.0.amount",
397                "$.tariffs.0.energy_mix.environ_impact.0.category",
398                "$.tariffs.0.energy_mix.environ_impact.1.amount",
399                "$.tariffs.0.energy_mix.environ_impact.1.category",
400                "$.tariffs.0.max_price",
401                "$.tariffs.0.max_price.excl_vat",
402                "$.tariffs.0.max_price.incl_vat",
403                "$.tariffs.0.min_price",
404                "$.tariffs.0.min_price.excl_vat",
405                "$.tariffs.0.min_price.incl_vat",
406                "$.tariffs.0.party_id",
407                "$.tariffs.0.type",
408                "$.total_cost.excl_vat",
409                "$.total_cost.incl_vat",
410                "$.total_energy_cost",
411                "$.total_energy_cost.excl_vat",
412                "$.total_energy_cost.incl_vat",
413                "$.total_time_cost",
414                "$.total_time_cost.excl_vat",
415                "$.total_time_cost.incl_vat"
416            ],
417            "The v221 Cdr should fail on the `total_cost` field as the internal structure differs between versions"
418        );
419    }
420}