88
99from models import HadithCollection , Book , Chapter , Hadith
1010
11+ # sunnah.com publishes these as standalone collections, but their hadiths are
12+ # stored as books of `forty`, so every lookup must also match the book.
13+ COLLECTION_ALIASES = {
14+ "nawawi40" : ("forty" , "1" ),
15+ "qudsi40" : ("forty" , "2" ),
16+ "shahwaliullah40" : ("forty" , "3" ),
17+ }
18+
19+
20+ def resolve_collection (name ):
21+ """Return the collection and book that store the hadiths of `name`."""
22+ return COLLECTION_ALIASES .get (name , (name , None ))
23+
24+
25+ def serialize_as (name ):
26+ """Echo the requested name so an alias response never reports `forty`."""
27+ return {"collection" : name } if name in COLLECTION_ALIASES else {}
28+
29+
30+ def alias_collection (name ):
31+ """Build the collection resource of an alias from its book row."""
32+ collection , book_number = COLLECTION_ALIASES [name ]
33+ book = Book .query .filter_by (collection = collection , status = 4 , ourBookID = book_number ).first ()
34+
35+ if book is None :
36+ return None
37+
38+ return {
39+ "name" : name ,
40+ "hasBooks" : "no" ,
41+ "hasChapters" : "no" ,
42+ "collection" : [
43+ {"lang" : "en" , "title" : book .englishBookName , "shortIntro" : "" },
44+ {"lang" : "ar" , "title" : book .arabicBookName , "shortIntro" : "" },
45+ ],
46+ "totalHadith" : book .totalNumber ,
47+ "totalAvailableHadith" : book .totalNumber ,
48+ }
49+
50+
51+ def resolve_book_collection (name , book_id ):
52+ """Resolve `name`, rejecting a book that is not the alias's own."""
53+ collection , book_number = resolve_collection (name )
54+
55+ if book_number is not None and book_id != book_number :
56+ abort (404 )
57+
58+ return collection
59+
60+
61+ def ref_condition (collection , hadith_number ):
62+ """Build the filter matching one `collection:hadithNumber` reference."""
63+ name , book_number = resolve_collection (collection )
64+ condition = and_ (Hadith .collection == name , Hadith .hadithNumber == hadith_number )
65+ return condition if book_number is None else and_ (condition , Hadith .bookNumber == book_number )
66+
67+
68+ def ref_match (results , collection , hadith_number ):
69+ """Find the hadith matching one reference, scoped to an alias's own book."""
70+ name , book_number = resolve_collection (collection )
71+
72+ for h in results :
73+ if h .collection == name and h .hadithNumber == hadith_number and book_number in (None , h .bookNumber ):
74+ return h
75+
76+ return None
77+
1178
1279@app .before_request
1380def verify_secret ():
@@ -22,15 +89,21 @@ def jsonify_http_error(error):
2289 return jsonify (response ), error .code
2390
2491
92+ def unpack_query (result ):
93+ """Allow a route to return a query, or a (query, serialize kwargs) pair."""
94+ return result if isinstance (result , tuple ) else (result , {})
95+
96+
2597def paginate_results (f ):
2698 @functools .wraps (f )
2799 def decorated_function (* args , ** kwargs ):
28100 limit = int (request .args .get ("limit" , 50 ))
29101 page = int (request .args .get ("page" , 1 ))
30102
31- queryset = f (* args , ** kwargs ).paginate (page = page , per_page = limit , max_per_page = 100 )
103+ query , opts = unpack_query (f (* args , ** kwargs ))
104+ queryset = query .paginate (page = page , per_page = limit , max_per_page = 100 )
32105 result = {
33- "data" : [x .serialize () for x in queryset .items ],
106+ "data" : [x .serialize (** opts ) for x in queryset .items ],
34107 "total" : queryset .total ,
35108 "limit" : queryset .per_page ,
36109 "previous" : queryset .prev_num ,
@@ -44,67 +117,109 @@ def decorated_function(*args, **kwargs):
44117def single_resource (f ):
45118 @functools .wraps (f )
46119 def decorated_function (* args , ** kwargs ):
47- result = f (* args , ** kwargs ). first_or_404 ( )
48- result = result . serialize ()
120+ query , opts = unpack_query ( f (* args , ** kwargs ))
121+ result = query . first_or_404 (). serialize (** opts )
49122 return jsonify (result )
50123
51124 return decorated_function
52125
53126
127+ def paginate_items (items ):
128+ """Paginate a serialized list the way `paginate_results` paginates a query."""
129+ limit = min (int (request .args .get ("limit" , 50 )), 100 )
130+ page = int (request .args .get ("page" , 1 ))
131+ start = (page - 1 ) * limit
132+ window = items [start :][:limit ]
133+
134+ if limit < 1 or page < 1 or (not window and page != 1 ):
135+ abort (404 )
136+
137+ return jsonify (
138+ {
139+ "data" : window ,
140+ "total" : len (items ),
141+ "limit" : limit ,
142+ "previous" : page - 1 if page > 1 else None ,
143+ "next" : page + 1 if start + limit < len (items ) else None ,
144+ }
145+ )
146+
147+
54148@app .route ("/" , methods = ["GET" ])
55149def home ():
56150 return "<h1>Welcome to sunnah.com API.</h1>"
57151
58152
59153@app .route ("/v1/collections" , methods = ["GET" ])
60- @paginate_results
61154def api_collections ():
62- return HadithCollection .query .order_by (HadithCollection .collectionID )
155+ items = [x .serialize () for x in HadithCollection .query .order_by (HadithCollection .collectionID )]
156+ stored = {x ["name" ] for x in items }
157+ aliases = [alias_collection (name ) for name in COLLECTION_ALIASES if name not in stored ]
158+ return paginate_items (items + [x for x in aliases if x is not None ])
63159
64160
65161@app .route ("/v1/collections/<string:name>" , methods = ["GET" ])
66- @single_resource
67162def api_collection (name ):
68- return HadithCollection .query .filter_by (name = name )
163+ row = HadithCollection .query .filter_by (name = name ).first ()
164+ result = row .serialize () if row is not None else alias_collection (name ) if name in COLLECTION_ALIASES else None
165+
166+ if result is None :
167+ abort (404 )
168+
169+ return jsonify (result )
69170
70171
71172@app .route ("/v1/collections/<string:name>/books" , methods = ["GET" ])
72173@paginate_results
73174def api_collection_books (name ):
74- return Book .query .filter_by (collection = name , status = 4 ).order_by (func .abs (Book .ourBookID ))
175+ collection , book_number = resolve_collection (name )
176+ query = Book .query .filter_by (collection = collection , status = 4 ).order_by (func .abs (Book .ourBookID ))
177+ return query if book_number is None else query .filter_by (ourBookID = book_number )
75178
76179
77180@app .route ("/v1/collections/<string:name>/books/<string:bookNumber>" , methods = ["GET" ])
78181@single_resource
79182def api_collection_book (name , bookNumber ):
80183 book_id = Book .get_id_from_number (bookNumber )
81- return Book .query .filter_by (collection = name , status = 4 , ourBookID = book_id )
184+ collection = resolve_book_collection (name , book_id )
185+ return Book .query .filter_by (collection = collection , status = 4 , ourBookID = book_id )
82186
83187
84188@app .route ("/v1/collections/<string:collection_name>/books/<string:bookNumber>/hadiths" , methods = ["GET" ])
85189@paginate_results
86190def api_collection_book_hadiths (collection_name , bookNumber ):
87- return Hadith .query .filter_by (collection = collection_name , bookNumber = bookNumber ).order_by (Hadith .englishURN )
191+ collection = resolve_book_collection (collection_name , bookNumber )
192+ query = Hadith .query .filter_by (collection = collection , bookNumber = bookNumber ).order_by (Hadith .englishURN )
193+ return query , serialize_as (collection_name )
88194
89195
90196@app .route ("/v1/collections/<string:collection_name>/hadiths/<string:hadithNumber>" , methods = ["GET" ])
91197@single_resource
92198def api_collection_hadith (collection_name , hadithNumber ):
93- return Hadith .query .filter_by (collection = collection_name , hadithNumber = hadithNumber )
199+ collection , book_number = resolve_collection (collection_name )
200+ # `forty` numbers each of its books from 1, so order to keep the pick stable
201+ query = Hadith .query .filter_by (collection = collection , hadithNumber = hadithNumber ).order_by (Hadith .englishURN )
202+
203+ if book_number is not None :
204+ query = query .filter_by (bookNumber = book_number )
205+
206+ return query , serialize_as (collection_name )
94207
95208
96209@app .route ("/v1/collections/<string:collection_name>/books/<string:bookNumber>/chapters" , methods = ["GET" ])
97210@paginate_results
98211def api_collection_book_chapters (collection_name , bookNumber ):
99212 book_id = Book .get_id_from_number (bookNumber )
100- return Chapter .query .filter_by (collection = collection_name , arabicBookID = book_id ).order_by (Chapter .babID )
213+ collection = resolve_book_collection (collection_name , book_id )
214+ return Chapter .query .filter_by (collection = collection , arabicBookID = book_id ).order_by (Chapter .babID )
101215
102216
103217@app .route ("/v1/collections/<string:collection_name>/books/<string:bookNumber>/chapters/<float:chapterId>" , methods = ["GET" ])
104218@single_resource
105219def api_collection_book_chapter (collection_name , bookNumber , chapterId ):
106220 book_id = Book .get_id_from_number (bookNumber )
107- return Chapter .query .filter_by (collection = collection_name , arabicBookID = book_id , babID = chapterId )
221+ collection = resolve_book_collection (collection_name , book_id )
222+ return Chapter .query .filter_by (collection = collection , arabicBookID = book_id , babID = chapterId )
108223
109224
110225@app .route ("/v1/hadiths" , methods = ["GET" ])
@@ -115,7 +230,10 @@ def api_hadiths():
115230 # Apply filters based on query parameters
116231 collection = request .args .get ("collection" )
117232 if collection :
118- query = query .filter_by (collection = collection )
233+ name , book_number = resolve_collection (collection )
234+ query = query .filter_by (collection = name )
235+ if book_number is not None :
236+ query = query .filter_by (bookNumber = book_number )
119237
120238 book_number = request .args .get ("bookNumber" )
121239 if book_number :
@@ -130,7 +248,7 @@ def api_hadiths():
130248 query = query .filter_by (hadithNumber = hadith_number )
131249
132250 # Order by URN for consistent results
133- return query .order_by (Hadith .englishURN )
251+ return query .order_by (Hadith .englishURN ), serialize_as ( collection )
134252
135253
136254@app .route ("/v1/hadiths/<int:urn>" , methods = ["GET" ])
@@ -243,31 +361,16 @@ def api_hadiths_by_refs():
243361 if len (refs ) > MAX_REFS :
244362 abort (400 , f"Too many refs (max { MAX_REFS } )." )
245363
246- results = (
247- Hadith .query .filter (
248- or_ (
249- * [
250- and_ (
251- Hadith .collection == collection ,
252- Hadith .hadithNumber == hadith_number ,
253- )
254- for collection , hadith_number in refs
255- ]
256- )
257- )
258- .all ()
259- )
260-
261- by_ref = {(h .collection , h .hadithNumber ): h for h in results }
364+ results = Hadith .query .filter (or_ (* [ref_condition (c , n ) for c , n in refs ])).order_by (Hadith .englishURN ).all ()
262365
263366 data = []
264367 missing = []
265368 for collection , hadith_number in refs :
266- h = by_ref . get (( collection , hadith_number ) )
369+ h = ref_match ( results , collection , hadith_number )
267370 if h is None :
268371 missing .append (f"{ collection } :{ hadith_number } " )
269372 else :
270- data .append (h .serialize ())
373+ data .append (h .serialize (** serialize_as ( collection ) ))
271374
272375 return jsonify ({"count" : len (data ), "missing" : missing , "data" : data })
273376
0 commit comments