The Tutorial¶
Note
This tutorial assumes some familiarity with the Falcon framework so make sure you check it out if you haven’t already.
Installation¶
Using PIP¶
pip supports installing packages from git repositories:
pip install git+ssh://git@bitbucket.org/leadbrite/req.git@v1.14.0#egg=req
This works inside of requirements files as well:
echo "git+ssh://git@bitbucket.org/leadbrite/req.git@v1.14.0#egg=req" >> requirements.txt
From Source¶
You can vendor req by copying it into your project. If you go this
route make sure that python-dateutil is on your PYTHONPATH.
Goals of the Tutorial¶
This tutorial will guide you through building a simple blogging application’s API. By the end of this tutorial you will have a sufficient understanding of req to be able to start using it in your own projects.
Structure Your Project¶
While Falcon and req don’t impose any particular project structure, for the purposes of this tutorial we will structure our project as follows:
example/
├── blog
│ ├── __init__.py
│ ├── endpoints.py # contains the req Endpoint declarations
│ ├── handlers.py # contains the Falcon handlers
│ ├── models.py # contains the database interfaces
│ └── schema.py # contains the req Schema declarations
└── main.py # initializes Falcon and registers the Endpoints
Note
Since req does not provide any facilities for interacting with database management systems, we will not focus on the contents of the models.py file. That part is left as an exercise to the reader to implement using their preferred storage engine.
Core Concepts¶
Before we begin, let’s briefly outline the two major concepts in req:
- Schemas
- Schemas describe how data is structured. You normally use them to validate user inputs but they’re also usable as containers for data (like models in an ORM) and they can be adapted to various data definition formats like JSONSchema.
- Endpoints
- Endpoints describe how data can be reached. They expose a domain-specific language for hooking up your routes, schema and handlers.
A Schema for Articles¶
The API is going to expose endpoints for creating, reading and listing blog Articles, so start by defining how an Article is supposed to be structured:
1 2 3 4 5 6 | from req import schema
class Article(schema.Schema):
headline = schema.String(_max_length=150)
content = schema.String()
author_email = schema.Email(_name="authorEmail")
|
With that, you get a simple Python interface with which you can construct, render and validate Article data:
>>> from blog.schema import Article
You can construct Articles:
>>> article = Article(
... headline="Hello, World!",
... content="...",
... author_email="bogdan@ave81.com"
... )
Convert them to dictionaries:
>>> article.materialize()
{
"headline": "Hello, World!",
"content": "...",
"authorEmail": "bogdan@ave81.com"
}
Use them to validate inputs:
>>> data = article.materialize()
>>> article.validate(data) is None
True
>>> article.validate({})
Traceback (most recent call last):
...
ValidationError: ["headline: missing", "content: missing", "authorEmail: missing"]
>>> article.validate({"authorEmail": "bogdan"})
Traceback (most recent call last):
...
ValidationError: ["headline: missing", "content: missing", "authorEmail: invalid e-mail 'bogdan'"]
You can populate their individual properties with data:
>>> article = Article()
>>> article.headline = "Hello"
>>> article.headline
"Hello"
>>> article.content = 42
Traceback (most recent call last):
...
ValidationError: invalid string
Finally, you can update all their data at once:
>>> article = Article()
>>> article.update({"headline": "Hello", "content": "...", "authorEmail": "john@example.com"})
>>> article.headline
"Hello"
>>> article.update({"headline": "Hello", "authorEmail": "john@example.com"})
>>> article.content = 42
Traceback (most recent call last):
...
ValidationError: ["content: missing"]
Next let’s define a naïve Falcon handler for adding new Articles to the system:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import json
from falcon import HTTP_201, HTTPBadRequest
from req import ValidationError
from .model import insert_article
from .schema import Article
class Articles(object):
def on_post(self, request, response):
try:
request_bytes = request.stream.read()
article_dict = json.loads(request_bytes)
article_schema = Article()
article_schema.validate(article_schema)
article_entity = insert_article(article_dict)
response.status = HTTP_201
response.body = json.dumps(article_entity)
except ValueError:
raise HTTPBadRequest(description="invalid json payload")
except ValidationError as e:
raise HTTPBadRequest(description=",".join(e.message))
|
That’s… not very pretty. In fact, the reason we called that a naïve handler is because req exposes decorators that can help you write the equivalent[1] handler much more concisely:
1 2 3 4 5 6 7 8 9 10 11 12 13 | from falcon import HTTP_201
from req.falcon.decorators import jsonrequest, jsonresponse
from .models import insert_article
class Articles(object):
@jsonresponse
@jsonrequest
def on_post(self, request, response):
article = request.context["json"]
article_entity = insert_article(article)
return HTTP_201, article_entity
|
Warning
The order in which the decorators are applied to the on_post method
is significant: in order for errors that occur when jsonrequest
runs to be formatted correctly, it must be applied to the handler
method before jsonresponse. This rule holds for all
request/response decorators in req.
Your First Endpoints¶
Now that you have a working handler, you can declare an endpoint for it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | from req import Endpoint, Method
from . import handlers, schema
class V1(Endpoint):
path = "/blog/v1"
class Articles(Endpoint):
# We didn't specify a path so it will default to `/articles`
handler = handlers.Articles
class POST(Method):
Request = schema.Article
Response = schema.Article
|
This probably looks a little weird. Let’s look at each chunk in isolation:
class V1(Endpoint):
path = "/blog/v1"
Declares an Endpoint called V1 whose path is /blog/v1. This will
serve as the root of our API and it has no handler or methods of its
own.
class Articles(Endpoint):
# We didn't specify a path so it will default to `/articles`
handler = handlers.Articles
Declares an Endpoint called Articles whose path is unspecified,
so it will default to /articles. Since this Endpoint is nested
under V1 its full path is going to be /blog/v1/articles.
Since its handler points to handlers.Articles, when this Endpoint
gets registered with Falcon its route is going to point to an instance
of that class.
class POST(Method):
Request = schema.Article
Response = schema.Article
This block states that the Articles endpoint accepts POST requests
whose payloads comply with the Article schema and that the response
payload will have the same structure. This is how jsonrequest
figures out which schema to use when validating requests.
Now that your endpoints are in place, you can register them with falcon:
1 2 3 4 5 6 7 8 9 | import blog.endpoints
import falcon
import logging
import req.falcon
logging.basicConfig(level=logging.DEBUG)
app = falcon.API()
req.falcon.register(app, blog.endpoints.V1)
|
And take your API for a spin:
~/example$ gunicorn main:app
[INFO] Starting gunicorn 19.3.0
[INFO] Listening at: http://127.0.0.1:8000 (20493)
[INFO] Using worker: sync
[INFO] Booting worker with pid: 20496
DEBUG:req.falcon:Registered endpoint 'articles' with path '/blog/v1/articles'...
Req tells us it registered a single endpoint at /blog/v1/articles.
Try creating an article[2]:
$ http POST http://localhost:8000/blog/v1/articles headline="Hello, World!" content="hello" authorEmail="bogdan@ave81.com"
HTTP/1.1 201 Created
Connection: close
Date: Wed, 23 Mar 2016 14:55:20 GMT
Server: gunicorn/19.3.0
content-length: 110
content-type: application/json; charset=utf-8
{
"_status": {
"code": 201
},
"authorEmail": "bogdan@ave81.com",
"content": "hello",
"headline": "Hello, World!"
}
Note
HTTPie[2] automatically generates a JSON payload based on the parameters we give it so the above command is the same as this curl command:
curl -H"Content-type: application/json" \
-d"{\"headline\": \"Hello, World!\", \"content\": \"hello\", \"authorEmail\": \"bogdan@ave81.com\"}" \
http://localhost:8000/blog/v1/articles
It looks like that worked; try making some malformed requests:
$ http POST http://localhost:8000/blog/v1/articles
HTTP/1.1 400 Bad Request
Connection: close
Date: Wed, 23 Mar 2016 14:47:05 GMT
Server: gunicorn/19.3.0
content-length: 103
content-type: application/json; charset=utf-8
{
"_status": {
"code": 400,
"errors": [
{
"message": "missing Content-Type header",
"severity": "error"
}
]
}
}
$ http POST http://localhost:8000/blog/v1/articles content="hello"
HTTP/1.1 400 Bad Request
Connection: close
Date: Wed, 23 Mar 2016 14:49:03 GMT
Server: gunicorn/19.3.0
content-length: 151
content-type: application/json; charset=utf-8
{
"_status": {
"code": 400,
"errors": [
{
"message": "headline: missing",
"severity": "error"
},
{
"message": "authorEmail: missing",
"severity": "error"
}
]
}
}
$ http POST http://localhost:8000/blog/v1/articles content="hello" authorEmail="bogdan"
HTTP/1.1 400 Bad Request
Connection: close
Date: Wed, 23 Mar 2016 14:53:19 GMT
Server: gunicorn/19.3.0
content-length: 167
content-type: application/json; charset=utf-8
{
"_status": {
"code": 400,
"errors": [
{
"message": "headline: missing",
"severity": "error"
},
{
"message": "authorEmail: invalid e-mail 'bogdan'",
"severity": "error"
}
]
}
}
Looking Up Individual Articles¶
You’re going to want to be able to grab individual Articles by id after you create them. Unfortunately, Articles don’t contain ids right now. Let’s fix that.
Start by splitting the Article schema into three separate classes:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | from req import schema
class BaseArticle(schema.Schema):
headline = schema.String(_max_length=150)
content = schema.String()
author_email = schema.Email(_name="authorEmail")
class CreateArticle(BaseArticle):
pass
class Article(BaseArticle):
id = schema.Integer()
|
- BaseArticle
- This serves as a representation for the common set of properties present on all Article requests and responses. Other Schemas can extend this and inherit its properties.
- CreateArticle
- This serves as the Schema for Article creation requests.
- Article
- This serves as the Schema for Article responses. This has one extra property compared to BaseArticle, the Article’s numeric id.
Update the Articles endpoint to reflect the schema change:
class Articles(Endpoint):
handler = handlers.Articles
class POST(Method):
Request = schema.CreateArticle
Response = schema.Article
Now if you create an Article, its response will contain an id property:
$ http POST http://localhost:8000/blog/v1/articles headline="Hello, World!" content="hello" authorEmail="bogdan@ave81.com"
HTTP/1.1 201 Created
Connection: close
Date: Wed, 23 Mar 2016 15:30:01 GMT
Server: gunicorn/19.3.0
content-length: 119
content-type: application/json; charset=utf-8
{
"_status": {
"code": 201
},
"authorEmail": "bogdan@ave81.com",
"content": "hello",
"headline": "Hello, World!",
"id": 1
}
Note
We’re assuming that the insert_article method was also updated to
return each Article’s id.
All you have to do now is add an endpoint and a handler for looking up individual Articles:
class Articles(Endpoint):
handler = handlers.Articles
class Article(Endpoint):
path = "/{article_id}"
handler = handlers.Article
class GET(Method):
Response = schema.Article
class POST(Method):
Request = schema.CreateArticle
Response = schema.Article
The curly bracket syntax in the new path denotes that we expect the path to contain a dynamic parameter holding the id. That value will be passed into the handler as a keyword argument.
from falcon import HTTP_201, HTTPNotFound
from req.falcon.decorators import jsonrequest, jsonresponse
from .models import articles, lookup_article, insert_article
class Article(object):
@jsonresponse
def on_get(self, request, response, article_id):
article = lookup_article(article_id)
if article is None:
raise HTTPNotFound(description="article {!r} not found".format(article_id))
return article
Restart your server and you should see the new endpoint being registered:
~/example$ gunicorn main:app
[INFO] Starting gunicorn 19.3.0
[INFO] Listening at: http://127.0.0.1:8000 (24784)
[INFO] Using worker: sync
[INFO] Booting worker with pid: 24787
DEBUG:req.falcon:Registered endpoint 'articles' with path '/blog/v1/articles'...
DEBUG:req.falcon:Registered endpoint 'article' with path '/blog/v1/articles/{article_id}'...
$ http GET http://localhost:8000/blog/v1/articles/1
HTTP/1.1 404 Not Found
Connection: close
Date: Wed, 23 Mar 2016 15:48:06 GMT
Server: gunicorn/19.3.0
content-length: 97
content-type: application/json; charset=utf-8
{
"_status": {
"code": 404,
"errors": [
{
"message": "article '1' not found",
"severity": "error"
}
]
}
}
After creating an article:
$ http GET http://localhost:8000/blog/v1/articles/0
HTTP/1.1 200 OK
Connection: close
Date: Wed, 23 Mar 2016 15:53:46 GMT
Server: gunicorn/19.3.0
content-length: 119
content-type: application/json; charset=utf-8
{
"_status": {
"code": 200
},
"authorEmail": "bogdan@ave81.com",
"content": "hello",
"headline": "Hello, World!",
"id": 0
}
Lists of Articles¶
Now that you can create Articles and look them up individually, you probably want to list them. Once again, start by defining a schema:
class Articles(schema.Schema):
items = schema.List(Article())
Then a handler:
class Articles(object):
@jsonresponse
def on_get(self, request, response):
return {"items": articles}
And hook it all up:
class Articles(Endpoint):
handler = handlers.Articles
class Article(Endpoint):
path = "/{article_id}"
handler = handlers.Article
class GET(Method):
Response = schema.Article
class GET(Method):
Response = schema.Articles
class POST(Method):
Request = schema.CreateArticle
Response = schema.Article
$ http GET http://localhost:8000/blog/v1/articles
HTTP/1.1 200 OK
Connection: close
Date: Wed, 23 Mar 2016 16:34:27 GMT
Server: gunicorn/19.3.0
content-length: 132
content-type: application/json; charset=utf-8
{
"_status": {
"code": 200
},
"items": [
{
"authorEmail": "bogdan@ave81.com",
"content": "hello",
"headline": "Hello, World!",
"id": 0
}
]
}
Next Steps¶
Congratulations on building your first req API! If you’re interested in learning more we suggest you check out the API reference documentation.
Footnotes
| [1] | The second version of the handler has improved error handling and better validation so it is not exactly “equivalent”. |
| [2] | (1, 2) We’re using the httpie utility to make requests to our API because it’s simpler to use than something like curl but you should feel free to use whatever tools you’re familiar with. You might consider using Postman instead of either of those if you prefer GUI tools. |