├── .dockerignore ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.rst ├── docker-compose.yml ├── docs ├── Makefile └── source │ ├── conf.py │ ├── index.rst │ ├── openelevationservice.rst │ └── readme_link.rst ├── gunicorn_config.py ├── manage.py ├── openelevationservice ├── __init__.py ├── server │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── api_exceptions.py │ │ ├── oes_post.yaml │ │ ├── querybuilder.py │ │ ├── response.py │ │ ├── validator.py │ │ └── views.py │ ├── config.py │ ├── db_import │ │ ├── __init__.py │ │ ├── filestreams.py │ │ └── models.py │ ├── ops_settings.sample.yml │ └── utils │ │ ├── __init__.py │ │ ├── codec.py │ │ ├── convert.py │ │ ├── custom_func.py │ │ └── logger.py └── tests │ ├── __init__.py │ ├── base.py │ ├── test_api_line.py │ ├── test_api_point.py │ ├── test_codec.py │ └── test_convert.py ├── ops_settings_docker.sample.yml ├── requirements.txt └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | tiles/* 3 | *.tif 4 | *.hdr 5 | *.tfw 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | sql/ 3 | run_travis.sh 4 | .venv/ 5 | .spyproject/ 6 | *__pycache__/ 7 | tiles2/ 8 | *.log 9 | openelevationservice/server/ops_settings.yml 10 | ops_settings_docker.yml 11 | test.py 12 | *.tif 13 | *.hdr 14 | *.tfw 15 | 16 | # Docs 17 | docs/build 18 | 19 | # PyPI 20 | dist/ 21 | *egg-info/ 22 | *.in 23 | build/ 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | 3 | language: python 4 | 5 | python: 6 | - "3.7" 7 | 8 | services: 9 | - postgresql 10 | 11 | addons: 12 | postgresql: "10" 13 | apt: 14 | packages: 15 | - postgresql-10 16 | - postgresql-client-10 17 | - postgresql-10-postgis-2.4 18 | 19 | before_install: 20 | # annoying tzdata 21 | - /bin/bash -c "DEBIAN_FRONTEND=noninteractive sudo apt-get install -y tzdata" 22 | - sudo ln -fs /usr/share/zoneinfo/Europe/Berlin /etc/localtime 23 | - sudo dpkg-reconfigure --frontend noninteractive tzdata 24 | # symlink raster2pgsql 25 | - sudo apt-get update -qq 26 | - sudo apt-get install -y postgis 27 | 28 | env: 29 | global: 30 | # Flask env variables 31 | - TESTING=true 32 | - FLASK_APP="$TRAVIS_BUILD_DIR/manage" 33 | - OES_LOGLEVEL="DEBUG" 34 | - APP_SETTINGS="openelevationservice.server.config.TestingConfig" 35 | # CGIAR credentials, encrypted 36 | #- secure: GfAv9pnb9FRodSO4FMnQS8o7fQ9nLUl56m50EkrOHumxxMDMQfmquT98cuaeiRLVq9XmQhz4P2U1kHqKBUuAHTF1s+/V6RC3KQ6WxOS3xlmpAg4NMQF7MwTLanUtXOVkIozaeuDZiXQqjo+wTC92FYvGA17KBmTK1yfsEn6HGcfHw5ax2gJ+kmFnSNwN0VzrGOzw0zOVSaM8pAObuEYr1mgqNfo0wnX9O7SCifSILJGxTx1FwZqQWGsxeIBjkQkNlhLmChCd1Qm0k+ioiZOuIOeEst0+owZ/Wla4pJellObJjCxZ1sMAvgaFvOaXgR8iscw8zsUbRO2cePuaRoHFLqOKBTSokS7KibY2LDTuI7m5aVblPWp49MjhjiHaok53VN0IXfW5yHZQW7LtHyMHopTVT7L5zn2RKZNfnREYlkKZcYb04WXxUVAl3LwRAkDe1vxrkb+GzqgY4n438joFEPUUT+oR3c+xMIP+eZXEGO0HnOmpkd29UFT/ZizXt02MfSRHgmieL3POi0Wif+0jGDlxfTcvom4gikv3vyldanVlO/PySop+R0mFczUD5IMZIacEu/smkp/9XNO3gmBJ0T0LX1+4BwShbw75HERiVArlY4f6kbnshx065ayCzFqvUd0fYtT7VniIgyL2otZdcEu+NL3G/MmNQ+mGhFZBZs8= 37 | #- secure: f4/sARD0iEUG8e5W3/D7ILk14Vf2w8OOJoPFDpOGiRBWdkCxf22mMjrErYWfLitW/rdhNUVXF3G2DP0WgxM6aTvNUR9aA4YoUrwbHy0ZF/vL70duayOH+wvHY1tDSvWJS3zfruaS4vXh27Eky1LZ1P1e5ke7t13TXmbyH6JqofYzIGAsttX2X2Kl0bBmO7LMlbKf8A7WjysSus+IkoDFMaGneUozDJvscmtD7XMuzjMcd5RxMHRiHNsWtlb+OjXVolSuJmF6T0ubD8un5X6kYD1IjWPUY8GLpegCe9zo2eAbZAYhocx0Sz0zDMcwSn1wtzSN83KYjLoRZjuKCBE36mxpRCHNmTzFsco6+MAjRMCZmNABRrufstfA6N/4mbpc6UJu2kAYyjUiufVt04sPInCYU7oR953whM1P1EfAFG2HqKJ5NWqiUOUBxxUr0IG2VACqmdUFzerKyYRhsASfwMXT+ghw9gxxso5SPFDt6v6vjqeA1sRX5rNSsZiWoT2GZEet38fIG603N9L/9b/q9PpbTZTAWB5HD8P3BUBimdCbApXPwATuqln+KXDr6CC0t0Q1t8r4Gab9RFfQ6xrqzgyd86YW/TcN8V0R8wXt9/jtb3W4KQYWMRyQm+y5Pp7qNc23MhXK9MxX9iqL1mhHGA2PoQTVjT5597S/qVVuptY= 38 | # - PGPORT=5432 39 | 40 | # database creation 41 | before_script: 42 | - sudo -u postgres psql -c "CREATE USER gis WITH PASSWORD 'gis';" 43 | - sudo -u postgres psql -c "ALTER USER gis WITH SUPERUSER;" 44 | - sudo -u postgres psql -c "CREATE DATABASE gis;" 45 | - sudo -u postgres psql -c "CREATE EXTENSION postgis;" -d gis 46 | - mv openelevationservice/server/ops_settings.sample.yml openelevationservice/server/ops_settings.yml 47 | 48 | install: pip install -r requirements.txt 49 | 50 | script: nosetests -v 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ### Added 10 | - 11 | 12 | ### Fixed 13 | - Accepts content-type charset definitions (#17) 14 | - X and Y are swapped in downloader (#20) 15 | 16 | ### Changed 17 | - 18 | 19 | ### Deprecated 20 | - 21 | 22 | ## [0.2] - 2019-03-25 23 | ### Added 24 | - 25 | ### Fixed 26 | 27 | - add polyline6 support (#11) 28 | - fix consecutive same coordinates being scrubbed (#8) 29 | - rounding issues in encode polyline (#9) 30 | - download_data confused x and y (no issue) 31 | 32 | ### Changed 33 | - 34 | ### Deprecated 35 | - 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # gunicorn-flask 2 | 3 | # requires this ubuntu version due to protobuf library update 4 | FROM ubuntu:18.04 5 | MAINTAINER Nils Nolde 6 | 7 | RUN apt-get update 8 | RUN apt-get install -y locales git python3-venv 9 | 10 | # Set the locale 11 | RUN locale-gen en_US.UTF-8 12 | ENV LANG en_US.UTF-8 13 | ENV LANGUAGE en_US:en 14 | ENV LC_ALL en_US.UTF-8 15 | # oes/flask variables 16 | ENV OES_LOGLEVEL INFO 17 | ENV FLASK_APP manage 18 | ENV FLASK_ENV production 19 | ENV APP_SETTINGS openelevationservice.server.config.ProductionConfig 20 | 21 | # tzdata is being annoying otherwise 22 | RUN /bin/bash -c "DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata" 23 | RUN ln -fs /usr/share/zoneinfo/Europe/Berlin /etc/localtime 24 | RUN dpkg-reconfigure --frontend noninteractive tzdata 25 | 26 | # Needs postgis installation locally for raster2pgsql 27 | RUN apt-get install -y postgis 28 | 29 | # Setup flask application 30 | RUN mkdir -p /deploy/app 31 | 32 | COPY gunicorn_config.py /deploy/gunicorn_config.py 33 | COPY manage.py /deploy/app/manage.py 34 | 35 | COPY requirements.txt /deploy/app/requirements.txt 36 | 37 | RUN python3 -m venv /oes_venv 38 | 39 | RUN /bin/bash -c "source /oes_venv/bin/activate" 40 | 41 | RUN /oes_venv/bin/pip3 install -r /deploy/app/requirements.txt 42 | 43 | COPY openelevationservice /deploy/app/openelevationservice 44 | COPY ops_settings_docker.yml /deploy/app/openelevationservice/server/ops_settings.yml 45 | 46 | WORKDIR /deploy/app 47 | 48 | EXPOSE 5000 49 | 50 | # Start gunicorn 51 | CMD ["/oes_venv/bin/gunicorn", "--config", "/deploy/gunicorn_config.py", "manage:app"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/GIScience/openelevationservice.svg?branch=master 2 | :target: https://travis-ci.com/GIScience/openelevationservice 3 | :alt: Build status 4 | 5 | .. image:: https://readthedocs.org/projects/openelevationservice/badge/?version=latest 6 | :target: https://openelevationservice.readthedocs.io/en/latest/ 7 | :alt: Documentation Status 8 | 9 | Quickstart 10 | ================================================== 11 | 12 | Description 13 | -------------------------------------------------- 14 | 15 | openelevationservice is a Flask application which extracts elevation from various elevation datasets for `Point` or `LineString` 2D geometries and returns 3D geometries in various formats. 16 | 17 | Supported formats are: 18 | 19 | - GeoJSON 20 | - Polyline, i.e. list of vertices 21 | - Google's `encoded polyline`_ 22 | - Point, i.e. one vertex 23 | 24 | For general support and questions, please contact our forum_. After successful installation, you can also find a API documentation locally at https://localhost:5000/apidocs, provided via flasgger_. 25 | 26 | For issues and improvement suggestions, use the repo's `issue tracker`_. 27 | 28 | This service is part of the GIScience_ software stack, crafted at `HeiGIT institute`_ at the University of Heidelberg. 29 | 30 | You can also use our `free API`_ via (also check Endpoints_ for usage): 31 | 32 | - https://api.openrouteservice.org/elevation/point?api_key=YOUR_KEY 33 | - https://api.openrouteservice.org/elevation/line?api_key=YOUR_KEY 34 | 35 | .. _GIScience: https://github.com/GIScience 36 | .. _`HeiGIT institute`: https://heigit.org 37 | .. _`SRTM v4.1`: http://srtm.csi.cgiar.org 38 | .. _`encoded polyline`: https://developers.google.com/maps/documentation/utilities/polylinealgorithm 39 | .. _forum: https://ask.openrouteservice.org/c/elevation 40 | .. _`issue tracker`: https://github.com/GIScience/openelevationservice/issues 41 | .. _flasgger: https://github.com/rochacbruno/flasgger 42 | .. _`free API`: https://openrouteservice.org/sign-up 43 | 44 | Installation 45 | ---------------------------------------------------- 46 | 47 | You can either run this service on a host machine (like your PC) in a virtual environment or via docker (recommended). 48 | 49 | Docker installation 50 | #################################################### 51 | 52 | Prerequisites 53 | ++++++++++++++++++++++++++++++++++++++++++++++++++++ 54 | 55 | - Docker 56 | - PostGIS installation (recommended `Kartoza's docker`_) 57 | 58 | Run Docker container 59 | ++++++++++++++++++++++++++++++++++++++++++++++++++++ 60 | 61 | 1. Customize ``ops_settings_docker.sample.yml`` to your needs and name it ``ops_settings_docker.yml`` 62 | 63 | 2. Build container 64 | ``sudo docker-compose up -d`` 65 | 66 | 3. Create the database 67 | 68 | .. code-block:: bash 69 | 70 | sudo docker exec -it bash -c "source /oes_venv/bin/activate; export OES_LOGLEVEL=DEBUG; flask create" 71 | 72 | 4. Download SRTM data 73 | 74 | .. code-block:: bash 75 | 76 | sudo docker exec -it bash -c "source /oes_venv/bin/activate; export OES_LOGLEVEL=DEBUG; flask download --xyrange=0,0,73,25" 77 | 78 | The optional ``xyrange`` parameter specfies the ``minx,miny,maxx,maxy`` indices of the available tiles, default is ``0,0,73,25``. You can see a representation of indices in the map on the `CGIAR website`_. 79 | 80 | **Note**, that you need to have credentials to access the `FTP site`_ , which you can request here_. 81 | 82 | 5. Import SRTM data 83 | 84 | .. code-block:: bash 85 | 86 | sudo docker exec -it bash -c "source /oes_venv/bin/activate; flask importdata" 87 | 88 | The import command will import whatever ``.tif`` files it finds in ``./tiles``. Now, it's time to grab a coffee, this might take a while. Expect a few hours for a remote database connection with HDD's and the global dataset. 89 | 90 | After it's all finished, the service will listen on port ``5020`` of your host machine, unless specified differently in ``docker-compose.yml``. 91 | 92 | 93 | .. _`Kartoza's docker`: https://github.com/kartoza/docker-postgis 94 | .. _here: https://harvestchoice.wufoo.com/forms/download-cgiarcsi-srtm/ 95 | .. _`FTP site`: http://data.cgiar-csi.org/srtm/tiles/GeoTIFF/ 96 | .. _`CGIAR website`: http://srtm.csi.cgiar.org/SELECTION/inputCoord.asp 97 | 98 | 99 | Conventional installation 100 | #################################################### 101 | 102 | This tutorial assumes a Ubuntu system. 103 | 104 | Max OSX should be similar, if not the same. Windows is of course possible, but many of the commands will have to be altered. 105 | 106 | Prerequisites 107 | ++++++++++++++++++++++++++++++++++++++++++++++++++++ 108 | 109 | - Python 3.6 or higher 110 | - PostGIS installation on the host machine (solely needed for `raster2pgsql`) 111 | - (Optional) Remote PostGIS installation (if you want to use a different data server) 112 | 113 | Create virtual environment 114 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ 115 | 116 | First, customize ``./openelevationservice/server/ops_settings.sample.yml`` and name it ``ops_settings.yml``. 117 | 118 | Then you can set up the environment: 119 | 120 | .. code-block:: bash 121 | 122 | cd openelevationservice 123 | # Either via virtualenv, venv package or conda 124 | python3.6 -m venv .venv 125 | # or 126 | virtualenv python=python3.6 .venv 127 | # or 128 | conda create -n oes python=3.6 129 | 130 | # Activate virtual env (or equivalent conda command) 131 | source .venv/bin/activate 132 | # Add FLASK_APP environment variable 133 | # For conda, see here: https://conda.io/docs/user-guide/tasks/manage-environments.html#macos-and-linux 134 | echo "export FLASK_APP=manage" >> .venv/bin/activate 135 | # Install required packages 136 | pip install -r requirements.txt 137 | 138 | When your environment is set up, you can run the import process and start the server: 139 | 140 | .. code-block:: bash 141 | 142 | # inside the repo root directory 143 | flask create 144 | # rather as a background/nohup job, will download 27 GB 145 | flask download --xyrange=0,0,73,25 146 | flask importdata 147 | 148 | # Start the server 149 | flask run 150 | 151 | The service will now listen on ```http://localhost:5000``. 152 | 153 | Endpoints 154 | ---------------------------------------------------------- 155 | 156 | The default base url is ``http://localhost:5000/``. 157 | 158 | The openelevationservice exposes 2 endpoints: 159 | 160 | - ``/elevation/line``: used for LineString geometries 161 | - ``/elevation/point``: used for single Point geometries 162 | 163 | +-----------------------+-------------------+------------+---------+---------------------------------------------------------+ 164 | | Endpoint | Method(s) allowed | Parameter | Default | Values | 165 | +=======================+===================+============+=========+=========================================================+ 166 | | ``/elevation/line`` | POST | format_in | -- | geojson, polyline, encodedpolyline5, encodedpolyline6 | 167 | | | +------------+---------+---------------------------------------------------------+ 168 | | | | geometry | -- | depends on ``format_in`` | 169 | | | +------------+---------+---------------------------------------------------------+ 170 | | | | format_out | geojson | geojson, polyline, encodedpolyline5, encodedpolyline6 | 171 | | | +------------+---------+---------------------------------------------------------+ 172 | | | | dataset | srtm | srtm (so far) | 173 | +-----------------------+-------------------+------------+---------+---------------------------------------------------------+ 174 | | ``/elevation/point`` | GET, POST | format_in | -- | geojson, point | 175 | | | +------------+---------+---------------------------------------------------------+ 176 | | | | geometry | -- | depends on ``format_in`` | 177 | | | +------------+---------+---------------------------------------------------------+ 178 | | | | format_out | geojson | geojson, point | 179 | | | +------------+---------+---------------------------------------------------------+ 180 | | | | dataset | srtm | srtm (so far) | 181 | +-----------------------+-------------------+------------+---------+---------------------------------------------------------+ 182 | 183 | For more detailed information, please visit the `API documentation`_. 184 | 185 | When hosted locally, visit ``https://localhost:5000/apidocs``. 186 | 187 | .. _`API documentation`: https://openrouteservice.org/dev/#/api-docs/elevation 188 | 189 | Environment variables 190 | ########################################################## 191 | 192 | openelevationservice recognizes the following environment variables: 193 | 194 | +-----------------+-----------------------------------------+-------------------------------------------------------+-----------------------------+ 195 | | variable | function | Default | Values | 196 | +=================+=========================================+=======================================================+=============================+ 197 | | OES_LOGLEVEL | Sets the level of logging output | INFO | DEBUG, INFO, WARNING, ERROR | 198 | +-----------------+-----------------------------------------+-------------------------------------------------------+-----------------------------+ 199 | | APP_SETTINGS | Controls the behavior of ``config.py`` | openelevationservice.server.config.ProductionConfig | ProductionConfig, | 200 | | | | | | 201 | | | | | DevelopmentConfig | 202 | +-----------------+-----------------------------------------+-------------------------------------------------------+-----------------------------+ 203 | | FLASK_APP | Sets the app | manage | | 204 | +-----------------+-----------------------------------------+-------------------------------------------------------+-----------------------------+ 205 | | FLASK_ENV | Development/Production server | development | production, development | 206 | +-----------------+-----------------------------------------+-------------------------------------------------------+-----------------------------+ 207 | | TESTING | Sets flask testing environment | None | true | 208 | +-----------------+-----------------------------------------+-------------------------------------------------------+-----------------------------+ 209 | 210 | In the case of the Docker setup, you don't need to worry about environment variables for the most part. 211 | 212 | CLI 213 | ########################################################## 214 | 215 | The flask command line interface has a few additional commands: 216 | 217 | - ``flask create``: creates a table for CGIAR data 218 | - ```flask download --xyrange=0,73,0,25``: downloads CGIAR data and limits the X, Y indices optionally with ``xyrange`` 219 | - ``flask importdata``: imports CGIAR tiles it finds in ``./tiles/`` 220 | - ``flask drop``: drops CGIAR table 221 | 222 | Testing 223 | ######################################################## 224 | 225 | The testing framework is `nosetests`, which makes it very easy to run the tests: 226 | 227 | .. code-block:: bash 228 | 229 | TESTING=true nosetests -v 230 | 231 | 232 | Usage 233 | -------------------------------------------------------- 234 | 235 | GET point 236 | ######################################################### 237 | 238 | .. code-block:: bash 239 | 240 | curl -XGET https://localhost:5000/elevation/point?geometry=13.349762,38.11295 241 | 242 | POST point as GeoJSON 243 | ######################################################### 244 | 245 | .. code-block:: bash 246 | 247 | curl -XPOST http://localhost:5000/elevation/point \ 248 | -H 'Content-Type: application/json' \ 249 | -d '{ 250 | "format_in": "geojson", 251 | "format_out": "geojson", 252 | "geometry": { 253 | "coordinates": [13.349762, 38.11295], 254 | "type": "Point" 255 | } 256 | }' 257 | 258 | POST LineString as polyline 259 | ######################################################### 260 | 261 | .. code-block:: bash 262 | 263 | curl -XPOST http://localhost:5000/elevation/line \ 264 | -H 'Content-Type: application/json' \ 265 | -d '{ 266 | "format_in": "polyline", 267 | "format_out": "encodedpolyline", 268 | "geometry": [[13.349762, 38.11295], 269 | [12.638397, 37.645772]] 270 | }' 271 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.2' 2 | services: 3 | gunicorn_flask: 4 | #network_mode: "host" 5 | build: . 6 | volumes: 7 | - ./tiles:/deploy/app/tiles 8 | ports: 9 | - "5020:5000" 10 | mem_limit: 28g -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = openelevationservice 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath('../..')) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'openelevationservice' 24 | copyright = '2018, Nils Nolde' 25 | author = 'Nils Nolde' 26 | 27 | # The short X.Y version 28 | version = '0.1' 29 | # The full version, including alpha/beta/rc tags 30 | release = '0.1' 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | 'sphinx.ext.autodoc', 44 | 'sphinx.ext.todo', 45 | 'sphinx.ext.mathjax', 46 | ] 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ['_templates'] 50 | 51 | # The suffix(es) of source filenames. 52 | # You can specify multiple suffix as a list of string: 53 | # 54 | # source_suffix = ['.rst', '.md'] 55 | source_suffix = '.rst' 56 | 57 | # The master toctree document. 58 | master_doc = 'index' 59 | 60 | # The language for content autogenerated by Sphinx. Refer to documentation 61 | # for a list of supported languages. 62 | # 63 | # This is also used if you do content translation via gettext catalogs. 64 | # Usually you set "language" from the command line for these cases. 65 | language = None 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | # This pattern also affects html_static_path and html_extra_path . 70 | exclude_patterns = [] 71 | 72 | # The name of the Pygments (syntax highlighting) style to use. 73 | pygments_style = 'sphinx' 74 | 75 | 76 | # -- Options for HTML output ------------------------------------------------- 77 | 78 | # The theme to use for HTML and HTML Help pages. See the documentation for 79 | # a list of builtin themes. 80 | # 81 | html_theme = 'alabaster' 82 | 83 | # Theme options are theme-specific and customize the look and feel of a theme 84 | # further. For a list of options available for each theme, see the 85 | # documentation. 86 | # 87 | # html_theme_options = {} 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | html_static_path = ['_static'] 93 | 94 | # Custom sidebar templates, must be a dictionary that maps document names 95 | # to template names. 96 | # 97 | # The default sidebars (for documents that don't match any pattern) are 98 | # defined by theme itself. Builtin themes are using these templates by 99 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 100 | # 'searchbox.html']``. 101 | # 102 | # html_sidebars = {} 103 | 104 | 105 | # -- Options for HTMLHelp output --------------------------------------------- 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'openelevationservicedoc' 109 | 110 | 111 | # -- Options for LaTeX output ------------------------------------------------ 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | # 116 | # 'papersize': 'letterpaper', 117 | 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | 126 | # Latex figure (float) alignment 127 | # 128 | # 'figure_align': 'htbp', 129 | } 130 | 131 | # Grouping the document tree into LaTeX files. List of tuples 132 | # (source start file, target name, title, 133 | # author, documentclass [howto, manual, or own class]). 134 | latex_documents = [ 135 | (master_doc, 'openelevationservice.tex', 'openelevationservice Documentation', 136 | 'Nils Nolde', 'manual'), 137 | ] 138 | 139 | 140 | # -- Options for manual page output ------------------------------------------ 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [ 145 | (master_doc, 'openelevationservice', 'openelevationservice Documentation', 146 | [author], 1) 147 | ] 148 | 149 | 150 | # -- Options for Texinfo output ---------------------------------------------- 151 | 152 | # Grouping the document tree into Texinfo files. List of tuples 153 | # (source start file, target name, title, author, 154 | # dir menu entry, description, category) 155 | texinfo_documents = [ 156 | (master_doc, 'openelevationservice', 'openelevationservice Documentation', 157 | author, 'openelevationservice', 'One line description of project.', 158 | 'Miscellaneous'), 159 | ] 160 | 161 | 162 | # -- Extension configuration ------------------------------------------------- 163 | 164 | # -- Options for todo extension ---------------------------------------------- 165 | 166 | # If true, `todo` and `todoList` produce output, else they produce nothing. 167 | todo_include_todos = True 168 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. openelevationservice documentation master file, created by 2 | sphinx-quickstart on Tue Nov 6 09:27:41 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to openelevationservice's documentation! 7 | ================================================ 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | readme_link 14 | openelevationservice 15 | 16 | .. include:: ../../README.rst 17 | .. include:: openelevationservice.rst 18 | -------------------------------------------------------------------------------- /docs/source/openelevationservice.rst: -------------------------------------------------------------------------------- 1 | Library reference 2 | ======================================= 3 | 4 | openelevationservice.server.api 5 | ------------------------------------------------------ 6 | 7 | openelevationservice.server.api.api\_exceptions module 8 | ############################################################## 9 | 10 | .. automodule:: openelevationservice.server.api.api_exceptions 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | openelevationservice.server.api.querybuilder module 16 | ############################################################## 17 | 18 | .. automodule:: openelevationservice.server.api.querybuilder 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | openelevationservice.server.api.validator module 24 | ############################################################## 25 | 26 | .. automodule:: openelevationservice.server.api.validator 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | openelevationservice.server.api.views module 32 | ############################################################## 33 | 34 | .. automodule:: openelevationservice.server.api.views 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | 40 | openelevationservice.server.db\_import 41 | ------------------------------------------------------ 42 | 43 | openelevationservice.server.db\_import.filestreams module 44 | ############################################################## 45 | 46 | .. automodule:: openelevationservice.server.db_import.filestreams 47 | :members: 48 | :undoc-members: 49 | :show-inheritance: 50 | 51 | openelevationservice.server.db\_import.models module 52 | ############################################################## 53 | 54 | .. automodule:: openelevationservice.server.db_import.models 55 | :members: 56 | :undoc-members: 57 | :show-inheritance: 58 | 59 | 60 | openelevationservice.server.utils 61 | ------------------------------------------------------ 62 | 63 | openelevationservice.server.utils.convert module 64 | ############################################################## 65 | 66 | .. automodule:: openelevationservice.server.utils.convert 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | openelevationservice.server.utils.custom\_func module 72 | ############################################################## 73 | 74 | .. automodule:: openelevationservice.server.utils.custom_func 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | openelevationservice.server.utils.logger module 80 | ############################################################## 81 | 82 | .. automodule:: openelevationservice.server.utils.logger 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | -------------------------------------------------------------------------------- /docs/source/readme_link.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | -------------------------------------------------------------------------------- /gunicorn_config.py: -------------------------------------------------------------------------------- 1 | bind = "0.0.0.0:5000" 2 | # Generally we recommend (2 x $num_cores) + 1 as the number of workers to start off with 3 | workers = 4 4 | worker_class = 'gevent' 5 | worker_connections = 1000 6 | timeout = 30 7 | keepalive = 2 8 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.server import create_app 4 | from openelevationservice.server.utils.logger import get_logger 5 | from openelevationservice.server.config import SETTINGS 6 | from openelevationservice.server.db_import.models import db 7 | from openelevationservice.server.db_import import filestreams 8 | 9 | import click 10 | 11 | log = get_logger(__name__) 12 | 13 | app = create_app() 14 | 15 | @app.cli.command() 16 | @click.option('--xyrange', default='0,73,0,25') 17 | def download(xyrange): 18 | """ 19 | Downloads SRTM tiles to disk. Can be specified over minx, maxx, miny, maxy. 20 | 21 | :param xyrange: A comma-separated list of x_min, x_max, y_min, y_max 22 | in that order. For reference grid, see http://srtm.csi.cgiar.org/SELECTION/inputCoord.asp 23 | :type xyrange: comma-separated integers 24 | """ 25 | 26 | filestreams.downloadsrtm(_arg_format(xyrange)) 27 | log.info("Downloaded all files") 28 | 29 | 30 | @app.cli.command() 31 | def create(): 32 | """Creates all tables defined in models.py""" 33 | 34 | db.create_all() 35 | log.info("Table {} was created.".format(SETTINGS['provider_parameters']['table_name'])) 36 | 37 | 38 | @app.cli.command() 39 | def drop(): 40 | """Drops all tables defined in models.py""" 41 | 42 | db.drop_all() 43 | log.info("Table {} was dropped.".format(SETTINGS['provider_parameters']['table_name'])) 44 | 45 | 46 | @app.cli.command() 47 | def importdata(): 48 | """ 49 | Imports all data found in ./tiles 50 | 51 | :param xyrange: A comma-separated list of x_min, x_max, y_min, y_max 52 | in that order. For reference grid, see http://srtm.csi.cgiar.org/SELECTION/inputCoord.asp 53 | :type xyrange: comma-separated integers 54 | """ 55 | log.info("Starting to import data...") 56 | 57 | filestreams.raster2pgsql() 58 | 59 | log.info("Imported data successfully!") 60 | 61 | 62 | def _arg_format(xy_range_txt): 63 | 64 | str_split = [int(s.strip()) for s in xy_range_txt.split(',')] 65 | 66 | xy_range = [[str_split[0], str_split[2]], 67 | [str_split[1], str_split[3]]] 68 | 69 | return xy_range 70 | 71 | if __name__ == '__main__': 72 | app.cli() -------------------------------------------------------------------------------- /openelevationservice/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from os import getcwd, path, environ, makedirs 4 | from yaml import safe_load 5 | 6 | basedir = path.abspath(path.dirname(__file__)) 7 | SETTINGS = safe_load(open(path.join(basedir, 'server', 'ops_settings.yml'))) 8 | 9 | TILES_DIR = path.join(getcwd(), 'tiles') 10 | 11 | if "TESTING" in environ: 12 | SETTINGS['provider_parameters']['table_name'] = SETTINGS['provider_parameters']['table_name'] + '_test' 13 | TILES_DIR = path.join(basedir, 'tests', 'tile') 14 | # if "CI" in environ: 15 | # SETTINGS['provider_parameters']['port'] = 5433 16 | 17 | if not path.exists(TILES_DIR): 18 | makedirs(TILES_DIR) 19 | 20 | 21 | __version__ = "0.2.1" -------------------------------------------------------------------------------- /openelevationservice/server/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from openelevationservice import SETTINGS 3 | from openelevationservice.server.db_import.models import db 4 | from openelevationservice.server.api import api_exceptions 5 | from openelevationservice.server.utils import logger 6 | 7 | from flask import Flask, jsonify, g 8 | from flask_cors import CORS 9 | from flasgger import Swagger 10 | import os 11 | import time 12 | 13 | log = logger.get_logger(__name__) 14 | 15 | def create_app(script_info=None): 16 | # instantiate the app 17 | 18 | app = Flask(__name__) 19 | 20 | cors = CORS(app, resources={r"/elevation/*": {"origins": "*"}}) 21 | 22 | app.config['SWAGGER'] = { 23 | 'title': 'openelevationservice', 24 | "swagger_version": "2.0", 25 | 'version': 0.1, 26 | 'uiversion': 3 27 | } 28 | 29 | # set config 30 | app_settings = os.getenv('APP_SETTINGS', 'openelevationservice.server.config.ProductionConfig') 31 | app.config.from_object(app_settings) 32 | 33 | # set up extensions 34 | db.init_app(app) 35 | 36 | provider_details = SETTINGS['provider_parameters'] 37 | log.info("Following provider parameters are active:\n" 38 | "Host:\t{host}\n" 39 | "DB:\t{db_name}\n" 40 | "Table:\t{table_name}\n" 41 | "User:\t{user_name}".format(**provider_details)) 42 | 43 | # register blueprints 44 | from openelevationservice.server.api.views import main_blueprint 45 | app.register_blueprint(main_blueprint) 46 | 47 | Swagger(app, template_file='api/oes_post.yaml') 48 | 49 | if "Development" in app_settings: 50 | @app.before_request 51 | def before_request(): 52 | g.start = time.time() 53 | 54 | @app.teardown_request 55 | def teardown_request(exception=None): 56 | if 'start' in g: 57 | diff = time.time() - g.start 58 | log.debug("Request took: {} seconds".format(diff)) 59 | 60 | # error handlers 61 | @app.errorhandler(400) 62 | def bad_request(error): 63 | return jsonify({"code": 400, "message": "Bad Request"}) 64 | 65 | @app.errorhandler(401) 66 | def unauthorized_page(error): 67 | return jsonify({"code": 401, "message": "Unauthorized to view page"}) 68 | 69 | @app.errorhandler(403) 70 | def forbidden_page(error): 71 | return jsonify({"code": 403, "message": "Forbidden page"}) 72 | 73 | @app.errorhandler(404) 74 | def page_not_found(error): 75 | return jsonify({"code": 404, "message": "Endpoint not found"}) 76 | 77 | @app.errorhandler(405) 78 | def method_not_allowed(error): 79 | return jsonify({"code": 405, 'message': "HTTP Method not allowed"}) 80 | 81 | @app.errorhandler(500) 82 | def server_error_page(error): 83 | return jsonify({"code": 500, 'message': 'Server error'}) 84 | 85 | @app.errorhandler(api_exceptions.InvalidUsage) 86 | def handle_invalid_usage(error): 87 | response = jsonify(error.to_dict()) 88 | response.status_code = error.status_code 89 | return response 90 | 91 | # shell context for flask cli 92 | app.shell_context_processor({ 93 | 'app': app, 94 | 'db': db} 95 | ) 96 | 97 | return app -------------------------------------------------------------------------------- /openelevationservice/server/api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | error_codes = { 4 | 4000: 'ValueError:', 5 | 4001: 'HeaderError:', 6 | 4002: 'GeometryError:', 7 | 4003: 'LimitError:', 8 | } -------------------------------------------------------------------------------- /openelevationservice/server/api/api_exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.server.api import error_codes 4 | 5 | 6 | class InvalidUsage(Exception): 7 | """Provides more detailed description of internal 500 error.""" 8 | 9 | def __init__(self, status_code=400, error_code=None, message=None): 10 | """ 11 | :param status_code: the HTTP status code 12 | :type status_code: integer 13 | 14 | :param error_code: internal error code 15 | :type payload: integer 16 | 17 | :param message: custom error message 18 | :type message: string 19 | """ 20 | 21 | Exception.__init__(self) 22 | 23 | if status_code is not None: 24 | self.status_code = status_code 25 | 26 | if message is None: 27 | message = error_codes[error_code] 28 | else: 29 | message = ' '.join([error_codes[error_code], 30 | message]) 31 | 32 | self.error = { 33 | "code": error_code, 34 | "message": message 35 | } 36 | 37 | def to_dict(self): 38 | """converts error to dict""" 39 | 40 | rv = dict(self.error or ()) 41 | return rv -------------------------------------------------------------------------------- /openelevationservice/server/api/oes_post.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | tags: 3 | - name: "Elevation" 4 | description: | 5 | Returns elevation for point or line geometries by building 3D geometries from freely available data sources. 6 | info: 7 | description: | 8 | Returns elevation for point or line geometries by building 3D geometries from freely available data sources. 9 | version: "0.1" 10 | title: "openelevationservice" 11 | contact: 12 | email: "support@openrouteservice.org" 13 | license: 14 | name: "MIT" 15 | url: "https://github.com/GIScience/openelevationservice/blob/master/LICENSE" 16 | consumes: 17 | - "application/json" 18 | schemes: 19 | - "https" 20 | produces: 21 | - "application/json" 22 | host: "api.openrouteservice.org" 23 | security: 24 | - UserSecurity: [api_key] 25 | securityDefinitions: 26 | UserSecurity: 27 | name: "api_key" 28 | description: | 29 | Add your API Key as the value of the api_key parameter to your request. 30 | type: "apiKey" 31 | in: "query" 32 | responses: 33 | success: 34 | description: "Standard response for successfully processed requests." 35 | schema: 36 | $ref: "#/definitions/GeoJSONResponse" 37 | authmissing: 38 | description: "Authorization field missing." 39 | notauthorized: 40 | description: "Key not authorised." 41 | serverissue: 42 | description: | 43 | An unexpected error was encountered and more detailed internal 44 | errorcode is provided. 45 | | Internal Code | Description | 46 | |:-------------:|----------------------------------------------------| 47 | | 4001 | ValueError in parameters | 48 | | 4002 | Wrong HTTP headers | 49 | | 4003 | Problems with the provided geometry | 50 | | 4004 | Exceeded the number of allowed vertices | 51 | paths: 52 | "/elevation/line": 53 | post: 54 | tags: 55 | - "Elevation" 56 | summary: "Elevation Line Service" 57 | description: | 58 | This endpoint can take planar 2D line objects and enrich them with elevation from a variety of datasets. 59 | 60 | The input and output formats are: 61 | * GeoJSON 62 | * Polyline 63 | * Google's Encoded polyline with coordinate precision 5 or 6 64 | 65 | Example: 66 | ``` 67 | # POST LineString as polyline 68 | curl -XPOST https://api.openrouteservice.org/elevation/line 69 | -H 'Content-Type: application/json' \ 70 | -H 'Authorization: INSERT_YOUR_KEY 71 | -d '{ 72 | "format_in": "polyline", 73 | "format_out": "encodedpolyline5", 74 | "geometry": [[13.349762, 38.112952], 75 | [12.638397, 37.645772]] 76 | }' 77 | ``` 78 | parameters: 79 | - name: "Authorization" 80 | in: "header" 81 | description: | 82 | Insert your API Key here. 83 | type: "string" 84 | required: true 85 | default: "your-api-key" 86 | - in: body 87 | name: body 88 | required: true 89 | description: Query the elevation of a line in various formats. 90 | example: '{"format_in":"polyline","format_out":"encodedpolyline5","geometry":[[13.349762,38.112952],[12.638397,37.645772]]}' 91 | type: object 92 | # requiredParams: 93 | # - format_in 94 | # - geometry 95 | props: 96 | $ref: '#/definitions/LineGeometryPost' 97 | responses: 98 | 200: 99 | $ref: "#/responses/success" 100 | 401: 101 | $ref: "#/responses/authmissing" 102 | 403: 103 | $ref: "#/responses/notauthorized" 104 | 500: 105 | $ref: "#/responses/serverissue" 106 | "/elevation/point": 107 | post: 108 | tags: 109 | - "Elevation" 110 | summary: "Elevation Point Service" 111 | description: | 112 | This endpoint can take a 2D point and enrich it with elevation from a variety of datasets. 113 | 114 | The input and output formats are: 115 | * GeoJSON 116 | * Point 117 | 118 | Examples: 119 | ``` 120 | # POST point as GeoJSON 121 | # https://api.openrouteservice.org/elevation/point?api_key=YOUR-KEY 122 | { 123 | "format_in": "geojson", 124 | "format_out": "geojson", 125 | "geometry": { 126 | "coordinates": [13.349762, 38.11295], 127 | "type": "Point" 128 | } 129 | } 130 | ``` 131 | parameters: 132 | - name: "Authorization" 133 | in: "header" 134 | description: | 135 | Insert your API Key here. 136 | type: "string" 137 | required: true 138 | default: "your-api-key" 139 | - in: body 140 | name: body 141 | description: Query the elevation of a point in various formats. 142 | example: '{"format_in":"geojson","format_out":"geojson","geometry":{"coordinates":[13.349762,38.11295],"type":"Point"}}' 143 | type: object 144 | required: true 145 | # requiredParams: 146 | # - format_in 147 | # - geometry 148 | properties: 149 | $ref: '#/definitions/PointGeometryPostProps' 150 | responses: 151 | 200: 152 | $ref: "#/responses/success" 153 | 401: 154 | $ref: "#/responses/authmissing" 155 | 403: 156 | $ref: "#/responses/notauthorized" 157 | 500: 158 | $ref: "#/responses/serverissue" 159 | get: 160 | tags: 161 | - "Elevation" 162 | summary: "Elevation Point Service" 163 | description: | 164 | This endpoint can take a 2D point and enrich it with elevation from a variety of datasets. 165 | 166 | The output formats are: 167 | * GeoJSON 168 | * Point 169 | 170 | Example: 171 | ``` 172 | # GET point 173 | curl -XGET https://localhost:5000/elevation/point?geometry=13.349762,38.11295 174 | ``` 175 | parameters: 176 | - name: "api_key" 177 | in: "query" 178 | description: | 179 | Insert your API Key here. 180 | type: "string" 181 | required: true 182 | example: "your-api-key" 183 | - in: query 184 | name: geometry 185 | description: The point to be queried, in comma-separated lon,lat values, e.g. [13.349762, 38.11295] 186 | required: true 187 | type: array 188 | items: 189 | type: double 190 | example: "13.349762,38.11295" 191 | - in: query 192 | name: format_out 193 | type: string 194 | description: The output format to be returned. 195 | enum: [geojson, point] 196 | apiDefault: geojson 197 | - in: query 198 | name: dataset 199 | description: The elevation dataset to be used. 200 | type: string 201 | enum: [srtm] 202 | apiDefault: srtm 203 | responses: 204 | 200: 205 | $ref: "#/responses/success" 206 | 401: 207 | $ref: "#/responses/authmissing" 208 | 403: 209 | $ref: "#/responses/notauthorized" 210 | 500: 211 | $ref: "#/responses/serverissue" 212 | definitions: 213 | LineGeometryPost: 214 | format_in: 215 | type: string 216 | description: The input format the API has to expect. 217 | enum: [geojson, polyline, encodedpolyline5, encodedpolyline6] 218 | example: encodedpolyline5 219 | required: true 220 | format_out: 221 | type: string 222 | description: The output format to be returned. 223 | enum: [geojson, polyline, encodedpolyline5, encodedpolyline6] 224 | apiDefault: geojson 225 | dataset: 226 | type: string 227 | description: The elevation dataset to be used. 228 | enum: [srtm] 229 | apiDefault: srtm 230 | geometry: 231 | type: object 232 | required: true 233 | example: u`rgFswjpAKD 234 | description: | 235 | * geojson: A geometry object of a LineString GeoJSON, e.g. 236 | {"type": "LineString", 237 | "coordinates": [[13.331302, 38.108433],[13.331273, 38.10849]] 238 | } 239 | * polyline: A list of coordinate lists, e.g. 240 | [[13.331302, 38.108433], [13.331273, 38.10849]] 241 | 242 | * encodedpolyline5: A Google encoded polyline with a coordinate precision of 5, e.g. 243 | u`rgFswjpAKD 244 | 245 | * encodedpolyline6: A Google encoded polyline with a coordinate precision of 6, e.g. 246 | ap}tgAkutlXqBx@ 247 | 248 | PointGeometryPostProps: 249 | format_in: 250 | type: string 251 | description: The input format the API has to expect. 252 | enum: [geojson, point] 253 | example: point 254 | required: true 255 | format_out: 256 | type: string 257 | description: The output format to be returned. 258 | enum: [geojson, point] 259 | apiDefault: geojson 260 | dataset: 261 | type: string 262 | description: The elevation dataset to be used. 263 | enum: [srtm] 264 | apiDefault: srtm 265 | geometry: 266 | type: object 267 | example: [13.331273, 38.10849] 268 | required: true 269 | description: | 270 | * geojson: A geometry object of a Point GeoJSON, e.g. 271 | {"type": "Point", 272 | "coordinates": [13.331273, 38.10849] 273 | } 274 | * point: A coordinate list, e.g. 275 | [13.331273, 38.10849] 276 | 277 | GeoJSONResponse: 278 | type: object 279 | properties: 280 | attribution: 281 | type: string 282 | version: 283 | type: string 284 | timestamp: 285 | type: integer 286 | geometry: 287 | type: object 288 | properties: 289 | type: 290 | type: string 291 | coordinates: 292 | type: array 293 | items: 294 | type: array 295 | maxItems: 2 296 | minItems: 2 297 | items: 298 | type: float 299 | -------------------------------------------------------------------------------- /openelevationservice/server/api/querybuilder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice import SETTINGS 4 | from openelevationservice.server.utils.logger import get_logger 5 | from openelevationservice.server.db_import.models import db, Cgiar 6 | from openelevationservice.server.utils.custom_func import ST_SnapToGrid 7 | from openelevationservice.server.api.api_exceptions import InvalidUsage 8 | 9 | from geoalchemy2.functions import ST_DumpPoints, ST_Dump, ST_Value, ST_Intersects, ST_X, ST_Y 10 | from sqlalchemy import func 11 | import json 12 | 13 | log = get_logger(__name__) 14 | 15 | coord_precision = SETTINGS['coord_precision'] 16 | 17 | 18 | def _getModel(dataset): 19 | """ 20 | Choose model based on dataset parameter 21 | 22 | :param dataset: elevation dataset to use for querying 23 | :type dataset: string 24 | 25 | :returns: database model 26 | :rtype: SQLAlchemy model 27 | """ 28 | if dataset == 'srtm': 29 | model = Cgiar 30 | 31 | return model 32 | 33 | 34 | def line_elevation(geometry, format_out, dataset): 35 | """ 36 | Performs PostGIS query to enrich a line geometry. 37 | 38 | :param geometry: Input 2D line to be enriched with elevation 39 | :type geometry: Shapely geometry 40 | 41 | :param format_out: Specifies output format. One of ['geojson', 'polyline', 42 | 'encodedpolyline'] 43 | :type format_out: string 44 | 45 | :param dataset: Elevation dataset to use for querying 46 | :type dataset: string 47 | 48 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 49 | 50 | :returns: 3D line as GeoJSON or WKT 51 | :rtype: string 52 | """ 53 | 54 | Model = _getModel(dataset) 55 | 56 | if geometry.geom_type == 'LineString': 57 | query_points2d = db.session\ 58 | .query(func.ST_SetSRID(ST_DumpPoints(geometry.wkt).geom, 4326) \ 59 | .label('geom')) \ 60 | .subquery().alias('points2d') 61 | 62 | query_getelev = db.session \ 63 | .query(query_points2d.c.geom, 64 | ST_Value(Model.rast, query_points2d.c.geom).label('z')) \ 65 | .filter(ST_Intersects(Model.rast, query_points2d.c.geom)) \ 66 | .subquery().alias('getelevation') 67 | 68 | query_points3d = db.session \ 69 | .query(func.ST_SetSRID(func.ST_MakePoint(ST_X(query_getelev.c.geom), 70 | ST_Y(query_getelev.c.geom), 71 | query_getelev.c.z), 72 | 4326).label('geom')) \ 73 | .subquery().alias('points3d') 74 | 75 | if format_out == 'geojson': 76 | # Return GeoJSON directly in PostGIS 77 | query_final = db.session \ 78 | .query(func.ST_AsGeoJson(func.ST_MakeLine(ST_SnapToGrid(query_points3d.c.geom, coord_precision)))) 79 | 80 | else: 81 | # Else return the WKT of the geometry 82 | query_final = db.session \ 83 | .query(func.ST_AsText(func.ST_MakeLine(ST_SnapToGrid(query_points3d.c.geom, coord_precision)))) 84 | else: 85 | raise InvalidUsage(400, 4002, "Needs to be a LineString, not a {}!".format(geometry.geom_type)) 86 | 87 | # Behaviour when all vertices are out of bounds 88 | if query_final[0][0] == None: 89 | raise InvalidUsage(404, 4002, 90 | 'The requested geometry is outside the bounds of {}'.format(dataset)) 91 | 92 | return query_final[0][0] 93 | 94 | 95 | def point_elevation(geometry, format_out, dataset): 96 | """ 97 | Performs PostGIS query to enrich a point geometry. 98 | 99 | :param geometry: Input point to be enriched with elevation 100 | :type geometry: shapely.geometry.Point 101 | 102 | :param format_out: Specifies output format. One of ['geojson', 'point'] 103 | :type format_out: string 104 | 105 | :param dataset: Elevation dataset to use for querying 106 | :type dataset: string 107 | 108 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 109 | 110 | :returns: 3D Point as GeoJSON or WKT 111 | :rtype: string 112 | """ 113 | 114 | Model = _getModel(dataset) 115 | 116 | if geometry.geom_type == "Point": 117 | query_point2d = db.session \ 118 | .query(func.ST_SetSRID(func.St_PointFromText(geometry.wkt), 4326).label('geom')) \ 119 | .subquery() \ 120 | .alias('points2d') 121 | 122 | query_getelev = db.session \ 123 | .query(query_point2d.c.geom, 124 | ST_Value(Model.rast, query_point2d.c.geom).label('z')) \ 125 | .filter(ST_Intersects(Model.rast, query_point2d.c.geom)) \ 126 | .subquery().alias('getelevation') 127 | 128 | if format_out == 'geojson': 129 | query_final = db.session \ 130 | .query(func.ST_AsGeoJSON(ST_SnapToGrid(func.ST_MakePoint(ST_X(query_getelev.c.geom), 131 | ST_Y(query_getelev.c.geom), 132 | query_getelev.c.z), 133 | coord_precision))) 134 | else: 135 | query_final = db.session \ 136 | .query(func.ST_AsText(ST_SnapToGrid(func.ST_MakePoint(ST_X(query_getelev.c.geom), 137 | ST_Y(query_getelev.c.geom), 138 | query_getelev.c.z), 139 | coord_precision))) 140 | else: 141 | raise InvalidUsage(400, 4002, "Needs to be a Point, not {}!".format(geometry.geom_type)) 142 | 143 | try: 144 | return query_final[0][0] 145 | except: 146 | raise InvalidUsage(404, 4002, 147 | 'The requested geometry is outside the bounds of {}'.format(dataset)) 148 | -------------------------------------------------------------------------------- /openelevationservice/server/api/response.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice import __version__, SETTINGS 4 | import time 5 | 6 | class ResponseBuilder(): 7 | """Builds the basis for a query response.""" 8 | 9 | def __init__(self): 10 | """ 11 | Initializises the query builder. 12 | """ 13 | self.attribution = SETTINGS['attribution'] 14 | self.version = __version__ 15 | self.timestamp = int(time.time()) -------------------------------------------------------------------------------- /openelevationservice/server/api/validator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.server.api import api_exceptions 4 | from openelevationservice.server.utils import logger 5 | 6 | from cerberus import Validator, TypeDefinition 7 | 8 | log = logger.get_logger(__name__) 9 | 10 | object_type = TypeDefinition("object", (object,), ()) 11 | Validator.types_mapping['object'] = object_type 12 | v = Validator() 13 | 14 | schema_post = {'geometry': {'anyof_type': ['object', 'list', 'string'], 'required': True}, 15 | 'format_in': {'type': 'string', 'allowed': ['geojson', 'point', 'encodedpolyline', 'encodedpolyline5', 'encodedpolyline6', 'polyline'], 'required': True}, 16 | 'format_out': {'type': 'string', 'allowed': ['geojson', 'point', 'encodedpolyline', 'encodedpolyline5', 'encodedpolyline6', 'polyline'], 'default': 'geojson'}, 17 | 'dataset': {'type': 'string', 'allowed': ['srtm'], 'default': 'srtm'} 18 | } 19 | 20 | schema_get = {'geometry': {'type': 'string', 'required': True}, 21 | 'format_out': {'type': 'string', 'allowed': ['geojson', 'point'], 'default': 'geojson'}, 22 | 'dataset': {'type': 'string', 'allowed': ['srtm'], 'default': 'srtm'} 23 | } 24 | 25 | def validate_request(request): 26 | """ 27 | Validates full request with regards to validation schemas and HTTP headers. 28 | 29 | :param request: POST or GET request from user 30 | :type request: Flask request 31 | 32 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 33 | 34 | :returns: validated and normalized request arguments 35 | :rtype: dict 36 | """ 37 | if request.method == 'GET': 38 | v.allow_unknown = True 39 | v.validate(dict(request.args), schema_get) 40 | 41 | if request.method == 'POST': 42 | if not request.headers.get('Content-Type'): 43 | raise api_exceptions.InvalidUsage(400, 44 | 4001, 45 | "Missing Content-Type request header") 46 | if 'application/json' not in request.headers.get('Content-Type'): 47 | raise api_exceptions.InvalidUsage(400, 48 | 4001, 49 | "MIME type is not application/json") 50 | 51 | v.validate(request.get_json(), schema_post) 52 | 53 | if v.errors: 54 | errors = [] 55 | for error in v.errors: 56 | errors.append("Argument '{}': {}".format(error, v.errors[error][0])) 57 | raise api_exceptions.InvalidUsage(400, 58 | 4000, 59 | ", ".join(errors)) 60 | 61 | return v.document -------------------------------------------------------------------------------- /openelevationservice/server/api/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice import SETTINGS 4 | from openelevationservice.server.api import api_exceptions 5 | from openelevationservice.server.utils import logger, convert, codec 6 | from openelevationservice.server.api import querybuilder, validator 7 | from openelevationservice.server.api.response import ResponseBuilder 8 | 9 | from shapely import wkt 10 | import json 11 | from flask import Blueprint, request, jsonify 12 | 13 | log = logger.get_logger(__name__) 14 | 15 | main_blueprint = Blueprint('main', __name__, ) 16 | 17 | @main_blueprint.route('/elevation/line', methods=['POST']) 18 | def elevationline(): 19 | """ 20 | Function called when user posts to /elevation/line. 21 | 22 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 23 | 24 | :returns: elevation response 25 | :rtype: Response 26 | """ 27 | # Cerberus validates and returns a processed arg dict 28 | req_args = validator.validate_request(request) 29 | 30 | # Incoming parameters 31 | geometry_str = req_args['geometry'] 32 | format_in = req_args['format_in'] 33 | format_out = req_args['format_out'] 34 | dataset = req_args['dataset'] 35 | 36 | # Get the geometry 37 | if format_in == 'geojson': 38 | geom = convert.geojson_to_geometry(geometry_str) 39 | elif format_in in ['encodedpolyline', 'encodedpolyline5']: 40 | geom = codec.decode(geometry_str, precision=5, is3d=False) 41 | elif format_in == 'encodedpolyline6': 42 | geom = codec.decode(geometry_str, precision=6, is3d=False) 43 | elif format_in == 'polyline': 44 | geom = convert.polyline_to_geometry(geometry_str) 45 | else: 46 | raise api_exceptions.InvalidUsage(400, 47 | 4000, 48 | f'Invalid format_in value "{format_in}"') 49 | 50 | if len(list(geom.coords)) > SETTINGS['maximum_nodes']: 51 | raise api_exceptions.InvalidUsage(status_code=400, 52 | error_code=4003, 53 | message='Maximum number of nodes exceeded.') 54 | 55 | results = ResponseBuilder().__dict__ 56 | geom_queried = querybuilder.line_elevation(geom, format_out, dataset) 57 | 58 | # decision tree for format_out 59 | if format_out != 'geojson': 60 | geom_out = wkt.loads(geom_queried) 61 | coords = geom_out.coords 62 | if format_out in ['encodedpolyline', 'encodedpolyline5']: 63 | results['geometry'] = codec.encode(coords, precision=5, is3d=True) 64 | elif format_out == 'encodedpolyline6': 65 | results['geometry'] = codec.encode(coords, precision=6, is3d=True) 66 | else: 67 | results['geometry'] = list(coords) 68 | elif format_out == 'geojson': 69 | results['geometry'] = json.loads(geom_queried) 70 | else: 71 | raise api_exceptions.InvalidUsage(400, 72 | 4000, 73 | f'Invalid format_out value "{format_out}"') 74 | 75 | return jsonify(results) 76 | 77 | 78 | @main_blueprint.route('/elevation/point', methods=['POST', 'GET']) 79 | def elevationpoint(): 80 | """ 81 | Function called when user posts to/gets /elevation/point. 82 | 83 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 84 | 85 | :returns: elevation response 86 | :rtype: Response class 87 | """ 88 | 89 | req_args = validator.validate_request(request) 90 | log.debug(req_args) 91 | 92 | if request.method == 'POST': 93 | 94 | # Check incoming parameters 95 | req_geometry = req_args['geometry'] 96 | format_in = req_args['format_in'] 97 | format_out = req_args['format_out'] 98 | dataset = req_args['dataset'] 99 | 100 | # Get the geometry 101 | if format_in == 'geojson': 102 | geom = convert.geojson_to_geometry(req_geometry) 103 | elif format_in == 'point': 104 | geom = convert.point_to_geometry(req_geometry) 105 | else: 106 | raise api_exceptions.InvalidUsage( 107 | 400, 108 | 4000, 109 | f"Invalid format_in value {format_in}" 110 | ) 111 | else: 112 | req_geometry = req_args['geometry'] 113 | format_out = req_args['format_out'] 114 | dataset = req_args['dataset'] 115 | try: 116 | # Catch errors when parsing the input string 117 | point_coords = [float(x) for x in req_geometry.split(',')] 118 | except: 119 | raise api_exceptions.InvalidUsage(500, 120 | 4000, 121 | '{} is not a comma separated list of long, lat'.format(req_geometry)) 122 | 123 | geom = convert.point_to_geometry(point_coords) 124 | 125 | # Build response with attribution etc. 126 | results = ResponseBuilder().__dict__ 127 | geom_queried = querybuilder.point_elevation(geom, format_out, dataset) 128 | 129 | if format_out == 'point': 130 | geom_out = wkt.loads(geom_queried) 131 | results['geometry'] = list(geom_out.coords[0]) 132 | elif format_out == 'geojson': 133 | results['geometry'] = json.loads(geom_queried) 134 | else: 135 | raise api_exceptions.InvalidUsage(400, 136 | 4000, 137 | f'Invalid format_out value "{format_out}"') 138 | 139 | return jsonify(results) 140 | -------------------------------------------------------------------------------- /openelevationservice/server/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice import SETTINGS 4 | 5 | class BaseConfig(object): 6 | """Base configuration.""" 7 | 8 | # SECRET_KEY = 'my_precious' 9 | WTF_CSRF_ENABLED = True 10 | DEBUG_TB_ENABLED = False 11 | DEBUG_TB_INTERCEPT_REDIRECTS = False 12 | 13 | 14 | class ProductionConfig(BaseConfig): 15 | """Production configuration.""" 16 | 17 | # SECRET_KEY = 'my_precious' 18 | SQLALCHEMY_DATABASE_URI = 'postgresql://{user_name}:{password}@{host}:{port}/{db_name}'.format(**SETTINGS['provider_parameters']) 19 | DEBUG_TB_ENABLED = False 20 | SQLALCHEMY_TRACK_MODIFICATIONS = False 21 | 22 | 23 | class DevelopmentConfig(BaseConfig): 24 | """Production configuration.""" 25 | 26 | SQLALCHEMY_DATABASE_URI = 'postgresql://{user_name}:{password}@{host}:{port}/{db_name}'.format(**SETTINGS['provider_parameters']) 27 | DEBUG_TB_ENABLED = True 28 | SQLALCHEMY_TRACK_MODIFICATIONS = True 29 | 30 | 31 | class TestingConfig(BaseConfig): 32 | """Testing configuration.""" 33 | 34 | SQLALCHEMY_DATABASE_URI = 'postgresql://{user_name}:{password}@{host}:{port}/{db_name}'.format(**SETTINGS['provider_parameters']) 35 | DEBUG_TB_ENABLED = False 36 | PRESERVE_CONTEXT_ON_EXCEPTION = False 37 | -------------------------------------------------------------------------------- /openelevationservice/server/db_import/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- -------------------------------------------------------------------------------- /openelevationservice/server/db_import/filestreams.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice import TILES_DIR, SETTINGS 4 | from openelevationservice.server.utils.logger import get_logger 5 | 6 | from os import path, environ 7 | import requests 8 | import subprocess 9 | import zipfile 10 | from bs4 import BeautifulSoup 11 | 12 | try: 13 | from io import BytesIO 14 | except: 15 | from StringIO import StringIO 16 | 17 | log = get_logger(__name__) 18 | 19 | def downloadsrtm(xy_range): 20 | """ 21 | Downlaods SRTM v4.1 tiles2 as bytestream and saves them to TILES_DIR. 22 | 23 | :param xy_range: The range of tiles2 in x and y as per grid in 24 | http://srtm.csi.cgiar.org/SELECTION/inputCoord.asp 25 | in 'minx, maxx, miny, maxy. 26 | :type xy_range: comma-separated range string 27 | """ 28 | 29 | base_url = r'http://data.cgiar-csi.org/srtm/tiles/GeoTIFF/' 30 | 31 | # Create session for authentication 32 | session = requests.Session() 33 | 34 | pw = environ.get('SRTMPASS') 35 | user = environ.get('SRTMUSER') 36 | if not user and not pw: 37 | auth = tuple(SETTINGS['srtm_parameters'].values()) 38 | else: 39 | auth = tuple([user,pw]) 40 | session.auth = auth 41 | 42 | log.debug("SRTM credentials: {}".format(session.auth)) 43 | 44 | response = session.get(base_url) 45 | 46 | soup = BeautifulSoup(response.content, features="html.parser") 47 | 48 | # First find all 'a' tags starting href with srtm* 49 | for link in soup.find_all('a', attrs={'href': lambda x: x.startswith('srtm') and x.endswith('.zip')}): 50 | link_parsed = link.text.split('_') 51 | link_x = int(link_parsed[1]) 52 | link_y = int(link_parsed[2].split('.')[0]) 53 | # Check if referenced geotif link is in xy_range 54 | if link_x in range(*xy_range[0]) and link_y in range(*xy_range[1]): 55 | log.info('yep') 56 | # Then load the zip data in memory 57 | if not path.exists(path.join(TILES_DIR, '_'.join(['srtm', str(link_x), str(link_y)]) + '.tif')): 58 | with zipfile.ZipFile(BytesIO(session.get(base_url + link.text).content)) as zip_obj: 59 | # Loop through the files in the zip 60 | for filename in zip_obj.namelist(): 61 | # Don't extract the readme.txt 62 | if filename != 'readme.txt': 63 | data = zip_obj.read(filename) 64 | # Write byte contents to file 65 | with open(path.join(TILES_DIR, filename), 'wb') as f: 66 | f.write(data) 67 | log.debug("Downloaded file {} to {}".format(link.text, TILES_DIR)) 68 | else: 69 | log.debug("File {} already exists in {}".format(link.text, TILES_DIR)) 70 | 71 | 72 | def raster2pgsql(): 73 | """ 74 | Imports SRTM v4.1 tiles to PostGIS. 75 | 76 | :raises subprocess.CalledProcessError: Raised when raster2pgsql throws an error. 77 | """ 78 | 79 | pg_settings = SETTINGS['provider_parameters'] 80 | 81 | # Copy all env variables and add PGPASSWORD 82 | env_current = environ.copy() 83 | env_current['PGPASSWORD'] = pg_settings['password'] 84 | 85 | # Tried to import every raster individually by user-specified xyrange 86 | # similar to download(), but raster2pgsql fuck it up somehow.. The PostGIS 87 | # raster will not be what one would expect. Instead doing a bulk import of all files. 88 | cmd_raster2pgsql = r"raster2pgsql -s 4326 -a -C -M -P -t 50x50 {filename} {table_name} | psql -q -h {host} -p {port} -U {user_name} -d {db_name}" 89 | # -s: raster SRID 90 | # -a: append to table (assumes it's been create with 'create()') 91 | # -C: apply all raster Constraints 92 | # -P: pad tiles to guarantee all tiles have the same width and height 93 | # -M: vacuum analyze after import 94 | # -t: specifies the pixel size of each row. Important to keep low for performance! 95 | 96 | cmd_raster2pgsql = cmd_raster2pgsql.format(**{'filename': path.join(TILES_DIR, '*.tif'), 97 | **pg_settings}) 98 | 99 | proc = subprocess.Popen(cmd_raster2pgsql, 100 | stdout=subprocess.PIPE, 101 | stderr=subprocess.STDOUT, 102 | shell=True, 103 | env=env_current 104 | ) 105 | 106 | # for line in proc.stdout: 107 | # log.debug(line.decode()) 108 | # proc.stdout.close() 109 | return_code = proc.wait() 110 | if return_code: 111 | raise subprocess.CalledProcessError(return_code, cmd_raster2pgsql) -------------------------------------------------------------------------------- /openelevationservice/server/db_import/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice import SETTINGS 4 | from openelevationservice.server.utils import logger 5 | 6 | from flask_sqlalchemy import SQLAlchemy 7 | from sqlalchemy import Index 8 | from geoalchemy2 import Raster 9 | 10 | db = SQLAlchemy() 11 | 12 | log = logger.get_logger(__name__) 13 | table_name = SETTINGS['provider_parameters']['table_name'] 14 | 15 | class Cgiar(db.Model): 16 | """Database model for SRTM v4.1 aka CGIAR dataset.""" 17 | 18 | __tablename__ = table_name 19 | 20 | rid = db.Column(db.Integer, primary_key=True) 21 | rast = db.Column(Raster) 22 | 23 | def __repr__(self): 24 | return ''.format(self.rid, self.rast) -------------------------------------------------------------------------------- /openelevationservice/server/ops_settings.sample.yml: -------------------------------------------------------------------------------- 1 | --- 2 | attribution: "service by https://openrouteservice.org | data by http://srtm.csi.cgiar.org" 3 | coord_precision: 1e-6 4 | maximum_nodes: 2000 5 | srtm_parameters: 6 | user: user 7 | password: pw 8 | provider_parameters: 9 | table_name: oes_cgiar 10 | db_name: gis 11 | user_name: gis 12 | password: gis 13 | host: localhost 14 | port: 5432 15 | -------------------------------------------------------------------------------- /openelevationservice/server/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GIScience/openelevationservice/93743b263ec9ddad45ce1f72bccb5d193fe123fe/openelevationservice/server/utils/__init__.py -------------------------------------------------------------------------------- /openelevationservice/server/utils/codec.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .convert import polyline_to_geometry 4 | 5 | import itertools 6 | import six 7 | import math 8 | 9 | """ 10 | Copyright (c) 2014 Bruno M. Custódio 11 | Copyright (c) 2016 Frederick Jansen 12 | 13 | https://github.com/hicsail/polyline/commit/ddd12e85c53d394404952754e39c91f63a808656 14 | """ 15 | 16 | 17 | def _pcitr(iterable): 18 | return six.moves.zip(iterable, itertools.islice(iterable, 1, None)) 19 | 20 | 21 | def _py2_round(x): 22 | # The polyline algorithm uses Python 2's way of rounding 23 | return int(math.copysign(math.floor(math.fabs(x) + 0.5), x)) 24 | 25 | 26 | def _write(output, curr_value, prev_value, factor): 27 | curr_value = _py2_round(curr_value * factor) 28 | prev_value = _py2_round(prev_value * factor) 29 | coord = curr_value - prev_value 30 | coord <<= 1 31 | coord = coord if coord >= 0 else ~coord 32 | 33 | while coord >= 0x20: 34 | output.write(six.unichr((0x20 | (coord & 0x1f)) + 63)) 35 | coord >>= 5 36 | 37 | output.write(six.unichr(coord + 63)) 38 | 39 | 40 | def _trans(value, index): 41 | byte, result, shift = None, 0, 0 42 | 43 | while byte is None or byte >= 0x20: 44 | byte = ord(value[index]) - 63 45 | index += 1 46 | result |= (byte & 0x1f) << shift 47 | shift += 5 48 | comp = result & 1 49 | 50 | return ~(result >> 1) if comp else (result >> 1), index 51 | 52 | 53 | def decode(expression, precision=5, is3d=False): 54 | """ 55 | Decodes Google encoded polyline format. Notable difference: the output is [X, Y], not [Lat, Long)! 56 | 57 | :param expression: encoded string 58 | :type enumerate: str 59 | 60 | :param precision: Decimal precision of provided coordinates. 61 | :type precision: int 62 | 63 | :param is3d: Whether input string has elevation info. 64 | :type is3d: bool 65 | 66 | :return: LineString from decoded coordinates 67 | :rtype: shapely.geometry.LineString 68 | """ 69 | coordinates, index, lat, lng, z, length, factor = [], 0, 0, 0, 0, len(expression), float(10 ** precision) 70 | 71 | while index < length: 72 | lat_change, index = _trans(expression, index) 73 | lng_change, index = _trans(expression, index) 74 | lat += lat_change 75 | lng += lng_change 76 | # Careful, Lng/Lat are interchanged here, so that shapely converts correctly 77 | if not is3d: 78 | coordinates.append((lng / factor, lat / factor)) 79 | else: 80 | z_change, index = _trans(expression, index) 81 | z += z_change 82 | coordinates.append((lng / factor, lat / factor, z / 100)) 83 | 84 | return polyline_to_geometry(coordinates) 85 | 86 | 87 | def encode(coordinates, precision=5, is3d=False): 88 | """ 89 | Encodes coordinates after Google's encoded polyline format. Able to encode arbitrary precision. 90 | 91 | :param coordinates: list of coordinates in [Lon, Lat] order. 92 | :type coordinates: lists of int 93 | 94 | :param precision: Decimal precision to be encoded. 95 | :type precision: int 96 | 97 | :param is3d: Whether elevation should be encoded. 98 | :type is3d: bool 99 | 100 | :return: encoded polyline string 101 | :rtype: str 102 | """ 103 | output, factor = six.StringIO(), int(10 ** precision) 104 | 105 | # Careful, Lnt/Lat are interchanged here, bcs PostGIS query outputs x, y, but encodedpolyline has the convention of 106 | # lat, lon 107 | _write(output, coordinates[0][1], 0, factor) 108 | _write(output, coordinates[0][0], 0, factor) 109 | if is3d: 110 | _write(output, coordinates[0][2], 0, 100) 111 | 112 | for prev, curr in _pcitr(coordinates): 113 | _write(output, curr[1], prev[1], factor) 114 | _write(output, curr[0], prev[0], factor) 115 | if is3d==True: 116 | _write(output, curr[2], prev[2], 100) 117 | 118 | return output.getvalue() 119 | -------------------------------------------------------------------------------- /openelevationservice/server/utils/convert.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.server.api.api_exceptions import InvalidUsage 4 | from openelevationservice.server.utils import logger 5 | 6 | from shapely.geometry import shape, LineString, Point 7 | 8 | log = logger.get_logger(__name__) 9 | 10 | def geojson_to_geometry(geometry_str): 11 | """ 12 | Converts GeoJSON to shapely geometries 13 | 14 | :param geometry_str: GeoJSON representation to be converted 15 | :type geometry_str: str 16 | 17 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 18 | 19 | :returns: Shapely geometry 20 | :rtype: Shapely geometry 21 | """ 22 | 23 | try: 24 | geom = shape(geometry_str) 25 | except Exception as e: 26 | raise InvalidUsage(status_code=400, 27 | error_code=4002, 28 | message=str(e)) 29 | return geom 30 | 31 | 32 | def point_to_geometry(point): 33 | """ 34 | Converts a point to shapely Point geometries 35 | 36 | :param point: coordinates of a point 37 | :type point: list/tuple 38 | 39 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 40 | 41 | :returns: Point 42 | :rtype: shapely.geometry.Point 43 | """ 44 | 45 | try: 46 | geom = Point(point) 47 | except Exception as e: 48 | raise InvalidUsage(status_code=400, 49 | error_code=4002, 50 | message=str(e)) 51 | return geom 52 | 53 | def polyline_to_geometry(point_list): 54 | """ 55 | Converts a list/tuple of coordinates lists/tuples to a shapely LineString. 56 | 57 | :param point_list: Coordinates of line to be converted. 58 | :type point_list: list/tuple of lists/tuples 59 | 60 | :raises InvalidUsage: internal HTTP 500 error with more detailed description. 61 | 62 | :returns: LineString 63 | :rtype: shapely.geometry.LineString 64 | """ 65 | 66 | try: 67 | geom = LineString(point_list) 68 | except Exception as e: 69 | raise InvalidUsage(status_code=400, 70 | error_code=4002, 71 | message=str(e)) 72 | return geom 73 | -------------------------------------------------------------------------------- /openelevationservice/server/utils/custom_func.py: -------------------------------------------------------------------------------- 1 | from geoalchemy2.functions import GenericFunction 2 | 3 | class ST_SnapToGrid(GenericFunction): 4 | """ 5 | Defines PostGIS function, which is not natively supported by geoalchemy2. 6 | """ 7 | name = 'ST_SnapToGrid' 8 | type = None -------------------------------------------------------------------------------- /openelevationservice/server/utils/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import logging 5 | 6 | def get_logger(name): 7 | """ 8 | Instantiate a logger with a defined name, e.g. module name. 9 | 10 | :param name: name for the logger 11 | :type name: string 12 | 13 | :returns: a logger object 14 | :rtype: logging.Logger 15 | """ 16 | 17 | log_format = '\n%(asctime)s %(name)8s %(levelname)5s: %(message)s' 18 | log_level = os.getenv('OES_LOGLEVEL', 'INFO') 19 | logging.basicConfig(format=log_format, 20 | level=log_level 21 | ) 22 | console = logging.StreamHandler() 23 | console.setLevel(log_level) 24 | console.setFormatter(logging.Formatter(log_format)) 25 | logging.getLogger(name).addHandler(console) 26 | return logging.getLogger(name) -------------------------------------------------------------------------------- /openelevationservice/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask_testing import TestCase 4 | from openelevationservice import TILES_DIR 5 | from openelevationservice.server import create_app 6 | from openelevationservice.server.db_import.models import db 7 | from openelevationservice.server.db_import import filestreams 8 | 9 | from os import path 10 | 11 | app = create_app() 12 | 13 | 14 | class BaseTestCase(TestCase): 15 | 16 | def create_app(self): 17 | app.config.from_object('openelevationservice.server.config.TestingConfig') 18 | app.config['TESTING'] = True 19 | 20 | return app 21 | 22 | def setUp(self): 23 | db.create_all() 24 | # Imports Sicily as raster, rather low-weight 25 | if not path.exists(path.join(TILES_DIR, 'srtm_39_05.tif')): 26 | test_range = [[39, 40], [5, 6]] 27 | filestreams.downloadsrtm(test_range) 28 | filestreams.raster2pgsql() 29 | 30 | @classmethod 31 | def tearDown(self): 32 | db.session.remove() 33 | db.drop_all() 34 | -------------------------------------------------------------------------------- /openelevationservice/tests/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask_testing import TestCase 4 | from openelevationservice import TILES_DIR 5 | from openelevationservice.server import create_app 6 | from openelevationservice.server.db_import.models import db 7 | from openelevationservice.server.db_import import filestreams 8 | 9 | from os import path 10 | 11 | app = create_app() 12 | 13 | 14 | class BaseTestCase(TestCase): 15 | 16 | def create_app(self): 17 | app.config.from_object('openelevationservice.server.config.TestingConfig') 18 | app.config['TESTING'] = True 19 | 20 | return app 21 | 22 | def setUp(self): 23 | db.create_all() 24 | # Imports Sicily as raster, rather low-weight 25 | if not path.exists(path.join(TILES_DIR, 'srtm_39_05.tif')): 26 | test_range = [[39,40],[5,6]] 27 | filestreams.downloadsrtm(test_range) 28 | filestreams.raster2pgsql() 29 | 30 | def tearDown(self): 31 | db.session.remove() 32 | db.drop_all() 33 | -------------------------------------------------------------------------------- /openelevationservice/tests/test_api_line.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.tests import BaseTestCase 4 | from openelevationservice import SETTINGS 5 | from openelevationservice.server.api import api_exceptions 6 | 7 | from copy import deepcopy 8 | import json 9 | 10 | valid_coords = [[13.331302, 38.108433], 11 | [13.331273, 38.10849]] 12 | 13 | invalid_coords = [[8.807132326847508, 53.07574568891761], 14 | [8.807514373051843, 53.0756845615249]] 15 | 16 | valid_line_geojson = dict(format_in='geojson', 17 | geometry=dict(type="LineString", 18 | coordinates=valid_coords)) 19 | 20 | valid_line_polyline = dict(format_in='polyline', 21 | geometry=valid_coords) 22 | 23 | 24 | polyline5 = dict(format_in='encodedpolyline5', 25 | geometry='u`rgFswjpAKD') 26 | 27 | polyline6 = dict(format_in='encodedpolyline6', 28 | geometry='ap}tgAkutlXqBx@') 29 | 30 | 31 | class LineTest(BaseTestCase): 32 | 33 | def test_input_geojson_output_geojson(self): 34 | geom = deepcopy(valid_line_geojson) 35 | geom.update({'format_out': 'geojson'}) 36 | response = self.client.post('elevation/line', 37 | json=geom, 38 | ) 39 | 40 | j = response.get_json() 41 | self.assertEqual(response.status_code, 200) 42 | self.assertIn(('type', 'LineString'), list(j['geometry'].items())) 43 | self.assertEqual(len(j['geometry']['coordinates']), 2) 44 | 45 | def test_input_geojson_output_encodedpolyline(self): 46 | geom = deepcopy(valid_line_geojson) 47 | geom.update({'format_out': 'encodedpolyline'}) 48 | response = self.client.post('elevation/line', 49 | json=geom, 50 | ) 51 | 52 | j = response.get_json() 53 | self.assertEqual(response.status_code, 200) 54 | self.assertEqual(j['geometry'], 'u`rgFswjpA_aMKD?') 55 | 56 | def test_output_polyline(self): 57 | geom = deepcopy(valid_line_polyline) 58 | geom.update({'format_out': 'polyline'}) 59 | response = self.client.post('elevation/line', 60 | json=geom) 61 | 62 | j = response.get_json() 63 | 64 | self.assertEqual(response.status_code, 200) 65 | self.assertIsInstance(j['geometry'], list) 66 | self.assertEqual(len(j['geometry']), 2) 67 | 68 | def test_output_polyline5(self): 69 | geom = deepcopy(polyline5) 70 | geom.update({'format_out': 'encodedpolyline5'}) 71 | response = self.client.post('elevation/line', 72 | json=geom) 73 | 74 | j = response.get_json() 75 | 76 | self.assertEqual(response.status_code, 200) 77 | self.assertEqual(j['geometry'], 'u`rgFswjpA_aMKD?') 78 | 79 | def test_output_polyline6(self): 80 | geom = deepcopy(polyline6) 81 | geom.update({'format_out': 'encodedpolyline6'}) 82 | response = self.client.post('elevation/line', 83 | json=geom) 84 | 85 | j = response.get_json() 86 | 87 | self.assertEqual(response.status_code, 200) 88 | self.assertEqual(j['geometry'], 'ap}tgAkutlX_aMqBx@?') 89 | 90 | def test_exceed_maximum_nodes(self): 91 | dummy_list = [[x, x] for x in range(SETTINGS['maximum_nodes'] + 1)] 92 | response = self.client.post('elevation/line', 93 | json={'format_in': 'polyline', 94 | 'geometry': dummy_list}) 95 | 96 | self.assertRaises(api_exceptions.InvalidUsage) 97 | self.assertEqual(response.get_json()['code'], 4003) 98 | 99 | def test_schema_wrong_format_in(self): 100 | response = self.client.post('elevation/line', 101 | json={'format_in': 'geoJSON', 102 | 'format_out': 'geoJson', 103 | 'dataset': 'SRTM'}) 104 | 105 | self.assertRaises(api_exceptions.InvalidUsage) 106 | self.assertEqual(response.get_json()['code'], 4000) 107 | 108 | def test_schema_no_header(self): 109 | response = self.client.post('elevation/line', 110 | data=json.dumps(valid_line_geojson) 111 | ) 112 | 113 | self.assertRaises(api_exceptions.InvalidUsage) 114 | self.assertEquals(response.get_json()['code'], 4001) 115 | 116 | def test_invalid_coords(self): 117 | geom = deepcopy(valid_line_polyline) 118 | geom.update(geometry=invalid_coords) 119 | response = self.client.post('elevation/line', 120 | json=geom, 121 | ) 122 | 123 | self.assertRaises(api_exceptions.InvalidUsage) 124 | self.assertEqual(response.get_json()['code'], 4002) 125 | self.assertIn(b'The requested geometry is outside the bounds of', response.data) 126 | 127 | def test_point_geojson_error(self): 128 | geom = deepcopy(valid_line_geojson) 129 | geom.update(geometry={'coordinates': valid_coords[0], 'type': 'Point'}) 130 | response = self.client.post('elevation/line', 131 | json=geom, 132 | ) 133 | 134 | self.assertRaises(api_exceptions.InvalidUsage) 135 | self.assertEqual(response.get_json()['code'], 4002) 136 | self.assertIn(b'not a Point', response.data) -------------------------------------------------------------------------------- /openelevationservice/tests/test_api_point.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.tests import BaseTestCase 4 | from openelevationservice.server.api import api_exceptions 5 | 6 | from copy import deepcopy 7 | 8 | valid_coord = [13.331302, 38.108433] 9 | 10 | invalid_coord = [8.807514373051843, 53.0756845615249] 11 | 12 | valid_point_geojson = dict(format_in='geojson', 13 | geometry=dict(coordinates=valid_coord, 14 | type='Point')) 15 | 16 | valid_point_point = dict(format_in='point', 17 | geometry=valid_coord) 18 | 19 | 20 | class PointTest(BaseTestCase): 21 | 22 | def test_post_geojson(self): 23 | geom = deepcopy(valid_point_geojson) 24 | geom.update({'format_out': 'geojson'}) 25 | response = self.client.post('elevation/point', 26 | json=geom, 27 | ) 28 | 29 | j = response.get_json() 30 | self.assertEqual(response.status_code, 200) 31 | self.assertIn(('type', 'Point'), list(j['geometry'].items())) 32 | self.assertEqual(len(j['geometry']['coordinates']), 3) 33 | 34 | def test_post_point(self): 35 | geom = deepcopy(valid_point_point) 36 | geom.update({'format_out': 'point'}) 37 | response = self.client.post('elevation/point', 38 | json=geom) 39 | 40 | j = response.get_json() 41 | self.assertEqual(response.status_code, 200) 42 | self.assertIsInstance(j['geometry'], list) 43 | self.assertEqual(len(j['geometry']), 3) 44 | 45 | def test_get_point(self): 46 | response = self.client.get('elevation/point', 47 | query_string=dict(geometry=','.join([str(x) for x in valid_coord]), 48 | format_out='point') 49 | ) 50 | 51 | j = response.get_json() 52 | self.assertEqual(response.status_code, 200) 53 | self.assertIsInstance(j['geometry'], list) 54 | self.assertEqual(len(j['geometry']), 3) 55 | 56 | def test_get_geojson(self): 57 | response = self.client.get('elevation/point', 58 | query_string=dict(geometry=','.join([str(x) for x in valid_coord]), 59 | format_out='geojson') 60 | ) 61 | 62 | j = response.get_json() 63 | self.assertEqual(response.status_code, 200) 64 | self.assertIn(('type', 'Point'), list(j['geometry'].items())) 65 | self.assertEqual(len(j['geometry']['coordinates']), 3) 66 | 67 | def test_semicolon_separated_geometry_string(self): 68 | response = self.client.get('elevation/point', 69 | query_string=dict(geometry=';'.join([str(x) for x in valid_coord]), 70 | format_out='geojson')) 71 | 72 | j = response.get_json() 73 | 74 | self.assertRaises(api_exceptions.InvalidUsage) 75 | self.assertEqual(j['code'], 4000) 76 | self.assertIn(b'is not a comma separated list of long, lat', response.data) 77 | 78 | def test_schema_get_wrong(self): 79 | response = self.client.get('elevation/point', 80 | query_string=dict(format_out='geoJson', 81 | dataset='SRTM', 82 | api_key=540032)) 83 | 84 | self.assertRaises(api_exceptions.InvalidUsage) 85 | self.assertEqual(response.get_json()['code'], 4000) 86 | # 87 | def test_invalid_coord_point(self): 88 | geom = deepcopy(valid_point_point) 89 | geom.update(geometry=invalid_coord) 90 | response = self.client.post('elevation/point', 91 | json=geom, 92 | ) 93 | 94 | self.assertRaises(api_exceptions.InvalidUsage) 95 | self.assertEqual(response.get_json()['code'], 4002) 96 | self.assertIn(b'The requested geometry is outside the bounds of', response.data) 97 | 98 | def test_point_geojson_error(self): 99 | geom = deepcopy(valid_point_geojson) 100 | geom.update(geometry={'coordinates': [valid_coord, valid_coord], 101 | 'type': 'Point'}) 102 | response = self.client.post('elevation/point', 103 | json=geom, 104 | ) 105 | 106 | self.assertRaises(api_exceptions.InvalidUsage) 107 | self.assertEqual(response.get_json()['code'], 4002) 108 | self.assertIn(b'must be real number, not list', response.data) 109 | -------------------------------------------------------------------------------- /openelevationservice/tests/test_codec.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.server.utils import codec 4 | 5 | from unittest import TestCase 6 | 7 | valid_coords_3d = [ 8 | (13.331302, 38.108433, 112.92), 9 | (13.331273, 38.10849, 1503.0932) 10 | ] 11 | 12 | 13 | class TestCodec(TestCase): 14 | 15 | def test_encode_polyline5(self): 16 | """Tests polyline encoding with precision 5""" 17 | 18 | self.assertEqual(codec.encode(valid_coords_3d, precision=5, is3d=True), 'u`rgFswjpAw`UKDqonG') 19 | 20 | def test_encode_polyline6(self): 21 | """Tests polyline encoding with precision 6""" 22 | 23 | self.assertEqual(codec.encode(valid_coords_3d, precision=6, is3d=True), 'ap}tgAkutlXw`UqBx@qonG') 24 | 25 | def test_decode_polyline5_3d(self): 26 | """Tests polyline decoding with precision 5""" 27 | 28 | valid_coords_2d = [ 29 | (13.3313, 38.10843), 30 | (13.33127, 38.10849) 31 | ] 32 | 33 | self.assertEqual(list(codec.decode('u`rgFswjpAKD').coords), valid_coords_2d) 34 | 35 | def test_decode_polyline6_3d(self): 36 | """Tests polyline decoding with precision 5""" 37 | 38 | valid_coords_2d = [ 39 | (13.331302, 38.108433), 40 | (13.331273, 38.10849) 41 | ] 42 | 43 | self.assertEqual(list(codec.decode('ap}tgAkutlXqBx@', precision=6).coords), valid_coords_2d) 44 | -------------------------------------------------------------------------------- /openelevationservice/tests/test_convert.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from openelevationservice.server.utils import convert 4 | 5 | from unittest import TestCase 6 | from shapely.geometry import LineString, Point 7 | 8 | valid_coords = [[13.331302, 38.108433], [13.331273, 38.10849]] 9 | 10 | valid_geojson = {"coordinates": valid_coords, 11 | "type": "LineString"} 12 | 13 | 14 | class TestConvert(TestCase): 15 | 16 | def test_geojson_to_geometry(self): 17 | geom = convert.geojson_to_geometry(valid_geojson) 18 | self.assertIsInstance(geom, LineString) 19 | 20 | def test_point_to_geometry(self): 21 | geom = convert.point_to_geometry(valid_coords[0]) 22 | self.assertIsInstance(geom, Point) 23 | 24 | 25 | def test_polyline_to_geometry(self): 26 | geom = convert.polyline_to_geometry(valid_coords) 27 | self.assertIsInstance(geom, LineString) 28 | -------------------------------------------------------------------------------- /ops_settings_docker.sample.yml: -------------------------------------------------------------------------------- 1 | --- 2 | attribution: "service by https://openrouteservice.org | data by http://srtm.csi.cgiar.org" 3 | coord_precision: 1e-6 4 | maximum_nodes: 2000 5 | srtm_parameters: 6 | user: 7 | password: 8 | provider_parameters: 9 | table_name: oes_cgiar 10 | db_name: 11 | user_name: 12 | password: 13 | host: localhost 14 | port: 5432 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=1.0.0 2 | Flask_Cors>=3.0.0 3 | Flask_Testing>0.7.0 4 | Flask-SQLAlchemy>=2.3.0 5 | Cerberus>=1.2 6 | beautifulsoup4>=4.6.0 7 | GeoAlchemy2>=0.5.0 8 | geojson>=2.4.0 9 | shapely>=1.6.0 10 | sqlalchemy>=1.2.0 11 | werkzeug>=0.14.0 12 | pyyaml>=4.2b1 13 | flasgger>=0.9.0 14 | gunicorn>=19.0.0 15 | gevent>=1.3.0 16 | requests>=2.20.0 17 | nose>=1.3.0 18 | psycopg2-binary==2.8.4 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | def readme(): 11 | with open('README.rst') as f: 12 | return f.read() 13 | 14 | if sys.version_info <= (3, 5): 15 | error = 'Requires Python Version 3.6 or above... exiting.' 16 | print >> sys.stderr, error 17 | sys.exit(1) 18 | 19 | setup( 20 | name='openelevationservice', 21 | version='0.2.1', 22 | description='Flask app to serve elevation data to GeoJSON queries.', 23 | long_description=readme(), 24 | classifiers=[ 25 | 'Development Status :: 4 - Beta', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Operating System :: OS Independent', 28 | 'Programming Language :: Python :: 3.7', 29 | ], 30 | keywords='flask elevation GIS GeoJSON ORS SRTM', 31 | url='https://github.com/GIScience/openelevationservice', 32 | author='Nils Nolde', 33 | author_email='nils.nolde@gmail.com', 34 | license='MIT', 35 | packages=['openelevationservice'], 36 | install_requires=[ 37 | 'Flask>=1.0.0', 38 | 'Flask_Cors>=3.0.0', 39 | 'Flask-SQLAlchemy>=2.3.0', 40 | 'Cerberus>=1.2', 41 | 'beautifulsoup4>=4.6.0', 42 | 'GeoAlchemy2>=0.5.0', 43 | 'geojson>=2.4.0', 44 | 'shapely>=1.6.0', 45 | 'sqlalchemy>=1.2.0', 46 | 'werkzeug>=0.14.0', 47 | 'pyyaml>=4.2b1', 48 | 'flasgger>=0.9.0', 49 | 'gunicorn>=19.0.0', 50 | 'gevent>=1.3.0', 51 | 'requests>=2.20.0', 52 | 'psycopg2>2.7.5' 53 | ], 54 | include_package_data=True, 55 | test_suite='nose.collector', 56 | tests_require=[ 57 | 'nose>1.3.0', 58 | 'Flask_Testing>=0.7.0', 59 | ], 60 | zip_safe=False, 61 | project_urls={ 62 | 'Bug Reports': 'https://github.com/GIScience/openelevationservice/issues', 63 | 'Source': 'https://github.com/GIScience/openelevationservice', 64 | } 65 | ) 66 | --------------------------------------------------------------------------------