Coverage for pygeoapi/flask_app.py: 72%
217 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
1# =================================================================
2#
3# Authors: Tom Kralidis <tomkralidis@gmail.com>
4# Norman Barker <norman.barker@gmail.com>
5#
6# Copyright (c) 2026 Tom Kralidis
7#
8# Permission is hereby granted, free of charge, to any person
9# obtaining a copy of this software and associated documentation
10# files (the "Software"), to deal in the Software without
11# restriction, including without limitation the rights to use,
12# copy, modify, merge, publish, distribute, sublicense, and/or sell
13# copies of the Software, and to permit persons to whom the
14# Software is furnished to do so, subject to the following
15# conditions:
16#
17# The above copyright notice and this permission notice shall be
18# included in all copies or substantial portions of the Software.
19#
20# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27# OTHER DEALINGS IN THE SOFTWARE.
28#
29# =================================================================
31"""Flask module providing the route paths to the api"""
33import os
34from typing import Callable, Union
36import click
37from flask import (Flask, Blueprint, make_response, request,
38 send_from_directory, Response, Request)
40from pygeoapi.api import API, APIRequest, apply_gzip
41import pygeoapi.api as core_api
42import pygeoapi.api.coverages as coverages_api
43import pygeoapi.api.environmental_data_retrieval as edr_api
44import pygeoapi.api.itemtypes as itemtypes_api
45import pygeoapi.api.maps as maps_api
46import pygeoapi.api.processes as processes_api
47import pygeoapi.api.stac as stac_api
48import pygeoapi.api.tiles as tiles_api
49from pygeoapi.asyncapi import load_asyncapi_document
50from pygeoapi.openapi import load_openapi_document
51from pygeoapi.config import get_config
52from pygeoapi.util import get_mimetype, get_api_rules
55CONFIG = get_config()
56OPENAPI = load_openapi_document()
57ASYNCAPI = load_asyncapi_document()
59API_RULES = get_api_rules(CONFIG)
61if CONFIG['server'].get('admin'): 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true
62 import pygeoapi.api.admin as admin_api
63 from pygeoapi.api.admin import Admin
65STATIC_FOLDER = 'static'
66if 'templates' in CONFIG['server']: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 STATIC_FOLDER = CONFIG['server']['templates'].get('static', 'static')
69APP = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path='/static')
70APP.url_map.strict_slashes = API_RULES.strict_slashes
72BLUEPRINT = Blueprint(
73 'pygeoapi',
74 __name__,
75 static_folder=STATIC_FOLDER,
76 url_prefix=API_RULES.get_url_prefix('flask')
77)
78ADMIN_BLUEPRINT = Blueprint(
79 'admin',
80 __name__,
81 static_folder=STATIC_FOLDER,
82 url_prefix=API_RULES.get_url_prefix('flask')
83)
85# CORS: optionally enable from config.
86if CONFIG['server'].get('cors', False): 86 ↛ 93line 86 didn't jump to line 93 because the condition on line 86 was always true
87 try:
88 from flask_cors import CORS
89 CORS(APP, CORS_EXPOSE_HEADERS=['*'])
90 except ModuleNotFoundError:
91 print('Python package flask-cors required for CORS support')
93APP.config['JSONIFY_PRETTYPRINT_REGULAR'] = CONFIG['server'].get(
94 'pretty_print', True)
96api_ = API(CONFIG, OPENAPI, ASYNCAPI)
98OGC_SCHEMAS_LOCATION = CONFIG['server'].get('ogc_schemas_location')
100if (OGC_SCHEMAS_LOCATION is not None and 100 ↛ 130line 100 didn't jump to line 130 because the condition on line 100 was always true
101 not OGC_SCHEMAS_LOCATION.startswith('http')):
102 # serve the OGC schemas locally
104 if not os.path.exists(OGC_SCHEMAS_LOCATION): 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 raise RuntimeError('OGC schemas misconfigured')
107 @BLUEPRINT.route('/schemas/<path:path>', methods=['GET'])
108 def schemas(path: str):
109 """
110 Serve OGC schemas locally
112 :param path: path of the OGC schema document
114 :returns: HTTP response
115 """
117 full_filepath = os.path.join(OGC_SCHEMAS_LOCATION, path)
118 dirname_ = os.path.dirname(full_filepath)
119 basename_ = os.path.basename(full_filepath)
121 path_ = dirname_.replace('..', '').replace('//', '').replace('./', '')
123 if '..' in path_: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 return 'Invalid path', 400
126 return send_from_directory(path_, basename_,
127 mimetype=get_mimetype(basename_))
130def execute_from_flask(api_function: Callable, request: Request, *args,
131 skip_valid_check: bool = False,
132 alternative_api: API | None = None
133 ) -> Response:
134 """
135 Executes API function from Flask
137 :param api_function: API function
138 :param request: request object
139 :param *args: variable length additional arguments
140 :param skip_validity_check: bool
141 :param alternative_api: specify custom api instance such as Admin
143 :returns: A Response instance
144 """
146 actual_api = api_ if alternative_api is None else alternative_api
148 api_request = APIRequest.from_flask(request, actual_api.locales)
150 content: Union[str, bytes]
152 if not skip_valid_check and not api_request.is_valid():
153 headers, status, content = actual_api.get_format_exception(api_request)
154 else:
155 headers, status, content = api_function(actual_api, api_request, *args)
156 content = apply_gzip(headers, content)
158 response = make_response(content, status)
160 if headers:
161 response.headers = headers
162 return response
165@BLUEPRINT.route('/')
166def landing_page():
167 """
168 OGC API landing page endpoint
170 :returns: HTTP response
171 """
172 return execute_from_flask(core_api.landing_page, request)
175@BLUEPRINT.route('/openapi')
176def openapi():
177 """
178 OpenAPI endpoint
180 :returns: HTTP response
181 """
183 return execute_from_flask(core_api.openapi_, request)
186@BLUEPRINT.route('/asyncapi')
187def asyncapi():
188 """
189 AsyncAPI endpoint
191 :returns: HTTP response
192 """
194 return execute_from_flask(core_api.asyncapi_, request)
197@BLUEPRINT.route('/conformance')
198def conformance():
199 """
200 OGC API conformance endpoint
202 :returns: HTTP response
203 """
205 return execute_from_flask(core_api.conformance, request)
208@BLUEPRINT.route('/TileMatrixSets/<tileMatrixSetId>')
209def get_tilematrix_set(tileMatrixSetId: str | None = None):
210 """
211 OGC API TileMatrixSet endpoint
213 :param tileMatrixSetId: identifier of tile matrix set
215 :returns: HTTP response
216 """
218 return execute_from_flask(tiles_api.tilematrixset, request,
219 tileMatrixSetId)
222@BLUEPRINT.route('/TileMatrixSets')
223def get_tilematrix_sets():
224 """
225 OGC API TileMatrixSets endpoint
227 :returns: HTTP response
228 """
230 return execute_from_flask(tiles_api.tilematrixsets, request)
233@BLUEPRINT.route('/collections')
234@BLUEPRINT.route('/collections/<path:collection_id>')
235def collections(collection_id: str | None = None):
236 """
237 OGC API collections endpoint
239 :param collection_id: collection identifier
241 :returns: HTTP response
242 """
244 return execute_from_flask(core_api.describe_collections, request,
245 collection_id)
248@BLUEPRINT.route('/collections/<path:collection_id>/schema')
249def collection_schema(collection_id: str | None = None):
250 """
251 OGC API - collections schema endpoint
253 :param collection_id: collection identifier
255 :returns: HTTP response
256 """
258 return execute_from_flask(core_api.get_collection_schema, request,
259 collection_id)
262@BLUEPRINT.route('/collections/<path:collection_id>/queryables')
263def collection_queryables(collection_id: str | None = None):
264 """
265 OGC API collections queryables endpoint
267 :param collection_id: collection identifier
269 :returns: HTTP response
270 """
272 return execute_from_flask(itemtypes_api.get_collection_queryables, request,
273 collection_id)
276@BLUEPRINT.route('/collections/<path:collection_id>/items',
277 methods=['GET', 'POST', 'OPTIONS'],
278 provide_automatic_options=False)
279@BLUEPRINT.route('/collections/<path:collection_id>/items/<path:item_id>',
280 methods=['GET', 'PUT', 'DELETE', 'OPTIONS'],
281 provide_automatic_options=False)
282def collection_items(collection_id: str, item_id: str | None = None):
283 """
284 OGC API collections items endpoint
286 :param collection_id: collection identifier
287 :param item_id: item identifier
289 :returns: HTTP response
290 """
292 if item_id is None:
293 if request.method == 'POST': # filter or manage items
294 if request.content_type is not None:
295 if request.content_type == 'application/geo+json': 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true
296 return execute_from_flask(
297 itemtypes_api.manage_collection_item,
298 request, 'create', collection_id,
299 skip_valid_check=True)
300 else:
301 return execute_from_flask(
302 itemtypes_api.get_collection_items, request,
303 collection_id, skip_valid_check=True)
304 elif request.method == 'OPTIONS':
305 return execute_from_flask(
306 itemtypes_api.manage_collection_item, request, 'options',
307 collection_id, skip_valid_check=True)
308 else: # GET: list items
309 return execute_from_flask(itemtypes_api.get_collection_items,
310 request, collection_id,
311 skip_valid_check=True)
313 elif request.method == 'DELETE':
314 return execute_from_flask(itemtypes_api.manage_collection_item,
315 request, 'delete', collection_id, item_id,
316 skip_valid_check=True)
317 elif request.method == 'PUT':
318 return execute_from_flask(itemtypes_api.manage_collection_item,
319 request, 'update', collection_id, item_id,
320 skip_valid_check=True)
321 elif request.method == 'OPTIONS':
322 return execute_from_flask(itemtypes_api.manage_collection_item,
323 request, 'options', collection_id, item_id,
324 skip_valid_check=True)
325 else:
326 return execute_from_flask(itemtypes_api.get_collection_item, request,
327 collection_id, item_id)
330@BLUEPRINT.route('/collections/<path:collection_id>/coverage')
331def collection_coverage(collection_id: str):
332 """
333 OGC API - Coverages coverage endpoint
335 :param collection_id: collection identifier
337 :returns: HTTP response
338 """
340 return execute_from_flask(coverages_api.get_collection_coverage, request,
341 collection_id, skip_valid_check=True)
344@BLUEPRINT.route('/collections/<path:collection_id>/tiles')
345def get_collection_tiles(collection_id: str | None = None):
346 """
347 OGC open api collections tiles access point
349 :param collection_id: collection identifier
351 :returns: HTTP response
352 """
354 return execute_from_flask(tiles_api.get_collection_tiles, request,
355 collection_id)
358@BLUEPRINT.route('/collections/<path:collection_id>/tiles/<tileMatrixSetId>')
359@BLUEPRINT.route('/collections/<path:collection_id>/tiles/<tileMatrixSetId>/metadata') # noqa
360def get_collection_tiles_metadata(collection_id: str | None = None,
361 tileMatrixSetId: str | None = None):
362 """
363 OGC open api collection tiles service metadata
365 :param collection_id: collection identifier
366 :param tileMatrixSetId: identifier of tile matrix set
368 :returns: HTTP response
369 """
371 return execute_from_flask(tiles_api.get_collection_tiles_metadata,
372 request, collection_id, tileMatrixSetId,
373 skip_valid_check=True)
376@BLUEPRINT.route('/collections/<path:collection_id>/tiles/\
377<tileMatrixSetId>/<tileMatrix>/<tileRow>/<tileCol>')
378def get_collection_tiles_data(collection_id: str | None = None,
379 tileMatrixSetId: str | None = None,
380 tileMatrix: str | None = None,
381 tileRow: str | None = None,
382 tileCol: str | None = None):
383 """
384 OGC open api collection tiles service data
386 :param collection_id: collection identifier
387 :param tileMatrixSetId: identifier of tile matrix set
388 :param tileMatrix: identifier of {z} matrix index
389 :param tileRow: identifier of {y} matrix index
390 :param tileCol: identifier of {x} matrix index
392 :returns: HTTP response
393 """
395 return execute_from_flask(
396 tiles_api.get_collection_tiles_data,
397 request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol,
398 skip_valid_check=True,
399 )
402@BLUEPRINT.route('/collections/<collection_id>/map')
403@BLUEPRINT.route('/collections/<collection_id>/styles/<style_id>/map')
404def collection_map(collection_id: str, style_id: str | None = None):
405 """
406 OGC API - Maps map render endpoint
408 :param collection_id: collection identifier
409 :param style_id: style identifier
411 :returns: HTTP response
412 """
414 return execute_from_flask(
415 maps_api.get_collection_map, request, collection_id, style_id
416 )
419@BLUEPRINT.route('/processes')
420@BLUEPRINT.route('/processes/<process_id>')
421def get_processes(process_id: str | None = None):
422 """
423 OGC API - Processes description endpoint
425 :param process_id: process identifier
427 :returns: HTTP response
428 """
430 return execute_from_flask(processes_api.describe_processes, request,
431 process_id)
434@BLUEPRINT.route('/jobs')
435@BLUEPRINT.route('/jobs/<job_id>',
436 methods=['GET', 'DELETE'])
437def get_jobs(job_id: str | None = None):
438 """
439 OGC API - Processes jobs endpoint
441 :param job_id: job identifier
443 :returns: HTTP response
444 """
446 if job_id is None:
447 return execute_from_flask(processes_api.get_jobs, request)
448 else:
449 if request.method == 'DELETE': # dismiss job
450 return execute_from_flask(processes_api.delete_job, request,
451 job_id)
452 else: # Return status of a specific job
453 return execute_from_flask(processes_api.get_jobs, request, job_id)
456@BLUEPRINT.route('/processes/<process_id>/execution', methods=['POST'])
457def execute_process_jobs(process_id: str):
458 """
459 OGC API - Processes execution endpoint
461 :param process_id: process identifier
463 :returns: HTTP response
464 """
466 return execute_from_flask(processes_api.execute_process, request,
467 process_id)
470@BLUEPRINT.route('/jobs/<job_id>/results',
471 methods=['GET'])
472def get_job_result(job_id: str | None = None):
473 """
474 OGC API - Processes job result endpoint
476 :param job_id: job identifier
478 :returns: HTTP response
479 """
481 return execute_from_flask(processes_api.get_job_result, request, job_id)
484@BLUEPRINT.route('/collections/<path:collection_id>/position')
485@BLUEPRINT.route('/collections/<path:collection_id>/area')
486@BLUEPRINT.route('/collections/<path:collection_id>/cube')
487@BLUEPRINT.route('/collections/<path:collection_id>/radius')
488@BLUEPRINT.route('/collections/<path:collection_id>/trajectory')
489@BLUEPRINT.route('/collections/<path:collection_id>/corridor')
490@BLUEPRINT.route('/collections/<path:collection_id>/locations/<location_id>')
491@BLUEPRINT.route('/collections/<path:collection_id>/locations')
492@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/position') # noqa
493@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/area') # noqa
494@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/cube') # noqa
495@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/radius') # noqa
496@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/trajectory') # noqa
497@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/corridor') # noqa
498@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/locations/<location_id>') # noqa
499@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>/locations') # noqa
500@BLUEPRINT.route('/collections/<path:collection_id>/instances/<instance_id>')
501@BLUEPRINT.route('/collections/<path:collection_id>/instances')
502def get_collection_edr_query(collection_id: str,
503 instance_id: str | None = None,
504 location_id: str | None = None):
505 """
506 OGC EDR API endpoints
508 :param collection_id: collection identifier
509 :param instance_id: instance identifier
510 :param location_id: location id of a /locations/<location_id> query
512 :returns: HTTP response
513 """
515 if (request.path.endswith('instances') or
516 (instance_id is not None and
517 request.path.endswith(instance_id))):
518 return execute_from_flask(
519 edr_api.get_collection_edr_instances, request, collection_id,
520 instance_id
521 )
523 if location_id:
524 query_type = 'locations'
525 else:
526 query_type = request.path.split('/')[-1]
528 return execute_from_flask(
529 edr_api.get_collection_edr_query, request, collection_id, instance_id,
530 query_type, location_id, skip_valid_check=True
531 )
534@BLUEPRINT.route('/stac-api')
535def stac_landing_page():
536 """
537 STAC API landing page endpoint
539 :returns: HTTP response
540 """
542 return execute_from_flask(stac_api.landing_page, request)
545@BLUEPRINT.route('/stac-api/search', methods=['GET', 'POST'])
546def stac_search():
547 """
548 STAC API search endpoint
550 :returns: HTTP response
551 """
553 return execute_from_flask(stac_api.search, request)
556@BLUEPRINT.route('/stac')
557def stac_catalog_root():
558 """
559 STAC root endpoint
561 :returns: HTTP response
562 """
564 return execute_from_flask(stac_api.get_stac_root, request)
567@BLUEPRINT.route('/stac/<path:path>')
568def stac_catalog_path(path: str):
569 """
570 STAC path endpoint
572 :param path: path
574 :returns: HTTP response
575 """
577 return execute_from_flask(stac_api.get_stac_path, request, path)
580@ADMIN_BLUEPRINT.route('/admin/config', methods=['GET', 'PUT', 'PATCH'])
581def admin_config():
582 """
583 Admin endpoint
585 :returns: HTTP response
586 """
588 if request.method == 'GET':
589 return execute_from_flask(admin_api.get_config_, request,
590 alternative_api=admin_)
592 elif request.method == 'PUT':
593 return execute_from_flask(admin_api.put_config, request,
594 alternative_api=admin_)
596 elif request.method == 'PATCH':
597 return execute_from_flask(admin_api.patch_config, request,
598 alternative_api=admin_)
601@ADMIN_BLUEPRINT.route('/admin/config/resources', methods=['GET', 'POST'])
602def admin_config_resources():
603 """
604 Resources endpoint
606 :returns: HTTP response
607 """
609 if request.method == 'GET':
610 return execute_from_flask(admin_api.get_resources, request,
611 alternative_api=admin_)
613 elif request.method == 'POST':
614 return execute_from_flask(admin_api.post_resource, request,
615 alternative_api=admin_)
618@ADMIN_BLUEPRINT.route(
619 '/admin/config/resources/<resource_id>',
620 methods=['GET', 'PUT', 'PATCH', 'DELETE'])
621def admin_config_resource(resource_id: str):
622 """
623 Resource endpoint
625 :returns: HTTP response
626 """
628 if request.method == 'GET':
629 return execute_from_flask(admin_api.get_resource, request,
630 resource_id,
631 alternative_api=admin_)
633 elif request.method == 'DELETE':
634 return execute_from_flask(admin_api.delete_resource, request,
635 resource_id,
636 alternative_api=admin_)
638 elif request.method == 'PUT':
639 return execute_from_flask(admin_api.put_resource, request,
640 resource_id,
641 alternative_api=admin_)
643 elif request.method == 'PATCH':
644 return execute_from_flask(admin_api.patch_resource, request,
645 resource_id,
646 alternative_api=admin_)
649APP.register_blueprint(BLUEPRINT)
651if CONFIG['server'].get('admin'): 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 admin_ = Admin(CONFIG, OPENAPI)
653 APP.register_blueprint(ADMIN_BLUEPRINT)
656@click.command()
657@click.pass_context
658@click.option('--debug', '-d', default=False, is_flag=True, help='debug')
659def serve(ctx, server: str | None = None, debug: bool = False):
660 """
661 Serve pygeoapi via Flask. Runs pygeoapi
662 as a flask server. Not recommend for production.
664 :param server: `string` of server type
665 :param debug: `bool` of whether to run in debug mode
667 :returns: void
668 """
670 # setup_logger(CONFIG['logging'])
671 APP.run(debug=True, host=api_.config['server']['bind']['host'],
672 port=api_.config['server']['bind']['port'])
675if __name__ == '__main__': # run locally, for testing 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true
676 serve()