diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd0b593 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__/ +.cache/ +*.pyc +*.db +.ash_* +.#* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..799808e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +.cache/ +env/ +*.pyc +*.db +.ash_* +.#* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66095d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.7-alpine + +ARG UID=1000 +ARG GID=1000 + +ENV PYTHONUNBUFFERED 1 +ENV LANG C.UTF-8 +ENV USER shorturl +ENV HOME /app + +COPY ./requirements.txt /app/requirements.txt + +RUN apk add --no-cache \ + --virtual build-deps \ + musl-dev postgresql-dev \ + build-base python3-dev zlib-dev git \ + && apk add --no-cache \ + postgresql-libs libpq \ + && pip install -U pip gunicorn \ + && pip install -r /app/requirements.txt \ + && rm -fr /app/.cache \ + && apk --purge del build-deps \ + && addgroup -S $USER -g $GID \ + && adduser -S -G $USER -u $UID -h $HOME $USER + +COPY . /app + +WORKDIR /app diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..77bcc7a --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,45 @@ +# shorturl how to + +## Install + +### docker + +``` +$ docker-compose build +``` + +### virtualenv + +``` +$ virtualenv -p python3 --system-site-packages env +$ . ./env/bin/activate +(env) $ pip install -r requirements.txt +``` + +### Run + +### docker + +``` +$ docker-compose up +``` + +### virtualenv + +``` +(env) $ ./entrypoint.sh +``` + +### Test + +### docker + +``` +$ docker-compose -f docker-compose.test.yml up +``` + +### virtualenv + +``` +(env) $ python -m unittest discover +``` diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..ca74eab --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,17 @@ +version: '3.1' +services: + web: + build: + context: . + args: + UID: ${UID:-1000} + GID: ${GID:-1000} + image: buoydev-py + container_name: buoydev-test + command: python -m unittest discover + environment: + FLASK_APP: shorturl.py + FLASK_ENV: development + FLASK_DEBUG: 1 + volumes: + - .:/app/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cdf74e5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3.1' +services: + db: + image: postgres:9.6-alpine + restart: unless-stopped + container_name: buoydev-db + env_file: + - ./docker.env + ports: + - "5432:5432" + networks: + - buoy_apps + web: + build: + context: . + args: + UID: ${UID:-1000} + GID: ${GID:-1000} + image: buoydev-py + container_name: buoydev-web + restart: unless-stopped + command: ./entrypoint.sh + env_file: + - ./docker.env + volumes: + - .:/app/ + ports: + - "5000:5000" + depends_on: + - db + networks: + - buoy_apps + +networks: + buoy_apps: + external: true diff --git a/docker.env b/docker.env new file mode 100644 index 0000000..f9bb3fc --- /dev/null +++ b/docker.env @@ -0,0 +1,13 @@ +POSTGRES_USER=buoy +POSTGRES_PASSWORD=buoy +POSTGRES_DB=buoy + +DB_HOST=db + +HOST=0.0.0.0 +PORT=5000 +WORKERS=3 + +FLASK_APP=shorturl.py +FLASK_ENV=development +FLASK_DEBUG=0 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..3032b22 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +flask create-tables && \ + gunicorn --bind $HOST:$PORT \ + --workers $WORKERS \ + shorturl:app diff --git a/models.py b/models.py new file mode 100644 index 0000000..a833d1a --- /dev/null +++ b/models.py @@ -0,0 +1,71 @@ +import datetime +from peewee import * +from os import environ as env + +db_proxy = Proxy() + +class BaseModel(Model): + class Meta: + database = db_proxy + +class URL(BaseModel): + url = CharField(max_length=35) + code = CharField(max_length=6, unique=True) + + class Meta: + table_name = 'urls' + + @property + def serialize(self): + data = { + 'id': self.id, + 'url': str(self.url).strip(), + 'code': str(self.code).strip(), + } + + return data + + def __repr__(self): + code = self.code + url = self.url + return f"{code} ({url})" + + +class Stats(BaseModel): + url = ForeignKeyField(URL, backref='url') + created_at = DateTimeField(default=datetime.datetime.now) + last_usage = DateTimeField(null=True) + usage_count = IntegerField() + + class Meta: + table_name = 'stats' + + @property + def serialize(self): + data = { + 'id': self.id, + 'url': str(self.url).strip(), + 'created_at': str(self.created_at).strip(), + 'last_usage': str(self.last_usage).strip(), + 'usage_count': str(self.usage_count).strip(), + } + + return data + + def __repr__(self): + url = self.url + count = self.usage_count + return f"{url} ({count})" + + + +db = SqliteDatabase(':memory:') +if int(env.get('FLASK_DEBUG')) == 0: + db = PostgresqlDatabase( + env.get('POSTGRES_DB', 'postgres'), + user=env.get('POSTGRES_USER', 'postgres'), + password=env.get('POSTGRES_PASSWORD', 'postgres'), + host=env.get('DB_HOST', 'localhost'), + ) + +db_proxy.initialize(db) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..677f627 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==1.1.0 +psycopg2==2.7.3.2 +peewee diff --git a/shorturl.py b/shorturl.py new file mode 100644 index 0000000..061d7a1 --- /dev/null +++ b/shorturl.py @@ -0,0 +1,111 @@ +import utils +from flask import ( + Flask, + jsonify, + request, + redirect, +) + + +app = Flask(__name__) + + +## +# Flask commands +# drop-tables +# create-tables +# + +@app.cli.command() +def drop_tables(): + """Use PeeWee to drop tables.""" + utils.drop_tables() + + +@app.cli.command() +def create_tables(): + """Use PeeWee to create tables.""" + utils.create_tables() + + +## +# Flask endpoints +# index +# url_get +# url_stats +# url_post +# + +@app.route('/', methods=['GET']) +def index(): + """Home""" + endpoints = { + "/urls": "POST -> url:str:required, code:str:optional", + "/": "GET -> code:str:required", + "//stats": "GET -> code:str:required" + } + + return jsonify(endpoints=endpoints) + + +@app.route('/urls', methods=['POST']) +def url_post(): + """Saves URLs if ok, else wont (?)""" + data = request.json.copy() + if "url" not in list(data.keys()): + return jsonify(error="URL not provided"), 400 + + else: + if "code" not in list(data.keys()): + code = utils.gen_code() + else: + code = data.get('code') + if not utils.is_valid_code(code): + return jsonify(error="Code not valid"), 409 + + _, exists = utils.code_exists(code) + if exists: + return jsonify(error="Code in use"), 422 + else: + if utils.is_valid_url(data.get('url')): + utils.insert_url(data.get('url'), code) + else: + return jsonify(error="URL not valid"), 409 + + return jsonify(code=code), 201 + + +@app.route('//stats', methods=['GET']) +def url_stats(code: str): + """Shows stats for a given code""" + _, exists = utils.code_exists(code) + if not exists: + return jsonify(error="Code Not Found"), 404 + else: + created_at, last_usage, usage_count = utils.get_stats( + code + ) + + result = { + 'created_at': utils.to_iso8601(created_at), + 'usage_count': usage_count + } + if last_usage: + result['last_usage'] = utils.to_iso8601(last_usage) + + return jsonify(result), 200 + + + +@app.route('/', methods=['GET']) +def url_get(code: str): + """Redirects to a URL given a code else 404""" + q, exists = utils.code_exists(code) + if exists: + url_id, url = q.execute().cursor.fetchone() + utils.bump_stats(url_id) + return redirect(url, code=302) + + else: + return jsonify(error="Code Not Found"), 404 + diff --git a/test_shorturl.py b/test_shorturl.py new file mode 100644 index 0000000..7b1227a --- /dev/null +++ b/test_shorturl.py @@ -0,0 +1,135 @@ +import json +import utils +import datetime +import unittest +from shorturl import app + + +def cmp_dt(dt_a: str, dt_b: str) -> bool: + dt_a = datetime.datetime.strftime( + datetime.datetime.strptime( + dt_a, '%Y-%m-%dT%H:%M:%S.%f' + ), '%Y-%m-%dT%H:%M:%S' + ) + dt_b = datetime.datetime.strftime( + dt_b, '%Y-%m-%dT%H:%M:%S' + ) + return dt_a == dt_b + + +class ShorturlTests(unittest.TestCase): + def setUp(self): + app.config["TESTING"] = True + app.config["DEBUG"] = True + self.app = app.test_client() + utils.create_tables() + self.data = { + "url": "https://ddg.gg/", + "code": utils.gen_code(), + } + self.assertEqual(app.debug, True) + + def tearDown(self): + utils.drop_tables() + + def test_index(self): + response = self.app.get('/', + follow_redirects=True) + self.assertEqual(response.status_code, 200) + + def test_url_post_201(self): + response = self.app.post('/urls', + data=json.dumps(self.data), + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json["code"], self.data["code"]) + + def test_url_post_201_nocode(self): + response = self.app.post('/urls', + data=json.dumps({ + "url": self.data["url"] + }), + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertTrue(len(response.json["code"]) == 6) + + def test_url_post_400(self): + response = self.app.post('/urls', + data=json.dumps({}), + content_type='application/json') + self.assertEqual(response.status_code, 400) + + def test_url_post_409_code(self): + response = self.app.post('/urls', + data=json.dumps({ + "url": self.data["url"], + "code": "this will fail" + }), + content_type='application/json') + self.assertEqual(response.status_code, 409) + + def test_url_post_409_url(self): + response = self.app.post('/urls', + data=json.dumps({ + "url": "http://lalala", + }), + content_type='application/json') + self.assertEqual(response.status_code, 409) + + def test_url_post_422(self): + self.app.post('/urls', + data=json.dumps(self.data), + content_type='application/json') + response = self.app.post('/urls', + data=json.dumps(self.data), + content_type='application/json') + self.assertEqual(response.status_code, 422) + + def test_url_get_302(self): + post = self.app.post('/urls', + data=json.dumps({ + "url": self.data["url"] + }), + content_type='application/json') + code = post.json["code"] + response = self.app.get(f"/{code}", + follow_redirects=False) + self.assertEqual(response.status_code, 302) + + def test_url_get_404(self): + code = "err404" + response = self.app.get(f"/{code}", + follow_redirects=False) + self.assertEqual(response.status_code, 404) + + def test_url_stats_200(self): + now = utils.now() + post = self.app.post('/urls', + data=json.dumps({ + "url": self.data["url"] + }), + content_type='application/json') + code = post.json["code"] + usage_expected = 5 + for i in range(usage_expected): + self.app.get(f"/{code}", + follow_redirects=False) + now_usage = utils.now() + + response = self.app.get(f"/{code}/stats") + + self.assertEqual(response.json["usage_count"], + usage_expected) + self.assertTrue(cmp_dt(response.json["created_at"], + now)) + self.assertTrue(cmp_dt(response.json["last_usage"], + now_usage)) + + + def test_url_stats_404(self): + response = self.app.get("/err404/stats") + self.assertEqual(response.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_utils.py b/test_utils.py new file mode 100644 index 0000000..21efebe --- /dev/null +++ b/test_utils.py @@ -0,0 +1,75 @@ +import utils +import unittest + + +class UtilsTests(unittest.TestCase): + def setUp(self): + utils.create_tables() + self.data = { + "url": "https://ddg.gg/", + "code": utils.gen_code(), + } + + def tearDown(self): + utils.drop_tables() + + def test_is_valid_url_ok(self): + self.assertTrue(utils.is_valid_url(self.data["url"])) + + def test_is_valid_url_eerr(self): + self.assertFalse(utils.is_valid_url("this will fail")) + + def test_is_valid_code_ok(self): + self.assertTrue(utils.is_valid_code(self.data["code"])) + + def test_is_valid_code_err(self): + self.assertFalse(utils.is_valid_code("this will fail")) + + def test_gen_code(self): + code = utils.gen_code() + self.assertEqual(len(code), 6) + self.assertTrue(utils.is_valid_code(code)) + + def test_insert_url(self): + url_id = utils.insert_url( + self.data["url"], + self.data["code"] + ) + self.assertEqual(isinstance(url_id, int), True) + + def test_stats(self): + _id = utils.insert_url( + self.data["url"], + self.data["code"] + ) + for i in range(1,5): + utils.bump_stats(_id) + created_at, last_usage, usage_count = utils.get_stats( + self.data["code"] + ) + self.assertEqual(usage_count, i) + + def test_code_exists_true(self): + utils.insert_url( + self.data["url"], + self.data["code"] + ) + _, exists = utils.code_exists(self.data["code"]) + self.assertTrue(exists) + + def test_code_exists_false(self): + _, exists = utils.code_exists("abc123") + self.assertFalse(exists) + + def test_date_iso8601(self): + now = utils.now() + now_iso8601 = now.isoformat() + + self.assertEqual( + utils.to_iso8601(str(now)), + now_iso8601 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..71192e8 --- /dev/null +++ b/utils.py @@ -0,0 +1,104 @@ +import re +import secrets +import string +import datetime +from urllib.parse import urlparse +from models import ( + db, + URL, + Stats +) + + +def now() -> datetime.datetime: + """what time is now""" + return datetime.datetime.now() + + +def to_iso8601(dt: str) -> datetime.datetime: + """Convert string to iso-8601""" + return (datetime.datetime + .strptime(str(dt), "%Y-%m-%d %H:%M:%S.%f") + .isoformat()) + + +def create_tables() -> None: + """Creates tables""" + db.create_tables([URL, Stats]) + + +def drop_tables() -> None: + """Drops tables""" + map(lambda m: m.drop_table(), [URL, Stats]) + + +def insert_url(url:str, code:str) -> int: + """Inserts a new URL""" + q = URL.insert(url=url, code=code) + url_id = q.execute() + Stats.insert(url=url_id, usage_count=0).execute() + + return url_id + + +def bump_stats(url_id: int) -> tuple: + """Updates Stat records""" + q = (Stats + .update({ + Stats.usage_count: Stats.usage_count+1, + Stats.last_usage: now() + }) + .where(Stats.url_id==url_id) + .execute()) + + return q + + +def get_stats(code: str) -> tuple: + """Returns Stats data""" + q = (Stats + .select( + Stats.created_at, + Stats.last_usage, + Stats.usage_count + ) + .join_from(Stats, URL) + .where(URL.code==code) + .execute()) + + return q.cursor.fetchone() + + +def gen_code() -> str: + """Generates a code""" + alphabet = string.ascii_letters + string.digits + code = ''.join(secrets.choice(alphabet) for i in range(6)) + _, exists = code_exists(code) + if exists: + return gen_code() + else: + return code + + +def code_exists(code) -> tuple: + """Checks if a code exists in DB""" + q = (URL + .select(URL.id, URL.code) + .where(URL.code==code) + .limit(1)) + return q, q.count() > 0 + + +def is_valid_url(url: str) -> bool: + """is this URL well formatted? + (aka: does it looks like an url?)""" + result = urlparse(url) + return all([result.scheme, result.netloc, result.path]) + + +def is_valid_code(code: str) -> bool: + """check if a code complies with <=6 alphanumeric chr""" + return ( + len(re.sub(r'[a-zA-Z0-9]', '', code)) == 0 and + len(code) == 6 + )