├── .env.local.sample ├── .flake8 ├── .github └── ISSUE_TEMPLATE │ └── request-for-api-access.md ├── .gitignore ├── Dockerfile ├── Dockerfile-prod ├── README.md ├── config.py ├── db └── 00-samplegitdb.sql ├── docker-compose.prod.yml ├── docker-compose.yml ├── main.py ├── models.py ├── pyproject.toml ├── requirements.txt ├── scripts └── key-gen.sh ├── spec.v1.yml ├── text_transform.py └── uwsgi.ini /.env.local.sample: -------------------------------------------------------------------------------- 1 | MYSQL_ROOT_PASSWORD=docker 2 | MYSQL_USER=docker 3 | MYSQL_PASSWORD=docker 4 | MYSQL_HOST=db 5 | MYSQL_DATABASE=hadithdb 6 | 7 | FLASK_ENV=development 8 | FLASK_APP=main.py 9 | 10 | AWS_SECRET=secret 11 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E402 3 | max-line-length = 160 4 | max-complexity = 10 5 | exclude = 6 | .git, 7 | venv, 8 | db -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request-for-api-access.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Request for API access 3 | about: Request for access to api.sunnah.com 4 | title: 'Request for API access: [Your Name]' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | * **Please tell us about yourself (include an email address):** 11 | 12 | 13 | 14 | 15 | * **Your purpose in using this API:** 16 | 17 | 18 | 19 | 20 | * **API rate limits:** 21 | 22 | * **Maximum requests per second:** 23 | * **Maximum requests per day:** 24 | 25 | 26 | 27 | * **Is your use case better served by having an offline dump of hadith data or programmatic API access?** 28 | 29 | 30 | 31 | 32 | * **What are the languages in which would you like hadith data?** 33 | 34 | 35 | 36 | 37 | * **What programming language will your API client be in?** 38 | 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | .DS_Store 4 | 5 | .vscode/ 6 | .coverage 7 | venv 8 | .venv/ 9 | 10 | .env.* 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | RUN mkdir /code 6 | WORKDIR /code 7 | COPY requirements.txt /code/ 8 | 9 | RUN pip install -r requirements.txt 10 | 11 | RUN groupadd -g 777 appuser && \ 12 | useradd -r -u 777 -g appuser appuser 13 | USER appuser 14 | 15 | COPY . /code/ 16 | -------------------------------------------------------------------------------- /Dockerfile-prod: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | RUN mkdir /code 6 | WORKDIR /code 7 | COPY requirements.txt /code/ 8 | 9 | RUN apt-get update && \ 10 | apt-get install gcc -y && \ 11 | apt-get clean 12 | RUN pip install uwsgi && \ 13 | pip install -r requirements.txt 14 | 15 | RUN groupadd -g 777 appuser && \ 16 | useradd -r -u 777 -g appuser appuser 17 | USER appuser 18 | 19 | COPY . /code/ 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | The official API of sunnah.com for retrieving information about hadith collections. 4 | 5 | # Getting started 6 | 7 | Please follow the instructions below. 8 | 9 | First create a local `.env.local` configuration file and update values as needed. 10 | A sample file is provided at `.env.local.sample`. 11 | 12 | Run manually: 13 | ```bash 14 | git clone REPO 15 | cd REPO 16 | python3 -m venv venv 17 | source venv/bin/activate 18 | pip install -r requirements.txt 19 | export FLASK_ENV=development FLASK_APP=main.py 20 | flask run --host=0.0.0.0 21 | ``` 22 | 23 | Or alternatively use `docker-compose` which will give a full environment with a MySQL instance loaded with a sample dataset: 24 | 25 | ```bash 26 | docker-compose up 27 | ``` 28 | 29 | * Use `--build` option to re-build. 30 | * Use the `-d` option to run in detached mode. 31 | 32 | You can then visit [localhost:5000](http://localhost:5000) to verify that it's running on your machine. Or, alternatively: 33 | 34 | ```bash 35 | $ curl http://localhost:5000 36 | ``` 37 | 38 | ## Deployment 39 | 40 | Configuration files are located at `env.local` and `uwsgi.ini`. 41 | 42 | A production ready uWSGI daemon (uwsgi socket exposed on port 5001) can be started with: 43 | 44 | ```bash 45 | docker-compose -f docker-compose.prod.yml up -d --build 46 | ``` 47 | 48 | ## Routes 49 | 50 | Visit https://sunnah.stoplight.io/docs/api/ for full API documentation. 51 | 52 | ## Linting and Formatting 53 | 54 | `flake8` and `black` are used for code linting and formatting respectively. Before submitting pull requests, make sure black and flake8 is run against the code. Follow the instructions below for using `black` and `flake8`: 55 | 56 | ```sh 57 | # goto repository root directory 58 | # make sure the virtual environment is activated 59 | black . 60 | flake8 . 61 | # fix any linting issues 62 | # Then you are ready to submit your PR 63 | ``` 64 | 65 | To add more rules for linting and formatting, make changes to `.flake8` and `pyproject.toml` accordingly. 66 | 67 | # Guidelines for Sending a Pull Request 68 | 69 | 1. Only change one thing at a time. 70 | 2. Don't mix a lot of formatting changes with logic change in the same pull request. 71 | 3. Keep code refactor and logic change in separate pull requests. 72 | 4. Squash your commits. When you address feedback, squash it as well. No one benefits from "addressed feedback" commit in the history. 73 | 5. Break down bigger changes into smaller separate pull requests. 74 | 6. If changing UI, attach a screenshot of how the changes look. 75 | 7. Reference the issue being fixed by adding the issue tag in the commit message. 76 | 8. Do not send a big change before first proposing it and getting a buy-in from the maintainer. 77 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv(".env.local") 5 | 6 | 7 | class Config(object): 8 | JSON_SORT_KEYS = False 9 | AWS_SECRET = "{AWS_SECRET}".format(**os.environ) 10 | SQLALCHEMY_TRACK_MODIFICATIONS = False 11 | SQLALCHEMY_DATABASE_URI = "mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}/{MYSQL_DATABASE}".format(**os.environ) 12 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | web: 5 | command: uwsgi --ini uwsgi.ini 6 | build: 7 | context: . 8 | dockerfile: Dockerfile-prod 9 | ports: 10 | - "5001:5001" 11 | restart: on-failure 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | image: mysql:8.0.35 6 | platform: linux/x86_64 7 | command: --default-authentication-plugin=mysql_native_password 8 | volumes: 9 | - ./db:/docker-entrypoint-initdb.d/:ro 10 | env_file: 11 | - .env.local 12 | web: 13 | depends_on: 14 | - "db" 15 | build: . 16 | platform: linux/x86_64 17 | command: flask run --host=0.0.0.0 18 | volumes: 19 | - .:/code 20 | ports: 21 | - "5000:5000" 22 | env_file: 23 | - .env.local 24 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from flask import Flask, jsonify, request, abort 3 | from sqlalchemy import func, or_ 4 | from werkzeug.exceptions import HTTPException 5 | 6 | app = Flask(__name__) 7 | app.config.from_object("config.Config") 8 | 9 | from models import HadithCollection, Book, Chapter, Hadith 10 | 11 | 12 | @app.before_request 13 | def verify_secret(): 14 | if not app.debug and request.headers.get("x-aws-secret") != app.config["AWS_SECRET"]: 15 | abort(401) 16 | 17 | 18 | @app.errorhandler(HTTPException) 19 | def jsonify_http_error(error): 20 | response = {"error": {"details": error.description, "code": error.code}} 21 | 22 | return jsonify(response), error.code 23 | 24 | 25 | def paginate_results(f): 26 | @functools.wraps(f) 27 | def decorated_function(*args, **kwargs): 28 | limit = int(request.args.get("limit", 50)) 29 | page = int(request.args.get("page", 1)) 30 | 31 | queryset = f(*args, **kwargs).paginate(page=page, per_page=limit, max_per_page=100) 32 | result = { 33 | "data": [x.serialize() for x in queryset.items], 34 | "total": queryset.total, 35 | "limit": queryset.per_page, 36 | "previous": queryset.prev_num, 37 | "next": queryset.next_num, 38 | } 39 | return jsonify(result) 40 | 41 | return decorated_function 42 | 43 | 44 | def single_resource(f): 45 | @functools.wraps(f) 46 | def decorated_function(*args, **kwargs): 47 | result = f(*args, **kwargs).first_or_404() 48 | result = result.serialize() 49 | return jsonify(result) 50 | 51 | return decorated_function 52 | 53 | 54 | @app.route("/", methods=["GET"]) 55 | def home(): 56 | return "

