diff --git a/main.py b/main.py
index 68281c6..f2ad7ec 100644
--- a/main.py
+++ b/main.py
@@ -8,6 +8,73 @@
from models import HadithCollection, Book, Chapter, Hadith
+# sunnah.com publishes these as standalone collections, but their hadiths are
+# stored as books of `forty`, so every lookup must also match the book.
+COLLECTION_ALIASES = {
+ "nawawi40": ("forty", "1"),
+ "qudsi40": ("forty", "2"),
+ "shahwaliullah40": ("forty", "3"),
+}
+
+
+def resolve_collection(name):
+ """Return the collection and book that store the hadiths of `name`."""
+ return COLLECTION_ALIASES.get(name, (name, None))
+
+
+def serialize_as(name):
+ """Echo the requested name so an alias response never reports `forty`."""
+ return {"collection": name} if name in COLLECTION_ALIASES else {}
+
+
+def alias_collection(name):
+ """Build the collection resource of an alias from its book row."""
+ collection, book_number = COLLECTION_ALIASES[name]
+ book = Book.query.filter_by(collection=collection, status=4, ourBookID=book_number).first()
+
+ if book is None:
+ return None
+
+ return {
+ "name": name,
+ "hasBooks": "no",
+ "hasChapters": "no",
+ "collection": [
+ {"lang": "en", "title": book.englishBookName, "shortIntro": ""},
+ {"lang": "ar", "title": book.arabicBookName, "shortIntro": ""},
+ ],
+ "totalHadith": book.totalNumber,
+ "totalAvailableHadith": book.totalNumber,
+ }
+
+
+def resolve_book_collection(name, book_id):
+ """Resolve `name`, rejecting a book that is not the alias's own."""
+ collection, book_number = resolve_collection(name)
+
+ if book_number is not None and book_id != book_number:
+ abort(404)
+
+ return collection
+
+
+def ref_condition(collection, hadith_number):
+ """Build the filter matching one `collection:hadithNumber` reference."""
+ name, book_number = resolve_collection(collection)
+ condition = and_(Hadith.collection == name, Hadith.hadithNumber == hadith_number)
+ return condition if book_number is None else and_(condition, Hadith.bookNumber == book_number)
+
+
+def ref_match(results, collection, hadith_number):
+ """Find the hadith matching one reference, scoped to an alias's own book."""
+ name, book_number = resolve_collection(collection)
+
+ for h in results:
+ if h.collection == name and h.hadithNumber == hadith_number and book_number in (None, h.bookNumber):
+ return h
+
+ return None
+
@app.before_request
def verify_secret():
@@ -22,15 +89,21 @@ def jsonify_http_error(error):
return jsonify(response), error.code
+def unpack_query(result):
+ """Allow a route to return a query, or a (query, serialize kwargs) pair."""
+ return result if isinstance(result, tuple) else (result, {})
+
+
def paginate_results(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
limit = int(request.args.get("limit", 50))
page = int(request.args.get("page", 1))
- queryset = f(*args, **kwargs).paginate(page=page, per_page=limit, max_per_page=100)
+ query, opts = unpack_query(f(*args, **kwargs))
+ queryset = query.paginate(page=page, per_page=limit, max_per_page=100)
result = {
- "data": [x.serialize() for x in queryset.items],
+ "data": [x.serialize(**opts) for x in queryset.items],
"total": queryset.total,
"limit": queryset.per_page,
"previous": queryset.prev_num,
@@ -44,67 +117,109 @@ def decorated_function(*args, **kwargs):
def single_resource(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
- result = f(*args, **kwargs).first_or_404()
- result = result.serialize()
+ query, opts = unpack_query(f(*args, **kwargs))
+ result = query.first_or_404().serialize(**opts)
return jsonify(result)
return decorated_function
+def paginate_items(items):
+ """Paginate a serialized list the way `paginate_results` paginates a query."""
+ limit = min(int(request.args.get("limit", 50)), 100)
+ page = int(request.args.get("page", 1))
+ start = (page - 1) * limit
+ window = items[start:][:limit]
+
+ if limit < 1 or page < 1 or (not window and page != 1):
+ abort(404)
+
+ return jsonify(
+ {
+ "data": window,
+ "total": len(items),
+ "limit": limit,
+ "previous": page - 1 if page > 1 else None,
+ "next": page + 1 if start + limit < len(items) else None,
+ }
+ )
+
+
@app.route("/", methods=["GET"])
def home():
return "
Welcome to sunnah.com API.
"
@app.route("/v1/collections", methods=["GET"])
-@paginate_results
def api_collections():
- return HadithCollection.query.order_by(HadithCollection.collectionID)
+ items = [x.serialize() for x in HadithCollection.query.order_by(HadithCollection.collectionID)]
+ stored = {x["name"] for x in items}
+ aliases = [alias_collection(name) for name in COLLECTION_ALIASES if name not in stored]
+ return paginate_items(items + [x for x in aliases if x is not None])
@app.route("/v1/collections/", methods=["GET"])
-@single_resource
def api_collection(name):
- return HadithCollection.query.filter_by(name=name)
+ row = HadithCollection.query.filter_by(name=name).first()
+ result = row.serialize() if row is not None else alias_collection(name) if name in COLLECTION_ALIASES else None
+
+ if result is None:
+ abort(404)
+
+ return jsonify(result)
@app.route("/v1/collections//books", methods=["GET"])
@paginate_results
def api_collection_books(name):
- return Book.query.filter_by(collection=name, status=4).order_by(func.abs(Book.ourBookID))
+ collection, book_number = resolve_collection(name)
+ query = Book.query.filter_by(collection=collection, status=4).order_by(func.abs(Book.ourBookID))
+ return query if book_number is None else query.filter_by(ourBookID=book_number)
@app.route("/v1/collections//books/", methods=["GET"])
@single_resource
def api_collection_book(name, bookNumber):
book_id = Book.get_id_from_number(bookNumber)
- return Book.query.filter_by(collection=name, status=4, ourBookID=book_id)
+ collection = resolve_book_collection(name, book_id)
+ return Book.query.filter_by(collection=collection, status=4, ourBookID=book_id)
@app.route("/v1/collections//books//hadiths", methods=["GET"])
@paginate_results
def api_collection_book_hadiths(collection_name, bookNumber):
- return Hadith.query.filter_by(collection=collection_name, bookNumber=bookNumber).order_by(Hadith.englishURN)
+ collection = resolve_book_collection(collection_name, bookNumber)
+ query = Hadith.query.filter_by(collection=collection, bookNumber=bookNumber).order_by(Hadith.englishURN)
+ return query, serialize_as(collection_name)
@app.route("/v1/collections//hadiths/", methods=["GET"])
@single_resource
def api_collection_hadith(collection_name, hadithNumber):
- return Hadith.query.filter_by(collection=collection_name, hadithNumber=hadithNumber)
+ collection, book_number = resolve_collection(collection_name)
+ # `forty` numbers each of its books from 1, so order to keep the pick stable
+ query = Hadith.query.filter_by(collection=collection, hadithNumber=hadithNumber).order_by(Hadith.englishURN)
+
+ if book_number is not None:
+ query = query.filter_by(bookNumber=book_number)
+
+ return query, serialize_as(collection_name)
@app.route("/v1/collections//books//chapters", methods=["GET"])
@paginate_results
def api_collection_book_chapters(collection_name, bookNumber):
book_id = Book.get_id_from_number(bookNumber)
- return Chapter.query.filter_by(collection=collection_name, arabicBookID=book_id).order_by(Chapter.babID)
+ collection = resolve_book_collection(collection_name, book_id)
+ return Chapter.query.filter_by(collection=collection, arabicBookID=book_id).order_by(Chapter.babID)
@app.route("/v1/collections//books//chapters/", methods=["GET"])
@single_resource
def api_collection_book_chapter(collection_name, bookNumber, chapterId):
book_id = Book.get_id_from_number(bookNumber)
- return Chapter.query.filter_by(collection=collection_name, arabicBookID=book_id, babID=chapterId)
+ collection = resolve_book_collection(collection_name, book_id)
+ return Chapter.query.filter_by(collection=collection, arabicBookID=book_id, babID=chapterId)
@app.route("/v1/hadiths", methods=["GET"])
@@ -115,7 +230,10 @@ def api_hadiths():
# Apply filters based on query parameters
collection = request.args.get("collection")
if collection:
- query = query.filter_by(collection=collection)
+ name, book_number = resolve_collection(collection)
+ query = query.filter_by(collection=name)
+ if book_number is not None:
+ query = query.filter_by(bookNumber=book_number)
book_number = request.args.get("bookNumber")
if book_number:
@@ -130,7 +248,7 @@ def api_hadiths():
query = query.filter_by(hadithNumber=hadith_number)
# Order by URN for consistent results
- return query.order_by(Hadith.englishURN)
+ return query.order_by(Hadith.englishURN), serialize_as(collection)
@app.route("/v1/hadiths/", methods=["GET"])
@@ -243,31 +361,16 @@ def api_hadiths_by_refs():
if len(refs) > MAX_REFS:
abort(400, f"Too many refs (max {MAX_REFS}).")
- results = (
- Hadith.query.filter(
- or_(
- *[
- and_(
- Hadith.collection == collection,
- Hadith.hadithNumber == hadith_number,
- )
- for collection, hadith_number in refs
- ]
- )
- )
- .all()
- )
-
- by_ref = {(h.collection, h.hadithNumber): h for h in results}
+ results = Hadith.query.filter(or_(*[ref_condition(c, n) for c, n in refs])).order_by(Hadith.englishURN).all()
data = []
missing = []
for collection, hadith_number in refs:
- h = by_ref.get((collection, hadith_number))
+ h = ref_match(results, collection, hadith_number)
if h is None:
missing.append(f"{collection}:{hadith_number}")
else:
- data.append(h.serialize())
+ data.append(h.serialize(**serialize_as(collection)))
return jsonify({"count": len(data), "missing": missing, "data": data})
diff --git a/models.py b/models.py
index 7956b76..e2324c0 100644
--- a/models.py
+++ b/models.py
@@ -113,11 +113,11 @@ def get_grade(self, field_name):
except ValueError:
return [{"graded_by": getattr(self.rel_collection, field_name), "grade": grade_val}]
- def serialize(self):
+ def serialize(self, collection=None):
grades = {"en": self.get_grade("englishgrade1"), "ar": self.get_grade("arabicgrade1")}
return {
- "collection": self.collection,
+ "collection": collection or self.collection,
"bookNumber": self.bookNumber,
"chapterId": str(self.babID),
"hadithNumber": self.hadithNumber,