feat(server): busca FTS5 com filtros e paginacao
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
pub mod announce;
|
pub mod announce;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod search;
|
||||||
|
|
||||||
use axum::{routing::{get, post}, Router};
|
use axum::{routing::{get, post}, Router};
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
@@ -9,6 +10,7 @@ use crate::state::SharedState;
|
|||||||
pub fn router(state: SharedState) -> Router {
|
pub fn router(state: SharedState) -> Router {
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
.route("/health", get(health::health))
|
.route("/health", get(health::health))
|
||||||
|
.route("/search", get(search::search))
|
||||||
.route("/files/{file_id}", get(files::get_file))
|
.route("/files/{file_id}", get(files::get_file))
|
||||||
.route("/announce", post(announce::announce))
|
.route("/announce", post(announce::announce))
|
||||||
.with_state(state);
|
.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 config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod search;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod validate;
|
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 "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,4 +72,47 @@ pub fn get_file(db: &Connection, file_id: &str) -> rusqlite::Result<FileRecord>
|
|||||||
[file_id],
|
[file_id],
|
||||||
FileRecord::from_row,
|
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