k_ruoka_mcp/browser/
catalog.rs1use crate::browser::session::{ApiError, KrApi};
8use crate::types::{ProductSearchResponse, StoreSearchResponse};
9
10const MAX_LIMIT: u32 = 50;
13const DEFAULT_LIMIT: u32 = 10;
14
15pub struct Catalog<'a> {
16 api: &'a dyn KrApi,
17}
18
19impl<'a> Catalog<'a> {
20 pub fn new(api: &'a dyn KrApi) -> Self {
21 Self { api }
22 }
23
24 pub async fn search_products(
30 &self,
31 store_id: &str,
32 query: &str,
33 limit: Option<u32>,
34 ) -> Result<ProductSearchResponse, ApiError> {
35 let query = query.trim();
36 if query.is_empty() {
37 return Err(ApiError::InvalidRequest(
38 "query must not be empty. Pass something to search for, e.g. \"banaani\"."
39 .to_string(),
40 ));
41 }
42 let limit = clamp_limit(limit);
43 let path = format!(
44 "/kr-api/v2/product-search/{}?language=fi&storeId={}&offset=0&limit={limit}",
45 percent_encode(query),
46 percent_encode(store_id),
47 );
48 let value = self.api.call("POST", &path, None).await?;
49 serde_json::from_value(value)
50 .map_err(|e| ApiError::Other(anyhow::anyhow!("unexpected product-search shape: {e}")))
51 }
52
53 pub async fn search_stores(
57 &self,
58 query: &str,
59 limit: Option<u32>,
60 ) -> Result<StoreSearchResponse, ApiError> {
61 let query = query.trim();
62 if query.is_empty() {
63 return Err(ApiError::InvalidRequest(
64 "query must not be empty. Pass a place or store name, e.g. \"Ruoholahti\"."
65 .to_string(),
66 ));
67 }
68 let body = serde_json::json!({
69 "query": query,
70 "limit": clamp_limit(limit),
71 "offset": 0,
72 });
73 let value = self
74 .api
75 .call("POST", "/kr-api/stores/search", Some(&body))
76 .await?;
77 serde_json::from_value(value)
78 .map_err(|e| ApiError::Other(anyhow::anyhow!("unexpected stores/search shape: {e}")))
79 }
80}
81
82fn clamp_limit(limit: Option<u32>) -> u32 {
83 limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
84}
85
86fn percent_encode(value: &str) -> String {
93 let mut out = String::with_capacity(value.len());
94 for byte in value.as_bytes() {
95 match byte {
96 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
97 out.push(*byte as char);
98 }
99 other => out.push_str(&format!("%{other:02X}")),
100 }
101 }
102 out
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn limits_are_clamped_into_range() {
111 assert_eq!(clamp_limit(None), DEFAULT_LIMIT);
112 assert_eq!(clamp_limit(Some(0)), 1);
113 assert_eq!(clamp_limit(Some(5)), 5);
114 assert_eq!(clamp_limit(Some(9999)), MAX_LIMIT);
115 }
116
117 #[test]
121 fn encoding_escapes_everything_that_could_change_the_url() {
122 assert_eq!(percent_encode("banaani"), "banaani");
123 assert_eq!(percent_encode("maito 1l"), "maito%201l");
124 assert_eq!(percent_encode("a&limit=9999"), "a%26limit%3D9999");
125 assert_eq!(percent_encode("a/../b"), "a%2F..%2Fb");
126 assert_eq!(percent_encode("a?b#c"), "a%3Fb%23c");
127 assert_eq!(percent_encode("รค"), "%C3%A4");
129 }
130}