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