Metadata-Version: 2.4
Name: django-ninja-spatial
Version: 0.9.0
Summary: GeoJSON serialization/deserialization of geospatial geometries for django-ninja (GeoDjango / GEOS).
Author: Marco Montanari
License: MIT
Project-URL: Homepage, https://github.com/marcomontanari/django-ninja-spatial
Keywords: django,django-ninja,geojson,geodjango,geos,gis,pydantic
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pydantic<3,>=2
Requires-Dist: typing-extensions>=4.6
Provides-Extra: ninja
Requires-Dist: django-ninja>=1.0; extra == "ninja"
Requires-Dist: Django>=4.2; extra == "ninja"
Provides-Extra: mvt
Requires-Dist: mapbox-vector-tile>=2; extra == "mvt"
Provides-Extra: dev
Requires-Dist: django-ninja>=1.0; extra == "dev"
Requires-Dist: Django>=4.2; extra == "dev"
Requires-Dist: mapbox-vector-tile>=2; extra == "dev"
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

<h1 align="center">django-ninja-spatial</h1>

<p align="center">
  <strong>GeoJSON &amp; Mapbox Vector Tile serialization for <a href="https://django-ninja.dev/">django-ninja</a> + GeoDjango.</strong><br>
  Turn <code>GEOSGeometry</code> model fields into clean GeoJSON APIs — and vector tiles — with almost no boilerplate.
</p>

<p align="center">
  <img alt="Python" src="https://img.shields.io/badge/python-3.8%2B-3776AB?logo=python&logoColor=white">
  <img alt="Django" src="https://img.shields.io/badge/django-4.2%2B-092E20?logo=django&logoColor=white">
  <img alt="django-ninja" src="https://img.shields.io/badge/django--ninja-1.0%2B-37709f">
  <img alt="pydantic" src="https://img.shields.io/badge/pydantic-v2-E92063?logo=pydantic&logoColor=white">
  <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green">
</p>

---

`django-ninja-spatial` makes your spatial models speak GeoJSON. Point your `ModelSchema`
at a model with GeoDjango geometry fields and they serialize to — and validate from —
[RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946) GeoJSON automatically:

```python
from ninja import NinjaAPI, ModelSchema
from ninja_spatial import register_geometry_fields
from places.models import Place                 # has location = models.PointField()

register_geometry_fields()                       # once, at startup

class PlaceSchema(ModelSchema):
    class Meta:
        model = Place
        fields = "__all__"
```

```jsonc
// GET /api/places/1  ->
{
  "id": 1,
  "name": "Duomo di Milano",
  "location": { "type": "Point", "coordinates": [9.1916, 45.4641] }
}
```

No serializers to hand-write, no `geom.json` plumbing, no GDAL round-trips.

## Features

- 🗺️ **All seven geometry types** — Point, LineString, Polygon, MultiPoint,
  MultiLineString, MultiPolygon, GeometryCollection, plus a generic *any-geometry* type.
- 🔄 **Bidirectional** — GeoJSON in (validated to `GEOSGeometry`) and GeoJSON out, in both
  `Schema` fields and `ModelSchema`.
- ✅ **Real validation** — RFC 7946 rules (position arity, `LineString` ≥ 2 points, closed
  linear rings) with precise pydantic errors and accurate **OpenAPI/Swagger** docs.
- 🧊 **Mapbox Vector Tiles** — encode/decode MVT and serve XYZ tiles with proper Web
  Mercator quantization.
- 🪶 **GDAL-free conversion** — works straight off GEOS, preserves **Z coordinates** and
  empty geometries.
- 🧩 **Layered & optional** — the pure-Pydantic GeoJSON schemas need *only* pydantic; the
  GeoDjango and MVT bits are opt-in extras.
- 🔢 **Pydantic v2 native**, typed (`py.typed`), tested across the whole matrix.

## Table of contents

