add shareable search results via query parameters

implements GET /api/search endpoint alongside existing POST endpoint,
enabling users to share search results via URL.

backend changes:
- refactor search logic into shared perform_search function
- add search_get handler for GET /api/search?query=...&top_k=...
- implement HTTP caching with ETag and Cache-Control headers
- ETag based on query hash enables 304 Not Modified responses
- 5-minute cache TTL balances freshness vs performance

frontend changes:
- update URL params when search is submitted (?q=...&top_k=...)
- auto-execute search on page load if URL contains query params
- support browser back/forward navigation with popstate
- migrate from POST to GET for all searches

caching strategy:
- browser caches results for 5 minutes using Cache-Control
- ETag validation prevents redundant data transfer
- results may be stale if new bufos ingested, but TTL is short

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+92 -16
src
static
+1
src/main.rs
··· 62 62 web::scope("/api") 63 63 .wrap(Governor::new(&governor_conf)) 64 64 .route("/search", web::post().to(search::search)) 65 + .route("/search", web::get().to(search::search_get)) 65 66 .route("/health", web::get().to(|| async { HttpResponse::Ok().body("ok") })) 66 67 ) 67 68 .service(fs::Files::new("/static", "./static").show_files_listing())
+54 -9
src/search.rs
··· 24 24 use crate::config::Config; 25 25 use crate::embedding::EmbeddingClient; 26 26 use crate::turbopuffer::{QueryRequest, TurbopufferClient}; 27 - use actix_web::{web, HttpResponse, Result as ActixResult}; 27 + use actix_web::{web, HttpRequest, HttpResponse, Result as ActixResult}; 28 28 use serde::{Deserialize, Serialize}; 29 + use std::collections::hash_map::DefaultHasher; 30 + use std::hash::{Hash, Hasher}; 29 31 30 32 #[derive(Debug, Deserialize)] 31 33 pub struct SearchQuery { ··· 51 53 pub score: f32, // normalized 0-1 score for display 52 54 } 53 55 54 - pub async fn search( 55 - query: web::Json<SearchQuery>, 56 - config: web::Data<Config>, 57 - ) -> ActixResult<HttpResponse> { 58 - let query_text = query.query.clone(); 59 - let top_k_val = query.top_k; 56 + /// generate etag for caching based on query parameters 57 + fn generate_etag(query: &str, top_k: usize) -> String { 58 + let mut hasher = DefaultHasher::new(); 59 + query.hash(&mut hasher); 60 + top_k.hash(&mut hasher); 61 + format!("\"{}\"", hasher.finish()) 62 + } 63 + 64 + /// shared search implementation used by both POST and GET handlers 65 + async fn perform_search( 66 + query_text: String, 67 + top_k_val: usize, 68 + config: &Config, 69 + ) -> ActixResult<SearchResponse> { 60 70 61 71 let _search_span = logfire::span!( 62 72 "bufo_search", ··· 113 123 serde_json::json!("ANN"), 114 124 serde_json::json!(query_embedding), 115 125 ], 116 - top_k: query.top_k, 126 + top_k: top_k_val, 117 127 include_attributes: Some(vec!["url".to_string(), "name".to_string(), "filename".to_string()]), 118 128 }; 119 129 ··· 203 213 avg_score = avg_score_val 204 214 ); 205 215 206 - Ok(HttpResponse::Ok().json(SearchResponse { results })) 216 + Ok(SearchResponse { results }) 217 + } 218 + 219 + /// POST /api/search handler (existing API) 220 + pub async fn search( 221 + query: web::Json<SearchQuery>, 222 + config: web::Data<Config>, 223 + ) -> ActixResult<HttpResponse> { 224 + let response = perform_search(query.query.clone(), query.top_k, &config).await?; 225 + Ok(HttpResponse::Ok().json(response)) 226 + } 227 + 228 + /// GET /api/search handler for shareable URLs 229 + pub async fn search_get( 230 + query: web::Query<SearchQuery>, 231 + config: web::Data<Config>, 232 + req: HttpRequest, 233 + ) -> ActixResult<HttpResponse> { 234 + // generate etag for caching 235 + let etag = generate_etag(&query.query, query.top_k); 236 + 237 + // check if client has cached version 238 + if let Some(if_none_match) = req.headers().get("if-none-match") { 239 + if if_none_match.to_str().unwrap_or("") == etag { 240 + return Ok(HttpResponse::NotModified() 241 + .insert_header(("etag", etag)) 242 + .finish()); 243 + } 244 + } 245 + 246 + let response = perform_search(query.query.clone(), query.top_k, &config).await?; 247 + 248 + Ok(HttpResponse::Ok() 249 + .insert_header(("etag", etag.clone())) 250 + .insert_header(("cache-control", "public, max-age=300")) // cache for 5 minutes 251 + .json(response)) 207 252 }
+37 -7
static/index.html
··· 283 283 284 284 let hasSearched = false; 285 285 286 - async function search() { 286 + async function search(updateUrl = true) { 287 287 const query = searchInput.value.trim(); 288 288 if (!query) return; 289 289 ··· 291 291 if (!hasSearched) { 292 292 peekingBufo.classList.add('hidden'); 293 293 hasSearched = true; 294 + } 295 + 296 + // update url with query parameters for sharing 297 + if (updateUrl) { 298 + const params = new URLSearchParams(); 299 + params.set('q', query); 300 + params.set('top_k', '20'); 301 + const newUrl = `${window.location.pathname}?${params.toString()}`; 302 + window.history.pushState({ query }, '', newUrl); 294 303 } 295 304 296 305 searchButton.disabled = true; ··· 299 308 errorDiv.style.display = 'none'; 300 309 301 310 try { 302 - const response = await fetch('/api/search', { 303 - method: 'POST', 311 + const params = new URLSearchParams(); 312 + params.set('query', query); 313 + params.set('top_k', '20'); 314 + 315 + const response = await fetch(`/api/search?${params.toString()}`, { 316 + method: 'GET', 304 317 headers: { 305 - 'Content-Type': 'application/json', 318 + 'Accept': 'application/json', 306 319 }, 307 - body: JSON.stringify({ query, top_k: 20 }), 308 320 }); 309 321 310 322 if (!response.ok) { ··· 343 355 }); 344 356 } 345 357 346 - searchButton.addEventListener('click', search); 358 + searchButton.addEventListener('click', () => search(true)); 347 359 searchInput.addEventListener('keypress', (e) => { 348 - if (e.key === 'Enter') search(); 360 + if (e.key === 'Enter') search(true); 361 + }); 362 + 363 + // handle browser back/forward 364 + window.addEventListener('popstate', (e) => { 365 + if (e.state && e.state.query) { 366 + searchInput.value = e.state.query; 367 + search(false); 368 + } 369 + }); 370 + 371 + // auto-execute search if url has query params (for shared links) 372 + window.addEventListener('DOMContentLoaded', () => { 373 + const params = new URLSearchParams(window.location.search); 374 + const query = params.get('q'); 375 + if (query) { 376 + searchInput.value = query; 377 + search(false); // don't update URL since we're already loading from it 378 + } 349 379 }); 350 380 </script> 351 381 </body>