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

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# ================================================================= 

30 

31"""Flask module providing the route paths to the api""" 

32 

33import os 

34from typing import Callable, Union 

35 

36import click 

37from flask import (Flask, Blueprint, make_response, request, 

38 send_from_directory, Response, Request) 

39 

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 

53 

54 

55CONFIG = get_config() 

56OPENAPI = load_openapi_document() 

57ASYNCAPI = load_asyncapi_document() 

58 

59API_RULES = get_api_rules(CONFIG) 

60 

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 

64 

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') 

68 

69APP = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path='/static') 

70APP.url_map.strict_slashes = API_RULES.strict_slashes 

71 

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) 

84 

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') 

92 

93APP.config['JSONIFY_PRETTYPRINT_REGULAR'] = CONFIG['server'].get( 

94 'pretty_print', True) 

95 

96api_ = API(CONFIG, OPENAPI, ASYNCAPI) 

97 

98OGC_SCHEMAS_LOCATION = CONFIG['server'].get('ogc_schemas_location') 

99 

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 

103 

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') 

106 

107 @BLUEPRINT.route('/schemas/<path:path>', methods=['GET']) 

108 def schemas(path: str): 

109 """ 

110 Serve OGC schemas locally 

111 

112 :param path: path of the OGC schema document 

113 

114 :returns: HTTP response 

115 """ 

116 

117 full_filepath = os.path.join(OGC_SCHEMAS_LOCATION, path) 

118 dirname_ = os.path.dirname(full_filepath) 

119 basename_ = os.path.basename(full_filepath) 

120 

121 path_ = dirname_.replace('..', '').replace('//', '').replace('./', '') 

122 

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 

125 

126 return send_from_directory(path_, basename_, 

127 mimetype=get_mimetype(basename_)) 

128 

129 

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 

136 

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 

142 

143 :returns: A Response instance 

144 """ 

145 

146 actual_api = api_ if alternative_api is None else alternative_api 

147 

148 api_request = APIRequest.from_flask(request, actual_api.locales) 

149 

150 content: Union[str, bytes] 

151 

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) 

157 

158 response = make_response(content, status) 

159 

160 if headers: 

161 response.headers = headers 

162 return response 

163 

164 

165@BLUEPRINT.route('/') 

166def landing_page(): 

167 """ 

168 OGC API landing page endpoint 

169 

170 :returns: HTTP response 

171 """ 

172 return execute_from_flask(core_api.landing_page, request) 

173 

174 

175@BLUEPRINT.route('/openapi') 

176def openapi(): 

177 """ 

178 OpenAPI endpoint 

179 

180 :returns: HTTP response 

181 """ 

182 

183 return execute_from_flask(core_api.openapi_, request) 

184 

185 

186@BLUEPRINT.route('/asyncapi') 

187def asyncapi(): 

188 """ 

189 AsyncAPI endpoint 

190 

191 :returns: HTTP response 

192 """ 

193 

194 return execute_from_flask(core_api.asyncapi_, request) 

195 

196 

197@BLUEPRINT.route('/conformance') 

198def conformance(): 

199 """ 

200 OGC API conformance endpoint 

201 

202 :returns: HTTP response 

203 """ 

204 

205 return execute_from_flask(core_api.conformance, request) 

206 

207 

208@BLUEPRINT.route('/TileMatrixSets/<tileMatrixSetId>') 

209def get_tilematrix_set(tileMatrixSetId: str | None = None): 

210 """ 

211 OGC API TileMatrixSet endpoint 

212 

213 :param tileMatrixSetId: identifier of tile matrix set 

214 

215 :returns: HTTP response 

216 """ 

217 

218 return execute_from_flask(tiles_api.tilematrixset, request, 

219 tileMatrixSetId) 

220 

221 

222@BLUEPRINT.route('/TileMatrixSets') 

223def get_tilematrix_sets(): 

224 """ 

225 OGC API TileMatrixSets endpoint 

226 

227 :returns: HTTP response 

