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}