feat(server): busca FTS5 com filtros e paginacao

This commit is contained in:
2026-08-05 20:35:17 -03:00
parent 2564c33748
commit 1d1de4bcec
7 changed files with 169 additions and 0 deletions
+2
View File
@@ -1,6 +1,7 @@
pub mod announce;
pub mod files;
pub mod health;
pub mod search;
use axum::{routing::{get, post}, Router};
use tower_http::cors::CorsLayer;
@@ -9,6 +10,7 @@ use crate::state::SharedState;
pub fn router(state: SharedState) -> Router {
let api = Router::new()
.route("/health", get(health::health))
.route("/search", get(search::search))
.route("/files/{file_id}", get(files::get_file))
.route("/announce", post(announce::announce))
.with_state(state);
+22
View File
@@ -0,0 +1,22 @@
use axum::extract::{Query, State};
use axum::Json;
use crate::error::AppResult;
use crate::models::{FileRecord, SearchQuery};
use crate::state::SharedState;
pub async fn search(
State(state): State<SharedState>,
Query(q): Query<SearchQuery>,
) -> AppResult<Json<Vec<FileRecord>>> {
let db = state.db.lock().unwrap();
let limit = q.limit.unwrap_or(state.config.max_results).clamp(1, 100);
let offset = q.offset.unwrap_or(0).max(0);
let rows = crate::store::search_files(
&db,
q.q.as_deref().unwrap_or(""),
q.ftype.as_deref(),
limit,
offset,
)?;
Ok(Json(rows))
}
+1
View File
@@ -2,6 +2,7 @@ pub mod api;
pub mod config;
pub mod error;
pub mod models;
pub mod search;
pub mod state;
pub mod store;
pub mod validate;
+13
View File
@@ -0,0 +1,13 @@
pub fn fts_query(q: &str) -> Option<String> {
let terms: Vec<String> = q
.split_whitespace()
.map(|t| t.trim_matches(['"', '\'', '*', '(', ')', ':', '[', ']']))
.filter(|t| !t.is_empty())
.map(|t| format!("\"{}\"", t))
.collect();
if terms.is_empty() {
None
} else {
Some(terms.join(" AND "))
}
}
+43
View File
@@ -73,3 +73,46 @@ pub fn get_file(db: &Connection, file_id: &str) -> rusqlite::Result<FileRecord>
FileRecord::from_row,
)
}
pub fn search_files(
db: &Connection,
q: &str,
ftype: Option<&str>,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<FileRecord>> {
let mut sql = String::from(
"SELECT f.id, f.peer_id, f.name, f.description, f.ftype, f.extension,
f.size_bytes, f.hash, f.license, f.tags, f.created_at
FROM files f",
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let mut where_added = false;
if let Some(query) = crate::search::fts_query(q) {
sql.push_str(" JOIN files_fts ON files_fts.rowid = f.rowid WHERE files_fts MATCH ?1");
params.push(Box::new(query));
where_added = true;
}
if let Some(ft) = ftype {
if !where_added {
sql.push_str(" WHERE");
where_added = true;
} else {
sql.push_str(" AND");
}
params.push(Box::new(ft.to_string()));
sql.push_str(&format!(" f.ftype = ?{}", params.len()));
}
params.push(Box::new(limit));
sql.push_str(&format!(" ORDER BY f.created_at DESC LIMIT ?{}", params.len()));
params.push(Box::new(offset));
sql.push_str(&format!(" OFFSET ?{}", params.len()));
let mut stmt = db.prepare(&sql)?;
let rows = stmt.query_map(
rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())),
FileRecord::from_row,
)?;
rows.collect()
}
+60
View File
@@ -0,0 +1,60 @@
mod common;
use common::{announce_test_file, test_server};
use serde_json::json;
#[tokio::test]
async fn search_by_name_filters() {
let server = test_server();
for (name, ty, hash) in [
("musica-bossa.ogg", "audio", "a1".repeat(32)),
("musica-rock.mp3", "audio", "a2".repeat(32)),
("ebook-p2p.pdf", "document", "a3".repeat(32)),
] {
announce_test_file(&server, name, ty, &hash).await;
}
let res = server.get("/api/v1/search?q=musica").await;
assert_eq!(res.status_code(), 200);
let items = res.json::<Vec<serde_json::Value>>();
assert_eq!(items.len(), 2);
}
#[tokio::test]
async fn search_filter_by_type() {
let server = test_server();
for (name, ty, hash) in [
("musica-bossa.ogg", "audio", "a1".repeat(32)),
("musica-rock.mp3", "audio", "a2".repeat(32)),
("ebook-p2p.pdf", "document", "a3".repeat(32)),
] {
announce_test_file(&server, name, ty, &hash).await;
}
let res = server.get("/api/v1/search?q=musica&ftype=audio").await;
let items = res.json::<Vec<serde_json::Value>>();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["ftype"], "audio");
}
#[tokio::test]
async fn search_pagination() {
let server = test_server();
for (name, ty, hash) in [
("musica-bossa.ogg", "audio", "a1".repeat(32)),
("musica-rock.mp3", "audio", "a2".repeat(32)),
("ebook-p2p.pdf", "document", "a3".repeat(32)),
] {
announce_test_file(&server, name, ty, &hash).await;
}
let res = server.get("/api/v1/search?q=&limit=1&offset=0").await;
let items = res.json::<Vec<serde_json::Value>>();
assert_eq!(items.len(), 1);
}
#[tokio::test]
async fn search_empty_results() {
let server = test_server();
let res = server.get("/api/v1/search?q=inexistente").await;
assert_eq!(res.status_code(), 200);
let items = res.json::<Vec<serde_json::Value>>();
assert_eq!(items.len(), 0);
}
+28
View File
@@ -0,0 +1,28 @@
use ares_server::search::fts_query;
#[test]
fn empty_query_returns_none() {
assert_eq!(fts_query(""), None);
assert_eq!(fts_query(" "), None);
}
#[test]
fn single_term_wrapped_in_quotes() {
assert_eq!(fts_query("musica").unwrap(), "\"musica\"");
}
#[test]
fn multiple_terms_are_and() {
assert_eq!(fts_query("bossa nova").unwrap(), "\"bossa\" AND \"nova\"");
}
#[test]
fn non_whitespace_tokens_split_kept() {
// tokens are split by whitespace; edge chars (like *) are stripped
assert_eq!(fts_query("*ideal* fluido*").unwrap(), "\"ideal\" AND \"fluido\"");
}
#[test]
fn fts_special_single_char_stripped_at_edges() {
assert_eq!(fts_query("album' rock").unwrap(), "\"album\" AND \"rock\"");
}