228 """ 

229 

230 return execute_from_flask(tiles_api.tilematrixsets, request) 

231 

232 

233@BLUEPRINT.route('/collections') 

234@BLUEPRINT.route('/collections/<path:collection_id>') 

235def collections(collection_id: str | None = None): 

236 """ 

237 OGC API collections endpoint 

238 

239 :param collection_id: collection identifier 

240 

241 :returns: HTTP response 

242 """ 

243 

244 return execute_from_flask(core_api.describe_collections, request, 

245 collection_id) 

246 

247 

248@BLUEPRINT.route('/collections/<path:collection_id>/schema') 

249def collection_schema(collection_id: str | None = None): 

250 """ 

251 OGC API - collections schema endpoint 

252 

253 :param collection_id: collection identifier 

254 

255 :returns: HTTP response 

256 """ 

257 

258 return execute_from_flask(core_api.get_collection_schema, request, 

259 collection_id) 

260 

261 

262@BLUEPRINT.route('/collections/<path:collection_id>/queryables') 

263def collection_queryables(collection_id: str | None = None): 

264 """ 

265 OGC API collections queryables endpoint 

266 

267 :param collection_id: collection identifier 

268 

269 :returns: HTTP response 

270 """ 

271 

272 return execute_from_flask(itemtypes_api.get_collection_queryables, request, 

273 collection_id) 

274 

275 

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 

285 

286 :param collection_id: collection identifier 

287 :param item_id: item identifier 

288 

289 :returns: HTTP response 

290 """ 

291 

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) 

312 

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) 

328 

329 

330@BLUEPRINT.route('/collections/<path:collection_id>/coverage') 

331def collection_coverage(collection_id: str): 

332 """ 

333 OGC API - Coverages coverage endpoint 

334 

335 :param collection_id: collection identifier 

336 

337 :returns: HTTP response 

338 """ 

339 

340 return execute_from_flask(coverages_api.get_collection_coverage, request, 

341 collection_id, skip_valid_check=True) 

342 

343 

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 

348 

349 :param collection_id: collection identifier 

350 

351 :returns: HTTP response 

352 """ 

353 

354 return execute_from_flask(tiles_api.get_collection_tiles, request, 

355 collection_id) 

356 

357 

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 

364 

365 :param collection_id: collection identifier 

366 :param tileMatrixSetId: identifier of tile matrix set 

367 

368 :returns: HTTP response 

369 """ 

370 

371 return execute_from_flask(tiles_api.get_collection_tiles_metadata, 

372 request, collection_id, tileMatrixSetId, 

373 skip_valid_check=True) 

374 

375 

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 

385 

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 

391 

392 :returns: HTTP response 

393 """ 

394 

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 ) 

400 

401 

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 

407 

408 :param collection_id: collection identifier 

409 :param style_id: style identifier 

410 

411 :returns: HTTP response 

412 """ 

413 

414 return execute_from_flask( 

415 maps_api.get_collection_map, request, collection_id, style_id 

416 ) 

417 

418 

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 

424 

425 :param process_id: process identifier 

426 

427 :returns: HTTP response 

428 """ 

429 

430 return execute_from_flask(processes_api.describe_processes, request, 

431 process_id) 

432 

433 

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 

440 

441 :param job_id: job identifier 

442 

443 :returns: HTTP response 

444 """ 

445 

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) 

454 

455 

456@BLUEPRINT.route('/processes/<process_id>/execution', methods=['POST']) 

457def execute_process_jobs(process_id: str): 

458 """ 

459 OGC API - Processes execution endpoint 

460 

461 :param process_id: process identifier 

462 

463 :returns: HTTP response 

464 """ 

465 

466 return execute_from_flask(processes_api.execute_process, request, 

467 process_id) 

468 

469 

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 

475 

476 :param job_id: job identifier 

477 

478 :returns: HTTP response 

