├── pytest.ini ├── coverage.ini ├── .gitignore ├── vc ├── database.py ├── cars │ ├── __init__.py │ └── models.py ├── auth │ ├── models.py │ └── __init__.py ├── main.py └── __init__.py ├── requirements.txt ├── tests ├── settings.py ├── conftest.py └── test_cars.py ├── test_requirements.txt ├── Dockerfile ├── Makefile └── README.md /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_paths = . 4 | -------------------------------------------------------------------------------- /coverage.ini: -------------------------------------------------------------------------------- 1 | [report] 2 | show_missing = True 3 | skip_covered = True 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | __pycache__ 3 | .coverage 4 | .coverage.* 5 | .cache 6 | -------------------------------------------------------------------------------- /vc/database.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy() 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==0.12 2 | flask-jwt==0.3.2 3 | flask-sqlalchemy==2.1 4 | passlib==1.7.0 5 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'super-secret' 2 | SQLALCHEMY_DATABASE_URI = 'sqlite://' 3 | TESTING = True 4 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest==3.0.5 3 | pytest-cov==2.4.0 4 | pytest-pythonpath==0.7.1 5 | pytest-flask==0.10.0 6 | freezegun==0.3.8 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from vc.main import create_app 4 | from vc.database import db 5 | 6 | @pytest.fixture 7 | def app(): 8 | app = create_app() 9 | with app.app_context(): 10 | db.create_all() 11 | return app 12 | -------------------------------------------------------------------------------- /vc/cars/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | from flask_jwt import jwt_required 3 | 4 | from vc.cars.models import Car 5 | 6 | cars = Blueprint('cars', __name__) 7 | 8 | @cars.route('/') 9 | @jwt_required() 10 | def list(): 11 | return jsonify([c.as_dict() for c in Car.query.all()]) 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | RUN python3 -m venv /venv && \ 4 | /venv/bin/pip install --no-cache-dir uwsgi==2.0.14 5 | COPY ./requirements.txt /tmp 6 | RUN /venv/bin/pip install --no-cache-dir -r /tmp/requirements.txt 7 | 8 | RUN mkdir /vc 9 | COPY ./vc /vc 10 | 11 | EXPOSE 5000 12 | 13 | #TODO: use configuration file! 14 | CMD ["/venv/bin/uwsgi", "--http-socket", ":5000", "--manage-script-name", "--mount", "/=vc.main:app"] 15 | -------------------------------------------------------------------------------- /vc/auth/models.py: -------------------------------------------------------------------------------- 1 | from passlib.hash import pbkdf2_sha256 2 | 3 | from vc.database import db 4 | 5 | class User(db.Model): 6 | id = db.Column(db.Integer, primary_key=True) 7 | email = db.Column(db.String(100), unique=True) 8 | password = db.Column(db.String(100)) 9 | confirmed = db.Column(db.Boolean, default=False) 10 | 11 | def __init__(self, email, password): 12 | self.email = email 13 | self.password = pbkdf2_sha256.hash(password) 14 | 15 | def __repr__(self): # pragma: no cover 16 | return '' % self.email 17 | -------------------------------------------------------------------------------- /vc/cars/models.py: -------------------------------------------------------------------------------- 1 | from vc.database import db 2 | 3 | class Car(db.Model): 4 | id = db.Column(db.Integer, primary_key=True) 5 | identifier = db.Column(db.String(100)) 6 | brand = db.Column(db.String(300)) 7 | 8 | def __init__(self, identifier, brand): 9 | self.identifier = identifier 10 | self.brand = brand 11 | 12 | def as_dict(self): 13 | return { 14 | "id": self.id, 15 | "identifier": self.identifier, 16 | "brand": self.brand 17 | } 18 | 19 | def __repr__(self): # pragma: no cover 20 | return '' % self.identifier 21 | -------------------------------------------------------------------------------- /vc/main.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from vc import create_app 4 | from vc.database import db 5 | from vc.auth.models import User 6 | from vc.cars.models import Car 7 | 8 | app = create_app() 9 | 10 | @app.cli.command() 11 | def initdb(): # pragma: no cover 12 | db.create_all() 13 | 14 | @app.cli.command() 15 | def dropdb(): # pragma: no cover 16 | db.drop_all() 17 | 18 | @app.cli.command() 19 | def populatedb(): # pragma: no cover 20 | u1 = User("max", "foo") 21 | u1.confirmed = True 22 | db.session.add(u1) 23 | db.session.add(User("moritz", "bar")) 24 | db.session.add(Car("A-1", "Ford")) 25 | db.session.add(Car("A-2", "Opel")) 26 | db.session.add(Car("B-1", "BMW")) 27 | db.session.commit() 28 | -------------------------------------------------------------------------------- /vc/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_jwt import JWT 3 | 4 | def create_app(): 5 | app = Flask(__name__) 6 | app.config['SECRET_KEY'] = 'super-secret' 7 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db' 8 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 9 | app.config['JWT_AUTH_URL_RULE'] = '/auth/login' 10 | app.config['JWT_AUTH_USERNAME_KEY'] = 'email' 11 | app.config.from_envvar('VC_SETTINGS') 12 | 13 | from vc.database import db 14 | db.init_app(app) 15 | 16 | from vc.auth import auth, jwt_authenticate, jwt_identity 17 | jwt = JWT(app, jwt_authenticate, jwt_identity) 18 | app.register_blueprint(auth, url_prefix='/auth') 19 | 20 | from vc.cars import cars 21 | app.register_blueprint(cars, url_prefix='/cars') 22 | 23 | return app 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VC_APP ?= vc/main.py 2 | VC_HOST ?= 127.0.0.1 3 | VC_PORT ?= 5000 4 | VC_ENV ?= env 5 | VC_IMAGE ?= vc 6 | VC_CONTAINER ?= vc 7 | VC_FLASK ?= FLASK_APP=$(VC_APP) $(VC_ENV)/bin/flask 8 | 9 | dev_init: 10 | python3 -m venv $(VC_ENV) 11 | $(VC_ENV)/bin/pip install -r requirements.txt 12 | $(VC_ENV)/bin/pip install -r test_requirements.txt 13 | 14 | dev_initdb: 15 | $(VC_FLASK) initdb 16 | 17 | dev_dropdb: 18 | $(VC_FLASK) dropdb 19 | 20 | dev_populatedb: 21 | $(VC_FLASK) populatedb 22 | 23 | dev_recreatedb: dev_dropdb dev_initdb dev_populatedb 24 | 25 | dev_server: 26 | $(VC_FLASK) run --host $(VC_HOST) --port $(VC_PORT) 27 | 28 | dev_test: 29 | VC_SETTINGS=../tests/settings.py $(VC_ENV)/bin/pytest --cov=vc --cov-config coverage.ini 30 | 31 | docker_image: 32 | docker build --tag $(VC_IMAGE) . 33 | 34 | docker_run: docker_image 35 | docker run --rm --name $(VC_CONTAINER) -p $(VC_PORT):5000 $(VC_IMAGE) 36 | -------------------------------------------------------------------------------- /tests/test_cars.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from freezegun import freeze_time 5 | 6 | from vc.database import db 7 | from vc.cars.models import Car 8 | from vc.auth.models import User 9 | 10 | @freeze_time("2017-01-11 13:20") 11 | def test_cars_with_jwt(client): 12 | db.session.add(User("max", "foobar")) 13 | cars = sorted([ 14 | ("lorem", "Opel"), 15 | ("ipsum", "BMW"), 16 | ("dolor", "Ford") 17 | ]) 18 | for car in cars: 19 | db.session.add(Car(*car)) 20 | db.session.commit() 21 | jwt = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0ODQxNDA3MTIsImV4cC" 22 | "I6MTQ4NDE0MTAxMiwibmJmIjoxNDg0MTQwNzEyLCJpZGVudGl0eSI6MX0.0Cp4qTYcb" 23 | "tOs7fx-vlvWxlrxdTR7HoX2ipyMwkul2mc") 24 | response = client.get('/cars/', headers=[('Authorization', 'JWT %s' % jwt)]) 25 | assert response.status_code == 200 26 | expected = [] 27 | for car in json.loads(response.data.decode('utf-8')): 28 | expected.append((car['identifier'], car['brand'])) 29 | assert sorted(expected) == cars 30 | 31 | def test_cars_without_jwt(client): 32 | response = client.get('/cars/') 33 | assert response.status_code == 401 34 | -------------------------------------------------------------------------------- /vc/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request, current_app, url_for 2 | from itsdangerous import URLSafeTimedSerializer 3 | from passlib.hash import pbkdf2_sha256 4 | 5 | from vc.database import db 6 | from vc.auth.models import User 7 | 8 | auth = Blueprint('auth', __name__) 9 | 10 | def jwt_authenticate(email, password): 11 | user = User.query.filter_by(email=email, confirmed=True).first() 12 | if user and pbkdf2_sha256.verify(password.encode('utf-8'), user.password): 13 | return user 14 | 15 | def jwt_identity(payload): 16 | #XXX: By default Flask-JWT uses the `id` attribute of whatever object is 17 | # used to represent the identity when generating the token, in our case this 18 | # object is a `User` instance. So here, `payload` is the decoded content of 19 | # the token and `payload['identity']` is a `User.id`. 20 | user = User.query.get(payload['identity']) 21 | return user 22 | 23 | def send_confirmation_email(user, link): 24 | #TODO: actually send email! 25 | print("Link for {user}: {link}".format(user=user, link=link)) 26 | 27 | @auth.route('/signup', methods=['POST']) 28 | def signup(): 29 | payload = request.get_json() 30 | email = payload['email'] 31 | password = payload['password'] 32 | user = User(email, password) 33 | db.session.add(user) 34 | db.session.commit() 35 | s = URLSafeTimedSerializer(current_app.config['SECRET_KEY']) #TODO: salt? 36 | token = s.dumps({"id": user.id, "email": user.email}) 37 | send_confirmation_email( 38 | user, 39 | url_for('auth.confirm', token=token, _external=True) 40 | ) 41 | return jsonify(result='success') 42 | 43 | @auth.route('/confirm') 44 | def confirm(): 45 | s = URLSafeTimedSerializer(current_app.config['SECRET_KEY']) #TODO: salt? 46 | payload = s.loads(request.args.get('token', '')) 47 | uid = payload['id'] 48 | email = payload['email'] 49 | user = User.query.filter_by(id=uid, email=email, confirmed=False).first() 50 | user.confirmed = True 51 | db.session.commit() 52 | return jsonify(result='success') 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vimcar Coding Challenge: Backend 2 | 3 | This project attempts to satisfy the 4 | [Vimcar Coding Challenge: Backend][vimcar-challenge]. 5 | 6 | It is a [Flask][flask] project using Python 3.5, [SQLAlchemy][sqlalchemy] as ORM 7 | (via [Flask-SQLAlchemy][flask-sqlalchemy]), and [JWT][jwt] for authorization 8 | (via [Flask-JWT][flask-jwt]). [Docker][docker] and [uwsgi][uwsgi] are used for 9 | production deployment. 10 | 11 | [vimcar-challenge]: https://github.com/vimcar/backend-challenge 12 | [flask]: http://flask.pocoo.org/ 13 | [sqlalchemy]: http://www.sqlalchemy.org/ 14 | [flask-sqlalchemy]: http://flask-sqlalchemy.pocoo.org/ 15 | [jwt]: https://jwt.io/ 16 | [flask-jwt]: https://pythonhosted.org/Flask-JWT/ 17 | [docker]: https://www.docker.com/ 18 | [uwsgi]: https://uwsgi-docs.readthedocs.io/ 19 | 20 | ## Setup and usage 21 | 22 | Please refer to the *Makefile*, it contains commands to setup a development 23 | environment as well as commands for a minimal Docker setup. 24 | 25 | ### Quickstart 26 | 27 | ``` 28 | make dev_init 29 | make dev_recreatedb 30 | make dev_server 31 | ``` 32 | or 33 | ``` 34 | make docker_run 35 | ``` 36 | 37 | ## Notes 38 | 39 | ### State of the project 40 | 41 | So far the project is still in a very rough state. There pretty much only *happy 42 | path* code which means no error handling is done at all. There are tests missing 43 | as well. The project is also not quite ready for production deployment yet. For 44 | persistence SQLite is used which is not the best choice in the long run. The 45 | Docker setup can be improved to make it run the tests as well (especially useful 46 | in a CI system) 47 | 48 | Furthermore, architecture-wise, I'd split authentication/authorization and 49 | resources into separate applications to have one system in charge of all auth 50 | for all other services behind it. 51 | 52 | ### Choice of libraries 53 | 54 | I'm very opinionated when it comes to dependencies. I try to avoid adding too 55 | many dependencies. I always evaluate multiple aspects of a dependency like how 56 | well it is maintained, how popular it is, how many dependencies it comes with 57 | itself, that it has proper releases, and whether it is easy and/or flexible 58 | enough to be replaced. 59 | 60 | *Flask* and *Flask-SQLAlchemy* are well maintained and even written by the same 61 | author. 62 | 63 | *Flask-JWT* on the other hand was a convenience choice for this very project and 64 | in this very context of a coding challenge. It's not as well maintained and 65 | while it's pretty flexible for what it does it has one or two quirks. But the 66 | biggest issue I have with it is that it doesn't really take advantage of JWT's 67 | authorization capabilities. JWT's claims are perfect for a permission system, 68 | yet Flask-JWT basically only allows giving full access to everyone or no access 69 | at all. 70 | 71 | On a more meta level I'd like to say that I could have chosen any REST framework 72 | that sits on top of Flask or at least something like *Flask-Security* and be 73 | done with the whole thing but I explicitly didn't do this because as far as I 74 | understood I was supposed to show that I understand certain concepts and I feel 75 | like using such frameworks or bundle libraries wouldn't let me do this. 76 | --------------------------------------------------------------------------------