Metadata-Version: 2.4
Name: django-ninja-spatial
Version: 0.9.1
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

# django-ninja-spatial

GeoJSON and Mapbox Vector Tile serialization for [django-ninja](https://django-ninja.dev/) with GeoDjango.

![Python](https://img.shields.io/badge/python-3.8%2B-3776AB?logo=python&logoColor=white)
![Django](https://img.shields.io/badge/django-4.2%2B-092E20?logo=django&logoColor=white)
![django-ninja](https://img.shields.io/badge/django--ninja-1.0%2B-37709f)
![pydantic](https://img.shields.io/badge/pydantic-v2-E92063?logo=pydantic&logoColor=white)
![License: MIT](https://img.shields.io/badge/license-MIT-green)

It serializes GeoDjango geometry fields to [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946)
GeoJSON and validates GeoJSON back into `GEOSGeometry`, both in hand-written `Schema`
classes and in `ModelSchema`. Point a `ModelSchema` at a model with geometry fields and
they round-trip as GeoJSON:

```python
from ninja import NinjaAPI, ModelSchema
from ninja_spatial import register_geometry_fields
from places.models import Place                 # 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] }
}
```

## Features

- Covers all seven GeoJSON geometry types (Point, LineString, Polygon, MultiPoint,
  MultiLineString, MultiPolygon, GeometryCollection) plus a generic geometry type that
  accepts any of them.
- Works in both directions: GeoJSON requests are validated into `GEOSGeometry`, and
  geometry fields are serialized to GeoJSON in responses.
- Validates per RFC 7946 (position length, `LineString` minimum points, closed linear
  rings), so you get useful pydantic errors and an accurate OpenAPI schema.
- Encodes and decodes Mapbox Vector Tiles, with helpers for XYZ tiling.
- Converts between GEOS and GeoJSON without going through GDAL, which keeps Z coordinates
  and empty geometries intact.
- The GeoJSON schemas depend only on pydantic. GeoDjango and MVT support are optional.
- Built on pydantic v2 and ships type hints (`py.typed`).

## Table of contents

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

## Installation

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

You also need GeoDjango itself (GEOS, plus GDAL/PROJ for a spatial database backend). See
the [GeoDjango install guide](https://docs.djangoproject.com/en/stable/ref/contrib/gis/install/).

## Quick start

Register the geometry field types once, for example 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)
```

Geometry columns now serialize to GeoJSON, and the OpenAPI schema describes them.

## Field types

These annotated types (importable from `ninja_spatial`) go in any `Schema`. They validate
input GeoJSON or 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 one accepts a GeoJSON object (dict), a GeoJSON / WKT / EWKT / HEXEWKB string, an
existing `GEOSGeometry`, or a `ninja_spatial.geojson` schema instance. A typed field
returns a `422` when given the wrong geometry type.

## Usage

### ModelSchema

After calling `register_geometry_fields()`, `ModelSchema` introspects GeoDjango geometry
columns on its own (see [Quick start](#quick-start)). There is nothing else to wire up.

### Explicit Schema fields

Write schemas by hand when you want full control. On input, fields validate GeoJSON and
produce a `GEOSGeometry`; on output, 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, so wrap in Optional
    anything: Optional[GeometryField] = None  # any of the seven 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}
```

Declare nullable geometry fields as `Optional[...]` (for example
`Optional[PointField] = None`). A bare `PointField = None` is not optional at the type
level and will reject `null`. For a non-default SRID, use the factory:
`geometry_field(geom_types=("Point",), srid=3857)`.

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

The `ninja_spatial.geojson` module is plain pydantic v2 and needs no GeoDjango:

```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 (the generic geometry type):
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 provide `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]"`.

`encode_tile` reprojects your geometries (SRID 4326 by default) to Web Mercator and
quantizes them to the requested 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")
```

For geometries that are already in tile or local coordinates you can encode directly, with
no GeoDjango involved:

```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 elsewhere. MVT covers Point, LineString, Polygon and
their Multi variants; `GeometryCollection` is not an MVT geometry.

## Example project

There is a complete GeoDjango and django-ninja app in [`example/`](example/): GeoJSON CRUD,
bounding-box and nearest-neighbour queries, point-in-polygon, and a vector-tile endpoint,
all running on SpatiaLite so it needs no PostGIS server.

```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 to 3.12 |
| Django | 4.2 to 5.x |
| django-ninja | 1.0+ |
| pydantic | 2.x |
| Databases | any GeoDjango spatial backend (PostGIS, SpatiaLite, and so on) |

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

## How it works

The `ninja_spatial.geojson` module holds a pydantic v2 model for each geometry type and a
`type`-discriminated `Geometry` union, validated against RFC 7946. The `ninja_spatial.fields`
module 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 documentation accurate. `register_geometry_fields()` registers each GeoDjango
field's `get_internal_type()` with django-ninja's ORM type registry so `ModelSchema`
introspection picks them up. The GEOS-to-GeoJSON conversion is written against GEOS
constructors and `.tuple` rather than GDAL, which is what preserves Z and empty geometries.

## Notes and limitations

- GeoJSON coordinates are WGS 84 (lon, lat, optional altitude). If a column uses another
  SRID, set it with `geometry_field(srid=...)` or reproject before assigning.
- GEOS does not support nested `GeometryCollection`s (RFC 7946 discourages them too), so
  converting one to GEOS raises an error.
- `RasterField` is out of scope, since rasters are not GeoJSON geometries.
- `Feature` and `FeatureCollection` wrappers are out of scope; this library handles geometry
  objects, which is what model fields store.

## Releasing

Releases go to PyPI through [`.github/workflows/publish.yml`](.github/workflows/publish.yml)
using [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so no API tokens
are 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 Trusted
   Publisher for owner `sirmmo`, repo `django-ninja-spatial`, workflow `publish.yml`,
   environment `pypi` (and `testpypi`).
2. Create the matching GitHub Environments named `pypi` and `testpypi`.

To cut a release, bump `version` in `pyproject.toml`, push, and publish a GitHub Release
tagged `vX.Y.Z` (the workflow checks the tag against the version). To rehearse against
TestPyPI, run the workflow manually with the target set to `testpypi`.

## Contributing

Issues and pull requests are welcome.

```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
```

The test suite configures Django in `tests/conftest.py` and runs in memory, with no spatial
database required.

## License

[MIT](LICENSE) © Marco Montanari
