diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 34f3de9..1af8a04 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v1 diff --git a/README.md b/README.md index 4b0439c..e69ad2e 100644 --- a/README.md +++ b/README.md @@ -19,70 +19,137 @@ --- -FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying hypermedia-driven API development. +FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying +hypermedia-driven API development. -This module adds a new Pydantic model base-class, supporting dynamic `href` generation based on object data. +Hypermedia consist of enriching API responses by providing links to other URIs +within the services to fetch related resources or perform certain actions. There +are several levels according to the [Hypermedia Maturity Model +Levels](https://8thlight.com/insights/the-hypermedia-maturity-model). Using +Hypermedia makes APIs reach Level 3 of the [Richardson Maturity Model +(RMM)](https://en.wikipedia.org/wiki/Richardson_Maturity_Model), which involves +leveraging [Hypertext As The Engine Of Application State +(HATEOAS)](https://en.wikipedia.org/wiki/HATEOAS), that is, Hypermedia. + +Below are some examples of responses using hypermedia. For detailed examples, +check the docs. - + - + + + + + + + + @@ -102,4 +169,3 @@ This is an upstream issue, being tracked [here](https://github.com/encode/starle Huge thanks to [@christoe](https://github.com/christoe) for building support for Pydantic 2. -Some functionality is based on [Flask-Marshmallow](https://github.com/marshmallow-code/flask-marshmallow/blob/dev/src/flask_marshmallow/fields.py) `URLFor` class. diff --git a/docs/advanced.md b/docs/advanced.md index e4b9aa0..7152e97 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,90 +1,566 @@ # Advanced Usage +In addition to what the standard for the format defines, fastapi-hypermodel has +some additional features, such as conditional links. + ##  Conditional Links -It is possible to add additional field-value-dependent conditions on links. For example, you may want certain links within a set to only appear if the application or session is in a state that allows that interaction. +It is possible to add additional field-value-dependent conditions on links. For +example, you may want certain links within a set to only appear if the +application or session is in a state that allows that interaction. -Let's begin with our `Person` example from earlier. +A new model `Person` is defined below, a person has an `id`, a `name` and a +collection of `items`. Moreover, a person could be locked, meaning no new items +could be added, this is modeled by the `is_locked` flag. Each `Person` has three +references, `self` (`href` for `URLFor`), `update` and `add_item`. -```python -class Person(HyperModel): - id: str - name: str - items: List[ItemSummary] - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - } - ) -``` - -We may want a new link that corresponds to adding a new Item to the Person. - -```python hl_lines="11" -class Person(HyperModel): - id: str - name: str - items: List[ItemSummary] - - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - "addItem": UrlFor("put_person_items", {"person_id": ""},), - } - ) -``` - -However, we may want functionality where a Person can be "locked", and no new items added. We add a new field `is_locked` to our model. - -```python hl_lines="4" -class Person(HyperModel): - id: str - name: str - is_locked: bool - items: List[ItemSummary] - - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - "addItem": UrlFor("put_person_items", {"person_id": ""},), - } - ) -``` - -Now, if the Person is locked, the `addItem` link is no longer relevant. Querying it will result in a denied error, and so we *may* choose to remove it from the link set. -To do this, we will add a field-dependent condition to the link. - -```python hl_lines="15" -class Person(HyperModel): - id: str - name: str - is_locked: bool - items: List[ItemSummary] - - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - "addItem": UrlFor( +The `condition` argument takes a callable, which will be passed a dict +containing the name-to-value mapping of all fields on the base `HyperModel` +instance. In this example, a lambda function that returns `True` or `False` +depending on the value `is_locked` of `HyperModel` instance. + +!!! note + Conditional links will *always* show up in the auto-generated OpenAPI/Swagger + documentation. These conditions *only* apply to the hypermedia fields + generated at runtime. + + +=== "URLFor" + + ```python linenums="1" hl_lines="13" + class Person(HyperModel): + id_: str + name: str + is_locked: bool + + items: Sequence[Item] + + href: UrlFor = UrlFor("read_person", {"id_": ""}) + update: UrlFor = UrlFor("update_person", {"id_": ""}) + add_item: UrlFor = UrlFor( + "put_person_items", + {"id_": ""}, + condition=lambda values: not values["is_locked"], + ) + ``` + +=== "HAL" + + ```python linenums="1" hl_lines="14" + class Person(HALHyperModel): + id_: str + name: str + is_locked: bool + + items: Sequence[Item] = Field(alias="sc:items") + + links: HALLinks = FrozenDict({ + "self": HALFor("read_person", {"id_": ""}), + "update": HALFor("update_person", {"id_": ""}), + "add_item": HALFor( + "put_person_items", + {"id_": ""}, + condition=lambda values: not values["is_locked"], + ), + }) + ``` + +=== "Siren" + + ```python linenums="1" hl_lines="19" + class Person(SirenHyperModel): + id_: str + name: str + is_locked: bool + + items: Sequence[Item] + + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("read_person", {"id_": ""}, rel=["self"]), + ) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("update_person", {"id_": ""}, name="update"), + SirenActionFor( "put_person_items", - {"person_id": ""}, + {"id_": ""}, + name="add_item", + populate_fields=False, condition=lambda values: not values["is_locked"], ), + ) + ``` + + +### Response for locked Person + +=== "URLFor" + + ```json linenums="1" + { + "id_": "person02", + "name": "Bob", + "is_locked": true, + + "items": [ + { + "id_": "item03", + "name": "Baz", + "href": "/items/item03", + "update": "/items/item03", + "description": "There goes my baz", + "price": 50.2 + }, + { + "id_": "item04", + "name": "Doe", + "href": "/items/item04", + "update": "/items/item04", + "description": "There goes my Doe", + "price": 5.0 + } + ], + + "href": "/people/person02", + "update": "/people/person02" + } + ``` + +=== "HAL" + + ```json linenums="1" + { + "id_": "person02", + "name": "Bob", + "is_locked": true, + + "_embedded": { + "sc:items": [ + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "_links": { + "self": { + "href": "/items/item03" + }, + "update": { + "href": "/items/item03" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "_links": { + "self": { + "href": "/items/item04" + }, + "update": { + "href": "/items/item04" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + "_links": { + "self": { + "href": "/people/person02" + }, + "update": { + "href": "/people/person02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] } - ) -``` + } + ``` -The `condition` argument takes a callable, which will be passed dict containing the name-to-value mapping of all fields on the parent `HyperModel` instance. -In this example, we use a lambda function that returns `True` or `False` depending on the value `is_locked` of the parent `HyperModel` instance. +=== "Siren" -!!! note - Conditional links will *always* show up in the auto-generated OpenAPI/Swagger documentation. - These conditions *only* apply to the hypermedia fields generated at runtime. + ```json linenums="1" + { + "properties": { + "id_": "person02", + "name": "Bob", + "is_locked": true + }, + "entities": [ + { + "properties": { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item03" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item03", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Baz" + }, + { + "name": "description", + "type": "text", + "value": "There goes my baz" + }, + { + "name": "price", + "type": "number", + "value": "50.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item04" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item04", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Doe" + }, + { + "name": "description", + "type": "text", + "value": "There goes my Doe" + }, + { + "name": "price", + "type": "number", + "value": "5.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/people/person02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/people/person02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bob" + }, + { + "name": "is_locked", + "type": "text", + "value": "True" + } + ] + } + ] + } + ``` + +### Response for unlocked Person + +=== "URLFor" + + ```json linenums="1" hl_lines="27" + { + "id_": "person01", + "name": "Alice", + "is_locked": false, + + "items": [ + { + "id_": "item01", + "name": "Foo", + "href": "/items/item01", + "update": "/items/item01", + "description": null, + "price": 50.2 + }, + { + "id_": "item02", + "name": "Bar", + "href": "/items/item02", + "update": "/items/item02", + "description": "The Bar fighters", + "price": 62.0 + } + ], + + "href": "/people/person01", + "update": "/people/person01", + "add_item": "/people/person01/items" + } + ``` + +=== "HAL" + + ```json linenums="1" hl_lines="61-63" + { + "id_": "person01", + "name": "Alice", + "is_locked": false, + + "_embedded": { + "sc:items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2, + + "_links": { + "self": { + "href": "/items/item01" + }, + "update": { + "href": "/items/item01" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "_links": { + "self": { + "href": "/items/item02" + }, + "update": { + "href": "/items/item02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + "_links": { + "self": { + "href": "/people/person01" + }, + "update": { + "href": "/people/person01" + }, + "add_item": { + "href": "/people/person01/items" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ``` + +=== "Siren" + + ```json linenums="1" hl_lines="114-126" + { + "properties": { + "id_": "person01", + "name": "Alice", + "is_locked": false + }, + "entities": [ + { + "properties": { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bar" + }, + { + "name": "description", + "type": "text", + "value": "The Bar fighters" + }, + { + "name": "price", + "type": "number", + "value": "62.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/people/person01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/people/person01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Alice" + }, + { + "name": "is_locked", + "type": "text", + "value": "None" + } + ] + }, + { + "name": "add_item", + "method": "PUT", + "href": "/people/person01/items", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "id_", + "type": "text" + } + ] + } + ] + } + ``` \ No newline at end of file diff --git a/docs/basics.md b/docs/basics.md index 1a63241..f00af33 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -1,131 +1,871 @@ #  Basic Usage -## Import `HyperModel` and optionally `HyperRef` +## Choose Hypermedia Formats -```python -from fastapi import FastAPI +Fastapi-hypermodel has support for following [Hypermedia Maturity Model +Levels](https://8thlight.com/insights/the-hypermedia-maturity-model): -from fastapi_hypermodel import HyperModel, UrlFor, LinkSet -``` +- Level 0: URLFor - Plain Text +- Level 1: [Hypertext Application Language (HAL)](https://datatracker.ietf.org/doc/html/draft-kelly-json-hal) +- Level 2: [Siren](https://github.com/kevinswiber/siren) -`HyperModel` will be your model base-class. +There is a fully working example for each format in the +[examples](https://github.com/jtc42/fastapi-hypermodel/tree/main/examples) +directory. -## Create your basic models +## Initialization -We'll create two models, a brief item summary including ID, name, and a link, and a full model containing additional information. We'll use `ItemSummary` in our item list, and `ItemDetail` for full item information. +=== "URLFor" -```python -class ItemSummary(HyperModel): - id: str - name: str + ```python linenums="1" + from fastapi import FastAPI -class ItemDetail(ItemSummary): - description: Optional[str] = None - price: float + from fastapi_hypermodel import HyperModel, UrlFor + ``` -class Person(HyperModel): - name: str - id: str - items: List[ItemSummary] -``` +=== "HAL" -## Create and attach your app + ```python linenums="1" + from fastapi import FastAPI -We'll now create our FastAPI app, and bind it to our `HyperModel` base class. + from fastapi_hypermodel import ( + FrozenDict, + HALFor, + HALHyperModel, + HALLinks, + HALResponse, + ) + ``` + +=== "Siren" + + ```python linenums="1" + from fastapi import FastAPI + + from fastapi_hypermodel import ( + SirenActionFor, + SirenHyperModel, + SirenLinkFor, + SirenResponse, + ) + ``` + +## Create Basic Models + +Two showcase the hypermedia feature, an `Item` model will be used. Each item +will have an `id_`, a `name`, an optional `description` and a `price`. Moreover +a `ItemCollection` will also be defined to return multiple items. Two hypermedia +references will be used, one called `self` (`href` in the case of `URLFor`) and +an `update`. + +All formats support "links", that is, plain references of HTTP URIs fetchable +via GET. Moreover, Level 2 formats (SIREN) support "actions", which also specify +the HTTP method and the fields needed. + +Even though not part of the standard, fastapi-hypermodel provides support for +"templated URIs". Allowing the client to form the URI with information from the +selected resource. This is useful when returning collections. + +!!! info -```python -from fastapi import FastAPI + The reason to define two classes `ItemSummary` and `Item` is to enable using + a lightweight version (`ItemSummary`) for nested objects -app = FastAPI() -HyperModel.init_app(app) -``` -## Add some API views +=== "URLFor" -We'll create an API view for a list of items, as well as details about an individual item. Note that we pass the item ID with our `{item_id}` URL variable. + ```python linenums="1" + class ItemSummary(HyperModel): + id_: str + name: str -```python -@app.get("/items", response_model=List[ItemSummary]) -def read_items(): - return list(items.values()) + href: UrlFor = UrlFor("read_item", {"id_": ""}) + update: UrlFor = UrlFor("update_item", {"id_": ""}) -@app.get("/items/{item_id}", response_model=ItemDetail) -def read_item(item_id: str): - return items[item_id] + class Item(ItemSummary): + description: Optional[str] = None + price: float -@app.get("/people/{person_id}", response_model=Person) -def read_person(person_id: str): - return people[person_id] + class ItemCollection(HyperModel): + items: Sequence[Item] -@app.get("/people/{person_id}/items", response_model=List[ItemDetail]) -def read_person_items(person_id: str): - return people[person_id]["items"] -``` + href: UrlFor = UrlFor("read_items") + find: UrlFor = UrlFor("read_item", templated=True) + update: UrlFor = UrlFor("update_item", templated=True) + ``` -## Create a model `href` +=== "HAL" -We'll now go back and add an `href` field with a special `UrlFor` value. This `UrlFor` class defines how our href elements will be generated. We'll change our `ItemSummary` class to: + ```python linenums="1" + class ItemSummary(HALHyperModel): + id_: str + name: str -```python -class ItemSummary(HyperModel): - name: str - id: str - href = UrlFor("read_item", {"item_id": ""}) -``` + links: HALLinks = FrozenDict({ + "self": HALFor("read_item", {"id_": ""}), + "update": HALFor("update_item", {"id_": ""}), + }) -The `UrlFor` class takes two arguments: + class Item(ItemSummary): + description: Optional[str] = None + price: float -### `endpoint` + class ItemCollection(HALHyperModel): + items: Sequence[Item] = Field(alias="sc:items") -Name of your FastAPI endpoint function you want to link to. In our example, we want our item summary to link to the corresponding item detail page, which maps to our `read_item` function. + links: HALLinks = FrozenDict({ + "self": HALFor("read_items"), + "find": HALFor("read_item", templated=True), + "update": HALFor("update_item", templated=True), + }) + ``` -Alternatively, rather than providing the endpoint name, you can provide a reference to the endpoint function itself, for example `UrlFor(read_item, {"item_id": ""})`. This can help with larger projects where function names may be refactored. +=== "Siren" -### `values` (optional depending on endpoint) + ```python linenums="1" + class ItemSummary(SirenHyperModel): + id_: str + name: str -Same keyword arguments as FastAPI's url_path_for, except string arguments enclosed in < > will be interpreted as attributes to pull from the object. For example, here we need to pass an `item_id` argument as required by our endpoint function, and we want to populate that with our item object's `id` attribute. + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("read_item", {"id_": ""}, rel=["self"]), + ) -## Create a link set + actions: Sequence[SirenActionFor] = ( + SirenActionFor("update_item", {"id_": ""}, name="update"), + ) -In some cases we want to create a map of relational links. In these cases we can create a `LinkSet` field describing each link and it's relationship to the object. The `LinkSet` class is really just a spicy dictionary that tells the parent `HyperModel` to "render" each link in the link set, and includes some extra OpenAPI schema stuff. -```python -class Person(HyperModel): - id: str - name: str - items: List[ItemSummary] + class Item(ItemSummary): + description: Optional[str] = None + price: float - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), + 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"), + ) + ``` + +## Define your data + +Before defining the app and the endpoints, sample data should be defined. In +this case all formats will use the same data. + +In the case of HAL, to showcase the "cURIes" feature the data will change and +use `sc:items` instead of `items` as the key. At the moment only HAL supports +"cURIes" as part of the standard. + +It is important to note that none of the additional fields added to the response +at runtime are leaked into the data implementation. Therefore, the hypermedia +format and the data model are totally decoupled, granting great flexibility. + +=== "URLFor" + + ```python linenums="1" + 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, + }, + ] + } + ``` + +=== "HAL" + + ```python linenums="1" + from typing import List + + from typing_extensions import NotRequired, TypedDict + + from fastapi_hypermodel import HALForType, UrlType + + + class Item(TypedDict): + id_: str + name: str + price: float + description: NotRequired[str] + + + Items = TypedDict("Items", {"sc:items": List[Item]}) + + items: Items = { + "sc: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, + }, + ] + } + + curies: List[HALForType] = [ + HALForType( + href=UrlType("https://schema.org/{rel}"), + name="sc", + templated=True, + ) + ] + ``` + +=== "Siren" + + ```python linenums="1" + 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, + }, + ] + } + ``` + + +## Create and Attach App + +To make the app "hypermedia-aware", it is enough to initiliaze the format's +HyperModel class with the app object. + +!!! warning + + At the moment this is handled by class variables so it is not thread-safe to + have multiple apps. + +=== "URLFor" + + ```python linenums="1" + app = FastAPI() + HyperModel.init_app(app) + ``` + +=== "HAL" + + ```python linenums="1" + app = FastAPI() + HALHyperModel.init_app(app) + HALHyperModel.register_curies(curies) + ``` + +=== "Siren" + + ```python linenums="1" + app = FastAPI() + SirenHyperModel.init_app(app) + ``` + +## Add API Endpoints + +To expose the data via endpoints, they are defined as usual in any FastAPI app. +The `response_model` and `response_class` need to be defined when appropiate. + +All formats are compatible with path parameters. In the case of Level 2 formats +(SIREN), it can auto detect path and body parameters as well. Query parameters +are not well supported yet. + +=== "URLFor" + + ```python linenums="1" + @app.get("/items", response_model=ItemCollection) + def read_items() -> Any: + return items + + @app.get("/items/{id_}", response_model=Item) + def read_item(id_: str) -> Any: + return next(item for item in items["items"] if item["id_"] == id_) + ``` + +=== "HAL" + + ```python linenums="1" + @app.get("/items", response_model=ItemCollection, response_class=HALResponse) + def read_items() -> Any: + return items + + @app.get("/items/{id_}", response_model=Item, response_class=HALResponse) + def read_item(id_: str) -> Any: + return next(item for item in items["sc:items"] if item["id_"] == id_) + ``` + +=== "Siren" + + ```python linenums="1" + @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_) + ``` + + +## Responses + +The response generated by each format varies based on their specification. Using +hypermedia usually results in heavier responses because of all the additional +information provided. + +!!! warning + + At the moment no optimizations are done under the hood to minimize the size + of the response. For instance, one such optimization could be removing + cURIes in HAL if they are already defined in a parent. + + Beware of highly nested objects. + + +### Fetching /items/item01 + + +=== "URLFor" + + ```json linenums="1" + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "href": "/items/item01", + "update": "/items/item01" + } + ``` + +=== "HAL" + + ```json linenums="1" + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "_links": { + "self": {"href": "/items/item01"}, + "update": {"href": "/items/item01"}, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ], + }, + } + ``` + +=== "Siren" + + ```json linenums="1" + { + "properties": { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ] + } + ``` + +### Fetching /items + + +=== "URLFor" + + ```json linenums="1" + { + "items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 50.2, + + "href": "/items/item01", + "update": "/items/item01" + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "href": "/items/item02", + "update": "/items/item02" + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "href": "/items/item03", + "update": "/items/item03" + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "href": "/items/item04", + "update": "/items/item04" + } + ], + + "href": "/items", + "find": "/items/{id_}", + "update": "/items/{id_}" + } + ``` + +=== "HAL" + + ```json linenums="1" + { + "_embedded": { + "sc:items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2, + + "_links": { + "self": { + "href": "/items/item01" + }, + "update": { + "href": "/items/item01" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "_links": { + "self": { + "href": "/items/item02" + }, + "update": { + "href": "/items/item02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "_links": { + "self": { + "href": "/items/item03" + }, + "update": { + "href": "/items/item03" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "_links": { + "self": { + "href": "/items/item04" + }, + "update": { + "href": "/items/item04" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + + "_links": { + "self": { + "href": "/items" + }, + "find": { + "href": "/items/{id_}", + "templated": true + }, + "update": { + "href": "/items/{id_}", + "templated": true + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] } - ) -``` - -## Putting it all together - -For this example, we can make a dictionary containing some fake data, and add extra models, even nesting models if we want. A complete example based on this documentation can be found [here](https://github.com/jtc42/fastapi-hypermodel/blob/main/examples/simple_app.py). - -If we run the example application and go to our `/items` URL, we should get a response like: - -```json -[ - { - "name": "Foo", - "id": "item01", - "href": "/items/item01" - }, - { - "name": "Bar", - "id": "item02", - "href": "/items/item02" - }, - { - "name": "Baz", - "id": "item03", - "href": "/items/item03" - } -] -``` + } + ``` + +=== "Siren" + + ```json linenums="1" + { + "entities": [ + { + "properties": { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bar" + }, + { + "name": "description", + "type": "text", + "value": "The Bar fighters" + }, + { + "name": "price", + "type": "number", + "value": "62.0" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item03" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item03", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Baz" + }, + { + "name": "description", + "type": "text", + "value": "There goes my baz" + }, + { + "name": "price", + "type": "number", + "value": "50.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item04" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item04", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Doe" + }, + { + "name": "description", + "type": "text", + "value": "There goes my Doe" + }, + { + "name": "price", + "type": "number", + "value": "5.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/items" + } + ], + "actions": [ + { + "name": "find", + "method": "GET", + "href": "/items/{id_}", + "templated": true + }, + { + "name": "update", + "method": "PUT", + "href": "/items/{id_}", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "None" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "None" + } + ], + "templated": true + } + ] + } + ``` diff --git a/docs/extending.md b/docs/extending.md index 976c779..1134a98 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -1,141 +1,27 @@ -# Extending FastAPI-HyperModel +# Extending + +It is possible to define new custom hypermedia formats. There are three aspects +to consider: + +- Skeleton Type: this is a class to for the underlying representation of the + fields in the response, it could be a single class (`URLForType`, + `HALForType`) or mutiple classes (`SirenActionType`, `SirenEmbeddedType`, + `SirenFieldType`, `SirenLinkType`). This skeleton type has the `Type` suffix + as a convention but it is not required. +- Builder Type: this is a helper class that inspects the app and gathers all the + necessary information to build the skeleton type. It it recommended to make it + a subclass of the skeleton type and also inherit from + `AbstractHyperField[SkeletonType]`. Some examples are `URLFor` and `HALFor` +- Hypermodel Type: This is an optional class to include response-wide logic. + `URLFor` has no Hypermodel type and leverages the base one, whereas HAL + implements `HALHyperModel` and uses it to handle the cURIes logic. Siren uses + the `SirenHyperModel` to move the different fields into `properties` and + `entitites`. This is usually required for Level 1+ Hypermdia formats +- Response Type: This is an optional class to define custom response behaviour. + It could be lightweight like `SirenResponse` where only a jsonchema is + checked, or it could be more complex like `HALReponse`. If no custom + content-type is needed, it could be omitted, as it happens with `URLFor`. + +All the formats (URLFor, HAL and Siren) are implemented in the same way a custom +type could be implemented. -The `HyperModel` class works by adding a root validator that iterates through each field on the instance being validated, and checks if it is an instance of `AbstractHyperField`. - -If an instance of `AbstractHyperField` is found, its `__build_hypermedia__` method is called, and the returned value will be substituted into the validated `HyperModel` instance. - -## Creating a new link class - -In most respects, a custom link class should be treated as a [custom Pydantic data type](https://pydantic-docs.helpmanual.io/usage/types/#custom-data-types). It can inherit from any Pydantic-compatible type, include any custom validation required, but *must* also inherit from `AbstractHyperField` and include a `__build_hypermedia__` method. - -This method must accept two arguments, an *optional* `fastapi.FastAPI` instance (optional only because prior to `HyperModel.init_app` being called, it will evaluate to `None`), and a dict containing the name-to-value mapping of all fields on the parent `HyperModel` instance. - -As an example, we'll re-implement the basic `URLFor` class from scratch. - -### Create a basic custom Pydantic class - -First we'll create a subclass of `UrlType` which accepts an endpoint string, and a dictionary of URL parameter to field mappings (see [Basic Usage](basics.md)). - -```python -from fastapi_hypermodel.hypermodel import UrlType - -class UrlFor(UrlType): - def __init__(self, endpoint: str, param_values: Optional[Dict[str, str]] = None): - self.endpoint: str = endpoint - self.param_values: Dict[str, str] = param_values or {} - super().__init__() -``` - -Next, we'll add out basic Pydantic validation functionality: - -```python hl_lines="10-12 14-16 18-30" -from fastapi_hypermodel.hypermodel import UrlType -from starlette.datastructures import URLPath - -class UrlFor(UrlType): - def __init__(self, endpoint: str, param_values: Optional[Dict[str, str]] = None): - self.endpoint: str = endpoint - self.param_values: Dict[str, str] = param_values or {} - super().__init__() - - @no_type_check - def __new__(cls, *_): - return str.__new__(cls) - - @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def validate(cls, value: Any) -> "UrlFor": - """ - Validate UrlFor object against itself. - The UrlFor field type will only accept UrlFor instances. - """ - # Return original object if it's already a UrlFor instance - if value.__class__ == URLPath: - return value - # Otherwise raise an exception - raise ValueError( - f"UrlFor field should resolve to a starlette.datastructures.URLPath instance. Instead got {value.__class__}" - ) -``` - -At this point, our custom type will behave as a normal Pydantic type, but won't do any hypermedia substitutions. -For this, we must add our "magic" `__build_hypermedia__` method. - -```python hl_lines="32-38" -from fastapi_hypermodel.hypermodel import UrlType, resolve_param_values -from starlette.datastructures import URLPath - -class UrlFor(UrlType, AbstractHyperField): - def __init__(self, endpoint: str, param_values: Optional[Dict[str, str]] = None): - self.endpoint: str = endpoint - self.param_values: Dict[str, str] = param_values or {} - super().__init__() - - @no_type_check - def __new__(cls, *_): - return str.__new__(cls) - - @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def validate(cls, value: Any) -> "UrlFor": - """ - Validate UrlFor object against itself. - The UrlFor field type will only accept UrlFor instances. - """ - # Return original object if it's already a UrlFor instance - if value.__class__ == URLPath: - return value - # Otherwise raise an exception - raise ValueError( - f"UrlFor field should resolve to a starlette.datastructures.URLPath instance. Instead got {value.__class__}" - ) - - def __build_hypermedia__( - self, app: Optional[FastAPI], values: Dict[str, Any] - ) -> Optional[str]: - if app is None: - return None - resolved_params = resolve_param_values(self.param_values, values) - return app.url_path_for(self.endpoint, **resolved_params) -``` - -Here we see that, as expected, our method accepts a `FastAPI` instance, and our dict of parent field values. We pass these field values, along with the URL parameter to field mappings, to a `resolve_param_values` function. This function takes our URL parameter to field mappings, and substitutes in the *actual* values from the parent. - -We can then pass this new dictionary of URL parameters to the FastAPI `url_path_for` function to generate a valid URL for this specific endpoint with this specific set of values. - -##  Creating a new link set class - -In FastAPI-HyperModel, a link set is essentially just another subclass of `AbstractHyperField`. The dictionary of returned links is generated by recursively calling `__build_hypermedia__` for each item, in the `__build_hypermedia__` method of the link set itself. - -This is most easily explained by analysing the source code for the built-in `LinkSet` class. - -```python -_LinkSetType = Dict[str, AbstractHyperField] - -class LinkSet(_LinkSetType, AbstractHyperField): # pylint: disable=too-many-ancestors - @classmethod - def __get_validators__(cls): - yield dict_validator - - @classmethod - def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: - field_schema.update({"additionalProperties": _uri_schema}) - - def __build_hypermedia__( - self, app: Optional[FastAPI], values: Dict[str, Any] - ) -> Dict[str, str]: - return {k: u.__build_hypermedia__(app, values) for k, u in self.items()} # type: ignore # pylint: disable=no-member -``` - -This class behaves link a standard dictionary, with `str` keys and any other `AbstractHyperField` as values. This allows, for example, nesting `LinkSet` instances for rich, deep hypermedia, as well as allowing different hypermedia types (such as `HALFor` links). - -The `__get_validators__` and `__modify_schema__` handle standard Pydantic functionality. -Within `__build_hypermedia__`, we simply return a dictionary of key-value pairs, where each value is generated by calling the item's `__build_hypermedia__` method. - -By overriding this method, it's possible to create entirely new formats of link sets as required. diff --git a/docs/index.md b/docs/index.md index 74268f4..afe39b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,92 +13,542 @@ --- -**Documentation**: https://jtc42.github.io/fastapi-hypermodel/ +**Documentation**: https://jtc42.github.io/fastapi-hypermodel/ -**Source Code**: https://github.com/jtc42/fastapi-hypermodel +**Source Code**: https://github.com/jtc42/fastapi-hypermodel --- -FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying hypermedia-driven API development. - -This module adds a new Pydantic model base-class, supporting dynamic `href` generation based on object data. - -
ModelFormat Response
-```python -class ItemSummary(HyperModel): - name: str - id: str - href = UrlFor( - "read_item", {"item_id": ""} - ) -``` +No Hypermdia -```json +```json linenums="1" { - "name": "Foo", - "id": "item01", - "href": "/items/item01" + "id_": "item01", + "name": "Foo", + "price": 10.2, } ```
-```python -class ItemSummary(HyperModel): - name: str - id: str - link = HALFor( - "read_item", {"item_id": ""}, - description="Read an item" - ) +Level 0 Hypermedia (URLFor) + + + +```json linenums="1" +{ + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "href": "/items/item01", + "update": "/items/item01" +} ```
-```json +Level 1 Hypermedia (HAL) + + + +```json linenums="1" { - "name": "Foo", - "id": "item01", - "link": { - "href": "/items/item01", - "method": "GET", - "description": "Read an item" - } + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "_links": { + "self": {"href": "/items/item01"}, + "update": {"href": "/items/item01"}, + }, } ``` +
+ +Level 2 Hypermedia (Siren) + + + +```json linenums="1" +{ + "properties": { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ] +} +```
- - - - - - - - - - - - - - - -
ModelResponse
- -```python -class ItemSummary(HyperModel): - name: str - id: str - href = UrlFor( - "read_item", {"item_id": ""} - ) -``` - - - -```json -{ - "name": "Foo", - "id": "item01", - "href": "/items/item01" -} -``` - -
- -```python -class ItemSummary(HyperModel): - name: str - id: str - link = HALFor( - "read_item", {"item_id": ""}, - description="Read an item" - ) -``` - - - -```json -{ - "name": "Foo", - "id": "item01", - "link": { - "href": "/items/item01", - "method": "GET", - "description": "Read an item" - } -} -``` - -
+FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying +hypermedia-driven API development. -## Installation +Hypermedia consist of enriching API responses by providing links to other URIs +within the services to fetch related resources or perform certain actions. There +are several levels according to the [Hypermedia Maturity Model +Levels](https://8thlight.com/insights/the-hypermedia-maturity-model). Using +Hypermedia makes APIs reach Level 3 of the [Richardson Maturity Model +(RMM)](https://en.wikipedia.org/wiki/Richardson_Maturity_Model), which involves +leveraging [Hypertext As The Engine Of Application State +(HATEOAS)](https://en.wikipedia.org/wiki/HATEOAS), that is, Hypermedia. -`pip install fastapi-hypermodel` +Below are two examples of how implementing hypermedia changes the responses in +different formats. The first example is for singular elements whereas the second +is for collections. -## Limitations -Currently, query parameters will not resolve correctly. When generating a resource URL, ensure all parameters passed are path parameters, not query parameters. +## Single Item Example + +=== "No Hypermedia" + + ```json linenums="1" + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + } + ``` + +=== "Level 0 (URLFor)" + + ```json linenums="1" + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "href": "/items/item01", + "update": "/items/item01" + } + ``` + +=== "Level 1 (HAL)" + + ```json linenums="1" + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "_links": { + "self": {"href": "/items/item01"}, + "update": {"href": "/items/item01"}, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ], + }, + } + ``` + +=== "Level 2 (Siren)" + + ```json linenums="1" + { + "properties": { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ] + } + ``` + +## Collection of Items Example + + +=== "No Hypermedia" + + ```json linenums="1" + { + "items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 50.2, + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + } + ], + } + ``` + +=== "Level 0 (URLFor)" -This is an upstream issue, being tracked [here](https://github.com/encode/starlette/issues/560). + ```json linenums="1" + { + "items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 50.2, -## Attributions + "href": "/items/item01", + "update": "/items/item01" + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "href": "/items/item02", + "update": "/items/item02" + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "href": "/items/item03", + "update": "/items/item03" + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "href": "/items/item04", + "update": "/items/item04" + } + ], + + "href": "/items", + "find": "/items/{id_}", + "update": "/items/{id_}" + } + ``` + +=== "Level 1 (HAL)" + + ```json linenums="1" + { + "_embedded": { + "sc:items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2, + + "_links": { + "self": { + "href": "/items/item01" + }, + "update": { + "href": "/items/item01" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "_links": { + "self": { + "href": "/items/item02" + }, + "update": { + "href": "/items/item02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "_links": { + "self": { + "href": "/items/item03" + }, + "update": { + "href": "/items/item03" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "_links": { + "self": { + "href": "/items/item04" + }, + "update": { + "href": "/items/item04" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + + "_links": { + "self": { + "href": "/items" + }, + "find": { + "href": "/items/{id_}", + "templated": true + }, + "update": { + "href": "/items/{id_}", + "templated": true + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ``` + +=== "Level 2 (Siren)" + + ```json linenums="1" + { + "entities": [ + { + "properties": { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bar" + }, + { + "name": "description", + "type": "text", + "value": "The Bar fighters" + }, + { + "name": "price", + "type": "number", + "value": "62.0" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item03" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item03", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Baz" + }, + { + "name": "description", + "type": "text", + "value": "There goes my baz" + }, + { + "name": "price", + "type": "number", + "value": "50.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item04" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item04", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Doe" + }, + { + "name": "description", + "type": "text", + "value": "There goes my Doe" + }, + { + "name": "price", + "type": "number", + "value": "5.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/items" + } + ], + "actions": [ + { + "name": "find", + "method": "GET", + "href": "/items/{id_}", + "templated": true + }, + { + "name": "update", + "method": "PUT", + "href": "/items/{id_}", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "None" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "None" + } + ], + "templated": true + } + ] + } + ``` + +## Installation + +`pip install fastapi-hypermodel` + +## Limitations -Some functionality is based on [Flask-Marshmallow](https://github.com/marshmallow-code/flask-marshmallow/blob/dev/src/flask_marshmallow/fields.py) `URLFor` class. +Currently, query parameters will not resolve correctly. When generating a +resource URL, ensure all parameters passed are path parameters, not query +parameters. +This is an upstream issue, being tracked +[here](https://github.com/encode/starlette/issues/560). diff --git a/docs/js/linked_tabs.js b/docs/js/linked_tabs.js new file mode 100644 index 0000000..1c703d9 --- /dev/null +++ b/docs/js/linked_tabs.js @@ -0,0 +1,21 @@ +const syncTabsByName = (tab) => () => { + const selected = document.querySelector(`label[for=${tab.id}]`) + const previousPosition = selected.getBoundingClientRect().top + const labelSelector = '.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label' + + Array.from(document.querySelectorAll(labelSelector)) + .filter(label => label.innerText === selected.innerText) + .forEach(label => document.querySelector(`input[id=${label.getAttribute('for')}]`).click()) + + // Preserve scroll position + const currentPosition = selected.getBoundingClientRect().top + const delta = currentPosition - previousPosition + window.scrollBy(0, delta) +} + +const tabSync = () => { + document.querySelectorAll(".tabbed-set > input") + .forEach(tab => tab.addEventListener("click", syncTabsByName(tab))) +} + +tabSync(); \ No newline at end of file diff --git a/examples/hal/app.py b/examples/hal/app.py index 3592d1b..5328db3 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -8,24 +8,22 @@ from examples.hal.data import Person as PersonData from examples.hal.data import curies, items, people from fastapi_hypermodel import ( + FrozenDict, HALFor, - HalHyperModel, + HALHyperModel, + HALLinks, HALResponse, - LinkSet, ) -class ItemSummary(HalHyperModel): - name: str +class ItemSummary(HALHyperModel): id_: str + name: str - links: LinkSet = Field( - default=LinkSet({ - "self": HALFor("read_item", {"id_": ""}), - "update": HALFor("update_item", {"id_": ""}), - }), - alias="_links", - ) + links: HALLinks = FrozenDict({ + "self": HALFor("read_item", {"id_": ""}), + "update": HALFor("update_item", {"id_": ""}), + }) class Item(ItemSummary): @@ -43,58 +41,45 @@ class ItemCreate(ItemUpdate): id_: str -class ItemCollection(HalHyperModel): +class ItemCollection(HALHyperModel): items: Sequence[Item] = Field(alias="sc:items") - links: LinkSet = Field( - default=LinkSet({ - "self": HALFor("read_items"), - "find": HALFor("read_item", templated=True), - "update": HALFor("update_item", templated=True), - }), - alias="_links", - ) + links: HALLinks = FrozenDict({ + "self": HALFor("read_items"), + "find": HALFor("read_item", templated=True), + "update": HALFor("update_item", templated=True), + }) -class Person(HalHyperModel): - name: str +class Person(HALHyperModel): id_: str + name: str is_locked: bool items: Sequence[Item] = Field(alias="sc:items") - links: LinkSet = Field( - default=LinkSet({ - "self": HALFor("read_person", {"id_": ""}), - "update": HALFor("update_person", {"id_": ""}), - "add_item": HALFor( - "put_person_items", - {"id_": ""}, - description="Add an item to this person and the items list", - condition=lambda values: not values["is_locked"], - ), - }), - alias="_links", - ) + links: HALLinks = FrozenDict({ + "self": HALFor("read_person", {"id_": ""}), + "update": HALFor("update_person", {"id_": ""}), + "add_item": HALFor( + "put_person_items", + {"id_": ""}, + condition=lambda values: not values["is_locked"], + ), + }) -class PersonCollection(HalHyperModel): +class PersonCollection(HALHyperModel): people: Sequence[Person] - links: LinkSet = Field( - default=LinkSet({ - "self": HALFor("read_people"), - "find": HALFor( - "read_person", description="Get a particular person", templated=True - ), - "update": HALFor( - "update_person", - description="Update a particular person", - templated=True, - ), - }), - alias="_links", - ) + links: HALLinks = FrozenDict({ + "self": HALFor("read_people"), + "find": HALFor("read_person", templated=True), + "update": HALFor( + "update_person", + templated=True, + ), + }) class PersonUpdate(BaseModel): @@ -103,21 +88,36 @@ class PersonUpdate(BaseModel): app = FastAPI() -HalHyperModel.init_app(app) -HalHyperModel.register_curies(curies) +HALHyperModel.init_app(app) +HALHyperModel.register_curies(curies) -@app.get("/items", response_model=ItemCollection, response_class=HALResponse) +@app.get( + "/items", + response_model=ItemCollection, + response_model_exclude_unset=True, + response_class=HALResponse, +) def read_items() -> Any: return items -@app.get("/items/{id_}", response_model=Item, response_class=HALResponse) +@app.get( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + response_class=HALResponse, +) def read_item(id_: str) -> Any: return next(item for item in items["sc:items"] if item["id_"] == id_) -@app.put("/items/{id_}", response_model=Item, response_class=HALResponse) +@app.put( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + response_class=HALResponse, +) def update_item(id_: str, item: ItemUpdate) -> Any: base_item = next(item for item in items["sc:items"] if item["id_"] == id_) update_item = cast(ItemData, item.model_dump(exclude_none=True)) @@ -125,17 +125,32 @@ def update_item(id_: str, item: ItemUpdate) -> Any: return base_item -@app.get("/people", response_model=PersonCollection, response_class=HALResponse) +@app.get( + "/people", + response_model=PersonCollection, + response_model_exclude_unset=True, + response_class=HALResponse, +) def read_people() -> Any: return people -@app.get("/people/{id_}", response_model=Person, response_class=HALResponse) +@app.get( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + response_class=HALResponse, +) 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=HALResponse) +@app.put( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + response_class=HALResponse, +) 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)) @@ -143,7 +158,12 @@ def update_person(id_: str, person: PersonUpdate) -> Any: return base_person -@app.put("/people/{id_}/items", response_model=Person, response_class=HALResponse) +@app.put( + "/people/{id_}/items", + response_model=Person, + response_model_exclude_unset=True, + response_class=HALResponse, +) def put_person_items(id_: str, item: ItemCreate) -> Any: complete_item = next( (item_ for item_ in items["sc:items"] if item_["id_"] == item.id_), diff --git a/examples/siren/app.py b/examples/siren/app.py index f55d12e..5091799 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -15,8 +15,8 @@ class ItemSummary(SirenHyperModel): - name: str id_: str + name: str links: Sequence[SirenLinkFor] = ( SirenLinkFor("read_item", {"id_": ""}, rel=["self"]), @@ -54,8 +54,8 @@ class ItemCollection(SirenHyperModel): class Person(SirenHyperModel): - name: str id_: str + name: str is_locked: bool items: Sequence[Item] @@ -69,10 +69,9 @@ class Person(SirenHyperModel): SirenActionFor( "put_person_items", {"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, + condition=lambda values: not values["is_locked"], ), ) @@ -85,13 +84,11 @@ class PersonCollection(SirenHyperModel): 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", ), @@ -107,17 +104,32 @@ class PersonUpdate(BaseModel): SirenHyperModel.init_app(app) -@app.get("/items", response_model=ItemCollection, response_class=SirenResponse) +@app.get( + "/items", + response_model=ItemCollection, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def read_items() -> Any: return items -@app.get("/items/{id_}", response_model=Item, response_class=SirenResponse) +@app.get( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + 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) +@app.put( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + 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)) @@ -125,17 +137,32 @@ def update_item(id_: str, item: ItemUpdate) -> Any: return base_item -@app.get("/people", response_model=PersonCollection, response_class=SirenResponse) +@app.get( + "/people", + response_model=PersonCollection, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def read_people() -> Any: return people -@app.get("/people/{id_}", response_model=Person, response_class=SirenResponse) +@app.get( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + 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) +@app.put( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + 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)) @@ -143,7 +170,12 @@ def update_person(id_: str, person: PersonUpdate) -> Any: return base_person -@app.put("/people/{id_}/items", response_model=Person, response_class=SirenResponse) +@app.put( + "/people/{id_}/items", + response_model=Person, + response_model_exclude_unset=True, + 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_), diff --git a/examples/url_for/app.py b/examples/url_for/app.py index a7b0ec8..991b9ed 100644 --- a/examples/url_for/app.py +++ b/examples/url_for/app.py @@ -10,8 +10,8 @@ class ItemSummary(HyperModel): - name: str id_: str + name: str href: UrlFor = UrlFor("read_item", {"id_": ""}) update: UrlFor = UrlFor("update_item", {"id_": ""}) @@ -36,14 +36,15 @@ class ItemCollection(HyperModel): items: Sequence[Item] href: UrlFor = UrlFor("read_items") - find: UrlFor = UrlFor("read_item", template=True) - update: UrlFor = UrlFor("update_item", template=True) + find: UrlFor = UrlFor("read_item", templated=True) + update: UrlFor = UrlFor("update_item", templated=True) class Person(HyperModel): - name: str id_: str + name: str is_locked: bool + items: Sequence[Item] href: UrlFor = UrlFor("read_person", {"id_": ""}) @@ -64,8 +65,8 @@ class PeopleCollection(HyperModel): people: Sequence[Person] href: UrlFor = UrlFor("read_people") - find: UrlFor = UrlFor("read_person", template=True) - update: UrlFor = UrlFor("update_person", template=True) + find: UrlFor = UrlFor("read_person", templated=True) + update: UrlFor = UrlFor("update_person", templated=True) app = FastAPI() diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 8cdc3d4..1d4fb8c 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -1,10 +1,23 @@ -from .hal import HALFor, HALForType, HalHyperModel, HALResponse -from .hypermodel import ( +from .base import ( + URL_TYPE_SCHEMA, AbstractHyperField, HasName, HyperModel, + InvalidAttribute, + UrlType, + extract_value_by_name, + get_route_from_app, + resolve_param_values, +) +from .hal import ( + FrozenDict, + HALFor, + HALForType, + HALHyperModel, + HALLinks, + HALResponse, + get_hal_link, ) -from .linkset import LinkSet, LinkSetType from .siren import ( SirenActionFor, SirenActionType, @@ -18,27 +31,19 @@ get_siren_link, ) from .url_for import UrlFor -from .url_type import URL_TYPE_SCHEMA, UrlType -from .utils import ( - InvalidAttribute, - extract_value_by_name, - get_hal_link_href, - get_route_from_app, - resolve_param_values, -) __all__ = [ "URL_TYPE_SCHEMA", "AbstractHyperField", + "FrozenDict", "HALFor", "HALForType", + "HALHyperModel", + "HALLinks", "HALResponse", - "HalHyperModel", "HasName", "HyperModel", "InvalidAttribute", - "LinkSet", - "LinkSetType", "SirenActionFor", "SirenActionType", "SirenEmbeddedType", @@ -50,7 +55,7 @@ "UrlFor", "UrlType", "extract_value_by_name", - "get_hal_link_href", + "get_hal_link", "get_route_from_app", "get_siren_action", "get_siren_link", diff --git a/fastapi_hypermodel/base/__init__.py b/fastapi_hypermodel/base/__init__.py new file mode 100644 index 0000000..115777e --- /dev/null +++ b/fastapi_hypermodel/base/__init__.py @@ -0,0 +1,20 @@ +from .hypermodel import AbstractHyperField, HasName, HyperModel +from .url_type import URL_TYPE_SCHEMA, UrlType +from .utils import ( + InvalidAttribute, + extract_value_by_name, + get_route_from_app, + resolve_param_values, +) + +__all__ = [ + "URL_TYPE_SCHEMA", + "AbstractHyperField", + "HasName", + "HyperModel", + "InvalidAttribute", + "UrlType", + "extract_value_by_name", + "get_route_from_app", + "resolve_param_values", +] diff --git a/fastapi_hypermodel/hypermodel.py b/fastapi_hypermodel/base/hypermodel.py similarity index 64% rename from fastapi_hypermodel/hypermodel.py rename to fastapi_hypermodel/base/hypermodel.py index ae04c28..200b6c3 100644 --- a/fastapi_hypermodel/hypermodel.py +++ b/fastapi_hypermodel/base/hypermodel.py @@ -1,8 +1,8 @@ -import json from abc import ABC, abstractmethod from string import Formatter from typing import ( Any, + Callable, ClassVar, Dict, Generic, @@ -10,22 +10,24 @@ Mapping, Optional, Protocol, + Sequence, Type, TypeVar, + Union, cast, runtime_checkable, ) -import jsonref -import pydantic_core from pydantic import ( BaseModel, model_validator, ) from starlette.applications import Starlette +from starlette.routing import Route from typing_extensions import Self -from fastapi_hypermodel.utils import extract_value_by_name +from fastapi_hypermodel.base.url_type import UrlType +from fastapi_hypermodel.base.utils import extract_value_by_name, resolve_param_values @runtime_checkable @@ -37,38 +39,31 @@ class HasName(Protocol): class AbstractHyperField(ABC, Generic[T]): - @classmethod - def __get_pydantic_core_schema__( - cls: Type[Self], *_: Any - ) -> pydantic_core.CoreSchema: - return pydantic_core.core_schema.any_schema() - - @classmethod - def __schema_subclasses__( - cls: Type[Self], caller_class: Optional[Type[Self]] = None - ) -> List[Dict[str, Any]]: - subclasses_schemas: List[Dict[str, Any]] = [] - for subclass in cls.__subclasses__(): - if caller_class and issubclass(subclass, caller_class): - continue - - if not issubclass(subclass, BaseModel): - continue - - schema = subclass.model_json_schema() - schema_dict = json.dumps(schema) - deref_schema: Dict[str, Any] = jsonref.loads(schema_dict) - - subclasses_schemas.append(deref_schema) - - return subclasses_schemas - @abstractmethod def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any] ) -> Optional[T]: raise NotImplementedError + @staticmethod + def _get_uri_path( + *, + templated: Optional[bool], + app: Starlette, + values: Mapping[str, Any], + route: Union[Route, str], + params: Mapping[str, str], + endpoint: str, + ) -> UrlType: + if templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(params, values) + return UrlType(app.url_path_for(endpoint, **params)) + + +R = TypeVar("R", bound=Callable[..., Any]) + class HyperModel(BaseModel): _app: ClassVar[Optional[Starlette]] = None @@ -119,3 +114,20 @@ def _parse_uri(values: Any, uri_template: str) -> str: def parse_uri(self: Self, uri_template: str) -> str: return self._parse_uri(self, uri_template) + + def _validate_factory( + self: Self, elements: Union[R, Sequence[R]], properties: Mapping[str, str] + ) -> List[R]: + if not isinstance(elements, Sequence): + elements = [elements] + + validated_elements: List[R] = [] + for element_factory in elements: + if not callable(element_factory): + validated_elements.append(element_factory) + continue + element = element_factory(self._app, properties) + if not element: + continue + validated_elements.append(element) + return validated_elements diff --git a/fastapi_hypermodel/url_type.py b/fastapi_hypermodel/base/url_type.py similarity index 100% rename from fastapi_hypermodel/url_type.py rename to fastapi_hypermodel/base/url_type.py diff --git a/fastapi_hypermodel/utils.py b/fastapi_hypermodel/base/utils.py similarity index 95% rename from fastapi_hypermodel/utils.py rename to fastapi_hypermodel/base/utils.py index d15ba7f..baabb31 100644 --- a/fastapi_hypermodel/utils.py +++ b/fastapi_hypermodel/base/utils.py @@ -108,10 +108,6 @@ def extract_value_by_name( return _clean_attribute_value(attribute_value) -def get_hal_link_href(response: Any, link_name: str) -> Union[str, Any]: - return response.get("_links", {}).get(link_name, {}).get("href", "") - - def get_route_from_app(app: Starlette, endpoint_function: str) -> Route: for route in app.routes: if isinstance(route, Route) and route.name == endpoint_function: diff --git a/fastapi_hypermodel/hal.py b/fastapi_hypermodel/hal.py deleted file mode 100644 index d847d83..0000000 --- a/fastapi_hypermodel/hal.py +++ /dev/null @@ -1,312 +0,0 @@ -from collections import defaultdict -from itertools import chain -from typing import ( - Any, - Callable, - ClassVar, - Dict, - List, - Mapping, - Optional, - Sequence, - Type, - Union, -) - -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator -from starlette.applications import Starlette -from starlette.responses import JSONResponse -from starlette.routing import Route -from typing_extensions import Self - -from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel -from fastapi_hypermodel.linkset import LinkSetType -from fastapi_hypermodel.url_type import UrlType -from fastapi_hypermodel.utils import get_route_from_app, resolve_param_values - - -class HALForType(BaseModel): - href: UrlType = Field(default=UrlType()) - templated: Optional[bool] = None - title: Optional[str] = None - name: Optional[str] = None - type: Optional[str] = None - hreflang: Optional[str] = None - profile: Optional[str] = None - deprecation: Optional[str] = None - - def __bool__(self: Self) -> bool: - return bool(self.href) - - -class HALFor(HALForType, AbstractHyperField[HALForType]): - # pylint: disable=too-many-instance-attributes - _endpoint: str = PrivateAttr() - _param_values: Mapping[str, 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 - _title: Optional[str] = PrivateAttr() - _name: Optional[str] = PrivateAttr() - _type: Optional[str] = PrivateAttr() - _hreflang: Optional[str] = PrivateAttr() - _profile: Optional[str] = PrivateAttr() - _deprecation: Optional[str] = PrivateAttr() - - def __init__( - self: Self, - endpoint: Union[HasName, str], - param_values: Optional[Mapping[str, str]] = None, - description: Optional[str] = None, - condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - templated: Optional[bool] = None, - title: Optional[str] = None, - name: Optional[str] = None, - type_: Optional[str] = None, - hreflang: Optional[str] = None, - profile: Optional[str] = None, - deprecation: Optional[str] = None, - ) -> None: - super().__init__() - self._endpoint = ( - endpoint.__name__ if isinstance(endpoint, HasName) else endpoint - ) - self._param_values = param_values or {} - self._description = description - self._condition = condition - self._templated = templated - self._title = title - self._name = name - self._type = type_ - self._hreflang = hreflang - self._profile = profile - self._deprecation = deprecation - - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - - def __call__( - self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> HALForType: - if app is None: - return HALForType() - - if self._condition and not self._condition(values): - return HALForType() - - route = get_route_from_app(app, self._endpoint) - - uri_path = self._get_uri_path(app, values, route) - - return HALForType( - href=uri_path, - templated=self._templated, - title=self._title, - name=self._name, - type=self._type, - hreflang=self._hreflang, - profile=self._profile, - deprecation=self._deprecation, - ) - - -class HalHyperModel(HyperModel): - curies_: ClassVar[Optional[Sequence[HALForType]]] = None - embedded: Mapping[str, Union[Self, Sequence[Self]]] = Field( - default=None, alias="_embedded" - ) - - # This config is needed to use the Self in Embedded - model_config = ConfigDict(arbitrary_types_allowed=True) - - @classmethod - def register_curies(cls: Type[Self], curies: Sequence[HALForType]) -> None: - cls.curies_ = curies - - @classmethod - def curies(cls: Type[Self]) -> Sequence[HALForType]: - return cls.curies_ or [] - - @model_validator(mode="after") - def add_curies_to_links(self: Self) -> Self: - for _, value in self: - if not isinstance(value, LinkSetType): - continue - value.mapping["curies"] = HalHyperModel.curies() # type: ignore - - return self - - @model_validator(mode="after") - def add_hypermodels_to_embedded(self: Self) -> Self: - embedded: Dict[str, Union[Self, Sequence[Self]]] = {} - for name, field in self: - value: Sequence[Union[Any, Self]] = ( - field if isinstance(field, Sequence) else [field] - ) - - if not all(isinstance(element, HalHyperModel) for element in value): - continue - - key = self.model_fields[name].alias or name - embedded[key] = value - delattr(self, name) - - self.embedded = embedded - - if not self.embedded: - delattr(self, "embedded") - - return self - - -EmbeddedRawType = Union[Mapping[str, Union[Sequence[Any], Any]], Any] -LinksRawType = Union[Mapping[str, Union[Any, Sequence[Any]]], Any] - - -class HALResponse(JSONResponse): - media_type = "application/hal+json" - - @staticmethod - def _validate_embedded( - content: Any, - ) -> Dict[str, List[Any]]: - embedded: EmbeddedRawType = content.get("_embedded") - - if embedded is None: - return {} - - if not embedded: - error_message = "If embedded is specified it must not be empty" - raise TypeError(error_message) - - if not isinstance(embedded, Mapping): - error_message = "Embedded must be a mapping" - raise TypeError(error_message) - - validated_embedded: Dict[str, List[Any]] = defaultdict(list) - for name, embedded_ in embedded.items(): - embedded_sequence = ( - embedded_ if isinstance(embedded_, Sequence) else [embedded_] - ) - validated_embedded[name].extend(embedded_sequence) - - return validated_embedded - - @staticmethod - def _validate_links(content: Any) -> Dict[str, List[HALForType]]: - links: LinksRawType = content.get("_links") - - if links is None: - return {} - - if not isinstance(links, Mapping): - error_message = "Links must be a Mapping" - raise TypeError(error_message) - - self_link_raw = links.get("self") - - if not self_link_raw: - error_message = "If _links is present, self link must be specified" - raise TypeError(error_message) - - self_link = HALForType.model_validate(self_link_raw) - - if self_link.templated: - error_message = "Self link must not be templated" - raise TypeError(error_message) - - if not self_link.href: - error_message = "Self link must have non-empty href" - raise TypeError(error_message) - - if not all(name for name in links): - error_message = "All Links must have non-empty names" - raise TypeError(error_message) - - validated_links: Dict[str, List[HALForType]] = defaultdict(list) - for name, links_ in links.items(): - link_sequence = links_ if isinstance(links_, Sequence) else [links_] - hal_for_type = [HALForType.model_validate(link_) for link_ in link_sequence] - validated_links[name].extend(hal_for_type) - - return validated_links - - @staticmethod - def _extract_curies( - links: Mapping[str, Sequence[HALForType]], - ) -> Sequence[HALForType]: - curies = links.get("curies") - - if curies is None: - return [] - - for link in curies: - if not link.templated: - error_message = "Curies must be templated" - raise TypeError(error_message) - - if not link.name: - error_message = "Curies must have a name" - raise TypeError(error_message) - - if not link.href: - error_message = "Curies must have href" - raise TypeError(error_message) - - key_in_template = "rel" - if key_in_template not in link.href: - error_message = "Curies must be have 'rel' parameter in href" - raise TypeError(error_message) - - return curies - - @staticmethod - def _validate_name_in_curies(curies: Sequence[HALForType], name: str) -> None: - expected_name, separator, _ = name.partition(":") - if not separator: - return - - curie_names = [curie.name for curie in curies] - if not curie_names: - error_message = "CURIEs were used but none was specified" - raise TypeError(error_message) - - if any(expected_name == name for name in curie_names): - return - - error_message = f"No CURIE found for '{expected_name}' in _links" - raise TypeError(error_message) - - def _validate( - self: Self, content: Any, parent_curies: Optional[Sequence[HALForType]] = None - ) -> None: - if not content: - return - - parent_curies = parent_curies or [] - - links = self._validate_links(content) - curies = self._extract_curies(links) - combined_curies = list(chain(curies, parent_curies)) - - for link_name in links: - self._validate_name_in_curies(combined_curies, link_name) - - embedded = self._validate_embedded(content) - - for embedded_name in embedded: - self._validate_name_in_curies(combined_curies, embedded_name) - - for embedded_ in embedded.values(): - for element in embedded_: - self._validate(element, parent_curies=combined_curies) - - def render(self: Self, content: Any) -> bytes: - self._validate(content) - return super().render(content) diff --git a/fastapi_hypermodel/hal/__init__.py b/fastapi_hypermodel/hal/__init__.py new file mode 100644 index 0000000..38dad3f --- /dev/null +++ b/fastapi_hypermodel/hal/__init__.py @@ -0,0 +1,12 @@ +from .hal_hypermodel import FrozenDict, HALFor, HALForType, HALHyperModel, HALLinks +from .hal_response import HALResponse, get_hal_link + +__all__ = [ + "FrozenDict", + "HALFor", + "HALForType", + "HALHyperModel", + "HALLinks", + "HALResponse", + "get_hal_link", +] diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py new file mode 100644 index 0000000..9e0ae31 --- /dev/null +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from typing import ( + Any, + Callable, + ClassVar, + Dict, + Mapping, + Optional, + Sequence, + Type, + Union, + cast, +) + +import pydantic_core +from frozendict import frozendict +from pydantic import ( + BaseModel, + ConfigDict, + Field, + GetCoreSchemaHandler, + PrivateAttr, + field_serializer, + model_serializer, + model_validator, +) +from starlette.applications import Starlette +from typing_extensions import Annotated, Self + +from fastapi_hypermodel.base import ( + AbstractHyperField, + HasName, + HyperModel, + UrlType, + get_route_from_app, +) + + +class HALForType(BaseModel): + href: UrlType = Field(default=UrlType()) + templated: Optional[bool] = None + title: Optional[str] = None + name: Optional[str] = None + type_: Optional[str] = Field(default=None, alias="type") + hreflang: Optional[str] = None + profile: Optional[str] = None + deprecation: Optional[str] = None + + model_config = ConfigDict( + populate_by_name=True, + ) + + def __bool__(self: Self) -> bool: + return bool(self.href) + + @model_serializer + def serialize(self: Self) -> Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]: + if isinstance(self, Sequence): + return [ + {value.model_fields[k].alias or k: v for k, v in value if v} # type: ignore + for value in self + ] + return {self.model_fields[k].alias or k: v for k, v in self if v} + + +class HALFor(HALForType, AbstractHyperField[HALForType]): + # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, 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 + _title: Optional[str] = PrivateAttr() + _name: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() + _hreflang: Optional[str] = PrivateAttr() + _profile: Optional[str] = PrivateAttr() + _deprecation: Optional[str] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Optional[Mapping[str, str]] = None, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, + templated: Optional[bool] = None, + title: Optional[str] = None, + name: Optional[str] = None, + type_: Optional[str] = None, + hreflang: Optional[str] = None, + profile: Optional[str] = None, + deprecation: Optional[str] = None, + ) -> None: + super().__init__() + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._condition = condition + self._templated = templated + self._title = title + self._name = name + self._type = type_ + self._hreflang = hreflang + self._profile = profile + self._deprecation = deprecation + + def __call__( + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> Optional[HALForType]: + if app is None: + return None + + if self._condition and not self._condition(values): + return None + + route = get_route_from_app(app, self._endpoint) + + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=values, + params=self._param_values, + route=route, + ) + + return HALForType( + href=uri_path, + templated=self._templated, + title=self._title, + name=self._name, + type_=self._type, # type: ignore + hreflang=self._hreflang, + profile=self._profile, + deprecation=self._deprecation, + ) + + +HALLinkType = Union[HALFor, Sequence[HALFor]] + + +class FrozenDict(frozendict): # type: ignore + @classmethod + def __get_pydantic_core_schema__( + cls: Type[Self], + __source: Type[BaseModel], + __handler: GetCoreSchemaHandler, + ) -> pydantic_core.CoreSchema: + hal_for_schema = HALFor.__get_pydantic_core_schema__(__source, __handler) + hal_for_type_schema = HALForType.__get_pydantic_core_schema__( + __source, __handler + ) + hal_link_schema = pydantic_core.core_schema.union_schema([ + hal_for_schema, + hal_for_type_schema, + ]) + link_schema = pydantic_core.core_schema.union_schema([ + hal_link_schema, + pydantic_core.core_schema.list_schema(hal_link_schema), + ]) + return pydantic_core.core_schema.dict_schema( + keys_schema=pydantic_core.core_schema.str_schema(), + values_schema=link_schema, + ) + + +HALLinks = Annotated[Optional[FrozenDict], Field(alias="_links")] + + +class HALHyperModel(HyperModel): + curies_: ClassVar[Optional[Sequence[HALForType]]] = None + links: HALLinks = None + embedded: Mapping[str, Union[Self, Sequence[Self]]] = Field( + default_factory=dict, alias="_embedded" + ) + + # This config is needed to use the Self in Embedded + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def register_curies(cls: Type[Self], curies: Sequence[HALForType]) -> None: + cls.curies_ = curies + + @classmethod + def curies(cls: Type[Self]) -> Sequence[HALForType]: + return cls.curies_ or [] + + @model_validator(mode="after") + def add_links(self: Self) -> Self: + links_key = "_links" + + validated_links: Dict[str, HALLinkType] = {} + for name, value in self: + alias = self.model_fields[name].alias or name + + if alias != links_key or not value: + continue + + links = cast(Mapping[str, HALLinkType], value) + for link_name, link_ in links.items(): + valid_links = self._validate_factory(link_, vars(self)) + + if not valid_links: + continue + + first_link, *_ = valid_links + validated_links[link_name] = ( + valid_links if isinstance(link_, Sequence) else first_link + ) + + validated_links["curies"] = HALHyperModel.curies() # type: ignore + + self.links = FrozenDict(validated_links) + + return self + + @model_validator(mode="after") + def add_hypermodels_to_embedded(self: Self) -> Self: + embedded: Dict[str, Union[Self, Sequence[Self]]] = {} + for name, field in self: + value: Sequence[Union[Any, Self]] = ( + field if isinstance(field, Sequence) else [field] + ) + + if not all(isinstance(element, HALHyperModel) for element in value): + continue + + key = self.model_fields[name].alias or name + embedded[key] = value + delattr(self, name) + + self.embedded = embedded + + if not self.embedded: + delattr(self, "embedded") + + return self + + @field_serializer("links") + @staticmethod + def serialize_links(links: HALLinks) -> Dict[str, HALLinkType]: + if not links: + return {} + return dict(links.items()) diff --git a/fastapi_hypermodel/hal/hal_response.py b/fastapi_hypermodel/hal/hal_response.py new file mode 100644 index 0000000..34632df --- /dev/null +++ b/fastapi_hypermodel/hal/hal_response.py @@ -0,0 +1,168 @@ +from collections import defaultdict +from itertools import chain +from typing import ( + Any, + Dict, + List, + Mapping, + Optional, + Sequence, + Union, +) + +from starlette.responses import JSONResponse +from typing_extensions import Self + +from .hal_hypermodel import HALForType + +EmbeddedRawType = Union[Mapping[str, Union[Sequence[Any], Any]], Any] +LinksRawType = Union[Mapping[str, Union[Any, Sequence[Any]]], Any] + + +class HALResponse(JSONResponse): + media_type = "application/hal+json" + + @staticmethod + def _validate_embedded( + content: Any, + ) -> Dict[str, List[Any]]: + embedded: EmbeddedRawType = content.get("_embedded") + + if embedded is None: + return {} + + if not embedded: + error_message = "If embedded is specified it must not be empty" + raise TypeError(error_message) + + if not isinstance(embedded, Mapping): + error_message = "Embedded must be a mapping" + raise TypeError(error_message) + + validated_embedded: Dict[str, List[Any]] = defaultdict(list) + for name, embedded_ in embedded.items(): + embedded_sequence = ( + embedded_ if isinstance(embedded_, Sequence) else [embedded_] + ) + validated_embedded[name].extend(embedded_sequence) + + return validated_embedded + + @staticmethod + def _validate_links(content: Any) -> Dict[str, List[HALForType]]: + links: LinksRawType = content.get("_links") + + if links is None: + return {} + + if not isinstance(links, Mapping): + error_message = "Links must be a Mapping" + raise TypeError(error_message) + + self_link_raw = links.get("self") + + if not self_link_raw: + error_message = "If _links is present, self link must be specified" + raise TypeError(error_message) + + self_link = HALForType.model_validate(self_link_raw) + + if self_link.templated: + error_message = "Self link must not be templated" + raise TypeError(error_message) + + if not self_link.href: + error_message = "Self link must have non-empty href" + raise TypeError(error_message) + + if not all(name for name in links): + error_message = "All Links must have non-empty names" + raise TypeError(error_message) + + validated_links: Dict[str, List[HALForType]] = defaultdict(list) + for name, links_ in links.items(): + link_sequence = links_ if isinstance(links_, Sequence) else [links_] + hal_for_type = [HALForType.model_validate(link_) for link_ in link_sequence] + validated_links[name].extend(hal_for_type) + + return validated_links + + @staticmethod + def _extract_curies( + links: Mapping[str, Sequence[HALForType]], + ) -> Sequence[HALForType]: + curies = links.get("curies") + + if curies is None: + return [] + + for link in curies: + if not link.templated: + error_message = "Curies must be templated" + raise TypeError(error_message) + + if not link.name: + error_message = "Curies must have a name" + raise TypeError(error_message) + + if not link.href: + error_message = "Curies must have href" + raise TypeError(error_message) + + key_in_template = "rel" + if key_in_template not in link.href: + error_message = "Curies must be have 'rel' parameter in href" + raise TypeError(error_message) + + return curies + + @staticmethod + def _validate_name_in_curies(curies: Sequence[HALForType], name: str) -> None: + expected_name, separator, _ = name.partition(":") + if not separator: + return + + curie_names = [curie.name for curie in curies] + if not curie_names: + error_message = "CURIEs were used but none was specified" + raise TypeError(error_message) + + if any(expected_name == name for name in curie_names): + return + + error_message = f"No CURIE found for '{expected_name}' in _links" + raise TypeError(error_message) + + def _validate( + self: Self, content: Any, parent_curies: Optional[Sequence[HALForType]] = None + ) -> None: + if not content: + return + + parent_curies = parent_curies or [] + + links = self._validate_links(content) + curies = self._extract_curies(links) + combined_curies = list(chain(curies, parent_curies)) + + for link_name in links: + self._validate_name_in_curies(combined_curies, link_name) + + embedded = self._validate_embedded(content) + + for embedded_name in embedded: + self._validate_name_in_curies(combined_curies, embedded_name) + + for embedded_ in embedded.values(): + for element in embedded_: + self._validate(element, parent_curies=combined_curies) + + def render(self: Self, content: Any) -> bytes: + self._validate(content) + return super().render(content) + + +def get_hal_link(response: Any, link_name: str) -> Optional[HALForType]: + links = response.get("_links", {}) + link = links.get(link_name, {}) + return HALForType.model_validate(link) if link else None diff --git a/fastapi_hypermodel/linkset.py b/fastapi_hypermodel/linkset.py deleted file mode 100644 index ffb4921..0000000 --- a/fastapi_hypermodel/linkset.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import ( - Any, - Dict, - List, - Mapping, - Optional, - Sequence, - Type, - Union, - cast, -) - -from pydantic import ( - BaseModel, - Field, - GetJsonSchemaHandler, - PrivateAttr, - model_serializer, -) -from pydantic.json_schema import JsonSchemaValue -from pydantic_core import CoreSchema -from starlette.applications import Starlette -from typing_extensions import Self - -from fastapi_hypermodel.hypermodel import AbstractHyperField - -LinkType = Union[AbstractHyperField[Any], Sequence[AbstractHyperField[Any]]] - - -class LinkSetType(BaseModel): - mapping: Mapping[str, LinkType] = Field(default_factory=dict) - - @model_serializer - def serialize(self: Self) -> Mapping[str, LinkType]: - return self if isinstance(self, Mapping) else self.mapping - - -class LinkSet(LinkSetType, AbstractHyperField[LinkSetType]): - _mapping: Mapping[str, LinkType] = PrivateAttr(default_factory=dict) - - def __init__( - self: Self, - mapping: Optional[Mapping[str, LinkType]] = None, - ) -> None: - super().__init__() - self._mapping = mapping or {} - - @classmethod - def __get_pydantic_json_schema__( - cls: Type[Self], __core_schema: CoreSchema, handler: GetJsonSchemaHandler - ) -> JsonSchemaValue: - json_schema = handler(__core_schema) - json_schema = handler.resolve_ref_schema(json_schema) - json_schema["type"] = "object" - - subclasses_schemas = AbstractHyperField.__schema_subclasses__(cls) - json_schema["additionalProperties"] = {"anyOf": subclasses_schemas} - - nested_properties_value = "properties" - if nested_properties_value in json_schema: - del json_schema[nested_properties_value] - - return json_schema - - def __call__( - self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> LinkSetType: - links: Dict[str, LinkType] = {} - - for key, hyperfields in self._mapping.items(): - hypermedia: Union[List[Any], Any] = [] - if isinstance(hyperfields, Sequence): - hypermedia = [hyperfield(app, values) for hyperfield in hyperfields] - else: - hypermedia = hyperfields(app, values) - - if not hypermedia: - continue - - links[key] = cast(LinkType, hypermedia) - - return LinkSetType(mapping=links) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py deleted file mode 100644 index 6cc787b..0000000 --- a/fastapi_hypermodel/siren.py +++ /dev/null @@ -1,500 +0,0 @@ -from __future__ import annotations - -from itertools import starmap -from typing import ( - Any, - Callable, - Dict, - List, - Mapping, - Sequence, - Type, - TypeVar, - Union, - cast, -) - -import jsonschema -from fastapi.routing import APIRoute -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PrivateAttr, - field_validator, - model_serializer, - model_validator, -) -from pydantic.fields import FieldInfo -from starlette.applications import Starlette -from starlette.responses import JSONResponse -from starlette.routing import Route -from typing_extensions import Self - -from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel -from fastapi_hypermodel.url_type import UrlType -from fastapi_hypermodel.utils import ( - get_route_from_app, - resolve_param_values, -) - -from .siren_schema import schema - - -class SirenBase(BaseModel): - class_: Union[Sequence[str], None] = Field(default=None, alias="class") - title: Union[str, None] = Field(default=None) - - @model_serializer - def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} - - -class SirenLinkType(SirenBase): - rel: Sequence[str] = Field(default_factory=list) - href: UrlType = Field(default=UrlType()) - type_: Union[str, None] = Field(default=None, alias="type") - - @field_validator("rel", "href") - @classmethod - def mandatory(cls: Type[Self], value: Union[str, None]) -> str: - if not value: - error_message = "Field rel and href are mandatory" - raise ValueError(error_message) - return value - - -class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): - # pylint: disable=too-many-instance-attributes - _endpoint: str = PrivateAttr() - _param_values: Mapping[str, str] = PrivateAttr() - _templated: bool = PrivateAttr() - _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() - - # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal - _title: Union[str, None] = PrivateAttr() - _type: Union[str, None] = PrivateAttr() - _rel: Sequence[str] = PrivateAttr() - _class: Union[Sequence[str], None] = PrivateAttr() - - def __init__( - self: Self, - endpoint: Union[HasName, str], - param_values: Union[Mapping[str, str], None] = None, - templated: bool = False, - condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, - title: Union[str, None] = None, - type_: Union[str, None] = None, - rel: Union[Sequence[str], None] = None, - class_: Union[Sequence[str], None] = None, - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._endpoint = ( - endpoint.__name__ if isinstance(endpoint, HasName) else endpoint - ) - self._param_values = param_values or {} - self._templated = templated - self._condition = condition - self._title = title - self._type = type_ - self._rel = rel or [] - self._class = class_ - - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - - def __call__( - self: Self, app: Union[Starlette, None], values: Mapping[str, Any] - ) -> Union[SirenLinkType, None]: - if app is None: - return None - - if self._condition and not self._condition(values): - return None - - route = get_route_from_app(app, self._endpoint) - - properties = values.get("properties", values) - uri_path = self._get_uri_path(app, properties, route) - - # Using model_validate to avoid conflicts with keyword class - return SirenLinkType.model_validate({ - "href": uri_path, - "rel": self._rel, - "title": self._title, - "type": self._type, - "class": self._class, - }) - - -class SirenFieldType(SirenBase): - name: str - type_: Union[str, None] = Field(default=None, alias="type") - value: Union[Any, None] = None - - @classmethod - def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: - return cls.model_validate({ - "name": name, - "type": cls.parse_type(field_info.annotation), - "value": field_info.default, - }) - - @staticmethod - def parse_type(python_type: Union[Type[Any], None]) -> str: - type_repr = repr(python_type) - - text_types = ("str",) - if any(text_type in type_repr for text_type in text_types): - return "text" - - number_types = ("float", "int") - if any(number_type in type_repr for number_type in number_types): - return "number" - - return "text" - - -class SirenActionType(SirenBase): - name: str = Field(default="") - method: str = Field(default="GET") - href: UrlType = Field(default=UrlType()) - type_: Union[str, None] = Field(default=None, alias="type") - fields: Union[Sequence[SirenFieldType], None] = Field(default=None) - templated: bool = Field(default=False) - - @field_validator("name", "href") - @classmethod - def mandatory(cls: Type[Self], value: Union[str, None]) -> str: - if not value: - error_message = f"Field name and href are mandatory, {value}" - raise ValueError(error_message) - return value - - -class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # pylint: disable=too-many-instance-attributes - _endpoint: str = PrivateAttr() - _param_values: Mapping[str, str] = PrivateAttr() - _templated: bool = PrivateAttr() - _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() - _populate_fields: bool = PrivateAttr() - - # For details on the folllowing fields, check https://github.com/kevinswiber/siren - _class: Union[Sequence[str], None] = PrivateAttr() - _title: Union[str, None] = PrivateAttr() - _name: Union[str, None] = PrivateAttr() - _method: Union[str, None] = PrivateAttr() - _type: Union[str, None] = PrivateAttr() - _fields: Union[Sequence[SirenFieldType], None] = PrivateAttr() - - def __init__( - self: Self, - endpoint: Union[HasName, str], - param_values: Union[Mapping[str, str], None] = None, - templated: bool = False, - condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, - populate_fields: bool = True, - title: Union[str, None] = None, - type_: Union[str, None] = None, - class_: Union[Sequence[str], None] = None, - fields: Union[Sequence[SirenFieldType], None] = None, - method: Union[str, None] = None, - name: Union[str, None] = "", - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._endpoint = ( - endpoint.__name__ if isinstance(endpoint, HasName) else endpoint - ) - self._param_values = param_values or {} - self._templated = templated - self._condition = condition - self._populate_fields = populate_fields - self._title = title - self._type = type_ - self._fields = fields or [] - self._method = method - self._name = name - self._class = class_ - - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - - def _prepopulate_fields( - self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] - ) -> List[SirenFieldType]: - if not self._populate_fields: - return list(fields) - - for field in fields: - value = values.get(field.name) or field.value - field.value = str(value) - return list(fields) - - def _compute_fields( - self: Self, route: Route, values: Mapping[str, Any] - ) -> List[SirenFieldType]: - if not isinstance(route, APIRoute): # pragma: no cover - route.body_field = "" # type: ignore - route = cast(APIRoute, route) - - body_field = route.body_field - if not body_field: - return [] - - annotation: Any = body_field.field_info.annotation or {} - model_fields: Any = annotation.model_fields if annotation else {} - model_fields = cast(Dict[str, FieldInfo], model_fields) - - fields = list(starmap(SirenFieldType.from_field_info, model_fields.items())) - return self._prepopulate_fields(fields, values) - - def __call__( - self: Self, app: Union[Starlette, None], values: Mapping[str, Any] - ) -> Union[SirenActionType, None]: - if app is None: - return None - - if self._condition and not self._condition(values): - return None - - route = get_route_from_app(app, self._endpoint) - - if not self._method: - self._method = next(iter(route.methods or {}), None) - - uri_path = self._get_uri_path(app, values, route) - - if not self._fields: - self._fields = self._compute_fields(route, values) - - if not self._type and self._fields: - self._type = "application/x-www-form-urlencoded" - - # Using model_validate to avoid conflicts with class and type - return SirenActionType.model_validate({ - "href": uri_path, - "name": self._name, - "fields": self._fields, - "method": self._method, - "title": self._title, - "type": self._type, - "class": self._class, - "templated": self._templated, - }) - - -class SirenEntityType(SirenBase): - properties: Union[Mapping[str, Any], None] = None - entities: Union[Sequence[Union[SirenEmbeddedType, SirenLinkType]], None] = None - links: Union[Sequence[SirenLinkType], None] = None - actions: Union[Sequence[SirenActionType], None] = None - - -class SirenEmbeddedType(SirenEntityType): - rel: Sequence[str] = Field() - - -T = TypeVar("T", bound=Callable[..., Any]) - -SIREN_RESERVED_FIELDS = { - "properties", - "entities", - "links", - "actions", -} - - -class SirenHyperModel(HyperModel): - properties: Dict[str, Any] = Field(default_factory=dict) - entities: Sequence[Union[SirenEmbeddedType, SirenLinkType]] = Field( - default_factory=list - ) - links: Sequence[SirenLinkFor] = Field(default_factory=list) - actions: Sequence[SirenActionFor] = Field(default_factory=list) - - # This config is needed to use the Self in Embedded - model_config = ConfigDict(arbitrary_types_allowed=True) - - @model_validator(mode="after") - def add_hypermodels_to_entities(self: Self) -> Self: - entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] - for name, field in self: - alias = self.model_fields[name].alias or name - - if alias in SIREN_RESERVED_FIELDS: - continue - - value: Sequence[Union[Any, Self]] = ( - field if isinstance(field, Sequence) else [field] - ) - - if not all( - isinstance(element, (SirenHyperModel, SirenLinkType)) - for element in value - ): - continue - - for field_ in value: - if isinstance(field_, SirenLinkType): - entities.append(field_) - continue - - child = self.as_embedded(field_, alias) - entities.append(child) - - delattr(self, name) - - self.entities = entities - - return self - - @model_validator(mode="after") - def add_properties(self: Self) -> Self: - properties = {} - for name, field in self: - alias = self.model_fields[name].alias or name - - if alias in SIREN_RESERVED_FIELDS: - continue - - value: Sequence[Any] = field if isinstance(field, Sequence) else [field] - - omit_types: Any = ( - AbstractHyperField, - SirenLinkFor, - SirenLinkType, - SirenActionFor, - SirenActionType, - SirenHyperModel, - ) - if any(isinstance(value_, omit_types) for value_ in value): - continue - - properties[alias] = value if isinstance(field, Sequence) else field - - delattr(self, name) - - if not self.properties: - self.properties = {} - - self.properties.update(properties) - - return self - - @model_validator(mode="after") - def add_links(self: Self) -> Self: - links_key = "links" - validated_links: List[SirenLinkFor] = [] - for name, value in self: - alias = self.model_fields[name].alias or name - - if alias != links_key or not value: - continue - - links = cast(Sequence[SirenLinkFor], value) - properties = self.properties or {} - validated_links = self._validate_factory(links, properties) - self.links = validated_links - - self.validate_has_self_link(validated_links) - - return self - - @staticmethod - def validate_has_self_link(links: Sequence[SirenLinkFor]) -> None: - if not links: - return - - if any(link.rel == ["self"] for link in links): - return - - error_message = "If links are present, a link with rel self must be present" - raise ValueError(error_message) - - @model_validator(mode="after") - def add_actions(self: Self) -> Self: - actions_key = "actions" - for name, value in self: - alias = self.model_fields[name].alias or name - - if alias != actions_key or not value: - continue - - properties = self.properties or {} - actions = cast(Sequence[SirenActionFor], value) - self.actions = self._validate_factory(actions, properties) - - return self - - def _validate_factory( - self: Self, elements: Sequence[T], properties: Mapping[str, str] - ) -> List[T]: - validated_elements: List[T] = [] - for element_factory in elements: - element = element_factory(self._app, properties) - if not element: - continue - validated_elements.append(element) - return validated_elements - - @model_validator(mode="after") - def no_action_outside_of_actions(self: Self) -> Self: - for _, field in self: - if not isinstance(field, (SirenActionFor, SirenActionType)): - continue - - error_message = "All actions must be inside the actions property" - raise ValueError(error_message) - - return self - - @model_serializer - def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} - - @staticmethod - def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: - return SirenEmbeddedType(rel=[rel], **field.model_dump()) - - def parse_uri(self: Self, uri_template: str) -> str: - return self._parse_uri(self.properties, uri_template) - - -class SirenResponse(JSONResponse): - media_type = "application/siren+json" - - @staticmethod - def _validate(content: Any) -> None: - jsonschema.validate(instance=content, schema=schema) - - def render(self: Self, content: Any) -> bytes: - self._validate(content) - return super().render(content) - - -def get_siren_link(response: Any, link_name: str) -> Union[SirenLinkType, None]: - links = response.get("links", []) - link = next((link for link in links if link_name in link.get("rel")), None) - return SirenLinkType.model_validate(link) if link else None - - -def get_siren_action(response: Any, action_name: str) -> Union[SirenActionType, None]: - actions = response.get("actions", []) - action = next( - (action for action in actions if action_name in action.get("name")), None - ) - return SirenActionType.model_validate(action) if action else None diff --git a/fastapi_hypermodel/siren/__init__.py b/fastapi_hypermodel/siren/__init__.py new file mode 100644 index 0000000..f49587d --- /dev/null +++ b/fastapi_hypermodel/siren/__init__.py @@ -0,0 +1,33 @@ +from .siren_action import ( + SirenActionFor, + SirenActionType, +) +from .siren_field import ( + SirenFieldType, +) +from .siren_hypermodel import ( + SirenEmbeddedType, + SirenHyperModel, +) +from .siren_link import ( + SirenLinkFor, + SirenLinkType, +) +from .siren_response import ( + SirenResponse, + get_siren_action, + get_siren_link, +) + +__all__ = [ + "SirenActionFor", + "SirenActionType", + "SirenEmbeddedType", + "SirenFieldType", + "SirenHyperModel", + "SirenLinkFor", + "SirenLinkType", + "SirenResponse", + "get_siren_action", + "get_siren_link", +] diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py new file mode 100644 index 0000000..5845d5b --- /dev/null +++ b/fastapi_hypermodel/siren/siren_action.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from itertools import starmap +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Optional, + Sequence, + Type, + Union, + cast, +) + +from fastapi.routing import APIRoute +from pydantic import ( + ConfigDict, + Field, + PrivateAttr, + field_validator, +) +from pydantic.fields import FieldInfo +from starlette.applications import Starlette +from starlette.routing import Route +from typing_extensions import Self + +from fastapi_hypermodel.base import ( + AbstractHyperField, + HasName, + UrlType, + get_route_from_app, +) + +from .siren_base import SirenBase +from .siren_field import SirenFieldType + + +class SirenActionType(SirenBase): + name: str = Field(default="") + method: str = Field(default="GET") + href: UrlType = Field(default=UrlType()) + type_: Optional[str] = Field(default=None, alias="type") + fields: Optional[Sequence[SirenFieldType]] = Field(default=None) + templated: Optional[bool] = Field(default=None) + + model_config = ConfigDict( + populate_by_name=True, + ) + + @field_validator("name", "href") + @classmethod + def mandatory(cls: Type[Self], value: Optional[str]) -> str: + if not value: + error_message = f"Field name and href are mandatory, {value}" + raise ValueError(error_message) + return value + + +class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, str] = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() + _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() + _populate_fields: bool = PrivateAttr() + + # For details on the folllowing fields, check https://github.com/kevinswiber/siren + _class: Optional[Sequence[str]] = PrivateAttr() + _title: Optional[str] = PrivateAttr() + _name: str = PrivateAttr() + _method: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() + _fields: Optional[Sequence[SirenFieldType]] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Optional[Mapping[str, str]] = None, + templated: Optional[bool] = None, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, + populate_fields: bool = True, + title: Optional[str] = None, + type_: Optional[str] = None, + class_: Optional[Sequence[str]] = None, + fields: Optional[Sequence[SirenFieldType]] = None, + method: Optional[str] = None, + name: str = "", + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._templated = templated + self._condition = condition + self._populate_fields = populate_fields + self._title = title + self._type = type_ + self._fields = fields or [] + self._method = method + self._name = name + self._class = class_ + + def _prepopulate_fields( + self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] + ) -> List[SirenFieldType]: + if not self._populate_fields: + return list(fields) + + for field in fields: + value = values.get(field.name) or field.value + field.value = str(value) + return list(fields) + + def _compute_fields( + self: Self, route: Route, values: Mapping[str, Any] + ) -> List[SirenFieldType]: + if not isinstance(route, APIRoute): # pragma: no cover + route.body_field = "" # type: ignore + route = cast(APIRoute, route) + + body_field = route.body_field + if not body_field: + return [] + + annotation: Any = body_field.field_info.annotation or {} + model_fields: Any = annotation.model_fields if annotation else {} + model_fields = cast(Dict[str, FieldInfo], model_fields) + + fields = list(starmap(SirenFieldType.from_field_info, model_fields.items())) + return self._prepopulate_fields(fields, values) + + def __call__( + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> Optional[SirenActionType]: + if app is None: + return None + + if self._condition and not self._condition(values): + return None + + route = get_route_from_app(app, self._endpoint) + + if not self._method: + self._method = next(iter(route.methods or {}), "GET") + + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=values, + params=self._param_values, + route=route, + ) + + if not self._fields: + self._fields = self._compute_fields(route, values) + + if not self._type and self._fields: + self._type = "application/x-www-form-urlencoded" + + return SirenActionType( + href=uri_path, + name=self._name, + fields=self._fields, + method=self._method, + title=self._title, + type_=self._type, # type: ignore + class_=self._class, # type: ignore + templated=self._templated, + ) diff --git a/fastapi_hypermodel/siren/siren_base.py b/fastapi_hypermodel/siren/siren_base.py new file mode 100644 index 0000000..6c9ed6f --- /dev/null +++ b/fastapi_hypermodel/siren/siren_base.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import ( + Any, + Mapping, + Sequence, + Union, +) + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + model_serializer, +) +from typing_extensions import Self + + +class SirenBase(BaseModel): + class_: Union[Sequence[str], None] = Field(default=None, alias="class") + title: Union[str, None] = Field(default=None) + + model_config = ConfigDict(populate_by_name=True) + + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} diff --git a/fastapi_hypermodel/siren/siren_field.py b/fastapi_hypermodel/siren/siren_field.py new file mode 100644 index 0000000..dfc996e --- /dev/null +++ b/fastapi_hypermodel/siren/siren_field.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import ( + Any, + Optional, + Type, +) + +from pydantic import ( + ConfigDict, + Field, +) +from pydantic.fields import FieldInfo +from typing_extensions import Self + +from .siren_base import SirenBase + + +class SirenFieldType(SirenBase): + name: str + type_: Optional[str] = Field(default=None, alias="type") + value: Optional[Any] = None + + model_config = ConfigDict(populate_by_name=True) + + @classmethod + def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: + return cls( + name=name, + type_=cls.parse_type(field_info.annotation), # type: ignore + value=field_info.default, + ) + + @staticmethod + def parse_type(python_type: Optional[Type[Any]]) -> str: + type_repr = repr(python_type) + + text_types = ("str",) + if any(text_type in type_repr for text_type in text_types): + return "text" + + number_types = ("float", "int") + if any(number_type in type_repr for number_type in number_types): + return "number" + + return "text" diff --git a/fastapi_hypermodel/siren/siren_hypermodel.py b/fastapi_hypermodel/siren/siren_hypermodel.py new file mode 100644 index 0000000..4412b6f --- /dev/null +++ b/fastapi_hypermodel/siren/siren_hypermodel.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from typing import ( + Any, + Dict, + List, + Mapping, + Sequence, + Union, + cast, +) + +from pydantic import ( + ConfigDict, + Field, + model_serializer, + model_validator, +) +from typing_extensions import Self + +from fastapi_hypermodel.base import AbstractHyperField, HyperModel + +from .siren_action import SirenActionFor, SirenActionType +from .siren_base import SirenBase +from .siren_link import SirenLinkFor, SirenLinkType + + +class SirenEntityType(SirenBase): + properties: Union[Mapping[str, Any], None] = None + entities: Union[Sequence[Union[SirenEmbeddedType, SirenLinkType]], None] = None + links: Union[Sequence[SirenLinkType], None] = None + actions: Union[Sequence[SirenActionType], None] = None + + +class SirenEmbeddedType(SirenEntityType): + rel: Sequence[str] = Field() + + +SIREN_RESERVED_FIELDS = { + "properties", + "entities", + "links", + "actions", +} + + +class SirenHyperModel(HyperModel): + properties: Dict[str, Any] = Field(default_factory=dict) + entities: Sequence[Union[SirenEmbeddedType, SirenLinkType]] = Field( + default_factory=list + ) + links: Sequence[SirenLinkFor] = Field(default_factory=list) + actions: Sequence[SirenActionFor] = Field(default_factory=list) + + # This config is needed to use the Self in Embedded + model_config = ConfigDict(arbitrary_types_allowed=True) + + @model_validator(mode="after") + def add_hypermodels_to_entities(self: Self) -> Self: + entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] + for name, field in self: + alias = self.model_fields[name].alias or name + + if alias in SIREN_RESERVED_FIELDS: + continue + + value: Sequence[Union[Any, Self]] = ( + field if isinstance(field, Sequence) else [field] + ) + + if not all( + isinstance(element, (SirenHyperModel, SirenLinkType)) + for element in value + ): + continue + + for field_ in value: + if isinstance(field_, SirenLinkType): + entities.append(field_) + continue + + child = self.as_embedded(field_, alias) + entities.append(child) + + delattr(self, name) + + self.entities = entities + + return self + + @model_validator(mode="after") + def add_properties(self: Self) -> Self: + properties = {} + for name, field in self: + alias = self.model_fields[name].alias or name + + if alias in SIREN_RESERVED_FIELDS: + continue + + value: Sequence[Any] = field if isinstance(field, Sequence) else [field] + + omit_types: Any = ( + AbstractHyperField, + SirenLinkFor, + SirenLinkType, + SirenActionFor, + SirenActionType, + SirenHyperModel, + ) + if any(isinstance(value_, omit_types) for value_ in value): + continue + + properties[alias] = value if isinstance(field, Sequence) else field + + delattr(self, name) + + if not self.properties: + self.properties = {} + + self.properties.update(properties) + + return self + + @model_validator(mode="after") + def add_links(self: Self) -> Self: + links_key = "links" + validated_links: List[SirenLinkFor] = [] + for name, value in self: + alias = self.model_fields[name].alias or name + + if alias != links_key or not value: + continue + + links = cast(Sequence[SirenLinkFor], value) + properties = self.properties or {} + validated_links = self._validate_factory(links, properties) + self.links = validated_links + + self.validate_has_self_link(validated_links) + + return self + + @staticmethod + def validate_has_self_link(links: Sequence[SirenLinkFor]) -> None: + if not links: + return + + if any(link.rel == ["self"] for link in links): + return + + error_message = "If links are present, a link with rel self must be present" + raise ValueError(error_message) + + @model_validator(mode="after") + def add_actions(self: Self) -> Self: + actions_key = "actions" + for name, value in self: + alias = self.model_fields[name].alias or name + + if alias != actions_key or not value: + continue + + properties = self.properties or {} + actions = cast(Sequence[SirenActionFor], value) + self.actions = self._validate_factory(actions, properties) + + return self + + @model_validator(mode="after") + def no_action_outside_of_actions(self: Self) -> Self: + for _, field in self: + if not isinstance(field, (SirenActionFor, SirenActionType)): + continue + + error_message = "All actions must be inside the actions property" + raise ValueError(error_message) + + return self + + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} + + @staticmethod + def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: + return SirenEmbeddedType(rel=[rel], **field.model_dump()) + + def parse_uri(self: Self, uri_template: str) -> str: + return self._parse_uri(self.properties, uri_template) diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py new file mode 100644 index 0000000..e69e871 --- /dev/null +++ b/fastapi_hypermodel/siren/siren_link.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import ( + Any, + Callable, + Mapping, + Optional, + Sequence, + Type, + Union, +) + +from pydantic import ( + ConfigDict, + Field, + PrivateAttr, + field_validator, +) +from starlette.applications import Starlette +from typing_extensions import Self + +from fastapi_hypermodel.base import ( + AbstractHyperField, + HasName, + UrlType, + get_route_from_app, +) + +from .siren_base import SirenBase + + +class SirenLinkType(SirenBase): + rel: Sequence[str] = Field(default_factory=list) + href: UrlType = Field(default=UrlType()) + type_: Optional[str] = Field(default=None, alias="type") + + model_config = ConfigDict(populate_by_name=True) + + @field_validator("rel", "href") + @classmethod + def mandatory(cls: Type[Self], value: Optional[str]) -> str: + if not value: + error_message = "Field rel and href are mandatory" + raise ValueError(error_message) + return value + + +class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): + # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, str] = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() + _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() + + # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal + _title: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() + _rel: Sequence[str] = PrivateAttr() + _class: Optional[Sequence[str]] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Optional[Mapping[str, str]] = None, + templated: Optional[bool] = None, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, + title: Optional[str] = None, + type_: Optional[str] = None, + rel: Optional[Sequence[str]] = None, + class_: Optional[Sequence[str]] = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._templated = templated + self._condition = condition + self._title = title + self._type = type_ + self._rel = rel or [] + self._class = class_ + + def __call__( + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> Optional[SirenLinkType]: + if app is None: + return None + + if self._condition and not self._condition(values): + return None + + route = get_route_from_app(app, self._endpoint) + + properties = values.get("properties", values) + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=properties, + params=self._param_values, + route=route, + ) + + # Using model_validate to avoid conflicts with keyword class + return SirenLinkType( + href=uri_path, + rel=self._rel, + title=self._title, + type_=self._type, # type: ignore + class_=self._class, # type: ignore + ) diff --git a/fastapi_hypermodel/siren/siren_response.py b/fastapi_hypermodel/siren/siren_response.py new file mode 100644 index 0000000..fdbc917 --- /dev/null +++ b/fastapi_hypermodel/siren/siren_response.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import ( + Any, + Optional, +) + +import jsonschema +from starlette.responses import JSONResponse +from typing_extensions import Self + +from .siren_action import SirenActionType +from .siren_link import SirenLinkType +from .siren_schema import schema + + +class SirenResponse(JSONResponse): + media_type = "application/siren+json" + + @staticmethod + def _validate(content: Any) -> None: + jsonschema.validate(instance=content, schema=schema) + + def render(self: Self, content: Any) -> bytes: + self._validate(content) + return super().render(content) + + +def get_siren_link(response: Any, link_name: str) -> Optional[SirenLinkType]: + links = response.get("links", []) + link = next((link for link in links if link_name in link.get("rel")), None) + return SirenLinkType.model_validate(link) if link else None + + +def get_siren_action(response: Any, action_name: str) -> Optional[SirenActionType]: + actions = response.get("actions", []) + action = next( + (action for action in actions if action_name in action.get("name")), None + ) + return SirenActionType.model_validate(action) if action else None diff --git a/fastapi_hypermodel/siren_schema.py b/fastapi_hypermodel/siren/siren_schema.py similarity index 100% rename from fastapi_hypermodel/siren_schema.py rename to fastapi_hypermodel/siren/siren_schema.py diff --git a/fastapi_hypermodel/url_for/__init__.py b/fastapi_hypermodel/url_for/__init__.py new file mode 100644 index 0000000..b04ba31 --- /dev/null +++ b/fastapi_hypermodel/url_for/__init__.py @@ -0,0 +1,3 @@ +from .url_for import UrlFor, UrlForType + +__all__ = ["UrlFor", "UrlForType"] diff --git a/fastapi_hypermodel/url_for.py b/fastapi_hypermodel/url_for/url_for.py similarity index 74% rename from fastapi_hypermodel/url_for.py rename to fastapi_hypermodel/url_for/url_for.py index b81bffc..fb46686 100644 --- a/fastapi_hypermodel/url_for.py +++ b/fastapi_hypermodel/url_for/url_for.py @@ -18,12 +18,13 @@ from starlette.applications import Starlette from typing_extensions import Self -from fastapi_hypermodel.hypermodel import ( +from fastapi_hypermodel.base import ( + URL_TYPE_SCHEMA, AbstractHyperField, HasName, + UrlType, + get_route_from_app, ) -from fastapi_hypermodel.url_type import URL_TYPE_SCHEMA, UrlType -from fastapi_hypermodel.utils import get_route_from_app, resolve_param_values class UrlForType(BaseModel): @@ -38,14 +39,14 @@ class UrlFor(UrlForType, AbstractHyperField[UrlForType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - _template: Optional[bool] = PrivateAttr() + _templated: bool = PrivateAttr() def __init__( self: Self, endpoint: Union[HasName, str], param_values: Optional[Mapping[str, Any]] = None, condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - template: Optional[bool] = None, + templated: bool = False, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -54,7 +55,7 @@ def __init__( ) self._param_values = param_values or {} self._condition = condition - self._template = template + self._templated = templated @classmethod def __get_pydantic_json_schema__( @@ -74,19 +75,22 @@ def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any], - ) -> UrlForType: + ) -> Optional[UrlForType]: if app is None: - return UrlForType() + return None if self._condition and not self._condition(values): - return UrlForType() - - if not self._template: - resolved_params = resolve_param_values(self._param_values, values) - uri_for = app.url_path_for(self._endpoint, **resolved_params) - return UrlForType(hypermedia=UrlType(uri_for)) + return None route = get_route_from_app(app, self._endpoint) - href = UrlType(route.path) - return UrlForType(hypermedia=href) + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=values, + params=self._param_values, + route=route, + ) + + return UrlForType(hypermedia=uri_path) diff --git a/mkdocs.yml b/mkdocs.yml index 9e1b3b4..0ad2e2e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,10 +1,15 @@ site_name: FastAPI HyperModel theme: name: material + features: + - content.code.copy repo_name: jtc42/fastapi-hypermodel repo_url: https://github.com/jtc42/fastapi-hypermodel +extra_javascript: + - js/linked_tabs.js + markdown_extensions: - admonition - pymdownx.highlight: @@ -12,6 +17,9 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + combine_header_slug: true docs_dir: './docs' diff --git a/pyproject.toml b/pyproject.toml index e8c6c74..ce69d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ typing_extensions = ">=4.0.0" python = ">=3.8,<4.0" jsonref = ">=1.1.0,<2.0.0" jsonschema = ">=4.0.0,<5.0.0" +frozendict = "^2.4.0" [tool.poetry.group.dev.dependencies] bandit = "^1.7.0" @@ -22,13 +23,13 @@ mkdocs-material = ">=8.3.9,<10.0.0" pylint = ">=2.6.2,<4.0.0" pylint_pydantic = "^0.3.2" mypy = ">=0.991,<1.9" -pytest = "^7.0" +pytest = ">=7,<9" pytest-cov = ">=3,<5" pytest-lazy-fixtures = ">=1.0.1" requests = "^2.25.1" -ruff = "^0.1.8" +ruff = ">=0.1.8,<0.3.0" tox = "^4.4" -uvicorn = "*" +uvicorn = ">=0.17.6,<0.28.0" [build-system] build-backend = "poetry.core.masonry.api" @@ -54,6 +55,9 @@ exclude_also = [ [tool.mypy] strict = true ignore_missing_imports = true +disable_error_code = [ + "unused-ignore", # To use names in Pydantic Init +] ######################################### # Ruff @@ -221,7 +225,7 @@ check-str-concat-over-line-jumps = true ignore-comments = true ignore-docstrings = true ignore-signatures = true -min-similarity-lines = 7 +min-similarity-lines = 12 [tool.pylint.variables] allow-global-unused-variables = false diff --git a/tests/integration/hal/conftest.py b/tests/integration/hal/conftest.py index 8632c32..a7e42b4 100644 --- a/tests/integration/hal/conftest.py +++ b/tests/integration/hal/conftest.py @@ -15,13 +15,13 @@ from examples.hal import ( people as people_, ) -from fastapi_hypermodel import HalHyperModel +from fastapi_hypermodel import HALHyperModel @pytest.fixture() def hal_client() -> TestClient: - HalHyperModel.init_app(app) - HalHyperModel.register_curies(curies) + HALHyperModel.init_app(app) + HALHyperModel.register_curies(curies) return TestClient(app=app, base_url="http://haltestserver") diff --git a/tests/integration/hal/test_hal_items.py b/tests/integration/hal/test_hal_items.py index 35a28c1..8dc6b3b 100644 --- a/tests/integration/hal/test_hal_items.py +++ b/tests/integration/hal/test_hal_items.py @@ -4,7 +4,7 @@ from fastapi.testclient import TestClient from examples.hal import Item -from fastapi_hypermodel import get_hal_link_href +from fastapi_hypermodel import UrlType, get_hal_link @pytest.fixture() @@ -13,17 +13,17 @@ def item_uri() -> str: @pytest.fixture() -def find_uri_template(hal_client: TestClient, item_uri: str) -> str: - find_uri = get_hal_link_href(hal_client.get(item_uri).json(), "find") +def find_uri_template(hal_client: TestClient, item_uri: str) -> UrlType: + find_uri = get_hal_link(hal_client.get(item_uri).json(), "find") assert find_uri - return find_uri + return find_uri.href @pytest.fixture() -def update_uri_template(hal_client: TestClient, item_uri: str) -> str: - update_uri = get_hal_link_href(hal_client.get(item_uri).json(), "update") +def update_uri_template(hal_client: TestClient, item_uri: str) -> UrlType: + update_uri = get_hal_link(hal_client.get(item_uri).json(), "update") assert update_uri - return update_uri + return update_uri.href def test_items_content_type(hal_client: TestClient, item_uri: str) -> None: @@ -35,8 +35,9 @@ def test_items_content_type(hal_client: TestClient, item_uri: str) -> None: def test_get_items(hal_client: TestClient, item_uri: str) -> None: response = hal_client.get(item_uri).json() - self_uri = get_hal_link_href(response, "self") - assert self_uri == item_uri + self_link = get_hal_link(response, "self") + assert self_link + assert self_link.href == item_uri find_uri = response.get("_links", {}).get("find", {}) assert find_uri.get("templated") @@ -56,10 +57,11 @@ def test_get_item( find_uri = item.parse_uri(find_uri_template) item_response = hal_client.get(find_uri).json() - item_href = get_hal_link_href(item_response, "self") + item_hal_link = get_hal_link(item_response, "self") - assert item_uri in item_href - assert item.id_ in item_href + assert item_hal_link + assert item_uri in item_hal_link.href + assert item.id_ in item_hal_link.href assert item_response.get("id_") == item.id_ @@ -79,8 +81,8 @@ def test_update_item_from_uri_template( assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri @@ -93,13 +95,15 @@ def test_update_item_from_update_uri( new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri = get_hal_link_href(before, "update") - response = hal_client.put(update_uri, json=new_data).json() + update_link = get_hal_link(before, "update") + assert update_link + + response = hal_client.put(update_link.href, json=new_data).json() assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri diff --git a/tests/integration/hal/test_hal_people.py b/tests/integration/hal/test_hal_people.py index 2c84ef4..eb8127a 100644 --- a/tests/integration/hal/test_hal_people.py +++ b/tests/integration/hal/test_hal_people.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from examples.hal import Person -from fastapi_hypermodel import get_hal_link_href +from fastapi_hypermodel import UrlType, get_hal_link @pytest.fixture() @@ -14,17 +14,17 @@ def people_uri() -> str: @pytest.fixture() -def find_uri_template(hal_client: TestClient, people_uri: str) -> str: - find_uri = get_hal_link_href(hal_client.get(people_uri).json(), "find") +def find_uri_template(hal_client: TestClient, people_uri: str) -> UrlType: + find_uri = get_hal_link(hal_client.get(people_uri).json(), "find") assert find_uri - return find_uri + return find_uri.href @pytest.fixture() -def update_uri_template(hal_client: TestClient, people_uri: str) -> str: - update_uri = get_hal_link_href(hal_client.get(people_uri).json(), "update") +def update_uri_template(hal_client: TestClient, people_uri: str) -> UrlType: + update_uri = get_hal_link(hal_client.get(people_uri).json(), "update") assert update_uri - return update_uri + return update_uri.href def test_people_content_type(hal_client: TestClient, people_uri: str) -> None: @@ -36,8 +36,9 @@ def test_people_content_type(hal_client: TestClient, people_uri: str) -> None: def test_get_people(hal_client: TestClient, people_uri: str) -> None: response = hal_client.get(people_uri).json() - self_uri = get_hal_link_href(response, "self") - assert self_uri == people_uri + self_link = get_hal_link(response, "self") + assert self_link + assert self_link.href == people_uri find_uri = response.get("_links", {}).get("find", {}) assert find_uri.get("templated") @@ -50,10 +51,11 @@ def test_get_person( find_uri = person.parse_uri(find_uri_template) person_response = hal_client.get(find_uri).json() - person_href = get_hal_link_href(person_response, "self") + self_link = get_hal_link(person_response, "self") - assert people_uri in person_href - assert person.id_ in person_href + assert self_link + assert people_uri in self_link.href + assert person.id_ in self_link.href assert person_response.get("id_") == person.id_ embedded = person_response.get("_embedded") @@ -79,8 +81,8 @@ def test_update_person_from_uri_template( assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri @@ -93,14 +95,15 @@ def test_update_person_from_update_uri( new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri = get_hal_link_href(before, "update") - response = hal_client.put(update_uri, json=new_data).json() + update_link = get_hal_link(before, "update") + assert update_link + response = hal_client.put(update_link.href, json=new_data).json() assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri @@ -117,8 +120,9 @@ def test_get_person_items( assert isinstance(person_items, list) first_item, *_ = person_items - first_item_uri = get_hal_link_href(first_item, "self") - first_item_response = hal_client.get(first_item_uri).json() + first_item_link = get_hal_link(first_item, "self") + assert first_item_link + first_item_response = hal_client.get(first_item_link.href).json() assert first_item == first_item_response @@ -142,11 +146,11 @@ def test_add_item_to_unlocked_person( find_uri = unlocked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() before_items = before.get("_embedded", {}).get("sc:items", []) - add_item_uri = get_hal_link_href(before, "add_item") + add_item_link = get_hal_link(before, "add_item") - assert add_item_uri + assert add_item_link - after = hal_client.put(add_item_uri, json=existing_item).json() + after = hal_client.put(add_item_link.href, json=existing_item).json() after_items = after.get("_embedded", {}).get("sc:items", []) assert after_items @@ -165,11 +169,11 @@ def test_add_item_to_unlocked_person_nonexisting_item( ) -> None: find_uri = unlocked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() - add_item_uri = get_hal_link_href(before, "add_item") + add_item_link = get_hal_link(before, "add_item") - assert add_item_uri + assert add_item_link - response = hal_client.put(add_item_uri, json=non_existing_item) + response = hal_client.put(add_item_link.href, json=non_existing_item) assert response.status_code == 404 assert response.json() == {"detail": "No item found with id item05"} @@ -181,6 +185,6 @@ def test_add_item_to_locked_person( ) -> None: find_uri = locked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() - add_item_uri = get_hal_link_href(before, "add_item") + add_item_uri = get_hal_link(before, "add_item") assert not add_item_uri diff --git a/tests/test_hal.py b/tests/test_hal.py index 59c6a0e..48224b5 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -8,79 +8,71 @@ from pytest_lazy_fixtures import lf from fastapi_hypermodel import ( + FrozenDict, HALFor, HALForType, - HalHyperModel, + HALHyperModel, + HALLinks, HALResponse, - LinkSet, UrlType, ) -class MockClass(HalHyperModel): +class MockClass(HALHyperModel): id_: str - links: LinkSet = Field( - default=LinkSet({ - "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - }), - alias="_links", - ) + links: HALLinks = FrozenDict({ + "self": HALFor("mock_read_with_path_hal", {"id_": ""}), + }) -class MockClassWithEmbedded(HalHyperModel): +class MockClassWithEmbedded(HALHyperModel): id_: str test: MockClass -class MockClassWithMultipleEmbedded(HalHyperModel): +class MockClassWithMultipleEmbedded(HALHyperModel): id_: str test: MockClass test2: MockClass -class MockClassWithEmbeddedAliased(HalHyperModel): +class MockClassWithEmbeddedAliased(HALHyperModel): id_: str test: MockClass = Field(alias="sc:test") -class MockClassWithEmbeddedList(HalHyperModel): +class MockClassWithEmbeddedList(HALHyperModel): id_: str test: Sequence[MockClass] -class MockClassWithEmbeddedListAliased(HalHyperModel): +class MockClassWithEmbeddedListAliased(HALHyperModel): id_: str test: Sequence[MockClass] = Field(alias="sc:test") -class MockClassWithCuries(HalHyperModel): +class MockClassWithCuries(HALHyperModel): id_: str - links: LinkSet = Field( - default=LinkSet({ - "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - "sc:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - }), - alias="_links", - ) + links: HALLinks = FrozenDict({ + "self": HALFor("mock_read_with_path_hal", {"id_": ""}), + "sc:item": HALFor("mock_read_with_path_hal", {"id_": ""}), + }) -class MockClassWithMissingCuries(HalHyperModel): +class MockClassWithMissingCuries(HALHyperModel): id_: str - links: LinkSet = Field( - default=LinkSet({ - "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - "missing:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - }), - alias="_links", - ) + links: HALLinks = FrozenDict({ + "self": HALFor("mock_read_with_path_hal", {"id_": ""}), + "missing:item": HALFor("mock_read_with_path_hal", {"id_": ""}), + }) @pytest.fixture() @@ -89,7 +81,7 @@ def hal_app(app: FastAPI) -> FastAPI: def mock_read_with_path_hal() -> Any: # pragma: no cover return {} - HalHyperModel.init_app(app) + HALHyperModel.init_app(app) return app @@ -221,9 +213,9 @@ def curies() -> List[HALForType]: @pytest.fixture() def _set_curies(curies: Sequence[HALForType]) -> Generator[None, None, None]: - HalHyperModel.register_curies(curies) + HALHyperModel.register_curies(curies) yield - HalHyperModel.register_curies([]) + HALHyperModel.register_curies([]) @pytest.fixture() @@ -451,7 +443,7 @@ def test_hal_for_no_app() -> None: hal_for = HALFor("mock_read_with_path_hal", {"id_": ""}) hypermedia = hal_for(None, vars(mock)) - assert hypermedia.href == "" + assert hypermedia is None def test_build_hypermedia_passing_condition(app: FastAPI) -> None: @@ -462,6 +454,7 @@ def test_build_hypermedia_passing_condition(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = hal_for(app, {"id_": sample_id, "locked": True}) + assert uri assert uri.href == f"/mock_read/{sample_id}" @@ -471,6 +464,7 @@ def test_build_hypermedia_template(hal_app: FastAPI) -> None: templated=True, ) uri = hal_for(hal_app, {}) + assert uri assert uri.href == "/mock_read/{id_}" @@ -482,7 +476,7 @@ def test_build_hypermedia_not_passing_condition(hal_app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = hal_for(hal_app, {"id_": sample_id, "locked": False}) - assert uri.href == "" + assert uri is None def test_build_hypermedia_with_href(app: FastAPI) -> None: @@ -493,6 +487,7 @@ def test_build_hypermedia_with_href(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = hal_for(app, {"id_": sample_id, "locked": True}) + assert uri assert uri.href == f"/mock_read/{sample_id}" @@ -500,12 +495,7 @@ def test_build_hypermedia_with_href(app: FastAPI) -> None: def test_openapi_schema(hal_for_schema: Mapping[str, Any]) -> None: mock = MockClass(id_="test") schema = mock.model_json_schema() - link_set_definition = schema["$defs"]["LinkSet"]["additionalProperties"]["anyOf"] - hal_for_definition = next( - definition - for definition in link_set_definition - if definition.get("title") == "HALFor" - ) + hal_for_definition = schema["$defs"]["HALFor"] assert all(hal_for_definition.get(k) == v for k, v in hal_for_schema.items()) diff --git a/tests/test_linkset.py b/tests/test_linkset.py deleted file mode 100644 index 691fa32..0000000 --- a/tests/test_linkset.py +++ /dev/null @@ -1,124 +0,0 @@ -from typing import Any, Optional - -from fastapi import FastAPI -from pydantic import BaseModel, PrivateAttr -from typing_extensions import Self - -from fastapi_hypermodel import ( - AbstractHyperField, - HyperModel, - LinkSet, -) - - -class MockHypermediaType(BaseModel): - href: Optional[str] = None - - def __bool__(self: Self) -> bool: - return bool(self.href) - - -class MockHypermedia(MockHypermediaType, AbstractHyperField[MockHypermediaType]): - _href: Optional[str] = PrivateAttr() - - def __init__(self: Self, href: Optional[str] = None) -> None: - super().__init__() - self._href = href - - def __call__(self: Self, *_: Any) -> MockHypermediaType: - return MockHypermediaType(href=self._href) - - -class MockHypermediaEmpty(AbstractHyperField[MockHypermediaType]): - def __call__(self: Self, *_: Any) -> MockHypermediaType: - return MockHypermediaType() - - -class MockClassLinkSet(HyperModel): - test_field: LinkSet = LinkSet({ - "self": MockHypermedia("test"), - }) - - -class MockClassLinkSetEmpty(HyperModel): - test_field: LinkSet = LinkSet() - - -class MockClassLinkSetWithEmptyHypermedia(HyperModel): - test_field: LinkSet = LinkSet({ - "self": MockHypermedia("test"), - "other": MockHypermediaEmpty(), - }) - - -class MockClassLinkSetWithMultipleHypermedia(HyperModel): - test_field: LinkSet = LinkSet({ - "self": MockHypermedia("test"), - "other": [MockHypermedia("test"), MockHypermedia("test2")], - }) - - -def test_linkset_in_hypermodel() -> None: - linkset = MockClassLinkSet() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - assert test_field - - expected = {"self": {"href": "test"}} - assert test_field == expected - - -def test_linkset_in_hypermodel_with_link_list() -> None: - linkset = MockClassLinkSetWithMultipleHypermedia() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - assert test_field - - expected = { - "self": {"href": "test"}, - "other": [{"href": "test"}, {"href": "test2"}], - } - assert test_field == expected - - -def test_linkset_in_hypermodel_empty() -> None: - linkset = MockClassLinkSetEmpty() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - expected = {} - assert test_field == expected - - -def test_linkset_in_hypermodel_with_empty_hypermedia() -> None: - linkset = MockClassLinkSetWithEmptyHypermedia() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - assert test_field - - expected = {"self": {"href": "test"}} - assert test_field == expected - - -def test_linkset_schema() -> None: - linkset = MockClassLinkSet() - schema = linkset.model_json_schema()["$defs"]["LinkSet"] - - schema_type = schema["type"] - assert schema_type == "object" - - assert "properties" not in schema - assert "additionalProperties" in schema - - -def test_linkset_empty(app: FastAPI) -> None: - linkset = LinkSet() - hypermedia = linkset(app, {}) - assert hypermedia - assert hypermedia.mapping == {} - - -def test_linkset_empty_no_app() -> None: - linkset = LinkSet() - hypermedia = linkset(None, {}) - assert hypermedia - assert hypermedia.mapping == {} diff --git a/tests/test_siren.py b/tests/test_siren.py index 301a9c6..8b88669 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -54,6 +54,10 @@ def mock_read_with_path_siren_with_hypermodel( ) -> Any: # pragma: no cover return mock.model_dump() + @app.post("siren_with_post", response_class=SirenResponse) + def mock_read_with_path_siren_with_post() -> Any: # pragma: no cover + return {} + SirenHyperModel.init_app(app) return app @@ -281,6 +285,22 @@ def test_siren_action_for(siren_app: FastAPI) -> None: assert not siren_action_for_type.fields +def test_siren_action_for_with_non_get(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren_with_post", name="test" + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert isinstance(siren_action_for_type, SirenActionType) + assert siren_action_for_type.href == "siren_with_post" + assert siren_action_for_type.name == "test" + assert siren_action_for_type.method == "POST" + assert not siren_action_for_type.fields + + def test_siren_action_for_serialize(siren_app: FastAPI) -> None: mock = MockClass(id_="test") diff --git a/tests/test_url_for.py b/tests/test_url_for.py index aa11ab3..f952759 100644 --- a/tests/test_url_for.py +++ b/tests/test_url_for.py @@ -19,13 +19,14 @@ def test_build_hypermedia_with_endpoint(app: FastAPI, endpoint: str) -> None: sample_id = "test" url_for = UrlFor(endpoint, {"id_": ""}) uri = url_for(app, {"id_": sample_id}) + assert uri assert uri.hypermedia == f"/mock_read/{sample_id}" def test_build_hypermedia_no_app() -> None: url_for = UrlFor("mock_read_with_path", {"id_": ""}) uri = url_for(None, {}) - assert uri.hypermedia is None + assert uri is None def test_build_hypermedia_passing_condition(app: FastAPI) -> None: @@ -37,6 +38,7 @@ def test_build_hypermedia_passing_condition(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = url_for(app, {"id_": sample_id, "locked": locked}) + assert uri assert uri.hypermedia == f"/mock_read/{sample_id}" @@ -49,22 +51,23 @@ def test_build_hypermedia_not_passing_condition(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = url_for(app, {"id_": sample_id, "locked": locked}) - assert uri.hypermedia is None + assert uri is None def test_build_hypermedia_template(app: FastAPI) -> None: url_for = UrlFor( "mock_read_with_path", - template=True, + templated=True, ) uri = url_for(app, {}) + assert uri assert uri.hypermedia == "/mock_read/{id_}" def test_json_serialization(app: FastAPI) -> None: url_for = UrlFor( "mock_read_with_path", - template=True, + templated=True, ) rendered_url = url_for(app, {}) assert rendered_url @@ -76,7 +79,7 @@ def test_json_serialization(app: FastAPI) -> None: def test_json_serialization_no_build() -> None: url_for = UrlFor( "mock_read_with_path", - template=True, + templated=True, ) uri = url_for.model_dump() diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py index b095e6a..f71a693 100644 --- a/tests/test_utility_functions.py +++ b/tests/test_utility_functions.py @@ -8,7 +8,7 @@ HyperModel, InvalidAttribute, extract_value_by_name, - get_hal_link_href, + get_hal_link, get_route_from_app, resolve_param_values, ) @@ -110,17 +110,17 @@ def test_extract_value_by_name_invalid() -> None: def test_get_hal_link_href(hal_response: Any) -> None: - actual = get_hal_link_href(hal_response, "self") + actual = get_hal_link(hal_response, "self") expected = "/self" - assert actual == expected + assert actual + assert actual.href == expected def test_get_hal_link_href_not_found(hal_response: Any) -> None: - actual = get_hal_link_href(hal_response, "update") - expected = "" + actual = get_hal_link(hal_response, "update") - assert actual == expected + assert not actual class MockModel(HyperModel):