Quickstart
Keen to get moving? These tutorials will walk you through some common Ponty concepts.
1. A basic application
A simple example demonstrates starting the server and serving a static JSON payload from a static URI.
The endpoint will be served at http://0.0.0.0:8000/hello.
import logging
from ponty import get, render_json, startmeup
@get("/hello")
@render_json
async def handler(_):
return {"greeting": "hello world"}
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
startmeup(port=8000)
Save in t1.py and run with:
$ python t1.py
Which displays:
serving:
GET /hello
DEBUG:asyncio:Using selector: KqueueSelector
======== Running on http://0.0.0.0:8000 ========
(Press CTRL+C to quit)
- From this, you can conclude:
one method/route pair, GET /hello, has been mounted
the server is listening on 0.0.0.0:8000
the server will shutdown if it receives a SIGINT
- Some things to note about the code:
Ponty uses decorator-style routing.
@get("/hello")indicates this handler will only be invoked by HTTP requests specifying aGETmethod to/helloRequest handlers must be coroutines, hence they must use
asyncPonty is built on aiohttp, and deliberately does not interfere with direct use of advanced aiohttp features. Hence, by default, each Ponty handler accepts an aiohttp.Request instance as its only argument and must return an aiohttp.Response instance. (Under the hood, most Ponty code simply intercepts and repackages these.)
In simple cases such as this, where we don’t need additional data from the request, it’s sufficient to stub out the aiohttp.Request parameter with a wildcard. (Later we’ll see how to use the Request ergonomically.)
Ponty is primarily oriented around the development of JSON APIs. For an endpoint returning JSON, use the
render_jsondecorator on a coroutine returning a JSON-serializable Python object to automatically build the aiohttp.Response (i.e. serialize the data, set the body, and set the content-type & contenth-length headers). 1
startmeupis the main entrypoint for an application. The port argument is required
2. Dynamic URIs
In many applications, URIs contain valuable information (e.g. a record id). Let’s stand up an endpoint that, given a person’s name, greets them:
import logging
from ponty import (
get,
expect,
render_json,
Request,
startmeup,
StringRouteParameter,
)
class HelloReq(Request):
name = StringRouteParameter()
@get(f"/hello/{HelloReq.name}")
@expect(HelloReq)
@render_json
async def handler(name: str):
return {"greeting": f"hi {name}!"}
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
startmeup(port=8000)
- New details:
The
HelloReqclass inheritsRequest, the Ponty base class responsible for applying a sequence of pre-processing rules to the HTTP requestname = StringRouteParameter()is one of those rules. It indicates we expect a dynamic URI component, which will be processed as a string, and which we’re choosing to call name
Note the URI,
f"/hello/{HelloReq.name}", an f-string which referencesname. When invoked from the class, thenamedescriptor evaluates to the expression required for path matching (i.e., interpolated,/hello/{name:\w+})Hence,
@get("/hello/{name:\w+}")matches on HTTP requests with method GET, where the URI matches^/hello/(\w+)$:>>> import re >>> match = re.match("^/hello/(\w+)$", "/hello/world") >>> match.groups() ('world',)
expectconnectsHelloReqwith the handler functionat compile time,
expectvalidates the bijection between the parameter list and the sequence of rulesat runtime,
expectcaptures the request, runs it through each rule inHelloReq, then parameterizes each result into the decorated handler. (In this case, it bindsname <- 'world')note now
async def handler(name: str)is conveniently parameterized (and typed)
3. Validating a request body
Many APIs react to data submitted by their clients. Ponty favors validating data as early as possible - this approach reduces the odds of a partial transaction rollback and reduces boilerplate.
Now let’s create an endpoint that updates a user’s profile:
from dataclasses import dataclass
import enum
import logging
from ponty import (
expect,
ParsedJsonBody,
PosIntRouteParameter,
put,
raise_status,
Request,
startmeup,
)
@dataclass(frozen=True)
class Profile:
given_name: str
surname: str
email: str
class UpdateProfileReq(Request):
user_id = PosIntRouteParameter()
body = ParsedJsonBody(Profile)
@put(f"/user/{UpdateProfileReq.user_id}")
@expect(UpdateProfileReq)
async def handler(user_id: int, body: Profile):
result = await update_profile(
user_id,
given_name=body.given_name,
surname=body.surname,
email=body.email,
)
raise_status(result.value)
class Result(enum.IntEnum):
SUCCESS = 200
INVALID = 400
MISSING = 404
async def update_profile(
user_id: int,
*,
given_name: str,
surname: str,
email: str,
) -> Result:
# write to db, etc
return Result.SUCCESS
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
startmeup(port=8000)
- New details:
This time, our
RequestsubclassUpdateProfileRequsesPosIntRouteParameterto capture the dynamic URI component.PosIntRouteParametermatches on\d+and casts the match to an integerUpdateProfileRequsesParsedJsonBodyas well, which has two effects:At compile-time, a jsonschema validator is auto-generated from the annotations in the given dataclass 2 3
When the request is captured, the body is validated against that jsonschema. If validation fails, a 400 Bad Request is raised immediately;
handleris not reached
Once validated, the request body is marshalled into an instance of the dataclass and passed to the handler. Since we called the descriptor body, it takes that name in the parameter list as well
raise_statusallows you to control additional aspects of the responseHTTP status code is a required argument
headers and request body (
textfor the raw string,bodyfor a JSON-serializable Python object) are optionalIn this case we’re using
Resultto enumerate the possible status codes, trustingupdate_profileto return the right one, and then usingraise_status(result.value)to build the HTTP response with that status code
A few curls:
$ curl localhost:8000/user/1 \
-X PUT \
-H "content-type:application/json" \
-d '{"given_name": "John", "surname": "Cleese", "email": "monty@python.org"}' \
-v
> PUT /user/1 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.79.1
> Accept: */*
> content-type:application/json
> Content-Length: 72
>
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Content-Length: 7
< Date: Fri, 14 Oct 2022 02:51:15 GMT
< Server: Python/3.9 aiohttp/3.7.3
<
200: OK
$ curl localhost:8000/user/1 \
-X PUT \
-H "content-type:application/json" \
-d '{"given_name": "John", "surname": "Cleese"}' \
-v
> PUT /user/1 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.79.1
> Accept: */*
> content-type:application/json
> Content-Length: 43
>
< HTTP/1.1 400 Bad Request
< Content-Type: text/plain; charset=utf-8
< Content-Length: 30
< Date: Fri, 14 Oct 2022 02:14:17 GMT
< Server: Python/3.9 aiohttp/3.7.3
<
'email' is a required property
user_id (404)$ curl -X PUT localhost:8000/user/abc -v
> PUT /user/abc HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.79.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Content-Type: text/plain; charset=utf-8
< Content-Length: 14
< Date: Fri, 14 Oct 2022 02:02:04 GMT
< Server: Python/3.9 aiohttp/3.7.3
<
404: Not Found
Footnotes
- 1
Hot tip: dataclasses are not JSON-serializable, but can be turned into a dictionary trivially with asdict.
- 2
This is the only case where Ponty allows runtime inspection of type annotations. Note it is possible to bypass this behavior by using
ParsedJsonBody’sfilepathargument instead.- 3
See
ponty.dataclass_to_jsonschema()for details and examples.