diff options
Diffstat (limited to 'webmap-cgi')
-rwxr-xr-x | webmap-cgi | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/webmap-cgi b/webmap-cgi new file mode 100755 index 0000000..616b2fd --- /dev/null +++ b/webmap-cgi @@ -0,0 +1,177 @@ +#!/usr/bin/python3 + +#---------------------------------------------------------------------- +# Webmap CGI (Common Gateway Interface) for the Klimatanalys Norr project +# Copyright © 2025 Guilhem Moulin <info@guilhem.se> +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +#---------------------------------------------------------------------- + +# pylint: disable=invalid-name, missing-module-docstring, fixme +# pylint: enable=invalid-name + +import sys +from os import path as os_path +from json import load as json_load, JSONDecodeError +import logging +from typing import Final, Iterator +import atexit + +from psycopg import connect, Cursor # pylint: disable=import-error + +import common + +def get_table_map() -> dict[tuple[str, str], str]: + """Get mapping of pairs (MVT group name, layername) to table name.""" + ret = {} + config = common.load_config() + layer_groups = config.get('layer-groups', {}) + layers = config.get('layers', {}) + layernames = set(layers.keys()) + for groupname, patterns in layer_groups.items(): + for layername in common.layers_in_group(groupname, patterns, layernames): + exportdef = layers[layername].get('publish', None) + if exportdef is None: + continue + if isinstance(exportdef, str): + exportdef = [ exportdef ] + for layername_mvt in exportdef: + k = (groupname, layername_mvt) + if k in ret: + raise RuntimeError(f'Duplicate key {k}') + ret[k] = layername + return ret + +SCHEMA_NAME : Final[str] = 'postgis' +def get_query(layername : str) -> bytes: + """Get GeoJSON-producing query for the given layer.""" + # TODO don't hardcode geometry column name + # TODO[trixie]id_column => 'ogc_fid' (but don't hardcode either) + query = 'SELECT convert_to(ST_AsGeoJSON(m.*,' + query += 'geom_column=>\'wkb_geometry\',' + query += 'pretty_bool=>\'f\'),' + query += '\'UTF8\') AS "GeoJSON" ' + query += 'FROM (' + query += 'SELECT l.* ' + query += 'FROM ' + common.escape_identifier(SCHEMA_NAME) + query += '.' + common.escape_identifier(layername) + ' l ' + query += 'WHERE l.ogc_fid = %s' + query += ') m' + return query.encode('utf-8') + + +STATUS_OK : Final[str] = '200 OK' +STATUS_BAD_REQUEST : Final[str] = '400 Bad Request' +STATUS_NOT_ALLOWED : Final[str] = '405 Method Not Allowed' +STATUS_INTERNAL_SERVER_ERROR : Final[str] = '500 Internal Server Error' + +EMPTY_RESPONSE_HEADERS : Final[list[tuple[str,str]]] = [ + ('Content-Type', 'text/plain'), + ('Content-Length', '0'), +] +CONTENT_TYPE_JSON : Final[tuple[str,str]] = ('Content-Type', 'application/json; charset=UTF-8') + +MAX_FEATURE_COUNT : Final[int] = 500 +def application(env, start_response) -> Iterator[bytes]: + """Main application.""" + if env['REQUEST_METHOD'].upper() != 'POST': + logging.error('Invalid request method %s', env['REQUEST_METHOD']) + start_response(STATUS_NOT_ALLOWED, EMPTY_RESPONSE_HEADERS) + return + + content_type = env.get('CONTENT_TYPE', '').lower() + if content_type != 'application/json' and not content_type.startswith('application/json;'): + logging.error('Invalid Content-Type: %s', content_type) + start_response(STATUS_BAD_REQUEST, EMPTY_RESPONSE_HEADERS) + return + + first = True + try: + body = json_load(env['wsgi.input']) + if not isinstance(body, dict): + raise ValueError + + start_response(STATUS_OK, [CONTENT_TYPE_JSON]) + # pylint: disable-next=no-member + with PG_CONN.cursor(binary=True, scrollable=False, withhold=False) as cur: + if not isinstance(body, dict): + raise ValueError + mvt = body.get('mvt', None) + layername = body.get('layer', None) + if not isinstance(mvt, str) or not isinstance(layername, str): + raise ValueError + query = QUERY_MAP[TABLE_MAP[(mvt, layername)]] + fid = body.get('fid', None) + if not isinstance(fid, int): + raise ValueError + cur.execute(query, params=(fid,)) + resp = cur.fetchone() + if resp is not None: + yield resp[0] + + except (JSONDecodeError, LookupError, UnicodeDecodeError, ValueError) as exc: + logging.exception('Invalid request body') + # start_response(,,sys.exc_info()) should work here, but doesn't + # because of https://github.com/unbit/uwsgi/issues/2278 + if first: + start_response(STATUS_BAD_REQUEST, EMPTY_RESPONSE_HEADERS) + else: + # headers already sent, can't do better; the client will get a 200 status + # code, but fail to parse the payload as JSON anyway + exc_info = sys.exc_info() + raise exc_info[1].with_traceback(exc_info[2]) from exc + except Exception as exc: # pylint: disable=broad-exception-caught + logging.exception('Internal Server Error') + if first: + start_response(STATUS_INTERNAL_SERVER_ERROR, EMPTY_RESPONSE_HEADERS) + else: + exc_info = sys.exc_info() + raise exc_info[1].with_traceback(exc_info[2]) from exc + +# We could use a psycopg_pool.ConnectionPool() but that would be +# overkill since we only have 2 workers and no threads. So each worker +# simply opens a (single) connection to PostgreSQL at launch time. +# Use autocommit to avoid starting a transaction, cf. +# https://www.psycopg.org/psycopg3/docs/basic/transactions.html#autocommit-transactions +PG_CONN = connect('postgresql://webmap_guest@/webmap', + autocommit=True, + prepare_threshold=0, + # TODO[trixie] use cursor_factory=RawCursor + # https://www.psycopg.org/psycopg3/docs/advanced/cursors.html#cursor-types + cursor_factory=Cursor) + +@atexit.register +def handler(): + """Gracefully close the connection before terminating the worker""" + # avoid "AttributeError: 'NoneType' object has no attribute 'connection_summary'" + # when destructing the object + # TODO[trixie] reevaluate, possibly related to https://github.com/psycopg/psycopg/issues/591 + PG_CONN.close() # pylint: disable=no-member + +common.init_logger(app=os_path.basename(__file__), level=logging.INFO) +TABLE_MAP : Final[dict[tuple[str, str], str]] = get_table_map() +QUERY_MAP : Final[dict[str,bytes]] = { lyr:get_query(lyr) for lyr in set(TABLE_MAP.values()) } + +PG_CONN.execute( # pylint: disable=no-member + 'SET search_path TO ' + common.escape_identifier(SCHEMA_NAME) + ',public', + prepare=False) +PG_CONN.execute( # pylint: disable=no-member + 'SET statement_timeout TO 15000', # 15s + prepare=False) + +del sys.modules['common'] +del common +del get_query +del get_table_map +del os_path |