Welcome to sunnah.com API.

" 57 | 58 | 59 | @app.route("/v1/collections", methods=["GET"]) 60 | @paginate_results 61 | def api_collections(): 62 | return HadithCollection.query.order_by(HadithCollection.collectionID) 63 | 64 | 65 | @app.route("/v1/collections/", methods=["GET"]) 66 | @single_resource 67 | def api_collection(name): 68 | return HadithCollection.query.filter_by(name=name) 69 | 70 | 71 | @app.route("/v1/collections//books", methods=["GET"]) 72 | @paginate_results 73 | def api_collection_books(name): 74 | return Book.query.filter_by(collection=name, status=4).order_by(func.abs(Book.ourBookID)) 75 | 76 | 77 | @app.route("/v1/collections//books/", methods=["GET"]) 78 | @single_resource 79 | def api_collection_book(name, bookNumber): 80 | book_id = Book.get_id_from_number(bookNumber) 81 | return Book.query.filter_by(collection=name, status=4, ourBookID=book_id) 82 | 83 | 84 | @app.route("/v1/collections//books//hadiths", methods=["GET"]) 85 | @paginate_results 86 | def api_collection_book_hadiths(collection_name, bookNumber): 87 | return Hadith.query.filter_by(collection=collection_name, bookNumber=bookNumber).order_by(Hadith.englishURN) 88 | 89 | 90 | @app.route("/v1/collections//hadiths/", methods=["GET"]) 91 | @single_resource 92 | def api_collection_hadith(collection_name, hadithNumber): 93 | return Hadith.query.filter_by(collection=collection_name, hadithNumber=hadithNumber) 94 | 95 | 96 | @app.route("/v1/collections//books//chapters", methods=["GET"]) 97 | @paginate_results 98 | def api_collection_book_chapters(collection_name, bookNumber): 99 | book_id = Book.get_id_from_number(bookNumber) 100 | return Chapter.query.filter_by(collection=collection_name, arabicBookID=book_id).order_by(Chapter.babID) 101 | 102 | 103 | @app.route("/v1/collections//books//chapters/", methods=["GET"]) 104 | @single_resource 105 | def api_collection_book_chapter(collection_name, bookNumber, chapterId): 106 | book_id = Book.get_id_from_number(bookNumber) 107 | return Chapter.query.filter_by(collection=collection_name, arabicBookID=book_id, babID=chapterId) 108 | 109 | 110 | @app.route("/v1/hadiths/", methods=["GET"]) 111 | @single_resource 112 | def api_hadith(urn): 113 | return Hadith.query.filter(or_(Hadith.arabicURN == urn, Hadith.englishURN == urn)) 114 | 115 | 116 | @app.route("/v1/hadiths/random", methods=["GET"]) 117 | @single_resource 118 | def api_hadiths_random(): 119 | # TODO Make this configurable instead of hardcoding 120 | return Hadith.query.filter_by(collection="riyadussalihin").order_by(func.rand()) 121 | 122 | 123 | if __name__ == "__main__": 124 | app.run(host="0.0.0.0") 125 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from main import app 3 | from text_transform import cleanup_text, cleanup_en_text, cleanup_chapter_title, cleanup_en_chapter_title 4 | import json 5 | 6 | 7 | db = SQLAlchemy(app) 8 | db.reflect() 9 | 10 | 11 | def is_number(s): 12 | try: 13 | int(s) 14 | return True 15 | except ValueError: 16 | return False 17 | 18 | 19 | class HadithCollection(db.Model): 20 | __tablename__ = "Collections" 21 | 22 | def serialize(self): 23 | return { 24 | "name": self.name, 25 | "hasBooks": self.includesBooks, 26 | "hasChapters": self.includesChapters, 27 | "collection": [ 28 | {"lang": "en", "title": self.englishTitle, "shortIntro": self.shortintro}, 29 | {"lang": "ar", "title": self.arabicTitle, "shortIntro": self.shortIntroArabic if hasattr(self, "shortIntroArabic") else self.shortintro}, 30 | ], 31 | "totalHadith": self.totalhadith, 32 | "totalAvailableHadith": self.numhadith, 33 | } 34 | 35 | 36 | class Book(db.Model): 37 | __tablename__ = "BookData" 38 | 39 | id_number_map = {-1: "introduction", -35: "35b"} 40 | 41 | @staticmethod 42 | def get_number_from_id(book_id): 43 | # Logic for dealing with non-straightforward ourBookIDs 44 | book_number = Book.id_number_map.get(book_id, int(book_id)) 45 | return str(book_number) 46 | 47 | @staticmethod 48 | def get_id_from_number(book_number): 49 | number_id_map = {v: k for k, v in Book.id_number_map.items()} 50 | 51 | if is_number(book_number): 52 | book_id = int(book_number) 53 | else: 54 | book_id = number_id_map.get(book_number) 55 | 56 | return str(book_id) 57 | 58 | def serialize(self): 59 | bookNumber = Book.get_number_from_id(self.ourBookID) 60 | return { 61 | "bookNumber": bookNumber, 62 | "book": [{"lang": "en", "name": self.englishBookName}, {"lang": "ar", "name": self.arabicBookName}], 63 | "hadithStartNumber": self.firstNumber, 64 | "hadithEndNumber": self.lastNumber, 65 | "numberOfHadith": self.totalNumber, 66 | } 67 | 68 | 69 | class Chapter(db.Model): 70 | __tablename__ = "ChapterData" 71 | 72 | def serialize(self): 73 | bookNumber = Book.get_number_from_id(self.arabicBookID) 74 | return { 75 | "bookNumber": bookNumber, 76 | "chapterId": str(self.babID), 77 | "chapter": [ 78 | { 79 | "lang": "en", 80 | "chapterNumber": str(self.englishBabNumber), 81 | "chapterTitle": cleanup_en_chapter_title(self.englishBabName), 82 | "intro": cleanup_en_text(self.englishIntro), 83 | "ending": cleanup_en_text(self.englishEnding), 84 | }, 85 | { 86 | "lang": "ar", 87 | "chapterNumber": str(self.arabicBabNumber), 88 | "chapterTitle": cleanup_chapter_title(self.arabicBabName), 89 | "intro": cleanup_text(self.arabicIntro), 90 | "ending": cleanup_text(self.arabicEnding), 91 | }, 92 | ], 93 | } 94 | 95 | 96 | class Hadith(db.Model): 97 | __tablename__ = "HadithTable" 98 | 99 | rel_collection = db.relationship( 100 | "HadithCollection", primaryjoin="Hadith.collection == HadithCollection.name", foreign_keys="Hadith.collection", lazy="joined" 101 | ) 102 | 103 | def get_grade(self, field_name): 104 | grade_val = getattr(self, field_name) 105 | if not grade_val: 106 | return [] 107 | 108 | # If the field has a json value, return it 109 | # Otherwise build same data structure from individual string fields 110 | try: 111 | vals = json.loads(grade_val) 112 | return [dict((k, x[k]) for k in ("graded_by", "grade")) for x in vals] 113 | except ValueError: 114 | return [{"graded_by": getattr(self.rel_collection, field_name), "grade": grade_val}] 115 | 116 | def serialize(self): 117 | grades = {"en": self.get_grade("englishgrade1"), "ar": self.get_grade("arabicgrade1")} 118 | 119 | return { 120 | "collection": self.collection, 121 | "bookNumber": self.bookNumber, 122 | "chapterId": str(self.babID), 123 | "hadithNumber": self.hadithNumber, 124 | "hadith": [ 125 | { 126 | "lang": "en", 127 | "chapterNumber": self.englishBabNumber, 128 | "chapterTitle": cleanup_en_chapter_title(self.englishBabName), 129 | "urn": self.englishURN, 130 | "body": cleanup_en_text(self.englishText), 131 | "grades": grades["en"], 132 | }, 133 | { 134 | "lang": "ar", 135 | "chapterNumber": self.arabicBabNumber, 136 | "chapterTitle": cleanup_chapter_title(self.arabicBabName), 137 | "urn": self.arabicURN, 138 | "body": cleanup_text(self.arabicText), 139 | "grades": grades["ar"], 140 | }, 141 | ], 142 | } 143 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 160 3 | target-version = ['py38'] 4 | include = '\.py?$' 5 | exclude = ''' 6 | ( 7 | /( 8 | | \.git 9 | | \venv 10 | )/ 11 | ) 12 | ''' 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.4 2 | astroid==2.4.1 3 | attrs==19.3.0 4 | black==24.3.0 5 | cfgv==3.1.0 6 | click==7.1.2 7 | colorama==0.4.3 8 | distlib==0.3.1 9 | filelock==3.0.12 10 | flake8==3.8.3 11 | Flask==2.2.5 12 | Flask-SQLAlchemy==2.4.3 13 | identify==1.4.20 14 | importlib-metadata==1.7.0 15 | isort==4.3.21 16 | itsdangerous==1.1.0 17 | Jinja2==3.1.4 18 | lazy-object-proxy==1.4.3 19 | lxml==4.9.1 20 | MarkupSafe==1.1.1 21 | mccabe==0.6.1 22 | nodeenv==1.4.0 23 | pathspec==0.8.0 24 | pycodestyle==2.6.0 25 | pyflakes==2.2.0 26 | pylint==2.5.2 27 | PyMySQL==1.1.1 28 | python-dotenv==0.13.0 29 | PyYAML==5.4 30 | regex==2020.6.8 31 | six==1.15.0 32 | SQLAlchemy==1.3.17 33 | toml==0.10.1 34 | typed-ast==1.4.1 35 | virtualenv==20.0.25 36 | Werkzeug==3.0.6 37 | wrapt==1.12.1 38 | zipp==3.19.1 39 | -------------------------------------------------------------------------------- /scripts/key-gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script requires aws-cli to be installed 3 | # Please visit https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2-linux.html 4 | # After installation use 'aws configure' to setup the credentials and then execute the script 5 | 6 | if [ "$#" -eq 0 ] 7 | then 8 | echo "This script takes one command-line argument: the name of the key to be created" 9 | exit 1 10 | fi 11 | 12 | KEYNAME=$1 13 | KEYINFO=`aws apigateway create-api-key --name ${KEYNAME} --region us-west-2 --enabled 2>&1` 14 | # KEYVALUE=`aws apigateway get-api-key --api-key ${KEYNAME} --include-value --region us-west-2 | grep value | cut -d\" -f4 2>&1` 15 | echo "Attempting to run command aws apigateway create-api-key --name ${KEYNAME} --region us-west-2 --enabled 2>&1" 16 | # echo "Command returned ${KEYINFO}" 17 | KEYID=`echo ${KEYINFO} | grep id | cut -d\" -f4` 18 | KEYVALUE=`echo ${KEYINFO} | grep value | cut -d\" -f8` 19 | aws apigateway create-usage-plan-key --usage-plan-id b3rk95 --key-type API_KEY --key-id ${KEYID} --region us-west-2 2>&1 20 | echo -e "\nNew key successfully created: ${KEYVALUE}" 21 | 22 | if [ -f "apimail.php" ]; then 23 | php apimail.php ${KEYNAME} ${KEYVALUE} 24 | fi 25 | -------------------------------------------------------------------------------- /spec.v1.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | version: "1.0" 4 | title: Sunnah.com API 5 | description: >- 6 | Sunnah.com API offers access to verified data of collections of hadith of 7 | the Prophet (SAWS). Here you will find a list of the available endpoints in 8 | our REST API. 9 | 10 | 11 | You will need an API key to access this data, which you may request by creating an 12 | issue on our GitHub repo. [Create an issue](https://github.com/sunnah-com/api/issues/new?template=request-for-api-access.md&title=Request+for+API+access%3A+[Your+Name]) 13 | contact: 14 | name: Sunnah.com 15 | url: https://sunnah.com/contact 16 | security: 17 | - APIKey: [] 18 | paths: 19 | /collections: 20 | get: 21 | summary: Get the list of collections 22 | description: "" 23 | responses: 24 | "200": 25 | description: Paginated list of available collections 26 | content: 27 | application/json: 28 | schema: 29 | type: object 30 | allOf: 31 | - properties: 32 | data: 33 | type: array 34 | items: 35 | $ref: "#/components/schemas/Collection" 36 | - $ref: "#/components/schemas/PaginatedResponse" 37 | parameters: 38 | - $ref: "#/components/parameters/limit" 39 | - $ref: "#/components/parameters/page" 40 | "/collections/{collectionName}": 41 | get: 42 | summary: Get a collection by name 43 | description: "" 44 | responses: 45 | "200": 46 | description: A collection 47 | content: 48 | application/json: 49 | schema: 50 | $ref: "#/components/schemas/Collection" 51 | parameters: 52 | - in: path 53 | name: collectionName 54 | description: Name of the collection 55 | required: true 56 | schema: 57 | type: string 58 | "/collections/{collectionName}/books": 59 | get: 60 | summary: Get the list of books for a collection 61 | description: "" 62 | responses: 63 | "200": 64 | description: Paginated list of available books of a collection 65 | content: 66 | application/json: 67 | schema: 68 | type: object 69 | allOf: 70 | - properties: 71 | data: 72 | type: array 73 | items: 74 | $ref: "#/components/schemas/Book" 75 | - $ref: "#/components/schemas/PaginatedResponse" 76 | parameters: 77 | - in: path 78 | name: collectionName 79 | description: Name of the collection 80 | required: true 81 | schema: 82 | type: string 83 | - $ref: "#/components/parameters/limit" 84 | - $ref: "#/components/parameters/page" 85 | "/collections/{collectionName}/books/{bookNumber}": 86 | get: 87 | summary: Get a book of a collection 88 | description: "" 89 | responses: 90 | "200": 91 | description: A book of a collection 92 | content: 93 | application/json: 94 | schema: 95 | $ref: "#/components/schemas/Book" 96 | parameters: 97 | - in: path 98 | name: collectionName 99 | description: Name of the collection 100 | required: true 101 | schema: 102 | type: string 103 | - in: path 104 | name: bookNumber 105 | description: Number of the book 106 | required: true 107 | schema: 108 | type: string 109 | "/collections/{collectionName}/books/{bookNumber}/chapters": 110 | get: 111 | summary: Get the list of chapters of a book for a collection 112 | description: "" 113 | responses: 114 | "200": 115 | description: Paginated list of chapters of a book of a collection 116 | content: 117 | application/json: 118 | schema: 119 | type: object 120 | allOf: 121 | - properties: 122 | data: 123 | type: array 124 | items: 125 | $ref: "#/components/schemas/Chapter" 126 | - $ref: "#/components/schemas/PaginatedResponse" 127 | parameters: 128 | - in: path 129 | name: collectionName 130 | description: Name of the collection 131 | required: true 132 | schema: 133 | type: string 134 | - in: path 135 | name: bookNumber 136 | description: Number of the book 137 | required: true 138 | schema: 139 | type: string 140 | - $ref: "#/components/parameters/limit" 141 | - $ref: "#/components/parameters/page" 142 | "/collections/{collectionName}/books/{bookNumber}/chapters/{chapterId}": 143 | get: 144 | summary: Get a chapter of a book for a collection 145 | description: "" 146 | responses: 147 | "200": 148 | description: A chapter of a book for a collection 149 | content: 150 | application/json: 151 | schema: 152 | $ref: "#/components/schemas/Chapter" 153 | parameters: 154 | - in: path 155 | name: collectionName 156 | description: Name of the collection 157 | required: true 158 | schema: 159 | type: string 160 | - in: path 161 | name: bookNumber 162 | description: Number of the book 163 | required: true 164 | schema: 165 | type: string 166 | - in: path 167 | name: chapterId 168 | description: Number of the book 169 | required: true 170 | schema: 171 | type: number 172 | format: float 173 | "/collections/{collectionName}/books/{bookNumber}/hadiths": 174 | get: 175 | summary: Get the list of hadith of a book for a collection 176 | description: "" 177 | responses: 178 | "200": 179 | description: Paginated list of hadiths of a book of a collection 180 | content: 181 | application/json: 182 | schema: 183 | type: object 184 | allOf: 185 | - properties: 186 | data: 187 | type: array 188 | items: 189 | $ref: "#/components/schemas/Hadith" 190 | - $ref: "#/components/schemas/PaginatedResponse" 191 | parameters: 192 | - in: path 193 | name: collectionName 194 | description: Name of the collection 195 | required: true 196 | schema: 197 | type: string 198 | - in: path 199 | name: bookNumber 200 | description: Number of the book 201 | required: true 202 | schema: 203 | type: string 204 | - $ref: "#/components/parameters/limit" 205 | - $ref: "#/components/parameters/page" 206 | "/collections/{collectionName}/hadiths/{hadithNumber}": 207 | get: 208 | summary: Get a hadith of a collection 209 | description: "" 210 | responses: 211 | "200": 212 | description: Hadith of a book of a collection 213 | content: 214 | application/json: 215 | schema: 216 | $ref: "#/components/schemas/Hadith" 217 | parameters: 218 | - in: path 219 | name: collectionName 220 | description: Name of the collection 221 | required: true 222 | schema: 223 | type: string 224 | - in: path 225 | name: hadithNumber 226 | description: Number of the hadith 227 | required: true 228 | schema: 229 | type: string 230 | "/hadiths/{urn}": 231 | get: 232 | summary: Get a hadith by its URN 233 | description: "" 234 | responses: 235 | "200": 236 | description: A hadith 237 | content: 238 | application/json: 239 | schema: 240 | $ref: "#/components/schemas/Hadith" 241 | parameters: 242 | - in: path 243 | name: urn 244 | required: true 245 | description: English or Arabic URN 246 | schema: 247 | type: integer 248 | /hadiths/random: 249 | get: 250 | summary: Get a randomly selected hadith 251 | description: "" 252 | responses: 253 | "200": 254 | description: A randomly selected hadith 255 | content: 256 | application/json: 257 | schema: 258 | $ref: "#/components/schemas/Hadith" 259 | servers: 260 | - url: https://api.sunnah.com/v1/ 261 | components: 262 | parameters: 263 | limit: 264 | in: query 265 | name: limit 266 | description: Maximum number of items 267 | required: false 268 | schema: 269 | type: integer 270 | maximum: 100 271 | default: 50 272 | page: 273 | in: query 274 | name: page 275 | description: Offset for pagination 276 | required: false 277 | schema: 278 | type: integer 279 | default: 1 280 | securitySchemes: 281 | APIKey: 282 | name: X-API-Key 283 | type: apiKey 284 | in: header 285 | schemas: 286 | PaginatedResponse: 287 | type: object 288 | properties: 289 | total: 290 | type: integer 291 | description: Total number of results matching the dataset 292 | limit: 293 | type: integer 294 | description: Maximum number of results in the current dataset 295 | previous: 296 | type: integer 297 | nullable: true 298 | description: Number of the previous page 299 | next: 300 | type: integer 301 | nullable: true 302 | description: Number of the next page 303 | Hadith: 304 | type: object 305 | properties: 306 | collection: 307 | type: string 308 | description: Name of the collection 309 | bookNumber: 310 | type: string 311 | description: The number of the book this hadith belongs to 312 | chapterId: 313 | type: string 314 | description: The ID of the chapter this hadith belongs to 315 | hadithNumber: 316 | type: string 317 | hadith: 318 | type: array 319 | description: Language specific data of the hadith 320 | items: 321 | type: object 322 | properties: 323 | lang: 324 | type: string 325 | chapterNumber: 326 | type: string 327 | chapterTitle: 328 | type: string 329 | urn: 330 | type: integer 331 | body: 332 | type: string 333 | grades: 334 | type: array 335 | description: Hadith grade information 336 | items: 337 | type: object 338 | properties: 339 | graded_by: 340 | type: string 341 | grade: 342 | type: string 343 | Chapter: 344 | properties: 345 | bookNumber: 346 | type: string 347 | description: The number of the book this chapter belongs to 348 | chapterId: 349 | type: string 350 | description: The ID of the chapter 351 | chapter: 352 | type: array 353 | description: Language specific data of the chapter 354 | items: 355 | type: object 356 | properties: 357 | lang: 358 | type: string 359 | chapterNumber: 360 | type: string 361 | chapterTitle: 362 | type: string 363 | intro: 364 | type: string 365 | nullable: true 366 | ending: 367 | type: string 368 | nullable: true 369 | Book: 370 | properties: 371 | bookNumber: 372 | type: string 373 | description: Number of the book 374 | book: 375 | type: array 376 | description: Language specific data of the book 377 | items: 378 | type: object 379 | properties: 380 | lang: 381 | type: string 382 | name: 383 | type: string 384 | hadithStartNumber: 385 | type: integer 386 | description: The first hadith number that is available in this book 387 | hadithEndNumber: 388 | type: integer 389 | description: The last hadith number that is available in this book 390 | numberOfHadith: 391 | type: integer 392 | description: Total number of available hadith in this book 393 | Collection: 394 | properties: 395 | name: 396 | type: string 397 | description: Name of the collection 398 | hasBooks: 399 | type: boolean 400 | description: Whether the collection has books or not 401 | hasChapters: 402 | type: boolean 403 | description: Whether the collection has chapters or not 404 | collection: 405 | type: array 406 | description: Language specific data of the collection 407 | items: 408 | type: object 409 | properties: 410 | lang: 411 | type: string 412 | title: 413 | type: string 414 | shortIntro: 415 | type: string 416 | totalHadith: 417 | type: integer 418 | description: Total number of hadith in the collection 419 | totalAvailableHadith: 420 | type: integer 421 | description: Total number of available hadith in the collection 422 | -------------------------------------------------------------------------------- /text_transform.py: -------------------------------------------------------------------------------- 1 | import re 2 | import lxml.html 3 | import lxml 4 | 5 | 6 | def fix_html(text, remove_wrapper=False): 7 | """Fix invalid html, remove unnecessary attribs, tags and whitespace""" 8 | text = text.strip() 9 | text = text.replace("\r", "") # remove \r as lxml escapes it 10 | doc = lxml.html.document_fromstring(text) 11 | 12 | anchors = doc.body.findall(".//a") 13 | for anchor in anchors: 14 | anchor.attrib.pop("id", None) 15 | anchor.attrib.pop("name", None) 16 | 17 | children = [] 18 | for elem in doc.body: 19 | if not elem.text: 20 | continue 21 | children.append(lxml.etree.tostring(elem, encoding="unicode")) 22 | text = "\n".join(children) 23 | if remove_wrapper: 24 | text = re.sub(r"^

