diff --git a/.gitignore b/.gitignore index 5b4880f..0598508 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ ENV/ # pygeoapi artifacts local.config.yml local.openapi.yml +openapi.yml # misc *.swp diff --git a/README.md b/README.md index caf2252..ea524ce 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ cd pygeoapi pip install -r requirements.txt pip install -r requirements-dev.txt # install provider requirements accordingly from requirements-provider.txt +# install starlette requirements accordingly from requirements-starlette.txt pip install -e . cp pygeoapi-config.yml local.config.yml vi local.config.yml diff --git a/docs/source/asgi.rst b/docs/source/asgi.rst new file mode 100644 index 0000000..88da5c2 --- /dev/null +++ b/docs/source/asgi.rst @@ -0,0 +1,42 @@ +.. _asgi: + +ASGI +==== + +Asynchronous Server Gateway Interface (ASGI) is standard interface between async-capable web servers, frameworks, and applications written on Python language. pygeoapi itself +doesn't implement ASGI since it is an API, therefore it is required a webframework to access HTTP requests and pass the information to pygeoapi + +.. code-block:: console + + HTTP request --> Starlette (starlette_app.py) --> pygeoapi API + + +the pygeoapi package integrates `starlette_app `_ as webframework for defining the API routes/end points and WSGI support. + +The starlette ASGI server can be easily run as a pygeoapi command with the option `--starlette`: + +.. code-block:: console + + pygeoapi serve --starlette + +Running a Uvicorn server is not advisable, the preferred option is as follows: + +.. code-block:: console + + HTTP request --> ASGI server (gunicorn) --> Starlette (starlette_app.py) --> pygeoapi API + +By having a specific ASGI server, the HTTPS are efficiently processed into threads/processes. The current docker pygeoapi +implement such strategy (see section: :ref:`docker`), it is prefered to implement pygeopai using docker solutions than running host native ASGI servers. + + +Running gunicorn +---------------- + +Uvicorn includes a Gunicorn worker class allowing you to run ASGI applications, with all of Uvicorn's performance benefits, while also giving you Gunicorn's fully-featured process management. This server +is simple to run from the command, e.g: + +.. code-block:: console + + gunicorn pygeoapi.starlette_app:app -w 4 -k uvicorn.workers.UvicornWorker + +For extra configuration parameters like port binding, workers please consult the gunicorn `settings `_ diff --git a/docs/source/index.rst b/docs/source/index.rst index 9450987..7995741 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -17,6 +17,7 @@ Welcome to pygeoapi's documentation! openapi docker wsgi + asgi configuration plugins code diff --git a/docs/source/install.rst b/docs/source/install.rst index 40ef175..432e226 100644 --- a/docs/source/install.rst +++ b/docs/source/install.rst @@ -3,18 +3,18 @@ Install ======= -pygeoapi is nativally run as a Flask app (the code struct is an API and Flask is used as a wrapper). +pygeoapi, by default, is natively run as a Flask app (the code struct is an API and Flask is used as a wrapper). Optionally it can be run as a Starlette app. pygeoapi uses two configuration files: **pygeoapi-config.yml** and **openapi.yml**. First configuration contains all the information and setup to run pygeoapi while the second is exclusivally for the openapi. pygeoapi requires setting ``PYGEOAPI_CONFIG`` and ``PYGEOAPI_OPENAPI`` env variable. ``PYGEOAPI_CONFIG`` points to the yaml file containing the configuration, in the example -bellow we copy the ``local.config.yml`` default configuration to ``pygeoapi-config.yml`` and use this configuration file. +below we copy the ``local.config.yml`` default configuration to ``pygeoapi-config.yml`` and use this configuration file. ``PYGEOAPI_OPENAPI`` variable is the path to openapi file configuration, this file **needs to be autogenerated** using the ``pygeoapi generate-openapi-document`` command and -the pygeoapi config files e.g: ``pygeoapi generate-openapi-document -c local.config.yml > openapi.yml``. And then setting the env variable to the path: +the pygeoapi config files e.g.: ``pygeoapi generate-openapi-document -c local.config.yml > openapi.yml``. And then setting the env variable to the path: ``export PYGEOAPI_OPENAPI=/path/to/openapi.yml`` -For production environments it is recommended to use :ref:`docker` or a specialized WSGI HTTP server :ref:`wsgi` +For production environments it is recommended to use :ref:`docker` or specialized servers like WSGI HTTP server :ref:`wsgi` or ASGI HTTP server :ref:`asgi` Copy/paste install ------------------ @@ -23,32 +23,37 @@ It is advisable to run pygeoapi inside an isolated enviroment either *virtualenv .. code-block:: console - #create virtualenv + # create virtualenv virtualenv -p python pygeoapi cd pygeoapi . bin/activate git clone https://github.com/geopython/pygeoapi.git cd pygeoapi - - #install requirements + + # install requirements pip install -r requirements.txt pip install -r requirements-dev.txt - + + # optionally install requirements for starlette + pip install -r requirements-starlette.txt + # install source in current directory pip install -e . cp pygeoapi-config.yml local.config.yml - #edit configuration file + # edit configuration file nano local.config.yml export PYGEOAPI_CONFIG=/path/to/local.config.yml # generate OpenAPI Document pygeoapi generate-openapi-document -c local.config.yml > openapi.yml export PYGEOAPI_OPENAPI=/path/to/openapi.yml - - #Run pygeoapi + + # run pygeoapi pygeoapi serve + # optionally run pygeoapi with starlette + pygeoapi serve --starlette -If the default configuration was used then we should have pygeoapi running on +If the default configuration was used then we should have pygeoapi running on .. image:: /_static/openapi_intro_page.png diff --git a/docs/source/wsgi.rst b/docs/source/wsgi.rst index f582956..53f5c68 100644 --- a/docs/source/wsgi.rst +++ b/docs/source/wsgi.rst @@ -5,7 +5,7 @@ WSGI Web Server Gateway Interface (WSGI) is standard for forwarding request to web applications written on Python language. pygeoapi it self doesn't implement WSGI since it is an API, - therefore it is required a webframework to access HTTP requests and pass the information to pygeopai + therefore it is required a webframework to access HTTP requests and pass the information to pygeoapi .. code-block:: console @@ -14,17 +14,17 @@ WSGI the pygeoapi package integrates `Flask `_ as webframework for defining the API routes/end points and WSGI support. -The flask WSGI server can be easely run as a pygeoapi command: +The flask WSGI server can be easily run as a pygeoapi command with the option `--flask`: .. code-block:: console - pygeoapi serve + pygeoapi serve --flask -Running a native Flask server is not adivsable, the prefered option is as follows: +Running a native Flask server is not advisable, the prefered option is as follows: .. code-block:: console - HTTP request --> WSGI server (gunicorn) --> Flask (flask_app.py) --> pygeopai API + HTTP request --> WSGI server (gunicorn) --> Flask (flask_app.py) --> pygeoapi API By having a specific WSGI server, the HTTPS are efficiently processed into threads/processes. The current docker pygeoapi implement such strategy (see section: :ref:`docker`), it is prefered to implement pygeopai using docker solutions than running host native WSGI servers. diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py index 41e1b17..8fdd3a5 100644 --- a/pygeoapi/__init__.py +++ b/pygeoapi/__init__.py @@ -30,15 +30,35 @@ __version__ = '0.6.0' import click -from pygeoapi.flask_app import serve from pygeoapi.openapi import generate_openapi_document -@click.group() -@click.version_option(version=__version__) -def cli(): - pass +cli = click.Group() +cli.version = __version__ + + +@cli.command() +@click.option('--flask', 'server', flag_value="flask", default=True) +@click.option('--starlette', 'server', flag_value="starlette") +@click.pass_context +def serve(ctx, server): + """Run the server with different daemon type + + :param server: `string` of server type + :returns void + + """ + + if server == "flask": + from pygeoapi.flask_app import serve as serve_flask + ctx.forward(serve_flask) + ctx.invoke(serve_flask) + elif server == "starlette": + from pygeoapi.starlette_app import serve as serve_starlette + ctx.forward(serve_starlette) + ctx.invoke(serve_starlette) + else: + raise click.ClickException('--flask/--starlette is required') -cli.add_command(serve) cli.add_command(generate_openapi_document) diff --git a/pygeoapi/api.py b/pygeoapi/api.py index 0bbb1ea..6d6a511 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -368,13 +368,14 @@ class API(object): return headers_, 200, json.dumps(fcm) - def get_features(self, headers, args, dataset): + def get_features(self, headers, args, dataset, pathinfo=None): """ Queries feature collection :param headers: dict of HTTP headers :param args: dict of HTTP request parameters :param dataset: dataset name + :param pathinfo: path location :returns: tuple of headers, status code, content """ @@ -559,9 +560,14 @@ class API(object): content['links'][1]['rel'] = 'self' # For constructing proper URIs to items - path_info = '/'.join([ - self.config['server']['url'].rstrip('/'), - headers.environ['PATH_INFO'].strip('/')]) + if pathinfo: + path_info = '/'.join([ + self.config['server']['url'].rstrip('/'), + pathinfo.strip('/')]) + else: + path_info = '/'.join([ + self.config['server']['url'].rstrip('/'), + headers.environ['PATH_INFO'].strip('/')]) content['items_path'] = path_info content['dataset_path'] = '/'.join(path_info.split('/')[:-1]) diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index 71192ad..50f9707 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -64,7 +64,7 @@ api_ = API(CONFIG) @APP.route('/') def root(): """ - HTTP root content of pygeopai. Intro page access point + HTTP root content of pygeoapi. Intro page access point :returns: HTTP response """ @@ -205,11 +205,12 @@ def execute_process(name=None): @click.command() @click.pass_context @click.option('--debug', '-d', default=False, is_flag=True, help='debug') -def serve(ctx, debug=False): +def serve(ctx, server, debug=False): """ Serve pygeoapi via Flask. Runs pygeoapi as a flask server. Not recommend for production. + :param server: `string` of server type :param debug: `bool` of whether to run in debug mode :returns void diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py new file mode 100644 index 0000000..8447bcf --- /dev/null +++ b/pygeoapi/starlette_app.py @@ -0,0 +1,232 @@ +# ================================================================= +# +# Authors: Francesco Bartoli +# +# +# Copyright (c) 2019 Francesco Bartoli +# +# 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. +# +# ================================================================= +""" Starlette module providing the route paths to the api""" + +import os + +import click + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +import uvicorn + +from pygeoapi.api import API +from pygeoapi.util import yaml_load + +app = Starlette() + +CONFIG = None + +if 'PYGEOAPI_CONFIG' not in os.environ: + raise RuntimeError('PYGEOAPI_CONFIG environment variable not set') + +with open(os.environ.get('PYGEOAPI_CONFIG')) as fh: + CONFIG = yaml_load(fh) + +# CORS: optionally enable from config. +if CONFIG['server'].get('cors', False): + from starlette.middleware.cors import CORSMiddleware + app.add_middleware(CORSMiddleware, allow_origins=['*']) + + +api_ = API(CONFIG) + + +@app.route('/') +async def root(request: Request): + """ + HTTP root content of pygeoapi. Intro page access point + :returns: Starlette HTTP Response + """ + headers, status_code, content = api_.root( + request.headers, request.query_params) + + response = Response(content=content, status_code=status_code) + if headers: + response.headers.update(headers) + + return response + + +@app.route('/api') +async def api(request: Request): + """ + OpenAPI access point + + :returns: Starlette HTTP Response + """ + with open(os.environ.get('PYGEOAPI_OPENAPI')) as ff: + openapi = yaml_load(ff) + + headers, status_code, content = api_.api( + request.headers, request.query_params, openapi) + + response = Response(content=content, status_code=status_code) + if headers: + response.headers.update(headers) + + return response + + +@app.route('/conformance') +async def api_conformance(request: Request): + """ + OGC open api conformance access point + + :returns: Starlette HTTP Response + """ + + headers, status_code, content = api_.api_conformance( + request.headers, request.query_params) + + response = Response(content=content, status_code=status_code) + if headers: + response.headers.update(headers) + + return response + + +@app.route('/collections') +@app.route('/collections/{name}') +async def describe_collections(request: Request, name=None): + """ + OGC open api collections access point + + :param name: identifier of collection name + :returns: Starlette HTTP Response + """ + + if 'name' in request.path_params: + name = request.path_params['name'] + headers, status_code, content = api_.describe_collections( + request.headers, request.query_params, name) + + response = Response(content=content, status_code=status_code) + if headers: + response.headers.update(headers) + + return response + + +@app.route('/collections/{feature_collection}/items') +@app.route('/collections/{feature_collection}/items/{feature}') +async def dataset(request: Request, feature_collection=None, feature=None): + """ + OGC open api collections/{dataset}/items/{feature} access point + + :returns: Starlette HTTP Response + """ + + if 'feature_collection' in request.path_params: + feature_collection = request.path_params['feature_collection'] + if 'feature' in request.path_params: + feature = request.path_params['feature'] + if feature is None: + headers, status_code, content = api_.get_features( + request.headers, request.query_params, + feature_collection, pathinfo=request.scope['path']) + else: + headers, status_code, content = api_.get_feature( + request.headers, request.query_params, feature_collection, feature) + + response = Response(content=content, status_code=status_code) + + if headers: + response.headers.update(headers) + + return response + + +@app.route('/processes') +@app.route('/processes/{name}') +async def describe_processes(request: Request, name=None): + """ + OGC open api processes access point (experimental) + + :param name: identifier of process to describe + :returns: Starlette HTTP Response + """ + headers, status_code, content = api_.describe_processes( + request.headers, request.query_params, name) + + response = Response(content=content, status_code=status_code) + + if headers: + response.headers.update(headers) + + return response + + +@app.route('/processes/{name}/jobs', methods=['GET', 'POST']) +async def execute_process(request: Request, name=None): + """ + OGC open api jobs from processes access point (experimental) + + :param name: identifier of process to execute + :returns: Starlette HTTP Response + """ + + if request.method == 'GET': + headers, status_code, content = ({}, 200, "[]") + elif request.method == 'POST': + headers, status_code, content = api_.execute_process( + request.headers, request.query_params, request.data, name) + + response = Response(content=content, status_code=status_code) + + if headers: + response.headers.update(headers) + + return response + + +@click.command() +@click.pass_context +@click.option('--debug', '-d', default=False, is_flag=True, help='debug') +def serve(ctx, server, debug=False): + """ + Serve pygeoapi via Starlette. Runs pygeoapi + as a uvicorn server. Not recommend for production. + + :param server: `string` of server type + :param debug: `bool` of whether to run in debug mode + :returns void + """ + +# setup_logger(CONFIG['logging']) + uvicorn.run( + app, debug=True, + host=api_.config['server']['bind']['host'], + port=api_.config['server']['bind']['port']) + + +if __name__ == "__main__": # run locally, for testing + serve() diff --git a/requirements-starlette.txt b/requirements-starlette.txt new file mode 100644 index 0000000..c087550 --- /dev/null +++ b/requirements-starlette.txt @@ -0,0 +1,2 @@ +starlette +uvicorn \ No newline at end of file