Skip to content

nuvic/flask_for_startups

Repository files navigation

flask_for_startups

Code style: black

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 ☕

Acknowledgements

Why is this useful?

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.

Features

How is this different from other Flask tutorials?

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 a view_func (aka controller)
    • routes.py actually only lists these add_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
  • 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 just user, making it clear that it's an ORM representation of the user
  • 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 and internal_error_code. the validation error message will be different in that it has field errors. (simplicity)
  • 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

Setup Instructions

Change .sample_flaskenv to .flaskenv

Database setup

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.

Repo setup

  • git clone git@github.com: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 dependencies
    • python3 -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

Updating db schema

  • 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 in migrations/versions

Run tests

  • 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

Dependency management

Using poetry.

Activate poetry shell and virtual environment:

  • poetry shell

Check for outdated dependencies:

  • poetry show --outdated

Other details

  • 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