tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 6dcb15d8efaeb69eb56eb15cc396ee712ce6db68
parent cba8126bd69d38cd209799054ca721b8790a62e1
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 00:52:47 -0700

http: add listing query parser

Diffstat:
MCargo.lock | 4++++
Mcrates/tangle_runtime/Cargo.toml | 4++++
Mcrates/tangle_runtime/src/lib.rs | 520++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 527 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3926,8 +3926,12 @@ dependencies = [ "http", "serde", "serde_json", + "tangle_core", + "tangle_nips", + "tangle_protocol", "tokio", "tower", + "url", ] [[package]] diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml @@ -11,6 +11,10 @@ description = "HTTP, WebSocket, and CLI runtime surfaces for tangle" axum = "0.8" http = "1" serde = { version = "1", features = ["derive"] } +tangle_core = { path = "../tangle_core" } +tangle_nips = { path = "../tangle_nips" } +tangle_protocol = { path = "../tangle_protocol" } +url = "2" [dev-dependencies] serde_json = "1" diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -9,6 +9,13 @@ use axum::{ use core::fmt; use http::{HeaderMap, HeaderValue, StatusCode, header}; use serde::{Deserialize, Serialize}; +use tangle_core::{ + MarketplaceListingStatus, MarketplaceQuery, MarketplaceQueryError, MarketplaceQuerySpec, + MarketplaceSort, RuntimeLimits, +}; +use tangle_nips::{FulfillmentMethod, ListingUnit}; +use tangle_protocol::PublicKeyHex; +use url::form_urlencoded; pub const TANGLE_SUPPORTED_NIPS: [u16; 8] = [1, 9, 11, 16, 33, 42, 50, 99]; pub const TANGLE_RELAY_SOFTWARE: &str = "https://github.com/radrootslabs/tangle"; @@ -270,6 +277,109 @@ pub struct RelayInfoLimitationDocument { pub restricted_writes: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListingHttpQuery { + marketplace: MarketplaceQuery, + geohash: Option<String>, +} + +impl ListingHttpQuery { + pub fn marketplace(&self) -> &MarketplaceQuery { + &self.marketplace + } + + pub fn geohash(&self) -> Option<&str> { + self.geohash.as_deref() + } +} + +pub fn parse_listing_query( + query: &str, + limits: RuntimeLimits, +) -> Result<ListingHttpQuery, ApiError> { + let mut spec = MarketplaceQuerySpec { + statuses: vec![MarketplaceListingStatus::Active], + ..MarketplaceQuerySpec::default() + }; + let mut geohash = None; + let mut saw_status = false; + let mut saw_sort = false; + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + let value = value.into_owned(); + match key.as_ref() { + "category" => push_text_values("category", &value, &mut spec.categories)?, + "seller" => set_once("seller", &mut spec.seller, parse_pubkey("seller", &value)?)?, + "status" => { + if !saw_status { + spec.statuses.clear(); + saw_status = true; + } + push_status_values(&value, &mut spec.statuses)?; + } + "currency" => push_text_values("currency", &value, &mut spec.currencies)?, + "unit" => push_unit_values(&value, &mut spec.units)?, + "min_price" => set_once( + "min_price", + &mut spec.min_price, + required_value("min_price", &value)?, + )?, + "max_price" => set_once( + "max_price", + &mut spec.max_price, + required_value("max_price", &value)?, + )?, + "fulfillment" => push_fulfillment_values(&value, &mut spec.fulfillment)?, + "delivery_only" => set_once( + "delivery_only", + &mut spec.delivery_only, + parse_bool("delivery_only", &value)?, + )?, + "pickup" => set_once("pickup", &mut spec.pickup, parse_bool("pickup", &value)?)?, + "geohash" => set_once("geohash", &mut geohash, parse_geohash_query_value(&value)?)?, + "lat" => set_once( + "lat", + &mut spec.latitude_microdegrees, + parse_microdegrees("lat", &value, -90_000_000, 90_000_000)?, + )?, + "lon" => set_once( + "lon", + &mut spec.longitude_microdegrees, + parse_microdegrees("lon", &value, -180_000_000, 180_000_000)?, + )?, + "radius_km" => set_once( + "radius_km", + &mut spec.radius_meters, + parse_radius_meters(&value)?, + )?, + "near" => set_once("near", &mut spec.near, required_value("near", &value)?)?, + "sort" => { + if saw_sort { + return Err(invalid_parameter("sort", "must not be repeated")); + } + saw_sort = true; + spec.sort = parse_sort(&value)?; + } + "limit" => set_once("limit", &mut spec.limit, parse_limit(&value)?)?, + "cursor" => { + return Err(invalid_parameter( + "cursor", + "signed cursor decoding is not implemented", + )); + } + unsupported => { + return Err(ApiError::invalid_request(format!( + "query parameter `{unsupported}` is unsupported" + ))); + } + } + } + let marketplace = MarketplaceQuery::from_spec(spec, limits).map_err(ApiError::from)?; + Ok(ListingHttpQuery { + marketplace, + geohash, + }) +} + pub fn health_router(readiness: ReadinessState) -> Router { Router::new() .route("/healthz", get(healthz)) @@ -328,15 +438,240 @@ fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool { }) } +impl From<MarketplaceQueryError> for ApiError { + fn from(error: MarketplaceQueryError) -> Self { + Self::invalid_request(error.message()) + } +} + +fn set_once<T>(field: &'static str, target: &mut Option<T>, value: T) -> Result<(), ApiError> { + if target.replace(value).is_some() { + return Err(invalid_parameter(field, "must not be repeated")); + } + Ok(()) +} + +fn push_text_values( + field: &'static str, + value: &str, + target: &mut Vec<String>, +) -> Result<(), ApiError> { + for value in split_query_list(field, value)? { + target.push(value); + } + Ok(()) +} + +fn push_status_values( + value: &str, + target: &mut Vec<MarketplaceListingStatus>, +) -> Result<(), ApiError> { + for value in split_query_list("status", value)? { + target.push(parse_status(&value)?); + } + Ok(()) +} + +fn push_unit_values(value: &str, target: &mut Vec<ListingUnit>) -> Result<(), ApiError> { + for value in split_query_list("unit", value)? { + target.push(parse_unit(&value)?); + } + Ok(()) +} + +fn push_fulfillment_values( + value: &str, + target: &mut Vec<FulfillmentMethod>, +) -> Result<(), ApiError> { + for value in split_query_list("fulfillment", value)? { + target.push(parse_fulfillment(&value)?); + } + Ok(()) +} + +fn split_query_list(field: &'static str, value: &str) -> Result<Vec<String>, ApiError> { + value + .split(',') + .map(|value| required_value(field, value)) + .collect() +} + +fn required_value(field: &'static str, value: &str) -> Result<String, ApiError> { + let value = value.trim(); + if value.is_empty() { + return Err(invalid_parameter(field, "must not be empty")); + } + Ok(value.to_owned()) +} + +fn parse_pubkey(field: &'static str, value: &str) -> Result<PublicKeyHex, ApiError> { + let value = required_value(field, value)?; + PublicKeyHex::new(&value) + .map_err(|_| invalid_parameter(field, "must be a 64-character hex public key")) +} + +fn parse_status(value: &str) -> Result<MarketplaceListingStatus, ApiError> { + match value.trim().to_ascii_lowercase().as_str() { + "active" => Ok(MarketplaceListingStatus::Active), + "sold" => Ok(MarketplaceListingStatus::Sold), + "draft" => Ok(MarketplaceListingStatus::Draft), + "inactive" => Ok(MarketplaceListingStatus::Inactive), + "expired" => Ok(MarketplaceListingStatus::Expired), + "deleted" => Ok(MarketplaceListingStatus::Deleted), + "hidden" => Ok(MarketplaceListingStatus::Hidden), + "rejected" => Ok(MarketplaceListingStatus::Rejected), + _ => Err(invalid_parameter("status", "is unsupported")), + } +} + +fn parse_sort(value: &str) -> Result<MarketplaceSort, ApiError> { + match required_value("sort", value)?.to_ascii_lowercase().as_str() { + "relevance" => Ok(MarketplaceSort::Relevance), + "freshness" => Ok(MarketplaceSort::Freshness), + "price_asc" => Ok(MarketplaceSort::PriceAsc), + "price_desc" => Ok(MarketplaceSort::PriceDesc), + "distance" => Ok(MarketplaceSort::Distance), + "seller_trust" => Ok(MarketplaceSort::SellerTrust), + _ => Err(invalid_parameter("sort", "is unsupported")), + } +} + +fn parse_unit(value: &str) -> Result<ListingUnit, ApiError> { + match value.trim().to_ascii_lowercase().as_str() { + "lb" | "lbs" | "pound" | "pounds" => Ok(ListingUnit::Lb), + "oz" | "ounce" | "ounces" => Ok(ListingUnit::Oz), + "each" | "ea" => Ok(ListingUnit::Each), + "bunch" | "bunches" => Ok(ListingUnit::Bunch), + "dozen" => Ok(ListingUnit::Dozen), + "kg" | "kilogram" | "kilograms" => Ok(ListingUnit::Kg), + "g" | "gram" | "grams" => Ok(ListingUnit::G), + "share" | "shares" => Ok(ListingUnit::Share), + "pint" | "pints" => Ok(ListingUnit::Pint), + "quart" | "quarts" => Ok(ListingUnit::Quart), + "box" | "boxes" => Ok(ListingUnit::Box), + "crate" | "crates" => Ok(ListingUnit::Crate), + "flat" | "flats" => Ok(ListingUnit::Flat), + _ => Err(invalid_parameter("unit", "is unsupported")), + } +} + +fn parse_fulfillment(value: &str) -> Result<FulfillmentMethod, ApiError> { + match value.trim().to_ascii_lowercase().as_str() { + "pickup" => Ok(FulfillmentMethod::Pickup), + "delivery" => Ok(FulfillmentMethod::Delivery), + "shipping" => Ok(FulfillmentMethod::Shipping), + _ => Err(invalid_parameter("fulfillment", "is unsupported")), + } +} + +fn parse_bool(field: &'static str, value: &str) -> Result<bool, ApiError> { + match required_value(field, value)?.to_ascii_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(invalid_parameter(field, "must be true or false")), + } +} + +fn parse_geohash_query_value(value: &str) -> Result<String, ApiError> { + let value = required_value("geohash", value)?.to_ascii_lowercase(); + if value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + Ok(value) + } else { + Err(invalid_parameter( + "geohash", + "must be lowercase alphanumeric", + )) + } +} + +fn parse_microdegrees( + field: &'static str, + value: &str, + min: i64, + max: i64, +) -> Result<i32, ApiError> { + let value = required_value(field, value)?; + let (negative, value) = match value.as_bytes().first() { + Some(b'-') => (true, &value[1..]), + Some(b'+') => (false, &value[1..]), + _ => (false, value.as_str()), + }; + let unsigned = parse_unsigned_decimal_scaled(field, value, 6)? as i64; + let signed = if negative { -unsigned } else { unsigned }; + if !(min..=max).contains(&signed) { + return Err(invalid_parameter(field, "is out of range")); + } + Ok(signed as i32) +} + +fn parse_radius_meters(value: &str) -> Result<u64, ApiError> { + let kilometers = required_value("radius_km", value)?; + let meters = parse_unsigned_decimal_scaled("radius_km", &kilometers, 3)?; + if meters == 0 { + return Err(invalid_parameter("radius_km", "must be greater than zero")); + } + Ok(meters) +} + +fn parse_limit(value: &str) -> Result<u64, ApiError> { + required_value("limit", value)? + .parse::<u64>() + .map_err(|_| invalid_parameter("limit", "must be an unsigned integer")) +} + +fn parse_unsigned_decimal_scaled( + field: &'static str, + value: &str, + scale_digits: usize, +) -> Result<u64, ApiError> { + let mut parts = value.split('.'); + let whole = parts.next().unwrap_or_default(); + let fraction = parts.next().unwrap_or_default(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + || fraction.len() > scale_digits + { + return Err(invalid_parameter( + field, + "must be an exact unsigned decimal", + )); + } + let whole = whole + .parse::<u64>() + .map_err(|_| invalid_parameter(field, "must be an exact unsigned decimal"))?; + let mut fraction = fraction.to_owned(); + while fraction.len() < scale_digits { + fraction.push('0'); + } + let fraction = fraction + .parse::<u64>() + .map_err(|_| invalid_parameter(field, "must be an exact unsigned decimal"))?; + whole + .checked_mul(10_u64.pow(scale_digits as u32)) + .and_then(|whole| whole.checked_add(fraction)) + .ok_or_else(|| invalid_parameter(field, "must fit the supported range")) +} + +fn invalid_parameter(field: &'static str, requirement: &str) -> ApiError { + ApiError::invalid_request(format!("{field} {requirement}")) +} + #[cfg(test)] mod tests { use super::{ ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, ReadinessCheckStatus, ReadinessState, RelayInfoDocument, TANGLE_RELAY_SOFTWARE, TANGLE_SUPPORTED_NIPS, - health_router, relay_info_router, + health_router, parse_listing_query, relay_info_router, }; use axum::{body::Body, response::IntoResponse}; use http::{HeaderValue, Request, StatusCode, header}; + use tangle_core::{MarketplaceListingStatus, MarketplaceSort, RuntimeLimits}; + use tangle_nips::{FulfillmentMethod, ListingUnit}; use tower::ServiceExt; #[test] @@ -614,4 +949,187 @@ mod tests { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + #[test] + fn listing_query_parser_defaults_to_active_marketplace_query() { + let parsed = parse_listing_query("", RuntimeLimits::default()).expect("query"); + let query = parsed.marketplace(); + + assert_eq!(parsed.geohash(), None); + assert_eq!(query.statuses, [MarketplaceListingStatus::Active]); + assert_eq!(query.limit, 50); + assert_eq!(query.sort, MarketplaceSort::Relevance); + assert_eq!(query.categories, Vec::<String>::new()); + assert_eq!(query.currencies, Vec::<String>::new()); + assert_eq!(query.units, Vec::<ListingUnit>::new()); + assert_eq!(query.fulfillment, Vec::<FulfillmentMethod>::new()); + } + + #[test] + fn listing_query_parser_reads_supported_parameters() { + let seller = "1".repeat(64); + let query_string = format!( + "category=vegetables,csa&category=roots&seller={seller}&status=active,sold,draft,inactive,expired,deleted,hidden,rejected&currency=usd,cad&unit=lb,oz,each,bunch,dozen,kg,g,share,pint,quart,box,crate,flat&min_price=1.50&max_price=10&fulfillment=pickup,delivery,shipping&delivery_only=false&pickup=true&geohash=C23NB62&lat=47.6062&lon=-122.332100&radius_km=25.5&near=Ballard&sort=distance&limit=25" + ); + let parsed = parse_listing_query(&query_string, RuntimeLimits::default()).expect("query"); + let query = parsed.marketplace(); + let point = query.location.point.expect("point"); + + assert_eq!(parsed.geohash(), Some("c23nb62")); + assert_eq!( + query.categories, + [ + "csa".to_owned(), + "roots".to_owned(), + "vegetables".to_owned() + ] + ); + assert_eq!(query.seller.as_ref().expect("seller").as_str(), seller); + assert_eq!( + query.statuses, + [ + MarketplaceListingStatus::Active, + MarketplaceListingStatus::Sold, + MarketplaceListingStatus::Draft, + MarketplaceListingStatus::Inactive, + MarketplaceListingStatus::Expired, + MarketplaceListingStatus::Deleted, + MarketplaceListingStatus::Hidden, + MarketplaceListingStatus::Rejected, + ] + ); + assert_eq!(query.currencies, ["CAD".to_owned(), "USD".to_owned()]); + assert_eq!( + query + .units + .iter() + .map(|unit| unit.canonical()) + .collect::<Vec<_>>(), + [ + "box", "bunch", "crate", "dozen", "each", "flat", "g", "kg", "lb", "oz", "pint", + "quart", "share", + ] + ); + assert_eq!(query.min_price.as_ref().expect("min").raw, "1.50"); + assert_eq!(query.max_price.as_ref().expect("max").raw, "10"); + assert_eq!( + query.fulfillment, + [ + FulfillmentMethod::Pickup, + FulfillmentMethod::Delivery, + FulfillmentMethod::Shipping, + ] + ); + assert_eq!(query.delivery_only, Some(false)); + assert_eq!(query.pickup, Some(true)); + assert_eq!(point.latitude_microdegrees, 47_606_200); + assert_eq!(point.longitude_microdegrees, -122_332_100); + assert_eq!(query.location.radius_meters, Some(25_500)); + assert_eq!(query.location.near.as_deref(), Some("ballard")); + assert_eq!(query.sort, MarketplaceSort::Distance); + assert_eq!(query.limit, 25); + } + + #[test] + fn listing_query_parser_accepts_all_sort_labels() { + let cases = [ + ("relevance", MarketplaceSort::Relevance, ""), + ("freshness", MarketplaceSort::Freshness, ""), + ("price_asc", MarketplaceSort::PriceAsc, ""), + ("price_desc", MarketplaceSort::PriceDesc, ""), + ("distance", MarketplaceSort::Distance, "&lat=+0&lon=0"), + ("seller_trust", MarketplaceSort::SellerTrust, ""), + ]; + for (label, expected, suffix) in cases { + let parsed = + parse_listing_query(&format!("sort={label}{suffix}"), RuntimeLimits::default()) + .expect("query"); + assert_eq!(parsed.marketplace().sort, expected); + } + } + + #[test] + fn listing_query_parser_rejects_invalid_parameters() { + let seller = "1".repeat(64); + let cases = [ + ( + "banana=1".to_owned(), + "query parameter `banana` is unsupported", + ), + ("category=,roots".to_owned(), "category must not be empty"), + ( + "seller=bad".to_owned(), + "seller must be a 64-character hex public key", + ), + ( + format!("seller={seller}&seller={seller}"), + "seller must not be repeated", + ), + ("status=bogus".to_owned(), "status is unsupported"), + ("currency=%20".to_owned(), "currency must not be empty"), + ("unit=bushel".to_owned(), "unit is unsupported"), + ("min_price=".to_owned(), "min_price must not be empty"), + ( + "min_price=2&max_price=1.99".to_owned(), + "min_price must not exceed max_price", + ), + ("fulfillment=drone".to_owned(), "fulfillment is unsupported"), + ( + "delivery_only=yes".to_owned(), + "delivery_only must be true or false", + ), + ("pickup=".to_owned(), "pickup must not be empty"), + ( + "geohash=c23-".to_owned(), + "geohash must be lowercase alphanumeric", + ), + ( + "geohash=c23&geohash=c24".to_owned(), + "geohash must not be repeated", + ), + ("lat=91".to_owned(), "lat is out of range"), + ("lon=181".to_owned(), "lon is out of range"), + ( + "lat=999999999999999999999999&lon=0".to_owned(), + "lat must be an exact unsigned decimal", + ), + ( + "lat=0&radius_km=1".to_owned(), + "lat and lon must be provided together", + ), + ( + "radius_km=0".to_owned(), + "radius_km must be greater than zero", + ), + ( + "radius_km=1.0000".to_owned(), + "radius_km must be an exact unsigned decimal", + ), + ( + "radius_km=18446744073709551615".to_owned(), + "radius_km must fit the supported range", + ), + ("near=%20".to_owned(), "near must not be empty"), + ( + "sort=relevance&sort=freshness".to_owned(), + "sort must not be repeated", + ), + ("sort=popular".to_owned(), "sort is unsupported"), + ( + "sort=distance".to_owned(), + "distance sort requires a point or near filter", + ), + ("limit=abc".to_owned(), "limit must be an unsigned integer"), + ("limit=0".to_owned(), "limit must be between 1 and 100"), + ( + "cursor=opaque".to_owned(), + "cursor signed cursor decoding is not implemented", + ), + ]; + for (query, expected) in cases { + let error = parse_listing_query(&query, RuntimeLimits::default()).expect_err(&query); + assert_eq!(error.code(), ApiErrorCode::InvalidRequest); + assert_eq!(error.message(), expected); + } + } }