479 """ 

480 

481 return execute_from_flask(processes_api.get_job_result, request, job_id) 

482 

483 

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 

507 

508 :param collection_id: collection identifier 

509 :param instance_id: instance identifier 

510 :param location_id: location id of a /locations/<location_id> query 

511 

512 :returns: HTTP response 

513 """ 

514 

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 ) 

522 

523 if location_id: 

524 query_type = 'locations' 

525 else: 

526 query_type = request.path.split('/')[-1] 

527 

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 ) 

532 

533 

534@BLUEPRINT.route('/stac-api') 

535def stac_landing_page(): 

536 """ 

537 STAC API landing page endpoint 

538 

539 :returns: HTTP response 

540 """ 

541 

542 return execute_from_flask(stac_api.landing_page, request) 

543 

544 

545@BLUEPRINT.route('/stac-api/search', methods=['GET', 'POST']) 

546def stac_search(): 

547 """ 

548 STAC API search endpoint 

549 

550 :returns: HTTP response 

551 """ 

552 

553 return execute_from_flask(stac_api.search, request) 

554 

555 

556@BLUEPRINT.route('/stac') 

557def stac_catalog_root(): 

558 """ 

559 STAC root endpoint 

560 

561 :returns: HTTP response 

562 """ 

563 

564 return execute_from_flask(stac_api.get_stac_root, request) 

565 

566 

567@BLUEPRINT.route('/stac/<path:path>') 

568def stac_catalog_path(path: str): 

569 """ 

570 STAC path endpoint 

571 

572 :param path: path 

573 

574 :returns: HTTP response 

575 """ 

576 

577 return execute_from_flask(stac_api.get_stac_path, request, path) 

578 

579 

580@ADMIN_BLUEPRINT.route('/admin/config', methods=['GET', 'PUT', 'PATCH']) 

581def admin_config(): 

582 """ 

583 Admin endpoint 

584 

585 :returns: HTTP response 

586 """ 

587 

588 if request.method == 'GET': 

589 return execute_from_flask(admin_api.get_config_, request, 

590 alternative_api=admin_) 

591 

592 elif request.method == 'PUT': 

593 return execute_from_flask(admin_api.put_config, request, 

594 alternative_api=admin_) 

595 

596 elif request.method == 'PATCH': 

597 return execute_from_flask(admin_api.patch_config, request, 

598 alternative_api=admin_) 

599 

600 

601@ADMIN_BLUEPRINT.route('/admin/config/resources', methods=['GET', 'POST']) 

602def admin_config_resources(): 

603 """ 

604 Resources endpoint 

605 

606 :returns: HTTP response 

607 """ 

608 

609 if request.method == 'GET': 

610 return execute_from_flask(admin_api.get_resources, request, 

611 alternative_api=admin_) 

612 

613 elif request.method == 'POST': 

614 return execute_from_flask(admin_api.post_resource, request, 

615 alternative_api=admin_) 

616 

617 

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 

624 

625 :returns: HTTP response 

626 """ 

627 

628 if request.method == 'GET': 

629 return execute_from_flask(admin_api.get_resource, request, 

630 resource_id, 

631 alternative_api=admin_) 

632 

633 elif request.method == 'DELETE': 

634 return execute_from_flask(admin_api.delete_resource, request, 

635 resource_id, 

636 alternative_api=admin_) 

637 

638 elif request.method == 'PUT': 

639 return execute_from_flask(admin_api.put_resource, request, 

640 resource_id, 

641 alternative_api=admin_) 

642 

643 elif request.method == 'PATCH': 

644 return execute_from_flask(admin_api.patch_resource, request, 

645 resource_id, 

646 alternative_api=admin_) 

647 

648 

649APP.register_blueprint(BLUEPRINT) 

650 

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) 

654 

655 

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. 

663 

664 :param server: `string` of server type 

665 :param debug: `bool` of whether to run in debug mode 

666 

667 :returns: void 

668 """ 

669 

670 # setup_logger(CONFIG['logging']) 

671 APP.run(debug=True, host=api_.config['server']['bind']['host'], 

672 port=api_.config['server']['bind']['port']) 

673 

674 

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()