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,