Skip to main content

data_gov_catalog/
models.rs

1//! Types that model the Catalog API response payloads.
2//!
3//! Every field that the upstream API omits is wrapped in [`Option`] because
4//! DCAT-US 3 records vary widely across publishers. Unknown or transitional
5//! fields are preserved in [`serde_json::Value`] extras where appropriate.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10/// Deserialize a JSON `null` as `T::default()`.
11///
12/// `#[serde(default)]` alone covers the *missing field* case but doesn't
13/// help when the field is *present and explicitly null*. The Catalog API
14/// returns `null` for empty repeated DCAT-US 3 fields like `references`,
15/// `keyword`, etc., so every `Vec<T>` field on these models needs this
16/// extra hop to avoid `invalid type: null, expected a sequence` panics.
17fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
18where
19    D: serde::Deserializer<'de>,
20    T: serde::Deserialize<'de> + Default,
21{
22    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
23}
24
25/// Envelope returned by the `/search` endpoint.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SearchResponse {
28    /// Datasets matching the query on this page.
29    #[serde(default, deserialize_with = "deserialize_null_as_default")]
30    pub results: Vec<SearchHit>,
31    /// Opaque cursor for the next page. Absent on the last page.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub after: Option<String>,
34    /// Sort mode echoed back by the server (e.g. `"relevance"`).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub sort: Option<String>,
37}
38
39/// A single search hit.
40///
41/// Denormalized top-level fields duplicate the most common DCAT-US 3 fields
42/// for convenience; the full canonical record is nested under [`Self::dcat`].
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SearchHit {
45    /// Publisher-assigned identifier (often a URL or URN).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub identifier: Option<String>,
48    /// URL-friendly slug for this dataset in the data.gov UI.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub slug: Option<String>,
51    /// Human-readable title.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub title: Option<String>,
54    /// Plain-text description.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub description: Option<String>,
57    /// Short name of the publishing source (may be a domain or agency code).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub publisher: Option<String>,
60    /// Publishing organization record.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub organization: Option<Organization>,
63    /// Free-form tags.
64    #[serde(default, deserialize_with = "deserialize_null_as_default")]
65    pub keyword: Vec<String>,
66    /// DCAT-US themes (broad subject categories).
67    #[serde(default, deserialize_with = "deserialize_null_as_default")]
68    pub theme: Vec<String>,
69    /// Whether this dataset advertises spatial coverage.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub has_spatial: Option<bool>,
72    /// Opaque popularity score used for ranking.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub popularity: Option<i64>,
75    /// Timestamp of the most recent successful harvest.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub last_harvested_date: Option<String>,
78    /// Distribution titles listed out for convenience (may be empty).
79    #[serde(default, deserialize_with = "deserialize_null_as_default")]
80    pub distribution_titles: Vec<String>,
81    /// URL of the harvest record for this dataset.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub harvest_record: Option<String>,
84    /// URL of the raw (pre-transform) harvest payload.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub harvest_record_raw: Option<String>,
87    /// GeoJSON centroid if `has_spatial` is true.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub spatial_centroid: Option<Value>,
90    /// GeoJSON shape if `has_spatial` is true.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub spatial_shape: Option<Value>,
93    /// Canonical DCAT-US 3 record for this dataset.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub dcat: Option<Dataset>,
96    /// Ranking score (present when `sort=relevance`).
97    #[serde(default, rename = "_score", skip_serializing_if = "Option::is_none")]
98    pub score: Option<f64>,
99    /// Cursor components that generated this hit's position.
100    #[serde(default, rename = "_sort", skip_serializing_if = "Option::is_none")]
101    pub sort_key: Option<Value>,
102}
103
104/// DCAT-US 3 dataset record.
105///
106/// Also the payload returned by `/harvest_record/{id}/transformed`.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Dataset {
109    /// DCAT type hint, typically `"dcat:Dataset"`.
110    #[serde(default, rename = "@type", skip_serializing_if = "Option::is_none")]
111    pub type_hint: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub title: Option<String>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub description: Option<String>,
116    /// Publisher-assigned identifier.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub identifier: Option<String>,
119    /// `public`, `restricted public`, or `non-public`.
120    #[serde(
121        default,
122        skip_serializing_if = "Option::is_none",
123        rename = "accessLevel"
124    )]
125    pub access_level: Option<String>,
126    /// ISO 8601 date the record was last modified.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub modified: Option<String>,
129    /// ISO 8601 date the record was first issued.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub issued: Option<String>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub publisher: Option<Publisher>,
134    #[serde(
135        default,
136        skip_serializing_if = "Option::is_none",
137        rename = "contactPoint"
138    )]
139    pub contact_point: Option<ContactPoint>,
140    #[serde(default, deserialize_with = "deserialize_null_as_default")]
141    pub keyword: Vec<String>,
142    #[serde(default, deserialize_with = "deserialize_null_as_default")]
143    pub theme: Vec<String>,
144    /// Downloadable / accessible representations of the dataset.
145    #[serde(default, deserialize_with = "deserialize_null_as_default")]
146    pub distribution: Vec<Distribution>,
147    /// Publisher's landing page for this dataset.
148    #[serde(
149        default,
150        skip_serializing_if = "Option::is_none",
151        rename = "landingPage"
152    )]
153    pub landing_page: Option<String>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub license: Option<String>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub rights: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub spatial: Option<String>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub temporal: Option<String>,
162    #[serde(
163        default,
164        skip_serializing_if = "Option::is_none",
165        rename = "accrualPeriodicity"
166    )]
167    pub accrual_periodicity: Option<String>,
168    #[serde(default, deserialize_with = "deserialize_null_as_default")]
169    pub language: Vec<String>,
170    #[serde(
171        default,
172        skip_serializing_if = "Option::is_none",
173        rename = "bureauCode"
174    )]
175    pub bureau_code: Option<Value>,
176    #[serde(
177        default,
178        skip_serializing_if = "Option::is_none",
179        rename = "programCode"
180    )]
181    pub program_code: Option<Value>,
182    /// Metadata describing the record's schema.
183    #[serde(
184        default,
185        skip_serializing_if = "Option::is_none",
186        rename = "describedBy"
187    )]
188    pub described_by: Option<String>,
189    #[serde(
190        default,
191        skip_serializing_if = "Option::is_none",
192        rename = "describedByType"
193    )]
194    pub described_by_type: Option<String>,
195    #[serde(default, deserialize_with = "deserialize_null_as_default")]
196    pub references: Vec<String>,
197    #[serde(
198        default,
199        skip_serializing_if = "Option::is_none",
200        rename = "dataQuality"
201    )]
202    pub data_quality: Option<bool>,
203    #[serde(
204        default,
205        skip_serializing_if = "Option::is_none",
206        rename = "systemOfRecords"
207    )]
208    pub system_of_records: Option<String>,
209}
210
211/// One downloadable or API-accessible representation of a dataset.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct Distribution {
214    #[serde(default, rename = "@type", skip_serializing_if = "Option::is_none")]
215    pub type_hint: Option<String>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub title: Option<String>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub description: Option<String>,
220    /// Direct download URL for the distribution file.
221    #[serde(
222        default,
223        skip_serializing_if = "Option::is_none",
224        rename = "downloadURL"
225    )]
226    pub download_url: Option<String>,
227    /// Access URL for APIs or web-based views.
228    #[serde(default, skip_serializing_if = "Option::is_none", rename = "accessURL")]
229    pub access_url: Option<String>,
230    /// IANA media type (e.g. `text/csv`).
231    #[serde(default, skip_serializing_if = "Option::is_none", rename = "mediaType")]
232    pub media_type: Option<String>,
233    /// Short format label (e.g. `CSV`, `JSON`).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub format: Option<String>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub license: Option<String>,
238    #[serde(
239        default,
240        skip_serializing_if = "Option::is_none",
241        rename = "describedBy"
242    )]
243    pub described_by: Option<String>,
244    #[serde(
245        default,
246        skip_serializing_if = "Option::is_none",
247        rename = "describedByType"
248    )]
249    pub described_by_type: Option<String>,
250}
251
252/// DCAT publisher object (`org:Organization`).
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct Publisher {
255    #[serde(default, rename = "@type", skip_serializing_if = "Option::is_none")]
256    pub type_hint: Option<String>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub name: Option<String>,
259    /// Nested publisher (parent organization).
260    #[serde(
261        default,
262        skip_serializing_if = "Option::is_none",
263        rename = "subOrganizationOf"
264    )]
265    pub sub_organization_of: Option<Box<Publisher>>,
266}
267
268/// DCAT contact point (`vcard:Contact`).
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct ContactPoint {
271    #[serde(default, rename = "@type", skip_serializing_if = "Option::is_none")]
272    pub type_hint: Option<String>,
273    /// Full name of the contact.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub fn_: Option<String>,
276    /// Email URI (e.g. `mailto:ops@example.gov`).
277    #[serde(default, skip_serializing_if = "Option::is_none", rename = "hasEmail")]
278    pub has_email: Option<String>,
279}
280
281// `fn` is a keyword; accept it via `fn_` with a rename.
282impl ContactPoint {
283    /// Create a [`ContactPoint`] with the DCAT `fn` field populated.
284    pub fn with_name(name: impl Into<String>) -> Self {
285        Self {
286            type_hint: None,
287            fn_: Some(name.into()),
288            has_email: None,
289        }
290    }
291}
292
293/// Envelope returned by `/api/organizations`.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct OrganizationsResponse {
296    #[serde(default, deserialize_with = "deserialize_null_as_default")]
297    pub organizations: Vec<Organization>,
298    #[serde(default)]
299    pub total: i64,
300}
301
302/// A publishing organization as the catalog knows it.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct Organization {
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub id: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub name: Option<String>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub slug: Option<String>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub description: Option<String>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub logo: Option<String>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub organization_type: Option<String>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub dataset_count: Option<i64>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub source_count: Option<i64>,
321    #[serde(default, deserialize_with = "deserialize_null_as_default")]
322    pub aliases: Vec<String>,
323}
324
325/// Envelope returned by `/api/keywords`.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct KeywordsResponse {
328    #[serde(default, deserialize_with = "deserialize_null_as_default")]
329    pub keywords: Vec<KeywordCount>,
330    #[serde(default)]
331    pub total: i64,
332    #[serde(default)]
333    pub size: i64,
334    #[serde(default)]
335    pub min_count: i64,
336}
337
338/// One keyword entry with its document-frequency count.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct KeywordCount {
341    pub keyword: String,
342    pub count: i64,
343}
344
345/// Envelope returned by `/api/locations/search`.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct LocationsResponse {
348    #[serde(default, deserialize_with = "deserialize_null_as_default")]
349    pub locations: Vec<Location>,
350    #[serde(default)]
351    pub total: i64,
352    #[serde(default)]
353    pub size: i64,
354}
355
356/// A location suggestion.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct Location {
359    pub id: String,
360    pub display_name: String,
361}
362
363/// A harvest record as returned by `/harvest_record/{id}` (metadata envelope,
364/// distinct from the transformed DCAT payload).
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct HarvestRecord {
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub id: Option<String>,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub ckan_id: Option<String>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub identifier: Option<String>,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub parent_identifier: Option<String>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub harvest_job_id: Option<String>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub harvest_source_id: Option<String>,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub action: Option<String>,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub status: Option<String>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub date_created: Option<String>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub date_finished: Option<String>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub source_hash: Option<String>,
389    /// Raw upstream payload (often a large JSON object or XML string).
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub source_raw: Option<Value>,
392    /// DCAT-US transformation of `source_raw`.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub source_transform: Option<Value>,
395}