Skip to content

Add provider for MVT tile generation from Postgres #1979

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
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)