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 a GET method to /hello

  • Request handlers must be coroutines, hence they must use async

  • Ponty 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_json decorator 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

  • startmeup is 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 HelloReq class inherits Request, the Ponty base class responsible for applying a sequence of pre-processing rules to the HTTP request

    • name = 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 references name. When invoked from the class, the name descriptor 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',)
      
  • expect connects HelloReq with the handler function

    • at compile time, expect validates the bijection between the parameter list and the sequence of rules

    • at runtime, expect captures the request, runs it through each rule in HelloReq, then parameterizes each result into the decorated handler. (In this case, it binds name <- '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 Request subclass UpdateProfileReq uses PosIntRouteParameter to capture the dynamic URI component. PosIntRouteParameter matches on \d+ and casts the match to an integer

  • UpdateProfileReq uses ParsedJsonBody as 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; handler is 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_status allows you to control additional aspects of the response

    • HTTP status code is a required argument

    • headers and request body (text for the raw string, body for a JSON-serializable Python object) are optional

    • In this case we’re using Result to enumerate the possible status codes, trusting update_profile to return the right one, and then using raise_status(result.value) to build the HTTP response with that status code

A few curls:

success (200)
$ 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
missing the “email” parameter (400)
$ 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
non-integer 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’s filepath argument instead.

3

See ponty.dataclass_to_jsonschema() for details and examples.