Skip to content
42 changes: 42 additions & 0 deletions docs/source/data-publishing/ogcapi-tiles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pygeoapi core tile providers are listed below, along with supported features.
`MVT-elastic`_,✅,✅,✅,❌,❌,✅
`MVT-proxy`_,❓,❓,❓,❓,❌,✅
`WMTSFacade`_,✅,❌,✅,✅,✅,❌
`MVT-postgresql`_,✅,✅,✅,✅,❌,✅

Below are specific connection examples based on supported providers.

Expand Down Expand Up @@ -130,6 +131,47 @@ Following code block shows how to configure pygeoapi to read Mapbox vector tiles
name: pbf
mimetype: application/vnd.mapbox-vector-tile

MVT-postgresql
^^^^^^^^^^^^^^

.. note::
Requires Python packages sqlalchemy, geoalchemy2 and psycopg2-binary

.. note::
Must have PostGIS installed with protobuf-c support

.. note::
Geometry must be using EPSG:4326

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that this note is outdated for the postgresql provider. Is the note valid for this case?


This provider gives support to serving tiles generated using `PostgreSQL <https://www.postgresql.org/>`_ with `PostGIS <https://postgis.net/>`_.
The tiles are rendered on-the-fly using `ST_AsMVT <https://postgis.net/docs/ST_AsMVT.html>`_ and related methods.

This code block shows how to configure pygeoapi to render Mapbox vector tiles from a PostGIS table.

.. code-block:: yaml

providers:
- type: tile
name: MVT-postgresql
data:
host: 127.0.0.1
port: 3010 # Default 5432 if not provided
dbname: test
user: postgres
password: postgres
search_path: [osm, public]
id_field: osm_id
table: hotosm_bdi_waterways
geom_field: foo_geom
options:
zoom:
min: 0
max: 15
format:
name: pbf
mimetype: application/vnd.mapbox-vector-tile

PostgreSQL-related connection options can also be added to `options`. Please refer to the :ref:`PostgreSQL OGC Features Provider<PostgreSQL>` documentation for more information.

