This flask boilerplate was written to help make it easy to iterate on your startup/indiehacker business, thereby increasing your chances of success.
Interested in learning how this works?
Want to show your support? Get me a coffee ☕
- Alex Krupp's django_for_startups repo and article
- Miguel Grinberg's flask mega tutorial repo.
When you're working on a project you're serious about, you want a set of conventions in place to let you develop fast and test different features. The main characteristics of this structure are:
- Predictability
- Readability
- Simplicity
- Upgradability
For side projects especially, having this structure would be useful because it would let you easily pick up the project after some time.
- Works with Python 3.9+
- 12-Factor based settings via
.flaskenv
configuration handling - Login and registration via flask-login
- SQLAlchemy Python SQL toolkit and ORM
- DB Migration using Alembic
- Role-based access control (RBAC) with User, UserRole, and Role models ready to go
- Pytest setup with fixtures for app and models, and integration tests with high coverage
- Validation using pydantic
If you haven't read the above article, what's written here is a summary of the main points, and along with how it contrasts with the Flask structure from other popular tutorials.
To make it simple to see, let's go through the /register
route to see how a user would create an account.
- user goes to
/register
- flask handles this request at
routes.py
:app.add_url_rule('/register', view_func=static_views.register)
- you can see that the route isn't using the usual decorator
@app.route
but instead, the route is connected with aview_func
(aka controller) routes.py
actually only lists theseadd_url_rule
functions connecting a url with a view_func- this makes it very easy for a developer to see exactly what route matches to which view function since it's all in one file. if the urls were split up, you would have to grep through your codebase to find the relevant url
- the view function in file
static_views.py
,register()
simply returns the template
- flask handles this request at
- user enters information on the register form (
register.html
), and submits their info - their user details are passed along to route
/api/register
:app.add_url_rule('/api/register', view_func=account_management_views.register_account, methods=['POST'])
- here the view function in file
account_management_views.py
looks like this:
def register_account(): unsafe_username = request.json.get("username") unsafe_email = request.json.get("email") unhashed_password = request.json.get("password") sanitized_username = sanitization.strip_xss(unsafe_username) sanitized_email = sanitization.strip_xss(unsafe_email) try: user_model = account_management_services.create_account( sanitized_username, sanitized_email, unhashed_password ) except ValidationError as e: return get_validation_error_response(validation_error=e, http_status_code=422) except custom_errors.EmailAddressAlreadyExistsError as e: return get_business_requirement_error_response( business_logic_error=e, http_status_code=409 ) except custom_errors.InternalDbError as e: return get_db_error_response(db_error=e, http_status_code=500) login_user(user_model, remember=True) return {"message": "success"}, 201
- it shows linearly what functions are called for this endpoint (readability and predictability)
- the user input is always sanitized first, with clear variable names of what's unsafe and what's sanitized
- then the actual account creation occurs in a
service
, which is where your business logic happens - if the
account_management_services.create_account
function returns an exception, it's caught here, and an appropriate error response is returned back to the user - otherwise, the user is logged in
- so how does the account creation service work?
def create_account(sanitized_username, sanitized_email): AccountValidator( username=sanitized_username, email=sanitized_email, password=unhashed_password ) if ( db.session.query(User.email).filter_by(email=sanitized_email).first() is not None ): raise custom_errors.EmailAddressAlreadyExistsError() hash = bcrypt.hashpw(unhashed_password.encode(), bcrypt.gensalt()) password_hash = hash.decode() account_model = Account() db.session.add(account_model) db.session.flush() user_model = User( username=sanitized_username, password_hash=password_hash, email=sanitized_email, account_id=account_model.account_id, ) db.session.add(user_model) db.session.commit() return user_model
- first, the user's info has to be validated through
AccountValidator
which checks for things like, does the email exist? - then it checks whether the email exists in the database, and if so, raise a custom error
EmailAddressAlreadyExists
- otherwise, it will add the user to the database and return the
user_model
- notice how the variable is called
user_model
instead of justuser
, making it clear that it's an ORM representation of the user
- first, the user's info has to be validated through
- how do these custom errors work?
- so if a user enters a email that already exists, it will raise this custom error from
custom_errors.py
class EmailAddressAlreadyExistsError(Error): message = "There is already an account associated with this email address." internal_error_code = 40902
- the message is externally displayed to the user, while the
internal_error_code
is more for the frontend to use in debugging. it makes it easy for the frontend to see exactly what error happened and debug it (readability)
def get_business_requirement_error_response(business_logic_error, http_status_code): resp = { "errors": { "display_error": business_logic_error.message, "internal_error_code": business_logic_error.internal_error_code, } } return resp, http_status_code
- error messages are passed back to the frontend via a similar format as above:
display_error
andinternal_error_code
. the validation error message will be different in that it has field errors. (simplicity)
- so if a user enters a email that already exists, it will raise this custom error from
- Testing
- the tests are mostly integration tests using a test database
- more work could be done here, but each endpoint should be tested for: permissions, validation errors, business requirement errors, and success conditions
Change .sample_flaskenv
to .flaskenv
Databases supported:
- PostgreSQL
- MySQL
- SQLite
However, I've only tested using PostgreSQL.
Replace the DEV_DATABASE_URI
with your database uri. If you're wishing to run the tests, update TEST_DATABASE_URI
.
git clone [email protected]:nuvic/flask_for_startups.git
sudo apt-get install python3-dev
(needed to compile psycopg2, the python driver for PostgreSQL)- If using
poetry
for dependency management- `poetry install
- Else use
pip
to install dependenciespython3 -m venv venv
- activate virtual environment:
source venv/bin/activate
- install requirements:
pip install -r requirements.txt
- rename
.sample_flaskenv
to.flaskenv
and update the relevant environment variables in.flaskenv
- initialize the dev database:
alembic -c migrations/alembic.ini -x db=dev upgrade head
- run server:
- with poetry:
poetry run flask run
- without poetry:
flask run
- with poetry:
- if you make changes to models.py and want alembic to auto generate the db migration: `./scripts/db_revision_autogen.sh "your_change_here"
- if you want to write your own changes:
./scripts/db_revision_manual.sh "your_change_here"
and find the new migration file inmigrations/versions
- if your test db needs to be migrated to latest schema:
alembic -c migrations/alembic.ini -x db=test upgrade head
python -m pytest tests
Using poetry.
Activate poetry shell and virtual environment:
poetry shell
Check for outdated dependencies:
poetry show --outdated
- Sequential IDs vs UUIDs?
- see brandur's article for a good analysis of UUID vs sequence IDs
- instead of UUID4, you can use a sequential UUID like a tuid