Skip to main content

raps_acc/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Shared types for ACC/BIM 360 API responses
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Deserializer, Serialize};
8
9/// Deserialize a field as Option<String>, gracefully handling non-string values
10/// (e.g., objects, arrays) by returning None instead of failing.
11/// This is needed because APS API responses sometimes return objects where the
12/// OpenAPI spec says string.
13fn string_or_none<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
14    let value: Option<serde_json::Value> = Option::deserialize(d)?;
15    Ok(value.and_then(|v| match v {
16        serde_json::Value::String(s) => Some(s),
17        _ => None,
18    }))
19}
20
21// ============================================================================
22// PAGINATION
23// ============================================================================
24
25/// Paginated API response wrapper
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct PaginatedResponse<T> {
28    /// Results for the current page
29    pub results: Vec<T>,
30    /// Pagination metadata
31    pub pagination: PaginationInfo,
32}
33
34/// Pagination metadata from API responses
35#[derive(Debug, Clone, Deserialize, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct PaginationInfo {
38    /// Maximum items per page
39    pub limit: usize,
40    /// Current offset (starting index)
41    pub offset: usize,
42    /// Total number of results available
43    pub total_results: usize,
44}
45
46impl<T> PaginatedResponse<T> {
47    /// Check if there are more pages available
48    pub fn has_more(&self) -> bool {
49        self.pagination.offset + self.results.len() < self.pagination.total_results
50    }
51
52    /// Get the offset for the next page
53    pub fn next_offset(&self) -> usize {
54        self.pagination.offset + self.pagination.limit
55    }
56
57    /// Check if this is the first page
58    pub fn is_first_page(&self) -> bool {
59        self.pagination.offset == 0
60    }
61}
62
63// ============================================================================
64// PROJECT CLASSIFICATION
65// ============================================================================
66
67/// Project classification (production, template, etc.)
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
69#[serde(rename_all = "lowercase")]
70pub enum ProjectClassification {
71    /// Production project
72    Production,
73    /// Template project (used as a source for creating new projects)
74    Template,
75    /// Component project
76    Component,
77    /// Sample project
78    Sample,
79}
80
81impl std::fmt::Display for ProjectClassification {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Self::Production => write!(f, "production"),
85            Self::Template => write!(f, "template"),
86            Self::Component => write!(f, "component"),
87            Self::Sample => write!(f, "sample"),
88        }
89    }
90}
91
92// ============================================================================
93// ACCOUNT TYPES
94// ============================================================================
95
96/// Account/Hub information
97#[derive(Debug, Clone, Deserialize, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct AccountInfo {
100    /// Account ID (e.g., "b.account-uuid")
101    pub id: String,
102    /// Account name
103    pub name: String,
104    /// Account region
105    #[serde(default)]
106    pub region: Option<String>,
107}
108
109/// User within an Autodesk account
110#[derive(Debug, Clone, Deserialize, Serialize)]
111#[serde(rename_all = "camelCase")]
112pub struct AccountUser {
113    /// User ID (Autodesk user identifier)
114    pub id: String,
115    /// User's email address
116    pub email: String,
117    /// User's display name
118    #[serde(default)]
119    pub name: Option<String>,
120    /// User's first name
121    #[serde(default)]
122    pub first_name: Option<String>,
123    /// User's last name
124    #[serde(default)]
125    pub last_name: Option<String>,
126    /// Company ID if associated
127    #[serde(default)]
128    pub company_id: Option<String>,
129    /// User status in the account
130    #[serde(default)]
131    pub status: Option<String>,
132    /// When the user was added to the account
133    #[serde(default)]
134    pub added_on: Option<DateTime<Utc>>,
135}
136
137impl AccountUser {
138    /// Get the user's display name, falling back to email if not available
139    pub fn display_name(&self) -> &str {
140        self.name.as_deref().unwrap_or(&self.email)
141    }
142}
143
144// ============================================================================
145// PROJECT TYPES
146// ============================================================================
147
148/// Project within an account
149#[derive(Debug, Clone, Deserialize, Serialize)]
150#[serde(rename_all = "camelCase")]
151pub struct AccountProject {
152    /// Project ID (e.g., "b.project-uuid")
153    pub id: String,
154    /// Project name
155    pub name: String,
156    /// Project status (active, inactive, archived)
157    #[serde(default, deserialize_with = "string_or_none")]
158    pub status: Option<String>,
159    /// Platform type (ACC or BIM360)
160    #[serde(default, deserialize_with = "string_or_none")]
161    pub platform: Option<String>,
162    /// Account ID this project belongs to
163    #[serde(default, deserialize_with = "string_or_none")]
164    pub account_id: Option<String>,
165    /// Project creation date
166    #[serde(default)]
167    pub created_at: Option<DateTime<Utc>>,
168    /// Last update date
169    #[serde(default)]
170    pub updated_at: Option<DateTime<Utc>>,
171    /// Project type (e.g., "ACC", "BIM 360")
172    #[serde(default, deserialize_with = "string_or_none", alias = "projectType")]
173    pub project_type: Option<String>,
174    /// Project classification (production, template, etc.)
175    #[serde(default)]
176    pub classification: Option<ProjectClassification>,
177    /// Number of members in the project
178    #[serde(default)]
179    pub member_count: Option<usize>,
180    /// Number of companies in the project
181    #[serde(default)]
182    pub company_count: Option<usize>,
183    /// Products enabled for this project (API returns objects with key/access fields)
184    #[serde(default)]
185    pub products: Option<Vec<serde_json::Value>>,
186}
187
188impl AccountProject {
189    /// Check if this is an ACC project
190    pub fn is_acc(&self) -> bool {
191        self.platform
192            .as_ref()
193            .map(|p| p.to_lowercase() == "acc")
194            .unwrap_or(false)
195            || self
196                .project_type
197                .as_ref()
198                .map(|t| t.to_lowercase().contains("acc"))
199                .unwrap_or(false)
200    }
201
202    /// Check if this is a BIM 360 project
203    pub fn is_bim360(&self) -> bool {
204        self.platform
205            .as_ref()
206            .map(|p| p.to_lowercase().contains("bim360") || p.to_lowercase().contains("bim 360"))
207            .unwrap_or(false)
208            || self
209                .project_type
210                .as_ref()
211                .map(|t| t.to_lowercase().contains("bim"))
212                .unwrap_or(false)
213    }
214
215    /// Check if the project is active
216    pub fn is_active(&self) -> bool {
217        self.status
218            .as_ref()
219            .map(|s| s.to_lowercase() == "active")
220            .unwrap_or(true)
221    }
222
223    /// Check if this is a template project
224    pub fn is_template(&self) -> bool {
225        self.classification == Some(ProjectClassification::Template)
226    }
227
228    /// Get the list of enabled product keys for this project
229    pub fn enabled_products(&self) -> Vec<String> {
230        self.products
231            .as_ref()
232            .map(|products| {
233                products
234                    .iter()
235                    .filter_map(|v| {
236                        // API returns objects like {"key": "docs", "access": "administrator"}
237                        v.as_object()
238                            .and_then(|obj| obj.get("key"))
239                            .and_then(|k| k.as_str())
240                            .map(String::from)
241                            // Fallback: if it's a plain string (shouldn't happen but be safe)
242                            .or_else(|| v.as_str().map(String::from))
243                    })
244                    .collect()
245            })
246            .unwrap_or_default()
247    }
248}
249
250// ============================================================================
251// PROJECT USER TYPES
252// ============================================================================
253
254/// User's membership in a specific project
255#[derive(Debug, Clone, Deserialize, Serialize)]
256#[serde(rename_all = "camelCase")]
257pub struct ProjectUser {
258    /// User ID
259    pub id: String,
260    /// User's email
261    #[serde(default)]
262    pub email: Option<String>,
263    /// User's display name
264    #[serde(default)]
265    pub name: Option<String>,
266    /// Role ID assigned in this project
267    #[serde(default)]
268    pub role_id: Option<String>,
269    /// Role name
270    #[serde(default)]
271    pub role_name: Option<String>,
272    /// Access levels for various products
273    #[serde(default)]
274    pub products: Option<Vec<ProductAccess>>,
275    /// When user was added to the project
276    #[serde(default)]
277    pub added_on: Option<DateTime<Utc>>,
278}
279
280/// Product access configuration for a project user
281#[derive(Debug, Clone, Deserialize, Serialize)]
282#[serde(rename_all = "camelCase")]
283pub struct ProductAccess {
284    /// Product key (e.g., "projectAdministration", "docs", "build")
285    pub key: String,
286    /// Access level (e.g., "administrator", "member", "none")
287    pub access: String,
288}
289
290// ============================================================================
291// COMPANY TYPES
292// ============================================================================
293
294/// Company within an account
295#[derive(Debug, Clone, Deserialize, Serialize)]
296#[serde(rename_all = "camelCase")]
297pub struct Company {
298    /// Company ID
299    pub id: String,
300    /// Company name
301    pub name: String,
302    /// Trade or discipline
303    #[serde(default)]
304    pub trade: Option<String>,
305    /// Address line 1
306    #[serde(default)]
307    pub address_line1: Option<String>,
308    /// City
309    #[serde(default)]
310    pub city: Option<String>,
311    /// State or province
312    #[serde(default)]
313    pub state_or_province: Option<String>,
314    /// Country
315    #[serde(default)]
316    pub country: Option<String>,
317    /// Number of members in the company
318    #[serde(default)]
319    pub member_count: Option<usize>,
320}
321
322// ============================================================================
323// FOLDER PERMISSION TYPES
324// ============================================================================
325
326/// Permission on a folder
327#[derive(Debug, Clone, Deserialize, Serialize)]
328#[serde(rename_all = "camelCase")]
329pub struct FolderPermission {
330    /// Permission ID
331    pub id: String,
332    /// Subject ID (user, role, or company ID)
333    pub subject_id: String,
334    /// Subject type
335    pub subject_type: SubjectType,
336    /// Permitted actions
337    pub actions: Vec<String>,
338}
339
340/// Type of subject for permissions
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
342#[serde(rename_all = "lowercase")]
343pub enum SubjectType {
344    /// Individual user
345    User,
346    /// Role-based permission
347    Role,
348    /// Company-wide permission
349    Company,
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_paginated_response_has_more() {
358        let response: PaginatedResponse<String> = PaginatedResponse {
359            results: vec!["a".to_string(), "b".to_string()],
360            pagination: PaginationInfo {
361                limit: 2,
362                offset: 0,
363                total_results: 10,
364            },
365        };
366        assert!(response.has_more());
367        assert_eq!(response.next_offset(), 2);
368    }
369
370    #[test]
371    fn test_paginated_response_last_page() {
372        let response: PaginatedResponse<String> = PaginatedResponse {
373            results: vec!["a".to_string()],
374            pagination: PaginationInfo {
375                limit: 2,
376                offset: 8,
377                total_results: 9,
378            },
379        };
380        assert!(!response.has_more());
381    }
382
383    #[test]
384    fn test_account_project_is_acc() {
385        let project = AccountProject {
386            id: "b.123".to_string(),
387            name: "Test".to_string(),
388            platform: Some("ACC".to_string()),
389            status: None,
390            account_id: None,
391            created_at: None,
392            updated_at: None,
393            project_type: None,
394            classification: None,
395            member_count: None,
396            company_count: None,
397            products: None,
398        };
399        assert!(project.is_acc());
400        assert!(!project.is_bim360());
401    }
402
403    #[test]
404    fn test_account_project_is_bim360() {
405        let project = AccountProject {
406            id: "b.123".to_string(),
407            name: "Test".to_string(),
408            platform: Some("BIM 360".to_string()),
409            status: None,
410            account_id: None,
411            created_at: None,
412            updated_at: None,
413            project_type: None,
414            classification: None,
415            member_count: None,
416            company_count: None,
417            products: None,
418        };
419        assert!(!project.is_acc());
420        assert!(project.is_bim360());
421    }
422
423    #[test]
424    fn test_project_classification_display() {
425        assert_eq!(
426            format!("{}", ProjectClassification::Production),
427            "production"
428        );
429        assert_eq!(format!("{}", ProjectClassification::Template), "template");
430        assert_eq!(format!("{}", ProjectClassification::Component), "component");
431        assert_eq!(format!("{}", ProjectClassification::Sample), "sample");
432    }
433
434    #[test]
435    fn test_account_project_is_template() {
436        let project = AccountProject {
437            id: "b.123".to_string(),
438            name: "Template Project".to_string(),
439            platform: Some("ACC".to_string()),
440            classification: Some(ProjectClassification::Template),
441            status: None,
442            account_id: None,
443            created_at: None,
444            updated_at: None,
445            project_type: None,
446            member_count: None,
447            company_count: None,
448            products: None,
449        };
450        assert!(project.is_template());
451    }
452
453    #[test]
454    fn test_account_project_not_template() {
455        let project = AccountProject {
456            id: "b.123".to_string(),
457            name: "Real Project".to_string(),
458            platform: Some("ACC".to_string()),
459            classification: Some(ProjectClassification::Production),
460            status: None,
461            account_id: None,
462            created_at: None,
463            updated_at: None,
464            project_type: None,
465            member_count: None,
466            company_count: None,
467            products: None,
468        };
469        assert!(!project.is_template());
470    }
471
472    #[test]
473    fn test_account_project_enabled_products() {
474        let project = AccountProject {
475            id: "b.123".to_string(),
476            name: "Test".to_string(),
477            platform: None,
478            products: Some(vec![
479                serde_json::json!({"key": "docs", "access": "administrator"}),
480                serde_json::json!({"key": "build", "access": "member"}),
481                serde_json::json!({"key": "modelCoordination", "access": "member"}),
482            ]),
483            status: None,
484            account_id: None,
485            created_at: None,
486            updated_at: None,
487            project_type: None,
488            classification: None,
489            member_count: None,
490            company_count: None,
491        };
492        let enabled = project.enabled_products();
493        assert_eq!(enabled.len(), 3);
494        assert!(enabled.contains(&"docs".to_string()));
495    }
496
497    #[test]
498    fn test_account_project_no_products() {
499        let project = AccountProject {
500            id: "b.123".to_string(),
501            name: "Test".to_string(),
502            platform: None,
503            products: None,
504            status: None,
505            account_id: None,
506            created_at: None,
507            updated_at: None,
508            project_type: None,
509            classification: None,
510            member_count: None,
511            company_count: None,
512        };
513        assert!(project.enabled_products().is_empty());
514    }
515
516    #[test]
517    fn test_company_deserialization() {
518        let json = r#"{
519            "id": "comp-123",
520            "name": "Acme Corp",
521            "trade": "General Contractor",
522            "city": "Portland",
523            "country": "US",
524            "memberCount": 42
525        }"#;
526        let company: Company = serde_json::from_str(json).unwrap();
527        assert_eq!(company.id, "comp-123");
528        assert_eq!(company.name, "Acme Corp");
529        assert_eq!(company.trade.unwrap(), "General Contractor");
530        assert_eq!(company.member_count.unwrap(), 42);
531    }
532
533    #[test]
534    fn test_company_deserialization_minimal() {
535        let json = r#"{"id": "comp-456", "name": "Minimal Co"}"#;
536        let company: Company = serde_json::from_str(json).unwrap();
537        assert_eq!(company.id, "comp-456");
538        assert!(company.trade.is_none());
539        assert!(company.member_count.is_none());
540    }
541}