commit 058b724e100dd34db7ccd1108dd55b9f58125a8e
parent 8b3ff2bf7de8821fdb01efb64b929ce38ff16d8e
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 01:04:08 -0700
http: add marketplace search endpoint
Diffstat:
1 file changed, 275 insertions(+), 2 deletions(-)
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -15,7 +15,7 @@ use tangle_core::{
};
use tangle_nips::{FulfillmentMethod, ListingUnit};
use tangle_protocol::{EventId, PublicKeyHex};
-use tangle_store_surreal::{ListingProjectionQuery, SurrealStore};
+use tangle_store_surreal::{ListingProjectionQuery, SearchDocumentQuery, SurrealStore};
use url::form_urlencoded;
pub const TANGLE_SUPPORTED_NIPS: [u16; 8] = [1, 9, 11, 16, 33, 42, 50, 99];
@@ -294,6 +294,27 @@ impl ListingHttpQuery {
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceSearchHttpQuery {
+ text: Option<String>,
+ seller: Option<PublicKeyHex>,
+ limit: u64,
+}
+
+impl MarketplaceSearchHttpQuery {
+ pub fn text(&self) -> Option<&str> {
+ self.text.as_deref()
+ }
+
+ pub fn seller(&self) -> Option<&PublicKeyHex> {
+ self.seller.as_ref()
+ }
+
+ pub fn limit(&self) -> u64 {
+ self.limit
+ }
+}
+
#[derive(Debug, Clone)]
pub struct ListingsHttpState {
store: SurrealStore,
@@ -433,6 +454,69 @@ pub fn parse_listing_query(
})
}
+pub fn parse_marketplace_search_query(
+ query: &str,
+ limits: RuntimeLimits,
+) -> Result<MarketplaceSearchHttpQuery, ApiError> {
+ let mut text = None;
+ let mut seller = None;
+ let mut status = None;
+ let mut sort = None;
+ let mut limit = None;
+ for (key, value) in form_urlencoded::parse(query.as_bytes()) {
+ let value = value.into_owned();
+ match key.as_ref() {
+ "q" => set_once("q", &mut text, required_value("q", &value)?)?,
+ "seller" => set_once("seller", &mut seller, parse_pubkey("seller", &value)?)?,
+ "status" => set_once("status", &mut status, parse_status(&value)?)?,
+ "sort" => set_once("sort", &mut sort, parse_sort(&value)?)?,
+ "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?,
+ "category" | "currency" | "unit" | "min_price" | "max_price" | "fulfillment"
+ | "delivery_only" | "pickup" | "lat" | "lon" | "radius_km" | "near" | "cursor" => {
+ return Err(ApiError::invalid_request(format!(
+ "{} is not supported by marketplace search",
+ key.as_ref()
+ )));
+ }
+ unsupported => {
+ return Err(ApiError::invalid_request(format!(
+ "query parameter `{unsupported}` is unsupported"
+ )));
+ }
+ }
+ }
+ limits
+ .validate_search_query(text.as_deref().unwrap_or_default())
+ .map_err(|violation| ApiError::invalid_request(format!("runtime limit: {violation}")))?;
+ let status = status.unwrap_or(MarketplaceListingStatus::Active);
+ if status != MarketplaceListingStatus::Active {
+ return Err(invalid_parameter(
+ "status",
+ "must be active for marketplace search",
+ ));
+ }
+ let expected_sort = if text.is_some() {
+ MarketplaceSort::Relevance
+ } else {
+ MarketplaceSort::Freshness
+ };
+ if sort.is_some_and(|sort| sort != expected_sort) {
+ return Err(invalid_parameter(
+ "sort",
+ "does not match marketplace search mode",
+ ));
+ }
+ let limit = limit.unwrap_or(MarketplaceQuery::DEFAULT_LIMIT);
+ if limit == 0 || limit > MarketplaceQuery::MAX_LIMIT {
+ return Err(invalid_parameter("limit", "must be between 1 and 100"));
+ }
+ Ok(MarketplaceSearchHttpQuery {
+ text,
+ seller,
+ limit,
+ })
+}
+
pub fn health_router(readiness: ReadinessState) -> Router {
Router::new()
.route("/healthz", get(healthz))
@@ -450,6 +534,7 @@ pub fn listings_router(state: ListingsHttpState) -> Router {
Router::new()
.route("/api/listings", get(listings))
.route("/api/listings/{pubkey}/{d}", get(listing_detail))
+ .route("/api/search", get(marketplace_search))
.with_state(state)
}
@@ -540,6 +625,42 @@ async fn listing_detail(
}))
}
+async fn marketplace_search(
+ State(state): State<ListingsHttpState>,
+ RawQuery(query): RawQuery,
+) -> Result<Json<ListingsDocument>, ApiError> {
+ let parsed =
+ parse_marketplace_search_query(query.as_deref().unwrap_or_default(), state.limits)?;
+ let search_query = search_document_query(&parsed);
+ let docs = state
+ .store
+ .query_search_documents(&search_query)
+ .await
+ .map_err(|_| ApiError::internal())?;
+ let mut items = Vec::new();
+ for doc in docs {
+ let Some(address_key) = optional_string_field(&doc, "address_key")? else {
+ continue;
+ };
+ let Some(row) = state
+ .store
+ .listing_current_row(&address_key)
+ .await
+ .map_err(|_| ApiError::internal())?
+ else {
+ continue;
+ };
+ if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? {
+ continue;
+ }
+ items.push(listing_item_document(&row)?);
+ }
+ Ok(Json(ListingsDocument {
+ items,
+ next_cursor: None,
+ }))
+}
+
fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool {
value
.and_then(|value| value.to_str().ok())
@@ -642,6 +763,22 @@ fn listing_projection_query(parsed: &ListingHttpQuery) -> Result<ListingProjecti
Ok(store_query.with_limit(query.limit))
}
+fn search_document_query(parsed: &MarketplaceSearchHttpQuery) -> SearchDocumentQuery {
+ let mut query = SearchDocumentQuery::new()
+ .with_doc_type("listing")
+ .with_kind(30_402)
+ .with_visible(true)
+ .with_status("active")
+ .with_limit(parsed.limit());
+ if let Some(text) = parsed.text() {
+ query = query.with_text(text);
+ }
+ if let Some(seller) = parsed.seller() {
+ query = query.with_pubkey(seller.as_str());
+ }
+ query
+}
+
fn listing_item_document(row: &serde_json::Value) -> Result<ListingItemDocument, ApiError> {
Ok(ListingItemDocument {
listing_key: string_field(row, "listing_key")?,
@@ -972,7 +1109,8 @@ mod tests {
ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, ListingsHttpState,
ReadinessCheckStatus, ReadinessState, RelayInfoDocument, TANGLE_RELAY_SOFTWARE,
TANGLE_SUPPORTED_NIPS, health_router, listing_item_document, listing_projection_query,
- listings_router, parse_listing_query, relay_info_router,
+ listings_router, parse_listing_query, parse_marketplace_search_query, relay_info_router,
+ search_document_query,
};
use axum::{body::Body, response::IntoResponse};
use http::{HeaderValue, Request, StatusCode, header};
@@ -1504,6 +1642,72 @@ mod tests {
}
#[test]
+ fn marketplace_search_query_parser_accepts_supported_modes() {
+ let seller = "1".repeat(64);
+ let text = parse_marketplace_search_query(
+ &format!("q=carrot&seller={seller}&sort=relevance&limit=25"),
+ RuntimeLimits::default(),
+ )
+ .expect("text search");
+ assert_eq!(text.text(), Some("carrot"));
+ assert_eq!(text.seller().expect("seller").as_str(), seller);
+ assert_eq!(text.limit(), 25);
+
+ let browse = parse_marketplace_search_query("sort=freshness", RuntimeLimits::default())
+ .expect("browse");
+ assert_eq!(browse.text(), None);
+ assert_eq!(browse.seller(), None);
+ assert_eq!(browse.limit(), 50);
+
+ let query = search_document_query(&text);
+ assert_eq!(format!("{query:?}").contains("SearchDocumentQuery"), true);
+ }
+
+ #[test]
+ fn marketplace_search_query_parser_rejects_invalid_parameters() {
+ let long_query = format!("q={}", "a".repeat(300));
+ let cases = [
+ ("q=".to_owned(), "q must not be empty"),
+ ("q=carrot&q=roots".to_owned(), "q must not be repeated"),
+ (
+ long_query,
+ "runtime limit: search query bytes exceeded: 300 > 256",
+ ),
+ (
+ "category=vegetables".to_owned(),
+ "category is not supported by marketplace search",
+ ),
+ (
+ "status=sold".to_owned(),
+ "status must be active for marketplace search",
+ ),
+ (
+ "q=carrot&sort=freshness".to_owned(),
+ "sort does not match marketplace search mode",
+ ),
+ (
+ "sort=relevance".to_owned(),
+ "sort does not match marketplace search mode",
+ ),
+ (
+ "sort=price_asc".to_owned(),
+ "sort does not match marketplace search mode",
+ ),
+ ("limit=0".to_owned(), "limit must be between 1 and 100"),
+ (
+ "banana=1".to_owned(),
+ "query parameter `banana` is unsupported",
+ ),
+ ];
+ for (raw, expected) in cases {
+ let error =
+ parse_marketplace_search_query(&raw, RuntimeLimits::default()).expect_err(&raw);
+ assert_eq!(error.code(), ApiErrorCode::InvalidRequest);
+ assert_eq!(error.message(), expected);
+ }
+ }
+
+ #[test]
fn listing_item_document_maps_projection_rows_and_rejects_malformed_rows() {
let row = serde_json::json!({
"listing_key": "30402:pubkey:listing-a",
@@ -1817,6 +2021,75 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
+ #[tokio::test]
+ async fn marketplace_search_endpoint_queries_search_docs_and_hydrates_listings() {
+ let store = runtime_memory_store().await;
+ let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+ let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
+ store
+ .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400))
+ .await
+ .expect("project listing");
+ store
+ .index_listing_search_document(&listing)
+ .await
+ .expect("index listing");
+
+ let uri = format!(
+ "/api/search?q=carrot&seller={}&sort=relevance&limit=5",
+ listing.unsigned().pubkey().as_str()
+ );
+ let response = listings_router(ListingsHttpState::new(
+ store.clone(),
+ RuntimeLimits::default(),
+ ))
+ .oneshot(
+ Request::builder()
+ .uri(uri)
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ let json = serde_json::from_slice::<serde_json::Value>(&body).expect("json");
+ assert_eq!(json["items"][0]["listing_key"], listing_key);
+ assert_eq!(json["items"][0]["title"], "Carrot bunches");
+ assert_eq!(json["next_cursor"], serde_json::Value::Null);
+
+ store
+ .database()
+ .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;")
+ .bind(("listing_key", listing_key.as_str()))
+ .await
+ .expect("hide listing")
+ .check()
+ .expect("hide check");
+ let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
+ .oneshot(
+ Request::builder()
+ .uri("/api/search?q=carrot")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "items": [],
+ "next_cursor": null
+ })
+ );
+ }
+
async fn runtime_memory_store() -> SurrealStore {
let config = SurrealConnectionConfig::memory("tangle_runtime", "listings_endpoint")
.expect("memory config");