The backend for our application that brings people together based on common interests.
Before you can install and run Bounce you'll need the following:
- Docker (see Install Docker)
- Docker Compose (see Install Docker Compose)
Both the Python backend and Postgres need to be configured before they can run. Copy the web and Postgres example configuration files to container/web.env
and container/postgres.env
respectively. These files will contain the environment variables that our web server and Postgres rely on.
Important: Don't expose your config files to the web or commit them to source control.
POSTGRES_USER
: The username to use with our database. This can be anything you like, butbounce
is probably the most sensical choice.POSTGRES_PASSWORD
: The DB password. Make this something relatively secure.POSTGRES_DB
: The name of the DB we'll use in Postgres.
PORT
: The port our web server will listen on. For development use8080
, for production use80
.POSTGRES_HOST
: The hostname of the container running our Postgres DB. This should almost always bepostgres
.POSTGRES_PORT
: The port the Postgres daemon should listen on. The default should be5432
.POSTGRES_USER
: Should match the setting by the same name inpostgres.env
.POSTGRES_PASSWORD
: Should match the setting by the same name inpostgres.env
.POSTGRES_DB
: Should match the setting by the same name inpostgres.env
.
Once you have the requirements installed and you've created your config files you can run the Postgres and web containers.
$ make dev
This will start a Postgres container (if one is not already running) with the environment variables from container/postgres.env
and a web container with the environment variables from container/web.env
.
You should be dropped into a shell in your web container once both containers are running. From there you can install the Bounce Python package for development.
$ pip install -e .
Now you're ready to run the server.
bounce start
To check if your server is running navigate to localhost:8080 in your browser. You should see see Bounce API accepting requests!
. Note that this project directory is mounted to /opt/bounce
in the web
development container, so any edits you make to it should be immediately available in the container - no need to rebuild or restart it while developing!
Bounce is packaged as a Python package. setup.py is used by tools like pip
(which is what we're using) to specify details about our package like it's requirements, the packages it provides, and it's entry point (so we can run it as a command-line utility.
We use requirements.in to specify our dependencies and their versions. When you add a new dependency to the project make sure you specify it in this file, along with a specific version.
We use pip-compile
to parse our depenencies and make sure they are all compatible. When you update requirements.in make sure you run
$ make requirements
to update requirements.txt accordingly.
To ensure our code is nicely formatted and documented we use isort
, flake8
, and pylint
through the lint
Make target.
Before your commit your code, have isort
and yapf
auto-format your code by running make format
from inside your dev container.
To check for code formatting issues, run make lint
from inside your dev container.
Linter configuration can be found in setup.cfg. If you feel that specific lint rules are too restricitve, you can disable them in that file.
All tests should go in the tests
folder. Put any fixtures your tests rely on in conftest.py. To run tests use:
# Run all tests
$ make docker-test
# Clean up test containers
$ make clean
We're relying on Sanic as our HTTP server framework. Our routes and HTTP request handlers can be found in server/init.py.
Adding a route that serves RESTful requests is best illustrated by example. In this example we'll add a new endpoint for managing Users.
Step 1: Create request and response schemas
First we need to figure out what we want our requests and responses to look like on this endpoint. For simplicity our endpoint will only accept GET requests. To make sure that requests and responses on this endpoint fit the required format we'll specify a schema for each, and we'll use these schemas to validate incoming requests and outgoing responses.
We want our GET requests to specify a username
as we'll use it to retreive information about a user. We create a file in bounce/server/resource
called users.py
and put our schema for the GetUserRequest
in it:
class GetUserRequest(metaclass=ResourceMeta):
"""Defines the schema for a GET /users request."""
__params__ = {
'type': 'object',
'required': ['username'],
'properties': {
'username': {
'type': 'string',
}
}
}
The __params__
field is used to specify the schema that the request parameters must match. Specifically, GET /users
requests require a username
field with a string
value. See JSONSchema for more information on schema creation.
We also want our responses to contain the user's full name, email, username, ID, and the time at which they were created, so we specify our GetUserResponse
in the same file as follows:
class GetUserResponse(metaclass=ResourceMeta):
"""Defines the schema for a GET /users response."""
__body__ = {
'type': 'object',
'required': ['full_name', 'username', 'email', 'id', 'createdAt'],
'additionalProperties': False,
'properties': {
'full_name': {
'type': 'string'
},
'username': {
'type': 'string',
},
'email': {
'type': 'string',
'format': 'email',
},
'id': {
'type': 'integer',
'minimum': 0,
},
'createdAt': {
'type': 'integer',
},
}
}
The __body__
field is used to specify the schema that the response body must match. Specifically, the response to a GET /users
request must contain the user's full name, username, email, ID and the time at which the user was created.
Note that in this example our request resource contained only a schema for params, and our response resource contained only a schema for the body. If you like you can specify neither or both schemas for __params__
and __body__
on your resource class.
Step 2: Create a new Endpoint
Now we create a new file in bounce/server/api
called users.py
and create a UsersEndpoint
class in users.py
that will contain all of our HTTP request handlers for the endpoint.
"""Request handlers for the /users endpoint."""
from sanic import response
from ..resource import validate
from ..resource.user import GetUserRequest, GetUserResponse
class UsersEndpoint(Endpoint):
"""Handles requests to /users."""
__uri__ = '/users'
@validate(GetUserRequest, GetUserResponse)
async def get(self, request):
"""Handles a GET /users request."""
return response.json({
'full_name': 'Test Boy',
'username': 'tester',
'email': 'test@test.com',
'id': 1234,
'created_at': 1529785677,
}, status=200)
Notice that we're using the @validate
decorator to validate the request parameters against our GetUserRequest
schema when the request is passed to the function and to validate the response we return against the GetUserResponse
schema. In this case we named our method get
because it serves GET requests. Your method's name should match the HTTP method it handles, otherwise the server will not register it as a request handler. Since our UsersEndpoint
does not have handlers for methods other than GET, it will automatically return an HTTP 405 "Method not allowed" when it sees requests to /users
that are not GET requests.
Step 3: Add the endpoint to the server
Now we can add the endpoint to the server by updating endpoints
in the start
function in cli.py
:
def start(port, pg_host, pg_port, pg_user, pg_password, pg_database):
"""Starts the Bounce webserver with the given configuration."""
conf = ServerConfig(port, pg_host, pg_port, pg_user, pg_password,
pg_database)
# Register your new endpoints here
endpoints = [UsersEndpoint]
serv = Server(conf, endpoints)
serv.start()
We're using SQLAlchemy for interacting with our Postgres DB. Anything related to the DB, like defining schemas/mappings from Python classes to tables, creating queries, and initialization should be placed in the db
module.
Every so often we'll have to update the DB schema. When you need to make an update, create a new .sql
migration file under the schema
folder. Your migration file's name should follow the format N_verb_qualifiers_subject_qualifiers.sql
. So if you were creating the first migration (N
=1) that updates the Clubs
table by adding a owner_id
column you would call your migration 1_add_owner_id_to_club.sql
.
To run your migration make sure your Postgres container is running (make dev
), then run:
# Run <your migration file> against the DB in the POSTGRES container
$ make migrate MIGRATION=<your migration name>
For exmaple, if you wanted to apply the 1_add_owner_id_to_club
migration you would run
# Run 1_add_owner_id_to_club.sql against the DB in the POSTGRES container
$ make migrate MIGRATION=1_add_owner_id_to_club
Bounce's command-line interface is built using Click. Commands can be found in cli/init.py. Note that we generally won't have to specify options when running Bounce commands because Click
will pull options from environment variables in our Docker conatiner (assuming envvar
s are declared for the options).