From fa81603c63a6c4e080bfaca703bb3050b403a8e9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:47:11 -0500 Subject: [PATCH] feat: add reports endpoint with get report and list reports methods Implement ReportInfo, ReportData, and ReportRow models. Add get_report (with optional data format and data type params) and list_reports (paginated) methods. Tests cover report data retrieval, format parameters, listing reports by app, and not-found error. --- src/endpoints/mod.rs | 1 + src/endpoints/reports.rs | 46 ++++++++++++++++++ src/models/mod.rs | 2 + src/models/report.rs | 27 +++++++++++ tests/test_reports.rs | 101 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 src/endpoints/reports.rs create mode 100644 src/models/report.rs create mode 100644 tests/test_reports.rs diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index a5a260b..bbc8d4e 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -4,3 +4,4 @@ mod files; mod lists; mod ping; mod records; +mod reports; diff --git a/src/endpoints/reports.rs b/src/endpoints/reports.rs new file mode 100644 index 0000000..6ca490c --- /dev/null +++ b/src/endpoints/reports.rs @@ -0,0 +1,46 @@ +use reqwest::Method; + +use crate::client::OnspringClient; +use crate::error::Result; +use crate::models::{ + DataFormat, PagedResponse, PagingRequest, ReportData, ReportDataType, ReportInfo, +}; + +impl OnspringClient { + /// Gets report data by report ID. + pub async fn get_report( + &self, + report_id: i32, + data_format: Option, + data_type: Option, + ) -> Result { + let path = format!("/Reports/id/{}", report_id); + let mut query = Vec::new(); + if let Some(fmt) = data_format { + query.push(("apiDataFormat", format!("{:?}", fmt))); + } + if let Some(dt) = data_type { + query.push(("dataType", format!("{:?}", dt))); + } + let query_refs: Vec<(&str, String)> = query.iter().map(|(k, v)| (*k, v.clone())).collect(); + self.request(Method::GET, &path, &query_refs, Option::<&()>::None) + .await + } + + /// Gets a paginated list of reports for a given application. + pub async fn list_reports( + &self, + app_id: i32, + paging: Option, + ) -> Result> { + let path = format!("/Reports/appId/{}", app_id); + let mut query = Vec::new(); + if let Some(p) = paging { + query.push(("PageNumber", p.page_number.to_string())); + query.push(("PageSize", p.page_size.to_string())); + } + let query_refs: Vec<(&str, String)> = query.iter().map(|(k, v)| (*k, v.clone())).collect(); + self.request(Method::GET, &path, &query_refs, Option::<&()>::None) + .await + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 9379529..abe5c44 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,6 +5,7 @@ pub mod file; pub mod list; mod paging; pub mod record; +mod report; pub use app::*; pub use enums::*; @@ -13,3 +14,4 @@ pub use file::*; pub use list::*; pub use paging::*; pub use record::*; +pub use report::*; diff --git a/src/models/report.rs b/src/models/report.rs new file mode 100644 index 0000000..cc8370e --- /dev/null +++ b/src/models/report.rs @@ -0,0 +1,27 @@ +use serde::Deserialize; + +/// Represents a report associated to an app (used in list responses). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportInfo { + pub app_id: i32, + pub id: i32, + pub name: Option, + pub description: Option, +} + +/// Represents the data returned by a report. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportData { + pub columns: Option>, + pub rows: Option>, +} + +/// Represents a single row in a report. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportRow { + pub record_id: Option, + pub cells: Option>, +} diff --git a/tests/test_reports.rs b/tests/test_reports.rs new file mode 100644 index 0000000..82cce26 --- /dev/null +++ b/tests/test_reports.rs @@ -0,0 +1,101 @@ +mod common; + +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test] +async fn test_get_report() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Reports/id/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "columns": ["Record Id", "Name", "Status"], + "rows": [ + {"recordId": 100, "cells": [100, "Test", "Active"]}, + {"recordId": 101, "cells": [101, "Other", "Inactive"]} + ] + }))) + .mount(&mock_server) + .await; + + let report = client.get_report(1, None, None).await.unwrap(); + let columns = report.columns.unwrap(); + assert_eq!(columns.len(), 3); + assert_eq!(columns[0], "Record Id"); + let rows = report.rows.unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].record_id, Some(100)); +} + +#[tokio::test] +async fn test_get_report_with_format() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Reports/id/1")) + .and(query_param("apiDataFormat", "Formatted")) + .and(query_param("dataType", "ChartData")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "columns": ["Name"], + "rows": [] + }))) + .mount(&mock_server) + .await; + + let result = client + .get_report( + 1, + Some(onspring::DataFormat::Formatted), + Some(onspring::ReportDataType::ChartData), + ) + .await + .unwrap(); + assert!(result.columns.unwrap().len() == 1); +} + +#[tokio::test] +async fn test_list_reports() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Reports/appId/5")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pageNumber": 1, + "pageSize": 50, + "totalPages": 1, + "totalRecords": 2, + "items": [ + {"appId": 5, "id": 10, "name": "Report A", "description": "First report"}, + {"appId": 5, "id": 11, "name": "Report B", "description": null} + ] + }))) + .mount(&mock_server) + .await; + + let result = client.list_reports(5, None).await.unwrap(); + assert_eq!(result.total_records, Some(2)); + let items = result.items.unwrap(); + assert_eq!(items[0].id, 10); + assert_eq!(items[0].name.as_deref(), Some("Report A")); + assert_eq!(items[1].description, None); +} + +#[tokio::test] +async fn test_get_report_not_found() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Reports/id/999")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let result = client.get_report(999, None, None).await; + assert!(result.is_err()); + if let Err(onspring::OnspringError::Api { status_code, .. }) = result { + assert_eq!(status_code, 404); + } else { + panic!("Expected Api error"); + } +}