- [Installation](#installation)
- [Quick start](#quick-start)
- [Field types](#field-types)
- [Usage](#usage)
  - [ModelSchema (the easy path)](#modelschema-the-easy-path)
  - [Explicit Schema fields](#explicit-schema-fields)
  - [Pure-Pydantic GeoJSON schemas](#pure-pydantic-geojson-schemas-no-geodjango)
  - [Conversion helpers](#conversion-helpers)
  - [Mapbox Vector Tiles](#mapbox-vector-tiles)
- [Example project](#example-project)
- [Compatibility](#compatibility)
- [How it works](#how-it-works)
- [Notes & limitations](#notes--limitations)
- [Contributing](#contributing)
- [License](#license)

## Installation

```bash
pip install django-ninja-spatial            # core (pydantic only)
pip install "django-ninja-spatial[ninja]"   # + Django & django-ninja (GeoDjango bridge)
pip install "django-ninja-spatial[mvt]"     # + Mapbox Vector Tile support (shapely)
```

GeoDjango itself (GEOS, and GDAL/PROJ for a spatial DB backend) must be available in your
environment as usual — see the
[GeoDjango install guide](https://docs.djangoproject.com/en/stable/ref/contrib/gis/install/).

## Quick start

Register the geometry field types once (e.g. in your app's `AppConfig.ready`):

```python
# apps.py
from django.apps import AppConfig
from ninja_spatial import register_geometry_fields

class PlacesConfig(AppConfig):
    name = "places"
    def ready(self):
        register_geometry_fields()
```

```python
# models.py
from django.contrib.gis.db import models

class Place(models.Model):
    name = models.CharField(max_length=100)
    location = models.PointField()
    area = models.PolygonField(null=True, blank=True)
```

```python
# api.py
from ninja import NinjaAPI, ModelSchema
from places.models import Place

api = NinjaAPI()

class PlaceSchema(ModelSchema):
    class Meta:
        model = Place
        fields = "__all__"

@api.get("/places/{place_id}", response=PlaceSchema)
def get_place(request, place_id: int):
    return Place.objects.get(pk=place_id)
```

That's it — geometry columns serialize to GeoJSON and the OpenAPI schema describes them.

## Field types

Use these annotated types (all importable from `ninja_spatial`) in any `Schema`. They
validate input GeoJSON/WKT into a `GEOSGeometry` and serialize a `GEOSGeometry` back to a
GeoJSON object.

| Field | Accepts / emits | GeoDjango model field it maps to |
|-------|-----------------|----------------------------------|
| `GeometryField` | any of the seven types | `GeometryField` |
| `PointField` | `Point` | `PointField` |
| `LineStringField` | `LineString` | `LineStringField` |
| `PolygonField` | `Polygon` | `PolygonField` |
| `MultiPointField` | `MultiPoint` | `MultiPointField` |
| `MultiLineStringField` | `MultiLineString` | `MultiLineStringField` |
| `MultiPolygonField` | `MultiPolygon` | `MultiPolygonField` |
| `GeometryCollectionField` | `GeometryCollection` | `GeometryCollectionField` |

Each accepts a GeoJSON object (dict), a GeoJSON / WKT / EWKT / HEXEWKB string, an existing
`GEOSGeometry`, or a `ninja_spatial.geojson` schema instance. Typed fields reject the wrong
geometry type with a `422`.

## Usage

### ModelSchema (the easy path)

After `register_geometry_fields()`, `ModelSchema` introspects GeoDjango geometry columns
automatically (see [Quick start](#quick-start)). Nothing else to do.

### Explicit Schema fields

Write schemas by hand when you want full control. On the way **in**, fields validate GeoJSON
and produce a real `GEOSGeometry`; on the way **out**, they serialize a `GEOSGeometry` to a
GeoJSON object.

```python
from typing import Optional
from ninja import NinjaAPI, Schema
from ninja_spatial import PointField, PolygonField, GeometryField

api = NinjaAPI()

class PlaceIn(Schema):
    name: str
    location: PointField                      # must be a Point
    area: Optional[PolygonField] = None       # nullable -> wrap in Optional
    anything: Optional[GeometryField] = None  # any of the 7 geometry types

@api.post("/places")
def create_place(request, payload: PlaceIn):
    # payload.location is a django.contrib.gis.geos.Point (srid 4326)
    place = Place.objects.create(name=payload.name, location=payload.location)
    return {"id": place.id}
```

> [!TIP]
> Declare nullable geometry fields as `Optional[...]` (e.g. `Optional[PointField] = None`).
> A bare `PointField = None` is *not* optional at the type level and will reject `null`.
>
> Need a non-default SRID? Use the factory: `geometry_field(geom_types=("Point",), srid=3857)`.

### Pure-Pydantic GeoJSON schemas (no GeoDjango)

The `ninja_spatial.geojson` module is plain pydantic v2 — use it anywhere, no GeoDjango
required:

```python
from ninja_spatial.geojson import Point, Geometry, parse_geometry

Point.model_validate({"type": "Point", "coordinates": [1.0, 2.0]})

# Geometry is a discriminated union over the "type" member ("generic geometry"):
geom = parse_geometry({"type": "LineString", "coordinates": [[0, 0], [1, 1]]})
type(geom).__name__   # "LineString"
```

```python
from ninja import Schema
from ninja_spatial.geojson import Geometry

class Shape(Schema):
    geometry: Geometry
```

### Conversion helpers

```python
from ninja_spatial import geos_to_geojson, geojson_to_geos

geom = geojson_to_geos({"type": "Point", "coordinates": [1, 2]})  # -> GEOSGeometry (srid 4326)
geos_to_geojson(geom)                                             # -> {"type": "Point", ...}
geos_to_geojson(geom, precision=6)                                # round coordinates
```

`geojson_to_geos(value, srid=...)` defaults to `srid=4326` (the GeoJSON CRS); pass `None` to
leave it unset. The schemas also expose `schema.to_geos(srid=...)` and
`Model.from_geos(geom, precision=...)`.

### Mapbox Vector Tiles

Install the extra (it brings in `shapely`): `pip install "django-ninja-spatial[mvt]"`.

Serve an XYZ tile — `encode_tile` reprojects your geometries (default SRID 4326) to Web
Mercator and quantizes them to the tile:

```python
from django.http import HttpResponse
from ninja_spatial import mvt
from places.models import Place

@api.get("/tiles/{int:z}/{int:x}/{int:y}")
def tile(request, z: int, x: int, y: int):
    minx, miny, maxx, maxy = mvt.tile_bounds(z, x, y)        # EPSG:3857
    rows = Place.objects.all()                               # filter to the bbox in prod
    data = mvt.encode_tile(
        [{"name": "places",
          "features": [{"geometry": p.location,
                        "properties": {"id": p.id, "name": p.name}} for p in rows]}],
        z, x, y,
    )
    return HttpResponse(data, content_type="application/vnd.mapbox-vector-tile")
```

Lower level (geometries already in tile/local coordinates, no GeoDjango needed):

```python
tile = mvt.encode_layer(
    "places",
    [{"geometry": {"type": "Point", "coordinates": [50, 50]}, "properties": {"id": 1}}],
    quantize_bounds=(0, 0, 100, 100), extent=4096,
)
mvt.decode(tile)   # {"places": {"features": [...]}}
```

Geometries accept the same forms as everywhere else. MVT covers Point/LineString/Polygon and
their Multi variants; `GeometryCollection` is not an MVT geometry.

## Example project

A complete, runnable GeoDjango + django-ninja app lives in [`example/`](example/): GeoJSON
CRUD, bounding-box and nearest-neighbour queries, point-in-polygon, and a vector-tile
endpoint — all backed by **SpatiaLite** (no PostGIS server needed).

```bash
cd example
pip install -r requirements.txt
python manage.py migrate && python manage.py seed
python manage.py runserver        # interactive docs at http://127.0.0.1:8000/api/docs
```

## Compatibility

| | Supported |
|--|-----------|
| Python | 3.8 – 3.12 |
| Django | 4.2 – 5.x |
| django-ninja | ≥ 1.0 |
| pydantic | 2.x |
| Databases | any GeoDjango spatial backend (PostGIS, SpatiaLite, …) |

The pure GeoJSON schemas (`ninja_spatial.geojson`) require only pydantic — GeoDjango and the
MVT extra are optional.

## How it works

- **Wire format** — `ninja_spatial.geojson` holds pydantic v2 models for each geometry type
  plus a `type`-discriminated `Geometry` union, validated per RFC 7946.
- **The GEOS bridge** — `ninja_spatial.fields` exposes annotated types whose
  `__get_pydantic_core_schema__` validates input into a `GEOSGeometry` and serializes it back
  to GeoJSON, while `__get_pydantic_json_schema__` keeps the OpenAPI docs accurate.
- **ModelSchema** — `register_geometry_fields()` registers each GeoDjango field's
  `get_internal_type()` with django-ninja's ORM type registry, so introspection just works.
- **Conversions** are written directly against GEOS constructors and `.tuple` — no GDAL —
  which preserves Z and empty geometries.

## Notes & limitations

- GeoJSON's coordinate space is WGS 84 (lon, lat[, alt]). If your column uses another SRID,
  set it with `geometry_field(srid=...)` or reproject before assignment.
- GEOS (and RFC 7946) discourage **nested** `GeometryCollection`s; converting one to GEOS
  raises a clear error.
- `RasterField` is out of scope (rasters are not GeoJSON geometries).
- `Feature` / `FeatureCollection` wrappers are out of scope; this library covers geometry
  objects (which is what model fields hold).

## Releasing

Releases are published to PyPI automatically by
[`.github/workflows/publish.yml`](.github/workflows/publish.yml) using
[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC — no API
tokens stored in the repo).

**One-time setup**

1. On [pypi.org](https://pypi.org/manage/account/publishing/) (and
   [test.pypi.org](https://test.pypi.org/manage/account/publishing/)) add a *pending*
   Trusted Publisher: owner `sirmmo`, repo `django-ninja-spatial`, workflow
   `publish.yml`, environment `pypi` (and `testpypi`).
2. Create the matching GitHub
   [Environments](https://github.com/sirmmo/django-ninja-spatial/settings/environments)
   named `pypi` and `testpypi`.

**Cutting a release**

1. Bump `version` in `pyproject.toml`.
2. Push, then publish a GitHub Release with tag `vX.Y.Z` (the workflow checks the tag
   matches the version) → it builds, `twine check`s, and uploads to PyPI.
3. To rehearse, run the workflow manually (*Actions → Publish to PyPI → Run workflow*)
   with target `testpypi`.

## Contributing

Issues and PRs are welcome! To set up locally:

```bash
git clone https://github.com/sirmmo/django-ninja-spatial
cd django-ninja-spatial
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest                       # ~145 tests, no spatial DB required
```

The suite configures Django in `tests/conftest.py` and runs entirely in memory.

## License

[MIT](LICENSE) © Marco Montanari
