Getting started¶
Let’s start with basic folder structure:
project folder named
polls
. A root of the project. Run all commands from here.application folder named
aiohttpdemo_polls
inside of itempty file
main.py
. The place where web server will live
We need this nested aiohttpdemo_polls
so we can put config, tests and other related
files next to it.
It looks like this:
polls <-- [current folder]
└── aiohttpdemo_polls
└── main.py
aiohttp server is built around aiohttp.web.Application
instance.
It is used for registering startup/cleanup signals, connecting routes etc.
The following code creates an application:
# aiohttpdemo_polls/main.py
from aiohttp import web
app = web.Application()
web.run_app(app)
Save it and start server by running:
$ python aiohttpdemo_polls/main.py
======== Running on http://0.0.0.0:8080 ========
(Press CTRL+C to quit)
Next, open the displayed link in a browser. It returns a 404: Not Found
error. To show something more meaningful than an error, let’s create a route
and a view.
Views¶
Let’s start with the first views. Create the file aiohttpdemo_polls/views.py
and add the following to it:
# aiohttpdemo_polls/views.py
from aiohttp import web
async def index(request):
return web.Response(text='Hello Aiohttp!')
This index
view is the simplest view possible in Aiohttp.
Now, we should create a route for this index
view. Put the following into
aiohttpdemo_polls/routes.py
. It is a good practice to separate views,
routes, models etc. You’ll have more of each file type, and it is nice to group
them into different places:
# aiohttpdemo_polls/routes.py
from views import index
def setup_routes(app):
app.router.add_get('/', index)
We should add a call to the setup_routes
function somewhere. The best place
to do this is in main.py
:
# aiohttpdemo_polls/main.py
from aiohttp import web
from routes import setup_routes
app = web.Application()
setup_routes(app)
web.run_app(app)
Start server again using python aiohttpdemo_polls/main.py
. This time when we open the browser
we see:
Hello Aiohttp!
Success! Now, your working directory should look like this:
.
├── ..
└── polls
└── aiohttpdemo_polls
├── main.py
├── routes.py
└── views.py
Configuration files¶
Note
aiohttp is configuration agnostic. It means the library does not require any specific configuration approach, and it does not have built-in support for any config schema.
Please note these facts:
99% of servers have configuration files.
Most products (except Python-based solutions like Django and Flask) do not store configs with source code.
For example Nginx has its own configuration files stored by default under
/etc/nginx
folder.MongoDB stores its config as
/etc/mongodb.conf
.Config file validation is a good idea. Strong checks may prevent unnecessary errors during product deployment.
Thus, we suggest to use the following approach:
Push configs as
yaml
files (json
orini
is also good butyaml
is preferred).Load
yaml
config from a list of predefined locations, e.g../config/app_cfg.yaml
,/etc/app_cfg.yaml
.Keep the ability to override a config file by a command line parameter, e.g.
./run_app --config=/opt/config/app_cfg.yaml
.Apply strict validation checks to loaded dict. trafaret, colander or JSON schema are good candidates for such job.
One way to store your config is in folder at the same level as aiohttpdemo_polls.
Create a config
folder and config file at desired location. E.g.:
.
├── ..
└── polls <-- [BASE_DIR]
│
├── aiohttpdemo_polls
│ ├── main.py
│ ├── routes.py
│ └── views.py
│
└── config
└── polls.yaml <-- [config file]
Create a config/polls.yaml
file with meaningful option names:
# config/polls.yaml
postgres:
database: aiohttpdemo_polls
user: aiohttpdemo_user
password: aiohttpdemo_pass
host: localhost
port: 5432
minsize: 1
maxsize: 5
Install pyyaml
package:
$ pip install pyyaml
Let’s also create a separate settings.py
file. It helps to leave main.py
clean and short:
# aiohttpdemo_polls/settings.py
import pathlib
import yaml
BASE_DIR = pathlib.Path(__file__).parent.parent
config_path = BASE_DIR / 'config' / 'polls.yaml'
def get_config(path):
with open(path) as f:
config = yaml.safe_load(f)
return config
config = get_config(config_path)
Next, load the config into the application:
# aiohttpdemo_polls/main.py
from aiohttp import web
from settings import config
from routes import setup_routes
app = web.Application()
setup_routes(app)
app['config'] = config
web.run_app(app)
Now, try to run your app again. Make sure you are running it from BASE_DIR
:
$ python aiohttpdemo_polls/main.py
======== Running on http://0.0.0.0:8080 ========
(Press CTRL+C to quit)
For the moment nothing should have changed in application’s behavior. But at least we know how to configure our application.
Database¶
Server¶
Here, we assume that you have running database and a user with write access. Refer to Database for details.
Schema¶
We will use SQLAlchemy to describe database schema for two related models,
question
and choice
:
+---------------+ +---------------+
| question | | choice |
+===============+ +===============+
| id | <---+ | id |
+---------------+ | +---------------+
| question_text | | | choice_text |
+---------------+ | +---------------+
| pub_date | | | votes |
+---------------+ | +---------------+
+-------- | question_id |
+---------------+
Create db.py
file with database schemas:
# aiohttpdemo_polls/db.py
from sqlalchemy import (
MetaData, Table, Column, ForeignKey,
Integer, String, Date
)
meta = MetaData()
question = Table(
'question', meta,
Column('id', Integer, primary_key=True),
Column('question_text', String(200), nullable=False),
Column('pub_date', Date, nullable=False)
)
choice = Table(
'choice', meta,
Column('id', Integer, primary_key=True),
Column('choice_text', String(200), nullable=False),
Column('votes', Integer, server_default="0", nullable=False),
Column('question_id',
Integer,
ForeignKey('question.id', ondelete='CASCADE'))
)
Note
It is possible to configure tables in a declarative style like so:
class Question(Base):
__tablename__ = 'question'
id = Column(Integer, primary_key=True)
question_text = Column(String(200), nullable=False)
pub_date = Column(Date, nullable=False)
But it doesn’t give much benefits later on. SQLAlchemy ORM doesn’t work in
asynchronous style and as a result aiopg.sa
doesn’t support related ORM
expressions such as Question.query.filter_by(question_text='Why').first()
or session.query(TableName).all()
.
You still can make select
queries after some code modifications:
from sqlalchemy.sql import select
result = await conn.execute(select([Question]))
instead of
result = await conn.execute(question.select())
But it is not as easy to deal with as update/delete queries.
Now we need to create tables in database as it was described with sqlalchemy.
Helper script can do that for you. Create a new file init_db.py
in project’s root:
# polls/init_db.py
from sqlalchemy import create_engine, MetaData
from aiohttpdemo_polls.settings import config
from aiohttpdemo_polls.db import question, choice
DSN = "postgresql://{user}:{password}@{host}:{port}/{database}"
def create_tables(engine):
meta = MetaData()
meta.create_all(bind=engine, tables=[question, choice])
def sample_data(engine):
conn = engine.connect()
conn.execute(question.insert(), [
{'question_text': 'What\'s new?',
'pub_date': '2015-12-15 17:17:49.629+02'}
])
conn.execute(choice.insert(), [
{'choice_text': 'Not much', 'votes': 0, 'question_id': 1},
{'choice_text': 'The sky', 'votes': 0, 'question_id': 1},
{'choice_text': 'Just hacking again', 'votes': 0, 'question_id': 1},
])
conn.close()
if __name__ == '__main__':
db_url = DSN.format(**config['postgres'])
engine = create_engine(db_url)
create_tables(engine)
sample_data(engine)
Note
A more advanced version of this script is mentioned in Database notes.
Install the aiopg[sa]
package (it will pull sqlalchemy
alongside) to interact with the database,
and run the script:
$ pip install aiopg[sa]
$ python init_db.py
Note
At this point we are not using any async features of the package. For this
reason, you could have installed psycopg2
package. Though since we are
using sqlalchemy, we also could switch the type of database server.
Now there should be one record for question with related choice options stored in corresponding tables in the database.
Use psql
, pgAdmin
or any other tool you like to check database contents:
$ psql -U postgres -h localhost -p 5432 -d aiohttpdemo_polls
aiohttpdemo_polls=# select * from question;
id | question_text | pub_date
----+---------------+------------
1 | What's new? | 2015-12-15
(1 row)
Doing things at startup and shutdown¶
Sometimes it is necessary to configure some component’s setup and tear down. For a database this would be the creation of a connection or connection pool and closing it afterwards.
Pieces of code below belong to aiohttpdemo_polls/db.py
and aiohttpdemo_polls/main.py
files.
Complete files will be shown shortly after.
Creating connection engine¶
For making DB queries we need an engine instance. Assuming conf
is
a dict
with the configuration info for a Postgres connection, this
could be done by the following async generator function:
async def pg_context(app):
conf = app['config']['postgres']
engine = await aiopg.sa.create_engine(
database=conf['database'],
user=conf['user'],
password=conf['password'],
host=conf['host'],
port=conf['port'],
minsize=conf['minsize'],
maxsize=conf['maxsize'],
)
app['db'] = engine
yield
app['db'].close()
await app['db'].wait_closed()
Add the code to aiohttpdemo_polls/db.py
file.
The best place for connecting to the DB is using the
cleanup_ctx
signal:
app.cleanup_ctx.append(pg_context)
On startup, the code is run until the yield
. When the application is
shutdown the code will resume and close the DB connection.
Note
We could also have used separate startup/shutdown functions with the
on_startup
and
on_cleanup
signals. However, a
cleanup context ties the 2 parts together so that the DB can be
correctly shutdown even if an error occurs in another startup step.
Complete files with changes¶
# aiohttpdemo_polls/db.py
import aiopg.sa
from sqlalchemy import (
MetaData, Table, Column, ForeignKey,
Integer, String, Date
)
__all__ = ['question', 'choice']
meta = MetaData()
question = Table(
'question', meta,
Column('id', Integer, primary_key=True),
Column('question_text', String(200), nullable=False),
Column('pub_date', Date, nullable=False)
)
choice = Table(
'choice', meta,
Column('id', Integer, primary_key=True),
Column('choice_text', String(200), nullable=False),
Column('votes', Integer, server_default="0", nullable=False),
Column('question_id',
Integer,
ForeignKey('question.id', ondelete='CASCADE'))
)
async def pg_context(app):
conf = app['config']['postgres']
engine = await aiopg.sa.create_engine(
database=conf['database'],
user=conf['user'],
password=conf['password'],
host=conf['host'],
port=conf['port'],
minsize=conf['minsize'],
maxsize=conf['maxsize'],
)
app['db'] = engine
yield
app['db'].close()
await app['db'].wait_closed()
# aiohttpdemo_polls/main.py
from aiohttp import web
from settings import config
from routes import setup_routes
from db import pg_context
app = web.Application()
app['config'] = config
setup_routes(app)
app.cleanup_ctx.append(pg_context)
web.run_app(app)
Since we now have database connection on start - let’s use it! Modify index view:
# aiohttpdemo_polls/views.py
from aiohttp import web
import db
async def index(request):
async with request.app['db'].acquire() as conn:
cursor = await conn.execute(db.question.select())
records = await cursor.fetchall()
questions = [dict(q) for q in records]
return web.Response(text=str(questions))
Run server and you should get list of available questions (one record at the moment) with all fields.
Templates¶
For setting up the template engine, we install the aiohttp_jinja2
library first:
$ pip install aiohttp_jinja2
After installing, setup the library:
# aiohttpdemo_polls/main.py
from aiohttp import web
import aiohttp_jinja2
import jinja2
from settings import config, BASE_DIR
from routes import setup_routes
from db import pg_context
app = web.Application()
app['config'] = config
aiohttp_jinja2.setup(app,
loader=jinja2.FileSystemLoader(str(BASE_DIR / 'aiohttpdemo_polls' / 'templates')))
setup_routes(app)
app.cleanup_ctx.append(pg_context)
web.run_app(app)
As you can see from setup above - templates should be placed at aiohttpdemo_polls/templates
folder.
Let’s create simple template and modify index view to use it:
<!--aiohttpdemo_polls/templates/index.html-->
{% set title = "Main" %}
{% if questions %}
<ul>
{% for question in questions %}
<li>{{ question.question_text }}</li>
{% endfor %}
</ul>
{% else %}
<p>No questions are available.</p>
{% endif %}
Templates are a very convenient way for web page writing. If we return a
dict with page content, the aiohttp_jinja2.template
decorator
processes the dict using the jinja2 template renderer.
# aiohttpdemo_polls/views.py
import aiohttp_jinja2
import db
@aiohttp_jinja2.template('index.html')
async def index(request):
async with request.app['db'].acquire() as conn:
cursor = await conn.execute(db.question.select())
records = await cursor.fetchall()
questions = [dict(q) for q in records]
return {"questions": questions}
Run the server and you should see a question decorated in html list element.
Let’s add more views:
@aiohttp_jinja2.template('detail.html')
async def poll(request):
async with request.app['db'].acquire() as conn:
question_id = request.match_info['question_id']
try:
question, choices = await db.get_question(conn,
question_id)
except db.RecordNotFound as e:
raise web.HTTPNotFound(text=str(e))
return {
'question': question,
'choices': choices
}
Static files¶
Any web site has static files such as: images, JavaScript sources, CSS files
The best way to handle static files in production is by setting up a reverse proxy like NGINX or using CDN services.
During development, handling static files using the aiohttp server is very convenient.
Fortunately, this can be done easily by a single call:
def setup_static_routes(app):
app.router.add_static('/static/',
path=PROJECT_ROOT / 'static',
name='static')
where project_root
is the path to the root folder.
Middlewares¶
Middlewares are stacked around every web-handler. They are called before the handler for a pre-processing request. After getting a response back, they are used for post-processing the given response.
A common use of middlewares is to implement custom error pages. Example from Middlewares documentation will render 404 errors using a JSON response, as might be appropriate for a REST service.
Here we’ll create a little bit more complex middleware custom display pages for 404 Not Found and 500 Internal Error.
Every middleware should accept two parameters, a request and a handler, and return the response. Middleware itself is a coroutine that can modify either request or response:
Now, create a new middlewares.py
file:
# middlewares.py
import aiohttp_jinja2
from aiohttp import web
async def handle_404(request):
return aiohttp_jinja2.render_template('404.html', request, {}, status=404)
async def handle_500(request):
return aiohttp_jinja2.render_template('500.html', request, {}, status=500)
def create_error_middleware(overrides):
@web.middleware
async def error_middleware(request, handler):
try:
return await handler(request)
except web.HTTPException as ex:
override = overrides.get(ex.status)
if override:
return await override(request)
raise
except Exception:
request.protocol.logger.exception("Error handling request")
return await overrides[500](request)
return error_middleware
def setup_middlewares(app):
error_middleware = create_error_middleware({
404: handle_404,
500: handle_500
})
app.middlewares.append(error_middleware)
As you can see, we do nothing before the web handler. In the case of an HTTPException
,
we use the Jinja2 template renderer based on ex.status
after the request was handled.
For other exceptions, we log the error and render our 500 template. Without the
create_error_middleware
function, the same task would take us many more if
statements.
We have registered middleware in app
by adding it to app.middlewares
.
Now, add a setup_middlewares
step to the main file:
# aiohttpdemo_polls/main.py
from aiohttp import web
from settings import config
from routes import setup_routes
from middlewares import setup_middlewares
app = web.Application()
setup_routes(app)
setup_middlewares(app)
app['config'] = config
web.run_app(app)
Run the app again. To test, try an invalid url.