Skip to content

Commit

Permalink
Implement the SIREN Hypermedia Format - Closes #42 (#49)
Browse files Browse the repository at this point in the history
* Add basic Siren classes

* Add Siren Example based on HAL's

* Add Support for Siren Links

* Add Support for Siren Actions

* Add Field type conversion to HTML Types

* Prepopulare Siren Fields with values from the serialized object

* Ruff Formatting

* Add integration tests for Items

* Add Siren Integration Tests

* Fix bug with conditioned actions and links

* Simplify integration tests

* Enable optional population of fields

* Use Model Objects instead of dicts

* Refactor Actions and Link factory

* Ruff formatting

* Simplify parse_uri logic

* Dereference schemas to avoid missing definitions

* Add some SirenLinkFor tests

* Add more unit tests for Links, Actions and Fields

* Increase test coverage with unit tests to 100%

* Fix edge case with None Actions/Links

* Add Response validation against official jsonschema

* Fix test for non-rendering actions

* Fix Pylint and Mypy warnings

* Remove unnecessary aliases for links and actions

* Unify import statements

* Add Python 3.8 compatibility

* Use Ruff verbose mode

* Use Python 3.8 compatible types

* Avoid fixing issues in ruff check

* Fix RUF022

* Sort imports in __all__

* Fix RUF022 check

* Add poetry.lock to git to avoid version mismatches

* Remove HAL's method and description
  • Loading branch information
ELC authored Feb 10, 2024
1 parent 8af2579 commit 28d7365
Show file tree
Hide file tree
Showing 23 changed files with 4,103 additions and 58 deletions.
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# Ignore lockfile. See https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
2 changes: 1 addition & 1 deletion examples/hal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from examples.hal.app import Item, ItemSummary, Person, app
from examples.hal.data import curies, items, people

__all__ = ["ItemSummary", "Item", "Person", "app", "items", "people", "curies"]
__all__ = ["Item", "ItemSummary", "Person", "app", "curies", "items", "people"]
4 changes: 4 additions & 0 deletions examples/siren/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from examples.siren.app import Item, ItemSummary, Person, app
from examples.siren.data import items, people

__all__ = ["Item", "ItemSummary", "Person", "app", "items", "people"]
4 changes: 4 additions & 0 deletions examples/siren/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import uvicorn

if __name__ == "__main__":
uvicorn.run("examples.siren.app:app", host="127.0.0.1", port=8000, reload=True)
159 changes: 159 additions & 0 deletions examples/siren/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from typing import Any, Optional, Sequence, cast

from fastapi import FastAPI, HTTPException
from pydantic.main import BaseModel

from examples.siren.data import Item as ItemData
from examples.siren.data import Person as PersonData
from examples.siren.data import items, people
from fastapi_hypermodel import (
SirenActionFor,
SirenHyperModel,
SirenLinkFor,
SirenResponse,
)


class ItemSummary(SirenHyperModel):
name: str
id_: str

links: Sequence[SirenLinkFor] = (
SirenLinkFor("read_item", {"id_": "<id_>"}, rel=["self"]),
)

actions: Sequence[SirenActionFor] = (
SirenActionFor("update_item", {"id_": "<id_>"}, name="update"),
)


class Item(ItemSummary):
description: Optional[str] = None
price: float


class ItemUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None


class ItemCreate(BaseModel):
id_: str


class ItemCollection(SirenHyperModel):
items: Sequence[Item]

links: Sequence[SirenLinkFor] = (SirenLinkFor("read_items", rel=["self"]),)

actions: Sequence[SirenActionFor] = (
SirenActionFor("read_item", templated=True, name="find"),
SirenActionFor("update_item", templated=True, name="update"),
)


class Person(SirenHyperModel):
name: str
id_: str
is_locked: bool

items: Sequence[Item]

links: Sequence[SirenLinkFor] = (
SirenLinkFor("read_person", {"id_": "<id_>"}, rel=["self"]),
)

actions: Sequence[SirenActionFor] = (
SirenActionFor("update_person", {"id_": "<id_>"}, name="update"),
SirenActionFor(
"put_person_items",
{"id_": "<id_>"},
description="Add an item to this person and the items list",
condition=lambda values: not values["is_locked"],
name="add_item",
populate_fields=False,
),
)


class PersonCollection(SirenHyperModel):
people: Sequence[Person]

links: Sequence[SirenLinkFor] = (SirenLinkFor("read_people", rel=["self"]),)

actions: Sequence[SirenActionFor] = (
SirenActionFor(
"read_person",
description="Get a particular person",
templated=True,
name="find",
),
SirenActionFor(
"update_person",
description="Update a particular person",
templated=True,
name="update",
),
)


class PersonUpdate(BaseModel):
name: Optional[str] = None
is_locked: Optional[bool] = None


app = FastAPI()
SirenHyperModel.init_app(app)


@app.get("/items", response_model=ItemCollection, response_class=SirenResponse)
def read_items() -> Any:
return items


@app.get("/items/{id_}", response_model=Item, response_class=SirenResponse)
def read_item(id_: str) -> Any:
return next(item for item in items["items"] if item["id_"] == id_)


@app.put("/items/{id_}", response_model=Item, response_class=SirenResponse)
def update_item(id_: str, item: ItemUpdate) -> Any:
base_item = next(item for item in items["items"] if item["id_"] == id_)
update_item = cast(ItemData, item.model_dump(exclude_none=True))
base_item.update(update_item)
return base_item


