├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.rst ├── appveyor.yml ├── deployment-redis.yml ├── deployment-reducepy.yml ├── docker-compose.yml ├── locustfile.py ├── reducepy ├── __init__.py ├── app.py ├── store.py └── url_shorten.py ├── requirements.testing.txt ├── requirements.txt ├── service-redis.yml ├── service-reducepy.yml ├── setup.py ├── tests ├── test_app.py ├── test_store.py └── test_url_shorten.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ReducePy CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.operating-system }} 9 | strategy: 10 | max-parallel: 4 11 | matrix: 12 | python-version: [3.6, 3.7, 3.8, 3.9, '3.10'] 13 | operating-system: [ubuntu-latest, windows-latest, macos-latest] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -e . 25 | pip install -r requirements.testing.txt 26 | - name: Lint with flake8 27 | run: | 28 | pip install flake8 29 | # stop the build if there are Python syntax errors or undefined names 30 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 31 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 32 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 33 | - name: Test with pytest 34 | run: | 35 | py.test -s -vv --cov-report xml --cov=reducepy tests/ 36 | codecov 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | #pytest 104 | .pytest_cache/ 105 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.5" 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | - "3.9" 9 | 10 | # command to install dependencies 11 | install: 12 | - pip install -r requirements.txt --upgrade 13 | - pip install -r requirements.testing.txt --upgrade 14 | - pip install -e . 15 | 16 | # command to run tests 17 | script: 18 | - py.test -s -v --cov-report xml --cov=reducepy tests/ 19 | - codecov 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.nosetestsEnabled": false, 7 | "python.testing.pytestEnabled": true 8 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | ADD . /code 3 | WORKDIR /code 4 | RUN pip install -r requirements.txt 5 | RUN pip install -e . 6 | CMD ["python", "reducepy/app.py"] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Abdullah Selek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | ReducePy 3 | ======== 4 | 5 | .. image:: https://github.com/abdullahselek/ReducePy/workflows/ReducePy%20CI/badge.svg 6 | :target: https://github.com/abdullahselek/ReducePy/actions 7 | 8 | .. image:: https://codecov.io/gh/abdullahselek/ReducePy/branch/master/graph/badge.svg 9 | :target: https://codecov.io/gh/abdullahselek/ReducePy 10 | :alt: Codecov 11 | 12 | ============ 13 | Introduction 14 | ============ 15 | 16 | Url shortener service using `Tornado` and `Redis` runs on `Docker` and `Kubernetes`. 17 | 18 | ================ 19 | Getting the code 20 | ================ 21 | 22 | The code is hosted at https://github.com/abdullahselek/ReducePy 23 | 24 | Check out the latest development version anonymously with:: 25 | 26 | $ git clone git://github.com/abdullahselek/ReducePy.git 27 | $ cd ReducePy 28 | 29 | To install dependencies, run either:: 30 | 31 | $ pip install -Ur requirements.testing.txt 32 | $ pip install -Ur requirements.txt 33 | 34 | To install the minimal dependencies for production use run:: 35 | 36 | $ pip install -Ur requirements.txt 37 | 38 | ======================== 39 | Downloading Docker Image 40 | ======================== 41 | 42 | You can download docker image with:: 43 | 44 | docker pull abdullahselek/reducepy 45 | 46 | and the docker page for the image https://hub.docker.com/r/abdullahselek/reducepy/. 47 | 48 | ============= 49 | Running Tests 50 | ============= 51 | 52 | The test suite can be run against a single Python version which requires ``pip install pytest`` and optionally ``pip install pytest-cov`` (these are included if you have installed dependencies from ``requirements.testing.txt``) 53 | 54 | To run the unit tests with a single Python version:: 55 | 56 | $ py.test -v 57 | 58 | to also run code coverage:: 59 | 60 | $ py.test -v --cov-report xml --cov=reducepy 61 | 62 | To run the unit tests against a set of Python versions:: 63 | 64 | $ tox 65 | 66 | ======== 67 | Commands 68 | ======== 69 | 70 | --- 71 | Run 72 | --- 73 | 74 | Running up in Docker 75 | 76 | .. code:: 77 | 78 | docker-compose up 79 | 80 | Running in Kubernetes 81 | 82 | - For testing you can run **reducepy** in **Kubernetes** with using **Docker**. Run docker and then the following 83 | commands should work for you. 84 | 85 | .. code:: 86 | 87 | # Use Docker for minikube 88 | eval $(minikube docker-env) 89 | 90 | # Create developments and pods 91 | kubectl create -f deployment-redis.yml 92 | kubectl create -f deployment-reducepy.yml 93 | 94 | # Create services 95 | kubectl create -f service-redis.yml 96 | kubectl create -f service-reducepy.yml 97 | 98 | # Get url for **reducepy** 99 | minikube service reducepy --url 100 | 101 | ------------ 102 | Sample Usage 103 | ------------ 104 | 105 | .. code:: 106 | 107 | # Shorten url with POST 108 | curl -i http://127.0.0.1 -F "url=https://github.com" 109 | 110 | # Response 111 | { 112 | "error": false, 113 | "shortened_url": "http://127.0.0.1/YjUwYQ" 114 | } 115 | 116 | # Redirect to original url 117 | http://127.0.0.1/YjUwYQ 118 | 119 | # Error case with invalid url 120 | curl -i http://127.0.0.1 -F "url=github" 121 | 122 | # Response 123 | { 124 | "error": true, 125 | "message": "Please post a valid url" 126 | } 127 | 128 | # Error case with null url 129 | curl -i http://127.0.0.1 -F "url=" 130 | 131 | # Response 132 | { 133 | "error": true, 134 | "message": "Please post a url" 135 | } 136 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | 3 | matrix: 4 | 5 | # For Python versions available on Appveyor, see 6 | # http://www.appveyor.com/docs/installed-software#python 7 | # The list here is complete (excluding Python 2.6, which 8 | # isn't covered by this document) at the time of writing. 9 | 10 | - PYTHON: "C:\\Python36-x64" 11 | - PYTHON: "C:\\Python37" 12 | - PYTHON: "C:\\Python37-x64" 13 | - PYTHON: "C:\\Python38" 14 | - PYTHON: "C:\\Python38-x64" 15 | - PYTHON: "C:\\Python39" 16 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 17 | - PYTHON: "C:\\Python39-x64" 18 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 19 | 20 | install: 21 | # We need wheel installed to build wheels 22 | # - "%PYTHON%\\python.exe -m pip install wheel" 23 | - "%PYTHON%\\python.exe -m pip install -r requirements.txt" 24 | - "%PYTHON%\\python.exe -m pip install -r requirements.testing.txt" 25 | 26 | build: off 27 | 28 | test_script: 29 | # Put your test command here. 30 | # If you don't need to build C extensions on 64-bit Python 3.3 or 3.4, 31 | # you can remove "build.cmd" from the front of the command, as it's 32 | # only needed to support those cases. 33 | # Note that you must use the environment variable %PYTHON% to refer to 34 | # the interpreter you're using - Appveyor does not do anything special 35 | # to put the Python version you want to use on PATH. 36 | - "%PYTHON%\\python.exe -m pytest -s -v --cov-report xml --cov=reducepy" 37 | 38 | # after_test: 39 | # This step builds your wheels. 40 | # Again, you only need build.cmd if you're building C extensions for 41 | # 64-bit Python 3.3/3.4. And you need to use %PYTHON% to get the correct 42 | # interpreter 43 | # - "%PYTHON%\\python.exe setup.py bdist_wheel" 44 | 45 | # artifacts: 46 | # bdist_wheel puts your built wheel in the dist directory 47 | # - path: dist\* 48 | 49 | #on_success: 50 | # You can use this step to upload your artifacts to a public website. 51 | # See Appveyor's documentation for more details. Or you can simply 52 | # access your wheels from the Appveyor "artifacts" tab for your build. 53 | -------------------------------------------------------------------------------- /deployment-redis.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: redis 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: redis 9 | template: 10 | metadata: 11 | labels: 12 | app: redis 13 | spec: 14 | containers: 15 | - name: redis 16 | image: redis:alpine 17 | imagePullPolicy: Always 18 | ports: 19 | - containerPort: 6379 20 | -------------------------------------------------------------------------------- /deployment-reducepy.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: reducepy 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: reducepy 9 | replicas: 3 10 | template: 11 | metadata: 12 | labels: 13 | app: reducepy 14 | spec: 15 | containers: 16 | - image: abdullahselek/reducepy:1.0 17 | name: reducepy 18 | imagePullPolicy: Always 19 | ports: 20 | - name: http 21 | containerPort: 80 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | web: 4 | build: . 5 | ports: 6 | - "80:80" 7 | environment: 8 | - SCHEME=http 9 | - IP_ADDRESS=127.0.0.1 10 | - PORT=80 11 | redis: 12 | image: "redis:alpine" 13 | -------------------------------------------------------------------------------- /locustfile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import string 4 | import random 5 | from locust import HttpLocust, TaskSet, task 6 | 7 | class WebsiteTasks(TaskSet): 8 | 9 | def id_generator(self, size=6, chars=string.ascii_lowercase + string.digits): 10 | return ''.join(random.choice(chars) for _ in range(size)) 11 | 12 | @task 13 | def post(self): 14 | self.client.post("/", { 15 | "url": "https://www." + self.id_generator() + ".com" 16 | }) 17 | 18 | class WebsiteUser(HttpLocust): 19 | task_set = WebsiteTasks 20 | min_wait = 5000 21 | max_wait = 15000 22 | host = 'http://127.0.0.1:80' 23 | -------------------------------------------------------------------------------- /reducepy/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """URL shortener service is written with Python using Tornado and Redis""" 4 | 5 | from __future__ import absolute_import 6 | 7 | __author__ = "Abdullah Selek" 8 | __email__ = "abdullahselek.os@gmail.com" 9 | __copyright__ = "Copyright (c) 2017 Abdullah Selek" 10 | __license__ = "MIT License" 11 | __version__ = "1.2.0" 12 | __url__ = "https://github.com/abdullahselek/ReducePy" 13 | __download_url__ = "https://github.com/abdullahselek/ReducePy" 14 | __description__ = "URL shortener service is written with Python using Tornado and Redis" 15 | 16 | from .app import MainHandler 17 | from .store import Store 18 | from .url_shorten import UrlShorten 19 | -------------------------------------------------------------------------------- /reducepy/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import tornado.ioloop 5 | import tornado.web 6 | import json 7 | 8 | from redis import Redis 9 | from reducepy.url_shorten import UrlShorten 10 | from reducepy.store import Store 11 | 12 | from tornado.concurrent import run_on_executor 13 | from concurrent.futures import ThreadPoolExecutor 14 | 15 | from urllib.parse import urlparse 16 | 17 | 18 | redis = Redis(host="redis", port=6379) 19 | store = Store(redis) 20 | 21 | try: 22 | scheme = os.environ["SCHEME"] 23 | ip_address = os.environ["IP_ADDRESS"] 24 | port = os.environ["PORT"] 25 | except KeyError: 26 | scheme = "http" 27 | ip_address = "localhost" 28 | port = 80 29 | 30 | 31 | class MainHandler(tornado.web.RequestHandler): 32 | 33 | executor = ThreadPoolExecutor(max_workers=5) 34 | 35 | def __uri_validator(self, url: str): 36 | try: 37 | result = urlparse(url) 38 | if result.path: 39 | return all([result.scheme, result.netloc, result.path]) 40 | return all([result.scheme, result.netloc]) 41 | except: 42 | return False 43 | 44 | def __create_shorten_url(self, url: str): 45 | netloc = ip_address + ":" + str(port) 46 | unique, short_url = UrlShorten.shorten_url(url, scheme, netloc) 47 | store.keep(unique, url) 48 | return short_url 49 | 50 | @run_on_executor 51 | def background_task(self, url: str): 52 | if url: 53 | if self.__uri_validator(url): 54 | response = { 55 | "error": False, 56 | "shortened_url": self.__create_shorten_url(url), 57 | } 58 | return 201, json.dumps(response, sort_keys=True) 59 | response = {"error": True, "message": "Please post a valid url"} 60 | return 400, json.dumps(response, sort_keys=True) 61 | else: 62 | response = {"error": True, "message": "Please post a url"} 63 | return 400, json.dumps(response, sort_keys=True) 64 | 65 | @tornado.gen.coroutine 66 | def post(self): 67 | url = self.get_argument("url", None) 68 | status_code, result = yield self.background_task(url) 69 | self.set_status(status_code) 70 | self.write(result) 71 | 72 | def get(self, *args): 73 | if len(args) == 1: 74 | unique = args[0] 75 | url = store.value_of(unique) 76 | if url: 77 | return self.redirect(url) 78 | else: 79 | response = {"error": True, "message": "No url found with given unique"} 80 | self.set_status(404) 81 | return self.write(json.dumps(response, sort_keys=True)) 82 | else: 83 | response = {"error": True, "message": "Please set only one argument path"} 84 | self.set_status(400) 85 | return self.write(json.dumps(response, sort_keys=True)) 86 | 87 | 88 | def main(): 89 | app = tornado.web.Application( 90 | [(r"/", MainHandler), (r"/(.*)", MainHandler)], 91 | debug=False, 92 | ) 93 | app.listen(port) 94 | tornado.ioloop.IOLoop.current().start() 95 | 96 | 97 | if __name__ == "__main__": 98 | main() 99 | -------------------------------------------------------------------------------- /reducepy/store.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from redis import Redis, exceptions 4 | 5 | from typing import Optional 6 | 7 | 8 | class Store(object): 9 | """Class used to store shorten ids and urls.""" 10 | 11 | def __init__(self, redis: Redis): 12 | self._redis = redis 13 | 14 | def keep(self, key: str, value: str): 15 | try: 16 | self._redis.set(key, value) 17 | except exceptions.ConnectionError: 18 | print("Redis connection error when trying to keep long url") 19 | 20 | def value_of(self, key: str) -> Optional[str]: 21 | try: 22 | url = self._redis.get(key) 23 | if url: 24 | return url.decode("utf-8") 25 | return None 26 | except exceptions.ConnectionError: 27 | print("Redis connection error when trying to get long url") 28 | return None 29 | -------------------------------------------------------------------------------- /reducepy/url_shorten.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import hashlib 4 | import base64 5 | 6 | from urllib.parse import urlunparse 7 | from typing import List, Tuple 8 | 9 | 10 | class UrlShorten(object): 11 | """Class used to shorten given urls.""" 12 | 13 | @staticmethod 14 | def md5(url: str): 15 | return hashlib.md5(url.encode("utf-8")).hexdigest() 16 | 17 | @staticmethod 18 | def byte_array(url: str) -> bytearray: 19 | byte_array = bytearray() 20 | byte_array.extend(map(ord, url)) 21 | return byte_array 22 | 23 | @staticmethod 24 | def get_last_x_element(byte_array: List, x: int) -> bytes: 25 | return byte_array[-x:] 26 | 27 | @staticmethod 28 | def string_from_bytes(byte_array: List) -> str: 29 | return "".join(map(chr, byte_array)) 30 | 31 | @staticmethod 32 | def encode_base64(byte_array: List) -> str: 33 | return str(base64.b64encode(byte_array).decode("utf-8")) 34 | 35 | @staticmethod 36 | def create_unique(url: str) -> str: 37 | url_hash = UrlShorten.md5(url) 38 | url_byte_array = UrlShorten.byte_array(url_hash) 39 | last_four_bytes = UrlShorten.get_last_x_element(url_byte_array, 4) 40 | return UrlShorten.encode_base64(last_four_bytes)[:-2] 41 | 42 | @staticmethod 43 | def shorten_url(url: str, scheme: str, netloc: str) -> Tuple[str, str]: 44 | unique = UrlShorten.create_unique(url) 45 | return unique, urlunparse((scheme, netloc, unique, None, None, None)) 46 | -------------------------------------------------------------------------------- /requirements.testing.txt: -------------------------------------------------------------------------------- 1 | fakeredis 2 | 3 | pytest 4 | pytest-cov 5 | pytest-runner 6 | codecov 7 | tox 8 | tox-pyenv 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tornado >= 6.1 2 | redis >= 4.2.1 3 | futures3 >= 1.0.0 4 | -------------------------------------------------------------------------------- /service-redis.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: redis 5 | spec: 6 | ports: 7 | - port: 6379 8 | selector: 9 | app: redis 10 | -------------------------------------------------------------------------------- /service-reducepy.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: reducepy 5 | spec: 6 | selector: 7 | app: reducepy 8 | type: NodePort 9 | ports: 10 | - nodePort: 31317 11 | port: 80 12 | protocol: TCP 13 | targetPort: 80 14 | targetPort: http 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, print_function 4 | 5 | import os 6 | import re 7 | import codecs 8 | 9 | from setuptools import setup, find_packages 10 | 11 | cwd = os.path.abspath(os.path.dirname(__file__)) 12 | 13 | def read(filename): 14 | with codecs.open(os.path.join(cwd, filename), 'rb', 'utf-8') as h: 15 | return h.read() 16 | 17 | metadata = read(os.path.join(cwd, 'reducepy', '__init__.py')) 18 | 19 | def extract_metaitem(meta): 20 | meta_match = re.search(r"""^__{meta}__\s+=\s+['\"]([^'\"]*)['\"]""".format(meta=meta), 21 | metadata, re.MULTILINE) 22 | if meta_match: 23 | return meta_match.group(1) 24 | raise RuntimeError('Unable to find __{meta}__ string.'.format(meta=meta)) 25 | 26 | with open('requirements.txt') as f: 27 | requirements = f.read().splitlines() 28 | 29 | with open('requirements.testing.txt') as f: 30 | requirements_testing = f.read().splitlines() 31 | 32 | setup( 33 | name='reducepy', 34 | version=extract_metaitem('version'), 35 | license=extract_metaitem('license'), 36 | description=extract_metaitem('description'), 37 | long_description=(read('README.rst')), 38 | author=extract_metaitem('author'), 39 | author_email=extract_metaitem('email'), 40 | maintainer=extract_metaitem('author'), 41 | maintainer_email=extract_metaitem('email'), 42 | url=extract_metaitem('url'), 43 | download_url=extract_metaitem('download_url'), 44 | packages=find_packages(exclude=('tests', 'docs')), 45 | platforms=['Any'], 46 | install_requires=requirements, 47 | tests_require=requirements_testing, 48 | keywords='url shorten service', 49 | classifiers=[ 50 | 'Intended Audience :: Developers', 51 | 'License :: OSI Approved :: MIT License', 52 | 'Operating System :: OS Independent', 53 | 'Topic :: Software Development :: Libraries :: Python Modules', 54 | 'Programming Language :: Python', 55 | 'Programming Language :: Python :: 3.5', 56 | 'Programming Language :: Python :: 3.6', 57 | 'Programming Language :: Python :: 3.7', 58 | 'Programming Language :: Python :: 3.8', 59 | 'Programming Language :: Python :: 3.9', 60 | ], 61 | ) 62 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import tornado.web 5 | 6 | from tornado.testing import AsyncHTTPTestCase 7 | from tornado.web import Application 8 | from reducepy import MainHandler 9 | 10 | try: 11 | from urllib import urlencode 12 | except ImportError: 13 | from urllib.parse import urlencode 14 | 15 | 16 | class AppTest(AsyncHTTPTestCase): 17 | def setUp(self): 18 | super(AppTest, self).setUp() 19 | # allow more time before timeout since we are doing remote access.. 20 | os.environ["ASYNC_TEST_TIMEOUT"] = str(20) 21 | 22 | def get_app(self): 23 | return Application( 24 | [(r"/", MainHandler), (r"/(.*)", MainHandler)], debug=True, autoreload=False 25 | ) 26 | 27 | def get_new_ioloop(self): 28 | return tornado.ioloop.IOLoop.instance() 29 | 30 | def test_shorten(self): 31 | post_data = {"url": "https://www.google.com"} 32 | body = urlencode(post_data) 33 | response = self.fetch(r"/", method="POST", body=body) 34 | self.assertEqual(response.code, 201) 35 | self.assertEqual( 36 | response.body, 37 | b'{"error": false, "shortened_url": "http://localhost:80/ZDYyMw"}', 38 | ) 39 | 40 | def test_shorten_without_www(self): 41 | post_data = {"url": "https://google.com"} 42 | body = urlencode(post_data) 43 | response = self.fetch(r"/", method="POST", body=body) 44 | self.assertEqual(response.code, 201) 45 | self.assertEqual( 46 | response.body, 47 | b'{"error": false, "shortened_url": "http://localhost:80/OTY5Zg"}', 48 | ) 49 | 50 | def test_shorten_with_path(self): 51 | post_data = {"url": "http://www.cwi.nl:80/%7Eguido/Python.html"} 52 | body = urlencode(post_data) 53 | response = self.fetch(r"/", method="POST", body=body) 54 | self.assertEqual(response.code, 201) 55 | self.assertEqual( 56 | response.body, 57 | b'{"error": false, "shortened_url": "http://localhost:80/NTc3NA"}', 58 | ) 59 | 60 | def test_shorten_with_invalid_url(self): 61 | post_data = {"url": "abdullahselek.com"} 62 | body = urlencode(post_data) 63 | response = self.fetch(r"/", method="POST", body=body) 64 | self.assertEqual(response.code, 400) 65 | self.assertEqual( 66 | response.body, b'{"error": true, "message": "Please post a valid url"}' 67 | ) 68 | 69 | def test_shorten_empty(self): 70 | post_data = {"key": "https://www.google.com"} 71 | body = urlencode(post_data) 72 | response = self.fetch(r"/", method="POST", body=body) 73 | self.assertEqual(response.code, 400) 74 | self.assertEqual( 75 | response.body, b'{"error": true, "message": "Please post a url"}' 76 | ) 77 | 78 | def test_shorten_unsupported(self): 79 | post_data = {"key": "https://www.google.com"} 80 | body = urlencode(post_data) 81 | response = self.fetch(r"/", method="PATCH", body=body) 82 | self.assertEqual(response.code, 405) 83 | 84 | def test_forward(self): 85 | response = self.fetch("/YjUwYQs", method="GET") 86 | self.assertEqual(response.code, 404) 87 | 88 | def test_forward_fail(self): 89 | response = self.fetch("/", method="GET") 90 | self.assertEqual(response.code, 400) 91 | self.assertEqual( 92 | response.body, 93 | b'{"error": true, "message": "Please set only one argument path"}', 94 | ) 95 | -------------------------------------------------------------------------------- /tests/test_store.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import unittest 4 | import fakeredis 5 | from reducepy.store import Store 6 | 7 | 8 | class StoreTest(unittest.TestCase): 9 | def setUp(self): 10 | self.redis = fakeredis.FakeStrictRedis() 11 | self.store = Store(self.redis) 12 | 13 | def test_initiation(self): 14 | self.assertIsNotNone(self.store) 15 | 16 | def test_keep(self): 17 | self.store.keep("key", "value") 18 | value = self.store.value_of("key") 19 | self.assertEqual(value, "value") 20 | -------------------------------------------------------------------------------- /tests/test_url_shorten.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import unittest 4 | from reducepy.url_shorten import UrlShorten 5 | 6 | 7 | class UrlShorterTest(unittest.TestCase): 8 | def test_md5(self): 9 | md5_text = UrlShorten.md5("https://www.google.com") 10 | self.assertEqual(md5_text, "8ffdefbdec956b595d257f0aaeefd623") 11 | 12 | def test_byte_array(self): 13 | byte_array = UrlShorten.byte_array("https://www.google.com") 14 | self.assertIsNotNone(byte_array) 15 | 16 | def test_get_last_x_element(self): 17 | byte_array = UrlShorten.byte_array("https://www.google.com") 18 | last_four_element = UrlShorten.get_last_x_element(byte_array, 4) 19 | self.assertEqual(len(last_four_element), 4) 20 | 21 | def test_string_from_bytes(self): 22 | bytes = [112, 52, 52] 23 | string = UrlShorten.string_from_bytes(bytes) 24 | self.assertEqual(string, "p44") 25 | 26 | def test_encode_base64(self): 27 | base64_encoded = UrlShorten.encode_base64(b"axcv4") 28 | self.assertEqual(base64_encoded, "YXhjdjQ=") 29 | 30 | def test_create_unique(self): 31 | unique = UrlShorten.create_unique("https://www.google.com") 32 | self.assertEqual(unique, "ZDYyMw") 33 | 34 | def test_shorten_url(self): 35 | scheme = "http" 36 | netloc = "localhost" 37 | unique, short_url = UrlShorten.shorten_url( 38 | "https://www.google.com", scheme, netloc 39 | ) 40 | self.assertEqual(unique, "ZDYyMw") 41 | self.assertEqual(short_url, "http://localhost/ZDYyMw") 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = clean,py27,py3,py36,pypy,pypy3 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | deps = -Ur{toxinidir}/requirements.txt 7 | -Ur{toxinidir}/requirements.testing.txt 8 | commands = pytest -s -v 9 | whitelist_externals = pyenv install -s 2.7.11 10 | pyenv install -s 3.6.1 11 | pyenv install -s pypy-5.3.1 12 | pyenv local 2.7.11 3.6.1 pypy-5.3.1 13 | 14 | [testenv:clean] 15 | deps = coverage 16 | commands = coverage erase 17 | 18 | [testenv:report] 19 | commands = py.test --cov-report html --cov=reducepy 20 | 21 | [testenv:coverage] 22 | commands = coverage combine 23 | coverage html 24 | coverage report 25 | codecov 26 | --------------------------------------------------------------------------------