WMTSFacade
^^^^^^^^^^
Expand Down
1 change: 1 addition & 0 deletions pygeoapi/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
'MVT-tippecanoe': 'pygeoapi.provider.mvt_tippecanoe.MVTTippecanoeProvider', # noqa: E501
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider',
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider',
'MVT-postgresql': 'pygeoapi.provider.mvt_postgresql.MVTPostgreSQLProvider', # noqa: E501
'OracleDB': 'pygeoapi.provider.oracle.OracleProvider',
'OGR': 'pygeoapi.provider.ogr.OGRProvider',
'OpenSearch': 'pygeoapi.provider.opensearch_.OpenSearchProvider',
Expand Down
3 changes: 2 additions & 1 deletion pygeoapi/provider/base_mvt.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ def get_tiles_service(self, baseurl=None, servicepath=None,
:returns: `dict` of item tile service
"""

url = urlparse(self.data)
# self.data will be a dict when using MVTPostgresProvider
url = urlparse(self.data) if isinstance(self.data, str) else urlparse('/') # noqa
baseurl = baseurl or f'{url.scheme}://{url.netloc}'
# @TODO: support multiple types
tile_type = tile_type or self.format_type
Expand Down
264 changes: 264 additions & 0 deletions pygeoapi/provider/mvt_postgresql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
# =================================================================
#
# Authors: Prajwal Amaravati <prajwal.s@satsure.co>
# Tanvi Prasad <tanvi.prasad@cdpg.org.in>
# Bryan Robert <bryan.robert@cdpg.org.in>
#
# Copyright (c) 2025 Prajwal Amaravati
# Copyright (c) 2025 Tanvi Prasad
# Copyright (c) 2025 Bryan Robert
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================

from copy import deepcopy
import logging

from sqlalchemy.sql import text

from pygeoapi.models.provider.base import (
TileSetMetadata, TileMatrixSetEnum, LinkType)
from pygeoapi.provider.base import ProviderConnectionError
from pygeoapi.provider.base_mvt import BaseMVTProvider
from pygeoapi.provider.postgresql import PostgreSQLProvider
from pygeoapi.provider.tile import ProviderTileNotFoundError
from pygeoapi.util import url_join

LOGGER = logging.getLogger(__name__)


class MVTPostgreSQLProvider(BaseMVTProvider):
"""
MVT PostgreSQL Provider
Provider for serving tiles rendered on-the-fly from
feature tables in PostgreSQL
"""

def __init__(self, provider_def):
"""
Initialize object

:param provider_def: provider definition

:returns: pygeoapi.provider.MVT.MVTPostgreSQLProvider
"""

super().__init__(provider_def)

pg_def = deepcopy(provider_def)
# delete the zoom option before initializing the PostgreSQL provider
# that provider breaks otherwise
del pg_def["options"]["zoom"]
self.postgres = PostgreSQLProvider(pg_def)

self.layer_name = provider_def["table"]
self.table = provider_def['table']
self.id_field = provider_def['id_field']
self.geom = provider_def.get('geom_field', 'geom')

LOGGER.debug(f'DB connection: {repr(self.postgres._engine.url)}')

def __repr__(self):
return f'<MVTPostgreSQLProvider> {self.data}'

@property
def service_url(self):
return self._service_url

@property
def service_metadata_url(self):
return self._service_metadata_url

def get_layer(self):
"""
Extracts layer name from url

:returns: layer name
"""

return self.layer_name

def get_tiling_schemes(self):

return [
TileMatrixSetEnum.WEBMERCATORQUAD.value,
TileMatrixSetEnum.WORLDCRS84QUAD.value
]

def get_tiles_service(self, baseurl=None, servicepath=None,
dirpath=None, tile_type=None):
"""
Gets mvt service description

:param baseurl: base URL of endpoint
:param servicepath: base path of URL
:param dirpath: directory basepath (equivalent of URL)
:param tile_type: tile format type

:returns: `dict` of item tile service
"""

super().get_tiles_service(baseurl, servicepath,
dirpath, tile_type)

self._service_url = servicepath
return self.get_tms_links()

def get_tiles(self, layer=None, tileset=None,
z=None, y=None, x=None, format_=None):
"""
Gets tile

:param layer: mvt tile layer
:param tileset: mvt tileset
:param z: z index
:param y: y index
:param x: x index
:param format_: tile format

:returns: an encoded mvt tile
"""
if format_ == 'mvt':
format_ = self.format_type

fields_arr = self.postgres.get_fields().keys()
fields = ', '.join(['"' + f + '"' for f in fields_arr])
if len(fields) != 0:
fields = ',' + fields

query = ''
if tileset == TileMatrixSetEnum.WEBMERCATORQUAD.value.tileMatrixSet:
if not self.is_in_limits(TileMatrixSetEnum.WEBMERCATORQUAD.value, z, x, y): # noqa
raise ProviderTileNotFoundError

query = text("""
WITH
bounds AS (
SELECT ST_TileEnvelope(:z, :x, :y) AS boundgeom
),
mvtgeom AS (
SELECT ST_AsMVTGeom(ST_Transform(ST_CurveToLine({geom}), 3857), bounds.boundgeom) AS geom {fields}
FROM "{table}", bounds
WHERE ST_Intersects({geom}, ST_Transform(bounds.boundgeom, 4326))
)
SELECT ST_AsMVT(mvtgeom, 'default') FROM mvtgeom;
""".format(geom=self.geom, table=self.table, fields=fields)) # noqa

if tileset == TileMatrixSetEnum.WORLDCRS84QUAD.value.tileMatrixSet:
if not self.is_in_limits(TileMatrixSetEnum.WORLDCRS84QUAD.value, z, x, y): # noqa
raise ProviderTileNotFoundError

query = text("""
WITH
bounds AS (
SELECT ST_TileEnvelope(:z, :x, :y,
'SRID=4326;POLYGON((-180 -90,-180 90,180 90,180 -90,-180 -90))'::geometry) AS boundgeom
),
mvtgeom AS (
SELECT ST_AsMVTGeom(ST_CurveToLine({geom}), bounds.boundgeom) AS geom {fields}
FROM "{table}", bounds
WHERE ST_Intersects({geom}, bounds.boundgeom)
)
SELECT ST_AsMVT(mvtgeom, 'default') FROM mvtgeom;
""".format(geom=self.geom, table=self.table, fields=fields)) # noqa

with self.postgres._engine.connect() as session:
result = session.execute(query, {
'z': z,
'y': y,
'x': x
}).fetchone()

if len(bytes(result[0])) == 0:
return None
return bytes(result[0])

def get_html_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):

service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
metadata_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/metadata')

metadata = dict()
metadata['id'] = dataset
metadata['title'] = title
metadata['tileset'] = tileset
metadata['collections_path'] = service_url
metadata['json_url'] = f'{metadata_url}?f=json'

return metadata

def get_default_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):

service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa

content = {}
tiling_schemes = self.get_tiling_schemes()
# Default values
tileMatrixSetURI = tiling_schemes[0].tileMatrixSetURI
crs = tiling_schemes[0].crs
# Checking the selected matrix in configured tiling_schemes
for schema in tiling_schemes:
if (schema.tileMatrixSet == tileset):
crs = schema.crs
tileMatrixSetURI = schema.tileMatrixSetURI

tiling_scheme_url = url_join(
server_url, f'/TileMatrixSets/{schema.tileMatrixSet}')
tiling_scheme_url_type = "application/json"
tiling_scheme_url_title = f'{schema.tileMatrixSet} tile matrix set definition' # noqa

tiling_scheme = LinkType(href=tiling_scheme_url,
rel="http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme", # noqa
type_=tiling_scheme_url_type,
title=tiling_scheme_url_title)

if tiling_scheme is None:
msg = f'Could not identify a valid tiling schema' # noqa
LOGGER.error(msg)
raise ProviderConnectionError(msg)

content = TileSetMetadata(title=title, description=description,
keywords=keywords, crs=crs,
tileMatrixSetURI=tileMatrixSetURI)

links = []
service_url_link_type = "application/vnd.mapbox-vector-tile"
service_url_link_title = f'{tileset} vector tiles for {layer}'
service_url_link = LinkType(href=service_url, rel="item",
type_=service_url_link_type,
title=service_url_link_title)

links.append(tiling_scheme)
links.append(service_url_link)

content.links = links

return content.dict(exclude_none=True)