Skip to content

Commit

Permalink
Fix: ensure Ppydantic models are properly json-ified (#19)
Browse files Browse the repository at this point in the history
# Context

When building props, Pydantic models were converted to dict using
Pydantic's `model_dump` method, which converts the model to a dict.
When an Inertia call was made, a simple `JSONResponse` (FastAPI's one)
was returned, with the props.
However, JSONResponse does not handle some fields, such as `datetime`,
and would require either:
- Handling those prior to passing the models to the `render` method of
inertia
- Overriding the `render` method of inertia in order to use a custom
`JSONResponse` which would handle those cases

This is very unoptimal.

# Solution
- Instead of using Pydantic's `model_dump` method, use its
`model_dump_json` method which handles much more types, and use
`json.loads` to convert it back to a dict.

- Expose a `_render_json` method so it can be easily overridden, without
having to override the whole `render` method.
  • Loading branch information
hxjo authored May 31, 2024
1 parent 54d44f5 commit b6e2186
Show file tree
Hide file tree
Showing 9 changed files with 489 additions and 353 deletions.
Binary file added .coverage
Binary file not shown.
47 changes: 47 additions & 0 deletions .github/workflows/versioning.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Check Package Version

on:
push:
branches:
- '**'
- '!main'
pull_request:
branches:
- main

jobs:
check-version:
runs-on: ubuntu-latest

steps:
- name: Checkout current branch
uses: actions/checkout@v2
with:
ref: ${{ github.head_ref }}

- name: Get version from current branch
id: get_current_version
run: |
current_version=$(grep -Po '(?<=^version = ")[^"]*' pyproject.toml)
echo "current_version=$current_version" >> $GITHUB_ENV
- name: Checkout main branch
uses: actions/checkout@v2
with:
ref: main
path: main_branch

- name: Get version from main branch
id: get_main_version
run: |
main_version=$(grep -Po '(?<=^version = ")[^"]*' main_branch/pyproject.toml)
echo "main_version=$main_version" >> $GITHUB_ENV
- name: Compare versions
run: |
if [ "$current_version" = "$main_version" ]; then
echo "Version in pyproject.toml is the same as in the main branch."
exit 1
else
echo "Version in pyproject.toml is different from the main branch."
fi
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.4] - 2024-05-31

* Handle better JSONification of Pydantic models
* Use `json.loads(model.model_dump.json())` rather than `model.model_dump()`
To avoid issues with some common field types (UUID, datetime, etc.)
* Expose a `_render_json` method on inertia to allow easier overriding.

## [0.1.3] - 2024-05-08

* Bump FastAPI version from 0.110.2 to 0.111.0
Expand Down
23 changes: 15 additions & 8 deletions inertia/inertia.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def _deep_transform_callables(
if callable(prop):
return prop()
if isinstance(prop, BaseModel):
return prop.model_dump()
return json.loads(prop.model_dump_json())
return prop

prop_ = prop.copy()
Expand Down Expand Up @@ -253,6 +253,19 @@ async def _render_ssr(self) -> HTMLResponse:

return HTMLResponse(content=html_content, status_code=200)

def _render_json(self) -> JSONResponse:
"""
Render the page using JSON
:return: The JSON response
"""
return JSONResponse(
content=self._get_page_data(),
headers={
"Vary": "Accept",
"X-Inertia": "true",
},
)

def share(self, **props: Any) -> None:
"""
Share props between functions. Useful to share props between dependencies/middlewares and routes
Expand Down Expand Up @@ -329,13 +342,7 @@ async def render(
self._props.update(props or {})

if "X-Inertia" in self._request.headers:
return JSONResponse(
content=self._get_page_data(),
headers={
"Vary": "Accept",
"X-Inertia": "true",
},
)
return self._render_json()

if self._config.ssr_enabled:
try:
Expand Down
21 changes: 13 additions & 8 deletions inertia/tests/test_first_request_returns_html.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
import os
from datetime import datetime

from fastapi import FastAPI, Depends
from typing import Annotated
from typing import Annotated, cast

from starlette.testclient import TestClient

Expand Down Expand Up @@ -47,8 +49,11 @@
),
]

PROPS = {
"message": "hello from index",
PROPS = {"message": "hello from index", "created_at": datetime.now()}

EXPECTED_PROPS = {
**PROPS,
"created_at": cast(datetime, PROPS["created_at"]).isoformat(),
}

COMPONENT = "IndexPage"
Expand Down Expand Up @@ -88,7 +93,7 @@ def test_first_request_returns_html() -> None:
assert response.headers.get("content-type").split(";")[0] == "text/html"
expected_url = str(client.base_url) + "/"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT, props=PROPS, url=expected_url
component_name=COMPONENT, props=EXPECTED_PROPS, url=expected_url
)


Expand All @@ -101,7 +106,7 @@ def test_first_request_returns_html_custom_url() -> None:
script_asset_url = CUSTOM_URL + "/src/main.js"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT,
props=PROPS,
props=EXPECTED_PROPS,
url=expected_url,
script_asset_url=script_asset_url,
)
Expand All @@ -115,7 +120,7 @@ def test_first_request_returns_html_typescript() -> None:
expected_url = str(client.base_url) + "/typescript"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT,
props=PROPS,
props=EXPECTED_PROPS,
url=expected_url,
script_asset_url="http://localhost:5173/src/main.ts",
)
Expand All @@ -135,7 +140,7 @@ def test_first_request_returns_html_production() -> None:
expected_url = str(client.base_url) + "/production"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT,
props=PROPS,
props=EXPECTED_PROPS,
url=expected_url,
script_asset_url=js_file,
css_asset_url=css_file,
Expand All @@ -157,7 +162,7 @@ def test_first_request_returns_html_production_typescript() -> None:
expected_url = str(client.base_url) + "/typescript-production"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT,
props=PROPS,
props=EXPECTED_PROPS,
url=expected_url,
script_asset_url=js_file,
css_asset_url=css_file,
Expand Down
25 changes: 19 additions & 6 deletions inertia/tests/test_pydantic_basemodel_are_encoded.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from fastapi import FastAPI, Depends
from typing import Annotated, cast
from pydantic import BaseModel
Expand All @@ -16,13 +17,17 @@
class Person(BaseModel):
name: str
age: int
created_at: datetime


PROPS = {
"person": {"name": "John Doe", "age": 42, "created_at": datetime.now()},
}
EXPECTED_PROPS = {
"person": {
"name": "John Doe",
"age": 42,
},
**PROPS["person"],
"created_at": cast(datetime, PROPS["person"]["created_at"]).isoformat(),
}
}

COMPONENT = "IndexPage"
Expand All @@ -32,8 +37,16 @@ class Person(BaseModel):
async def index(inertia: InertiaDep) -> InertiaResponse:
name = PROPS["person"]["name"]
age = PROPS["person"]["age"]
created_at = PROPS["person"]["created_at"]
return await inertia.render(
COMPONENT, {"person": Person(name=cast(str, name), age=cast(int, age))}
COMPONENT,
{
"person": Person(
name=cast(str, name),
age=cast(int, age),
created_at=cast(datetime, created_at),
)
},
)


Expand All @@ -44,7 +57,7 @@ def test_pydantic_basemodel_are_encoded_on_json_response() -> None:
assert response.headers.get("content-type").split(";")[0] == "application/json"
assert response.json() == {
"component": COMPONENT,
"props": PROPS,
"props": EXPECTED_PROPS,
"url": f"{client.base_url}/",
"version": "1.0",
}
Expand All @@ -57,5 +70,5 @@ def test_pydantic_basemodel_are_encoded_on_html_response() -> None:
assert response.headers.get("content-type").split(";")[0] == "text/html"
expected_url = str(client.base_url) + "/"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT, props=PROPS, url=expected_url
component_name=COMPONENT, props=EXPECTED_PROPS, url=expected_url
)
18 changes: 11 additions & 7 deletions inertia/tests/test_ssr.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
from datetime import datetime
from unittest.mock import patch, MagicMock
from fastapi import FastAPI, Depends
from typing import Annotated, cast
Expand All @@ -24,8 +25,11 @@
)
),
]
PROPS = {
"message": "hello from index",
PROPS = {"message": "hello from index", "created_at": datetime.now()}

EXPECTED_PROPS = {
**PROPS,
"created_at": cast(datetime, PROPS["created_at"]).isoformat(),
}

COMPONENT = "IndexPage"
Expand All @@ -44,7 +48,7 @@ def test_calls_inertia_render(post_function: MagicMock) -> None:
f"{SSR_URL}/render",
json={
"component": COMPONENT,
"props": PROPS,
"props": EXPECTED_PROPS,
"url": f"{client.base_url}/",
"version": "1.0",
},
Expand All @@ -69,7 +73,7 @@ def test_returns_html(post_function: MagicMock) -> None:
f"{SSR_URL}/render",
json={
"component": COMPONENT,
"props": PROPS,
"props": EXPECTED_PROPS,
"url": f"{client.base_url}/",
"version": "1.0",
},
Expand All @@ -79,7 +83,7 @@ def test_returns_html(post_function: MagicMock) -> None:
assert response.headers.get("content-type").split(";")[0] == "text/html"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT,
props=PROPS,
props=EXPECTED_PROPS,
url=f"{client.base_url}/",
script_asset_url=js_file,
css_asset_url=css_file,
Expand All @@ -103,7 +107,7 @@ def test_fallback_to_classic_if_render_errors(post_function: MagicMock) -> None:
f"{SSR_URL}/render",
json={
"component": COMPONENT,
"props": PROPS,
"props": EXPECTED_PROPS,
"url": f"{client.base_url}/",
"version": "1.0",
},
Expand All @@ -113,7 +117,7 @@ def test_fallback_to_classic_if_render_errors(post_function: MagicMock) -> None:
assert response.headers.get("content-type").split(";")[0] == "text/html"
assert response.text.strip() == get_stripped_html(
component_name=COMPONENT,
props=PROPS,
props=EXPECTED_PROPS,
url=f"{client.base_url}/",
script_asset_url=js_file,
css_asset_url=css_file,
Expand Down
Loading

0 comments on commit b6e2186

Please sign in to comment.