", "", text) 25 | text = re.sub(r"

$", "", text) 26 | text = re.sub(r"", "", text) # remove like tags 27 | return text 28 | 29 | 30 | def standardize_terms(text): 31 | terms = [ 32 | ("PBUH", "\ufdfa"), 33 | ("P.B.U.H.", "\ufdfa"), 34 | ("peace_be_upon_him", "\ufdfa"), 35 | ("(may peace be upon him)", "(\ufdfa)"), 36 | ("(saws)", "(\ufdfa)"), 37 | ("(SAW)", "(\ufdfa)"), 38 | ("(saw)", "(\ufdfa)"), 39 | ("he Apostle of Allah", "he Messenger of Allah"), 40 | ("he Apostle of Allaah", "he Messenger of Allah"), 41 | ("Allah's Apostle", "Allah's Messenger"), 42 | ("he Holy Prophet ", "he Prophet "), 43 | ] 44 | 45 | for old, new in terms: 46 | text = text.replace(old, new) 47 | 48 | text = re.sub(r"Allah\'s Messenger (?!\()", "Allah's Messenger (\ufdfa) ", text) 49 | text = re.sub(r"he Messenger of Allah (?!\()", "he Messenger of Allah (\ufdfa) ", text) 50 | text = re.sub(r"he Prophet (?!\()", "he Prophet (\ufdfa) ", text) 51 | 52 | return text 53 | 54 | 55 | def fix_hyperlinks(text): 56 | """Converts links to ayah and hadith to quran.com and sunnah.com respectively""" 57 | text = text.replace('href="/', 'href="https://sunnah.com/') 58 | 59 | quran_links = re.findall(r"javascript:openquran\((.+?)\)", text) 60 | for link_match in quran_links: 61 | surah, begin, end = link_match.split(",") 62 | text = text.replace("javascript:openquran({})".format(link_match), "https://quran.com/{}/{}-{}".format(int(surah) + 1, begin, end)) 63 | return text 64 | 65 | 66 | def cleanup_text(text): 67 | if not text: 68 | return text 69 | text = re.sub(r"\n+", "\n", text) 70 | text = re.sub(r" +", " ", text) 71 | text = fix_html(text) 72 | text = fix_hyperlinks(text) 73 | text = text.strip() 74 | return text 75 | 76 | 77 | def cleanup_en_text(text): 78 | if not text: 79 | return text 80 | text = cleanup_text(text) 81 | text = standardize_terms(text) 82 | return text 83 | 84 | 85 | def cleanup_chapter_title(text): 86 | if not text: 87 | return text 88 | text = re.sub(r"\n+", "\n", text) 89 | text = re.sub(r" +", " ", text) 90 | text = fix_html(text, remove_wrapper=True) 91 | text = fix_hyperlinks(text) 92 | text = text.strip() 93 | return text 94 | 95 | 96 | def cleanup_en_chapter_title(text): 97 | if not text: 98 | return text 99 | text = cleanup_chapter_title(text) 100 | text = standardize_terms(text) 101 | return text 102 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | module = main:app 3 | socket = 0.0.0.0:5001 4 | master = true 5 | processes = 4 6 | --------------------------------------------------------------------------------