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.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
mod apps;
|
mod apps;
|
||||||
mod fields;
|
mod fields;
|
||||||
mod ping;
|
mod ping;
|
||||||
|
mod records;
|
||||||
|
|||||||
@@ -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<PagingRequest>,
|
||||||
|
field_ids: Option<&[i32]>,
|
||||||
|
data_format: Option<DataFormat>,
|
||||||
|
) -> Result<PagedResponse<Record>> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<DataFormat>,
|
||||||
|
) -> Result<Record> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<SaveRecordResponse> {
|
||||||
|
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<CollectionResponse<Record>> {
|
||||||
|
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<PagingRequest>,
|
||||||
|
) -> Result<PagedResponse<Record>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ mod app;
|
|||||||
mod enums;
|
mod enums;
|
||||||
mod field;
|
mod field;
|
||||||
mod paging;
|
mod paging;
|
||||||
|
pub mod record;
|
||||||
|
|
||||||
pub use app::*;
|
pub use app::*;
|
||||||
pub use enums::*;
|
pub use enums::*;
|
||||||
pub use field::*;
|
pub use field::*;
|
||||||
pub use paging::*;
|
pub use paging::*;
|
||||||
|
pub use record::*;
|
||||||
|
|||||||
@@ -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<Vec<RecordFieldValue>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i32>,
|
||||||
|
pub fields: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from saving a record.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SaveRecordResponse {
|
||||||
|
pub id: i32,
|
||||||
|
pub warnings: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<i32>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data_format: Option<DataFormat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub field_ids: Option<Vec<i32>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data_format: Option<DataFormat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i32>,
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user