From 46c54b4589683084cbb8618342c5c25d183350f0 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:40:11 -0500 Subject: [PATCH] feat: add fields endpoint with get, batch-get, and list methods Implement Field model supporting base fields and polymorphic variants (list, formula, reference fields via optional properties). Add get_field, batch_get_fields, and list_fields endpoint methods with tests covering basic fields, list-type fields, batch retrieval, pagination, and not-found scenarios. --- src/endpoints/fields.rs | 37 +++++++++++ src/endpoints/mod.rs | 1 + src/models/field.rs | 35 ++++++++++ src/models/mod.rs | 2 + tests/test_fields.rs | 142 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 src/endpoints/fields.rs create mode 100644 src/models/field.rs create mode 100644 tests/test_fields.rs diff --git a/src/endpoints/fields.rs b/src/endpoints/fields.rs new file mode 100644 index 0000000..7b66f14 --- /dev/null +++ b/src/endpoints/fields.rs @@ -0,0 +1,37 @@ +use reqwest::Method; + +use crate::client::OnspringClient; +use crate::error::Result; +use crate::models::{CollectionResponse, Field, PagedResponse, PagingRequest}; + +impl OnspringClient { + /// Gets a field by its identifier. + pub async fn get_field(&self, field_id: i32) -> Result { + let path = format!("/Fields/id/{}", field_id); + self.request(Method::GET, &path, &[], Option::<&()>::None) + .await + } + + /// Gets up to 100 fields by their identifiers. + pub async fn batch_get_fields(&self, ids: &[i32]) -> Result> { + self.request(Method::POST, "/Fields/batch-get", &[], Some(&ids)) + .await + } + + /// Gets a paginated list of fields for a given application. + pub async fn list_fields( + &self, + app_id: i32, + paging: Option, + ) -> Result> { + let path = format!("/Fields/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/endpoints/mod.rs b/src/endpoints/mod.rs index 5d163cf..1ed19b6 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -1,2 +1,3 @@ mod apps; +mod fields; mod ping; diff --git a/src/models/field.rs b/src/models/field.rs new file mode 100644 index 0000000..05c571c --- /dev/null +++ b/src/models/field.rs @@ -0,0 +1,35 @@ +use serde::Deserialize; +use uuid::Uuid; + +use super::enums::{FormulaOutputType, Multiplicity}; + +/// Represents a field in an Onspring application. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Field { + pub id: i32, + pub app_id: i32, + pub name: Option, + #[serde(rename = "type")] + pub field_type: Option, + pub status: Option, + pub is_required: bool, + pub is_unique: bool, + pub multiplicity: Option, + pub list_id: Option, + pub values: Option>, + #[serde(rename = "outputType")] + pub output_type: Option, + pub referenced_app_id: Option, +} + +/// Represents a value in a list field. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListFieldValue { + pub id: Uuid, + pub name: String, + pub sort_order: i32, + pub numeric_value: Option, + pub color: Option, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index e2ff6ed..a6f2eb0 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,7 +1,9 @@ mod app; mod enums; +mod field; mod paging; pub use app::*; pub use enums::*; +pub use field::*; pub use paging::*; diff --git a/tests/test_fields.rs b/tests/test_fields.rs new file mode 100644 index 0000000..a9da994 --- /dev/null +++ b/tests/test_fields.rs @@ -0,0 +1,142 @@ +mod common; + +use wiremock::matchers::{body_json, method, path, query_param}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test] +async fn test_get_field_success() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Fields/id/100")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 100, + "appId": 1, + "name": "Text Field", + "type": "Text", + "status": "Enabled", + "isRequired": true, + "isUnique": false + }))) + .mount(&mock_server) + .await; + + let field = client.get_field(100).await.unwrap(); + assert_eq!(field.id, 100); + assert_eq!(field.app_id, 1); + assert_eq!(field.name.as_deref(), Some("Text Field")); + assert!(field.is_required); + assert!(!field.is_unique); +} + +#[tokio::test] +async fn test_get_field_list_type() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Fields/id/200")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 200, + "appId": 1, + "name": "Status", + "type": "List", + "status": "Enabled", + "isRequired": false, + "isUnique": false, + "multiplicity": "SingleSelect", + "listId": 50, + "values": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Active", + "sortOrder": 1, + "numericValue": 1.0, + "color": "#00ff00" + } + ] + }))) + .mount(&mock_server) + .await; + + let field = client.get_field(200).await.unwrap(); + assert_eq!( + field.multiplicity, + Some(onspring::Multiplicity::SingleSelect) + ); + assert_eq!(field.list_id, Some(50)); + let values = field.values.unwrap(); + assert_eq!(values.len(), 1); + assert_eq!(values[0].name, "Active"); +} + +#[tokio::test] +async fn test_batch_get_fields() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("POST")) + .and(path("/Fields/batch-get")) + .and(body_json(serde_json::json!([1, 2]))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 2, + "items": [ + {"id": 1, "appId": 1, "name": "Field 1", "type": "Text", "status": "Enabled", "isRequired": false, "isUnique": false}, + {"id": 2, "appId": 1, "name": "Field 2", "type": "Number", "status": "Enabled", "isRequired": true, "isUnique": false} + ] + }))) + .mount(&mock_server) + .await; + + let result = client.batch_get_fields(&[1, 2]).await.unwrap(); + assert_eq!(result.count, Some(2)); + assert_eq!(result.items.unwrap().len(), 2); +} + +#[tokio::test] +async fn test_list_fields_for_app() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Fields/appId/5")) + .and(query_param("PageNumber", "1")) + .and(query_param("PageSize", "25")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pageNumber": 1, + "pageSize": 25, + "totalPages": 1, + "totalRecords": 3, + "items": [ + {"id": 10, "appId": 5, "name": "Name", "type": "Text", "status": "Enabled", "isRequired": true, "isUnique": false}, + {"id": 11, "appId": 5, "name": "Email", "type": "Text", "status": "Enabled", "isRequired": false, "isUnique": true}, + {"id": 12, "appId": 5, "name": "Notes", "type": "Text", "status": "Enabled", "isRequired": false, "isUnique": false} + ] + }))) + .mount(&mock_server) + .await; + + let paging = onspring::PagingRequest { + page_number: 1, + page_size: 25, + }; + let result = client.list_fields(5, Some(paging)).await.unwrap(); + assert_eq!(result.total_records, Some(3)); + assert_eq!(result.items.unwrap().len(), 3); +} + +#[tokio::test] +async fn test_get_field_not_found() { + let (mock_server, client) = common::setup().await; + + Mock::given(method("GET")) + .and(path("/Fields/id/999")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let result = client.get_field(999).await; + assert!(result.is_err()); + if let Err(onspring::OnspringError::Api { status_code, .. }) = result { + assert_eq!(status_code, 404); + } else { + panic!("Expected Api error"); + } +}