diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 1c3f2ab..48d4d1a 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -15,13 +15,15 @@ jobs:
uses: astral-sh/setup-uv@v6
with:
python-version: "3.10"
- - run: uv sync
+ - run: uv sync --extra docs
- name: Lint with Ruff
run: uv run ./lint
- name: Test with pytest
run: uv run ./test
- name: Typecheck with ty
run: uv run ./typecheck
+ - name: Build docs (MkDocs)
+ run: uv run ./docs-build
- name: Check project version
uses: maybe-hello-world/pyproject-check-version@v4
id: versioncheck
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 3f7b31d..caf3305 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -5,8 +5,12 @@ build:
tools:
python: "3.10"
-sphinx:
- configuration: docs/conf.py
+mkdocs:
+ configuration: mkdocs.yml
-formats:
- - pdf
+python:
+ install:
+ - method: pip
+ path: .
+ extra_requirements:
+ - docs
diff --git a/README.md b/README.md
index 972ebec..731c751 100644
--- a/README.md
+++ b/README.md
@@ -4,14 +4,16 @@


+**Documentation:** [pydanja.readthedocs.io](https://pydanja.readthedocs.io/en/latest/)
+
# PyDANJA
**PyDAN**tic **J**SON**A**PI
-[JSON:API (or JSONAPI)](https://jsonapi.org/format/) Suport for [Pydantic](https://docs.pydantic.dev/latest/)
+[JSON:API (or JSONAPI)](https://jsonapi.org/format/) support for [Pydantic](https://docs.pydantic.dev/latest/)
-Output [JSONAPI](https://jsonapi.org/format/) from your [FastAPI](https://fastapi.tiangolo.com/) or [PyDantic](https://docs.pydantic.dev/latest/) based application with very little code.
+Output [JSONAPI](https://jsonapi.org/format/) from your [FastAPI](https://fastapi.tiangolo.com/) or [Pydantic](https://docs.pydantic.dev/latest/) based application with very little code.
This is a series of classes that can be included into your [Pydantic](https://docs.pydantic.dev/latest/) project that act as a container format for outputting and verifying [JSON:API](https://jsonapi.org/format/) compliant content.
@@ -25,11 +27,44 @@ This library makes use of BaseModel generics to contain either a single resource
This will support the oldest non-EOL Python (3.10 as of the writing of this document)
+## Quickstart
+
+Import the core types and build JSON:API responses from your Pydantic models.
+
+```python
+from pydantic import BaseModel, Field
+from pydanja import DANJAResource, DANJAResourceList
+```
+
+## API Reference
+
+### Primary containers
+
+- `DANJAResource[T]` - single-resource JSON:API document (`data` is one resource)
+- `DANJAResourceList[T]` - collection JSON:API document (`data` is a list of resources)
+- `DANJASingleResource[T]` - internal resource object used in `data`/`included`
+- `DANJAError` and `DANJAErrorList` - JSON:API error payloads
+- `DANJALink`, `DANJARelationship`, `DANJAResourceIdentifier`, `DANJASource` - JSON:API support types
+
+### Helper methods
+
+- `DANJAResource.from_basemodel(resource, resource_name=None, resource_id=None)`
+ - wraps a `BaseModel` as JSON:API
+ - auto-resolves resource type and id field when not provided
+- `DANJAResourceList.from_basemodel_list(resources, resource_name=None, resource_id=None)`
+ - wraps a list of `BaseModel` instances as JSON:API
+- `include_from_basemodels(includes)`
+ - attaches related resources in `included`
+- `resource` and `resources` properties
+ - return the original wrapped model(s)
+- `danja_openapi(schema)`
+ - rewrites generated OpenAPI schema names to cleaner JSON:API model names
+
## Usage
With pydantic
-```
+```python
from pydanja import DANJAResource
@@ -60,7 +95,7 @@ resource = resource_container.resource
This basic example shows a [Pydantic](https://docs.pydantic.dev/latest/) BaseModel being contained within a `DANJAResource` object. The `model_dump_json` will output [JSON:API](https://jsonapi.org/format/):
-```
+```json
{
"data": {
"id": "1",
@@ -83,9 +118,39 @@ This basic example shows a [Pydantic](https://docs.pydantic.dev/latest/) BaseMod
Note that all [JSON:API](https://jsonapi.org/format/) fields are included in the output of the model dump. If you are using an API framework like [FastAPI](https://fastapi.tiangolo.com/), you use the `response_model_exclude_none` to suppress fields with no values.
-### FastAPI example
+### Pydantic v2 notes
+
+`DANJAResource` and `DANJAResourceList` use wrap validators internally. On current releases this is compatible with Pydantic v2 validation semantics (the validators return the validated model instance), so you should not see the warning:
+`A custom validator is returning a value other than self`
+
+`included` resources are intentionally excluded from generic type validation so a response can include related resource types that differ from the top-level `data` resource type.
+
+### More examples
+
+Use an explicit resource type/id field (if auto detection is not desired):
+
+```python
+resource = DANJAResource.from_basemodel(
+ my_model,
+ resource_name="articles",
+ resource_id="article_id",
+)
+```
+
+Add `included` resources:
+
+```python
+response = DANJAResource.from_basemodel(article)
+response.include_from_basemodels([
+ {"type": "people", "id": "1", "attributes": {"name": "Ada"}},
+ {"type": "comments", "id": "99", "attributes": {"body": "Nice post"}},
+])
```
+
+### FastAPI example
+
+```python
from typing import Optional, Union
from pydantic import BaseModel, Field, ConfigDict
from fastapi import FastAPI
@@ -155,14 +220,7 @@ async def test_get() -> Union[DANJAResourceList[TestType], DANJAError]:
return DANJAResourceList.from_basemodel_list(values)
```
-This library supports:
-
-* Single resources (`DANJAResource`)
-* Lists of resources (`DANJAResourceList`)
-* Error objects (`DANJAErrorList`/`DANJAError`)
-* Link objects (`DANJALink`)
-
-There are more examples, including [FastAPI](https://fastapi.tiangolo.com/) code in the `src/examples` directory.
+There are more runnable examples, including [FastAPI](https://fastapi.tiangolo.com/) usage, in `src/examples`.
### Contributing
@@ -183,3 +241,9 @@ These can be run through `uv` by using:
* `uv run ./test`
* `uv run ./typecheck`
* `uv run ./all`
+
+Documentation site (MkDocs Material) — install extras and preview locally:
+
+* `uv sync --extra docs`
+* `uv run mkdocs serve`
+* `uv run ./docs-build` (static output in `site/`)
diff --git a/docs-build b/docs-build
new file mode 100755
index 0000000..f82b4ad
--- /dev/null
+++ b/docs-build
@@ -0,0 +1,4 @@
+#!/bin/bash
+set -euo pipefail
+uv sync --extra docs
+mkdocs build --strict "$@"
diff --git a/docs/mkdocs/api-reference.md b/docs/mkdocs/api-reference.md
new file mode 100644
index 0000000..1fecde3
--- /dev/null
+++ b/docs/mkdocs/api-reference.md
@@ -0,0 +1,20 @@
+# API reference
+
+This page documents the public surface of the `pydanja` package (same symbols as `from pydanja import ...`).
+
+::: pydanja
+ options:
+ members:
+ - DANJALink
+ - DANJASource
+ - DANJAResourceIdentifier
+ - DANJARelationship
+ - DANJAError
+ - DANJAErrorList
+ - DANJASingleResource
+ - DANJAResource
+ - DANJAResourceList
+ - danja_openapi
+ show_category_heading: true
+ show_root_heading: true
+ heading_level: 2
diff --git a/docs/mkdocs/contributing.md b/docs/mkdocs/contributing.md
new file mode 100644
index 0000000..6d10cfb
--- /dev/null
+++ b/docs/mkdocs/contributing.md
@@ -0,0 +1,45 @@
+# Contributing
+
+The project uses [uv](https://github.com/astral-sh/uv) for dependencies and local workflows.
+
+## Checks
+
+From the repository root:
+
+```bash
+uv sync
+uv run ./lint
+uv run ./test
+uv run ./typecheck
+```
+
+Or run everything:
+
+```bash
+uv run ./all
+```
+
+## Documentation site (this site)
+
+Install documentation dependencies and serve locally:
+
+```bash
+uv sync --extra docs
+uv run mkdocs serve
+```
+
+Build a static site (same command CI uses):
+
+```bash
+uv run ./docs-build
+```
+
+The built output is written to `site/` (gitignored).
+
+## Hosting
+
+Published docs are intended for [Read the Docs](https://readthedocs.org/) using `mkdocs.yml` (see `.readthedocs.yaml`). Maintainers can alternatively deploy the `site/` output to GitHub Pages or another static host.
+
+## Legacy Sphinx files
+
+The `docs/conf.py` tree is an older Sphinx scaffold kept in-repo for reference; the canonical doc build for Read the Docs is MkDocs as configured in `mkdocs.yml`.
diff --git a/docs/mkdocs/fastapi.md b/docs/mkdocs/fastapi.md
new file mode 100644
index 0000000..f4534d2
--- /dev/null
+++ b/docs/mkdocs/fastapi.md
@@ -0,0 +1,15 @@
+# FastAPI
+
+PyDANJA types work well as FastAPI `response_model` / body types: annotate handlers with `DANJAResource[YourModel]` or `DANJAResourceList[YourModel]` so inbound JSON:API payloads are validated.
+
+## Response shaping
+
+Use FastAPI’s model exclusion helpers so empty JSON:API members do not clutter responses, for example `response_model_exclude_none=True` on routes.
+
+## OpenAPI schema cleanup
+
+Large generic models can make OpenAPI noisy. The library provides `danja_openapi` to simplify schema names for DANJA-related components after you build the base OpenAPI dict (see the project README and `src/examples/fastapi.py`).
+
+## Example code
+
+Runnable examples live under `src/examples/` in the repository — start with `fastapi.py` alongside `basic.py` for non-framework usage.
diff --git a/docs/mkdocs/getting-started.md b/docs/mkdocs/getting-started.md
new file mode 100644
index 0000000..82df6b1
--- /dev/null
+++ b/docs/mkdocs/getting-started.md
@@ -0,0 +1,46 @@
+# Getting started
+
+## Installation
+
+```bash
+pip install pydanja
+```
+
+Requires **Python 3.10+** and **Pydantic v2** (see `pyproject.toml` on the repository for the declared minimum versions).
+
+## Minimal example
+
+Define a Pydantic model, mark which field should map to the JSON:API resource id (via `json_schema_extra`), then wrap it with `DANJAResource`:
+
+```python
+from typing import Optional
+
+from pydantic import BaseModel, Field
+from pydanja import DANJAResource
+
+
+class Article(BaseModel):
+ article_id: Optional[int] = Field(
+ alias="id",
+ default=None,
+ json_schema_extra={"resource_id": True},
+ )
+ title: str
+
+
+doc = DANJAResource.from_basemodel(
+ Article(id=1, title="Hello JSON:API"),
+)
+print(doc.model_dump_json(indent=2))
+```
+
+You can read the wrapped model back via `doc.resource`.
+
+## Lists
+
+Use `DANJAResourceList.from_basemodel_list([...])` for collection documents. Access inner models with `.resources`.
+
+## Related reading
+
+- [API reference](api-reference.md) — all exported types and `danja_openapi`
+- [FastAPI](fastapi.md) — response models and OpenAPI helpers
diff --git a/docs/mkdocs/index.md b/docs/mkdocs/index.md
new file mode 100644
index 0000000..be4b6e5
--- /dev/null
+++ b/docs/mkdocs/index.md
@@ -0,0 +1,14 @@
+# PyDANJA
+
+**PyDAN**tic **J**SON**A**PI — lightweight [JSON:API](https://jsonapi.org/format/)-shaped containers for [Pydantic](https://docs.pydantic.dev/latest/) models, with optional helpers for [FastAPI](https://fastapi.tiangolo.com/) and OpenAPI cleanup.
+
+Use this library when you want JSON:API-style request and response documents without committing your entire stack to a single framework-specific JSON:API server implementation.
+
+## Where to go next
+
+- [Getting started](getting-started.md) — install and your first resource document
+- [API reference](api-reference.md) — public types and helpers
+- [FastAPI](fastapi.md) — routing and OpenAPI integration patterns
+- [Contributing](contributing.md) — build these docs locally
+
+The package README on GitHub and PyPI stays the quick overview; this site is the place for structured guides and API detail.
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..c597902
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,62 @@
+# MkDocs Material — https://squidfunk.github.io/mkdocs-material/
+site_name: PyDANJA
+site_description: JSON:API support for Pydantic
+site_url: https://pydanja.readthedocs.io/
+docs_dir: docs/mkdocs
+
+repo_url: https://github.com/Centurix/pydanja
+repo_name: Centurix/pydanja
+edit_uri: edit/master/docs/mkdocs/
+
+strict: true
+
+theme:
+ name: material
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+ features:
+ - navigation.instant
+ - navigation.tracking
+ - content.code.copy
+
+plugins:
+ - search
+ - mkdocstrings:
+ handlers:
+ python:
+ paths: [src]
+ options:
+ docstring_style: google
+ members_order: source
+ show_root_heading: true
+ show_symbol_type_heading: true
+ show_signature_annotations: true
+
+markdown_extensions:
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ - pymdownx.snippets
+ - pymdownx.superfences
+
+nav:
+ - Home: index.md
+ - Getting started: getting-started.md
+ - API reference: api-reference.md
+ - FastAPI: fastapi.md
+ - Contributing: contributing.md
diff --git a/pyproject.toml b/pyproject.toml
index 3c04771..3b847e6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,12 @@ classifiers = [
"Topic :: Software Development :: Libraries"
]
+[project.optional-dependencies]
+docs = [
+ "mkdocs-material>=9.5.0",
+ "mkdocstrings[python]>=0.26.0",
+]
+
[project.urls]
homepage = "https://github.com/Centurix/pydanja"
repository = "https://github.com/Centurix/pydanja"
diff --git a/src/pydanja/__init__.py b/src/pydanja/__init__.py
index 84e381e..d900690 100644
--- a/src/pydanja/__init__.py
+++ b/src/pydanja/__init__.py
@@ -18,6 +18,30 @@
]
ResourceType = TypeVar("ResourceType")
+ModelType = TypeVar("ModelType", bound=BaseModel)
+
+
+def _validate_ignoring_included(data: Any, handler: ModelWrapValidatorHandler[ModelType]) -> ModelType:
+ """
+ Validate a resource container while bypassing validation for `included`.
+ """
+ data_copy = deepcopy(data)
+ included = None
+
+ # dict payloads (e.g. DANJAResource(...))
+ if isinstance(data_copy, dict):
+ included = data_copy.pop("included", None)
+ # model payloads (e.g. DANJAResource.model_validate(existing_model))
+ elif hasattr(data_copy, "included"):
+ included = getattr(data_copy, "included")
+ delattr(data_copy, "included")
+
+ validated = handler(data_copy)
+
+ if included is not None:
+ setattr(validated, "included", included)
+
+ return validated
class DANJALink(BaseModel):
@@ -105,7 +129,7 @@ def resolve_resource_name(cls, resource) -> str:
@classmethod
def resolve_resource_id(cls, resource) -> Optional[str]:
- for field_name, field in resource.model_fields.items():
+ for field_name, field in resource.__class__.model_fields.items():
if (
hasattr(field, "primary_key") and isinstance(field.primary_key, bool) and field.primary_key
): # Latest SQLMode
@@ -170,7 +194,7 @@ def include_from_basemodels(self, includes: list[Any]) -> None:
self.included = []
for include in includes:
# Convert these to resource types
- self.included.append(DANJASingleResource(**include)) # ty: ignore
+ self.included.append(DANJASingleResource(**include))
@model_validator(mode="wrap")
@classmethod
@@ -181,14 +205,7 @@ def ignore_included(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) ->
resource type as the top level data block. So in the meantime, we exclude `included` resources
from the validation process.
"""
- data_copy = deepcopy(data)
-
- # Exclude included resource types
- if hasattr(data_copy, "included"):
- delattr(data_copy, "included")
-
- handler(data_copy)
- return data
+ return _validate_ignoring_included(data, handler)
class DANJAResourceList(BaseModel, ResourceResolver, Generic[ResourceType]):
@@ -246,7 +263,7 @@ def include_from_basemodels(self, includes: list[Any]) -> None:
self.included = []
for include in includes:
# Convert these to resource types
- self.included.append(DANJASingleResource(**include)) # ty: ignore
+ self.included.append(DANJASingleResource(**include))
@model_validator(mode="wrap")
@classmethod
@@ -257,11 +274,4 @@ def ignore_included(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) ->
resource type as the top level data block. So in the meantime, we exclude `included` resources
from the validation process.
"""
- data_copy = deepcopy(data)
-
- # Exclude included resource types
- if hasattr(data_copy, "included"):
- delattr(data_copy, "included")
-
- handler(data_copy)
- return data
+ return _validate_ignoring_included(data, handler)
diff --git a/tests/test_resource_creation.py b/tests/test_resource_creation.py
index 5b2d006..1e371b4 100644
--- a/tests/test_resource_creation.py
+++ b/tests/test_resource_creation.py
@@ -1,5 +1,6 @@
import pytest
import json
+import warnings
from typing import Optional
from pathlib import Path
from src.pydanja import DANJAResource, DANJAResourceList
@@ -137,3 +138,58 @@ def test_it_creates_a_container_list_from_base_model_without_id(resource):
# Check resource data
assert(len(new_resources.resources) == len(basemodel_instances))
+
+
+def test_it_does_not_warn_for_single_resource_with_included():
+ payload = {
+ "data": {
+ "id": "1",
+ "type": "fixturetesttype",
+ "attributes": {"id": 1, "name": "Stuff!", "description": "This is desc!"},
+ },
+ "included": [
+ {
+ "id": "200",
+ "type": "other",
+ "attributes": {"name": "Included model"},
+ }
+ ],
+ }
+
+ with warnings.catch_warnings(record=True) as warning_records:
+ warnings.simplefilter("always")
+ resource = DANJAResource[FixtureTestType](**payload)
+
+ assert resource.included == payload["included"]
+ assert not any("Returning anything other than `self`" in str(w.message) for w in warning_records)
+
+
+def test_it_does_not_warn_for_resource_list_with_included():
+ payload = {
+ "data": [
+ {
+ "id": "1",
+ "type": "fixturetesttype",
+ "attributes": {"id": 1, "name": "Stuff!", "description": "This is desc!"},
+ },
+ {
+ "id": "2",
+ "type": "fixturetesttype",
+ "attributes": {"id": 2, "name": "More Stuff!", "description": "This is more desc!"},
+ },
+ ],
+ "included": [
+ {
+ "id": "200",
+ "type": "other",
+ "attributes": {"name": "Included model"},
+ }
+ ],
+ }
+
+ with warnings.catch_warnings(record=True) as warning_records:
+ warnings.simplefilter("always")
+ resource_list = DANJAResourceList[FixtureTestType](**payload)
+
+ assert resource_list.included == payload["included"]
+ assert not any("Returning anything other than `self`" in str(w.message) for w in warning_records)