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}