From 3965a7a06d354686047739c37b69d744567e56a5 Mon Sep 17 00:00:00 2001 From: Migue Vargas Date: Tue, 20 Aug 2019 00:33:39 -0300 Subject: [PATCH 1/5] Added app --- .gitignore | 116 ++++++++++++++++++++++++++++ INSTALL.md | 34 ++++++++ app.py | 23 ++++++ build.sh | 20 +++++ features/__init__.py | 0 features/api.feature | 88 +++++++++++++++++++++ features/environment.py | 25 ++++++ features/steps/__init__.py | 0 features/steps/api.py | 133 ++++++++++++++++++++++++++++++++ requirements.txt | 9 +++ server.py | 4 + server.sh | 5 ++ shorter_app/__init__.py | 0 shorter_app/apis/__init__.py | 7 ++ shorter_app/apis/shorter_api.py | 84 ++++++++++++++++++++ shorter_app/config.py | 32 ++++++++ shorter_app/database.py | 19 +++++ shorter_app/models.py | 32 ++++++++ shorter_app/repositories.py | 38 +++++++++ shorter_app/validator.py | 39 ++++++++++ test/__init__.py | 0 test/test_api.py | 53 +++++++++++++ test/test_models.py | 2 + test/test_repository.py | 2 + test/test_validator.py | 70 +++++++++++++++++ 25 files changed, 835 insertions(+) create mode 100755 .gitignore create mode 100644 INSTALL.md create mode 100644 app.py create mode 100755 build.sh create mode 100644 features/__init__.py create mode 100644 features/api.feature create mode 100644 features/environment.py create mode 100644 features/steps/__init__.py create mode 100644 features/steps/api.py create mode 100644 requirements.txt create mode 100644 server.py create mode 100755 server.sh create mode 100644 shorter_app/__init__.py create mode 100644 shorter_app/apis/__init__.py create mode 100644 shorter_app/apis/shorter_api.py create mode 100644 shorter_app/config.py create mode 100644 shorter_app/database.py create mode 100644 shorter_app/models.py create mode 100644 shorter_app/repositories.py create mode 100644 shorter_app/validator.py create mode 100644 test/__init__.py create mode 100644 test/test_api.py create mode 100644 test/test_models.py create mode 100644 test/test_repository.py create mode 100644 test/test_validator.py diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..70635eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +include/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +pip-selfcheck.json +bin/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ +*_venv/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# idea +.idea/ + +# miscellaneous files +log/ +.DS_Store +baseline_coverage/ +.docker_config/ +reports/ + +# intellij +*.iml + +# vs code +*.vscode + +# Pact files +.pacts/ + +# SonarQube +.scannerwork/ diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..3df36c8 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,34 @@ + + +# Shorter URL Application +This application contains the APIs for translate long URLs in short URLs. + +## Getting Started +#### First make sure you run this code in a virtual environment: + +* pip (or conda) package manager are required +`python3 -m venv shorter_venv` +`source shorter_venv/bin/activate` + + +#### Now to get the app running: + +1. Create a build `./build.sh` +2. Start the local dev server `./server.sh` +3. Check that the app is running: http://localhost:5000/shorter/healthcheck/ +4. For Unit test execute `pytest` +5. For Feature test execute `behave` + + +## Database + +### Applying Changes +Alembic is in implementation process + +###TODO: + +* Complete UT for all APIs +* Change SQL DB for some NON-SQL DB +* Dockerize +* Add log +* Test date object properly diff --git a/app.py b/app.py new file mode 100644 index 0000000..ce470fb --- /dev/null +++ b/app.py @@ -0,0 +1,23 @@ +from flask import Flask +from shorter_app.apis import api +from shorter_app.config import DefaultConfig +from shorter_app.database import db_session, init_db + + +def create_app(config=DefaultConfig): + app = Flask(__name__) + app.config.from_object(config) + api.init_app( + app, + title="Shorter Application", + version="0.0.1", + description="Microservice for translate URL in a shorter URL", + ) + + init_db() + + @app.teardown_request + def teardown_request(request): + db_session.remove() + + return app diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d97ecb6 --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +echo "Installing Requirements" +pip install -r requirements.txt + +echo "Auto-formatting code" +black . + +echo "Running Unit Tests" +if ! pytest; then + echo "One or more unit tests failed" + exit 1 +fi + +echo "Running System Tests" +if ! behave; then + echo "One or more system tests failed" + exit 1 +fi +echo "Build Done!" diff --git a/features/__init__.py b/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/api.feature b/features/api.feature new file mode 100644 index 0000000..ccbf7a7 --- /dev/null +++ b/features/api.feature @@ -0,0 +1,88 @@ +Feature: Test Shorter API + + Scenario: Healthcheck + When I test healthcheck + Then I get status code 200 + And the value of Service is OK + + + Scenario: Create a short URL + When I post a valid short URL with a valid code + Then I get status code 201 + And the code is correct + + + Scenario: Create a short URL without code + When I post a valid short URL without code + Then I get status code 201 + And the code is correct + + + Scenario: Invalid URL + When I post an invalid URL + Then I get status code 400 + And the value of Error is ERROR_INVALID_URL + + + Scenario: Missing URL + When I post a missing URL + Then I get status code 400 + And the value of Error is ERROR_URL_IS_REQUIRED + + + Scenario: Invalid code + When I post an invalid code + Then I get status code 400 + And the value of Error is ERROR_INVALID_CODE + + + Scenario: Duplicated code + When I post a valid short URL with a valid code + Then I get status code 201 + And the code is correct + When I post a valid short URL with the same valid code + Then I get status code 409 + And the value of Error is ERROR_DUPLICATED_CODE + + + Scenario: Get code + When I post a valid short URL with a valid code and URL http://url.com + Then I get status code 201 + And the code is correct + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + + Scenario: Get invalid code + When I get an invalid code + Then I get status code 404 + And the value of Error is ERROR_CODE_NOT_FOUND + + + Scenario: Get code stats + When I post a valid short URL with a valid code and URL http://url.com + Then I get status code 201 + And the code is correct + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + When I get code stats + Then the value of created_at is a_valid_date + Then the value of last_usage is a_valid_date + Then the value of usage_count is 3 + + + Scenario: Get an invalid code for stats + When I get invalid code stats + Then I get status code 404 + And the value of Error is ERROR_CODE_NOT_FOUND diff --git a/features/environment.py b/features/environment.py new file mode 100644 index 0000000..da462e1 --- /dev/null +++ b/features/environment.py @@ -0,0 +1,25 @@ +import os +import tempfile +from behave import fixture, use_fixture + +from app import create_app +from shorter_app.database import init_db +from shorter_app.config import TestConfig + + +@fixture +def shorter_client(context, *args, **kwargs): + app = create_app(TestConfig) + context.db, app.config["DATABASE"] = tempfile.mkstemp() + app.testing = True + context.client = app.test_client() + with app.app_context(): + init_db() + yield context.client + # -- CLEANUP: + os.close(context.db) + os.unlink(app.config["DATABASE"]) + + +def before_feature(context, feature): + use_fixture(shorter_client, context) diff --git a/features/steps/__init__.py b/features/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/steps/api.py b/features/steps/api.py new file mode 100644 index 0000000..31cf8d5 --- /dev/null +++ b/features/steps/api.py @@ -0,0 +1,133 @@ +import json +from behave import when, then +from shorter_app.apis.shorter_api import generate_shortcode +from shorter_app.validator import ( + ERROR_INVALID_CODE, + ERROR_DUPLICATED_CODE, + ERROR_INVALID_URL, + ERROR_CODE_NOT_FOUND, + ERROR_URL_IS_REQUIRED, +) + + +@when("I test healthcheck") +def step_impl(context): + response = context.client.get("/shorter/healthcheck/") + context.response = response + + +@then("I get status code {status_code}") +def step_impl(context, status_code): + assert int(status_code) == context.response.status_code + + +@then("the value for location header is {url}") +def test_get_code(context, url): + assert context.response.headers.get("location") == url + + +@then("the value of {attribute} is {value}") +def step_impl(context, attribute, value): + if attribute in ["created_at", "last_usage"]: + assert json.loads(context.response.data).get(attribute) + elif attribute == "usage_count": + assert json.loads(context.response.data).get(attribute) == value + else: + if value == "ERROR_INVALID_CODE": + value = ERROR_INVALID_CODE + if value == "ERROR_DUPLICATED_CODE": + value = ERROR_DUPLICATED_CODE + if value == "ERROR_INVALID_URL": + value = ERROR_INVALID_URL + if value == "ERROR_CODE_NOT_FOUND": + value = ERROR_CODE_NOT_FOUND + if value == "ERROR_URL_IS_REQUIRED": + value = ERROR_URL_IS_REQUIRED + + assert json.loads(context.response.data).get(attribute) == value + + +@when("I post a valid short URL with a valid code") +def test_post_ok(context): + context.code = generate_shortcode() + response = context.client.post( + "/shorter/urls/", json={"url": "http://url.com", "code": context.code} + ) + context.response = response + + +@when("I post a valid short URL with a valid code and URL {url}") +def test_post_ok(context, url): + context.code = generate_shortcode() + response = context.client.post( + "/shorter/urls/", json={"url": url, "code": context.code} + ) + context.response = response + + +@then("the code is correct") +def step_impl(context): + code = json.loads(context.response.data).get("code") + assert code == context.code + + +@when("I post a valid short URL without code") +def test_post_ok_without_code(context): + response = context.client.post("/shorter/urls/", json={"url": "http://url.com"}) + context.response = response + context.code = json.loads(response.data).get("code") + + +@when("I post an invalid URL") +def test_post_invalid_url(context): + response = context.client.post("/shorter/urls/", json={"url": "XXX"}) + context.response = response + + +@when("I post a missing URL") +def test_post_invalid_url(context): + response = context.client.post("/shorter/urls/", json={"invalid_field": "XXX"}) + context.response = response + + +@when("I post an invalid code") +def test_post_invalid_shortcode(context): + response = context.client.post( + "/shorter/urls/", json={"url": "http://url.com", "code": "$%$^$%&"} + ) + context.response = response + + +@when("I post a valid short URL with the same valid code") +def test_post_duplicated_shortcode(context): + code = context.code + response = context.client.post( + "/shorter/urls/", json={"url": "http://url.com", "code": code} + ) + context.response = response + + +@when("I get the same valid code") +def test_get_code(context): + code = context.code + response = context.client.get(f"/shorter/urls/{code}/") + context.response = response + + +@when("I get an invalid code") +def test_get_url_invalid_code(context): + response = context.client.get("/shorter/urls/XXXX/") + context.response = response + + +@when("I get code stats") +def test_get_code_stats(context): + code = context.code + response = context.client.get(f"/shorter/urls/{code}/stats/") + context.response = response + + +@when("I get invalid code stats") +def test_get_invalid_code_stats(context): + response = context.client.get("/shorter/urls/XXXX/stats/") + context.response = response diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6658cee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +requests==2.21.0 +Flask==1.0.2 +flask-restplus==0.12.1 +pytest==4.2.0 +SQLAlchemy==1.3.0 +validators==0.13.0 +behave==1.2.6 +pytest-mock==1.10.0 +black diff --git a/server.py b/server.py new file mode 100644 index 0000000..6e53e78 --- /dev/null +++ b/server.py @@ -0,0 +1,4 @@ +from app import create_app + +app = create_app() +app.run(debug=True) diff --git a/server.sh b/server.sh new file mode 100755 index 0000000..68b6d58 --- /dev/null +++ b/server.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +export FLASK_APP=app.py +export FLASK_ENV=development +flask run -p 5000 \ No newline at end of file diff --git a/shorter_app/__init__.py b/shorter_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shorter_app/apis/__init__.py b/shorter_app/apis/__init__.py new file mode 100644 index 0000000..92c6f1e --- /dev/null +++ b/shorter_app/apis/__init__.py @@ -0,0 +1,7 @@ +from flask_restplus import Api + +from shorter_app.apis.shorter_api import api as shorter_api + +api = Api(title="Shorter API", version="1.0", description="Shorter API") + +api.add_namespace(shorter_api) diff --git a/shorter_app/apis/shorter_api.py b/shorter_app/apis/shorter_api.py new file mode 100644 index 0000000..2cfa99e --- /dev/null +++ b/shorter_app/apis/shorter_api.py @@ -0,0 +1,84 @@ +import random +import string +from flask import request, redirect +from flask_restplus import Namespace, Resource +from shorter_app.models import Shorter, Stats +from shorter_app.repositories import ShorterRepository, StatsRepository +from shorter_app.validator import validate_url, validate_code, ERROR_CODE_NOT_FOUND + +from datetime import datetime + +api = Namespace("shorter", description="Shorter App") + + +def generate_shortcode(): + return "".join(random.choices(string.ascii_letters + string.digits, k=6)) + + +@api.route("/healthcheck/") +class Healthcheck(Resource): + def get(self): + return {"Service": "OK"} + + +@api.route("/urls/") +class Url(Resource): + @api.doc("Get shorter url") + @api.response(200, "Get Short URL list") + def get(self): + shorter_list = ShorterRepository.get_all() + return {"shorter_list": [s.as_dict() for s in shorter_list]}, 200 + + @api.doc("Post shorter url") + @api.response(201, "Short URL Created") + def post(self): + content = request.json + url = content.get("url") + code = content.get("code") + + error, status_code = validate_url(url) + if error: + return error, status_code + + if code: + error, status_code = validate_code(code) + if error: + return error, status_code + else: + code = generate_shortcode() + shorter = Shorter(url=url, code=code) + ShorterRepository.add(shorter) + created_at = datetime.now() + stats = Stats(code, created_at) + StatsRepository.add(stats) + return {"code": code}, 201 + + +@api.route("/urls//") +class UrlItem(Resource): + @api.doc("Get shorter url") + def get(self, code): + result = ShorterRepository.get(code) + + if not result: + return {"Error": ERROR_CODE_NOT_FOUND}, 404 + stats = StatsRepository.get(code) + stats.usage_count += 1 + stats.last_usage = datetime.now() + StatsRepository.commit() + return redirect(result.url) + + +@api.route("/urls//stats/") +class StatsItem(Resource): + @api.doc("Get code stast") + def get(self, code): + result = StatsRepository.get(code) + if not result: + return {"Error": ERROR_CODE_NOT_FOUND}, 404 + + return { + "created_at": str(result.created_at), + "last_usage": str(result.last_usage), + "usage_count": str(result.usage_count), + } diff --git a/shorter_app/config.py b/shorter_app/config.py new file mode 100644 index 0000000..e1541e5 --- /dev/null +++ b/shorter_app/config.py @@ -0,0 +1,32 @@ +import logging + + +class FixedConfig: + DEBUG = False + TESTING = False + JSON_AS_ASCII = False + USE_FILE_LOGGING = True + LOG_LOCATION = "./log/shorter.log" + LOG_MAX_BYTES = 100000 + LOG_BACKUP_COUNT = 2 + LOG_FORMAT = "%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]" + LOG_LEVEL = logging.DEBUG + SQLALCHEMY_ECHO = False + DATABASE_NAME = "shorter" + USE_API_STUBS = False + INTERNAL_API_TIMEOUT = 3.5 + + +class DefaultConfig(FixedConfig): + SERVER_NAME = "localhost:5000" + ENVIRONMENT_NAME = "development" + SQLALCHEMY_DATABASE_URI = "sqlite:////tmp/app.db" + BASE_URL = "" + + +class TestConfig(FixedConfig): + SERVER_NAME = "localhost:5000" + ENVIRONMENT_NAME = "local_test" + SQLALCHEMY_DATABASE_URI = "" + USE_LOCAL_MOCK_SERVER = True + BASE_URL = "shorter" diff --git a/shorter_app/database.py b/shorter_app/database.py new file mode 100644 index 0000000..61f3857 --- /dev/null +++ b/shorter_app/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +engine = create_engine("sqlite:////tmp/test2.db", convert_unicode=True) +db_session = scoped_session( + sessionmaker(autocommit=False, autoflush=False, bind=engine) +) +Base = declarative_base() +Base.query = db_session.query_property() + + +def init_db(): + # import all modules here that might define models so that + # they will be registered properly on the metadata. Otherwise + # you will have to import them first before calling init_db() + import shorter_app.models + + Base.metadata.create_all(bind=engine) diff --git a/shorter_app/models.py b/shorter_app/models.py new file mode 100644 index 0000000..e753719 --- /dev/null +++ b/shorter_app/models.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime +from shorter_app.database import Base +from datetime import datetime + + +class Shorter(Base): + __tablename__ = "shorter" + id = Column(Integer, primary_key=True) + url = Column(String, nullable=False) + code = Column(String, nullable=False, unique=True) + + def __init__(self, url, code): + self.url = url + self.code = code + + def as_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + + +class Stats(Base): + __tablename__ = "stats" + id = Column(Integer, primary_key=True) + code = Column(String, ForeignKey("shorter.code"), nullable=False) + usage_count = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=True) + last_usage = Column(DateTime, default=datetime.utcnow, nullable=True) + + def __init__(self, code, created_at): + self.code = code + self.created_at = created_at + self.last_usage = created_at + self.usage_count = 0 diff --git a/shorter_app/repositories.py b/shorter_app/repositories.py new file mode 100644 index 0000000..f6c603c --- /dev/null +++ b/shorter_app/repositories.py @@ -0,0 +1,38 @@ +from shorter_app.database import db_session +from shorter_app.models import Shorter, Stats + + +class BaseRespository: + @classmethod + def commit(cls): + db_session.commit() + + +class ShorterRepository(BaseRespository): + @classmethod + def add(cls, shorter): + db_session.add(shorter) + db_session.commit() + + @classmethod + def get(cls, code): + return Shorter.query.filter(Shorter.code == code).first() + + @classmethod + def get_all(cls): + return db_session.query(Shorter).all() + + +class StatsRepository(BaseRespository): + @classmethod + def add(self, stats): + db_session.add(stats) + db_session.commit() + + @classmethod + def get(self, code): + return Stats.query.filter(Stats.code == code).first() + + @classmethod + def get_all(self): + return db_session.query(Stats).all() diff --git a/shorter_app/validator.py b/shorter_app/validator.py new file mode 100644 index 0000000..2da1ee4 --- /dev/null +++ b/shorter_app/validator.py @@ -0,0 +1,39 @@ +import re +from shorter_app.repositories import ShorterRepository + +ERROR_INVALID_CODE = ( + "Invalid code. The code must comply with the following restrictions: " + "Alphanumeric string. " + "6 chars length. " + "Case-sensitive" +) +ERROR_DUPLICATED_CODE = "Code already registered. Try a new one" +ERROR_INVALID_URL = "An invalid URL was provided" +ERROR_CODE_NOT_FOUND = "Code was not found" +ERROR_URL_IS_REQUIRED = "URL is required" + + +def is_valid_code(code): + return code and len(code) == 6 and re.match("^[a-zA-Z0-9_]+$", code) + + +def validate_url(url): + if not url: + return {"Error": ERROR_URL_IS_REQUIRED}, 400 + import validators + + if not validators.url(url): + return {"Error": ERROR_INVALID_URL}, 400 + + return None, None + + +def validate_code(code): + if is_valid_code(code): + result = ShorterRepository.get(code) + if result: + return {"Error": ERROR_DUPLICATED_CODE}, 409 + else: + return None, None + else: + return {"Error": ERROR_INVALID_CODE}, 400 diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_api.py b/test/test_api.py new file mode 100644 index 0000000..95e6436 --- /dev/null +++ b/test/test_api.py @@ -0,0 +1,53 @@ +import re +import flask +from unittest.mock import MagicMock +from shorter_app.repositories import ShorterRepository, StatsRepository +from shorter_app.apis.shorter_api import generate_shortcode, Url, UrlItem, StatsItem + + +class TestApi: + def test_generate_code(self): + code = generate_shortcode() + assert len(code) == 6 + assert re.match("^[a-zA-Z0-9_]+$", code) + + def test_get_shorter_list(self): + url_api = Url() + response, status_code = url_api.get() + assert response.get("shorter_list") + assert status_code == 200 + + def test_post_shorter_item(self, mocker): + url_api = Url() + + request_mock = mocker.patch.object(flask, "request") + # response, status_code = url_api.post() + + def test_get_shorter_item(self, mocker): + url_api = UrlItem() + + query_mock = MagicMock() + repository_get_mock = mocker.patch.object( + ShorterRepository, "get", return_value=query_mock + ) + + stats_commit_mock = mocker.patch.object( + StatsRepository, "commit", return_value=query_mock + ) + + stats_get_mock = mocker.patch.object( + StatsRepository, "get", return_value=query_mock + ) + + stats_get_mock.usage_count = 0 + url_api.get("123456") + repository_get_mock.assert_called_once_with("123456") + stats_commit_mock.assert_called_once_with() + stats_get_mock.assert_called_once_with("123456") + + def test_get_shorter_item_not_found(self): + url_api = UrlItem() + response, status_code = url_api.get("XXXXXX") + assert status_code == 404 + + # TODO complete UT for all API diff --git a/test/test_models.py b/test/test_models.py new file mode 100644 index 0000000..8e16911 --- /dev/null +++ b/test/test_models.py @@ -0,0 +1,2 @@ +class TestModel: + pass diff --git a/test/test_repository.py b/test/test_repository.py new file mode 100644 index 0000000..4a6de32 --- /dev/null +++ b/test/test_repository.py @@ -0,0 +1,2 @@ +class TestRepository: + pass diff --git a/test/test_validator.py b/test/test_validator.py new file mode 100644 index 0000000..5d5e4a9 --- /dev/null +++ b/test/test_validator.py @@ -0,0 +1,70 @@ +from unittest.mock import MagicMock +from shorter_app.repositories import ShorterRepository +from shorter_app.validator import ( + is_valid_code, + validate_url, + validate_code, + ERROR_INVALID_URL, + ERROR_DUPLICATED_CODE, + ERROR_INVALID_CODE, + ERROR_URL_IS_REQUIRED, +) + + +class TestValidator: + def test_generate_code(self): + assert is_valid_code("ABC123") + assert is_valid_code("abc123") + assert is_valid_code("123456") + assert is_valid_code("ABCDEF") + assert is_valid_code("abcdef") + assert not is_valid_code("xxx") + assert not is_valid_code("xxxxxxxx") + assert not is_valid_code("!@@$") + + assert not is_valid_code("") + assert not is_valid_code(None) + + +class TestvalidateURL: + def test_validate_url(self): + error, status_code = validate_url("http://url.com") + assert not error + assert not status_code + + def test_validate_missing_url(self): + error, status_code = validate_url("") + assert error.get("Error") == ERROR_URL_IS_REQUIRED + assert status_code == 400 + + def test_validate_wrong_url(self): + error, status_code = validate_url("XXX") + assert error.get("Error") == ERROR_INVALID_URL + assert status_code == 400 + + +class TestvalidateCode: + def test_validate_code(self): + error, status_code = validate_code("ABCDEF") + assert not error + assert not status_code + + def test_validate_missing_code(self): + error, status_code = validate_code("") + assert error.get("Error") == ERROR_INVALID_CODE + assert status_code == 400 + + def test_validate_wrong_code(self): + error, status_code = validate_code("XXX") + assert error.get("Error") == ERROR_INVALID_CODE + assert status_code == 400 + + def test_validate_duplicated_code(self, mocker): + query_mock = MagicMock() + repository_get_mock = mocker.patch.object( + ShorterRepository, "get", return_value=query_mock + ) + error, status_code = validate_code("123456") + assert error.get("Error") == ERROR_DUPLICATED_CODE + assert status_code == 409 + repository_get_mock.assert_called_once_with("123456") From 006acb26c60ba7d3e86d778e81cdfffb2d184a09 Mon Sep 17 00:00:00 2001 From: Migue Vargas Date: Tue, 3 Sep 2019 00:42:31 -0300 Subject: [PATCH 2/5] Added more UT. Still pending test about datetime, Stast model, and fix broken test --- build.sh | 2 +- shorter_app/apis/shorter_api.py | 15 ++-- shorter_app/models.py | 4 +- shorter_app/validator.py | 45 +++++----- test/test_api.py | 142 ++++++++++++++++++++++++++++++-- test/test_validator.py | 38 ++++----- 6 files changed, 184 insertions(+), 62 deletions(-) diff --git a/build.sh b/build.sh index d97ecb6..2bafe65 100755 --- a/build.sh +++ b/build.sh @@ -4,7 +4,7 @@ echo "Installing Requirements" pip install -r requirements.txt echo "Auto-formatting code" -black . +#black . echo "Running Unit Tests" if ! pytest; then diff --git a/shorter_app/apis/shorter_api.py b/shorter_app/apis/shorter_api.py index 2cfa99e..381d6b4 100644 --- a/shorter_app/apis/shorter_api.py +++ b/shorter_app/apis/shorter_api.py @@ -1,10 +1,10 @@ import random import string -from flask import request, redirect +from flask import redirect from flask_restplus import Namespace, Resource from shorter_app.models import Shorter, Stats from shorter_app.repositories import ShorterRepository, StatsRepository -from shorter_app.validator import validate_url, validate_code, ERROR_CODE_NOT_FOUND +from shorter_app.validator import Validator, ERROR_CODE_NOT_FOUND from datetime import datetime @@ -16,7 +16,7 @@ def generate_shortcode(): @api.route("/healthcheck/") -class Healthcheck(Resource): +class HealtCheck(Resource): def get(self): return {"Service": "OK"} @@ -32,16 +32,15 @@ def get(self): @api.doc("Post shorter url") @api.response(201, "Short URL Created") def post(self): - content = request.json - url = content.get("url") - code = content.get("code") + url = self.api.payload.get("url") + code = self.api.payload.get("code") - error, status_code = validate_url(url) + error, status_code = Validator().validate_url(url) if error: return error, status_code if code: - error, status_code = validate_code(code) + error, status_code = Validator().validate_code(code) if error: return error, status_code else: diff --git a/shorter_app/models.py b/shorter_app/models.py index e753719..3f5af30 100644 --- a/shorter_app/models.py +++ b/shorter_app/models.py @@ -22,8 +22,8 @@ class Stats(Base): id = Column(Integer, primary_key=True) code = Column(String, ForeignKey("shorter.code"), nullable=False) usage_count = Column(Integer, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=True) - last_usage = Column(DateTime, default=datetime.utcnow, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=True, dt_format='rfc822') + last_usage = Column(DateTime, default=datetime.utcnow, nullable=True, dt_format='rfc822') def __init__(self, code, created_at): self.code = code diff --git a/shorter_app/validator.py b/shorter_app/validator.py index 2da1ee4..0905b8c 100644 --- a/shorter_app/validator.py +++ b/shorter_app/validator.py @@ -13,27 +13,26 @@ ERROR_URL_IS_REQUIRED = "URL is required" -def is_valid_code(code): - return code and len(code) == 6 and re.match("^[a-zA-Z0-9_]+$", code) - - -def validate_url(url): - if not url: - return {"Error": ERROR_URL_IS_REQUIRED}, 400 - import validators - - if not validators.url(url): - return {"Error": ERROR_INVALID_URL}, 400 - - return None, None - - -def validate_code(code): - if is_valid_code(code): - result = ShorterRepository.get(code) - if result: - return {"Error": ERROR_DUPLICATED_CODE}, 409 +class Validator: + def is_valid_code(self, code): + return code and len(code) == 6 and re.match("^[a-zA-Z0-9_]+$", code) + + def validate_url(self, url): + if not url: + return {"Error": ERROR_URL_IS_REQUIRED}, 400 + + import validators + if not validators.url(url): + return {"Error": ERROR_INVALID_URL}, 400 + + return None, None + + def validate_code(self, code): + if self.is_valid_code(code): + result = ShorterRepository.get(code) + if result: + return {"Error": ERROR_DUPLICATED_CODE}, 409 + else: + return None, None else: - return None, None - else: - return {"Error": ERROR_INVALID_CODE}, 400 + return {"Error": ERROR_INVALID_CODE}, 400 diff --git a/test/test_api.py b/test/test_api.py index 95e6436..1c788e3 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,8 +1,15 @@ import re -import flask from unittest.mock import MagicMock from shorter_app.repositories import ShorterRepository, StatsRepository from shorter_app.apis.shorter_api import generate_shortcode, Url, UrlItem, StatsItem +from shorter_app.validator import Validator +from shorter_app.models import Shorter, Stats +from shorter_app.validator import ( + ERROR_URL_IS_REQUIRED, + ERROR_DUPLICATED_CODE, + ERROR_INVALID_URL, + ERROR_INVALID_CODE, +) class TestApi: @@ -11,17 +18,138 @@ def test_generate_code(self): assert len(code) == 6 assert re.match("^[a-zA-Z0-9_]+$", code) - def test_get_shorter_list(self): + def test_get_shorter_list(self, mocker): + shorter_get_all_mock = mocker.patch.object( + ShorterRepository, "get_all", + return_value=[Shorter("url1", "code1"), + Shorter("url2", "code2")] + ) url_api = Url() response, status_code = url_api.get() - assert response.get("shorter_list") assert status_code == 200 + assert len(response.get("shorter_list")) == 2 + shorter_get_all_mock.assert_called_once_with() def test_post_shorter_item(self, mocker): - url_api = Url() + api_mock = MagicMock() + api_mock.payload = dict(url="http://url.com", code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = None, None + validate_code_mock = mocker.patch.object(Validator, "validate_code") + validate_code_mock.return_value = None, None + shorter_mock = mocker.patch.object( + Shorter, "__init__", return_value=None + ) + # shorter_mock2 = mocker.patch("shorter_app.apis.shorter_api.Shorter") + stats_mock = mocker.patch.object( + Stats, "__init__", return_value=None + ) + shorter_repository_add_mock = mocker.patch.object( + ShorterRepository, "add" + ) + stats_repository_add_mock = mocker.patch.object( + StatsRepository, "add" + ) + response, status_code = url_api.post() + assert response.get("code") + assert 201 == status_code + # shorter_mock2.assert_called_once_with(url="http://url.com", code="123456") + shorter_mock.assert_called_once_with(url="http://url.com", code="123456") + # datetime.now is not possible to test due the current time offset. + # stats_mock.assert_called_once_with(response.get("code"), datetime.now()) + validate_url_mock.assert_called_once_with("http://url.com") + validate_code_mock.assert_called_once_with("123456") + + # TODO fix this calls + #shorter_repository_add_mock.assert_called_once_with(shorter_mock.result_value) + #stats_repository_add_mock.assert_called_once_with(stats_mock) - request_mock = mocker.patch.object(flask, "request") - # response, status_code = url_api.post() + def test_post_invalid_code(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="http://url.com", code="1") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = None, None + validate_code_mock = mocker.patch.object(Validator, "validate_code") + validate_code_mock.return_value = {"Error": ERROR_INVALID_CODE}, 400 + + response, status_code = url_api.post() + assert response.get("Error") == ERROR_INVALID_CODE + assert 400 == status_code + validate_url_mock.assert_called_once_with("http://url.com") + validate_code_mock.assert_called_once_with("1") + + def test_post_invalid_url(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="XXX", code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = {"Error": ERROR_INVALID_URL}, 400 + response, status_code = url_api.post() + assert response.get("Error") == ERROR_INVALID_URL + assert 400 == status_code + validate_url_mock.assert_called_once_with("XXX") + + def test_post_missing_url(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = {"Error": ERROR_URL_IS_REQUIRED}, 400 + response, status_code = url_api.post() + assert response.get("Error") == ERROR_URL_IS_REQUIRED + assert 400 == status_code + validate_url_mock.assert_called_once_with(None) + + def test_post_duplicated_item(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="http://url.com", code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = None, None + validate_code_mock = mocker.patch.object(Validator, "validate_code") + validate_code_mock.return_value = {"Error": ERROR_DUPLICATED_CODE}, 409 + response, status_code = url_api.post() + assert response.get("Error") == ERROR_DUPLICATED_CODE + assert 409 == status_code + validate_url_mock.assert_called_once_with("http://url.com") + validate_code_mock.assert_called_once_with("123456") + + def test_generate_code(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="http://url.com") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = None, None + with mocker.patch('shorter_app.apis.shorter_api.generate_shortcode') as generate_shortcode_mock: + generate_shortcode_mock.return_value = "111111" + + shorter_mock = mocker.patch.object( + Shorter, "__init__", return_value=None + ) + # shorter_mock2 = mocker.patch("shorter_app.apis.shorter_api.Shorter") + stats_mock = mocker.patch.object( + Stats, "__init__", return_value=None + ) + shorter_repository_add_mock = mocker.patch.object( + ShorterRepository, "add" + ) + stats_repository_add_mock = mocker.patch.object( + StatsRepository, "add" + ) + response, status_code = url_api.post() + assert response.get("code") + assert 201 == status_code + # shorter_mock2.assert_called_once_with(url="http://url.com", code="123456") + shorter_mock.assert_called_once_with(url="http://url.com") + # datetime.now is not possible to test due the current time offset. + # stats_mock.assert_called_once_with(response.get("code"), datetime.now()) + validate_url_mock.assert_called_once_with("http://url.com") + generate_shortcode_mock.assert_called_once_with() + # TODO fix this calls + # shorter_repository_add_mock.assert_called_once_with(shorter_mock.result_value) + # stats_repository_add_mock.assert_called_once_with(stats_mock) def test_get_shorter_item(self, mocker): url_api = UrlItem() @@ -49,5 +177,3 @@ def test_get_shorter_item_not_found(self): url_api = UrlItem() response, status_code = url_api.get("XXXXXX") assert status_code == 404 - - # TODO complete UT for all API diff --git a/test/test_validator.py b/test/test_validator.py index 5d5e4a9..4612ff4 100644 --- a/test/test_validator.py +++ b/test/test_validator.py @@ -1,9 +1,7 @@ from unittest.mock import MagicMock from shorter_app.repositories import ShorterRepository from shorter_app.validator import ( - is_valid_code, - validate_url, - validate_code, + Validator, ERROR_INVALID_URL, ERROR_DUPLICATED_CODE, ERROR_INVALID_CODE, @@ -13,49 +11,49 @@ class TestValidator: def test_generate_code(self): - assert is_valid_code("ABC123") - assert is_valid_code("abc123") - assert is_valid_code("123456") - assert is_valid_code("ABCDEF") - assert is_valid_code("abcdef") - assert not is_valid_code("xxx") - assert not is_valid_code("xxxxxxxx") - assert not is_valid_code("!@@$") + assert Validator().is_valid_code("ABC123") + assert Validator().is_valid_code("abc123") + assert Validator().is_valid_code("123456") + assert Validator().is_valid_code("ABCDEF") + assert Validator().is_valid_code("abcdef") + assert not Validator().is_valid_code("xxx") + assert not Validator().is_valid_code("xxxxxxxx") + assert not Validator().is_valid_code("!@@$") - assert not is_valid_code("") - assert not is_valid_code(None) + assert not Validator().is_valid_code("") + assert not Validator().is_valid_code(None) class TestvalidateURL: def test_validate_url(self): - error, status_code = validate_url("http://url.com") + error, status_code = Validator().validate_url("http://url.com") assert not error assert not status_code def test_validate_missing_url(self): - error, status_code = validate_url("") + error, status_code = Validator().validate_url("") assert error.get("Error") == ERROR_URL_IS_REQUIRED assert status_code == 400 def test_validate_wrong_url(self): - error, status_code = validate_url("XXX") + error, status_code = Validator().validate_url("XXX") assert error.get("Error") == ERROR_INVALID_URL assert status_code == 400 class TestvalidateCode: def test_validate_code(self): - error, status_code = validate_code("ABCDEF") + error, status_code = Validator().validate_code("ABCDEF") assert not error assert not status_code def test_validate_missing_code(self): - error, status_code = validate_code("") + error, status_code = Validator().validate_code("") assert error.get("Error") == ERROR_INVALID_CODE assert status_code == 400 def test_validate_wrong_code(self): - error, status_code = validate_code("XXX") + error, status_code = Validator().validate_code("XXX") assert error.get("Error") == ERROR_INVALID_CODE assert status_code == 400 @@ -64,7 +62,7 @@ def test_validate_duplicated_code(self, mocker): repository_get_mock = mocker.patch.object( ShorterRepository, "get", return_value=query_mock ) - error, status_code = validate_code("123456") + error, status_code = Validator().validate_code("123456") assert error.get("Error") == ERROR_DUPLICATED_CODE assert status_code == 409 repository_get_mock.assert_called_once_with("123456") From 336b5f80d8948c0cccad9c74d880b1a78de8556e Mon Sep 17 00:00:00 2001 From: Migue Vargas Date: Tue, 3 Sep 2019 20:52:01 -0300 Subject: [PATCH 3/5] Added test for stats --- shorter_app/apis/shorter_api.py | 10 ++-- test/test_api.py | 92 ++++++++++++++++----------------- 2 files changed, 53 insertions(+), 49 deletions(-) diff --git a/shorter_app/apis/shorter_api.py b/shorter_app/apis/shorter_api.py index 381d6b4..ae9e25e 100644 --- a/shorter_app/apis/shorter_api.py +++ b/shorter_app/apis/shorter_api.py @@ -11,6 +11,10 @@ api = Namespace("shorter", description="Shorter App") +def get_current_time(): + return datetime.now() + + def generate_shortcode(): return "".join(random.choices(string.ascii_letters + string.digits, k=6)) @@ -47,7 +51,7 @@ def post(self): code = generate_shortcode() shorter = Shorter(url=url, code=code) ShorterRepository.add(shorter) - created_at = datetime.now() + created_at = get_current_time() stats = Stats(code, created_at) StatsRepository.add(stats) return {"code": code}, 201 @@ -63,7 +67,7 @@ def get(self, code): return {"Error": ERROR_CODE_NOT_FOUND}, 404 stats = StatsRepository.get(code) stats.usage_count += 1 - stats.last_usage = datetime.now() + stats.last_usage = get_current_time() StatsRepository.commit() return redirect(result.url) @@ -80,4 +84,4 @@ def get(self, code): "created_at": str(result.created_at), "last_usage": str(result.last_usage), "usage_count": str(result.usage_count), - } + }, 200 diff --git a/test/test_api.py b/test/test_api.py index 1c788e3..acb9b18 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,4 +1,5 @@ import re +import datetime from unittest.mock import MagicMock from shorter_app.repositories import ShorterRepository, StatsRepository from shorter_app.apis.shorter_api import generate_shortcode, Url, UrlItem, StatsItem @@ -9,10 +10,15 @@ ERROR_DUPLICATED_CODE, ERROR_INVALID_URL, ERROR_INVALID_CODE, + ERROR_CODE_NOT_FOUND, ) -class TestApi: +def mocked_get_current_time(): + return datetime.datetime(2020, 1, 1, 1, 1, 1) + + +class TestURLApi: def test_generate_code(self): code = generate_shortcode() assert len(code) == 6 @@ -21,8 +27,8 @@ def test_generate_code(self): def test_get_shorter_list(self, mocker): shorter_get_all_mock = mocker.patch.object( ShorterRepository, "get_all", - return_value=[Shorter("url1", "code1"), - Shorter("url2", "code2")] + return_value=[MagicMock(url="url1", code="code1"), + MagicMock(url="url2", code="code2")] ) url_api = Url() response, status_code = url_api.get() @@ -38,11 +44,11 @@ def test_post_shorter_item(self, mocker): validate_url_mock.return_value = None, None validate_code_mock = mocker.patch.object(Validator, "validate_code") validate_code_mock.return_value = None, None - shorter_mock = mocker.patch.object( + shorter_init_mock = mocker.patch.object( Shorter, "__init__", return_value=None ) # shorter_mock2 = mocker.patch("shorter_app.apis.shorter_api.Shorter") - stats_mock = mocker.patch.object( + stats_init_mock = mocker.patch.object( Stats, "__init__", return_value=None ) shorter_repository_add_mock = mocker.patch.object( @@ -54,16 +60,15 @@ def test_post_shorter_item(self, mocker): response, status_code = url_api.post() assert response.get("code") assert 201 == status_code - # shorter_mock2.assert_called_once_with(url="http://url.com", code="123456") - shorter_mock.assert_called_once_with(url="http://url.com", code="123456") - # datetime.now is not possible to test due the current time offset. - # stats_mock.assert_called_once_with(response.get("code"), datetime.now()) + shorter_init_mock.assert_called_once_with(url="http://url.com", code="123456") + # TODO check how to mock this function + # stats_init_mock.assert_called_once_with(response.get("code"), mocked_get_current_time()) validate_url_mock.assert_called_once_with("http://url.com") validate_code_mock.assert_called_once_with("123456") # TODO fix this calls - #shorter_repository_add_mock.assert_called_once_with(shorter_mock.result_value) - #stats_repository_add_mock.assert_called_once_with(stats_mock) + # shorter_repository_add_mock.assert_called_once_with(shorter_mock.result_value) + # stats_repository_add_mock.assert_called_once_with(stats_mock) def test_post_invalid_code(self, mocker): api_mock = MagicMock() @@ -116,41 +121,6 @@ def test_post_duplicated_item(self, mocker): validate_url_mock.assert_called_once_with("http://url.com") validate_code_mock.assert_called_once_with("123456") - def test_generate_code(self, mocker): - api_mock = MagicMock() - api_mock.payload = dict(url="http://url.com") - url_api = Url(api=api_mock) - validate_url_mock = mocker.patch.object(Validator, "validate_url") - validate_url_mock.return_value = None, None - with mocker.patch('shorter_app.apis.shorter_api.generate_shortcode') as generate_shortcode_mock: - generate_shortcode_mock.return_value = "111111" - - shorter_mock = mocker.patch.object( - Shorter, "__init__", return_value=None - ) - # shorter_mock2 = mocker.patch("shorter_app.apis.shorter_api.Shorter") - stats_mock = mocker.patch.object( - Stats, "__init__", return_value=None - ) - shorter_repository_add_mock = mocker.patch.object( - ShorterRepository, "add" - ) - stats_repository_add_mock = mocker.patch.object( - StatsRepository, "add" - ) - response, status_code = url_api.post() - assert response.get("code") - assert 201 == status_code - # shorter_mock2.assert_called_once_with(url="http://url.com", code="123456") - shorter_mock.assert_called_once_with(url="http://url.com") - # datetime.now is not possible to test due the current time offset. - # stats_mock.assert_called_once_with(response.get("code"), datetime.now()) - validate_url_mock.assert_called_once_with("http://url.com") - generate_shortcode_mock.assert_called_once_with() - # TODO fix this calls - # shorter_repository_add_mock.assert_called_once_with(shorter_mock.result_value) - # stats_repository_add_mock.assert_called_once_with(stats_mock) - def test_get_shorter_item(self, mocker): url_api = UrlItem() @@ -177,3 +147,33 @@ def test_get_shorter_item_not_found(self): url_api = UrlItem() response, status_code = url_api.get("XXXXXX") assert status_code == 404 + + +class TestStatsApi: + def test_get_shorter_list(self, mocker): + stats_get_all_mock = mocker.patch.object( + StatsRepository, "get", + return_value=MagicMock(created_at="2019-09-03 00:00:00", + last_usage="2019-10-03 00:00:00", + usage_count=1) + + ) + mock_api = MagicMock(code="123456") + stats_api = StatsItem(mock_api) + response, status_code = stats_api.get("123456") + assert status_code == 200 + assert response.get("created_at") == "2019-09-03 00:00:00" + assert response.get("last_usage") == "2019-10-03 00:00:00" + assert response.get("usage_count") == '1' + stats_get_all_mock.assert_called_once_with("123456") + + def test_get_shorter_list_error(self, mocker): + stats_get_all_mock = mocker.patch.object( + StatsRepository, "get", + return_value=None) + mock_api = MagicMock(code="123456") + stats_api = StatsItem(mock_api) + response, status_code = stats_api.get("123456") + assert status_code == 404 + assert response.get("Error") == ERROR_CODE_NOT_FOUND + stats_get_all_mock.assert_called_once_with("123456") From 9741da6e198828ab078059912afe16d01d0f9a21 Mon Sep 17 00:00:00 2001 From: Migue Vargas Date: Fri, 6 Sep 2019 00:07:39 -0300 Subject: [PATCH 4/5] Added swagger and marshal --- app.py | 1 + features/api.feature | 4 +++ features/steps/api.py | 6 ++++ shorter_app/apis/shorter_api.py | 62 +++++++++++++++++++++++++-------- shorter_app/config.py | 1 + test/test_api.py | 20 ++++++----- 6 files changed, 71 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index ce470fb..604cc1b 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ def create_app(config=DefaultConfig): app = Flask(__name__) app.config.from_object(config) + api._doc = app.config.get("SWAGGER_DOC_PATH") api.init_app( app, title="Shorter Application", diff --git a/features/api.feature b/features/api.feature index ccbf7a7..9ef4fad 100644 --- a/features/api.feature +++ b/features/api.feature @@ -5,6 +5,10 @@ Feature: Test Shorter API Then I get status code 200 And the value of Service is OK + Scenario: Get a short URL list + When I get a valid short URL with a valid code + Then I get status code 200 + Scenario: Create a short URL When I post a valid short URL with a valid code diff --git a/features/steps/api.py b/features/steps/api.py index 31cf8d5..ce842c4 100644 --- a/features/steps/api.py +++ b/features/steps/api.py @@ -131,3 +131,9 @@ def test_get_code_stats(context): def test_get_invalid_code_stats(context): response = context.client.get("/shorter/urls/XXXX/stats/") context.response = response + + +@when("I get a valid short URL with a valid code") +def test_get_invalid_code_stats(context): + response = context.client.get("/shorter/urls/") + context.response = response diff --git a/shorter_app/apis/shorter_api.py b/shorter_app/apis/shorter_api.py index ae9e25e..5befa0b 100644 --- a/shorter_app/apis/shorter_api.py +++ b/shorter_app/apis/shorter_api.py @@ -1,7 +1,7 @@ import random import string from flask import redirect -from flask_restplus import Namespace, Resource +from flask_restplus import Namespace, Resource, fields, marshal from shorter_app.models import Shorter, Stats from shorter_app.repositories import ShorterRepository, StatsRepository from shorter_app.validator import Validator, ERROR_CODE_NOT_FOUND @@ -25,16 +25,48 @@ def get(self): return {"Service": "OK"} +request_shorter_url = api.model( + "Post Short URL (Request)", + { + "code": fields.String, + "url": fields.String, + }, +) + +response_shorter_url = api.model( + "Get Short URL list (Response)", + { + "code": fields.String, + }, +) + +response_stats = api.model( + "Get Stats(Response)", + { + "created_at": fields.String, + "last_usage": fields.String, + "usage_count": fields.String, + }, +) + + @api.route("/urls/") class Url(Resource): - @api.doc("Get shorter url") - @api.response(200, "Get Short URL list") + @api.doc("Get short url") + # TODO update response_shorter_url + @api.response(200, "Get Short URL list", response_shorter_url) + @api.response(401, "Unauthorized") + @api.response(400, "Bad Request") def get(self): shorter_list = ShorterRepository.get_all() - return {"shorter_list": [s.as_dict() for s in shorter_list]}, 200 - - @api.doc("Post shorter url") - @api.response(201, "Short URL Created") + return marshal(shorter_list, response_shorter_url), 200 + + @api.doc("Post short url") + @api.expect(request_shorter_url) + @api.response(401, "Unauthorized") + @api.response(400, "Bad Request") + @api.response(409, "Duplicated code") + @api.response(201, "Short URL Created", response_shorter_url) def post(self): url = self.api.payload.get("url") code = self.api.payload.get("code") @@ -54,12 +86,15 @@ def post(self): created_at = get_current_time() stats = Stats(code, created_at) StatsRepository.add(stats) - return {"code": code}, 201 + return marshal(shorter, response_shorter_url), 201 @api.route("/urls//") class UrlItem(Resource): - @api.doc("Get shorter url") + @api.doc("Get short url by code") + @api.response(200, "Get Short URL list", response_shorter_url) + @api.response(401, "Unauthorized") + @api.response(404, "Not Found") def get(self, code): result = ShorterRepository.get(code) @@ -75,13 +110,12 @@ def get(self, code): @api.route("/urls//stats/") class StatsItem(Resource): @api.doc("Get code stast") + @api.response(200, "Get Short URL stasts", response_stats) + @api.response(401, "Unauthorized") + @api.response(404, "Not Found") def get(self, code): result = StatsRepository.get(code) if not result: return {"Error": ERROR_CODE_NOT_FOUND}, 404 - return { - "created_at": str(result.created_at), - "last_usage": str(result.last_usage), - "usage_count": str(result.usage_count), - }, 200 + return marshal(result, response_stats), 200 diff --git a/shorter_app/config.py b/shorter_app/config.py index e1541e5..e5a2190 100644 --- a/shorter_app/config.py +++ b/shorter_app/config.py @@ -15,6 +15,7 @@ class FixedConfig: DATABASE_NAME = "shorter" USE_API_STUBS = False INTERNAL_API_TIMEOUT = 3.5 + SWAGGER_DOC_PATH = "/documents/" class DefaultConfig(FixedConfig): diff --git a/test/test_api.py b/test/test_api.py index acb9b18..27234dd 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -27,13 +27,15 @@ def test_generate_code(self): def test_get_shorter_list(self, mocker): shorter_get_all_mock = mocker.patch.object( ShorterRepository, "get_all", - return_value=[MagicMock(url="url1", code="code1"), - MagicMock(url="url2", code="code2")] + return_value=[{"url": "url1", "code": "code1"}, + {"url": "url2", "code": "code2"}] ) url_api = Url() response, status_code = url_api.get() assert status_code == 200 - assert len(response.get("shorter_list")) == 2 + assert len(response) == 2 + assert response[0].get("code") == "code1" + assert response[1].get("code") == "code2" shorter_get_all_mock.assert_called_once_with() def test_post_shorter_item(self, mocker): @@ -44,9 +46,9 @@ def test_post_shorter_item(self, mocker): validate_url_mock.return_value = None, None validate_code_mock = mocker.patch.object(Validator, "validate_code") validate_code_mock.return_value = None, None - shorter_init_mock = mocker.patch.object( - Shorter, "__init__", return_value=None - ) + #shorter_init_mock = mocker.patch.object( + # Shorter, "__init__", return_value=None + #) # shorter_mock2 = mocker.patch("shorter_app.apis.shorter_api.Shorter") stats_init_mock = mocker.patch.object( Stats, "__init__", return_value=None @@ -58,16 +60,16 @@ def test_post_shorter_item(self, mocker): StatsRepository, "add" ) response, status_code = url_api.post() - assert response.get("code") + assert response.get("code") == "123456" assert 201 == status_code - shorter_init_mock.assert_called_once_with(url="http://url.com", code="123456") + #shorter_init_mock.assert_called_once_with(url="http://url.com", code="123456") # TODO check how to mock this function # stats_init_mock.assert_called_once_with(response.get("code"), mocked_get_current_time()) validate_url_mock.assert_called_once_with("http://url.com") validate_code_mock.assert_called_once_with("123456") # TODO fix this calls - # shorter_repository_add_mock.assert_called_once_with(shorter_mock.result_value) + # shorter_repository_add_mock.assert_called_once_with(shorter_mock) # stats_repository_add_mock.assert_called_once_with(stats_mock) def test_post_invalid_code(self, mocker): From 049c162c7293751b7fd3ee410e547e5734130ea1 Mon Sep 17 00:00:00 2001 From: Migue Vargas Date: Fri, 6 Sep 2019 00:43:35 -0300 Subject: [PATCH 5/5] Moved app to shorter_app --- features/environment.py | 2 +- server.py | 3 +-- server.sh | 2 +- shorter_app/apis/shorter_api.py | 1 + app.py => shorter_app/app.py | 0 test/test_api.py | 2 ++ 6 files changed, 6 insertions(+), 4 deletions(-) rename app.py => shorter_app/app.py (100%) diff --git a/features/environment.py b/features/environment.py index da462e1..40e7997 100644 --- a/features/environment.py +++ b/features/environment.py @@ -2,7 +2,7 @@ import tempfile from behave import fixture, use_fixture -from app import create_app +from shorter_app.app import create_app from shorter_app.database import init_db from shorter_app.config import TestConfig diff --git a/server.py b/server.py index 6e53e78..c56ed49 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,3 @@ -from app import create_app +from shorter_app.app import create_app app = create_app() -app.run(debug=True) diff --git a/server.sh b/server.sh index 68b6d58..6810a33 100755 --- a/server.sh +++ b/server.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -export FLASK_APP=app.py +export FLASK_APP=server.py export FLASK_ENV=development flask run -p 5000 \ No newline at end of file diff --git a/shorter_app/apis/shorter_api.py b/shorter_app/apis/shorter_api.py index 5befa0b..488ceb9 100644 --- a/shorter_app/apis/shorter_api.py +++ b/shorter_app/apis/shorter_api.py @@ -37,6 +37,7 @@ def get(self): "Get Short URL list (Response)", { "code": fields.String, + "url": fields.String, }, ) diff --git a/app.py b/shorter_app/app.py similarity index 100% rename from app.py rename to shorter_app/app.py diff --git a/test/test_api.py b/test/test_api.py index 27234dd..831a863 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -35,7 +35,9 @@ def test_get_shorter_list(self, mocker): assert status_code == 200 assert len(response) == 2 assert response[0].get("code") == "code1" + assert response[0].get("url") == "url1" assert response[1].get("code") == "code2" + assert response[1].get("url") == "url2" shorter_get_all_mock.assert_called_once_with() def test_post_shorter_item(self, mocker):