diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40cf140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Folders + +venv +.vscode +.history +__pycache__ + +# Files + +test.db \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..3dbe1af --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,29 @@ +# Code Challenge for BuoyDevelopment by Mauricio Bergallo + +## Description + +The API in this repo is used to shorten urls. Is an API created in Python 3 with Flask Framework; the Application uses a Database SQLite. + +## PreRequisites + +- Virtual Environment should be installed and active +- To Activate in Windows ``` λ venv\Scripts\activate.bat ```; in Unix / MacOS ```$ source ./env/bin/activate``` +- Install Dependencies ```$ pip install -r .\requirements.txt``` + +## Steps to Run Locally + +- Set the Environment Variable: ``` λ set APP_SETTINGS=src.config.LocalConfig ``` (Windows) ``` $ export APP_SETTINGS=src.config.LocalConfig ``` +- Start the Server: ``` py manage.py run ``` +- Test: +``` +curl -X POST \ + http://localhost:5000/urls \ + -H 'Content-Type: application/json' \ + -d '{ + "url": "http://example.com", + "code": "Ncr8p7" +}' +``` + +## Steps to Run Unit Test +- Execute the Test: ``` py manage.py test ``` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..ac8f8a3 --- /dev/null +++ b/app.py @@ -0,0 +1,12 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route('/') +def hello_world(): + return 'Hello World' + +app.run(port=5050) + +# __name__ = "__main__" \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..21cd920 --- /dev/null +++ b/manage.py @@ -0,0 +1,30 @@ +# manage.py + +import unittest +from flask.cli import FlaskGroup +from src import create_app, db +# from project.api.models import Product, Price +app = create_app() +cli = FlaskGroup(create_app=create_app) + +@cli.command() +def recreate_db(): + """ Recreates the DataBase """ + db.drop_all() + db.create_all() + db.session.commit() + +@cli.command() +def test(): + """ Runs the tests without code coverage """ + tests = unittest.TestLoader().discover('src/test/', pattern='*_test.py') + result = unittest.TextTestRunner(verbosity=2).run(tests) + + if result.wasSuccessful(): + return 0 + + return 1 + +if __name__ == '__main__': + db.create_all() + cli() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b4a2812 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==1.1.1 +Flask-Testing==0.7.1 +Flask-SQLAlchemy==2.4.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..f1c9182 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,40 @@ +# src/__init__.py + +import os +import datetime +from flask import Flask, jsonify +from flask_sqlalchemy import SQLAlchemy + +#from project.api.models import Product + +#instantiate the app +app = Flask(__name__) + +#set config +app_settings = os.getenv('APP_SETTINGS') +app.config.from_object(app_settings) + +#instantiate the db +db = SQLAlchemy(app) + +def create_app(script_info=None): + #instiantiate the app + app = Flask(__name__) + + #set config + app_settings = os.getenv('APP_SETTINGS') + app.config.from_object(app_settings) + + #set up extensions + db.init_app(app) + + #register blueprints + from src.api.urls import urls_blueprint + app.register_blueprint(urls_blueprint) + # from project.api.products import products_blueprint + # app.register_blueprint(products_blueprint) + + # shell context for flask cli + app.shell_context_processor({'app': app, 'db': db}) + + return app \ No newline at end of file diff --git a/src/api/models.py b/src/api/models.py new file mode 100644 index 0000000..2678bcc --- /dev/null +++ b/src/api/models.py @@ -0,0 +1,60 @@ +#! src/api/models.py + +from src import db +import datetime + +class Url(db.Model): + __tablename__ = "url" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + url = db.Column(db.String(256), nullable=False) + code = db.Column(db.String(6), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow(), nullable=False) + last_usage = db.Column(db.DateTime, nullable=True) + usage_count = db.Column(db.Integer, default=0, nullable=False) + + def to_json(self): + return { + 'url': self.url, + 'code': self.code + } + + def stats_to_json(self): + result = { + 'created_at': self.created_at.isoformat(), + 'usage_count': self.usage_count + } + + if self.last_usage is not None: + result['last_usage'] = self.last_usage.isoformat() + + return result + + def save(self): + db.session.add(self) + db.session.commit() + + +def get_url_by_code(value): + if already_existant_code(value) == False: + return False + + url = Url.query.filter_by(code=value).first() + url.last_usage = datetime.datetime.utcnow() + url.usage_count = url.usage_count + 1 + url.save() + + return url + + +def get_url_stats_by_code(value): + if already_existant_code(value) == False: + return False + + url = Url.query.filter_by(code=value).first() + return url + + +def already_existant_code(value): + url = Url.query.filter_by(code=value).first() + + return (url is not None) diff --git a/src/api/urls.py b/src/api/urls.py new file mode 100644 index 0000000..a51c254 --- /dev/null +++ b/src/api/urls.py @@ -0,0 +1,80 @@ +# src/api/products.py + +import random +import string +from flask import Blueprint, jsonify, request +from src.api.models import Url, already_existant_code, get_url_by_code, get_url_stats_by_code +from src import db +from sqlalchemy import exc + +urls_blueprint = Blueprint('urls', __name__) + +@urls_blueprint.route('/urls', methods=['POST']) +def add_url(): + """ + Endpoint to Add a New URL + """ + post_data = request.get_json() + + if not post_data: + return jsonify(response_object), 400 + + url = post_data.get('url') + code = post_data.get('code') + + if url is None or len(url) == 0: + return {}, 400 + + if code is None or len(code) == 0: + code = generate_random_code() + else: + status = validate_code(code) + if status != 201: + return {}, status + + newUrl = Url() + newUrl.url = url + newUrl.code = code + + newUrl.save() + + return jsonify({ 'code': newUrl.code }), 201 + +@urls_blueprint.route('/', methods=['GET']) +def get_by_code(code): + """ + Endpoint to Retrieve an existant URL + """ + url = get_url_by_code(code) + + if url == False: + return {}, 404 + + return {}, 302, { 'location': url.url } + +@urls_blueprint.route('//stats', methods=['GET']) +def get_stats_by_code(code): + """ + Endpoint to retrieve the Statistics of this particular CODE + """ + url = get_url_stats_by_code(code) + + if url == False: + return {}, 404 + + return url.stats_to_json(), 200 + +def validate_code(code): + if len(code) != 6: + return 422 + elif any(char in string.punctuation for char in code): + return 422 + else: + if already_existant_code(code) == True: + return 409 + else: + return 201 + +def generate_random_code(): + letters = string.ascii_lowercase + string.ascii_uppercase + string.digits + return ''.join(random.choice(letters) for i in range(6)) \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..dd7651d --- /dev/null +++ b/src/config.py @@ -0,0 +1,21 @@ +# src/config.py + +import os + +class BaseConfig: + """Base configuration""" + TESTING = False + SQLALCHEMY_TRACK_MODIFICATIONS = False + SECRET_KEY = 'my_precious' + IS_MEMORY_DB = False + +class LocalConfig(BaseConfig): + """Development configuration""" + SQLALCHEMY_DATABASE_URI = 'sqlite:///test.db' + # IS_MEMORY_DB = True + IS_MEMORY_DB = False + +class TestingConfig(BaseConfig): + """Testing configuration""" + TESTING = True + IS_MEMORY_DB = True diff --git a/src/test/__init__.py b/src/test/__init__.py new file mode 100644 index 0000000..540ee5f --- /dev/null +++ b/src/test/__init__.py @@ -0,0 +1 @@ +# src/test/__init__.py diff --git a/src/test/base.py b/src/test/base.py new file mode 100644 index 0000000..1c0fe70 --- /dev/null +++ b/src/test/base.py @@ -0,0 +1,19 @@ +# src/test/base.py + +from flask_testing import TestCase +from src import create_app, db + +app = create_app() + +class BaseTestCase(TestCase): + def create_app(self): + app.config.from_object('project.config.TestingConfig') + return app + + def setUp(self): + db.create_all() + db.session.commit() + + def tearDown(self): + db.session.remove() + db.drop_all() \ No newline at end of file diff --git a/src/test/config_test.py b/src/test/config_test.py new file mode 100644 index 0000000..bc67950 --- /dev/null +++ b/src/test/config_test.py @@ -0,0 +1,34 @@ +# src/test/config_test.py + +import os +import unittest + +from flask import current_app +from flask_testing import TestCase +from src import create_app + +app = create_app() + +class TestLocalConfig(TestCase): + def create_app(self): + app.config.from_object('src.config.LocalConfig') + return app + + def test_app_is_local(self): + self.assertTrue(app.config['SECRET_KEY'] == 'my_precious') + self.assertFalse(current_app is None) + self.assertTrue(app.config['SQLALCHEMY_DATABASE_URI'] == 'sqlite:///test.db') + +class TestTestingConfig(TestCase): + def create_app(self): + app.config.from_object('src.config.TestingConfig') + return app + + def test_app_is_testing(self): + self.assertTrue(app.config['SECRET_KEY'] == 'my_precious') + self.assertTrue(app.config['TESTING']) + self.assertFalse(app.config['PRESERVE_CONTEXT_ON_EXCEPTION']) + self.assertTrue(app.config['IS_MEMORY_DB']) + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/urls_api_test.py b/src/test/urls_api_test.py new file mode 100644 index 0000000..130dedf --- /dev/null +++ b/src/test/urls_api_test.py @@ -0,0 +1,221 @@ +# src/test/urls_api_test.py + +import json +import unittest +import datetime + +from flask import current_app +from flask_testing import TestCase + +from src import db, create_app +# from src.api.urls import +from src.api.models import Url +from src.test.base import BaseTestCase + +app = create_app() + +def add_url(code, url): + newUrl = Url() + newUrl.code = code + newUrl.url = url + + newUrl.save() + return Url.query.filter_by(code=code).first() + +class TestUrlEndpoint(TestCase): + def create_app(self): + app.config.from_object('src.config.TestingConfig') + return app + + def setUp(self): + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + + def test_post_should_be_ok(self): + payload = { + 'url': 'http://example.com', + 'code': 'Ncr8p7' + } + + expected_body = { + 'code': 'Ncr8p7' + } + expected_status = 201 + + response = app.test_client().post( + '/urls', + data=json.dumps(payload), + content_type='application/json' + ) + response_body = json.loads(response.get_data()) + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_body == expected_body) + + def test_post_should_throw_conflic(self): + add_url('Ncr8p7', 'http://example.com') + + payload = { + 'url': 'http://example.com', + 'code': 'Ncr8p7' + } + + expected_body = {} + expected_status = 409 + + response = app.test_client().post( + '/urls', + data=json.dumps(payload), + content_type='application/json' + ) + response_body = json.loads(response.get_data()) + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_body == expected_body) + + def test_post_should_throw_unprocessable_entity_due_to_invalid_characters(self): + payload = { + 'url': 'http://example.com', + 'code': '######' + } + + expected_body = {} + expected_status = 422 + + response = app.test_client().post( + '/urls', + data=json.dumps(payload), + content_type='application/json' + ) + response_body = json.loads(response.get_data()) + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_body == expected_body) + + def test_post_should_throw_unprocessable_entity_due_to_invalid_maxlength(self): + payload = { + 'url': 'http://example.com', + 'code': 'asd21134' + } + + expected_body = {} + expected_status = 422 + + response = app.test_client().post( + '/urls', + data=json.dumps(payload), + content_type='application/json' + ) + response_body = json.loads(response.get_data()) + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_body == expected_body) + + def test_post_should_throw_unprocessable_entity_due_to_invalid_minlength(self): + payload = { + 'url': 'http://example.com', + 'code': 'a1' + } + + expected_body = {} + expected_status = 422 + + response = app.test_client().post( + '/urls', + data=json.dumps(payload), + content_type='application/json' + ) + response_body = json.loads(response.get_data()) + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_body == expected_body) + + def test_get_should_be_ok(self): + expected_status = 302 + expected_location = 'http://www.google.com' + add_url('ab1234', 'http://www.google.com') + + response = app.test_client().get( + '/ab1234', + content_type='application/json' + ) + + response_header_location = response.headers.get('location') + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_header_location == expected_location) + + def test_get_with_inexistant_code_should_be_not_found(self): + expected_status = 404 + response = app.test_client().get( + '/ab1234', + content_type='application/json' + ) + + self.assertTrue(response.status_code == expected_status) + + def test_get_stats_with_existant_code_and_0_calls_should_be_ok(self): + created_now = datetime.datetime.utcnow() + + url = Url() + url.code = 'ab1234' + url.url = 'http://www.google.com' + url.created_at = created_now + + url.save() + + expected_status = 200 + expected_body = { + 'created_at': created_now.isoformat(), + 'usage_count': 0 + } + + response = app.test_client().get( + '/ab1234/stats', + content_type='application/json' + ) + + response_body = json.loads(response.get_data()) + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_body == expected_body) + + def test_get_stats_with_inexistant_code_should_be_not_found(self): + expected_status = 404 + response = app.test_client().get( + '/ab1234/stats', + content_type='application/json' + ) + + self.assertTrue(response.status_code == expected_status) + + def test_get_stats_after_consume_should_be_ok(self): + created_now = datetime.datetime.utcnow() + + url = Url() + url.code = 'ab1234' + url.url = 'http://www.google.com' + url.created_at = created_now + + url.save() + + response_pre = app.test_client().get('/ab1234', content_type='application/json') + self.assertTrue(response_pre.status_code == 302) + + expected_status = 200 + + response = app.test_client().get( + '/ab1234/stats', + content_type='application/json' + ) + + response_body = json.loads(response.get_data()) + + self.assertTrue(response.status_code == expected_status) + self.assertTrue(response_body['created_at'] == created_now.isoformat()) + self.assertTrue(response_body['usage_count'] == 1) + self.assertIsNotNone(response_body['last_usage']) + \ No newline at end of file diff --git a/src/test/urls_model_test.py b/src/test/urls_model_test.py new file mode 100644 index 0000000..00cc082 --- /dev/null +++ b/src/test/urls_model_test.py @@ -0,0 +1,176 @@ +# src/test/urls_model_test.py + +import json +import unittest +import datetime + +from flask import current_app +from flask_testing import TestCase + +from src import db, create_app +from src.api.models import Url, get_url_by_code, get_url_stats_by_code, already_existant_code +from src.test.base import BaseTestCase + +app = create_app() + +def new_url(code, url): + newUrl = Url() + newUrl.code = code + newUrl.url = url + + newUrl.save() + return Url.query.filter_by(code=code).first() + +class TestUrlModel(TestCase): + def create_app(self): + app.config.from_object('src.config.TestingConfig') + return app + + def setUp(self): + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + + def test_add_url_correctly(self): + url = Url() + url.code = 'aBC123' + url.url = 'http://www.google.com' + + url.save() + + added_url = Url.query.filter_by(code='aBC123').first() + self.assertIsNotNone(added_url) + self.assertIsNotNone(added_url.created_at) + self.assertIsNone(added_url.last_usage) + self.assertTrue(added_url.usage_count == 0) + + def test_to_json_method_ok(self): + url = Url() + url.code = 'aBC124' + url.url = 'http://www.google.com/' + + expected_json = { + 'url': 'http://www.google.com/', + 'code': 'aBC124' + } + + self.assertTrue(url.to_json() == expected_json) + + def test_to_json_method_not_ok(self): + url = Url() + url.code = 'aBC125' + url.url = 'http://www.google.com/' + + expected_json = { + 'url': 'http://www.google.com/' + } + + self.assertTrue(url.to_json() != expected_json) + + def test_stats_to_json_method_ok(self): + created_now = datetime.datetime.utcnow() + updated_now = datetime.datetime.utcnow() + + url = Url() + url.code = 'aBC126' + url.url = 'http://www.google.com/' + url.created_at = created_now + url.last_usage = updated_now + url.usage_count = 3 + url.save() + + added_url = Url.query.filter_by(code='aBC126').first() + + expected_json = { + 'created_at': created_now.isoformat(), + 'last_usage': updated_now.isoformat(), + 'usage_count': 3 + } + + self.assertTrue(added_url.stats_to_json() == expected_json) + + def test_stats_to_json_method_with_default_values_ok(self): + created_now = datetime.datetime.utcnow() + + url = Url() + url.code = 'aBC127' + url.url = 'http://www.google.com/' + url.created_at = created_now + url.save() + + added_url = Url.query.filter_by(code='aBC127').first() + + expected_json = { + 'created_at': created_now.isoformat(), + 'usage_count': 0 + } + + self.assertTrue(added_url.stats_to_json() == expected_json) + + def test_stats_to_json_method_not_ok(self): + created_now = datetime.datetime.utcnow() + updated_now = datetime.datetime.utcnow() + + url = Url() + url.code = 'aBC126' + url.url = 'http://www.google.com/' + url.created_at = created_now + url.last_usage = updated_now + url.usage_count = 3 + url.save() + + added_url = Url.query.filter_by(code='aBC126').first() + + expected_json = { + 'created_at': created_now.isoformat(), + 'last_usage': updated_now.isoformat(), + 'usage_count': 4 + } + + self.assertTrue(added_url.stats_to_json() != expected_json) + +class TestHelperMethods(TestCase): + def create_app(self): + app.config.from_object('src.config.TestingConfig') + return app + + def setUp(self): + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + + def test_get_url_by_code_ok(self): + url = new_url('a123bc', 'http://www.google.com') + expected_url = get_url_by_code('a123bc') + + self.assertTrue(url == expected_url) + + def test_get_url_by_code_not_ok(self): + url = new_url('a123bc', 'http://www.google.com') + expected_url = get_url_by_code('bc123a') + + self.assertFalse(expected_url) + self.assertTrue(url != expected_url) + + def test_get_url_stats_by_code_ok(self): + url = new_url('bc123a', 'http://www.google.com') + expected_url = get_url_by_code('bc123a') + + self.assertTrue(url == expected_url) + + def test_get_url_stats_by_code_not_ok(self): + url = new_url('bc123a', 'http://www.google.com') + expected_url = get_url_by_code('123456') + + self.assertFalse(expected_url) + self.assertTrue(url != expected_url) + + def test_already_existant_code_ok(self): + url = new_url('bc123a', 'http://www.google.com') + + self.assertFalse(already_existant_code('a1234c')) + self.assertTrue(already_existant_code('bc123a')) \ No newline at end of file