Getting started

Using hrefs with FastAPI

Setting up the application

The hrefs library resolves URLs automatically. In order for that to work, it only needs the hrefs.starlette.HrefMiddleware to be included in the middleware stack:

from fastapi import FastAPI
from fastapi.middleware import Middleware
from hrefs.starlette import HrefMiddleware

app = FastAPI(middleware=[Middleware(HrefMiddleware)])

Defining a referrable model

from hrefs.starlette import ReferrableModel

class Book(ReferrableModel):
    id: int
    title: str

    class Config:
        details_view = "get_book"

@app.get("/books/{id}", response_model=Book)
def get_book(id: int):
    book = books.get(id)
    if not book:
        raise HTTPException(status_code=404, detail="Book not found")
    return book

To make a model target for hrefs, it needs to:

  • Inherit from hrefs.starlette.ReferrableModel.

  • Have configuration called details_view naming the route that will return the details of the referrable model. The URLs will be built by reversed routing, using the primary key of the model as parameters.

  • Have a primary key used as a router parameter in details_view. In the above example Book.id is the primary key. This may or may not correspond to the primary key in a database, but hrefs really isn’t concerned the database layer. By default the primary key is the id field, but can be configured. See Configuring model key for details.

Note

If the way to set up the route doesn’t look familiar, refer to the FastAPI documentation. The route name defaults to the name of the handler function, but can also be defined explicitly using the name keyword argument in the @app.get() decorator.

Defining a relationship to the referrable model

from fastapi import HTTPException, Request, Response
from hrefs import Href
from pydantic import BaseModel

class Library(BaseModel):
    id: int
    books: List[Href[Book]]

@app.get("/libraries/{id}", response_model=Library)
def get_library(id: int):
    library = libraries.get(id)
    if not library:
        raise HTTPException(status_code=404, detail="Library not found")
    return library

@app.post("/libraries")
def post_library(library: Library, request: Request):
    if any(book.get_key() not in books for book in library.books):
        raise HTTPException(
            status_code=400, detail="Trying to add nonexisting book to library"
        )
    libraries[library.id] = library
    return Response(
        status_code=201,
        headers={"Location": request.url_for("get_library", id=library.id)},
    )

An annotated type Href[Book] is used to declare a hyperlink to Book — or any other subclass of hrefs.Referrable for that matter!

The hrefs.Href class integrates to pydantic. When parsing the books field , the following values can automatically be converted to hrefs:

  • Another hrefs.Href instance.

  • An instance of the referred object type (in this case Book).

  • A value convertible to the id type of the referred object (in this case int).

  • A URL that can be matched to the route named in the details_view of the referred object type (in this case "get_library").

When serializing hrefs.Href objects to JSON (FastAPI serializes the object returned from the route handler behind the scenes!), it will be represented by URL.

A full working example

The tests/ folder contains a minimal toy application demonstrating how the hrefs library is used. The code is reproduced here for convenience:

"""A test application

This is a self-contained demo of the `hrefs` library. You can run it with your
favorite ASGI server, and interact with it using your favorite HTTP client.

Its state is only contained in memory, so it is advisable also to manage your
libraries in a more persistent format!
"""

# pylint: disable=wrong-import-position,wrong-import-order

from typing import Dict, List
import uuid

from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware import Middleware
from pydantic import BaseModel, parse_obj_as
from typing_extensions import Annotated

from hrefs import Href, PrimaryKey
from hrefs.starlette import ReferrableModel, HrefMiddleware


class _BookBase(BaseModel):
    title: str


class BookCreate(_BookBase):
    """Book creation payload"""


class Book(ReferrableModel, _BookBase):
    """Book in a library

    A book has a `title` and identity, represented by a hyperlink in the `self`
    field.
    """

    self: Annotated[Href["Book"], PrimaryKey(type_=uuid.UUID, name="id")]

    class Config:
        """Book config

        The `hrefs` library requires `details_view` option, which is the name of
        the Starlette route (in this case FastAPI route that is a Starlette
        route under the hood) serving the representation of a book.
        """

        details_view = "get_book"


Book.update_forward_refs()


class _LibraryBase(BaseModel):
    books: List[Href[Book]]


