Skip to content

Commit 079ad24

Browse files
committed
Add an example of injected plugin context for data validation
1 parent 8e4f144 commit 079ad24

File tree

1 file changed

+210
-0
lines changed

1 file changed

+210
-0
lines changed

docs/source/plugins.rst

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,216 @@ The below template provides a minimal example (let's call the file ``mycooljsonf
502502
return out_data
503503
504504
505+
Example: transaction validation with PluginContext
506+
--------------------------------------------------
507+
508+
pygeoapi serves the schema of each collection at ``/collections/{id}/schema``, but
509+
when a transaction (create/update) comes in, providers write the data without validating
510+
it against that schema.
511+
512+
If you want to fill this gap then you should write a dedicated plugin, i.e. a
513+
``ValidatedGeoJSONProvider`` plugin for the ``GeoJSON`` provider. It reads the provider's
514+
JSON Schema (from ``get_fields()``) and builds a validator that checks incoming features
515+
on ``create()`` and ``update()``. In python there are plenty of data validation libraries
516+
but pygeoapi aims at being dependency least so at first glance the provider code should be
517+
**technology-agnostic**: it calls the validator's interface without knowing whether the
518+
validator is implemented with dataclasses, pydantic, or any other library.
519+
520+
The bare minimal, without injecting any ``PluginContext``, is to build a new provider
521+
(or add the validation logic to the existing one) using the standard python and the
522+
existing dependencies to validate from its own fields.
523+
With ``PluginContext``, a downstream project can inject a different validator — for
524+
example one built with pydantic that adds stricter constraints — without changing the
525+
provider code.
526+
527+
The provider plugin
528+
^^^^^^^^^^^^^^^^^^^
529+
530+
The provider resolves its validator in this order:
531+
532+
1. ``context.feature_validator`` if a ``ValidatingContext`` is injected
533+
2. A validator built from the provider's own JSON Schema (default fallback)
534+
535+
The only interface the provider expects is that the validator is callable with
536+
``**properties`` as keyword arguments and raises on invalid data. This makes the
537+
provider invariant to the validation technology used.
538+
539+
.. code-block:: python
540+
541+
from pygeoapi.provider.geojson import GeoJSONProvider
542+
543+
544+
class ValidatedGeoJSONProvider(GeoJSONProvider):
545+
"""GeoJSON provider with transaction validation."""
546+
547+
def __init__(self, provider_def, context: Optional[PluginContext] = None):
548+
super().__init__(provider_def, context)
549+
550+
# Resolve: injected validator or auto-built default
551+
if (context and hasattr(context, 'feature_validator')
552+
and context.feature_validator is not None):
553+
self._feature_validator = context.feature_validator
554+
else:
555+
self._feature_validator = build_feature_validator(
556+
self.fields
557+
)
558+
559+
def _validate_feature(self, feature):
560+
"""Validate feature properties.
561+
562+
The validator is called with **properties.
563+
It may be a dataclass, a pydantic model, or any
564+
callable that raises on invalid input.
565+
"""
566+
567+
if self._feature_validator is None:
568+
return
569+
properties = feature.get('properties', {})
570+
self._feature_validator(**properties)
571+
572+
def create(self, new_feature):
573+
self._validate_feature(new_feature)
574+
return super().create(new_feature)
575+
576+
def update(self, identifier, new_feature):
577+
self._validate_feature(new_feature)
578+
return super().update(identifier, new_feature)
579+
580+
Default validator (standard library only)
581+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
582+
583+
The default validator uses only dataclasses and ``validate_type`` from pygeoapi core.
584+
No external dependency is needed. It reads the provider's JSON Schema
585+
(``provider.fields``) and dynamically creates a ``@dataclass`` whose fields match the
586+
data. The type checking is done by ``validate_type`` in ``__post_init__``.
587+
588+
.. code-block:: python
589+
590+
from dataclasses import dataclass
591+
from typing import Optional
592+
593+
from pygeoapi.models.validation import validate_type
594+
595+
_JSON_SCHEMA_TYPE_MAP = {
596+
'string': str, 'number': float,
597+
'integer': int, 'boolean': bool,
598+
}
599+
600+
def _make_feature_validator_cls(fields: dict):
601+
"""Build a dataclass validator from provider fields.
602+
603+
No external dependency required.
604+
"""
605+
606+
if not fields:
607+
return None
608+
609+
annotations = {}
610+
defaults = {}
611+
for name, schema in fields.items():
612+
json_type = schema.get('type', 'string')
613+
py_type = _JSON_SCHEMA_TYPE_MAP.get(json_type, str)
614+
annotations[name] = Optional[py_type]
615+
defaults[name] = None
616+
617+
ns = {'__annotations__': annotations, **defaults}
618+
619+
def __post_init__(self):
620+
validate_type(self)
621+
622+
ns['__post_init__'] = __post_init__
623+
cls = type('FeatureValidator', (), ns)
624+
return dataclass(cls)
625+
626+
This validator catches type errors (e.g. a string where an integer is expected) using
627+
only the standard library. The provider does not know or define what technology the
628+
validator uses — it only calls ``self._feature_validator(**properties)``.
629+
630+
Injecting a custom validator downstream
631+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
632+
633+
A downstream project, that uses pygeoapi as a library, can subclass ``PluginContext``
634+
and inject a more robust validator. The provider code does not change, only the
635+
context differs.
636+
637+
The injected validator could be a pydantic ``BaseModel`` with custom field constraints,
638+
a dataclass with ``__post_init__`` validation, or any callable that accepts ``**kwargs``
639+
and raises on invalid input.
640+
641+
.. code-block:: python
642+
643+
from dataclasses import dataclass
644+
from typing import Any, Optional
645+
646+
from pydantic import BaseModel, Field, field_validator
647+
from pygeoapi.plugin import PluginContext, load_plugin
648+
649+
650+
@dataclass
651+
class ValidatingContext(PluginContext):
652+
"""Extended context carrying a feature validator."""
653+
feature_validator: Optional[Any] = None
654+
655+
656+
# Stricter validator with domain-specific rules
657+
class StrictLakeProperties(BaseModel):
658+
id: int
659+
scalerank: int = Field(..., ge=0, le=10)
660+
name: str = Field(..., min_length=1)
661+
featureclass: str
662+
663+
@field_validator('featureclass')
664+
@classmethod
665+
def must_be_known_class(cls, v):
666+
allowed = {'Lake', 'Reservoir', 'Playa'}
667+
if v not in allowed:
668+
raise ValueError(f'must be one of {allowed}')
669+
return v
670+
671+
672+
# Inject via context
673+
context = ValidatingContext(
674+
config=provider_def,
675+
feature_validator=StrictLakeProperties,
676+
)
677+
provider = load_plugin('provider', provider_def, context=context)
678+
679+
# Accepted: valid feature
680+
provider.create({
681+
'type': 'Feature',
682+
'geometry': {'type': 'Point', 'coordinates': [0, 0]},
683+
'properties': {'id': 1, 'scalerank': 3, 'name': 'Test',
684+
'featureclass': 'Lake'},
685+
})
686+
687+
# Rejected: scalerank out of range (default validator would accept)
688+
provider.create({
689+
'type': 'Feature',
690+
'geometry': {'type': 'Point', 'coordinates': [0, 0]},
691+
'properties': {'id': 2, 'scalerank': 99, 'name': 'Test',
692+
'featureclass': 'Lake'},
693+
})
694+
695+
So with the same class in the core and the same data sent to the provider, the result
696+
of the validation may change depending on the injected context. Without context,
697+
the default validator catches type errors. With a ``ValidatingContext``, the downstream
698+
project might add domain constraints (value ranges, allowed values, minimum lengths) without
699+
modifying the provider.
700+
701+
Configuration
702+
^^^^^^^^^^^^^
703+
704+
.. code-block:: yaml
705+
706+
providers:
707+
- type: feature
708+
name: pygeoapi.provider.validated_geojson.ValidatedGeoJSONProvider
709+
data: tests/data/ne_110m_lakes.geojson
710+
id_field: id
711+
title_field: name
712+
editable: true
713+
714+
505715
Featured plugins
506716
----------------
507717

0 commit comments

Comments
 (0)