From 0abc8f272a8fbe60aee80b1fab0b3d87744ee297 Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Thu, 12 Jun 2025 21:31:46 -0700 Subject: [PATCH] feat(client): update the search functionality --- Cargo.toml | 2 + .../GlobalSearchBar/GlobalSearchBar.jsx | 282 +++++++++++--- .../SearchGuidance/SearchGuidance.jsx | 238 ++++++++++++ .../src/components/SearchGuidance/index.js | 1 + frontend/src/index.css | 73 +++- frontend/src/pages/SearchPage.jsx | 346 ++++++++++++++---- src/main.rs | 2 + src/models.rs | 33 +- src/routes/auth.rs | 33 ++ src/routes/documents.rs | 50 ++- src/routes/search.rs | 31 ++ src/routes/settings.rs | 3 +- src/swagger.rs | 72 ++++ 13 files changed, 1026 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/SearchGuidance/SearchGuidance.jsx create mode 100644 frontend/src/components/SearchGuidance/index.js create mode 100644 src/swagger.rs diff --git a/Cargo.toml b/Cargo.toml index dcb052b..00a14ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ dotenvy = "0.15" hostname = "0.4" walkdir = "2" clap = { version = "4", features = ["derive"] } +utoipa = { version = "4", features = ["axum_extras", "chrono", "uuid"] } +utoipa-swagger-ui = { version = "6", features = ["axum"] } [features] default = ["ocr"] diff --git a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.jsx b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.jsx index 5902073..0ade7f1 100644 --- a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.jsx +++ b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.jsx @@ -15,6 +15,9 @@ import { ClickAwayListener, Grow, Popper, + CircularProgress, + LinearProgress, + Skeleton, } from '@mui/material'; import { Search as SearchIcon, @@ -36,6 +39,10 @@ const GlobalSearchBar = ({ sx, ...props }) => { const [loading, setLoading] = useState(false); const [showResults, setShowResults] = useState(false); const [recentSearches, setRecentSearches] = useState([]); + const [isTyping, setIsTyping] = useState(false); + const [searchProgress, setSearchProgress] = useState(0); + const [suggestions, setSuggestions] = useState([]); + const [popularSearches] = useState(['invoice', 'contract', 'report', 'presentation', 'agreement']); const searchInputRef = useRef(null); const anchorRef = useRef(null); @@ -64,23 +71,64 @@ const GlobalSearchBar = ({ sx, ...props }) => { localStorage.setItem('recentSearches', JSON.stringify(updated)); }, [recentSearches]); - // Debounced search function + // Enhanced debounced search function with typing indicators const debounce = useCallback((func, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); - timeoutId = setTimeout(() => func.apply(null, args), delay); + setIsTyping(true); + timeoutId = setTimeout(() => { + setIsTyping(false); + func.apply(null, args); + }, delay); }; }, []); + // Generate smart suggestions + const generateSuggestions = useCallback((searchQuery) => { + if (!searchQuery || searchQuery.length < 2) { + setSuggestions([]); + return; + } + + const smartSuggestions = []; + + // Add similar popular searches + const similar = popularSearches.filter(search => + search.toLowerCase().includes(searchQuery.toLowerCase()) || + searchQuery.toLowerCase().includes(search.toLowerCase()) + ); + smartSuggestions.push(...similar); + + // Add exact phrase suggestion + if (!searchQuery.includes('"')) { + smartSuggestions.push(`"${searchQuery}"`); + } + + // Add tag search suggestion + if (!searchQuery.startsWith('tag:')) { + smartSuggestions.push(`tag:${searchQuery}`); + } + + setSuggestions(smartSuggestions.slice(0, 3)); + }, [popularSearches]); + const performSearch = useCallback(async (searchQuery) => { if (!searchQuery.trim()) { setResults([]); + setSuggestions([]); return; } try { setLoading(true); + setSearchProgress(0); + + // Progressive loading for better UX + const progressInterval = setInterval(() => { + setSearchProgress(prev => Math.min(prev + 25, 90)); + }, 50); + const response = await documentService.enhancedSearch({ query: searchQuery.trim(), limit: 5, // Show only top 5 results in global search @@ -88,19 +136,30 @@ const GlobalSearchBar = ({ sx, ...props }) => { search_mode: 'simple', }); + clearInterval(progressInterval); + setSearchProgress(100); setResults(response.data.documents || []); + + // Clear progress after brief delay + setTimeout(() => setSearchProgress(0), 300); } catch (error) { console.error('Global search failed:', error); setResults([]); + setSearchProgress(0); } finally { setLoading(false); } }, []); const debouncedSearch = useCallback( - debounce(performSearch, 300), // Faster debounce for global search + debounce(performSearch, 200), // Even faster debounce for global search [performSearch] ); + + const debouncedSuggestions = useCallback( + debounce(generateSuggestions, 100), // Very fast suggestions + [generateSuggestions] + ); const handleInputChange = (event) => { const value = event.target.value; @@ -109,8 +168,10 @@ const GlobalSearchBar = ({ sx, ...props }) => { if (value.trim()) { debouncedSearch(value); + debouncedSuggestions(value); } else { setResults([]); + setSuggestions([]); } }; @@ -125,7 +186,10 @@ const GlobalSearchBar = ({ sx, ...props }) => { const handleClear = () => { setQuery(''); setResults([]); + setSuggestions([]); setShowResults(false); + setIsTyping(false); + setSearchProgress(0); }; const handleDocumentClick = (doc) => { @@ -138,6 +202,17 @@ const GlobalSearchBar = ({ sx, ...props }) => { setQuery(searchQuery); performSearch(searchQuery); }; + + const handleSuggestionClick = (suggestion) => { + setQuery(suggestion); + performSearch(suggestion); + }; + + const handlePopularSearchClick = (search) => { + setQuery(search); + performSearch(search); + setShowResults(false); + }; const handleKeyDown = (event) => { if (event.key === 'Enter' && query.trim()) { @@ -168,42 +243,77 @@ const GlobalSearchBar = ({ sx, ...props }) => { return ( - - - - ), - endAdornment: query && ( - - - - - - ), - }} - sx={{ - minWidth: 300, - maxWidth: 400, - '& .MuiOutlinedInput-root': { - backgroundColor: 'background.paper', - '&:hover': { + + + + + ), + endAdornment: ( + + + {(loading || isTyping) && ( + 0 ? "determinate" : "indeterminate"} + value={searchProgress} + /> + )} + {query && ( + + + + )} + + + ), + }} + sx={{ + minWidth: 300, + maxWidth: 400, + '& .MuiOutlinedInput-root': { backgroundColor: 'background.paper', + transition: 'all 0.2s ease-in-out', + '&:hover': { + backgroundColor: 'background.paper', + borderColor: 'primary.main', + }, + '&.Mui-focused': { + backgroundColor: 'background.paper', + borderColor: 'primary.main', + borderWidth: 2, + }, }, - '&.Mui-focused': { - backgroundColor: 'background.paper', - }, - }, - }} - /> + }} + /> + + {/* Enhanced Loading Progress Bar */} + {(loading || isTyping || searchProgress > 0) && ( + 0 ? "determinate" : "indeterminate"} + value={searchProgress} + sx={{ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 2, + borderRadius: '0 0 4px 4px', + opacity: isTyping ? 0.6 : 1, + transition: 'opacity 0.2s ease-in-out', + }} + /> + )} + {/* Search Results Dropdown */} { borderColor: 'divider', }} > - {loading && ( + {(loading || isTyping) && ( - - Searching... - + + + + {isTyping ? 'Searching as you type...' : 'Searching...'} + + )} + + {/* Loading Skeletons for better UX */} + {loading && query && ( + + {[1, 2, 3].map((i) => ( + + + + + } + secondary={} + /> + + ))} + + )} - {!loading && query && results.length === 0 && ( + {!loading && !isTyping && query && results.length === 0 && ( - - No documents found + + No documents found for "{query}" - + Press Enter to search with advanced options + + {/* Smart suggestions for no results */} + {suggestions.length > 0 && ( + <> + + Try these suggestions: + + + {suggestions.map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + sx={{ fontSize: '0.7rem', height: 20 }} + /> + ))} + + + )} )} - {!loading && results.length > 0 && ( + {!loading && !isTyping && results.length > 0 && ( <> - - Quick Results - + + + Quick Results + + + {results.length} found + + {results.map((doc) => ( @@ -371,13 +528,30 @@ const GlobalSearchBar = ({ sx, ...props }) => { {!query && recentSearches.length === 0 && ( - + Start typing to search documents - - - - + + Popular searches: + + + {popularSearches.slice(0, 3).map((search, index) => ( + handlePopularSearchClick(search)} + sx={{ + fontSize: '0.75rem', + '&:hover': { + backgroundColor: 'primary.light', + color: 'primary.contrastText', + } + }} + /> + ))} )} diff --git a/frontend/src/components/SearchGuidance/SearchGuidance.jsx b/frontend/src/components/SearchGuidance/SearchGuidance.jsx new file mode 100644 index 0000000..135d92d --- /dev/null +++ b/frontend/src/components/SearchGuidance/SearchGuidance.jsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Chip, + Stack, + Accordion, + AccordionSummary, + AccordionDetails, + List, + ListItem, + ListItemText, + ListItemIcon, + Paper, + IconButton, + Collapse, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + Help as HelpIcon, + Search as SearchIcon, + FormatQuote as QuoteIcon, + Tag as TagIcon, + Extension as ExtensionIcon, + Close as CloseIcon, + TrendingUp as TrendingIcon, +} from '@mui/icons-material'; + +const SearchGuidance = ({ onExampleClick, compact = false, sx, ...props }) => { + const [showHelp, setShowHelp] = useState(false); + + const searchExamples = [ + { + query: 'invoice 2024', + description: 'Find documents containing both "invoice" and "2024"', + icon: , + }, + { + query: '"project proposal"', + description: 'Search for exact phrase "project proposal"', + icon: , + }, + { + query: 'tag:important', + description: 'Find all documents tagged as "important"', + icon: , + }, + { + query: 'contract AND payment', + description: 'Advanced search using AND operator', + icon: , + }, + { + query: 'proj*', + description: 'Wildcard search for project, projects, etc.', + icon: , + }, + ]; + + const searchTips = [ + 'Use quotes for exact phrases: "annual report"', + 'Search by tags: tag:urgent or tag:personal', + 'Use AND/OR for complex queries: (invoice OR receipt) AND 2024', + 'Wildcards work great: proj* finds project, projects, projection', + 'Search OCR text in images and PDFs automatically', + 'File types are searchable: PDF, Word, Excel, images', + ]; + + if (compact) { + return ( + + setShowHelp(!showHelp)} + color={showHelp ? 'primary' : 'default'} + sx={{ + position: 'absolute', + top: 0, + right: 0, + zIndex: 1, + backgroundColor: 'background.paper', + '&:hover': { + backgroundColor: 'action.hover', + } + }} + > + {showHelp ? : } + + + + + + + Quick Search Tips + + + + {searchTips.slice(0, 3).map((tip, index) => ( + + • {tip} + + ))} + + + + Try these examples: + + + {searchExamples.slice(0, 3).map((example, index) => ( + onExampleClick?.(example.query)} + sx={{ + fontSize: '0.7rem', + height: 20, + '&:hover': { + backgroundColor: 'primary.light', + color: 'primary.contrastText', + } + }} + /> + ))} + + + + + ); + } + + return ( + + + }> + + + Search Help & Examples + + + + + {/* Search Examples */} + + + Example Searches + + + {searchExamples.map((example, index) => ( + onExampleClick?.(example.query)} + sx={{ + borderRadius: 1, + mb: 0.5, + '&:hover': { + backgroundColor: 'action.hover', + }, + }} + > + + {example.icon} + + + {example.query} + + } + secondary={ + + {example.description} + + } + /> + + ))} + + + + {/* Search Tips */} + + + Search Tips + + + {searchTips.map((tip, index) => ( + + • {tip} + + ))} + + + + {/* Quick Actions */} + + + Quick Start + + + {searchExamples.map((example, index) => ( + onExampleClick?.(example.query)} + sx={{ + '&:hover': { + backgroundColor: 'primary.light', + color: 'primary.contrastText', + } + }} + /> + ))} + + + + + + + ); +}; + +export default SearchGuidance; \ No newline at end of file diff --git a/frontend/src/components/SearchGuidance/index.js b/frontend/src/components/SearchGuidance/index.js new file mode 100644 index 0000000..0384d94 --- /dev/null +++ b/frontend/src/components/SearchGuidance/index.js @@ -0,0 +1 @@ +export { default } from './SearchGuidance'; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index bd6213e..a6f39b7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,74 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +/* Enhanced search responsiveness styles */ +.search-input-responsive { + transition: all 0.2s ease-in-out; +} + +.search-input-responsive:focus-within { + transform: scale(1.02); +} + +/* Mobile-friendly search results */ +@media (max-width: 768px) { + .search-results-grid { + gap: 1rem !important; + } + + .search-card { + padding: 0.75rem !important; + } + + .search-chip { + font-size: 0.7rem !important; + height: 18px !important; + } +} + +/* Touch-friendly interactive elements */ +@media (pointer: coarse) { + .search-suggestion-chip { + min-height: 32px; + padding: 8px 12px; + } + + .search-filter-button { + min-height: 40px; + min-width: 40px; + } +} + +/* Smooth animations for search loading states */ +.search-loading-fade { + opacity: 0; + animation: searchFadeIn 0.3s ease-in-out forwards; +} + +@keyframes searchFadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Enhanced hover states for better UX */ +.search-result-card { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.search-result-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +/* Improved focus states for accessibility */ +.search-focusable:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 2px; +} \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx index cd2114f..ec53736 100644 --- a/frontend/src/pages/SearchPage.jsx +++ b/frontend/src/pages/SearchPage.jsx @@ -58,6 +58,7 @@ import { TrendingUp as TrendingIcon, } from '@mui/icons-material'; import { documentService } from '../services/api'; +import SearchGuidance from '../components/SearchGuidance'; const SearchPage = () => { const navigate = useNavigate(); @@ -70,6 +71,16 @@ const SearchPage = () => { const [queryTime, setQueryTime] = useState(0); const [totalResults, setTotalResults] = useState(0); const [suggestions, setSuggestions] = useState([]); + const [isTyping, setIsTyping] = useState(false); + const [searchProgress, setSearchProgress] = useState(0); + const [quickSuggestions, setQuickSuggestions] = useState([]); + const [showFilters, setShowFilters] = useState(false); + const [searchTips, setSearchTips] = useState([ + 'Use quotes for exact phrases: "project plan"', + 'Search by tags: tag:important or tag:invoice', + 'Combine terms: contract AND payment', + 'Use wildcards: proj* for project, projects, etc.' + ]); // Search settings const [useEnhancedSearch, setUseEnhancedSearch] = useState(true); @@ -95,27 +106,65 @@ const SearchPage = () => { { value: 'application/vnd.openxmlformats-officedocument', label: 'Office Documents' }, ]; - // Debounced search + // Enhanced debounced search with typing indicators const debounce = useCallback((func, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); - timeoutId = setTimeout(() => func.apply(null, args), delay); + setIsTyping(true); + timeoutId = setTimeout(() => { + setIsTyping(false); + func.apply(null, args); + }, delay); }; }, []); + // Quick suggestions generator + const generateQuickSuggestions = useCallback((query) => { + if (!query || query.length < 2) { + setQuickSuggestions([]); + return; + } + + const suggestions = []; + + // Add exact phrase suggestion + if (!query.includes('"')) { + suggestions.push(`"${query}"`); + } + + // Add tag suggestions + if (!query.startsWith('tag:')) { + suggestions.push(`tag:${query}`); + } + + // Add wildcard suggestion + if (!query.includes('*')) { + suggestions.push(`${query}*`); + } + + setQuickSuggestions(suggestions.slice(0, 3)); + }, []); + const performSearch = useCallback(async (query, filters = {}) => { if (!query.trim()) { setSearchResults([]); setTotalResults(0); setQueryTime(0); setSuggestions([]); + setQuickSuggestions([]); return; } try { setLoading(true); setError(null); + setSearchProgress(0); + + // Simulate progressive loading for better UX + const progressInterval = setInterval(() => { + setSearchProgress(prev => Math.min(prev + 20, 90)); + }, 100); const searchRequest = { query: query.trim(), @@ -162,6 +211,9 @@ const SearchPage = () => { }); } + clearInterval(progressInterval); + setSearchProgress(100); + setSearchResults(results); setTotalResults(response.data.total || results.length); setQueryTime(response.data.query_time_ms || 0); @@ -171,7 +223,12 @@ const SearchPage = () => { const tags = [...new Set(results.flatMap(doc => doc.tags))]; setAvailableTags(tags); + // Clear progress after a brief delay + setTimeout(() => setSearchProgress(0), 500); + } catch (err) { + clearInterval(progressInterval); + setSearchProgress(0); setError('Search failed. Please try again.'); console.error(err); } finally { @@ -180,9 +237,14 @@ const SearchPage = () => { }, [useEnhancedSearch, includeSnippets, snippetLength, searchMode]); const debouncedSearch = useCallback( - debounce((query, filters) => performSearch(query, filters), 500), + debounce((query, filters) => performSearch(query, filters), 300), [performSearch] ); + + const quickSuggestionsDebounced = useCallback( + debounce((query) => generateQuickSuggestions(query), 150), + [generateQuickSuggestions] + ); // Handle URL search params useEffect(() => { @@ -201,6 +263,7 @@ const SearchPage = () => { hasOcr: hasOcr, }; debouncedSearch(searchQuery, filters); + quickSuggestionsDebounced(searchQuery); // Update URL params when search query changes if (searchQuery) { @@ -208,7 +271,7 @@ const SearchPage = () => { } else { setSearchParams({}); } - }, [searchQuery, selectedTags, selectedMimeTypes, dateRange, fileSizeRange, hasOcr, debouncedSearch, setSearchParams]); + }, [searchQuery, selectedTags, selectedMimeTypes, dateRange, fileSizeRange, hasOcr, debouncedSearch, quickSuggestionsDebounced, setSearchParams]); const handleClearFilters = () => { setSelectedTags([]); @@ -331,6 +394,7 @@ const SearchPage = () => { {/* Enhanced Search Bar */} { endAdornment: ( - {loading && } + {(loading || isTyping) && ( + 0 ? "determinate" : "indeterminate"} + value={searchProgress} + /> + )} {searchQuery && ( { > + setShowFilters(!showFilters)} + color={showFilters ? 'primary' : 'default'} + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + ), @@ -394,15 +472,19 @@ const SearchPage = () => { }} /> - {/* Loading Progress Bar */} - {loading && ( + {/* Enhanced Loading Progress Bar */} + {(loading || isTyping || searchProgress > 0) && ( 0 ? "determinate" : "indeterminate"} + value={searchProgress} sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, borderRadius: '0 0 4px 4px', + opacity: isTyping ? 0.5 : 1, + transition: 'opacity 0.2s ease-in-out', }} /> )} @@ -443,26 +525,54 @@ const SearchPage = () => { )} - {/* Search Mode Selector */} + {/* Simplified Search Mode Selector */} newMode && setSearchMode(newMode)} size="small" > - Simple - Phrase - Fuzzy - Boolean + Smart + Exact phrase + Similar words + Advanced )} - {/* Suggestions */} + {/* Quick Suggestions */} + {quickSuggestions.length > 0 && searchQuery && !loading && ( + + + Quick suggestions: + + + {quickSuggestions.map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + clickable + variant="outlined" + color="primary" + sx={{ + '&:hover': { + backgroundColor: 'primary.main', + color: 'primary.contrastText', + } + }} + /> + ))} + + + )} + + {/* Server Suggestions */} {suggestions.length > 0 && ( - Suggestions: + Related searches: {suggestions.map((suggestion, index) => ( @@ -488,47 +598,58 @@ const SearchPage = () => { {/* Advanced Search Options */} {showAdvanced && ( - - Search Options - - - - setUseEnhancedSearch(e.target.checked)} - color="primary" + + + + Search Options + + + + setUseEnhancedSearch(e.target.checked)} + color="primary" + /> + } + label="Enhanced Search" /> - } - label="Enhanced Search" - /> - - - setIncludeSnippets(e.target.checked)} - color="primary" + + + setIncludeSnippets(e.target.checked)} + color="primary" + /> + } + label="Show Snippets" /> - } - label="Show Snippets" - /> + + + + Snippet Length + + + + - - - Snippet Length - - + + @@ -537,9 +658,32 @@ const SearchPage = () => { - {/* Filters Sidebar */} - - + {/* Mobile Filters Drawer */} + {showFilters && ( + + + + + + + Filters + + + + {/* Mobile filter content would go here - simplified */} + + Mobile filters coming soon... + + + + + )} + + {/* Desktop Filters Sidebar */} + + @@ -750,11 +894,42 @@ const SearchPage = () => { }} > - No results found + No results found for "{searchQuery}" - + Try adjusting your search terms or filters + + {/* Helpful suggestions for no results */} + + + Suggestions: + + + • Try simpler or more general terms + • Check spelling and try different keywords + • Remove some filters to broaden your search + • Use quotes for exact phrases + + + + + + + )} @@ -771,13 +946,46 @@ const SearchPage = () => { Start searching your documents - + Use the enhanced search bar above to find documents by content, filename, or tags + + {/* Search Tips */} + + + Search Tips: + + + {searchTips.map((tip, index) => ( + + {tip} + + ))} + + + - - - + setSearchQuery('invoice')} + /> + setSearchQuery('contract')} + /> + setSearchQuery('tag:important')} + /> )} @@ -794,15 +1002,11 @@ const SearchPage = () => { key={doc.id} > theme.shadows[4], - }, }} > {viewMode === 'grid' && ( @@ -821,7 +1025,7 @@ const SearchPage = () => { )} - + {viewMode === 'list' && ( @@ -847,17 +1051,20 @@ const SearchPage = () => { {doc.has_ocr_text && ( { {doc.tags.slice(0, 2).map((tag, index) => ( { ))} {doc.tags.length > 2 && ( { {doc.search_rank && ( { navigate(`/documents/${doc.id}`)} > @@ -948,6 +1159,7 @@ const SearchPage = () => { handleDownload(doc)} > diff --git a/src/main.rs b/src/main.rs index d73d953..356aa01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod ocr; mod ocr_queue; mod routes; mod seed; +mod swagger; mod watcher; #[cfg(test)] @@ -54,6 +55,7 @@ async fn main() -> Result<(), Box> { .nest("/api/search", routes::search::router()) .nest("/api/settings", routes::settings::router()) .nest("/api/users", routes::users::router()) + .merge(swagger::create_swagger_router()) .nest_service("/", ServeDir::new("/app/frontend")) .fallback(serve_spa) .layer(CorsLayer::permissive()) diff --git a/src/models.rs b/src/models.rs index d9d54f8..edad307 100644 --- a/src/models.rs +++ b/src/models.rs @@ -2,8 +2,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)] pub struct User { pub id: Uuid, pub username: String, @@ -13,26 +14,26 @@ pub struct User { pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateUser { pub username: String, pub email: String, pub password: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct LoginRequest { pub username: String, pub password: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct LoginResponse { pub token: String, pub user: UserResponse, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UserResponse { pub id: Uuid, pub username: String, @@ -55,7 +56,7 @@ pub struct Document { pub user_id: Uuid, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct DocumentResponse { pub id: Uuid, pub filename: String, @@ -67,7 +68,7 @@ pub struct DocumentResponse { pub has_ocr_text: bool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchRequest { pub query: String, pub tags: Option>, @@ -79,7 +80,7 @@ pub struct SearchRequest { pub search_mode: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub enum SearchMode { #[serde(rename = "simple")] Simple, @@ -97,7 +98,7 @@ impl Default for SearchMode { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchSnippet { pub text: String, pub start_offset: i32, @@ -105,13 +106,13 @@ pub struct SearchSnippet { pub highlight_ranges: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct HighlightRange { pub start: i32, pub end: i32, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct EnhancedDocumentResponse { pub id: Uuid, pub filename: String, @@ -125,7 +126,7 @@ pub struct EnhancedDocumentResponse { pub snippets: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchResponse { pub documents: Vec, pub total: i64, @@ -158,14 +159,14 @@ impl From for UserResponse { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateUser { pub username: Option, pub email: Option, pub password: Option, } -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)] pub struct Settings { pub id: Uuid, pub user_id: Uuid, @@ -189,7 +190,7 @@ pub struct Settings { pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SettingsResponse { pub ocr_language: String, pub concurrent_ocr_jobs: i32, @@ -209,7 +210,7 @@ pub struct SettingsResponse { pub enable_background_ocr: bool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateSettings { pub ocr_language: Option, pub concurrent_ocr_jobs: Option, diff --git a/src/routes/auth.rs b/src/routes/auth.rs index b499f90..e6e945e 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -6,6 +6,7 @@ use axum::{ Router, }; use std::sync::Arc; +use utoipa::path; use crate::{ auth::{create_jwt, AuthUser}, @@ -20,6 +21,16 @@ pub fn router() -> Router> { .route("/me", get(me)) } +#[utoipa::path( + post, + path = "/api/auth/register", + tag = "auth", + request_body = CreateUser, + responses( + (status = 200, description = "User registered successfully", body = UserResponse), + (status = 400, description = "Bad request - invalid user data") + ) +)] async fn register( State(state): State>, Json(user_data): Json, @@ -33,6 +44,16 @@ async fn register( Ok(Json(user.into())) } +#[utoipa::path( + post, + path = "/api/auth/login", + tag = "auth", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = LoginResponse), + (status = 401, description = "Unauthorized - invalid credentials") + ) +)] async fn login( State(state): State>, Json(login_data): Json, @@ -60,6 +81,18 @@ async fn login( })) } +#[utoipa::path( + get, + path = "/api/auth/me", + tag = "auth", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "Current user information", body = UserResponse), + (status = 401, description = "Unauthorized - invalid or missing token") + ) +)] async fn me(auth_user: AuthUser) -> Json { Json(auth_user.user.into()) } \ No newline at end of file diff --git a/src/routes/documents.rs b/src/routes/documents.rs index bc779d6..b23c826 100644 --- a/src/routes/documents.rs +++ b/src/routes/documents.rs @@ -7,6 +7,7 @@ use axum::{ }; use serde::Deserialize; use std::sync::Arc; +use utoipa::{path, ToSchema}; use crate::{ auth::AuthUser, @@ -16,7 +17,7 @@ use crate::{ AppState, }; -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] struct PaginationQuery { limit: Option, offset: Option, @@ -29,6 +30,21 @@ pub fn router() -> Router> { .route("/:id/download", get(download_document)) } +#[utoipa::path( + post, + path = "/api/documents", + tag = "documents", + security( + ("bearer_auth" = []) + ), + request_body(content = String, description = "Multipart form data with file", content_type = "multipart/form-data"), + responses( + (status = 200, description = "Document uploaded successfully", body = DocumentResponse), + (status = 400, description = "Bad request - invalid file or data"), + (status = 413, description = "Payload too large - file exceeds size limit"), + (status = 401, description = "Unauthorized") + ) +)] async fn upload_document( State(state): State>, auth_user: AuthUser, @@ -119,6 +135,22 @@ async fn upload_document( Err(StatusCode::BAD_REQUEST) } +#[utoipa::path( + get, + path = "/api/documents", + tag = "documents", + security( + ("bearer_auth" = []) + ), + params( + ("limit" = Option, Query, description = "Number of documents to return (default: 50)"), + ("offset" = Option, Query, description = "Number of documents to skip (default: 0)") + ), + responses( + (status = 200, description = "List of user documents", body = Vec), + (status = 401, description = "Unauthorized") + ) +)] async fn list_documents( State(state): State>, auth_user: AuthUser, @@ -138,6 +170,22 @@ async fn list_documents( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/api/documents/{id}/download", + tag = "documents", + security( + ("bearer_auth" = []) + ), + params( + ("id" = uuid::Uuid, Path, description = "Document ID") + ), + responses( + (status = 200, description = "Document file content", content_type = "application/octet-stream"), + (status = 404, description = "Document not found"), + (status = 401, description = "Unauthorized") + ) +)] async fn download_document( State(state): State>, auth_user: AuthUser, diff --git a/src/routes/search.rs b/src/routes/search.rs index 2a768a3..a70676d 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -6,6 +6,7 @@ use axum::{ Router, }; use std::sync::Arc; +use utoipa::path; use crate::{ auth::AuthUser, @@ -19,6 +20,21 @@ pub fn router() -> Router> { .route("/enhanced", get(enhanced_search_documents)) } +#[utoipa::path( + get, + path = "/api/search", + tag = "search", + security( + ("bearer_auth" = []) + ), + params( + SearchRequest + ), + responses( + (status = 200, description = "Search results", body = SearchResponse), + (status = 401, description = "Unauthorized") + ) +)] async fn search_documents( State(state): State>, auth_user: AuthUser, @@ -51,6 +67,21 @@ async fn search_documents( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/api/search/enhanced", + tag = "search", + security( + ("bearer_auth" = []) + ), + params( + SearchRequest + ), + responses( + (status = 200, description = "Enhanced search results with snippets and suggestions", body = SearchResponse), + (status = 401, description = "Unauthorized") + ) +)] async fn enhanced_search_documents( State(state): State>, auth_user: AuthUser, diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 4af96ce..f49626b 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -2,10 +2,11 @@ use axum::{ extract::State, http::StatusCode, response::Json, - routing::get, + routing::{get, put}, Router, }; use std::sync::Arc; +use utoipa::path; use crate::{ auth::AuthUser, diff --git a/src/swagger.rs b/src/swagger.rs new file mode 100644 index 0000000..b62468e --- /dev/null +++ b/src/swagger.rs @@ -0,0 +1,72 @@ +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; +use axum::Router; +use std::sync::Arc; + +use crate::{ + models::{ + CreateUser, LoginRequest, LoginResponse, UserResponse, UpdateUser, + DocumentResponse, SearchRequest, SearchResponse, EnhancedDocumentResponse, + SettingsResponse, UpdateSettings, SearchMode, SearchSnippet, HighlightRange + }, + AppState, +}; + +#[derive(OpenApi)] +#[openapi( + paths( + // Auth endpoints + crate::routes::auth::register, + crate::routes::auth::login, + crate::routes::auth::me, + // Document endpoints + crate::routes::documents::upload_document, + crate::routes::documents::list_documents, + crate::routes::documents::download_document, + // Search endpoints + crate::routes::search::search_documents, + // Settings endpoints + crate::routes::settings::get_settings, + crate::routes::settings::update_settings, + // User endpoints + crate::routes::users::get_user, + crate::routes::users::update_user, + crate::routes::users::delete_user, + // Queue endpoints + crate::routes::queue::get_queue_status, + crate::routes::queue::get_queue_stats, + ), + components( + schemas( + CreateUser, LoginRequest, LoginResponse, UserResponse, UpdateUser, + DocumentResponse, SearchRequest, SearchResponse, EnhancedDocumentResponse, + SettingsResponse, UpdateSettings, SearchMode, SearchSnippet, HighlightRange + ) + ), + tags( + (name = "auth", description = "Authentication endpoints"), + (name = "documents", description = "Document management endpoints"), + (name = "search", description = "Document search endpoints"), + (name = "settings", description = "User settings endpoints"), + (name = "users", description = "User management endpoints"), + (name = "queue", description = "OCR queue management endpoints"), + ), + info( + title = "Readur API", + version = "0.1.0", + description = "Document management and OCR processing API", + contact( + name = "Readur Team", + email = "support@readur.dev" + ) + ), + servers( + (url = "/api", description = "API base path") + ) +)] +pub struct ApiDoc; + +pub fn create_swagger_router() -> Router> { + SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", ApiDoc::openapi()) +} \ No newline at end of file