class LibraryCreate(_LibraryBase):
    """Library creation payload"""


class Library(ReferrableModel, _LibraryBase):
    """A library containing some books

    A library has a list of `books`, represented by hyperlinks. Its own identity
    in `self` is also a hyperlink.
    """

    self: Annotated[Href["Library"], PrimaryKey(type_=uuid.UUID, name="id")]

    class Config:
        """Book config

        The `hrefs` library requires `details_view` option, which is the name of
        the Starlette route (in this case FastAPI route that is a Starlette
        route under the hood) serving the representation of a library.
        """

        details_view = "get_library"


Library.update_forward_refs()

books: Dict[Href[Book], Book] = {}
libraries: Dict[Href[Library], Library] = {}

app = FastAPI(middleware=[Middleware(HrefMiddleware)])


@app.get("/libraries/{id}", response_model=Library)
def get_library(id: uuid.UUID):
    """Retrieve a library identified by `id`"""
    href = parse_obj_as(Href[Library], id)
    library = libraries.get(href)
    if not library:
        raise HTTPException(status_code=404, detail="Library not found")
    return library


@app.post("/libraries")
def post_library(library: LibraryCreate):
    """Create a new library

    A library can contain any number of books, represented by a list of
    hyperlinks. The books can either be referred by their `id` (an UUID encoded
    as a string), or a hyperlink (an URL previously returned by `POST /books`
    call).

    The identity will be automatically generated, and returned in the `Location`
    header.

    """
    if any(book not in books for book in library.books):
        raise HTTPException(
            status_code=400, detail="Trying to add nonexisting book to library"
        )
    new_library = Library(self=uuid.uuid4(), **library.dict())
    libraries[new_library.self] = new_library
    return Response(
        status_code=201,
        headers={"Location": new_library.self.url},
    )


@app.get("/books/{id}", response_model=Book)
def get_book(id: uuid.UUID):
    """Retrieve a book identified by `id`"""
    href = parse_obj_as(Href[Book], id)
    book = books.get(href)
    if not book:
        raise HTTPException(status_code=404, detail="Book not found")
    return book


@app.post("/books", status_code=204)
def post_book(book: BookCreate):
    """Create a new book

    The identity will be automatically generated, and returned in the `Location`
    header."""
    new_book = Book(self=uuid.uuid4(), **book.dict())
    books[new_book.self] = new_book
    return Response(status_code=201, headers={"Location": new_book.self.url})

You can run the test application in your favorite ASGI server:

$ uvicorn tests.app:app
INFO:     Started server process
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Then use your favorite HTTP client and start creating libraries. You can use either IDs or URL to refer to the books in the second POST request — the library knows how to parse either!

$ http POST localhost:8000/books title='My first book'
HTTP/1.1 201 Created
Transfer-Encoding: chunked
location: http://localhost:8000/books/fb69a5d5-b956-4189-8e3e-89f001815bec
server: uvicorn


$ http POST localhost:8000/libraries books:='["http://localhost:8000/books/fb69a5d5-b956-4189-8e3e-89f001815bec"]'
HTTP/1.1 201 Created
Transfer-Encoding: chunked
location: http://localhost:8000/libraries/50c224ff-c9f4-4186-8a05-4999f522ea67
server: uvicorn


$ http GET http://localhost:8000/libraries/50c224ff-c9f4-4186-8a05-4999f522ea67
HTTP/1.1 200 OK
content-length: 50
content-type: application/json
server: uvicorn

{
    "books": [
        "http://localhost:8000/books/fb69a5d5-b956-4189-8e3e-89f001815bec"
    ],
    "self": "http://localhost:8000/libraries/50c224ff-c9f4-4186-8a05-4999f522ea67"
}

Using hrefs with Starlette

While the library was written with FastAPI in mind, the integration doesn’t actually depend on FastAPI, only pydantic and Starlette. You can perfectly well write Starlette apps containing hrefs. You just need to ensure that:

Writing a custom integration

The hrefs.Href class can refer to any type implementing the hrefs.Referrable protocol. You can also use the hrefs.BaseReferrableModel ABC to get part of the implementation for free.