Skip to main content

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::{generate, guess, json, price, tariff, ParseError, Verdict, 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 to 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/// Generate a [`PartialCdr`](generate::PartialCdr) that can be priced by the given tariff.
166///
167/// The CDR is partial as not all required fields are set as the `cdr_from_tariff` function
168/// does not know anything about the EVSE location or the token used to authenticate the chargesession.
169///
170/// * See: [OCPI spec 2.2.1: CDR](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>)
171pub fn generate_from_tariff(
172    tariff: &tariff::Versioned<'_>,
173    config: generate::Config,
174) -> Verdict<generate::Report, generate::WarningKind> {
175    generate::cdr_from_tariff(tariff, config)
176}
177
178/// Price a single `CDR` and return a [`Report`](price::Report).
179///
180/// The `CDR` is checked for internal consistency before being priced. As pricing a `CDR` with
181/// contradictory data will lead to a difficult to debug [`Report`](price::Report).
182/// An [`Error`](price::WarningKind) is returned if the `CDR` is deemed to be internally inconsistent.
183///
184/// > **_Note_** Pricing the CDR does not require a spec compliant CDR or tariff.
185/// > A best effort is made to parse the given CDR and tariff JSON.
186///
187/// The [`Report`](price::Report) contains the charge session priced according to the specified
188/// tariff and a selection of fields from the source `CDR` that can be used for comparing the
189/// source `CDR` totals with the calculated totals. The [`Report`](price::Report) also contains
190/// a list of unknown fields to help spot misspelled fields.
191///
192/// The source of the tariffs can be controlled using the [`TariffSource`](price::TariffSource).
193/// The timezone can be found or inferred using the [`timezone::find_or_infer`](crate::timezone::find_or_infer) function.
194///
195/// # Example
196///
197/// ```rust
198/// # use ocpi_tariffs::{cdr, price, warning, Version, ParseError};
199/// #
200/// # const CDR_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time/cdr.json");
201///
202/// let report = cdr::parse_with_version(CDR_JSON, Version::V211)?;
203/// let cdr::ParseReport {
204///     cdr,
205///     unexpected_fields,
206/// } = report;
207///
208/// # if !unexpected_fields.is_empty() {
209/// #     eprintln!("Strange... there are fields in the CDR that are not defined in the spec.");
210/// #
211/// #     for path in &unexpected_fields {
212/// #         eprintln!("{path}");
213/// #     }
214/// # }
215///
216/// let report = cdr::price(&cdr, price::TariffSource::UseCdr, chrono_tz::Tz::Europe__Amsterdam).unwrap();
217/// let (report, warnings) = report.into_parts();
218///
219/// if !warnings.is_empty() {
220///     eprintln!("Pricing the CDR resulted in `{}` warnings", warnings.len());
221///
222///     for warning::Group {element, warnings} in warnings.group_by_elem(cdr.as_element()) {
223///         eprintln!("  {}", element.path());
224///
225///         for warning in warnings {
226///             eprintln!("    - {warning}");
227///         }
228///     }
229/// }
230///
231/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
232/// ```
233pub fn price(
234    cdr: &Versioned<'_>,
235    tariff_source: price::TariffSource<'_>,
236    timezone: Tz,
237) -> Verdict<price::Report, price::WarningKind> {
238    price::cdr(cdr, tariff_source, timezone)
239}
240
241/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
242/// and was determined to be one of the supported [`Version`]s.
243pub struct Versioned<'buf> {
244    /// The source JSON as string.
245    source: &'buf str,
246
247    /// The parsed JSON as structured [`Element`](crate::json::Element)s.
248    element: json::Element<'buf>,
249
250    /// The `Version` of the CDR, determined during parsing.
251    version: Version,
252}
253
254impl fmt::Debug for Versioned<'_> {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        if f.alternate() {
257            fmt::Debug::fmt(&self.element, f)
258        } else {
259            match self.version {
260                Version::V211 => f.write_str("V211"),
261                Version::V221 => f.write_str("V221"),
262            }
263        }
264    }
265}
266
267impl crate::Versioned for Versioned<'_> {
268    fn version(&self) -> Version {
269        self.version
270    }
271}
272
273impl<'buf> Versioned<'buf> {
274    pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
275        Self {
276            source,
277            element,
278            version,
279        }
280    }
281
282    /// Return the inner [`json::Element`] and discard the version info.
283    pub fn into_element(self) -> json::Element<'buf> {
284        self.element
285    }
286
287    /// Return the inner [`json::Element`] and discard the version info.
288    pub fn as_element(&self) -> &json::Element<'buf> {
289        &self.element
290    }
291
292    /// Return the inner JSON `str` and discard the version info.
293    pub fn as_json_str(&self) -> &'buf str {
294        self.source
295    }
296}
297
298/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
299/// and was determined to not be one of the supported [`Version`]s.
300#[derive(Debug)]
301pub struct Unversioned<'buf> {
302    source: &'buf str,
303    element: json::Element<'buf>,
304}
305
306impl<'buf> Unversioned<'buf> {
307    /// Create an unversioned [`json::Element`].
308    pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
309        Self {
310            source,
311            element: elem,
312        }
313    }
314
315    /// Return the inner [`json::Element`] and discard the version info.
316    pub fn into_element(self) -> json::Element<'buf> {
317        self.element
318    }
319
320    /// Return the inner [`json::Element`] and discard the version info.
321    pub fn as_element(&self) -> &json::Element<'buf> {
322        &self.element
323    }
324
325    /// Return the inner `str` and discard the version info.
326    pub fn as_json_str(&self) -> &'buf str {
327        self.source
328    }
329}
330
331impl<'buf> crate::Unversioned for Unversioned<'buf> {
332    type Versioned = Versioned<'buf>;
333
334    fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
335        let Self { source, element } = self;
336        Versioned {
337            source,
338            element,
339            version,
340        }
341    }
342}
343
344#[cfg(test)]
345mod test_every_field_set {
346    use crate::{cdr, test, Version, Versioned as _};
347
348    #[test]
349    fn should_parse_v221_cdr_as_v211_with_unexpected_fields() {
350        const JSON: &str = include_str!("../test_data/v221/lint/every_field_set/cdr.json");
351
352        test::setup();
353
354        let cdr::ParseReport {
355            cdr,
356            unexpected_fields,
357        } = cdr::parse_with_version(JSON, Version::V211).unwrap();
358
359        assert_eq!(
360            cdr.version(),
361            Version::V211,
362            "The v221 CDR was forced to be parsed as v211"
363        );
364
365        let mut unexpected_fields = unexpected_fields.to_strings();
366        unexpected_fields.sort();
367
368        assert_eq!(
369            unexpected_fields,
370            vec![
371                "$.cdr_location",
372                "$.cdr_location.address",
373                "$.cdr_location.city",
374                "$.cdr_location.connector_format",
375                "$.cdr_location.connector_id",
376                "$.cdr_location.connector_power_type",
377                "$.cdr_location.connector_standard",
378                "$.cdr_location.coordinates.latitude",
379                "$.cdr_location.coordinates.longitude",
380                "$.cdr_location.country",
381                "$.cdr_location.evse_id",
382                "$.cdr_location.evse_uid",
383                "$.cdr_location.id",
384                "$.cdr_location.name",
385                "$.cdr_location.postal_code",
386                "$.cdr_token",
387                "$.cdr_token.contract_id",
388                "$.cdr_token.type",
389                "$.cdr_token.uid",
390                "$.charging_periods.0.tariff_id",
391                "$.charging_periods.1.tariff_id",
392                "$.country_code",
393                "$.end_date_time",
394                "$.party_id",
395                "$.session_id",
396                "$.tariffs.0.country_code",
397                "$.tariffs.0.elements.0.price_components.0.vat",
398                "$.tariffs.0.elements.0.restrictions.max_current",
399                "$.tariffs.0.elements.0.restrictions.min_current",
400                "$.tariffs.0.elements.0.restrictions.reservation",
401                "$.tariffs.0.elements.1.price_components.0.vat",
402                "$.tariffs.0.elements.2.price_components.0.vat",
403                "$.tariffs.0.end_date_time",
404                "$.tariffs.0.energy_mix.energy_sources.0.percentage",
405                "$.tariffs.0.energy_mix.energy_sources.0.source",
406                "$.tariffs.0.energy_mix.energy_sources.1.percentage",
407                "$.tariffs.0.energy_mix.energy_sources.1.source",
408                "$.tariffs.0.energy_mix.energy_sources.2.percentage",
409                "$.tariffs.0.energy_mix.energy_sources.2.source",
410                "$.tariffs.0.energy_mix.energy_sources.3.percentage",
411                "$.tariffs.0.energy_mix.energy_sources.3.source",
412                "$.tariffs.0.energy_mix.energy_sources.4.percentage",
413                "$.tariffs.0.energy_mix.energy_sources.4.source",
414                "$.tariffs.0.energy_mix.environ_impact.0.amount",
415                "$.tariffs.0.energy_mix.environ_impact.0.category",
416                "$.tariffs.0.energy_mix.environ_impact.1.amount",
417                "$.tariffs.0.energy_mix.environ_impact.1.category",
418                "$.tariffs.0.max_price",
419                "$.tariffs.0.max_price.excl_vat",
420                "$.tariffs.0.max_price.incl_vat",
421                "$.tariffs.0.min_price",
422                "$.tariffs.0.min_price.excl_vat",
423                "$.tariffs.0.min_price.incl_vat",
424                "$.tariffs.0.party_id",
425                "$.tariffs.0.type",
426                "$.total_cost.excl_vat",
427                "$.total_cost.incl_vat",
428                "$.total_energy_cost",
429                "$.total_energy_cost.excl_vat",
430                "$.total_energy_cost.incl_vat",
431                "$.total_time_cost",
432                "$.total_time_cost.excl_vat",
433                "$.total_time_cost.incl_vat"
434            ],
435            "The v221 Cdr should fail on the `total_cost` field as the internal structure differs between versions"
436        );
437    }
438}