Getting started

Using hrefs with FastAPI

Setting up the application

To make the hrefs library work with FastAPI, hrefs.starlette.HrefMiddleware needs 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 fastapi import HTTPException
from hrefs import BaseReferrableModel

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

    class Config:
        details_view = "get_book"

books = {
  1: Book(id=1, title="Basic hrefs"),
  2: Book(id=2, title="Advanced hrefs"),
  3: Book(id=3, title="Masterful hrefs"),
}

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

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

  • Inherit from hrefs.BaseReferrableModel.

  • Have a configuration called details_view naming the route that returns the representation of the model. The URLs will be built by reversing that route, using the model primary key as parameter.

Note

Pydantic v2 deprecates config classes and uses config dicts as the new configuration mechanism. See Using hrefs with pydantic v2 for more information how this affects hrefs.

In the above example Book.id is the primary key. The primary key usually corresponds to a database primary key, but it’s by no means a requirement. By default, the primary key is the id field but can be configured. See Configuring model key for details.

The primary key name typically appears as a path parameter in the route, but this isn’t required either. Keys can be converted to and from both path and query parameters. Keys omitted from the path are assumed to be query parameters.

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.

Note

Routes mounted via sub-applications are also supported. The library relies on the Starlette reverse URL lookup to convert keys to URLs, so don’t forget to use the {prefix}:{name} style to refer to the route in case you use named mounts.

Defining a relationship to the referrable model

from fastapi import Response
from hrefs import Href
from pydantic import parse_obj_as

class Library(BaseReferrableModel):
    id: int
    books: list[Href[Book]]

    class Config:
        details_view = "get_library"

libraries: dict[int, Library] = {}

@app.get("/libraries/{id}")
def get_library(id: int) -> Library:
    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):
    if any(book.key not in books for book in library.books):
        raise HTTPException(
            status_code=400, detail="Trying to add a nonexistent book to library"
        )
    libraries[library.id] = library
    return Response(
        status_code=201,
        headers={"Location": str(parse_obj_as(Href[Library], library).url)},
    )

The annotated type Href[Book] is used to declare a hyperlink to Book.

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

  • Another hrefs.Href instance.

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

  • Any value convertible to the type of the id field (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 pydantic serializes hrefs.Href objects to JSON, they are serialized as URLs.

>>> from fastapi.testclient import TestClient
>>> client = TestClient(app)
>>> response = client.post(
...     "/libraries",
...     json={
...         "id": 1,
...         "books": [
...             "http://testserver/books/1",
...             "http://testserver/books/2",
...             "http://testserver/books/3",
...         ]
...     }
... )
>>> response.headers["Location"]
'http://testserver/libraries/1'
>>> response = client.get("http://testserver/libraries/1")
>>> response.json()
{'id': 1, 'books': ['http://testserver/books/1', 'http://testserver/books/2', 'http://testserver/books/3']}

A full working example

The tests/ folder contains a minimal toy application demonstrating how the hrefs library is used. It is an expanded version of the small application used as an example in this chapter that also demonstrates Advanced topics. 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
import warnings

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

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

if int(getattr(pydantic, "__version__", "1").split(".", 1)[0]) >= 2:
    warnings.simplefilter("ignore", category=pydantic.PydanticDeprecatedSince20)


class _BookBase(BaseReferrableModel):
    title: str


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


class Book(_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(BaseReferrableModel):
    books: List[Href[Book]]


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


class Library(_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", status_code=201)
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": str(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=201)
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": str(new_book.self.url)})

You can run the test application on 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 URLs 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 pydantic v2

Pydantic v2 introduces numerous backward incompatible changes and deprecates many classes and functions used in v1. Unless otherwise mentioned, this guide uses v1 style, but it is straightforward to use the corresponding v2 classes and functions.

One notable change in pydantic v2 is using model config instead of config classes. Using v2 style, the definition of Book from the Defining a referrable model section becomes:

from hrefs import BaseReferrableModel, HrefsConfigDict

class Book(BaseReferrableModel):
    model_config = HrefsConfigDict(details_view="get_book")

    id: int
    title: str

Note that we use hrefs.HrefsConfigDict instead of pydantic.ConfigDict. The former is a subtype of the latter that includes details_view. Using it ensures that auto-completion and type checking tools work as intended. If you don’t care about type based tooling, using pydantic.ConfigDict or native dict is just as good.

Using hrefs with Starlette

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

  • For each subclass of hrefs.BaseReferrableModel there is a named route matching the details_view configuration.

  • hrefs.starlette.HrefMiddleware is added to the middleware stack.

  • In the responses, the pydantic models containing references are explicitly serialized using the model.json() method (or model.model_dump_json() in pydantic v2).

Writing a custom integration

The hrefs library works out of the box with Starlette and FastAPI, but can be integrated to work with other web frameworks too.

The hrefs.Href class can refer to any type implementing the hrefs.Referrable abstract base class. If you plan to take advantage of pydantic type annotations and want metaclass magic to take care of most of the heavy lifting, hrefs.BaseReferrableModel is the best starting point.

See Custom web framework integrations for the API that new web framework integrations need to implement.