@app.get("/people", response_model=PersonCollection, response_class=SirenResponse)
def read_people() -> Any:
return people


@app.get("/people/{id_}", response_model=Person, response_class=SirenResponse)
def read_person(id_: str) -> Any:
return next(person for person in people["people"] if person["id_"] == id_)


@app.put("/people/{id_}", response_model=Person, response_class=SirenResponse)
def update_person(id_: str, person: PersonUpdate) -> Any:
base_person = next(person for person in people["people"] if person["id_"] == id_)
update_person = cast(PersonData, person.model_dump(exclude_none=True))
base_person.update(update_person)
return base_person


@app.put("/people/{id_}/items", response_model=Person, response_class=SirenResponse)
def put_person_items(id_: str, item: ItemCreate) -> Any:
complete_item = next(
(item_ for item_ in items["items"] if item_["id_"] == item.id_),
None,
)
if not complete_item:
raise HTTPException(status_code=404, detail=f"No item found with id {item.id_}")

base_person = next(person for person in people["people"] if person["id_"] == id_)

base_person_items = base_person["items"]
base_person_items.append(complete_item)
return base_person
68 changes: 68 additions & 0 deletions examples/siren/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from typing import List

from typing_extensions import NotRequired, TypedDict


class Item(TypedDict):
id_: str
name: str
price: float
description: NotRequired[str]


class Items(TypedDict):
items: List[Item]


items: Items = {
"items": [
{"id_": "item01", "name": "Foo", "price": 10.2},
{
"id_": "item02",
"name": "Bar",
"description": "The Bar fighters",
"price": 62,
},
{
"id_": "item03",
"name": "Baz",
"description": "There goes my baz",
"price": 50.2,
},
{
"id_": "item04",
"name": "Doe",
"description": "There goes my Doe",
"price": 5,
},
]
}


class Person(TypedDict):
id_: str
name: str
is_locked: bool
items: List[Item]


class People(TypedDict):
people: List[Person]


people: People = {
"people": [
{
"id_": "person01",
"name": "Alice",
"is_locked": False,
"items": items["items"][:2],
},
{
"id_": "person02",
"name": "Bob",
"is_locked": True,
"items": items["items"][2:],
},
]
}
9 changes: 1 addition & 8 deletions examples/url_for/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
from examples.url_for.app import Item, ItemSummary, Person, app
from examples.url_for.data import items, people

__all__ = [
"items",
"people",
"Person",
"ItemSummary",
"Item",
"app",
]
__all__ = ["Item", "ItemSummary", "Person", "app", "items", "people"]
38 changes: 30 additions & 8 deletions fastapi_hypermodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
HyperModel,
)
from .linkset import LinkSet, LinkSetType
from .siren import (
SirenActionFor,
SirenActionType,
SirenEmbeddedType,
SirenFieldType,
SirenHyperModel,
SirenLinkFor,
SirenLinkType,
SirenResponse,
get_siren_action,
get_siren_link,
)
from .url_for import UrlFor
from .url_type import URL_TYPE_SCHEMA, UrlType
from .utils import (
Expand All @@ -16,21 +28,31 @@
)

__all__ = [
"InvalidAttribute",
"HasName",
"HyperModel",
"UrlFor",
"URL_TYPE_SCHEMA",
"AbstractHyperField",
"HALFor",
"HALForType",
"HALResponse",
"HalHyperModel",
"HasName",
"HyperModel",
"InvalidAttribute",
"LinkSet",
"LinkSetType",
"SirenActionFor",
"SirenActionType",
"SirenEmbeddedType",
"SirenFieldType",
"SirenHyperModel",
"SirenLinkFor",
"SirenLinkType",
"SirenResponse",
"UrlFor",
"UrlType",
"resolve_param_values",
"AbstractHyperField",
"get_hal_link_href",
"extract_value_by_name",
"get_hal_link_href",
"get_route_from_app",
"URL_TYPE_SCHEMA",
"get_siren_action",
"get_siren_link",
"resolve_param_values",
]
6 changes: 0 additions & 6 deletions fastapi_hypermodel/hal.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ class HALForType(BaseModel):
hreflang: Optional[str] = None
profile: Optional[str] = None
deprecation: Optional[str] = None
method: Optional[str] = None
description: Optional[str] = None

def __bool__(self: Self) -> bool:
return bool(self.href)
Expand All @@ -45,7 +43,6 @@ class HALFor(HALForType, AbstractHyperField[HALForType]):
# pylint: disable=too-many-instance-attributes
_endpoint: str = PrivateAttr()
_param_values: Mapping[str, str] = PrivateAttr()
_description: Optional[str] = PrivateAttr()
_condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr()
_templated: Optional[bool] = PrivateAttr()
# For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal
Expand Down Expand Up @@ -104,14 +101,11 @@ def __call__(
return HALForType()

route = get_route_from_app(app, self._endpoint)
method = next(iter(route.methods), "GET") if route.methods else "GET"

uri_path = self._get_uri_path(app, values, route)

return HALForType(
href=uri_path,
method=method,
description=self._description,
templated=self._templated,
title=self._title,
name=self._name,
Expand Down
Loading

0 comments on commit 28d7365

Please sign in to comment.