Skip to main content

k_ruoka_mcp/browser/
catalog.rs

1//! Catalogue lookups: finding a product's EAN, and finding a store's id.
2//!
3//! Separate from `basket` because none of this touches a cart. Both endpoints are
4//! read-only, and both exist because the cart tools take opaque identifiers that a
5//! caller otherwise has no way to obtain.
6
7use crate::browser::session::{ApiError, KrApi};
8use crate::types::{ProductSearchResponse, StoreSearchResponse};
9
10/// Keeps one response from being large enough to swamp a model's context. The API's own
11/// default is 48; the tools cap at this.
12const 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    /// Search products at a store.
25    ///
26    /// The term goes in the *path*, percent-encoded, with paging and store in the query
27    /// string. Results are store-scoped: price and availability differ per store, so the
28    /// same term at a different store is a genuinely different answer.
29    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    /// Search stores by name or place.
54    ///
55    /// Unlike product search, the term goes in a JSON body.
56    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
86/// Percent-encode a path segment or query value.
87///
88/// Hand-rolled rather than pulling in a dependency for it: the allowed set here is
89/// deliberately conservative (unreserved characters only), so anything else -- spaces,
90/// Finnish letters, `?`, `&`, `#`, `/` -- is escaped rather than being able to change
91/// which URL is requested.
92fn 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    /// The search term reaches the server inside a URL, so anything that could end the
118    /// path or start a new parameter has to be escaped. `&limit=` here would otherwise
119    /// override the real one.
120    #[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        // Finnish letters are multi-byte UTF-8 and must be escaped per byte.
128        assert_eq!(percent_encode("รค"), "%C3%A4");
129    }
130}