diff --git a/README.md b/README.md index 3236c91..6b858e2 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,17 @@ The following tools were used in the construction of the project: ``` bash . -├── api_dictionary +├── mldictionary_api +│   ├── models +│   │   ├── __init__.py +│   │   ├── base.py +│   │   ├── const.py +│   │   ├── meanings.py +│   │   └── requests.py │   ├── resources │   │   ├── __init__.py +│   │   ├── const.py +│   │   ├── response.py │   │   └── translator.py │   ├── routes │   │   ├── __init__.py @@ -82,7 +90,8 @@ The following tools were used in the construction of the project: ├── docker-compose.yml └── requirements.txt -7 directories, 18 files +8 directories, 25 files + ``` --- diff --git a/mldictionary_api/const.py b/mldictionary_api/const.py index 52de459..ef068c8 100644 --- a/mldictionary_api/const.py +++ b/mldictionary_api/const.py @@ -1,9 +1,5 @@ from random import randint as random -from mldictionary import English, Portuguese, Spanish - -from mldictionary_api.resources import Translator - VIEWS_PREFIX = '/' @@ -26,16 +22,3 @@ API_ROUTES_EXAMPLES[random(0, 1)] + 'crazy', API_ROUTES_EXAMPLES[random(0, 1)] + 'mad', ] - -DICTIONARIES = { - 'en': English(), - 'pt': Portuguese(), - 'es': Spanish(), - 'en-pt': Translator(), - 'pt-en': Translator(), -} - - -LOCAL_ADDR = "127.0.0.1" -TOTAL_REQUESTS_ALLOW = 50 -TTL_REQUEST = 60 * 60 diff --git a/mldictionary_api/models/__init__.py b/mldictionary_api/models/__init__.py index 848da7b..85ed42d 100644 --- a/mldictionary_api/models/__init__.py +++ b/mldictionary_api/models/__init__.py @@ -1 +1,2 @@ +from .meanings import RedisMeaningsCache from .requests import RedisRequests diff --git a/mldictionary_api/models/meanings.py b/mldictionary_api/models/meanings.py new file mode 100644 index 0000000..6eed91c --- /dev/null +++ b/mldictionary_api/models/meanings.py @@ -0,0 +1,17 @@ +__all__ = ['RedisMeaningsCache'] + +from mldictionary_api.models.base import RedisBaseModel + + +class RedisMeaningsCache(RedisBaseModel): + def __init__(self): + super().__init__() + + def get(self, match: str) -> list: + meanings = self.db.smembers(match) or set() + return list(meanings) + + def set(self, key, values, ttl: int): + for value in values: + self.db.sadd(key, value) + self.db.expire(key, ttl) diff --git a/mldictionary_api/resources/__init__.py b/mldictionary_api/resources/__init__.py index 546c41b..e69de29 100644 --- a/mldictionary_api/resources/__init__.py +++ b/mldictionary_api/resources/__init__.py @@ -1 +0,0 @@ -from .translator import Translator diff --git a/mldictionary_api/resources/const.py b/mldictionary_api/resources/const.py new file mode 100644 index 0000000..fee02c5 --- /dev/null +++ b/mldictionary_api/resources/const.py @@ -0,0 +1,25 @@ +from mldictionary import English, Portuguese, Spanish + +from mldictionary_api.resources.translator import Translator + +LIMITED_REQUESTS_DICTIONARIES = [Translator] +LOCAL_ADDR = '127.0.0.1' +TOTAL_REQUESTS_ALLOW = 50 +TTL_REQUEST = 60 * 60 +TTL_MEANINGS_CACHE = 24 * 60 * 60 + + +ENGLISH_REPR = 'en' +PORTUGUESE_REPR = 'pt' +SPANISH_REPR = 'es' +ENGLISH_TO_PORTUGUESE_REPR = 'en-pt' +PORTUGUESE_TO_ENGLISH = 'pt-en' + + +DICTIONARIES = { + ENGLISH_REPR: English(), + PORTUGUESE_REPR: Portuguese(), + SPANISH_REPR: Spanish(), + ENGLISH_TO_PORTUGUESE_REPR: Translator(), + PORTUGUESE_TO_ENGLISH: Translator(), +} diff --git a/mldictionary_api/resources/response.py b/mldictionary_api/resources/response.py new file mode 100644 index 0000000..5a7ff0e --- /dev/null +++ b/mldictionary_api/resources/response.py @@ -0,0 +1,74 @@ +import traceback +from typing import Type + +from flask import jsonify, request +from mldictionary import Dictionary +from werkzeug.exceptions import NotFound, TooManyRequests + +from mldictionary_api.models import RedisRequests, RedisMeaningsCache +from mldictionary_api.resources.const import ( + DICTIONARIES, + LIMITED_REQUESTS_DICTIONARIES, + LOCAL_ADDR, + TOTAL_REQUESTS_ALLOW, + TTL_MEANINGS_CACHE, + TTL_REQUEST, +) + + +class ResponseAPI: + def get_meanings(self, lang, word): + dictionary = DICTIONARIES[lang] + request_ip = self.__get_request_ip(request.headers.getlist("X-Forwarded-For")) + + if not (ilimited_dictionary := self.__valid_request(request_ip, dictionary)): + total_requests = RedisRequests().get(f'requests:{request_ip}') + if total_requests > TOTAL_REQUESTS_ALLOW: + raise TooManyRequests( + f'The address {request_ip} is allow to make only "{TOTAL_REQUESTS_ALLOW}" requests ' + f'wait until {int(TTL_REQUEST / 60)} minutes and try again' + ) + + if not (meanings := self.__get_meanings(word, dictionary)): + raise NotFound(f'"{word}" not found, check the spelling and try again') + + if not ilimited_dictionary: + self.__make_cache(request_ip, total_requests, word, meanings) + return ( + jsonify({'source': dictionary.URL.format(word), 'meanings': meanings}), + 200, + ) + + def handle_error(self, err): + traceback.print_tb(err.__traceback__) + return ( + jsonify({'message': err.description, 'http_status': err.code}), + err.code, + ) + + def __get_request_ip(self, heroku_proxy_header: list[str]): + return ( + request.remote_addr if not heroku_proxy_header else heroku_proxy_header[0] + ) + + def __valid_request(self, request_ip: str, dictionary: Type[Dictionary]): + if request_ip in LOCAL_ADDR: + return True + + for limited_dictionary in LIMITED_REQUESTS_DICTIONARIES: + if isinstance(dictionary, limited_dictionary): + return False + return True + + def __get_meanings(self, word: str, dictionary: Type[Dictionary]) -> list[str]: + meanings = RedisMeaningsCache().get(f'meanings:{word}') + return meanings if meanings else dictionary.get_meanings(word) + + def __make_cache( + self, request_ip: str, total_requests: int, word: str, meanings: list[str] + ): + + RedisRequests().set( + f'requests:{request_ip}', str(total_requests + 1), TTL_REQUEST + ) + RedisMeaningsCache().set(f'meanings:{word}', meanings, TTL_MEANINGS_CACHE) diff --git a/mldictionary_api/resources/translator.py b/mldictionary_api/resources/translator.py index b253730..aa550c8 100644 --- a/mldictionary_api/resources/translator.py +++ b/mldictionary_api/resources/translator.py @@ -4,6 +4,6 @@ class Translator(Dictionary): URL = 'https://www.linguee.com/english-portuguese/search?source=auto&query={}' LANGUAGE = 'Translator(en-pt)' - TARGET_TAG = 'span' - TARGET_ATTR = {'class': 'tag_trans'} + TARGET_TAG = 'a' + TARGET_ATTR = {'class': 'featured'} REPLACES = {} diff --git a/mldictionary_api/routes/api.py b/mldictionary_api/routes/api.py index 962bef2..f70dfb3 100644 --- a/mldictionary_api/routes/api.py +++ b/mldictionary_api/routes/api.py @@ -1,67 +1,61 @@ -import traceback - -from flask import Blueprint, jsonify, request +from flask import Blueprint from werkzeug.exceptions import NotFound, InternalServerError, TooManyRequests -from mldictionary_api.models import RedisRequests -from mldictionary_api.const import ( - API_PREFIX, - DICTIONARIES, - LOCAL_ADDR, - TOTAL_REQUESTS_ALLOW, - TTL_REQUEST, +from mldictionary_api.const import API_PREFIX +from mldictionary_api.resources.response import ResponseAPI +from mldictionary_api.resources.const import ( + ENGLISH_REPR, + ENGLISH_TO_PORTUGUESE_REPR, + PORTUGUESE_REPR, + PORTUGUESE_TO_ENGLISH, + SPANISH_REPR, ) - api = Blueprint('mldictionary_api', __name__, url_prefix=API_PREFIX) @api.route('/dictionary/en//') +def english(word: str): + return ResponseAPI().get_meanings(ENGLISH_REPR, word) + + @api.route('/dictionary/pt//') +def portuguese(word: str): + return ResponseAPI().get_meanings(PORTUGUESE_REPR, word) + + @api.route('/dictionary/es//') -@api.route('/translator/en-pt//') -@api.route('/translator/pt-en//') -def dictionary(word: str): +def spanish(word: str): + return ResponseAPI().get_meanings(SPANISH_REPR, word) - requests_db = RedisRequests() - choice = request.url.split('/')[5] - dictionary = DICTIONARIES[choice] - request_ip = request.remote_addr if not request.headers.getlist("X-Forwarded-For") else request.headers.getlist("X-Forwarded-For")[0] - total_requests = requests_db.get(f'requests:{request_ip}') - if not (meanings := dictionary.get_meanings(word)): - raise NotFound(f'"{word}" not found, check the spelling and try again') - if request_ip != LOCAL_ADDR: - if total_requests > TOTAL_REQUESTS_ALLOW: - raise TooManyRequests( - f'The address {request_ip} is allow to make only "{TOTAL_REQUESTS_ALLOW}" requests ' - f'wait until {int(TTL_REQUEST / 60)} minutes and try again' - ) +@api.route('/translator/en-pt//') +def english_to_portuguese(word: str): + return ResponseAPI().get_meanings(ENGLISH_TO_PORTUGUESE_REPR, word) - requests_db.set(f'requests:{request_ip}', str(total_requests + 1), TTL_REQUEST) - return jsonify({'source': dictionary.URL.format(word), 'meanings': meanings}), 200 +@api.route('/translator/pt-en//') +def portuguese_to_english(word: str): + return ResponseAPI().get_meanings(PORTUGUESE_TO_ENGLISH, word) @api.app_errorhandler(NotFound) def not_found(err): - traceback.print_tb(err.__traceback__) - return jsonify({'message': err.description}), err.code + return ResponseAPI().handle_error(err) @api.app_errorhandler(TooManyRequests) def too_many_requests(err): - traceback.print_tb(err.__traceback__) - return jsonify({'message': err.description}), err.code + return ResponseAPI().handle_error(err) @api.app_errorhandler(InternalServerError) def internal_error(err): - traceback.print_tb(err.__traceback__) - return jsonify({'message': err.description}), err.code + return ResponseAPI().handle_error(err) @api.app_errorhandler(Exception) def general_exception(err): - traceback.print_tb(err.__traceback__) - return jsonify({'message': 'Don\'t recognize erro'}), 500 + err.description = 'Don\'t recognize erro' + err.code = 500 + return ResponseAPI().handle_error(err)