From f7050506d26196066e5d015fe1580559a6e1d62c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:42:24 -0500 Subject: [PATCH] feat: add records endpoint with all CRUD and query methods Implement Record, RecordFieldValue, SaveRecordRequest, QueryRecordsRequest, BatchGetRecordsRequest, and BatchDeleteRecordsRequest models. Add 7 endpoint methods: list_records, get_record, save_record, delete_record, batch_get_records, query_records, batch_delete_records. Add 9 tests covering all methods including parameterized queries. --- src/endpoints/mod.rs | 1 + src/endpoints/records.rs | 111 +++++++++++++++++++ src/models/mod.rs | 2 + src/models/record.rs | 74 +++++++++++++ tests/test_records.rs | 226 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 414 insertions(+) create mode 100644 src/endpoints/records.rs create mode 100644 src/models/record.rs create mode 100644 tests/test_records.rs diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index 1ed19b6..64644c5 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -1,3 +1,4 @@ mod apps; mod fields; mod ping; +mod records; diff --git a/src/endpoints/records.rs b/src/endpoints/records.rs new file mode 100644 index 0000000..e756d03 --- /dev/null +++ b/src/endpoints/records.rs @@ -0,0 +1,111 @@ +use reqwest::Method; + +use crate::client::OnspringClient; +use crate::error::Result; +use crate::models::{ + BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, DataFormat, + PagedResponse, PagingRequest, QueryRecordsRequest, Record, SaveRecordRequest, + SaveRecordResponse, +}; + +impl OnspringClient { + /// Gets a paginated collection of records for a given app. + pub async fn list_records( + &self, + app_id: i32, + paging: Option, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> Result> { + let path = format!("/Records/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())); + } + if let Some(ids) = field_ids { + let ids_str = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + query.push(("fieldIds", ids_str)); + } + if let Some(fmt) = data_format { + query.push(("dataFormat", format!("{:?}", fmt))); + } + 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 record by its identifier. + pub async fn get_record( + &self, + app_id: i32, + record_id: i32, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> Result { + let path = format!("/Records/appId/{}/recordId/{}", app_id, record_id); + let mut query = Vec::new(); + if let Some(ids) = field_ids { + let ids_str = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + query.push(("fieldIds", ids_str)); + } + if let Some(fmt) = data_format { + query.push(("dataFormat", format!("{:?}", fmt))); + } + 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 + } + + /// Creates or updates a record. + pub async fn save_record(&self, request: SaveRecordRequest) -> Result { + self.request(Method::PUT, "/Records", &[], Some(&request)) + .await + } + + /// Deletes a record by its identifier. + pub async fn delete_record(&self, app_id: i32, record_id: i32) -> Result<()> { + let path = format!("/Records/appId/{}/recordId/{}", app_id, record_id); + self.request_no_content(Method::DELETE, &path, &[], Option::<&()>::None) + .await + } + + /// Gets a batch of records. + pub async fn batch_get_records( + &self, + request: BatchGetRecordsRequest, + ) -> Result> { + self.request(Method::POST, "/Records/batch-get", &[], Some(&request)) + .await + } + + /// Queries records using a filter expression. + pub async fn query_records( + &self, + request: QueryRecordsRequest, + paging: Option, + ) -> Result> { + 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::POST, "/Records/Query", &query_refs, Some(&request)) + .await + } + + /// Deletes a batch of records. + pub async fn batch_delete_records(&self, request: BatchDeleteRecordsRequest) -> Result<()> { + self.request_no_content(Method::POST, "/Records/batch-delete", &[], Some(&request)) + .await + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index a6f2eb0..e4c6ebd 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -2,8 +2,10 @@ mod app; mod enums; mod field; mod paging; +pub mod record; pub use app::*; pub use enums::*; pub use field::*; pub use paging::*; +pub use record::*; diff --git a/src/models/record.rs b/src/models/record.rs new file mode 100644 index 0000000..0727a6d --- /dev/null +++ b/src/models/record.rs @@ -0,0 +1,74 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::enums::{DataFormat, ValueType}; + +/// Represents a record in an Onspring application. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Record { + pub app_id: i32, + pub record_id: i32, + pub field_data: Option>, +} + +/// Represents a single field value within a record. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordFieldValue { + #[serde(rename = "type")] + pub value_type: ValueType, + pub field_id: i32, + pub value: serde_json::Value, +} + +/// Request to create or update a record. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveRecordRequest { + pub app_id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub record_id: Option, + pub fields: HashMap, +} + +/// Response from saving a record. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveRecordResponse { + pub id: i32, + pub warnings: Option>, +} + +/// Request to query records with a filter. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryRecordsRequest { + pub app_id: i32, + pub filter: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub field_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_format: Option, +} + +/// Request to get a batch of records. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchGetRecordsRequest { + pub app_id: i32, + pub record_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub field_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_format: Option, +} + +/// Request to delete a batch of records. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchDeleteRecordsRequest { + pub app_id: i32, + pub record_ids: Vec, +} diff --git a/tests/test_records.rs b/tests/test_records.rs new file mode 100644 index 0000000..04e252d --- /dev/null +++ b/tests/test_records.rs @@ -0,0 +1,226 @@ +mod common; + +use std::collections::HashMap; + +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test] +async fn test_list_records() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Records/appId/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pageNumber": 1, + "pageSize": 50, + "totalPages": 1, + "totalRecords": 1, + "items": [ + { + "appId": 1, + "recordId": 100, + "fieldData": [ + {"type": "String", "fieldId": 10, "value": "hello"} + ] + } + ] + }))) + .mount(&mock_server) + .await; + + let result = client.list_records(1, None, None, None).await.unwrap(); + assert_eq!(result.total_records, Some(1)); + let items = result.items.unwrap(); + assert_eq!(items[0].record_id, 100); + let field_data = items[0].field_data.as_ref().unwrap(); + assert_eq!(field_data[0].field_id, 10); + assert_eq!(field_data[0].value, serde_json::json!("hello")); +} + +#[tokio::test] +async fn test_list_records_with_params() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Records/appId/1")) + .and(query_param("fieldIds", "10,20")) + .and(query_param("dataFormat", "Formatted")) + .and(query_param("PageNumber", "2")) + .and(query_param("PageSize", "10")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pageNumber": 2, + "pageSize": 10, + "totalPages": 5, + "totalRecords": 50, + "items": [] + }))) + .mount(&mock_server) + .await; + + let paging = onspring::PagingRequest { + page_number: 2, + page_size: 10, + }; + let result = client + .list_records( + 1, + Some(paging), + Some(&[10, 20]), + Some(onspring::DataFormat::Formatted), + ) + .await + .unwrap(); + assert_eq!(result.page_number, Some(2)); +} + +#[tokio::test] +async fn test_get_record() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Records/appId/1/recordId/42")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "appId": 1, + "recordId": 42, + "fieldData": [ + {"type": "Integer", "fieldId": 5, "value": 123} + ] + }))) + .mount(&mock_server) + .await; + + let record = client.get_record(1, 42, None, None).await.unwrap(); + assert_eq!(record.record_id, 42); + assert_eq!(record.app_id, 1); +} + +#[tokio::test] +async fn test_save_record_create() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("PUT")) + .and(path("/Records")) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "id": 99, + "warnings": [] + }))) + .mount(&mock_server) + .await; + + let mut fields = HashMap::new(); + fields.insert("10".to_string(), serde_json::json!("test value")); + + let request = onspring::models::record::SaveRecordRequest { + app_id: 1, + record_id: None, + fields, + }; + let result = client.save_record(request).await.unwrap(); + assert_eq!(result.id, 99); +} + +#[tokio::test] +async fn test_delete_record() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("DELETE")) + .and(path("/Records/appId/1/recordId/42")) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + + let result = client.delete_record(1, 42).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_batch_get_records() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("POST")) + .and(path("/Records/batch-get")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 2, + "items": [ + {"appId": 1, "recordId": 1, "fieldData": []}, + {"appId": 1, "recordId": 2, "fieldData": []} + ] + }))) + .mount(&mock_server) + .await; + + let request = onspring::models::record::BatchGetRecordsRequest { + app_id: 1, + record_ids: vec![1, 2], + field_ids: None, + data_format: None, + }; + let result = client.batch_get_records(request).await.unwrap(); + assert_eq!(result.count, Some(2)); +} + +#[tokio::test] +async fn test_query_records() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("POST")) + .and(path("/Records/Query")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pageNumber": 1, + "pageSize": 50, + "totalPages": 1, + "totalRecords": 1, + "items": [ + {"appId": 1, "recordId": 5, "fieldData": []} + ] + }))) + .mount(&mock_server) + .await; + + let request = onspring::models::record::QueryRecordsRequest { + app_id: 1, + filter: "fieldId eq 'test'".to_string(), + field_ids: None, + data_format: None, + }; + let result = client.query_records(request, None).await.unwrap(); + assert_eq!(result.total_records, Some(1)); +} + +#[tokio::test] +async fn test_batch_delete_records() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("POST")) + .and(path("/Records/batch-delete")) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + + let request = onspring::models::record::BatchDeleteRecordsRequest { + app_id: 1, + record_ids: vec![1, 2, 3], + }; + let result = client.batch_delete_records(request).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_get_record_not_found() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Records/appId/1/recordId/999")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let result = client.get_record(1, 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"); + } +}