feat(server): busca FTS5 com filtros e paginacao
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 "))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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\"");
|
||||
}
|
||||
Reference in New Issue
Block a user