| Crates.io | reso-client |
| lib.rs | reso-client |
| version | 0.2.1 |
| created_at | 2025-11-07 18:08:01.656582+00 |
| updated_at | 2025-11-07 18:08:01.656582+00 |
| description | Rust client library for RESO Web API (OData 4.0) servers |
| homepage | https://github.com/jeremeybingham/reso_client |
| repository | https://github.com/jeremeybingham/reso_client |
| max_upload_size | |
| id | 1921957 |
| size | 334,501 |
A Rust client library for RESO Web API servers using OData 4.0.
⚠️ Some features not supported by the RESO Web API / actris_ref test server.
Pending Cargo crate publication, use the Github repository to install - add to your Cargo.toml:
[dependencies]
# Import the RESO client from GitHub
reso-client = { git = "https://github.com/jeremeybingham/reso_client" }
The client reads configuration from environment variables:
| Variable | Required | Description | Example |
|---|---|---|---|
RESO_BASE_URL |
Yes | Base URL of the RESO Web API server | https://api.bridgedataoutput.com/api/v2/OData |
RESO_TOKEN |
Yes | OAuth bearer token for authentication | your-token-here |
RESO_DATASET_ID |
No | Dataset identifier (see below) | actris_ref |
RESO_TIMEOUT |
No | HTTP timeout in seconds (default: 30) | 60 |
Create a .env file:
RESO_BASE_URL=https://api.bridgedataoutput.com/api/v2/OData
RESO_TOKEN=your-token-here
RESO_DATASET_ID=actris_ref
RESO_TIMEOUT=30
Configured to handle the Bridges/ACTRIS RESO Web API Reference Server, which uses a dataset identifier in the URL path. The dataset ID is inserted between the base URL and the resource name.
Without dataset ID:
https://api.mls.com/OData/Property
With dataset ID:
https://api.mls.com/OData/actris_ref/Property
https://api.mls.com/OData/actris_ref/$metadata
When to use:
You can set it via environment variable or programmatically:
// Via environment
let client = ResoClient::from_env()?;
// Via builder
let config = ClientConfig::new("https://api.mls.com/OData", "token")
.with_dataset_id("actris_ref");
let client = ResoClient::with_config(config)?;
The library includes comprehensive test coverage with both unit and integration tests:
# Run all tests (unit + integration)
cargo test
# Run only unit tests (in src/ modules)
cargo test --lib
# Run only integration tests (in tests/ directory)
cargo test --test '*'
# Run specific test file
cargo test --test queries_tests
Test Organization:
tests/ directory
tests/queries_tests.rs - Comprehensive query building and URL generation testsAll examples include detailed comments, error handling, and work with the RESO Web API reference server / actris_ref unless otherwise noted. The library includes a comprehensive suite of examples in the examples directory demonstrating all major functionality. Assuming you've set your .env variables correctly, you can run any example with:
cargo run --example <example_name>
Basic Usage:
test_connectivity - Test basic API connectivity and authenticationtest_property - Property resource queries with filtering and field selectiontest_member - Query Member resource for agent/broker informationtest_metadata - Fetch and parse OData metadata documentstest_core_queries - Tests the "Core Queries" specified in the RESO Web API reference documentationQuery Features:
test_filters - OData filter syntax (comparison, logical operators, string functions)test_select - Field selection and projection to optimize response sizeAnalysis Examples:
analyze_property_fields - Analyze field usage across 200 active listings to identify which fields are most populated; generates property_field_analysis_report.json with recommended field sets (minimal, standard, comprehensive)
analyze_active_listings - Statistical analysis of 200 active residential listings including price analysis, property type distribution, geographic distribution, bedroom/bathroom statistics, size metrics, and photo counts
⚠️ Server-Specific (currently untestested, requires server support):
The $count, $apply, and $expand features are not supported by the RESO Web API test server / actris_ref. The Replication endpoint is also not supported by default on actris_ref. Examples using these features will fail with 404 or 401 errors.
⚠️ test_replication - Replication endpoint for bulk data transfer (up to 2000 records/request)
⚠️ test_count_only - Efficient count-only queries using /$count endpoint
⚠️ test_pagination_nextlink - Server-side pagination with @odata.nextLink
⚠️ test_apply - OData aggregation with $apply parameter
⚠️ test_expand - Navigation property expansion with $expand parameter
use reso_client::{ResoClient, QueryBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create client from environment variables
let client = ResoClient::from_env()?;
// Build and execute a query
let query = QueryBuilder::new("Property")
.filter("City eq 'Austin' and ListPrice gt 500000")
.select(&["ListingKey", "City", "ListPrice"])
.top(10)
.build()?;
let results = client.execute(&query).await?;
// OData responses have structure: { "value": [...records...], "@odata.count": 123 }
if let Some(records) = results["value"].as_array() {
println!("Found {} properties", records.len());
for record in records {
println!("{}", serde_json::to_string_pretty(record)?);
}
}
Ok(())
}
use reso_client::{ResoClient, ReplicationQueryBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ResoClient::from_env()?;
// Build replication query (up to 2000 records per request)
let query = ReplicationQueryBuilder::new("Property")
.filter("StandardStatus eq 'Active'")
.top(2000)
.build()?;
let response = client.execute_replication(&query).await?;
println!("Retrieved {} records", response.record_count);
// Continue with next link if available
if response.has_more() {
let next_response = client.execute_next_link(
response.next_link().unwrap()
).await?;
println!("Retrieved {} more records", next_response.record_count);
}
Ok(())
}
let query = QueryBuilder::new("Property")
.top(10)
.build()?;
let results = client.execute(&query).await?;
Use OData 4.0 filter syntax:
// Simple equality
let query = QueryBuilder::new("Property")
.filter("City eq 'Austin'")
.build()?;
// Comparison operators
let query = QueryBuilder::new("Property")
.filter("ListPrice gt 500000 and ListPrice lt 1000000")
.build()?;
// String functions
let query = QueryBuilder::new("Property")
.filter("startswith(City, 'San')")
.build()?;
// Date comparison
let query = QueryBuilder::new("Property")
.filter("ModificationTimestamp gt 2025-01-01T00:00:00Z")
.build()?;
// Complex expressions
let query = QueryBuilder::new("Property")
.filter("City eq 'Austin' and (ListPrice gt 500000 or BedroomsTotal ge 4)")
.build()?;
let query = QueryBuilder::new("Property")
.select(&["ListingKey", "City", "ListPrice", "BedroomsTotal"])
.top(10)
.build()?;
let query = QueryBuilder::new("Property")
.order_by("ListPrice", "desc")
.top(10)
.build()?;
// First page
let query = QueryBuilder::new("Property")
.top(20)
.build()?;
// Second page
let query = QueryBuilder::new("Property")
.skip(20)
.top(20)
.build()?;
let query = QueryBuilder::new("Property")
.filter("City eq 'Austin'")
.with_count()
.top(10)
.build()?;
let results = client.execute(&query).await?;
// Access the count
if let Some(count) = results["@odata.count"].as_u64() {
println!("Total matching records: {}", count);
}
Efficiently get just the count without fetching records:
let query = QueryBuilder::new("Property")
.filter("City eq 'Austin'")
.count() // Returns just the count via /$count endpoint
.build()?;
let results = client.execute(&query).await?;
let count = results.as_u64().unwrap_or(0);
println!("Total: {}", count);
⚠️ Server Compatibility, NOT supported by the RESO Web API reference server / actris_ref Note: The apply() method requires server support for OData v4.0 Aggregation Extensions. Not all RESO servers support this feature.
// Group by field with aggregation (if server supports $apply)
let query = QueryBuilder::new("Property")
.apply("groupby((StandardStatus), aggregate($count as TotalCount))")
.build()?;
let results = client.execute(&query).await?;
If your server doesn't support $apply, use multiple filtered queries instead:
⚠️ This is the method supported by the RESO Web API reference server / actris_ref
// Workaround: Use $filter for counts by category
let statuses = ["Active", "Pending", "Closed"];
for status in statuses {
let query = QueryBuilder::new("Property")
.filter(format!("StandardStatus eq '{}'", status))
.count()
.build()?;
let results = client.execute(&query).await?;
let count = results.as_u64().unwrap_or(0);
println!("{}: {}", status, count);
}
Retrieve the OData metadata document:
let metadata_xml = client.fetch_metadata().await?;
println!("{}", metadata_xml);
The replication endpoint is designed for bulk data transfer and synchronization of large datasets (>10,000 records). It supports up to 2000 records per request (vs 200 for standard queries) and uses header-based pagination.
Important notes:
$skip, $orderby, $apply, or count options$select to reduce payload size and improve performanceuse reso_client::{ResoClient, ReplicationQueryBuilder};
// Build a replication query
let query = ReplicationQueryBuilder::new("Property")
.filter("StandardStatus eq 'Active'")
.select(&["ListingKey", "City", "ListPrice"])
.top(2000) // Maximum: 2000
.build()?;
// Execute the query
let response = client.execute_replication(&query).await?;
println!("Retrieved {} records", response.record_count);
// Process records
for record in &response.records {
let key = record["ListingKey"].as_str().unwrap_or("");
let city = record["City"].as_str().unwrap_or("");
println!("{}: {}", key, city);
}
// Continue with next link if more records available
if let Some(next_link) = response.next_link {
let next_response = client.execute_next_link(&next_link).await?;
println!("Retrieved {} more records", next_response.record_count);
}
Fetching all records with pagination:
let mut query = ReplicationQueryBuilder::new("Property")
.top(2000)
.build()?;
let mut response = client.execute_replication(&query).await?;
let mut all_records = response.records;
// Continue fetching while next link is available
while let Some(next_link) = response.next_link {
response = client.execute_next_link(&next_link).await?;
all_records.extend(response.records);
}
println!("Total records fetched: {}", all_records.len());
The RESO Web API returns responses in OData format:
{
"value": [
{
"ListingKey": "12345",
"City": "Austin",
"ListPrice": 750000
},
{
"ListingKey": "67890",
"City": "Austin",
"ListPrice": 850000
}
],
"@odata.context": "https://api.example.com/odata/$metadata#Property",
"@odata.count": 42
}
Key fields:
value: Array of records matching your query@odata.count: Total count (only when with_count() is used)@odata.nextLink: URL for next page (for server-side pagination)Access records:
let results = client.execute(&query).await?;
if let Some(records) = results["value"].as_array() {
for record in records {
let listing_key = record["ListingKey"].as_str();
let price = record["ListPrice"].as_f64();
// ... process record
}
}
use reso_client::{ResoClient, ResoError};
match client.execute(&query).await {
Ok(results) => {
// Process results
}
Err(ResoError::Config(msg)) => {
eprintln!("Configuration error: {}", msg);
}
Err(ResoError::Network(msg)) => {
eprintln!("Network error: {}", msg);
}
Err(ResoError::ODataError(msg)) => {
eprintln!("OData server error: {}", msg);
}
Err(e) => {
eprintln!("Error: {}", e);
}
}
use std::time::Duration;
let config = ClientConfig::new("https://api.mls.com/OData", "token")
.with_timeout(Duration::from_secs(60));
let client = ResoClient::with_config(config)?;
let config = ClientConfig::new(
"https://api.mls.com/odata",
"your-bearer-token"
)
.with_dataset_id("actris_ref")
.with_timeout(Duration::from_secs(45));
let client = ResoClient::with_config(config)?;
Common OData 4.0 operators:
| Operator | Description | Example |
|---|---|---|
eq |
Equals | City eq 'Austin' |
ne |
Not equals | Status ne 'Closed' |
gt |
Greater than | ListPrice gt 500000 |
ge |
Greater than or equal | BedroomsTotal ge 3 |
lt |
Less than | ListPrice lt 1000000 |
le |
Less than or equal | BedroomsTotal le 5 |
and |
Logical AND | City eq 'Austin' and ListPrice gt 500000 |
or |
Logical OR | City eq 'Austin' or City eq 'Manor' |
not |
Logical NOT | not (City eq 'Austin') |
String functions:
startswith(field, 'value')endswith(field, 'value')contains(field, 'value')Date functions:
year(field) eq 2025month(field) eq 6day(field) eq 15For complete OData 4.0 filter syntax, see: OData URL Conventions
Licensed under the terms of the MIT license. See the file: