├── .dockerignore ├── .env-dev ├── .env-integration-tests ├── .flake8 ├── .github ├── pull_request_template.md └── workflows │ ├── ci-build-push.yml │ └── ci-tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── babel.cfg ├── data ├── Dockerfile ├── load │ ├── db.py │ ├── db_check.py │ └── setup.sh └── sql │ ├── all.sql │ ├── neighbourhoods-4326-data.sql │ ├── neighbourhoods-4326-schema.sql │ └── neighbourhoods-4326.sql ├── docker-compose.override.yml ├── docker-compose.test.yml ├── docker-compose.yml ├── oaff ├── app │ ├── README.md │ ├── oaff │ │ └── app │ │ │ ├── assets │ │ │ ├── css │ │ │ │ ├── all.css │ │ │ │ ├── global.css │ │ │ │ ├── header.css │ │ │ │ ├── page-collection-items.css │ │ │ │ ├── page-collection.css │ │ │ │ ├── page-feature.css │ │ │ │ └── page-landing.css │ │ │ └── js │ │ │ │ └── common.js │ │ │ ├── configuration │ │ │ ├── data.py │ │ │ ├── frontend_configuration.py │ │ │ └── frontend_interface.py │ │ │ ├── data │ │ │ ├── retrieval │ │ │ │ ├── feature_provider.py │ │ │ │ ├── feature_set_provider.py │ │ │ │ ├── filter_parameters.py │ │ │ │ └── item_constraints.py │ │ │ └── sources │ │ │ │ ├── common │ │ │ │ ├── data_source.py │ │ │ │ ├── data_source_manager.py │ │ │ │ ├── layer.py │ │ │ │ ├── provider.py │ │ │ │ └── temporal.py │ │ │ │ └── postgresql │ │ │ │ ├── postgresql_manager.py │ │ │ │ ├── profile.py │ │ │ │ ├── settings.py │ │ │ │ └── stac_hybrid │ │ │ │ ├── migrations │ │ │ │ ├── alembic.ini │ │ │ │ ├── env.py │ │ │ │ ├── script.py.mako │ │ │ │ └── versions │ │ │ │ │ ├── .gitkeep │ │ │ │ │ ├── 0fda79152d26_.py │ │ │ │ │ └── 1e7b6d30b536_.py │ │ │ │ ├── models │ │ │ │ └── collections.py │ │ │ │ ├── postgresql_data_source.py │ │ │ │ ├── postgresql_feature_provider.py │ │ │ │ ├── postgresql_feature_set_provider.py │ │ │ │ ├── postgresql_layer.py │ │ │ │ ├── settings.py │ │ │ │ └── sql │ │ │ │ └── layers.sql │ │ │ ├── gateway.py │ │ │ ├── i18n │ │ │ ├── locale │ │ │ │ └── en_US │ │ │ │ │ └── LC_MESSAGES │ │ │ │ │ ├── translations.mo │ │ │ │ │ └── translations.po │ │ │ ├── locales.py │ │ │ ├── translations.pot │ │ │ └── translations.py │ │ │ ├── request_handlers │ │ │ ├── collection.py │ │ │ ├── collection_items.py │ │ │ ├── collections_list.py │ │ │ ├── common │ │ │ │ └── request_handler.py │ │ │ ├── conformance.py │ │ │ ├── feature.py │ │ │ └── landing_page.py │ │ │ ├── requests │ │ │ ├── collection.py │ │ │ ├── collection_items.py │ │ │ ├── collections_list.py │ │ │ ├── common │ │ │ │ └── request_type.py │ │ │ ├── conformance.py │ │ │ ├── feature.py │ │ │ └── landing_page.py │ │ │ ├── responses │ │ │ ├── data_response.py │ │ │ ├── error_response.py │ │ │ ├── models │ │ │ │ ├── collection.py │ │ │ │ ├── collection_item_html.py │ │ │ │ ├── collection_items_html.py │ │ │ │ ├── collections.py │ │ │ │ ├── conformance.py │ │ │ │ ├── extent.py │ │ │ │ ├── extent_spatial.py │ │ │ │ ├── extent_temporal.py │ │ │ │ ├── item_type.py │ │ │ │ ├── landing.py │ │ │ │ └── link.py │ │ │ ├── response.py │ │ │ ├── response_format.py │ │ │ ├── response_type.py │ │ │ └── templates │ │ │ │ ├── html │ │ │ │ ├── Collection.jinja2 │ │ │ │ ├── CollectionItems.jinja2 │ │ │ │ ├── CollectionsList.jinja2 │ │ │ │ ├── Conformance.jinja2 │ │ │ │ ├── Feature.jinja2 │ │ │ │ ├── LandingPage.jinja2 │ │ │ │ ├── footer.jinja2 │ │ │ │ └── header.jinja2 │ │ │ │ └── templates.py │ │ │ ├── settings.py │ │ │ ├── tests │ │ │ └── test_data_configuration.py │ │ │ └── util.py │ └── setup.py ├── fastapi │ ├── api │ │ ├── __init__.py │ │ ├── delegator.py │ │ ├── main.py │ │ ├── middleware │ │ │ └── request_context_log_middleware.py │ │ ├── openapi │ │ │ ├── openapi.py │ │ │ └── vnd_response.py │ │ ├── routes │ │ │ ├── __init__.py │ │ │ ├── collections.py │ │ │ ├── common │ │ │ │ ├── common_parameters.py │ │ │ │ └── parameter_control.py │ │ │ ├── conformance.py │ │ │ ├── control.py │ │ │ └── landing_page.py │ │ ├── settings.py │ │ └── util.py │ ├── gunicorn │ │ └── gunicorn.conf.py │ └── tests │ │ ├── common.py │ │ ├── common_delegation.py │ │ ├── conftest.py │ │ ├── test_collection_delegation.py │ │ ├── test_collection_items_delegation.py │ │ ├── test_collection_items_params.py │ │ ├── test_collections_list_delegation.py │ │ ├── test_conformance_delegation.py │ │ ├── test_feature_delegation.py │ │ ├── test_landing_delegation.py │ │ └── test_util.py └── testing │ ├── Dockerfile │ ├── data │ ├── load │ │ ├── db.py │ │ └── setup.sh │ ├── loader │ │ └── Dockerfile │ └── sql │ │ ├── schema │ │ └── schema_management.sql │ │ └── stac_hybrid │ │ ├── all.sql │ │ ├── collections.sql │ │ ├── neighbourhoods-4326-data.sql │ │ ├── neighbourhoods-4326-schema.sql │ │ └── neighbourhoods-4326.sql │ ├── integration_tests │ ├── common.py │ ├── common_pg.py │ ├── conftest.py │ ├── test_collection_items_pg_all.py │ ├── test_collection_items_pg_basic.py │ ├── test_collection_items_pg_bbox.py │ ├── test_collection_items_pg_datetime_mac.py │ ├── test_collection_items_pg_datetime_nomac.py │ ├── test_collection_pg_basic_mac.py │ ├── test_collection_pg_basic_nomac.py │ ├── test_collection_pg_interval_mac.py │ ├── test_collection_pg_interval_nomac.py │ ├── test_collections_list_pg_mac.py │ ├── test_collections_list_pg_nomac.py │ └── test_feature_pg.py │ └── requirements-test.txt ├── pyproject.toml ├── requirements.txt ├── requirements_dev.txt └── scripts ├── cibuild ├── console ├── debug_test_start ├── debug_test_stop ├── demo_data ├── format ├── logs ├── server ├── setup ├── stop ├── test ├── update └── update_i18n /.dockerignore: -------------------------------------------------------------------------------- 1 | oaff/integration_tests -------------------------------------------------------------------------------- /.env-dev: -------------------------------------------------------------------------------- 1 | APP_DATA_SOURCE_TYPES=postgresql 2 | 3 | APP_POSTGRESQL_SOURCE_NAMES=stac 4 | 5 | APP_POSTGRESQL_PROFILE_stac=stac_hybrid 6 | APP_POSTGRESQL_HOST_stac=postgres 7 | -------------------------------------------------------------------------------- /.env-integration-tests: -------------------------------------------------------------------------------- 1 | APP_DATA_SOURCE_TYPES=postgresql 2 | 3 | APP_POSTGRESQL_SOURCE_NAMES=stac 4 | 5 | APP_POSTGRESQL_PROFILE_stac=stac_hybrid 6 | APP_POSTGRESQL_HOST_stac=postgres 7 | 8 | 9 | API_CPU_LIMIT=1 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | select = C,E,F,W,B,B950 4 | max-complexity = 9 5 | 6 | # Error Codes: http://pycdestyle.pycqa.org/en/latest/intro.html#configuration 7 | ignore = W503 -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | # Instructions 17 | 18 | Explain what someone needs to do in order to test the functionality of the changes. 19 | 20 | # How Has This Been Tested? 21 | 22 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 23 | 24 | - [ ] Test A 25 | - [ ] Test B 26 | 27 | # Checklist: 28 | 29 | - [ ] My code follows the style guidelines of this project 30 | - [ ] I have performed a self-review of my own code 31 | - [ ] I have commented my code, particularly in hard-to-understand areas 32 | - [ ] I have made corresponding changes to the documentation 33 | - [ ] My changes generate no new warnings 34 | - [ ] I have added tests that prove my fix is effective or that my feature works 35 | - [ ] New and existing unit tests pass locally with my changes 36 | - [ ] Any dependent changes have been merged and published in downstream modules 37 | -------------------------------------------------------------------------------- /.github/workflows/ci-build-push.yml: -------------------------------------------------------------------------------- 1 | name: CI Tests, Build, Push to ghcr.io 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | DOCKER_REGISTRY: ghcr.io 10 | DOCKER_IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | test-api: 14 | name: test-api 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Run cibuild script (build and test) 21 | run: scripts/cibuild 22 | 23 | docker: 24 | name: docker 25 | needs: 26 | - test-api 27 | permissions: 28 | contents: read 29 | packages: write 30 | runs-on: ubuntu-latest 31 | steps: 32 | - 33 | name: Checkout 34 | uses: actions/checkout@v2 35 | - 36 | name: Login to GitHub Container Registry 37 | uses: docker/login-action@v1 38 | with: 39 | registry: ${{ env.DOCKER_REGISTRY }} 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | - 43 | name: Docker meta main 44 | id: meta-main 45 | uses: docker/metadata-action@v3 46 | with: 47 | images: ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }} 48 | tags: | 49 | type=ref,event=branch 50 | type=ref,event=pr 51 | - 52 | name: Build and push 53 | uses: docker/build-push-action@v2 54 | with: 55 | context: . 56 | file: Dockerfile 57 | # Don't try to push if the event is a PR from a fork 58 | push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} 59 | tags: ${{ steps.meta-main.outputs.tags }} 60 | labels: ${{ steps.meta-main.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: CI Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '**' 8 | 9 | jobs: 10 | 11 | test-api: 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | 18 | - name: Run tests 19 | run: scripts/test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode/ 3 | .idea/ 4 | .pytest_cache 5 | .notes 6 | *.pyc 7 | __pycache__ 8 | .autoenv*.zsh 9 | **/*.egg-info 10 | **/.DS_Store 11 | **/*.orig 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/timothycrosley/isort 3 | rev: 5.8.0 4 | hooks: 5 | - id: isort 6 | - repo: https://github.com/psf/black 7 | rev: 21.5b1 8 | hooks: 9 | - id: black 10 | language_version: python 11 | - repo: https://gitlab.com/pycqa/flake8 12 | rev: 3.9.2 13 | hooks: 14 | - id: flake8 15 | - repo: https://github.com/pre-commit/mirrors-mypy 16 | rev: v0.910 17 | hooks: 18 | - id: mypy 19 | language_version: python 20 | args: [--install-types, --non-interactive] -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM python:3.8-slim-bullseye 3 | 4 | RUN apt-get update && apt-get install -y \ 5 | # OS dependencies only required to build certain Python dependencies 6 | libpq-dev \ 7 | python3-pip \ 8 | python3-psycopg2 \ 9 | # OS dependency required at runtime 10 | curl \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | WORKDIR /opt/ogc-api-fast-features 14 | COPY requirements.txt ./ 15 | RUN pip install --no-cache-dir -r requirements.txt 16 | COPY oaff oaff 17 | RUN pip install -e oaff/app 18 | 19 | 20 | CMD ["gunicorn", "-c", "/opt/ogc-api-fast-features/oaff/fastapi/gunicorn/gunicorn.conf.py", "oaff.fastapi.api.main:app", "--timeout", "185"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # TODO: The maintainer of this repo has not yet edited this file 2 | 3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? 4 | 5 | - **No CSS support:** Fill out this template with information about how to file issues and get help. 6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/spot](https://aka.ms/spot). CSS will work with/help you to determine next steps. More details also available at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). 7 | - **Not sure?** Fill out a SPOT intake as though the answer were "Yes". CSS will help you decide. 8 | 9 | *Then remove this first heading from this SUPPORT.MD file before publishing your repo.* 10 | 11 | # Support 12 | 13 | ## How to file issues and get help 14 | 15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 17 | feature request as a new Issue. 18 | 19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE 20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER 21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**. 22 | 23 | ## Microsoft Support Policy 24 | 25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 26 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | # Extraction from Python files 2 | [python: oaff/app/oaff/app/**.py] 3 | 4 | # Extraction from Jinja2 template files 5 | [jinja2: oaff/app/oaff/app/responses/templates/**.jinja2] 6 | encoding = utf-8 -------------------------------------------------------------------------------- /data/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.5-alpine3.13 2 | 3 | RUN apk add --no-cache --update \ 4 | bash \ 5 | postgresql-client 6 | 7 | RUN apk add --no-cache --update --virtual .build-deps gcc libc-dev make python3-dev postgresql-dev \ 8 | && pip install psycopg2-binary==2.8.6 \ 9 | && apk del .build-deps 10 | 11 | COPY sql /sql 12 | COPY load /load 13 | 14 | CMD [ "/load/setup.sh" ] -------------------------------------------------------------------------------- /data/load/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging import getLogger 3 | from typing import Final, List, Tuple 4 | 5 | import psycopg2 # type: ignore 6 | 7 | LOGGER: Final = getLogger(__file__) 8 | SOURCE_NAME: Final = "TEST" 9 | 10 | 11 | def query(sql: str) -> List[Tuple]: 12 | connection = _get_connection() 13 | cursor = connection.cursor() 14 | cursor.execute(sql) 15 | result = cursor.fetchall() 16 | cursor.close() 17 | _release_connection(connection) 18 | 19 | return result 20 | 21 | 22 | def update_db(statement: str) -> None: 23 | connection = _get_connection() 24 | cursor = connection.cursor() 25 | cursor.execute(statement) 26 | connection.commit() 27 | cursor.close() 28 | _release_connection(connection) 29 | 30 | 31 | def check_connection() -> None: 32 | _release_connection(_get_connection()) 33 | 34 | 35 | def _get_connection() -> psycopg2.extensions.connection: 36 | try: 37 | return psycopg2.connect( 38 | host=os.environ["APP_POSTGRESQL_HOST"], 39 | port=os.environ["APP_POSTGRESQL_PORT"], 40 | dbname=os.environ["APP_POSTGRESQL_DBNAME"], 41 | user=os.environ["APP_POSTGRESQL_USER"], 42 | password=os.environ["APP_POSTGRESQL_PASSWORD"], 43 | ) 44 | except Exception as e: 45 | LOGGER.error(f"Error establishing DB connection: {e}") 46 | raise e 47 | 48 | 49 | def _release_connection(connection: psycopg2.extensions.connection) -> None: 50 | try: 51 | connection.close() 52 | except Exception as e: 53 | LOGGER.warning(f"Problem encountered when closing database connection: {e}") 54 | -------------------------------------------------------------------------------- /data/load/db_check.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Final 3 | 4 | from .db import check_connection 5 | 6 | LOGGER: Final = getLogger(__file__) 7 | if __name__ == "__main__": 8 | try: 9 | LOGGER.info("testing connection") 10 | check_connection() 11 | except Exception: 12 | LOGGER.warning("errored connection, exiting") 13 | exit(1) 14 | -------------------------------------------------------------------------------- /data/load/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | iteration=0 4 | until python -m load.db_check || [ "$iteration" -gt "30" ] 5 | do 6 | ((iteration++)) 7 | echo "Waiting on db connection, iteration $iteration" 8 | sleep 1 9 | done 10 | 11 | PGPASSWORD=$APP_POSTGRESQL_PASSWORD psql -h $APP_POSTGRESQL_HOST -U $APP_POSTGRESQL_USER -f /sql/all.sql -------------------------------------------------------------------------------- /data/sql/all.sql: -------------------------------------------------------------------------------- 1 | \ir neighbourhoods-4326.sql -------------------------------------------------------------------------------- /data/sql/neighbourhoods-4326-schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS neighbourhoods ( 2 | ID SERIAL PRIMARY KEY 3 | , name VARCHAR(100) NOT NULL UNIQUE 4 | , boundary GEOMETRY(MULTIPOLYGON) NOT NULL 5 | ) 6 | ; -------------------------------------------------------------------------------- /data/sql/neighbourhoods-4326.sql: -------------------------------------------------------------------------------- 1 | \ir neighbourhoods-4326-schema.sql 2 | \ir neighbourhoods-4326-data.sql -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | services: 3 | loader: 4 | build: 5 | context: ./oaff/testing/data 6 | dockerfile: loader/Dockerfile 7 | image: oaff-loader 8 | volumes: 9 | - ./oaff/testing/data/sql:/sql 10 | - ./oaff/testing/data/load:/load 11 | env_file: .env-dev 12 | depends_on: 13 | api: 14 | condition: service_healthy 15 | api: 16 | env_file: .env-dev 17 | ports: 18 | - 8008:80 19 | postgres: 20 | ports: 21 | - 5432:5432 22 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | services: 3 | loader: 4 | build: 5 | context: ./oaff/testing 6 | image: oaff-tester 7 | depends_on: 8 | api: 9 | condition: service_healthy 10 | environment: 11 | PGPASSWORD: postgres 12 | command: psql -U postgres -h postgres -d postgres -f ./oaff/testing/data/sql/schema/schema_management.sql 13 | api: 14 | build: 15 | context: ./oaff/testing 16 | image: oaff-tester 17 | env_file: .env-integration-tests 18 | postgres: 19 | ports: 20 | - 2345:5432 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' # upgrade to 3.9 when available, depends_on with condition problematic in some v3 versions 2 | services: 3 | api: 4 | build: 5 | context: . 6 | image: oaff 7 | volumes: 8 | - ./oaff:/opt/ogc-api-fast-features/oaff 9 | command: uvicorn --reload --host 0.0.0.0 --port 80 --log-level "info" "oaff.fastapi.api.main:app" 10 | healthcheck: 11 | test: curl -s --fail http://localhost/docs || exit 1 12 | interval: 2s 13 | timeout: 2s 14 | retries: 5 15 | depends_on: 16 | postgres: 17 | condition: service_healthy 18 | postgres: 19 | image: mdillon/postgis:11 20 | healthcheck: 21 | test: ["CMD-SHELL", "pg_isready -U postgres"] 22 | interval: 2s 23 | timeout: 2s 24 | retries: 5 25 | -------------------------------------------------------------------------------- /oaff/app/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/ogc-api-fast-features/f0fe22148499b3e428bb81ce8f653b4d46ad4125/oaff/app/README.md -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/css/all.css: -------------------------------------------------------------------------------- 1 | @import url('./global.css'); 2 | @import url('./header.css'); 3 | @import url('./page-collection.css'); 4 | @import url('./page-collection-items.css'); 5 | @import url('./page-feature.css'); 6 | @import url('./page-landing.css'); -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/css/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | max-width: 1160px; 4 | margin: 0 auto; 5 | padding: 0 20px; 6 | } 7 | 8 | a { 9 | text-decoration: none; 10 | } 11 | 12 | .hidden { 13 | display: none; 14 | } 15 | 16 | .title-links-container { 17 | display: flex; 18 | padding: 1em 0; 19 | align-items: center; 20 | justify-content: space-between; 21 | } 22 | .title-links-container .title-container h2,.title-links-container .title-container h3 { 23 | display: inline-block; 24 | margin: 0; 25 | } 26 | .title-links-container .links-container .link-self { 27 | color: unset; 28 | } 29 | 30 | table.table-common { 31 | border: 1px solid #808080; 32 | border-radius: 0.2em; 33 | } 34 | table.table-common th { 35 | text-align: left; 36 | padding: 1em 0.5em; 37 | } 38 | table.table-common td { 39 | padding: 0.5em; 40 | } 41 | table.table-common tr:nth-child(odd) { 42 | background-color: #f5f5f5; 43 | } 44 | 45 | table.table-subtle { 46 | border: 1px solid lightgray; 47 | } 48 | table.table-subtle td { 49 | padding: 0.5em; 50 | } 51 | 52 | table.highlight-first-cell td:first-child { 53 | font-weight: bold; 54 | } 55 | 56 | .map-table-container { 57 | display: flex; 58 | justify-content: space-between; 59 | } 60 | .map-table-container #map { 61 | height: 500px; 62 | } 63 | .map-table-container table { 64 | white-space: nowrap; 65 | overflow-x: auto; 66 | display: block; 67 | border: 0; 68 | } 69 | @media screen and (max-width: 1180px) { 70 | .map-table-container .dynamic-col { 71 | width: 100%; 72 | } 73 | .map-table-container .dynamic-col:not(:first-child) { 74 | margin-top: 20px; 75 | } 76 | .map-table-container { 77 | flex-wrap: wrap; 78 | } 79 | } 80 | @media screen and (min-width: 1181px) { 81 | .map-table-container .dynamic-col { 82 | width: 570px; 83 | } 84 | } -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/css/header.css: -------------------------------------------------------------------------------- 1 | #header-links-container { 2 | margin: 0.2em 0; 3 | border-bottom: 1px solid #a9a9a9; 4 | } 5 | #header-links-container a { 6 | color: #a9a9a9; 7 | } 8 | #header-links-container a { 9 | padding: 0.4em 0.7em; 10 | display: inline-block; 11 | } -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/css/page-collection-items.css: -------------------------------------------------------------------------------- 1 | #page-collection-items .paging-links-container { 2 | display: flex; 3 | } 4 | #page-collection-items .page-link { 5 | background-color: #f5f5f5; 6 | padding: 0.5em 1em; 7 | } 8 | #page-collection-items .page-link:not(:first-child) { 9 | margin-left: 0.5em; 10 | } 11 | #page-collection-items .page-link-inactive { 12 | color: darkgray; 13 | } -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/css/page-collection.css: -------------------------------------------------------------------------------- 1 | #page-collection .map-container { 2 | margin: 1em 0; 3 | } 4 | #page-collection #map { 5 | height: 500px; 6 | width: 100%; 7 | } 8 | #page-collection .features-link-container { 9 | margin-top: 1em; 10 | } 11 | #page-collection .items-links-container ul { 12 | list-style: none; 13 | padding: 0; 14 | } 15 | #page-collection .keywords-container .keyword { 16 | background-color: #faebd7; 17 | padding: 0.2em; 18 | } 19 | #page-collection .providers-container .provider-role { 20 | background-color: #faebd7; 21 | padding: 0.2em; 22 | display: inline-block; 23 | margin: 0.2em 0; 24 | } -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/css/page-feature.css: -------------------------------------------------------------------------------- 1 | #page-feature .map-table-container table td:first-child { 2 | font-weight: bold; 3 | } -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/css/page-landing.css: -------------------------------------------------------------------------------- 1 | #page-landing .links-container ul { 2 | list-style: none; 3 | padding: 0; 4 | } 5 | #page-landing .links-container li:not(:first-child) { 6 | padding-top: 0.2em; 7 | } -------------------------------------------------------------------------------- /oaff/app/oaff/app/assets/js/common.js: -------------------------------------------------------------------------------- 1 | function getCurrentPageJsonUrl() { 2 | const formatRegex = /(\?|&)format=html\b/; 3 | const jsonFormat = "format=json" 4 | if (window.location.href.match(formatRegex)) { 5 | return window.location.href.replace(formatRegex, "$1" + jsonFormat) 6 | } 7 | const builder = [window.location.href]; 8 | if (window.location.search.length) { 9 | builder.push("&"); 10 | } else { 11 | builder.push("?"); 12 | } 13 | builder.push(jsonFormat) 14 | return builder.join(""); 15 | } 16 | 17 | function mapFeatures(dataHandler) { 18 | const map = L.map("map"); 19 | L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { 20 | attribution: '© OpenStreetMap contributors' 21 | }).addTo(map); 22 | fetch(getCurrentPageJsonUrl()) 23 | .then(function(response) { 24 | return response.json(); 25 | }) 26 | .then(function(data) { 27 | dataHandler(data, map); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/configuration/data.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from threading import Lock 3 | from typing import Dict, Final, List 4 | 5 | from oaff.app import settings 6 | from oaff.app.data.sources.common.data_source import DataSource 7 | from oaff.app.data.sources.common.layer import Layer 8 | 9 | LOGGER: Final = getLogger(__file__) 10 | _data_sources: Final[Dict[str, DataSource]] = dict() 11 | _data_sources_lock: Final = Lock() 12 | _layers: Final[Dict[str, Layer]] = dict() 13 | _layers_lock: Final = Lock() 14 | 15 | 16 | async def discover() -> None: # noqa: C901 17 | with _data_sources_lock: 18 | for data_source in _data_sources.values(): 19 | await data_source.disconnect() 20 | _data_sources.clear() 21 | for data_source_type in settings.DATA_SOURCE_TYPES(): 22 | manager = None 23 | if data_source_type == "postgresql": 24 | try: 25 | from oaff.app.data.sources.postgresql.postgresql_manager import ( 26 | PostgresqlManager, 27 | ) 28 | 29 | manager = PostgresqlManager() 30 | except Exception as e: 31 | LOGGER.error(f"error creating data source manager: {e}") 32 | continue 33 | else: 34 | LOGGER.warning(f"Unknown data source type {data_source_type}") 35 | continue 36 | 37 | try: 38 | for data_source in manager.get_data_sources(): 39 | _data_sources[data_source.id] = data_source 40 | except Exception as e: 41 | LOGGER.error(f"error retrieving data sources from manager: {e}") 42 | 43 | with _layers_lock: 44 | _layers.clear() 45 | for data_source in _data_sources.values(): 46 | LOGGER.info(f"initializing data source {data_source.name}") 47 | try: 48 | await data_source.initialize() 49 | except Exception as e: 50 | LOGGER.error(f"error initializing {data_source.name}: {e}") 51 | continue 52 | LOGGER.info(f"configuring layers in {data_source.name}") 53 | try: 54 | layers = await data_source.get_layers() 55 | except Exception as e: 56 | LOGGER.error(f"error configuring layers for {data_source.name}: {e}") 57 | continue 58 | for layer in layers: 59 | if layer.id in _layers: 60 | LOGGER.warning( 61 | f"layer ID clash on {layer.id}, latest wins ({data_source.name})" 62 | ) 63 | _layers[layer.id] = layer 64 | 65 | 66 | async def cleanup() -> None: 67 | for data_source in _data_sources.values(): 68 | await data_source.disconnect() 69 | _data_sources.clear() 70 | 71 | 72 | def get_data_source(data_source_id: str) -> DataSource: 73 | return _data_sources[data_source_id] 74 | 75 | 76 | def get_layers() -> List[Layer]: 77 | with _layers_lock: 78 | return list(_layers.values()) 79 | 80 | 81 | def get_layer(layer_id: str) -> Layer: 82 | with _layers_lock: 83 | return _layers[layer_id] if layer_id in _layers else None 84 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/configuration/frontend_configuration.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.responses.response_format import ResponseFormat 6 | 7 | 8 | class FrontendConfiguration(BaseModel): 9 | asset_url_base: str 10 | api_url_base: str 11 | endpoint_format_switcher: Callable[[str, ResponseFormat], str] 12 | next_page_link_generator: Callable[[str], str] 13 | prev_page_link_generator: Callable[[str], str] 14 | openapi_path_html: str 15 | openapi_path_json: str 16 | 17 | def get_items_path(self, layer_id: str) -> str: 18 | return f"{self.api_url_base}/collections/{layer_id}/items" 19 | 20 | def get_collection_path(self, layer_id: str) -> str: 21 | return f"{self.api_url_base}/collections/{layer_id}" 22 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/configuration/frontend_interface.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from os import path 3 | from typing import Final 4 | 5 | from oaff.app.configuration.frontend_configuration import FrontendConfiguration 6 | 7 | LOGGER: Final = getLogger(__file__) 8 | _frontend_configuration: FrontendConfiguration = None 9 | 10 | 11 | def set_frontend_configuration(frontend_configuration: FrontendConfiguration) -> None: 12 | global _frontend_configuration 13 | _frontend_configuration = frontend_configuration 14 | 15 | 16 | def get_frontend_configuration() -> FrontendConfiguration: 17 | return _frontend_configuration 18 | 19 | 20 | def get_assets_path() -> str: 21 | return path.join(path.dirname(__file__), "..", "assets") 22 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/retrieval/feature_provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from oaff.app.responses.models.collection_item_html import CollectionItemHtml 4 | 5 | 6 | class FeatureProvider(ABC): 7 | """ 8 | Allow different data sources to customise how they return features in a given format. 9 | One approach is to convert all source data to a common format, and then convert from 10 | that common format to the target format. 11 | If the source format and the target format are equivalent, or if a data source has 12 | specialised functionality to provide data in the target format, it may be 13 | significantly faster to bypass a common format conversion. 14 | """ 15 | 16 | @abstractmethod 17 | async def as_geojson(self) -> str: 18 | pass 19 | 20 | @abstractmethod 21 | async def as_html_compatible(self) -> CollectionItemHtml: 22 | pass 23 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/retrieval/feature_set_provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Callable, List 3 | 4 | from oaff.app.responses.models.collection_items_html import CollectionItemsHtml 5 | from oaff.app.responses.models.link import Link 6 | 7 | 8 | class FeatureSetProvider(ABC): 9 | """ 10 | Allow different data sources to customise how they return features in a given format. 11 | One approach is to convert all source data to a common format, and then convert from 12 | that common format to the target format. 13 | If the source format and the target format are equivalent, or if a data source has 14 | specialised functionality to provide data in the target format, it may be 15 | significantly faster to bypass a common format conversion. 16 | """ 17 | 18 | @abstractmethod 19 | async def as_geojson( 20 | self, links: List[Link], page_links_provider: Callable[[int, int], List[Link]] 21 | ) -> str: 22 | pass 23 | 24 | @abstractmethod 25 | async def as_html_compatible( 26 | self, links: List[Link], page_links_provider: Callable[[int, int], List[Link]] 27 | ) -> CollectionItemsHtml: 28 | pass 29 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/retrieval/filter_parameters.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, Tuple, Union 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class FilterParameters(BaseModel): 8 | spatial_bounds: Optional[ 9 | Union[ 10 | Tuple[float, float, float, float], 11 | Tuple[float, float, float, float, float, float], 12 | ] 13 | ] 14 | spatial_bounds_crs: Optional[str] 15 | temporal_bounds: Optional[ 16 | Union[Tuple[Optional[datetime], Optional[datetime]], Tuple[datetime]] 17 | ] 18 | filter_cql: Optional[str] 19 | filter_lang: Optional[str] 20 | filter_crs: Optional[str] 21 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/retrieval/item_constraints.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ItemConstraints(BaseModel): 5 | limit: int 6 | offset: int 7 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/common/data_source.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, List, Type 3 | from uuid import uuid4 4 | 5 | from pygeofilter.ast import Node 6 | 7 | from oaff.app.data.retrieval.feature_provider import FeatureProvider 8 | from oaff.app.data.retrieval.feature_set_provider import FeatureSetProvider 9 | from oaff.app.data.retrieval.item_constraints import ItemConstraints 10 | from oaff.app.data.sources.common.layer import Layer 11 | 12 | 13 | class DataSource(ABC): 14 | def __init__(self, name: str): 15 | self._id = str(uuid4()) 16 | self.name = name 17 | 18 | @property 19 | def id(self) -> str: 20 | return self._id 21 | 22 | @abstractmethod 23 | async def initialize(self) -> None: 24 | pass 25 | 26 | @abstractmethod 27 | async def get_layers(self) -> List[Layer]: 28 | pass 29 | 30 | @abstractmethod 31 | async def get_feature_set_provider( 32 | self, 33 | layer: Layer, 34 | constraints: ItemConstraints = None, 35 | ast: Type[Node] = None, 36 | ) -> Type[FeatureSetProvider]: 37 | pass 38 | 39 | @abstractmethod 40 | async def get_feature_provider( 41 | self, 42 | layer: Layer, 43 | ) -> Type[FeatureProvider]: 44 | pass 45 | 46 | async def get_crs_identifier(self, layer: Layer) -> Any: 47 | return f"{layer.geometry_crs_auth_name}:{layer.geometry_crs_auth_code}" 48 | 49 | @abstractmethod 50 | async def disconnect(self) -> None: 51 | pass 52 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/common/data_source_manager.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Type 3 | 4 | from oaff.app.data.sources.common.data_source import DataSource 5 | 6 | 7 | class DataSourceManager(ABC): 8 | @abstractmethod 9 | def get_data_sources(self) -> List[Type[DataSource]]: 10 | pass 11 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/common/layer.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.data.sources.common.provider import Provider 6 | from oaff.app.data.sources.common.temporal import TemporalDeclaration 7 | 8 | 9 | class Layer(BaseModel): 10 | id: str 11 | title: str 12 | description: Optional[str] = None 13 | bboxes: List[List[float]] 14 | intervals: List[List[Optional[str]]] 15 | data_source_id: str 16 | geometry_crs_auth_name: str 17 | geometry_crs_auth_code: int 18 | temporal_attributes: List[TemporalDeclaration] 19 | license: Optional[str] = None 20 | keywords: Optional[List[str]] = None 21 | providers: Optional[List[Provider]] = None 22 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/common/provider.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Provider(BaseModel): 7 | url: str 8 | name: str 9 | roles: Optional[List[str]] = None 10 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/common/temporal.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class TemporalDeclaration(BaseModel): 7 | class Config: 8 | arbitrary_types_allowed = True 9 | 10 | 11 | class TemporalInstant(TemporalDeclaration): 12 | field_name: str 13 | field_tz_aware: bool 14 | tz: Any # not actually Any but seemingly no common base class for pytz timezones 15 | 16 | 17 | class TemporalRange(TemporalDeclaration): 18 | start_field_name: str 19 | start_tz_aware: bool 20 | end_field_name: str 21 | end_tz_aware: bool 22 | tz: Any 23 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/postgresql_manager.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from time import sleep 3 | from typing import Final, List, Type 4 | 5 | from databases import Database 6 | 7 | from oaff.app.data.sources.common.data_source import DataSource 8 | from oaff.app.data.sources.common.data_source_manager import DataSourceManager 9 | from oaff.app.data.sources.postgresql import settings 10 | from oaff.app.data.sources.postgresql.profile import PostgresqlProfile 11 | from oaff.app.data.sources.postgresql.stac_hybrid.postgresql_data_source import ( 12 | PostgresqlDataSource as HybridDataSource, 13 | ) 14 | 15 | LOGGER: Final = getLogger(__file__) 16 | PROFILES: Final = { 17 | PostgresqlProfile.STAC_HYBRID: HybridDataSource, 18 | } 19 | 20 | 21 | class PostgresqlManager(DataSourceManager): 22 | def get_data_sources(self) -> List[Type[DataSource]]: 23 | data_sources = list() 24 | source_names = settings.source_names() 25 | 26 | async def connection_tester(database: Database, source_name: str) -> None: 27 | if not database.is_connected: 28 | iteration = 0 29 | while iteration < settings.connect_retries(source_name): 30 | try: 31 | await database.connect() 32 | LOGGER.info(f"connection tester succeeded iteration {iteration}") 33 | return 34 | except Exception as e: 35 | LOGGER.info( 36 | f"connection tester iteration {iteration} failed: {e}" 37 | ) 38 | iteration += 1 39 | sleep(1) 40 | 41 | for source_name in source_names if len("".join(source_names)) > 1 else [None]: 42 | data_sources.append( 43 | PROFILES[settings.profile(source_name)]( 44 | source_name, 45 | connection_tester, 46 | ) 47 | ) 48 | 49 | return data_sources 50 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/profile.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class PostgresqlProfile(str, Enum): 5 | STAC_HYBRID = "stac_hybrid" 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Set 3 | 4 | from oaff.app.settings import ENV_VAR_PREFIX 5 | 6 | 7 | def source_names() -> Set[str]: 8 | return set(os.environ.get(f"{ENV_VAR_PREFIX}POSTGRESQL_SOURCE_NAMES", "").split(",")) 9 | 10 | 11 | def profile(name: str) -> str: 12 | return os.environ[f"{ENV_VAR_PREFIX}POSTGRESQL_PROFILE{name_to_suffix(name)}"] 13 | 14 | 15 | def host(name: str) -> str: 16 | return os.environ.get( 17 | f"{ENV_VAR_PREFIX}POSTGRESQL_HOST{name_to_suffix(name)}", "localhost" 18 | ) 19 | 20 | 21 | def port(name: str) -> int: 22 | return int( 23 | os.environ.get(f"{ENV_VAR_PREFIX}POSTGRESQL_PORT{name_to_suffix(name)}", 5432) 24 | ) 25 | 26 | 27 | def user(name: str) -> str: 28 | return os.environ.get( 29 | f"{ENV_VAR_PREFIX}POSTGRESQL_USER{name_to_suffix(name)}", "postgres" 30 | ) 31 | 32 | 33 | def password(name: str) -> str: 34 | return os.environ.get( 35 | f"{ENV_VAR_PREFIX}POSTGRESQL_PASSWORD{name_to_suffix(name)}", "postgres" 36 | ) 37 | 38 | 39 | def dbname(name: str) -> str: 40 | return os.environ.get( 41 | f"{ENV_VAR_PREFIX}POSTGRESQL_DBNAME{name_to_suffix(name)}", "postgres" 42 | ) 43 | 44 | 45 | def connect_retries(name: str) -> int: 46 | return int( 47 | os.environ.get( 48 | f"{ENV_VAR_PREFIX}POSTGRESQL_CONNECT_RETRIES{name_to_suffix(name)}", 30 49 | ) 50 | ) 51 | 52 | 53 | def url(name: str) -> str: 54 | return "".join( 55 | [ 56 | "postgresql://", 57 | f"{user(name)}:", 58 | f"{password(name)}@", 59 | f"{host(name)}:", 60 | f"{port(name)}/", 61 | f"{dbname(name)}", 62 | ] 63 | ) 64 | 65 | 66 | def default_tz_code(name: str) -> str: 67 | return os.environ.get( 68 | f"{ENV_VAR_PREFIX}POSTGRESQL_DEFAULT_TZ{name_to_suffix(name)}", "UTC" 69 | ) 70 | 71 | 72 | def name_to_suffix(name: str) -> str: 73 | if name is None: 74 | return "" 75 | else: 76 | return f"_{name}" 77 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | script_location = /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations 3 | 4 | [loggers] 5 | keys = root,sqlalchemy,alembic 6 | 7 | [handlers] 8 | keys = console 9 | 10 | [formatters] 11 | keys = generic 12 | 13 | [logger_root] 14 | level = WARN 15 | handlers = console 16 | qualname = 17 | 18 | [logger_sqlalchemy] 19 | level = WARN 20 | handlers = 21 | qualname = sqlalchemy.engine 22 | 23 | [logger_alembic] 24 | level = INFO 25 | handlers = 26 | qualname = alembic 27 | 28 | [handler_console] 29 | class = StreamHandler 30 | args = (sys.stderr,) 31 | level = NOTSET 32 | formatter = generic 33 | 34 | [formatter_generic] 35 | format = %(levelname)-5.5s [%(name)s] %(message)s 36 | datefmt = %H:%M:%S -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | from os import environ 3 | from typing import Final 4 | 5 | from alembic import context 6 | from sqlalchemy import engine_from_config, pool 7 | 8 | from oaff.app.data.sources.postgresql import settings 9 | from oaff.app.data.sources.postgresql.stac_hybrid.models import * # noqa: F403, F401 10 | from oaff.app.data.sources.postgresql.stac_hybrid.settings import OAFF_METADATA 11 | 12 | EXCLUDE_TABLES: Final = ["spatial_ref_sys"] 13 | SOURCE_NAME: Final = environ.get("ALEMBIC_SOURCE_NAME") 14 | CONFIG_KEY_URL: Final = "sqlalchemy.url" 15 | 16 | config = context.config 17 | if config.get_main_option(CONFIG_KEY_URL) is None: 18 | """ 19 | if URL is already set this migration is being called from the API 20 | if not set this migration is being called independently and expects 21 | an environment variable identifying the source name 22 | """ 23 | config.set_main_option(CONFIG_KEY_URL, settings.url(SOURCE_NAME)) 24 | 25 | if config.config_file_name is not None: 26 | fileConfig(config.config_file_name) 27 | 28 | 29 | def include_object(object, name, type_, reflected, compare_to): 30 | if type_ == "table" and name in EXCLUDE_TABLES: 31 | return False 32 | return not object.info.get("is_view", False) 33 | 34 | 35 | def process_revision_directives(context, revision, directives): 36 | if config.cmd_opts.autogenerate: 37 | script = directives[0] 38 | if script.upgrade_ops.is_empty(): 39 | directives[:] = [] 40 | 41 | 42 | def run_migrations_online() -> None: 43 | connectable = engine_from_config( 44 | config.get_section(config.config_ini_section), 45 | prefix="sqlalchemy.", 46 | poolclass=pool.NullPool, 47 | ) 48 | 49 | with connectable.connect() as connection: 50 | context.configure( 51 | connection=connection, 52 | target_metadata=OAFF_METADATA, 53 | include_object=include_object, 54 | process_revision_directives=process_revision_directives, 55 | ) 56 | 57 | with context.begin_transaction(): 58 | context.run_migrations() 59 | 60 | 61 | run_migrations_online() 62 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | # flake8: noqa 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | """ 7 | from alembic import op 8 | import sqlalchemy as sa 9 | import geoalchemy2 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations/versions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/ogc-api-fast-features/f0fe22148499b3e428bb81ce8f653b4d46ad4125/oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations/versions/.gitkeep -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations/versions/0fda79152d26_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | # flake8: noqa 3 | Revision ID: 0fda79152d26 4 | Revises: 5 | Create Date: 2021-06-03 18:39:28.737450 6 | """ 7 | from alembic import op 8 | 9 | from oaff.app.data.sources.postgresql.stac_hybrid import settings 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "0fda79152d26" 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | op.execute(f"CREATE SCHEMA IF NOT EXISTS {settings.OAFF_SCHEMA_NAME}") 20 | op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') 21 | 22 | 23 | def downgrade(): 24 | # don't drop schema or extension on downgrade 25 | # no guarantee that we created them in upgrade 26 | pass 27 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/migrations/versions/1e7b6d30b536_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | # flake8: noqa 3 | Revision ID: 1e7b6d30b536 4 | Revises: 0fda79152d26 5 | Create Date: 2021-06-08 22:20:14.577127 6 | """ 7 | import geoalchemy2 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.dialects import postgresql 11 | 12 | from oaff.app.data.sources.postgresql.stac_hybrid import settings 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "1e7b6d30b536" 16 | down_revision = "0fda79152d26" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | op.create_table( 23 | "collections", 24 | sa.Column("id", sa.String(length=1024), nullable=False), 25 | sa.Column("title", sa.TEXT(), nullable=False), 26 | sa.Column("description", sa.Text(), nullable=True), 27 | sa.Column("keywords", sa.ARRAY(sa.String(length=50)), nullable=True), 28 | sa.Column("license", sa.Text(), nullable=True), 29 | sa.Column("providers", postgresql.JSONB(astext_type=sa.Text()), nullable=True), 30 | sa.Column("extent", postgresql.JSONB(astext_type=sa.Text()), nullable=True), 31 | sa.Column("temporal", postgresql.JSONB(astext_type=sa.Text()), nullable=True), 32 | sa.Column("schema_name", sa.String(length=63), nullable=False), 33 | sa.Column("table_name", sa.String(length=63), nullable=False), 34 | sa.PrimaryKeyConstraint("id"), 35 | sa.UniqueConstraint("schema_name", "table_name"), 36 | sa.UniqueConstraint("title"), 37 | schema=settings.OAFF_SCHEMA_NAME, 38 | ) 39 | 40 | 41 | def downgrade(): 42 | op.drop_table("collections", schema=settings.OAFF_SCHEMA_NAME) 43 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/models/collections.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy.dialects.postgresql import JSONB 3 | 4 | from oaff.app.data.sources.postgresql.stac_hybrid.settings import OAFF_METADATA 5 | 6 | collections = sa.Table( 7 | "collections", 8 | OAFF_METADATA, 9 | sa.Column("id", sa.types.String(1024), primary_key=True), 10 | sa.Column("title", sa.types.TEXT, nullable=False, unique=True), 11 | sa.Column("description", sa.types.Text), 12 | sa.Column("keywords", sa.types.ARRAY(sa.types.String(50))), 13 | sa.Column("license", sa.types.Text), 14 | sa.Column("providers", JSONB), 15 | sa.Column("extent", JSONB), 16 | sa.Column("temporal", JSONB), 17 | sa.Column("schema_name", sa.String(63), nullable=False), 18 | sa.Column("table_name", sa.String(63), nullable=False), 19 | sa.UniqueConstraint("schema_name", "table_name"), 20 | ) 21 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/postgresql_feature_provider.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | from typing import Final, List 3 | from uuid import uuid4 4 | 5 | import sqlalchemy as sa 6 | from databases.core import Database 7 | 8 | from oaff.app.data.retrieval.feature_provider import FeatureProvider 9 | from oaff.app.data.sources.postgresql.stac_hybrid.postgresql_layer import PostgresqlLayer 10 | from oaff.app.responses.models.collection_item_html import CollectionItemHtml 11 | from oaff.app.responses.models.link import Link 12 | 13 | 14 | class PostgresqlFeatureProvider(FeatureProvider): 15 | 16 | LINKS_PLACEHOLDER: Final = str(uuid4()) 17 | 18 | def __init__( 19 | self, 20 | db: Database, 21 | layer: PostgresqlLayer, 22 | ): 23 | self.db = db 24 | self.layer = layer 25 | 26 | async def as_geojson( 27 | self, 28 | feature_id: str, 29 | links: List[Link], 30 | ) -> str: 31 | result = await self.db.fetch_one( 32 | # fmt: off 33 | sa.select([ 34 | sa.text(f""" 35 | JSON_BUILD_OBJECT( 36 | 'type', 'Feature', 37 | 'id', "{self.layer.unique_field_name}", 38 | 'geometry', ST_AsGeoJSON( 39 | "{self.layer.geometry_field_name}" 40 | )::JSONB, 41 | 'properties', TO_JSONB({self.layer.table_name}) - '{ 42 | self.layer.unique_field_name 43 | }' - '{ 44 | self.layer.geometry_field_name 45 | }', 46 | 'links', '{self.LINKS_PLACEHOLDER}' 47 | ) 48 | """) 49 | ]).select_from( 50 | self.layer.model 51 | ).where(self.get_clause(self.layer, feature_id)) 52 | # fmt: on 53 | ) 54 | return ( 55 | result[0].replace( 56 | f'"{self.LINKS_PLACEHOLDER}"', dumps([dict(link) for link in links]) 57 | ) 58 | if result is not None 59 | else None 60 | ) 61 | 62 | async def as_html_compatible( 63 | self, 64 | feature_id: str, 65 | links: List[Link], 66 | ) -> CollectionItemHtml: 67 | result = await self.db.fetch_one( 68 | sa.select( 69 | [ 70 | col 71 | for col in self.layer.model.c 72 | if col.name != self.layer.geometry_field_name 73 | ] 74 | ) 75 | .select_from(self.layer.model) 76 | .where(self.get_clause(self.layer, feature_id)) 77 | ) 78 | return ( 79 | CollectionItemHtml( 80 | collection_id=self.layer.id, 81 | feature_id=feature_id, 82 | format_links=links, 83 | properties={key: str(value) for key, value in dict(result).items()}, 84 | ) 85 | if result is not None 86 | else None 87 | ) 88 | 89 | def get_clause(self, layer: PostgresqlLayer, feature_id: str): 90 | id_field = layer.model.columns[layer.unique_field_name] 91 | id_type = id_field.type.python_type 92 | if id_type is int: 93 | try: 94 | return id_field == int(feature_id) 95 | except ValueError: 96 | return False 97 | elif id_type is float: 98 | try: 99 | return id_field == float(feature_id) 100 | except ValueError: 101 | return False 102 | elif id_type is str: 103 | return id_field == feature_id 104 | else: 105 | return sa.cast(id_field, sa.types.String) == feature_id 106 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/postgresql_feature_set_provider.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | from typing import Callable, Dict, Final, List 3 | from uuid import uuid4 4 | 5 | import sqlalchemy as sa 6 | from databases.core import Database 7 | 8 | from oaff.app.data.retrieval.feature_set_provider import FeatureSetProvider 9 | from oaff.app.data.sources.postgresql.stac_hybrid.postgresql_layer import PostgresqlLayer 10 | from oaff.app.responses.models.collection_items_html import CollectionItemsHtml 11 | from oaff.app.responses.models.link import Link, PageLinkRel 12 | from oaff.app.util import now_as_rfc3339 13 | 14 | 15 | class PostgresqlFeatureSetProvider(FeatureSetProvider): 16 | 17 | FEATURES_PLACEHOLDER: Final = str(uuid4()) 18 | 19 | def __init__( 20 | self, 21 | db: Database, 22 | id_set: sa.sql.expression.Select, 23 | layer: PostgresqlLayer, 24 | total_count: int, 25 | ): 26 | self.db = db 27 | self.id_set = id_set 28 | self.layer = layer 29 | self.total_count = total_count 30 | 31 | async def as_geojson( 32 | self, 33 | links: List[Link], 34 | page_links_provider: Callable[[int, int], Dict[PageLinkRel, Link]], 35 | ) -> str: 36 | rows = [ 37 | row[0] 38 | for row in await self.db.fetch_all( 39 | # fmt: off 40 | sa.select([ 41 | sa.text(f""" 42 | JSON_BUILD_OBJECT( 43 | 'type', 'Feature', 44 | 'id', source."{self.layer.unique_field_name}", 45 | 'geometry', ST_AsGeoJSON( 46 | source."{self.layer.geometry_field_name}" 47 | )::JSONB, 48 | 'properties', TO_JSONB(source) - '{ 49 | self.layer.unique_field_name 50 | }' - '{ 51 | self.layer.geometry_field_name 52 | }' 53 | ) 54 | """) 55 | ]) 56 | .select_from( 57 | self.layer.model.alias("source").join( 58 | self.id_set, 59 | self.layer.model.alias("source").c[self.layer.unique_field_name] 60 | == self.id_set.c["id"], 61 | ) 62 | ) 63 | # fmt: on 64 | ) 65 | ] 66 | return dumps( 67 | { 68 | "type": "FeatureCollection", 69 | "features": self.FEATURES_PLACEHOLDER, 70 | "links": [ 71 | dict(link) 72 | for link in links 73 | + list(page_links_provider(self.total_count, len(rows)).values()) 74 | ], 75 | "numberMatched": self.total_count, 76 | "numberReturned": len(rows), 77 | "timeStamp": now_as_rfc3339(), 78 | } 79 | ).replace(f'"{self.FEATURES_PLACEHOLDER}"', f'[{",".join(rows)}]') 80 | 81 | async def as_html_compatible( 82 | self, links: List[Link], page_links_provider: Callable[[int, int], List[Link]] 83 | ) -> CollectionItemsHtml: 84 | rows = [ 85 | dict(row) 86 | for row in await self.db.fetch_all( 87 | sa.select( 88 | [ 89 | col 90 | for col in self.layer.model.c 91 | if col.name != self.layer.geometry_field_name 92 | ] 93 | ).select_from( 94 | self.layer.model.join( 95 | self.id_set, 96 | self.layer.model.primary_key.columns[self.layer.unique_field_name] 97 | == self.id_set.c["id"], 98 | ) 99 | ) 100 | ) 101 | ] 102 | page_links = page_links_provider(self.total_count, len(rows)) 103 | return CollectionItemsHtml( 104 | format_links=links, 105 | next_link=page_links[PageLinkRel.NEXT] 106 | if PageLinkRel.NEXT in page_links 107 | else None, 108 | prev_link=page_links[PageLinkRel.PREV] 109 | if PageLinkRel.PREV in page_links 110 | else None, 111 | features=rows, 112 | collection_id=self.layer.id, 113 | unique_field_name=self.layer.unique_field_name, 114 | ) 115 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/postgresql_layer.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql.schema import Table 2 | 3 | from oaff.app.data.sources.common.layer import Layer 4 | 5 | 6 | class PostgresqlLayer(Layer): 7 | schema_name: str 8 | table_name: str 9 | geometry_field_name: str 10 | geometry_srid: int 11 | model: Table 12 | 13 | class Config: 14 | arbitrary_types_allowed = True 15 | 16 | @property 17 | def unique_field_name(self): 18 | # layers are only available if they have exactly one PK 19 | return self.model.primary_key.columns.keys()[0] 20 | 21 | @property 22 | def fields(self): 23 | return self.model.columns.keys() 24 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Final, Set 3 | 4 | from sqlalchemy import MetaData 5 | 6 | from oaff.app.data.sources.postgresql.settings import ENV_VAR_PREFIX, name_to_suffix 7 | 8 | OAFF_SCHEMA_NAME: Final = "oaff" 9 | OAFF_METADATA: Final = MetaData(schema=OAFF_SCHEMA_NAME) 10 | 11 | 12 | def manage_as_collections(name: str) -> bool: 13 | return ( 14 | int(os.environ.get(f"{ENV_VAR_PREFIX}POSTGRESQL_MAC{name_to_suffix(name)}", "1")) 15 | == 1 16 | ) 17 | 18 | 19 | def blacklist(name: str) -> Set[str]: 20 | return { 21 | entry 22 | for entry in os.environ.get( 23 | f"{ENV_VAR_PREFIX}POSTGRESQL_LAYER_BLACKLIST{name_to_suffix(name)}", 24 | "", 25 | ).split(",") 26 | if len(entry) > 0 27 | } 28 | 29 | 30 | def whitelist(name: str) -> Set[str]: 31 | return { 32 | entry 33 | for entry in os.environ.get( 34 | f"{ENV_VAR_PREFIX}POSTGRESQL_LAYER_WHITELIST{name_to_suffix(name)}", 35 | "", 36 | ).split(",") 37 | if len(entry) > 0 38 | } 39 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/data/sources/postgresql/stac_hybrid/sql/layers.sql: -------------------------------------------------------------------------------- 1 | /* 2 | This query returns all geospatial tables within the database that may be exposed as layers. 3 | It excludes tables with multiple geometry columns and tables with composite primary keys. 4 | All tables must have a non-null SRID 5 | */ 6 | WITH geom_geog_columns AS ( 7 | SELECT f_table_schema schema_name 8 | , f_table_name table_name 9 | , f_geometry_column geometry_field 10 | , srid 11 | FROM geometry_columns 12 | UNION 13 | SELECT f_table_schema schema_name 14 | , f_table_name table_name 15 | , f_geography_column geometry_field 16 | , srid 17 | FROM geography_columns 18 | ), single_geom_tables AS ( 19 | SELECT gc.schema_name 20 | , gc.table_name 21 | , gc.geometry_field 22 | , srs.srid srid 23 | , srs.auth_name 24 | , srs.auth_srid auth_code 25 | FROM geom_geog_columns gc 26 | JOIN ( 27 | SELECT schema_name 28 | , table_name 29 | FROM geom_geog_columns 30 | GROUP BY schema_name 31 | , table_name 32 | HAVING COUNT(*) = 1 33 | ) s ON gc.schema_name = s.schema_name AND gc.table_name = s.table_name 34 | LEFT JOIN spatial_ref_sys srs ON gc.srid = srs.srid 35 | ), single_pk_tables AS ( 36 | SELECT kcu.table_schema schema_name 37 | , kcu.table_name 38 | , kcu.column_name unique_field 39 | FROM information_schema.key_column_usage kcu 40 | JOIN ( 41 | SELECT kcu.constraint_name 42 | , COUNT(*) 43 | FROM information_schema.key_column_usage kcu 44 | JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND tc.constraint_type = 'PRIMARY KEY' 45 | GROUP BY kcu.constraint_name 46 | HAVING COUNT(*) = 1 47 | ) spk ON kcu.constraint_name = spk.constraint_name 48 | order by kcu.table_schema 49 | , kcu.table_name 50 | , kcu.column_name 51 | ), eligible_tables AS ( 52 | SELECT schemaname schema_name 53 | , tablename table_name 54 | FROM pg_catalog.pg_tables 55 | WHERE schemaname NOT IN('pg_catalog', 'information_schema', 'oaff') 56 | ) 57 | SELECT QUOTE_IDENT(et.schema_name) || '.' || QUOTE_IDENT(et.table_name) qualified_table_name 58 | , et.schema_name 59 | , et.table_name 60 | , CASE 61 | WHEN sgt.table_name IS NULL THEN 'exactly one spatial column required' 62 | WHEN sgt.srid IS NULL THEN 'non-null SRID is required' 63 | WHEN spt.table_name IS NULL THEN 'exactly one primary key column required' 64 | ELSE NULL 65 | END exclude_reason 66 | , sgt.geometry_field 67 | , sgt.srid 68 | , sgt.auth_name 69 | , sgt.auth_code 70 | FROM eligible_tables et 71 | LEFT JOIN single_geom_tables sgt ON et.schema_name = sgt.schema_name AND et.table_name = sgt.table_name 72 | LEFT JOIN single_pk_tables spt ON et.schema_name = spt.schema_name AND et.table_name = spt.table_name 73 | ; -------------------------------------------------------------------------------- /oaff/app/oaff/app/gateway.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type 2 | 3 | from oaff.app.configuration.data import cleanup as cleanup_config, discover 4 | from oaff.app.configuration.frontend_configuration import FrontendConfiguration 5 | from oaff.app.configuration.frontend_interface import set_frontend_configuration 6 | from oaff.app.request_handlers.collection import Collection as CollectionRequestHandler 7 | from oaff.app.request_handlers.collection_items import ( 8 | CollectionsItems as CollectionsItemsRequestHandler, 9 | ) 10 | from oaff.app.request_handlers.collections_list import CollectionsListRequestHandler 11 | from oaff.app.request_handlers.common.request_handler import RequestHandler 12 | from oaff.app.request_handlers.conformance import ConformanceRequestHandler 13 | from oaff.app.request_handlers.feature import Feature as FeatureRequestHandler 14 | from oaff.app.request_handlers.landing_page import LandingPageRequestHandler 15 | from oaff.app.requests.common.request_type import RequestType 16 | from oaff.app.responses.response import Response 17 | 18 | handlers: Dict[str, RequestHandler] = { 19 | LandingPageRequestHandler.type_name(): LandingPageRequestHandler(), 20 | CollectionsListRequestHandler.type_name(): CollectionsListRequestHandler(), 21 | CollectionRequestHandler.type_name(): CollectionRequestHandler(), 22 | CollectionsItemsRequestHandler.type_name(): CollectionsItemsRequestHandler(), 23 | FeatureRequestHandler.type_name(): FeatureRequestHandler(), 24 | ConformanceRequestHandler.type_name(): ConformanceRequestHandler(), 25 | } 26 | 27 | 28 | async def handle(request: Type[RequestType]) -> Response: 29 | return await handlers[request.__class__.__name__].handle(request) 30 | 31 | 32 | async def configure(frontend_configuration: FrontendConfiguration) -> None: 33 | set_frontend_configuration(frontend_configuration) 34 | await discover() 35 | 36 | 37 | async def cleanup() -> None: 38 | await cleanup_config() 39 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/i18n/locale/en_US/LC_MESSAGES/translations.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/ogc-api-fast-features/f0fe22148499b3e428bb81ce8f653b4d46ad4125/oaff/app/oaff/app/i18n/locale/en_US/LC_MESSAGES/translations.mo -------------------------------------------------------------------------------- /oaff/app/oaff/app/i18n/locale/en_US/LC_MESSAGES/translations.po: -------------------------------------------------------------------------------- 1 | # English (United States) translations for PROJECT. 2 | # Copyright (C) 2017 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2017. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PROJECT VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2021-07-13 20:59-0700\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language: en_US\n" 15 | "Language-Team: en_US \n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=utf-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Generated-By: Babel 2.9.1\n" 21 | 22 | #: oaff/app/oaff/app/request_handlers/landing_page.py:23 23 | msgid "Features API Landing Page" 24 | msgstr "" 25 | 26 | #: oaff/app/oaff/app/request_handlers/landing_page.py:31 27 | #, python-format 28 | msgid "API Definition (%s)text/html" 29 | msgstr "" 30 | 31 | #: oaff/app/oaff/app/request_handlers/landing_page.py:37 32 | #, python-format 33 | msgid "API Definition (%s)" 34 | msgstr "" 35 | 36 | #: oaff/app/oaff/app/request_handlers/landing_page.py:50 37 | #, python-format 38 | msgid "Conformance (%s)" 39 | msgstr "" 40 | 41 | #: oaff/app/oaff/app/request_handlers/landing_page.py:64 42 | #, python-format 43 | msgid "Collections (%s)" 44 | msgstr "" 45 | 46 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:21 47 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:21 48 | msgid "Description" 49 | msgstr "" 50 | 51 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:28 52 | msgid "License" 53 | msgstr "" 54 | 55 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:35 56 | msgid "Keywords" 57 | msgstr "" 58 | 59 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:42 60 | msgid "Providers" 61 | msgstr "" 62 | 63 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:57 64 | msgid "Extents" 65 | msgstr "" 66 | 67 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:63 68 | msgid "Spatial" 69 | msgstr "" 70 | 71 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:77 72 | msgid "Temporal" 73 | msgstr "" 74 | 75 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:83 76 | msgid "Unbounded" 77 | msgstr "" 78 | 79 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:97 80 | msgid "Features Links" 81 | msgstr "" 82 | 83 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:103 84 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:22 85 | msgid "Features" 86 | msgstr "" 87 | 88 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:110 89 | msgid "Features in map bounds" 90 | msgstr "" 91 | 92 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:11 93 | msgid "Collection Items" 94 | msgstr "" 95 | 96 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:26 97 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:28 98 | msgid "Previous Page" 99 | msgstr "" 100 | 101 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:33 102 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:35 103 | msgid "Next Page" 104 | msgstr "" 105 | 106 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:9 107 | #: oaff/app/oaff/app/responses/templates/html/LandingPage.jinja2:35 108 | msgid "Collections" 109 | msgstr "" 110 | 111 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:19 112 | msgid "Name" 113 | msgstr "" 114 | 115 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:20 116 | msgid "Type" 117 | msgstr "" 118 | 119 | #: oaff/app/oaff/app/responses/templates/html/Conformance.jinja2:9 120 | #: oaff/app/oaff/app/responses/templates/html/LandingPage.jinja2:46 121 | msgid "Conformance" 122 | msgstr "" 123 | 124 | #: oaff/app/oaff/app/responses/templates/html/LandingPage.jinja2:23 125 | msgid "OpenAPI" 126 | msgstr "" 127 | 128 | #~ msgid "Collections List template" 129 | #~ msgstr "" 130 | 131 | #~ msgid "Links" 132 | #~ msgstr "" 133 | 134 | #~ msgid "Features" 135 | #~ msgstr "" 136 | 137 | #~ msgid "Spatial Extent" 138 | #~ msgstr "" 139 | 140 | #~ msgid "View Features" 141 | #~ msgstr "" 142 | 143 | #~ msgid "Landing Page template" 144 | #~ msgstr "" 145 | 146 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/i18n/locales.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Locales(str, Enum): 5 | en_US = "en-US" 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/i18n/translations.pot: -------------------------------------------------------------------------------- 1 | # Translations template for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PROJECT VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2021-07-13 20:59-0700\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Generated-By: Babel 2.9.1\n" 19 | 20 | #: oaff/app/oaff/app/request_handlers/landing_page.py:23 21 | msgid "Features API Landing Page" 22 | msgstr "" 23 | 24 | #: oaff/app/oaff/app/request_handlers/landing_page.py:31 25 | #, python-format 26 | msgid "API Definition (%s)text/html" 27 | msgstr "" 28 | 29 | #: oaff/app/oaff/app/request_handlers/landing_page.py:37 30 | #, python-format 31 | msgid "API Definition (%s)" 32 | msgstr "" 33 | 34 | #: oaff/app/oaff/app/request_handlers/landing_page.py:50 35 | #, python-format 36 | msgid "Conformance (%s)" 37 | msgstr "" 38 | 39 | #: oaff/app/oaff/app/request_handlers/landing_page.py:64 40 | #, python-format 41 | msgid "Collections (%s)" 42 | msgstr "" 43 | 44 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:21 45 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:21 46 | msgid "Description" 47 | msgstr "" 48 | 49 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:28 50 | msgid "License" 51 | msgstr "" 52 | 53 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:35 54 | msgid "Keywords" 55 | msgstr "" 56 | 57 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:42 58 | msgid "Providers" 59 | msgstr "" 60 | 61 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:57 62 | msgid "Extents" 63 | msgstr "" 64 | 65 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:63 66 | msgid "Spatial" 67 | msgstr "" 68 | 69 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:77 70 | msgid "Temporal" 71 | msgstr "" 72 | 73 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:83 74 | msgid "Unbounded" 75 | msgstr "" 76 | 77 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:97 78 | msgid "Features Links" 79 | msgstr "" 80 | 81 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:103 82 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:22 83 | msgid "Features" 84 | msgstr "" 85 | 86 | #: oaff/app/oaff/app/responses/templates/html/Collection.jinja2:110 87 | msgid "Features in map bounds" 88 | msgstr "" 89 | 90 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:11 91 | msgid "Collection Items" 92 | msgstr "" 93 | 94 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:26 95 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:28 96 | msgid "Previous Page" 97 | msgstr "" 98 | 99 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:33 100 | #: oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2:35 101 | msgid "Next Page" 102 | msgstr "" 103 | 104 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:9 105 | #: oaff/app/oaff/app/responses/templates/html/LandingPage.jinja2:35 106 | msgid "Collections" 107 | msgstr "" 108 | 109 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:19 110 | msgid "Name" 111 | msgstr "" 112 | 113 | #: oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2:20 114 | msgid "Type" 115 | msgstr "" 116 | 117 | #: oaff/app/oaff/app/responses/templates/html/Conformance.jinja2:9 118 | #: oaff/app/oaff/app/responses/templates/html/LandingPage.jinja2:46 119 | msgid "Conformance" 120 | msgstr "" 121 | 122 | #: oaff/app/oaff/app/responses/templates/html/LandingPage.jinja2:23 123 | msgid "OpenAPI" 124 | msgstr "" 125 | 126 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/i18n/translations.py: -------------------------------------------------------------------------------- 1 | from gettext import translation 2 | from os import path 3 | from typing import Any, Callable, Final 4 | 5 | from oaff.app.i18n.locales import Locales 6 | 7 | DEFAULT_LOCALE: Final = Locales.en_US 8 | _TRANSLATIONS: Final = { 9 | locale.value: translation( 10 | domain="translations", 11 | localedir=path.join(path.dirname(__file__), "locale"), 12 | languages=[locale.name], 13 | ) 14 | for locale in Locales 15 | } 16 | 17 | 18 | def get_translations_for_locale(locale: Locales) -> Any: 19 | return ( 20 | _TRANSLATIONS[locale.value] 21 | if locale.value in _TRANSLATIONS 22 | else _TRANSLATIONS[DEFAULT_LOCALE] 23 | ) 24 | 25 | 26 | def gettext_for_locale(locale: Locales) -> Callable[[str], str]: 27 | return get_translations_for_locale(locale).gettext 28 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/request_handlers/collection.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Final, Type 3 | 4 | from oaff.app.configuration.data import get_layer 5 | from oaff.app.request_handlers.common.request_handler import RequestHandler 6 | from oaff.app.requests.collection import Collection as CollectionRequestType 7 | from oaff.app.responses.models.collection import CollectionHtml, CollectionJson 8 | from oaff.app.responses.response import Response 9 | from oaff.app.responses.response_format import ResponseFormat 10 | 11 | LOGGER: Final = getLogger(__file__) 12 | 13 | 14 | class Collection(RequestHandler): 15 | @classmethod 16 | def type_name(cls) -> str: 17 | return CollectionRequestType.__name__ 18 | 19 | async def handle(self, request: CollectionRequestType) -> Type[Response]: 20 | layer = get_layer(request.collection_id) 21 | if layer is None: 22 | return self.collection_404(request.collection_id) 23 | format_links = self.get_links_for_self(request) 24 | if request.format == ResponseFormat.html: 25 | collection = CollectionHtml.from_layer(layer, request) 26 | collection.format_links = format_links 27 | return self.object_to_html_response( 28 | collection, 29 | request, 30 | ) 31 | elif request.format == ResponseFormat.json: 32 | collection = CollectionJson.from_layer(layer, request) 33 | collection.add_format_links(format_links) 34 | return self.object_to_json_response( 35 | collection, 36 | request, 37 | ) 38 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/request_handlers/collections_list.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Final, Type 3 | 4 | from oaff.app.configuration.data import get_layers 5 | from oaff.app.request_handlers.common.request_handler import RequestHandler 6 | from oaff.app.requests.collections_list import CollectionsList as CollectionsListRequest 7 | from oaff.app.responses.models.collection import CollectionHtml, CollectionJson 8 | from oaff.app.responses.models.collections import CollectionsHtml, CollectionsJson 9 | from oaff.app.responses.response import Response 10 | from oaff.app.responses.response_format import ResponseFormat 11 | 12 | LOGGER: Final = getLogger(__file__) 13 | 14 | 15 | class CollectionsListRequestHandler(RequestHandler): 16 | @classmethod 17 | def type_name(cls) -> str: 18 | return CollectionsListRequest.__name__ 19 | 20 | async def handle(self, request: CollectionsListRequest) -> Type[Response]: 21 | format_links = self.get_links_for_self(request) 22 | if request.format == ResponseFormat.html: 23 | collections = [ 24 | CollectionHtml.from_layer(layer, request) for layer in get_layers() 25 | ] 26 | return self.object_to_html_response( 27 | CollectionsHtml(collections=collections, format_links=format_links), 28 | request, 29 | ) 30 | elif request.format == ResponseFormat.json: 31 | collections = [ 32 | CollectionJson.from_layer(layer, request) for layer in get_layers() 33 | ] 34 | return self.object_to_json_response( 35 | CollectionsJson(collections=collections, links=format_links), 36 | request, 37 | ) 38 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/request_handlers/common/request_handler.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from http import HTTPStatus 3 | from json import dumps 4 | from typing import Any, Final, List, Type 5 | 6 | from oaff.app.configuration.frontend_interface import get_frontend_configuration 7 | from oaff.app.i18n.translations import gettext_for_locale 8 | from oaff.app.requests.common.request_type import RequestType 9 | from oaff.app.responses.data_response import DataResponse 10 | from oaff.app.responses.error_response import ErrorResponse 11 | from oaff.app.responses.models.link import Link, LinkRel 12 | from oaff.app.responses.response import Response 13 | from oaff.app.responses.response_format import ResponseFormat 14 | from oaff.app.responses.templates.templates import get_rendered_html 15 | 16 | 17 | class RequestHandler(ABC): 18 | @abstractmethod 19 | async def handle(self, request: Type[RequestType]) -> Type[Response]: 20 | pass 21 | 22 | def get_links_for_self( 23 | self, 24 | request: Type[RequestType], 25 | ) -> List[Link]: 26 | url_modifier = get_frontend_configuration().endpoint_format_switcher 27 | return [ 28 | Link( 29 | href=url_modifier(request.url, response_format), 30 | rel=LinkRel.SELF 31 | if request.format == response_format 32 | else LinkRel.ALTERNATE, 33 | type=response_format[request.type], 34 | # variable substitution explained 35 | # https://inventwithpython.com/blog/2014/12/20/translate-your-python-3-program-with-the-gettext-module/ # noqa: E501 36 | title=gettext_for_locale(request.locale)("This document") 37 | if request.format == response_format 38 | else gettext_for_locale(request.locale)( 39 | "This document (%s)" % response_format[request.type] 40 | ), 41 | ) 42 | for response_format in ResponseFormat 43 | ] 44 | 45 | def raw_to_response( 46 | self, 47 | response: Any, 48 | request: Type[RequestType], 49 | ) -> DataResponse: 50 | return DataResponse( 51 | mime_type=request.format[request.type], 52 | encoded_response=response, 53 | ) 54 | 55 | def object_to_response( 56 | self, 57 | object: Any, 58 | request: Type[RequestType], 59 | ) -> DataResponse: 60 | format_to_response: Final = { 61 | ResponseFormat.json.name: self.object_to_json_response, 62 | ResponseFormat.html.name: self.object_to_html_response, 63 | } 64 | return format_to_response[request.format.name](object, request) 65 | 66 | def object_to_json_response( 67 | self, 68 | object: Any, 69 | request: Type[RequestType], 70 | ) -> DataResponse: 71 | jsonable_method = getattr(object, "jsonable", None) 72 | if callable(jsonable_method): 73 | object = jsonable_method() 74 | return DataResponse( 75 | mime_type=ResponseFormat.json[request.type], 76 | encoded_response=dumps(object), 77 | ) 78 | 79 | def object_to_html_response( 80 | self, 81 | object: Any, 82 | request: Type[RequestType], 83 | ) -> DataResponse: 84 | return DataResponse( 85 | mime_type=ResponseFormat.html[request.type], 86 | encoded_response=get_rendered_html( 87 | f"{request.__class__.__name__}", object, request.locale 88 | ), 89 | ) 90 | 91 | def collection_404(self, collection_id: str) -> ErrorResponse: 92 | return self._get_404(f"Collection {collection_id} not found") 93 | 94 | def feature_404(self, collection_id: str, feature_id: str) -> ErrorResponse: 95 | return self._get_404(f"Feature {collection_id}/{feature_id} not found") 96 | 97 | def _get_404(self, detail: str = "") -> ErrorResponse: 98 | return ErrorResponse(status_code=HTTPStatus.NOT_FOUND, detail=detail) 99 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/request_handlers/conformance.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from oaff.app.request_handlers.common.request_handler import RequestHandler 4 | from oaff.app.requests.conformance import Conformance as ConformanceRequest 5 | from oaff.app.responses.models.conformance import ConformanceHtml, ConformanceJson 6 | from oaff.app.responses.response import Response 7 | from oaff.app.responses.response_format import ResponseFormat 8 | 9 | 10 | class ConformanceRequestHandler(RequestHandler): 11 | @classmethod 12 | def type_name(cls) -> str: 13 | return ConformanceRequest.__name__ 14 | 15 | async def handle(self, request: ConformanceRequest) -> Type[Response]: 16 | conform_list = [ 17 | "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", 18 | "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", 19 | "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", 20 | "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", 21 | "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html", 22 | "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", 23 | ] 24 | 25 | if request.format == ResponseFormat.html: 26 | return self.object_to_html_response( 27 | ConformanceHtml( 28 | conformsTo=conform_list, 29 | format_links=self.get_links_for_self(request), 30 | ), 31 | request, 32 | ) 33 | elif request.format == ResponseFormat.json: 34 | return self.object_to_json_response( 35 | ConformanceJson( 36 | conformsTo=conform_list, 37 | ), 38 | request, 39 | ) 40 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/request_handlers/feature.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Final, Type 3 | from urllib.parse import quote 4 | 5 | from oaff.app.configuration.data import get_data_source, get_layer 6 | from oaff.app.configuration.frontend_interface import get_frontend_configuration 7 | from oaff.app.request_handlers.common.request_handler import RequestHandler 8 | from oaff.app.requests.feature import Feature as FeatureRequestType 9 | from oaff.app.responses.models.link import Link, LinkRel 10 | from oaff.app.responses.response import Response 11 | from oaff.app.responses.response_format import ResponseFormat 12 | from oaff.app.responses.response_type import ResponseType 13 | 14 | LOGGER: Final = getLogger(__file__) 15 | 16 | 17 | class Feature(RequestHandler): 18 | @classmethod 19 | def type_name(cls) -> str: 20 | return FeatureRequestType.__name__ 21 | 22 | async def handle(self, request: FeatureRequestType) -> Type[Response]: 23 | layer = get_layer(request.collection_id) 24 | if layer is None: 25 | return self.feature_404(request.collection_id, request.feature_id) 26 | data_source = get_data_source(layer.data_source_id) 27 | feature_provider = await data_source.get_feature_provider( 28 | layer, 29 | ) 30 | format_links = self.get_links_for_self(request) 31 | if request.format == ResponseFormat.json: 32 | response = await feature_provider.as_geojson( 33 | request.feature_id, 34 | format_links 35 | + [ 36 | Link( 37 | href="".join( 38 | [ 39 | request.root, 40 | f"{get_frontend_configuration().api_url_base}/", 41 | f"collections/{quote(request.collection_id)}", 42 | f"?format={format.name}", 43 | ] 44 | ), 45 | rel=LinkRel.COLLECTION, 46 | type=format[ResponseType.METADATA], 47 | title=layer.title, 48 | ) 49 | for format in ResponseFormat 50 | ], 51 | ) 52 | return ( 53 | self.raw_to_response( 54 | response, 55 | request, 56 | ) 57 | if response is not None 58 | else self.feature_404(request.collection_id, request.feature_id) 59 | ) 60 | elif request.format == ResponseFormat.html: 61 | response = await feature_provider.as_html_compatible( 62 | request.feature_id, 63 | format_links, 64 | ) 65 | return ( 66 | self.object_to_response( 67 | response, 68 | request, 69 | ) 70 | if response is not None 71 | else self.feature_404(request.collection_id, request.feature_id) 72 | ) 73 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/request_handlers/landing_page.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from oaff.app.configuration.frontend_interface import get_frontend_configuration 4 | from oaff.app.i18n.translations import gettext_for_locale 5 | from oaff.app.request_handlers.common.request_handler import RequestHandler 6 | from oaff.app.requests.landing_page import LandingPage as LandingPageRequest 7 | from oaff.app.responses.models.landing import LandingHtml, LandingJson 8 | from oaff.app.responses.models.link import Link, LinkRel 9 | from oaff.app.responses.response import Response 10 | from oaff.app.responses.response_format import ResponseFormat 11 | from oaff.app.responses.response_type import ResponseType 12 | from oaff.app.settings import OPENAPI_OGC_TYPE 13 | 14 | 15 | class LandingPageRequestHandler(RequestHandler): 16 | @classmethod 17 | def type_name(cls) -> str: 18 | return LandingPageRequest.__name__ 19 | 20 | async def handle(self, request: LandingPageRequest) -> Type[Response]: 21 | gettext = gettext_for_locale(request.locale) 22 | frontend = get_frontend_configuration() 23 | title = gettext("Features API Landing Page") 24 | description = "" 25 | format_links = self.get_links_for_self(request) 26 | openapi_links = [ 27 | Link( 28 | href=f"{request.root}{frontend.openapi_path_html}", 29 | rel=LinkRel.SERVICE_DOC, 30 | type="text/html", 31 | title=gettext("API Definition (%s)" % "text/html"), 32 | ), 33 | Link( 34 | href=f"{request.root}{frontend.openapi_path_json}", 35 | rel=LinkRel.SERVICE_DESC, 36 | type=OPENAPI_OGC_TYPE, 37 | title=gettext("API Definition (%s)" % OPENAPI_OGC_TYPE), 38 | ), 39 | ] 40 | conformance_links = [ 41 | Link( 42 | href="".join( 43 | [ 44 | f"{request.root}{frontend.api_url_base}/conformance", 45 | f"?format={format.name}", 46 | ] 47 | ), 48 | rel=LinkRel.CONFORMANCE, 49 | type=format[ResponseType.METADATA], 50 | title=gettext("Conformance (%s)" % format[ResponseType.METADATA]), 51 | ) 52 | for format in ResponseFormat 53 | ] 54 | collection_links = [ 55 | Link( 56 | href="".join( 57 | [ 58 | f"{request.root}{frontend.api_url_base}/collections", 59 | f"?format={format.name}", 60 | ] 61 | ), 62 | rel=LinkRel.DATA, 63 | type=format[ResponseType.METADATA], 64 | title=gettext("Collections (%s)" % format[ResponseType.METADATA]), 65 | ) 66 | for format in ResponseFormat 67 | ] 68 | if request.format == ResponseFormat.html: 69 | return self.object_to_html_response( 70 | LandingHtml( 71 | title=title, 72 | description=description, 73 | format_links=format_links, 74 | openapi_links=openapi_links, 75 | conformance_links=conformance_links, 76 | collection_links=collection_links, 77 | ), 78 | request, 79 | ) 80 | elif request.format == ResponseFormat.json: 81 | return self.object_to_json_response( 82 | LandingJson( 83 | title=title, 84 | description=description, 85 | links=format_links 86 | + openapi_links 87 | + conformance_links 88 | + collection_links, 89 | ), 90 | request, 91 | ) 92 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/requests/collection.py: -------------------------------------------------------------------------------- 1 | from oaff.app.requests.common.request_type import RequestType 2 | 3 | 4 | class Collection(RequestType): 5 | collection_id: str 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/requests/collection_items.py: -------------------------------------------------------------------------------- 1 | from oaff.app.data.retrieval.filter_parameters import FilterParameters 2 | from oaff.app.data.retrieval.item_constraints import ItemConstraints 3 | from oaff.app.requests.common.request_type import RequestType 4 | 5 | 6 | class CollectionItems(RequestType, ItemConstraints, FilterParameters): 7 | collection_id: str 8 | 9 | def get_item_constraints(self) -> ItemConstraints: 10 | return ItemConstraints( 11 | limit=self.limit, 12 | offset=self.offset, 13 | ) 14 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/requests/collections_list.py: -------------------------------------------------------------------------------- 1 | from oaff.app.requests.common.request_type import RequestType 2 | 3 | 4 | class CollectionsList(RequestType): 5 | pass 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/requests/common/request_type.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from oaff.app.i18n.locales import Locales 4 | from oaff.app.responses.response_format import ResponseFormat 5 | from oaff.app.responses.response_type import ResponseType 6 | 7 | 8 | class RequestType(BaseModel): 9 | url: str 10 | root: str 11 | format: ResponseFormat 12 | type: ResponseType 13 | locale: Locales 14 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/requests/conformance.py: -------------------------------------------------------------------------------- 1 | from oaff.app.requests.common.request_type import RequestType 2 | 3 | 4 | class Conformance(RequestType): 5 | pass 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/requests/feature.py: -------------------------------------------------------------------------------- 1 | from oaff.app.requests.common.request_type import RequestType 2 | 3 | 4 | class Feature(RequestType): 5 | collection_id: str 6 | feature_id: str 7 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/requests/landing_page.py: -------------------------------------------------------------------------------- 1 | from oaff.app.requests.common.request_type import RequestType 2 | 3 | 4 | class LandingPage(RequestType): 5 | pass 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/data_response.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from oaff.app.responses.response import Response 4 | 5 | 6 | class DataResponse(Response): 7 | mime_type: str 8 | encoded_response: Any # could be HTML str, GPKG bytes etc 9 | additional_headers: Dict[str, str] = dict() 10 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/error_response.py: -------------------------------------------------------------------------------- 1 | from oaff.app.responses.response import Response 2 | 3 | 4 | class ErrorResponse(Response): 5 | detail: str = "" 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/collection.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Type 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.configuration.frontend_interface import get_frontend_configuration 6 | from oaff.app.data.sources.common.layer import Layer 7 | from oaff.app.data.sources.common.provider import Provider 8 | from oaff.app.requests.common.request_type import RequestType 9 | from oaff.app.responses.models.extent import Extent 10 | from oaff.app.responses.models.item_type import ItemType 11 | from oaff.app.responses.models.link import Link, LinkRel 12 | from oaff.app.responses.response_format import ResponseFormat 13 | from oaff.app.responses.response_type import ResponseType 14 | 15 | 16 | class Collection(BaseModel): 17 | id: str 18 | title: str 19 | description: Optional[str] = None 20 | extent: Optional[Extent] = None 21 | itemType: ItemType 22 | links: List[Link] 23 | license: Optional[str] = None 24 | keywords: Optional[List[str]] = None 25 | providers: Optional[List[Provider]] = None 26 | 27 | @classmethod 28 | def from_layer(cls, layer: Layer, request: Type[RequestType]): 29 | return cls( 30 | id=layer.id, 31 | title=layer.title, 32 | description=layer.description, 33 | extent=Extent.from_layer(layer) if Extent.has_extent(layer) else None, 34 | itemType=ItemType.FEATURE, 35 | links=[ 36 | Link( 37 | href="".join( 38 | [ 39 | request.root, 40 | f"{get_frontend_configuration().get_items_path(layer.id)}", 41 | f"?format={format.name}", 42 | ] 43 | ), 44 | rel=LinkRel.ITEMS, 45 | type=format[ResponseType.DATA], 46 | title=layer.title, 47 | ) 48 | for format in ResponseFormat 49 | ], 50 | license=layer.license, 51 | keywords=layer.keywords, 52 | providers=layer.providers, 53 | ) 54 | 55 | 56 | class CollectionJson(Collection): 57 | def add_format_links(self, format_links: List[Link]) -> None: 58 | self.links.extend(format_links) 59 | 60 | def jsonable(self): 61 | return { 62 | **dict(self), 63 | **{ 64 | "extent": self.extent.jsonable() if self.extent is not None else None, 65 | "links": [dict(link) for link in self.links], 66 | "providers": [dict(provider) for provider in self.providers] 67 | if self.providers 68 | else None, 69 | }, 70 | } 71 | 72 | 73 | class CollectionHtml(Collection): 74 | format_links: List[Link] = [] 75 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/collection_item_html.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.responses.models.link import Link 6 | 7 | 8 | class CollectionItemHtml(BaseModel): 9 | collection_id: str 10 | feature_id: str 11 | format_links: List[Link] 12 | properties: Dict[str, str] 13 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/collection_items_html.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.responses.models.link import Link 6 | 7 | 8 | class CollectionItemsHtml(BaseModel): 9 | format_links: List[Link] 10 | next_link: Optional[Link] 11 | prev_link: Optional[Link] 12 | features: List[Dict[str, Any]] 13 | collection_id: str 14 | unique_field_name: str 15 | 16 | def has_features(self) -> bool: 17 | return len(self.features) > 0 18 | 19 | def field_names(self) -> List[str]: 20 | return ( 21 | [self.unique_field_name] 22 | + sorted( 23 | [ 24 | field_name 25 | for field_name in self.features[0].keys() 26 | if field_name != self.unique_field_name 27 | ] 28 | ) 29 | if self.has_features() 30 | else [] 31 | ) 32 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/collections.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.responses.models.collection import CollectionHtml, CollectionJson 6 | from oaff.app.responses.models.link import Link 7 | 8 | 9 | class CollectionsHtml(BaseModel): 10 | collections: List[CollectionHtml] 11 | format_links: List[Link] 12 | 13 | 14 | class CollectionsJson(BaseModel): 15 | collections: List[CollectionJson] 16 | links: List[Link] 17 | 18 | def jsonable(self): 19 | return { 20 | "collections": [collection.jsonable() for collection in self.collections], 21 | "links": [dict(link) for link in self.links], 22 | } 23 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/conformance.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.responses.models.link import Link 6 | 7 | 8 | class Conformance(BaseModel): 9 | conformsTo: List[str] 10 | 11 | def jsonable(self): 12 | return dict(self) 13 | 14 | 15 | class ConformanceHtml(Conformance): 16 | format_links: List[Link] 17 | 18 | 19 | class ConformanceJson(Conformance): 20 | pass 21 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/extent.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from oaff.app.data.sources.common.layer import Layer 4 | from oaff.app.responses.models.extent_spatial import ExtentSpatial 5 | from oaff.app.responses.models.extent_temporal import ExtentTemporal 6 | 7 | 8 | class Extent(BaseModel): 9 | spatial: ExtentSpatial 10 | temporal: ExtentTemporal 11 | 12 | @classmethod 13 | def has_extent(cls, layer: Layer): 14 | return layer.bboxes is not None and layer.intervals is not None 15 | 16 | @classmethod 17 | def from_layer(cls, layer: Layer): 18 | return cls( 19 | spatial=ExtentSpatial( 20 | bbox=layer.bboxes, 21 | ), 22 | temporal=ExtentTemporal(interval=layer.intervals), 23 | ) 24 | 25 | def jsonable(self): 26 | return { 27 | "spatial": dict(self.spatial), 28 | "temporal": dict(self.temporal), 29 | } 30 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/extent_spatial.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel, validator 4 | 5 | from oaff.app.util import LAT_MAX, LAT_MIN, LON_MAX, LON_MIN, is_valid_lat, is_valid_lon 6 | 7 | 8 | class ExtentSpatial(BaseModel): 9 | bbox: List[List[float]] 10 | 11 | @validator("bbox") 12 | def _bbox_valid(cls, value): 13 | for each_bbox in value: 14 | if len(each_bbox) != 4: 15 | raise ValueError( 16 | f"bbox must have exactly four entries (currently {len(each_bbox)})" 17 | ) 18 | if not ( 19 | is_valid_lon(each_bbox[0]) 20 | and is_valid_lat(each_bbox[1]) 21 | and is_valid_lon(each_bbox[2]) 22 | and is_valid_lat(each_bbox[3]) 23 | ): 24 | raise ValueError( 25 | "".join( 26 | [ 27 | f"{each_bbox[0]} and {each_bbox[2]} ", 28 | f"must not exceed {LON_MIN} and {LON_MAX}. " 29 | f"{each_bbox[1]} and {each_bbox[3]} ", 30 | f"must not exceed {LAT_MIN} and {LAT_MAX}.", 31 | ] 32 | ) 33 | ) 34 | return value 35 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/extent_temporal.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel, validator 4 | 5 | 6 | class ExtentTemporal(BaseModel): 7 | interval: List[List[Optional[str]]] 8 | 9 | @validator("interval") 10 | def _pairs_only(cls, value): 11 | for entry in value: 12 | if len(entry) != 2: 13 | raise ValueError( 14 | "".join( 15 | [ 16 | "Temporal intervals must be described in pairs; ", 17 | "use 'null' to indicate an open-ended interval", 18 | ] 19 | ) 20 | ) 21 | return value 22 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/item_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ItemType(str, Enum): 5 | FEATURE = "feature" 6 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/landing.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from oaff.app.responses.models.link import Link 6 | 7 | 8 | class Landing(BaseModel): 9 | title: str 10 | description: str 11 | 12 | 13 | class LandingJson(Landing): 14 | links: List[Link] 15 | 16 | def jsonable(self): 17 | return { 18 | **dict(self), 19 | **{ 20 | "links": [link.jsonable() for link in self.links], 21 | }, 22 | } 23 | 24 | 25 | class LandingHtml(Landing): 26 | format_links: List[Link] 27 | openapi_links: List[Link] 28 | collection_links: List[Link] 29 | conformance_links: List[Link] 30 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/models/link.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | 4 | from pydantic import BaseModel, validator 5 | 6 | 7 | class LinkRel(str, Enum): 8 | SELF = "self" 9 | ALTERNATE = "alternate" 10 | DESCRIBED_BY = "describedby" 11 | ENCLOSURE = "enclosure" 12 | ITEMS = "items" 13 | NEXT = "next" 14 | PREV = "prev" 15 | COLLECTION = "collection" 16 | SERVICE_DESC = "service-desc" 17 | SERVICE_DOC = "service-doc" 18 | CONFORMANCE = "conformance" 19 | DATA = "data" 20 | 21 | 22 | class PageLinkRel(str, Enum): 23 | NEXT = LinkRel.NEXT.value 24 | PREV = LinkRel.PREV.value 25 | 26 | 27 | class Link(BaseModel): 28 | href: str 29 | rel: LinkRel 30 | type: str 31 | title: str 32 | 33 | @validator("type") 34 | def _type_is_mime(cls, value): 35 | if re.match(r"^\w+/[-.\w]+(?:\+[-.\w]+)?", value) is not None: 36 | return value 37 | raise ValueError(f"{value} is not recognised as a MIME type") 38 | 39 | def jsonable(self): 40 | return dict(self) 41 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/response.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Response(BaseModel): 7 | status_code: HTTPStatus = HTTPStatus.OK 8 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/response_format.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from oaff.app.responses.response_type import ResponseType 4 | 5 | 6 | class ResponseFormat(dict, Enum): # type: ignore[misc] 7 | html = { 8 | ResponseType.DATA: "text/html", 9 | ResponseType.METADATA: "text/html", 10 | } 11 | json = { 12 | ResponseType.DATA: "application/geo+json", 13 | ResponseType.METADATA: "application/json", 14 | } 15 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/response_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ResponseType(Enum): 5 | DATA = 0 6 | METADATA = 1 7 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/Collection.jinja2: -------------------------------------------------------------------------------- 1 | {% include "header.jinja2" %} 2 | 7 |
8 | 18 | 19 | {% if response.description is not none and response.description|length > 0 %} 20 |
21 |

{% trans %}Description{% endtrans %}

22 | {{response.description}} 23 |
24 | {% endif %} 25 | 26 | {% if response.license is not none and response.license|length > 0 %} 27 |
28 |

{% trans %}License{% endtrans %}

29 | {{response.license}} 30 |
31 | {% endif %} 32 | 33 | {% if response.keywords is not none and response.keywords|length > 0 %} 34 |
35 |

{% trans %}Keywords{% endtrans %}

36 | {{response.keywords|join(' ')}} 37 |
38 | {% endif %} 39 | 40 | {% if response.providers is not none and response.providers|length > 0 %} 41 |
42 |

{% trans %}Providers{% endtrans %}

43 |
    44 | {% for provider in response.providers %} 45 |
  • 46 | {{provider.name}} 47 | {% if provider.roles is not none and provider.roles|length > 0 %} 48 | {{provider.roles|join(' ')}} 49 | {% endif %} 50 |
  • 51 | {% endfor %} 52 |
53 |
54 | {% endif %} 55 | 56 |
57 |

{% trans %}Extents{% endtrans %}

58 | 59 | {% for bbox in response.extent.spatial.bbox %} 60 | 61 | 66 | 71 | 72 | {% endfor %} 73 | {% for interval in response.extent.temporal.interval %} 74 | 75 | 80 | 90 | 91 | {% endfor %} 92 |
62 | {% if loop.index0 == 0 %} 63 | {% trans %}Spatial{% endtrans %} 64 | {% endif %} 65 | 67 | {% for pos in bbox %} 68 | {{pos}}{{ ", " if not loop.last else "" }} 69 | {% endfor %} 70 |
76 | {% if loop.index0 == 0 %} 77 | {% trans %}Temporal{% endtrans %} 78 | {% endif %} 79 | 81 | {% for end in interval %} 82 | {% if end is none %} 83 | {% trans %}Unbounded{% endtrans %} 84 | {% else %} 85 | {{end}} 86 | {% endif %} 87 | {{ "/ " if not loop.last else "" }} 88 | {% endfor %} 89 |
93 |
94 | 95 | 96 | 116 | 117 |
118 |
119 |
120 |
121 | 122 | 145 | {% include "footer.jinja2" %} -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/CollectionItems.jinja2: -------------------------------------------------------------------------------- 1 | {% include "header.jinja2" %} 2 | 8 |
9 | 19 | {% if response.has_features() %} 20 |
21 | 40 | 41 |
42 |
43 |
44 | 45 | 46 | {% for field_name in response.field_names() %} 47 | 48 | {% endfor %} 49 | 50 | {% for feature in response.features %} 51 | 52 | {% for field_name in response.field_names() %} 53 | {% if field_name == response.unique_field_name %} 54 | 59 | {% else %} 60 | 61 | {% endif %} 62 | {% endfor %} 63 | 64 | {% endfor %} 65 |
{{field_name}}
55 | 56 | {{feature[field_name]}} 57 | 58 | {{feature[field_name]}}
66 |
67 |
68 |
69 | {% endif %} 70 |
71 | 72 | 89 | {% include "footer.jinja2" %} -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/CollectionsList.jinja2: -------------------------------------------------------------------------------- 1 | {% include "header.jinja2" %} 2 | 6 |
7 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for summary in response.collections %} 24 | 25 | 26 | 27 | 28 | 29 | {% endfor %} 30 |
{% trans %}Name{% endtrans %}{% trans %}Type{% endtrans %}{% trans %}Description{% endtrans %}
{{summary.title}}{{summary.itemType.value}}{{summary.description}}
31 |
32 | {% include "footer.jinja2" %} -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/Conformance.jinja2: -------------------------------------------------------------------------------- 1 | {% include "header.jinja2" %} 2 | 6 |
7 | 17 | 18 | 27 | 28 |
29 | {% include "footer.jinja2" %} -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/Feature.jinja2: -------------------------------------------------------------------------------- 1 | {% include "header.jinja2" %} 2 | 9 |
10 | 20 |
21 |
22 |
23 | 24 | {% for key, value in response.properties.items() %} 25 | 26 | 27 | 28 | 29 | {% endfor %} 30 |
{{key}}{{value}}
31 |
32 |
33 |
34 | 41 | {% include "footer.jinja2" %} 42 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/LandingPage.jinja2: -------------------------------------------------------------------------------- 1 | {% include "header.jinja2" %} 2 | 5 |
6 | 16 | 17 | {% if response.description|length > 1 %} 18 |
19 | {{response.description}} 20 |
21 | {% endif %} 22 | 23 |

{% trans %}OpenAPI{% endtrans %}

24 | 33 | 34 | 35 |

{% trans %}Collections{% endtrans %}

36 | 45 | 46 |

{% trans %}Conformance{% endtrans %}

47 | 56 | 57 | 58 |
59 | {% include "footer.jinja2" %} -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/footer.jinja2: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/html/header.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 11 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/responses/templates/templates.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from jinja2 import Environment, PackageLoader, select_autoescape 4 | 5 | from oaff.app.configuration.frontend_interface import get_frontend_configuration 6 | from oaff.app.i18n.locales import Locales 7 | from oaff.app.i18n.translations import get_translations_for_locale 8 | 9 | 10 | def get_rendered_html(template_name: str, data: object, locale: Locales) -> str: 11 | env = Environment( 12 | extensions=["jinja2.ext.i18n"], 13 | loader=PackageLoader("oaff.app", path.join("responses", "templates", "html")), 14 | autoescape=select_autoescape(["html"]), 15 | ) 16 | env.install_gettext_translations(get_translations_for_locale(locale)) # type: ignore 17 | frontend_config = get_frontend_configuration() 18 | return env.get_template(f"{template_name}.jinja2").render( 19 | response=data, 20 | api_url_base=frontend_config.api_url_base, 21 | asset_url_base=frontend_config.asset_url_base, 22 | ) 23 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Final, List 3 | 4 | ENV_VAR_PREFIX: Final = os.environ.get("APP_ENV_VAR_PREFIX", "APP_") 5 | SPATIAL_FILTER_GEOMETRY_FIELD_ALIAS: Final = "geometry" 6 | OPENAPI_OGC_TYPE: Final = "application/vnd.oai.openapi+json;version=3.0" 7 | 8 | 9 | def DATA_SOURCE_TYPES() -> List[str]: 10 | return [ 11 | type.lower() 12 | for type in os.environ.get(f"{ENV_VAR_PREFIX}DATA_SOURCE_TYPES", "").split(",") 13 | ] 14 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/tests/test_data_configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | from asyncio import get_event_loop 3 | from typing import Type 4 | from unittest.mock import patch 5 | from uuid import uuid4 6 | 7 | from oaff.app.configuration.data import get_layer, get_layers 8 | from oaff.app.configuration.frontend_configuration import FrontendConfiguration 9 | from oaff.app.data.retrieval.feature_provider import FeatureProvider 10 | from oaff.app.data.retrieval.feature_set_provider import FeatureSetProvider 11 | from oaff.app.data.sources.common.data_source import DataSource 12 | from oaff.app.data.sources.common.layer import Layer 13 | from oaff.app.gateway import cleanup, configure 14 | from oaff.app.responses.response_format import ResponseFormat 15 | from oaff.app.responses.response_type import ResponseType 16 | from oaff.app.settings import ENV_VAR_PREFIX 17 | 18 | 19 | def setup_module(): 20 | os.environ[f"{ENV_VAR_PREFIX}DATA_SOURCE_TYPES"] = "postgresql" 21 | 22 | 23 | def teardown_module(): 24 | del os.environ[f"{ENV_VAR_PREFIX}DATA_SOURCE_TYPES"] 25 | 26 | 27 | def teardown_function(): 28 | get_event_loop().run_until_complete(cleanup()) 29 | 30 | 31 | @patch("oaff.app.data.sources.postgresql.postgresql_manager.PostgresqlManager") 32 | def test_empty(PostgresqlManagerMock): 33 | PostgresqlManagerMock.return_value.get_data_sources.return_value = [] 34 | get_event_loop().run_until_complete( 35 | configure( 36 | FrontendConfiguration( 37 | asset_url_base="", 38 | api_url_base="", 39 | endpoint_format_switcher=_endpoint_format_switcher, 40 | next_page_link_generator=_next_page_link_generator, 41 | prev_page_link_generator=_prev_page_link_generator, 42 | openapi_path_html="/html", 43 | openapi_path_json="/json", 44 | ) 45 | ) 46 | ) 47 | assert len(get_layers()) == 0 48 | 49 | 50 | @patch("oaff.app.data.sources.postgresql.postgresql_manager.PostgresqlManager") 51 | def test_with_layers(PostgresqlManagerMock): 52 | 53 | PostgresqlManagerMock.return_value.get_data_sources.return_value = [ 54 | _TestDataSource1(str(uuid4())), 55 | _TestDataSource2(str(uuid4())), 56 | ] 57 | get_event_loop().run_until_complete( 58 | configure( 59 | FrontendConfiguration( 60 | asset_url_base="", 61 | api_url_base="", 62 | endpoint_format_switcher=_endpoint_format_switcher, 63 | next_page_link_generator=_next_page_link_generator, 64 | prev_page_link_generator=_prev_page_link_generator, 65 | openapi_path_html="/html", 66 | openapi_path_json="/json", 67 | ) 68 | ) 69 | ) 70 | assert len(get_layers()) == 3 71 | for lyrnum in [1, 2, 3]: 72 | assert get_layer(f"layer{lyrnum}").title == f"title{lyrnum}" 73 | assert get_layer(f"layer{lyrnum}").description == f"description{lyrnum}" 74 | assert len(get_layer(f"layer{lyrnum}").bboxes) == 1 75 | assert get_layer(f"layer{lyrnum}").bboxes[0] == [ 76 | int(f"{lyrnum}1"), 77 | int(f"{lyrnum}2"), 78 | int(f"{lyrnum}3"), 79 | int(f"{lyrnum}4"), 80 | ] 81 | 82 | 83 | def _endpoint_format_switcher( 84 | url: str, format: ResponseFormat, type: ResponseType 85 | ) -> str: 86 | return url 87 | 88 | 89 | def _next_page_link_generator(url: str) -> str: 90 | return url 91 | 92 | 93 | def _prev_page_link_generator(url: str) -> str: 94 | return url 95 | 96 | 97 | class _TestDataSource1(DataSource): 98 | async def get_layers(self): 99 | return [ 100 | Layer( 101 | id="layer1", 102 | title="title1", 103 | description="description1", 104 | bboxes=[[11, 12, 13, 14]], 105 | intervals=[[None, None]], 106 | data_source_id=self.id, 107 | geometry_crs_auth_name="EPSG", 108 | geometry_crs_auth_code=3857, 109 | temporal_attributes=[], 110 | ), 111 | Layer( 112 | id="layer2", 113 | title="title2", 114 | description="description2", 115 | bboxes=[[21, 22, 23, 24]], 116 | intervals=[[None, None]], 117 | data_source_id=self.id, 118 | geometry_crs_auth_name="EPSG", 119 | geometry_crs_auth_code=4326, 120 | temporal_attributes=[], 121 | ), 122 | ] 123 | 124 | async def disconnect(self): 125 | pass 126 | 127 | async def get_feature_set_provider(self) -> Type[FeatureSetProvider]: 128 | pass 129 | 130 | async def get_feature_provider(self) -> Type[FeatureProvider]: 131 | pass 132 | 133 | async def initialize(self): 134 | pass 135 | 136 | 137 | class _TestDataSource2(DataSource): 138 | async def get_layers(self): 139 | return [ 140 | Layer( 141 | id="layer3", 142 | title="title3", 143 | description="description3", 144 | bboxes=[[31, 32, 33, 34]], 145 | intervals=[[None, None]], 146 | data_source_id=self.id, 147 | geometry_crs_auth_name="EPSG", 148 | geometry_crs_auth_code=3857, 149 | temporal_attributes=[], 150 | ) 151 | ] 152 | 153 | async def disconnect(self): 154 | pass 155 | 156 | async def get_feature_set_provider(self) -> Type[FeatureSetProvider]: 157 | pass 158 | 159 | async def get_feature_provider(self) -> Type[FeatureProvider]: 160 | pass 161 | 162 | async def initialize(self): 163 | pass 164 | -------------------------------------------------------------------------------- /oaff/app/oaff/app/util.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Final 3 | 4 | from pytz import timezone 5 | 6 | LON_MIN: Final = -180 7 | LON_MAX: Final = 180 8 | LAT_MIN: Final = -90 9 | LAT_MAX: Final = 90 10 | ALT_MIN: Final = -500 11 | ALT_MAX: Final = 9000 12 | 13 | 14 | def is_valid_lat(value: float) -> bool: 15 | return _within_range(value, LAT_MIN, LAT_MAX) 16 | 17 | 18 | def is_valid_lon(value: float) -> bool: 19 | return _within_range(value, LON_MIN, LON_MAX) 20 | 21 | 22 | def is_valid_alt(value: float) -> bool: 23 | return _within_range(value, ALT_MIN, ALT_MAX) 24 | 25 | 26 | def _within_range(value: float, min: int, max: int) -> bool: 27 | return value >= min and value <= max 28 | 29 | 30 | def limit_precision(input: float, precision: int) -> float: 31 | multiplier = pow(10, precision) 32 | return round(input * multiplier) / multiplier 33 | 34 | 35 | def now_as_rfc3339() -> str: 36 | return datetime_as_rfc3339(datetime.now()) 37 | 38 | 39 | def datetime_as_rfc3339(datetime: datetime) -> str: 40 | if datetime is None: 41 | return None 42 | return datetime.astimezone(timezone("UTC")).strftime("%Y-%m-%dT%H:%M:%SZ") 43 | -------------------------------------------------------------------------------- /oaff/app/setup.py: -------------------------------------------------------------------------------- 1 | """Setup oaff-app""" 2 | 3 | from setuptools import find_namespace_packages, setup # type: ignore 4 | 5 | with open("README.md") as f: 6 | long_description = f.read() 7 | 8 | inst_reqs = [ 9 | "uvicorn==0.13.4", 10 | "gunicorn==20.1.0", 11 | "uvloop==0.15.2", 12 | "httptools==0.2.0", 13 | "pygeofilter==0.0.2", 14 | "psycopg2==2.8.6", 15 | "asyncpg==0.23.0", 16 | ] 17 | extra_reqs = { 18 | "test": ["pytest"], 19 | } 20 | 21 | setup( 22 | name="oaff.app", 23 | version="0.0.1", 24 | description=u"Business logic for oaff", 25 | long_description=long_description, 26 | long_description_content_type="text/markdown", 27 | python_requires=">=3.8", 28 | classifiers=[ 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | ], 32 | packages=find_namespace_packages(exclude=["tests*"]), 33 | include_package_data=False, 34 | zip_safe=True, 35 | install_requires=inst_reqs, 36 | extras_require=extra_reqs, 37 | ) 38 | -------------------------------------------------------------------------------- /oaff/fastapi/api/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | 4 | from oaff.fastapi.api import settings 5 | from oaff.fastapi.api.middleware.request_context_log_middleware import get_request_id 6 | 7 | 8 | class LogContextFilter(logging.Filter): 9 | def filter(self, record): 10 | record.request_id = get_request_id() 11 | return True 12 | 13 | 14 | def configure_logging() -> None: 15 | logging_levels = {value: key for key, value in logging._levelToName.items()} 16 | logging_level_name = settings.LOG_LEVEL 17 | if logging_level_name not in logging_levels: 18 | raise ValueError(f"{logging_level_name} is not a valid log level") 19 | logging.config.dictConfig( 20 | { 21 | "version": 1, 22 | "disable_existing_loggers": False, 23 | "filters": { 24 | "request_context": { 25 | "()": LogContextFilter, 26 | } 27 | }, 28 | "formatters": { 29 | "default": { 30 | "class": "logging.Formatter", 31 | "datefmt": "%H:%M:%S%z", 32 | "format": "".join( 33 | [ 34 | "%(asctime)s %(filename)s:%(lineno)d %(levelname)s", 35 | " - req:%(request_id)s %(message)s", 36 | ] 37 | ), 38 | }, 39 | }, 40 | "handlers": { 41 | "console": { 42 | "level": logging_levels[logging_level_name], 43 | "class": "logging.StreamHandler", 44 | "formatter": "default", 45 | "stream": "ext://sys.stdout", 46 | "filters": ["request_context"], 47 | }, 48 | }, 49 | "root": { 50 | "level": logging_levels[logging_level_name], 51 | "handlers": ["console"], 52 | }, 53 | } 54 | ) 55 | 56 | 57 | configure_logging() 58 | -------------------------------------------------------------------------------- /oaff/fastapi/api/delegator.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, Type 2 | 3 | from fastapi import status 4 | from fastapi.exceptions import HTTPException 5 | from fastapi.responses import Response 6 | 7 | from oaff.app.gateway import handle as gateway_handler 8 | from oaff.app.request_handlers.common.request_handler import RequestHandler 9 | from oaff.app.requests.common.request_type import RequestType 10 | from oaff.app.responses.error_response import ErrorResponse 11 | from oaff.app.responses.response import Response as AppResponse 12 | 13 | 14 | def get_default_handler() -> Callable[[Type[RequestHandler]], Awaitable[AppResponse]]: 15 | return gateway_handler 16 | 17 | 18 | # expect handler as argument so that it is overridable in testing 19 | async def delegate( 20 | request: Type[RequestType], 21 | handler: Callable[ 22 | [Type[RequestHandler]], Awaitable[Type[AppResponse]] 23 | ] = get_default_handler(), 24 | ) -> Response: 25 | try: 26 | app_response = await handler(request) 27 | except Exception as e: 28 | raise HTTPException( 29 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) 30 | ) 31 | if isinstance(app_response, ErrorResponse): 32 | raise HTTPException( 33 | status_code=app_response.status_code, detail=app_response.detail 34 | ) 35 | else: 36 | return Response( 37 | content=app_response.encoded_response, 38 | status_code=app_response.status_code, 39 | headers=app_response.additional_headers, 40 | media_type=app_response.mime_type, 41 | ) 42 | -------------------------------------------------------------------------------- /oaff/fastapi/api/main.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Final 3 | 4 | from fastapi import FastAPI 5 | from fastapi.exceptions import RequestValidationError 6 | from fastapi.middleware.cors import CORSMiddleware 7 | from fastapi.responses import PlainTextResponse 8 | from fastapi.staticfiles import StaticFiles 9 | 10 | from oaff.app.configuration.frontend_configuration import FrontendConfiguration 11 | from oaff.app.configuration.frontend_interface import get_assets_path 12 | from oaff.app.gateway import cleanup, configure 13 | from oaff.fastapi.api import settings 14 | from oaff.fastapi.api.middleware.request_context_log_middleware import ( 15 | RequestContextLogMiddleware, 16 | ) 17 | from oaff.fastapi.api.openapi.openapi import get_openapi_handler 18 | from oaff.fastapi.api.routes import router 19 | from oaff.fastapi.api.util import alternate_format_for_url, next_page, prev_page 20 | 21 | LOGGER: Final = getLogger(__file__) 22 | ASSETS_PATH: Final = f"{settings.ROOT_PATH}/assets" 23 | 24 | 25 | app = FastAPI( 26 | title=settings.NAME, 27 | docs_url=settings.SWAGGER_PATH, 28 | openapi_url=settings.OPENAPI_PATH, 29 | redoc_url=settings.REDOC_PATH, 30 | ) 31 | # OGC requires a specific response header 32 | # Duplicate OpenAPI docs with this content-type 33 | app.add_route( 34 | settings.OPENAPI_OGC_PATH, get_openapi_handler(app), include_in_schema=False 35 | ) 36 | 37 | app.include_router(router) 38 | 39 | # CORS configuration, select appropriate CORS regex from settings 40 | app.add_middleware( 41 | CORSMiddleware, 42 | allow_origin_regex=settings.CORS_ANY_LOCALHOST_REGEX, 43 | allow_credentials=True, 44 | allow_methods=["*"], 45 | allow_headers=["*"], 46 | ) 47 | # Log messages with unique request IDs 48 | app.add_middleware(RequestContextLogMiddleware) 49 | 50 | # expose assets provided by business logic package 51 | app.mount(ASSETS_PATH, StaticFiles(directory=get_assets_path()), name="assets") 52 | 53 | 54 | # override FastAPI's default 422 bad request error to match OGC spec 55 | @app.exception_handler(RequestValidationError) 56 | async def validation_exception_handler(_, exc): 57 | return PlainTextResponse(str(exc), status_code=400) 58 | 59 | 60 | @app.on_event("startup") 61 | async def startup(): 62 | await configure( 63 | FrontendConfiguration( 64 | asset_url_base=ASSETS_PATH, 65 | api_url_base=settings.ROOT_PATH, 66 | endpoint_format_switcher=alternate_format_for_url, 67 | next_page_link_generator=next_page, 68 | prev_page_link_generator=prev_page, 69 | openapi_path_html=settings.SWAGGER_PATH, 70 | openapi_path_json=settings.OPENAPI_OGC_PATH, 71 | ) 72 | ) 73 | 74 | 75 | @app.on_event("shutdown") 76 | async def shutdown(): 77 | await cleanup() 78 | 79 | 80 | # Development / debug support, not executed when running in container 81 | # Start a local server on port 8008 by default, 82 | # or whichever port was provided by the caller, when script / module executed directly 83 | if __name__ == "__main__": 84 | import sys 85 | 86 | import uvicorn # type: ignore 87 | 88 | port = 8008 if len(sys.argv) == 1 else int(sys.argv[1]) 89 | LOGGER.info("Available on port %d", port) 90 | LOGGER.debug("Debug logging enabled if visible") 91 | uvicorn.run(app, host="0.0.0.0", port=port) 92 | -------------------------------------------------------------------------------- /oaff/fastapi/api/middleware/request_context_log_middleware.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | from uuid import uuid4 3 | 4 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 5 | from starlette.requests import Request 6 | 7 | REQUEST_ID_CTX_KEY = "request_id" 8 | 9 | _request_id_ctx_var: ContextVar[str] = ContextVar( 10 | REQUEST_ID_CTX_KEY, default=None # type: ignore 11 | ) 12 | 13 | 14 | def get_request_id() -> str: 15 | return _request_id_ctx_var.get() 16 | 17 | 18 | class RequestContextLogMiddleware(BaseHTTPMiddleware): 19 | async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): 20 | request_id = _request_id_ctx_var.set(str(uuid4())) 21 | 22 | response = await call_next(request) 23 | response.headers["X-Request-ID"] = get_request_id() 24 | 25 | _request_id_ctx_var.reset(request_id) 26 | 27 | return response 28 | -------------------------------------------------------------------------------- /oaff/fastapi/api/openapi/openapi.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from fastapi.applications import FastAPI 4 | from fastapi.requests import Request 5 | 6 | from oaff.fastapi.api.settings import ROOT_PATH 7 | from oaff.fastapi.api.openapi.vnd_response import VndResponse 8 | 9 | 10 | def get_openapi_handler(app: FastAPI) -> Callable[[Request], VndResponse]: 11 | def handler(_: Request): 12 | # OpenAPI spec must be modified because FastAPI doesn't support 13 | # encoding style: https://github.com/tiangolo/fastapi/issues/283 14 | definition = app.openapi().copy() 15 | for path in definition["paths"].values(): 16 | if "get" in path: 17 | if "parameters" in path["get"]: 18 | for parameter in path["get"]["parameters"]: 19 | if "style" not in parameter: 20 | parameter["style"] = "form" 21 | 22 | # This API actually expects BBOX as a string but OGC spec requires 23 | # array with form-style, which is not possible in FastAPI. 24 | # Continue to accept string but pretend it's form-style array. 25 | # End result is the same. String BBOX param is validated by regex. 26 | collection_items_bbox_param = list( 27 | filter( 28 | lambda parameter: parameter["name"] == "bbox", 29 | definition["paths"][f"{ROOT_PATH}/collections/{{collection_id}}/items"][ 30 | "get" 31 | ]["parameters"], 32 | ) 33 | )[0] 34 | collection_items_bbox_param["schema"] = { 35 | "type": "array", 36 | "minItems": 4, 37 | "maxItems": 6, 38 | "items": { 39 | "type": "number", 40 | }, 41 | } 42 | 43 | return VndResponse(definition) 44 | 45 | return handler 46 | -------------------------------------------------------------------------------- /oaff/fastapi/api/openapi/vnd_response.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import JSONResponse 2 | 3 | from oaff.app.settings import OPENAPI_OGC_TYPE 4 | 5 | 6 | class VndResponse(JSONResponse): 7 | media_type = OPENAPI_OGC_TYPE 8 | -------------------------------------------------------------------------------- /oaff/fastapi/api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from starlette.responses import RedirectResponse 3 | 4 | from oaff.fastapi.api.routes.collections import ( 5 | PATH as path_collections, 6 | ROUTER as router_collections, 7 | ) 8 | from oaff.fastapi.api.routes.conformance import ( 9 | PATH as path_conformance, 10 | ROUTER as router_conformance, 11 | ) 12 | from oaff.fastapi.api.routes.control import PATH as path_control, ROUTER as router_control 13 | from oaff.fastapi.api.routes.landing_page import ( 14 | PATH as path_landing_page, 15 | ROUTER as router_landing_page, 16 | ) 17 | from oaff.fastapi.api.settings import ROOT_PATH 18 | 19 | router = APIRouter() 20 | router.include_router(router_landing_page, prefix=path_landing_page) 21 | router.include_router(router_collections, prefix=path_collections) 22 | router.include_router(router_control, prefix=path_control) 23 | router.include_router(router_conformance, prefix=path_conformance) 24 | 25 | if len(ROOT_PATH) > 0 and ROOT_PATH != "/": 26 | 27 | @router.get("/") 28 | async def redirect(): 29 | return RedirectResponse(url=ROOT_PATH) 30 | -------------------------------------------------------------------------------- /oaff/fastapi/api/routes/common/common_parameters.py: -------------------------------------------------------------------------------- 1 | from re import compile, search, sub 2 | from typing import Final, List, Optional 3 | 4 | from fastapi import Header, Query 5 | from fastapi.requests import Request 6 | from pydantic import BaseModel 7 | 8 | from oaff.app.i18n.locales import Locales 9 | from oaff.app.i18n.translations import DEFAULT_LOCALE 10 | from oaff.app.responses.response_format import ResponseFormat 11 | from oaff.app.responses.response_type import ResponseType 12 | 13 | RESPONSE_FORMAT_BY_MIME: Final = { 14 | mime_type: format_name 15 | for format_name, mime_types in { 16 | item.name: set(item.value.values()) for item in ResponseFormat 17 | }.items() 18 | for mime_type in mime_types 19 | } 20 | RESPONSE_FORMAT_BY_NAME: Final = {format.name: format for format in ResponseFormat} 21 | COMMON_QUERY_PARAMS: Final = ["format"] 22 | 23 | 24 | class CommonParameters(BaseModel): 25 | format: ResponseFormat 26 | locale: Locales 27 | root: str 28 | 29 | @classmethod 30 | async def populate( 31 | cls, 32 | request: Request, 33 | format_qs: Optional[str] = Query( 34 | alias="format", 35 | default=None, 36 | ), 37 | format_header: Optional[str] = Header( 38 | alias="Accept", 39 | default=ResponseFormat.json[ResponseType.METADATA], 40 | ), 41 | language_header: Optional[str] = Header( 42 | alias="Accept-Language", 43 | default=None, 44 | ), 45 | ): 46 | locale = None 47 | if language_header is not None: 48 | for option in cls._header_options_by_preference(language_header): 49 | if option in Locales._value2member_map_: 50 | locale = Locales._value2member_map_[option] 51 | break 52 | 53 | format = None 54 | if format_header is not None: 55 | for option in cls._header_options_by_preference(format_header): 56 | if option in RESPONSE_FORMAT_BY_MIME: 57 | format = ResponseFormat[RESPONSE_FORMAT_BY_MIME[option]] 58 | 59 | if format_qs in RESPONSE_FORMAT_BY_NAME: 60 | format = RESPONSE_FORMAT_BY_NAME[format_qs] 61 | 62 | return cls( 63 | format=format or ResponseFormat.json, 64 | locale=locale or DEFAULT_LOCALE, 65 | root=sub("/$", "", str(request.base_url)), 66 | ) 67 | 68 | @classmethod 69 | def _header_options_by_preference(cls, header_value: str) -> List[str]: 70 | options = list( 71 | filter( 72 | lambda option: len(option) > 0, 73 | [option.strip() for option in header_value.split(",")], 74 | ) 75 | ) 76 | weight_pattern = compile(r";q=") 77 | unweighted = [ 78 | option for option in options if search(weight_pattern, option) is None 79 | ] 80 | weighted = [ 81 | option for option in options if search(weight_pattern, option) is not None 82 | ] 83 | options_with_weight = { 84 | option_and_weight[0].strip(): option_and_weight[1].strip() 85 | for option_and_weight in [option.split(";q=") for option in weighted] 86 | } 87 | return unweighted + sorted(options_with_weight, key=options_with_weight.get)[::-1] 88 | -------------------------------------------------------------------------------- /oaff/fastapi/api/routes/common/parameter_control.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from fastapi import status 4 | from fastapi.exceptions import HTTPException 5 | from fastapi.requests import Request 6 | 7 | from oaff.fastapi.api.routes.common.common_parameters import COMMON_QUERY_PARAMS 8 | 9 | 10 | def strict(request: Request, permitted: Optional[List[str]] = None) -> None: 11 | permitted = list() if permitted is None else permitted 12 | excessive = set(request.query_params.keys()).difference( 13 | set(permitted + COMMON_QUERY_PARAMS) 14 | ) 15 | if len(excessive) > 0: 16 | raise HTTPException( 17 | status_code=status.HTTP_400_BAD_REQUEST, 18 | detail=f"Unknown query parameters: {', '.join(excessive)}", 19 | ) 20 | -------------------------------------------------------------------------------- /oaff/fastapi/api/routes/conformance.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Final 3 | 4 | from fastapi import APIRouter, Depends 5 | from fastapi.requests import Request 6 | 7 | from oaff.app.requests.conformance import Conformance as ConformanceRequest 8 | from oaff.app.responses.response_type import ResponseType 9 | from oaff.fastapi.api import settings 10 | from oaff.fastapi.api.delegator import delegate, get_default_handler 11 | from oaff.fastapi.api.routes.common.common_parameters import CommonParameters 12 | from oaff.fastapi.api.routes.common.parameter_control import strict as enforce_strict 13 | 14 | PATH: Final = f"{settings.ROOT_PATH}/conformance" 15 | ROUTER: Final = APIRouter() 16 | LOGGER: Final = getLogger(__file__) 17 | 18 | 19 | @ROUTER.get("") 20 | async def root( 21 | request: Request, 22 | common_parameters: CommonParameters = Depends(CommonParameters.populate), 23 | handler=Depends(get_default_handler), 24 | ): 25 | enforce_strict(request) 26 | return await delegate( 27 | ConformanceRequest( 28 | type=ResponseType.METADATA, 29 | format=common_parameters.format, 30 | url=str(request.url), 31 | locale=common_parameters.locale, 32 | root=common_parameters.root, 33 | ), 34 | handler, 35 | ) 36 | -------------------------------------------------------------------------------- /oaff/fastapi/api/routes/control.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from logging import getLogger 3 | from typing import Final 4 | 5 | from fastapi import APIRouter, status 6 | from fastapi.exceptions import HTTPException 7 | from fastapi.requests import Request 8 | 9 | from oaff.app.gateway import discover 10 | from oaff.fastapi.api import settings 11 | 12 | PATH: Final = f"{settings.ROOT_PATH}/control" 13 | ROUTER: Final = APIRouter() 14 | LOGGER: Final = getLogger(__file__) 15 | 16 | 17 | @ROUTER.post("/reconfigure", include_in_schema=False) 18 | async def reconfigure( 19 | request: Request, 20 | ): 21 | if _permit(request): 22 | await discover() 23 | 24 | 25 | # exercise basic control over who is allowed to update configuration 26 | # may expand to more comprehensive authorisation logic in future 27 | def _permit(request: Request) -> bool: 28 | if request.client.host in [ 29 | *settings.PERMITTED_CONTROL_IPS, 30 | socket.gethostname(), 31 | "testclient", 32 | ]: 33 | return True 34 | else: 35 | LOGGER.warning(f"Attempt to access {PATH} by {request.client.host}") 36 | raise HTTPException( 37 | status_code=status.HTTP_404_NOT_FOUND, 38 | headers={ 39 | "x-client-host": request.client.host, 40 | "x-server-host": socket.gethostname(), 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /oaff/fastapi/api/routes/landing_page.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Final 3 | 4 | from fastapi import APIRouter, Depends 5 | from fastapi.requests import Request 6 | 7 | from oaff.app.requests.landing_page import LandingPage as LandingPageRequest 8 | from oaff.app.responses.response_type import ResponseType 9 | from oaff.fastapi.api import settings 10 | from oaff.fastapi.api.delegator import delegate, get_default_handler 11 | from oaff.fastapi.api.routes.common.common_parameters import CommonParameters 12 | from oaff.fastapi.api.routes.common.parameter_control import strict as enforce_strict 13 | 14 | PATH: Final = f"{settings.ROOT_PATH}" 15 | ROUTER: Final = APIRouter() 16 | LOGGER: Final = getLogger(__file__) 17 | 18 | 19 | @ROUTER.get("/") 20 | async def root( 21 | request: Request, 22 | common_parameters: CommonParameters = Depends(CommonParameters.populate), 23 | handler=Depends(get_default_handler), 24 | ): 25 | enforce_strict(request) 26 | return await delegate( 27 | LandingPageRequest( 28 | type=ResponseType.METADATA, 29 | format=common_parameters.format, 30 | url=str(request.url), 31 | locale=common_parameters.locale, 32 | root=common_parameters.root, 33 | ), 34 | handler, 35 | ) 36 | -------------------------------------------------------------------------------- /oaff/fastapi/api/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Final 3 | 4 | ENV_VAR_PREFIX: Final = os.environ.get("API_ENV_VAR_PREFIX", "API_") 5 | ROOT_PATH: Final = os.environ.get(f"{ENV_VAR_PREFIX}ROOT_PATH", "") 6 | LOG_LEVEL: Final = os.environ.get(f"{ENV_VAR_PREFIX}LOG_LEVEL", "INFO").upper() 7 | NAME: Final = os.environ.get(f"{ENV_VAR_PREFIX}NAME", "API") 8 | SWAGGER_PATH: Final = os.environ.get(f"{ENV_VAR_PREFIX}SWAGGER_PATH", f"{ROOT_PATH}/docs") 9 | OPENAPI_PATH: Final = os.environ.get( 10 | f"{ENV_VAR_PREFIX}OPENAPI_PATH", f"{ROOT_PATH}/openapi.json" 11 | ) 12 | OPENAPI_OGC_PATH: Final = os.environ.get( 13 | f"{ENV_VAR_PREFIX}OPENAPI_OGC_PATH", f"{ROOT_PATH}/ogc/openapi.json" 14 | ) 15 | REDOC_PATH: Final = os.environ.get(f"{ENV_VAR_PREFIX}REDOC_PATH", f"{ROOT_PATH}/redoc") 16 | PERMITTED_CONTROL_IPS: Final = list( 17 | filter( 18 | lambda ip: len(ip) > 0, 19 | os.environ.get(f"{ENV_VAR_PREFIX}PERMITTED_CONTROL_IPS", "127.0.0.1").split(","), 20 | ) 21 | ) 22 | 23 | CORS_ANY_DOMAIN_REGEX: Final = ".*" 24 | CORS_ANY_LOCALHOST_REGEX: Final = r"http(s)?://localhost(:\d{1,5})?/.*" 25 | 26 | ITEMS_LIMIT_DEFAULT: Final = 10 27 | ITEMS_LIMIT_MIN: Final = 1 28 | ITEMS_LIMIT_MAX: Final = 10000 29 | ITEMS_OFFSET_DEFAULT: Final = 0 30 | ITEMS_OFFSET_MIN: Final = 0 31 | ITEMS_BBOX_DEFAULT: Final = None 32 | ITEMS_BBOX_CRS_DEFAULT: Final = "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 33 | ITEMS_DATETIME_DEFAULT: Final = None 34 | -------------------------------------------------------------------------------- /oaff/fastapi/api/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from oaff.app.responses.response_format import ResponseFormat 4 | from oaff.fastapi.api import settings 5 | 6 | 7 | def alternate_format_for_url(url: str, format: ResponseFormat) -> str: 8 | format_pattern = re.compile( 9 | rf"(\?|&)format=({'|'.join([format.name for format in ResponseFormat])})" # noqa: E501 10 | ) 11 | query_string_pattern = re.compile(r"(\?|&).+=.*") 12 | if re.search(format_pattern, url) is not None: 13 | return re.sub(format_pattern, rf"\1format={format.name}", url) 14 | else: 15 | connector = "?" 16 | if re.search(query_string_pattern, url): 17 | connector = "&" 18 | return f"{url}{connector}format={format.name}" 19 | 20 | 21 | def next_page(url: str) -> str: 22 | return _change_page(url, True) 23 | 24 | 25 | def prev_page(url: str) -> str: 26 | return _change_page(url, False) 27 | 28 | 29 | def _change_page(url: str, forward: bool) -> str: 30 | url_parts = url.split("?") 31 | parameters = { 32 | key: value 33 | for key, value in [ 34 | part.split("=") 35 | for part in [ 36 | part 37 | for part in (url_parts[1] if len(url_parts) == 2 else "").split("&") 38 | if len(part) > 0 39 | ] 40 | ] 41 | } 42 | limit = ( 43 | int(parameters["limit"]) 44 | if "limit" in parameters 45 | else settings.ITEMS_LIMIT_DEFAULT 46 | ) 47 | offset = ( 48 | int(parameters["offset"]) 49 | if "offset" in parameters 50 | else settings.ITEMS_OFFSET_DEFAULT 51 | ) 52 | return "{0}?{1}".format( 53 | url_parts[0], 54 | "&".join( 55 | [ 56 | f"{key}={value}" 57 | for key, value in { 58 | **parameters, 59 | **{ 60 | "offset": str(max(offset + limit * (1 if forward else -1), 0)), 61 | "limit": str(limit), 62 | }, 63 | }.items() 64 | ] 65 | ), 66 | ) 67 | -------------------------------------------------------------------------------- /oaff/fastapi/gunicorn/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | cpu_limit = os.environ.get("API_CPU_LIMIT") 4 | if cpu_limit is None: 5 | num_avail_cpus = len(os.sched_getaffinity(0)) # type: ignore[attr-defined] 6 | else: 7 | try: 8 | num_avail_cpus = int(cpu_limit) 9 | except ValueError: 10 | num_avail_cpus = 1 11 | 12 | loglevel = os.environ.get("API_LOG_LEVEL", "INFO") 13 | worker_class = "uvicorn.workers.UvicornWorker" 14 | workers = num_avail_cpus 15 | bind = "0.0.0.0:80" 16 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/common.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Type 3 | 4 | from oaff.app.requests.common.request_type import RequestType 5 | from oaff.app.responses.data_response import DataResponse 6 | from oaff.app.responses.response_format import ResponseFormat 7 | from oaff.app.responses.response_type import ResponseType 8 | 9 | 10 | def get_mock_handler_provider(handler_calls: List[Type[RequestType]]): 11 | def mock_handler_provider(): 12 | async def mock_handler(request): 13 | handler_calls.append(request) 14 | return DataResponse( 15 | mime_type=ResponseFormat.json[ResponseType.METADATA], 16 | encoded_response="", 17 | ) 18 | 19 | return mock_handler 20 | 21 | return mock_handler_provider 22 | 23 | 24 | def get_valid_datetime_parameter() -> str: 25 | return datetime.utcnow().isoformat("T") + "Z" 26 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/common_delegation.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import List, Type 3 | from uuid import uuid4 4 | 5 | from oaff.app.i18n.locales import Locales 6 | from oaff.app.requests.common.request_type import RequestType 7 | from oaff.app.responses.response_format import ResponseFormat 8 | from oaff.fastapi.api.delegator import get_default_handler 9 | from oaff.fastapi.api.main import app 10 | from oaff.fastapi.tests.common import get_mock_handler_provider 11 | 12 | 13 | def make_params_common(): 14 | return (list(), str(uuid4()), str(uuid4())) 15 | 16 | 17 | def setup_module(handler_calls): 18 | app.dependency_overrides[get_default_handler] = get_mock_handler_provider( 19 | handler_calls 20 | ) 21 | 22 | 23 | def setup_function(handler_calls): 24 | handler_calls.clear() 25 | 26 | 27 | def teardown_module(): 28 | app.dependency_overrides = {} 29 | 30 | 31 | def test_basic_defaults( 32 | test_app, 33 | endpoint_path: str, 34 | type: Type[RequestType], 35 | handler_calls: List[Type[RequestType]], 36 | ): 37 | request(test_app, endpoint_path) 38 | assert handler_calls[0].__class__ == type 39 | assert handler_calls[0].format == ResponseFormat.json 40 | assert handler_calls[0].locale == Locales.en_US 41 | assert str(handler_calls[0].url).endswith(endpoint_path) 42 | 43 | 44 | def test_format_html( 45 | test_app, 46 | endpoint_path: str, 47 | handler_calls: List[Type[RequestType]], 48 | ): 49 | request(test_app, endpoint_path, url_suffix="?format=html") 50 | assert len(handler_calls) == 1 51 | assert handler_calls[0].format == ResponseFormat.html 52 | 53 | 54 | def test_format_json( 55 | test_app, 56 | endpoint_path: str, 57 | handler_calls: List[Type[RequestType]], 58 | ): 59 | request(test_app, endpoint_path, url_suffix="?format=json") 60 | assert len(handler_calls) == 1 61 | assert handler_calls[0].format == ResponseFormat.json 62 | 63 | 64 | def test_language_en_US( 65 | test_app, 66 | endpoint_path: str, 67 | handler_calls: List[Type[RequestType]], 68 | ): 69 | request(test_app, endpoint_path) 70 | assert len(handler_calls) == 1 71 | assert handler_calls[0].locale == Locales.en_US 72 | 73 | 74 | def test_language_unknown_with_known_fallback( 75 | test_app, 76 | endpoint_path: str, 77 | handler_calls: List[Type[RequestType]], 78 | ): 79 | request( 80 | test_app, 81 | endpoint_path, 82 | headers={"Accept-Language": "unknown_language, en_US:0.9"}, 83 | ) 84 | assert len(handler_calls) == 1 85 | assert handler_calls[0].locale == Locales.en_US 86 | 87 | 88 | def test_language_unknown_without_known_fallback( 89 | test_app, 90 | endpoint_path: str, 91 | handler_calls: List[Type[RequestType]], 92 | ): 93 | request( 94 | test_app, 95 | endpoint_path, 96 | headers={"Accept-Language": "unknown_language, another_unknown_language:0.9"}, 97 | ) 98 | assert len(handler_calls) == 1 99 | assert handler_calls[0].locale == Locales.en_US 100 | 101 | 102 | def test_accept_ordering_multiple_ordered( 103 | test_app, 104 | endpoint_path: str, 105 | handler_calls: List[Type[RequestType]], 106 | ): 107 | request( 108 | test_app, 109 | endpoint_path, 110 | headers={"Accept": "text/html, application/json"}, 111 | ) 112 | assert len(handler_calls) == 1 113 | assert handler_calls[0].format == ResponseFormat.html 114 | 115 | 116 | def test_accept_ordering_weighted( 117 | test_app, 118 | endpoint_path: str, 119 | handler_calls: List[Type[RequestType]], 120 | ): 121 | request( 122 | test_app, 123 | endpoint_path, 124 | headers={"Accept": "text/html;q=0.7, application/json;q=0.9"}, 125 | ) 126 | assert len(handler_calls) == 1 127 | assert handler_calls[0].format == ResponseFormat.json 128 | 129 | 130 | def test_format_header_html( 131 | test_app, 132 | endpoint_path: str, 133 | handler_calls: List[Type[RequestType]], 134 | ): 135 | request(test_app, endpoint_path, url_suffix="", headers={"Accept": "text/html"}) 136 | assert len(handler_calls) == 1 137 | assert handler_calls[0].format == ResponseFormat.html 138 | 139 | 140 | def test_format_header_json( 141 | test_app, 142 | endpoint_path: str, 143 | handler_calls: List[Type[RequestType]], 144 | ): 145 | request( 146 | test_app, endpoint_path, url_suffix="", headers={"Accept": "application/json"} 147 | ) 148 | assert len(handler_calls) == 1 149 | assert handler_calls[0].format == ResponseFormat.json 150 | 151 | 152 | def test_format_header_override( 153 | test_app, 154 | endpoint_path: str, 155 | handler_calls: List[Type[RequestType]], 156 | ): 157 | request( 158 | test_app, 159 | endpoint_path, 160 | url_suffix="?format=json", 161 | headers={"Accept": "text/html"}, 162 | ) 163 | assert len(handler_calls) == 1 164 | assert handler_calls[0].format == ResponseFormat.json 165 | 166 | 167 | def test_unknown_param(test_app, endpoint_path: str): 168 | assert ( 169 | request(test_app, endpoint_path, f"?{str(uuid4())}={str(uuid4())}").status_code 170 | == HTTPStatus.BAD_REQUEST 171 | ) 172 | 173 | 174 | def request(test_app, endpoint_path: str, url_suffix="", headers=dict()): 175 | return test_app.get(f"{endpoint_path}{url_suffix}", headers=headers) 176 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from oaff.fastapi.api.main import app 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def test_app(): 9 | with TestClient(app) as client: 10 | yield client 11 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/test_collection_delegation.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from oaff.app.requests.collection import Collection 4 | from oaff.fastapi.api.routes.collections import PATH as ROOT_PATH 5 | from oaff.fastapi.tests import common_delegation as common 6 | 7 | handler_calls, collection_id, _ = common.make_params_common() 8 | endpoint_path: Final = f"{ROOT_PATH}/{collection_id}" 9 | 10 | 11 | def setup_module(): 12 | common.setup_module(handler_calls) 13 | 14 | 15 | def setup_function(): 16 | common.setup_function(handler_calls) 17 | 18 | 19 | def teardown_module(): 20 | common.teardown_module() 21 | 22 | 23 | def test_basic_defaults(test_app): 24 | common.test_basic_defaults( 25 | test_app, 26 | endpoint_path, 27 | Collection, 28 | handler_calls, 29 | ) 30 | assert handler_calls[0].collection_id == collection_id 31 | 32 | 33 | def test_format_html(test_app): 34 | common.test_format_html(test_app, endpoint_path, handler_calls) 35 | 36 | 37 | def test_format_json(test_app): 38 | common.test_format_json(test_app, endpoint_path, handler_calls) 39 | 40 | 41 | def test_language_en_US(test_app): 42 | common.test_language_en_US(test_app, endpoint_path, handler_calls) 43 | 44 | 45 | def test_language_unknown_with_known_fallback(test_app): 46 | common.test_language_unknown_with_known_fallback( 47 | test_app, 48 | endpoint_path, 49 | handler_calls, 50 | ) 51 | 52 | 53 | def test_language_unknown_without_known_fallback(test_app): 54 | common.test_language_unknown_without_known_fallback( 55 | test_app, 56 | endpoint_path, 57 | handler_calls, 58 | ) 59 | 60 | 61 | def test_format_header_html(test_app): 62 | common.test_format_header_html(test_app, endpoint_path, handler_calls) 63 | 64 | 65 | def test_format_header_json(test_app): 66 | common.test_format_header_json(test_app, endpoint_path, handler_calls) 67 | 68 | 69 | def test_format_header_override(test_app): 70 | common.test_format_header_override(test_app, endpoint_path, handler_calls) 71 | 72 | 73 | def test_unknown_param(test_app): 74 | common.test_unknown_param(test_app, endpoint_path) 75 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/test_collection_items_delegation.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from oaff.app.requests.collection_items import CollectionItems 4 | from oaff.fastapi.api.routes.collections import PATH as ROOT_PATH 5 | from oaff.fastapi.api.settings import ITEMS_LIMIT_DEFAULT, ITEMS_OFFSET_DEFAULT 6 | from oaff.fastapi.tests import common_delegation as common 7 | 8 | handler_calls, collection_id, _ = common.make_params_common() 9 | endpoint_path: Final = f"{ROOT_PATH}/{collection_id}/items" 10 | 11 | 12 | def setup_module(): 13 | common.setup_module(handler_calls) 14 | 15 | 16 | def setup_function(): 17 | common.setup_function(handler_calls) 18 | 19 | 20 | def teardown_module(): 21 | common.teardown_module() 22 | 23 | 24 | def test_basic_defaults(test_app): 25 | common.test_basic_defaults( 26 | test_app, 27 | endpoint_path, 28 | CollectionItems, 29 | handler_calls, 30 | ) 31 | assert handler_calls[0].collection_id == collection_id 32 | assert handler_calls[0].limit == ITEMS_LIMIT_DEFAULT 33 | assert handler_calls[0].offset == ITEMS_OFFSET_DEFAULT 34 | 35 | 36 | def test_format_html(test_app): 37 | common.test_format_html(test_app, endpoint_path, handler_calls) 38 | 39 | 40 | def test_format_json(test_app): 41 | common.test_format_json(test_app, endpoint_path, handler_calls) 42 | 43 | 44 | def test_language_en_US(test_app): 45 | common.test_language_en_US(test_app, endpoint_path, handler_calls) 46 | 47 | 48 | def test_language_unknown_with_known_fallback(test_app): 49 | common.test_language_unknown_with_known_fallback( 50 | test_app, 51 | endpoint_path, 52 | handler_calls, 53 | ) 54 | 55 | 56 | def test_language_unknown_without_known_fallback(test_app): 57 | common.test_language_unknown_without_known_fallback( 58 | test_app, 59 | endpoint_path, 60 | handler_calls, 61 | ) 62 | 63 | 64 | def test_non_default_limit(test_app): 65 | limit: Final = 123 66 | common.request(test_app, endpoint_path, f"?limit={limit}") 67 | assert handler_calls[0].limit == limit 68 | 69 | 70 | def test_non_default_offset(test_app): 71 | offset: Final = 321 72 | common.request(test_app, endpoint_path, f"?offset={offset}") 73 | assert handler_calls[0].offset == offset 74 | 75 | 76 | def test_non_default_limit_offset(test_app): 77 | limit: Final = 123 78 | offset: Final = 321 79 | common.request(test_app, endpoint_path, f"?limit={limit}&offset={offset}") 80 | assert handler_calls[0].limit == limit 81 | assert handler_calls[0].offset == offset 82 | 83 | 84 | def test_format_header_html(test_app): 85 | common.test_format_header_html(test_app, endpoint_path, handler_calls) 86 | 87 | 88 | def test_format_header_json(test_app): 89 | common.test_format_header_json(test_app, endpoint_path, handler_calls) 90 | 91 | 92 | def test_format_header_override(test_app): 93 | common.test_format_header_override(test_app, endpoint_path, handler_calls) 94 | 95 | 96 | def test_unknown_param(test_app): 97 | common.test_unknown_param(test_app, endpoint_path) 98 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/test_collections_list_delegation.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from oaff.app.requests.collections_list import CollectionsList 4 | from oaff.fastapi.api.routes.collections import PATH as ROOT_PATH 5 | from oaff.fastapi.tests import common_delegation as common 6 | 7 | handler_calls, collection_id, _ = common.make_params_common() 8 | endpoint_path: Final = f"{ROOT_PATH}" 9 | 10 | 11 | def setup_module(): 12 | common.setup_module(handler_calls) 13 | 14 | 15 | def setup_function(): 16 | common.setup_function(handler_calls) 17 | 18 | 19 | def teardown_module(): 20 | common.teardown_module() 21 | 22 | 23 | def test_basic_defaults(test_app): 24 | common.test_basic_defaults( 25 | test_app, 26 | endpoint_path, 27 | CollectionsList, 28 | handler_calls, 29 | ) 30 | 31 | 32 | def test_format_html(test_app): 33 | common.test_format_html(test_app, endpoint_path, handler_calls) 34 | 35 | 36 | def test_format_json(test_app): 37 | common.test_format_json(test_app, endpoint_path, handler_calls) 38 | 39 | 40 | def test_language_en_US(test_app): 41 | common.test_language_en_US(test_app, endpoint_path, handler_calls) 42 | 43 | 44 | def test_language_unknown_with_known_fallback(test_app): 45 | common.test_language_unknown_with_known_fallback( 46 | test_app, 47 | endpoint_path, 48 | handler_calls, 49 | ) 50 | 51 | 52 | def test_language_unknown_without_known_fallback(test_app): 53 | common.test_language_unknown_without_known_fallback( 54 | test_app, 55 | endpoint_path, 56 | handler_calls, 57 | ) 58 | 59 | 60 | def test_format_header_html(test_app): 61 | common.test_format_header_html(test_app, endpoint_path, handler_calls) 62 | 63 | 64 | def test_format_header_json(test_app): 65 | common.test_format_header_json(test_app, endpoint_path, handler_calls) 66 | 67 | 68 | def test_format_header_override(test_app): 69 | common.test_format_header_override(test_app, endpoint_path, handler_calls) 70 | 71 | 72 | def test_unknown_param(test_app): 73 | common.test_unknown_param(test_app, endpoint_path) 74 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/test_conformance_delegation.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from oaff.app.requests.conformance import Conformance as ConformanceRequest 4 | from oaff.fastapi.api.routes.conformance import PATH as ROOT_PATH 5 | from oaff.fastapi.tests import common_delegation as common 6 | 7 | handler_calls, collection_id, _ = common.make_params_common() 8 | endpoint_path: Final = f"{ROOT_PATH}" 9 | 10 | 11 | def setup_module(): 12 | common.setup_module(handler_calls) 13 | 14 | 15 | def setup_function(): 16 | common.setup_function(handler_calls) 17 | 18 | 19 | def teardown_module(): 20 | common.teardown_module() 21 | 22 | 23 | def test_basic_defaults(test_app): 24 | common.test_basic_defaults( 25 | test_app, 26 | endpoint_path, 27 | ConformanceRequest, 28 | handler_calls, 29 | ) 30 | 31 | 32 | def test_format_html(test_app): 33 | common.test_format_html(test_app, endpoint_path, handler_calls) 34 | 35 | 36 | def test_format_json(test_app): 37 | common.test_format_json(test_app, endpoint_path, handler_calls) 38 | 39 | 40 | def test_language_en_US(test_app): 41 | common.test_language_en_US(test_app, endpoint_path, handler_calls) 42 | 43 | 44 | def test_language_unknown_with_known_fallback(test_app): 45 | common.test_language_unknown_with_known_fallback( 46 | test_app, 47 | endpoint_path, 48 | handler_calls, 49 | ) 50 | 51 | 52 | def test_language_unknown_without_known_fallback(test_app): 53 | common.test_language_unknown_without_known_fallback( 54 | test_app, 55 | endpoint_path, 56 | handler_calls, 57 | ) 58 | 59 | 60 | def test_format_header_html(test_app): 61 | common.test_format_header_html(test_app, endpoint_path, handler_calls) 62 | 63 | 64 | def test_format_header_json(test_app): 65 | common.test_format_header_json(test_app, endpoint_path, handler_calls) 66 | 67 | 68 | def test_format_header_override(test_app): 69 | common.test_format_header_override(test_app, endpoint_path, handler_calls) 70 | 71 | 72 | def test_unknown_param(test_app): 73 | common.test_unknown_param(test_app, endpoint_path) 74 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/test_feature_delegation.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from oaff.app.requests.feature import Feature 4 | from oaff.fastapi.api.routes.collections import PATH as ROOT_PATH 5 | from oaff.fastapi.tests import common_delegation as common 6 | 7 | handler_calls, collection_id, feature_id = common.make_params_common() 8 | endpoint_path: Final = f"{ROOT_PATH}/{collection_id}/items/{feature_id}" 9 | 10 | 11 | def setup_module(): 12 | common.setup_module(handler_calls) 13 | 14 | 15 | def setup_function(): 16 | common.setup_function(handler_calls) 17 | 18 | 19 | def teardown_module(): 20 | common.teardown_module() 21 | 22 | 23 | def test_basic_defaults(test_app): 24 | common.test_basic_defaults( 25 | test_app, 26 | endpoint_path, 27 | Feature, 28 | handler_calls, 29 | ) 30 | 31 | 32 | def test_format_html(test_app): 33 | common.test_format_html(test_app, endpoint_path, handler_calls) 34 | 35 | 36 | def test_format_json(test_app): 37 | common.test_format_json(test_app, endpoint_path, handler_calls) 38 | 39 | 40 | def test_language_en_US(test_app): 41 | common.test_language_en_US(test_app, endpoint_path, handler_calls) 42 | 43 | 44 | def test_language_unknown_with_known_fallback(test_app): 45 | common.test_language_unknown_with_known_fallback( 46 | test_app, 47 | endpoint_path, 48 | handler_calls, 49 | ) 50 | 51 | 52 | def test_language_unknown_without_known_fallback(test_app): 53 | common.test_language_unknown_without_known_fallback( 54 | test_app, 55 | endpoint_path, 56 | handler_calls, 57 | ) 58 | 59 | 60 | def test_format_header_html(test_app): 61 | common.test_format_header_html(test_app, endpoint_path, handler_calls) 62 | 63 | 64 | def test_format_header_json(test_app): 65 | common.test_format_header_json(test_app, endpoint_path, handler_calls) 66 | 67 | 68 | def test_format_header_override(test_app): 69 | common.test_format_header_override(test_app, endpoint_path, handler_calls) 70 | 71 | 72 | def test_unknown_param(test_app): 73 | common.test_unknown_param(test_app, endpoint_path) 74 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/test_landing_delegation.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from oaff.app.requests.landing_page import LandingPage as LandingPageRequest 4 | from oaff.fastapi.api.routes.landing_page import PATH as ROOT_PATH 5 | from oaff.fastapi.tests import common_delegation as common 6 | 7 | handler_calls, collection_id, _ = common.make_params_common() 8 | endpoint_path: Final = f"{ROOT_PATH}/" 9 | 10 | 11 | def setup_module(): 12 | common.setup_module(handler_calls) 13 | 14 | 15 | def setup_function(): 16 | common.setup_function(handler_calls) 17 | 18 | 19 | def teardown_module(): 20 | common.teardown_module() 21 | 22 | 23 | def test_basic_defaults(test_app): 24 | common.test_basic_defaults( 25 | test_app, 26 | endpoint_path, 27 | LandingPageRequest, 28 | handler_calls, 29 | ) 30 | 31 | 32 | def test_format_html(test_app): 33 | common.test_format_html(test_app, endpoint_path, handler_calls) 34 | 35 | 36 | def test_format_json(test_app): 37 | common.test_format_json(test_app, endpoint_path, handler_calls) 38 | 39 | 40 | def test_language_en_US(test_app): 41 | common.test_language_en_US(test_app, endpoint_path, handler_calls) 42 | 43 | 44 | def test_language_unknown_with_known_fallback(test_app): 45 | common.test_language_unknown_with_known_fallback( 46 | test_app, 47 | endpoint_path, 48 | handler_calls, 49 | ) 50 | 51 | 52 | def test_language_unknown_without_known_fallback(test_app): 53 | common.test_language_unknown_without_known_fallback( 54 | test_app, 55 | endpoint_path, 56 | handler_calls, 57 | ) 58 | 59 | 60 | def test_format_header_html(test_app): 61 | common.test_format_header_html(test_app, endpoint_path, handler_calls) 62 | 63 | 64 | def test_format_header_json(test_app): 65 | common.test_format_header_json(test_app, endpoint_path, handler_calls) 66 | 67 | 68 | def test_format_header_override(test_app): 69 | common.test_format_header_override(test_app, endpoint_path, handler_calls) 70 | 71 | 72 | def test_unknown_param(test_app): 73 | common.test_unknown_param(test_app, endpoint_path) 74 | -------------------------------------------------------------------------------- /oaff/fastapi/tests/test_util.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from oaff.app.responses.response_format import ResponseFormat 4 | from oaff.fastapi.api.util import alternate_format_for_url 5 | 6 | input_url_template: Final = "https://test.url/with/endpoint{0}" 7 | 8 | 9 | def test_alternate_format_without_parameter(): 10 | for end in ["", "/"]: 11 | for format in ResponseFormat: 12 | assert alternate_format_for_url( 13 | input_url_template.format(end), format 14 | ) == input_url_template.format(f"{end}?format={format.name}") 15 | 16 | 17 | def test_alternate_format_without_parameter_end_slash(): 18 | for end in ["", "/"]: 19 | for current_format in ResponseFormat: 20 | for target_format in [ 21 | target_format 22 | for target_format in ResponseFormat 23 | if target_format != current_format 24 | ]: 25 | assert ( 26 | alternate_format_for_url( 27 | input_url_template.format(f"{end}?format={current_format.name}"), 28 | target_format, 29 | ) 30 | == input_url_template.format(f"{end}?format={target_format.name}") 31 | ) 32 | -------------------------------------------------------------------------------- /oaff/testing/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oaff 2 | 3 | RUN apt-get update && apt-get install -y --no-install-recommends \ 4 | postgresql-client \ 5 | && rm -rf /var/lib/apt/lists/* 6 | 7 | COPY requirements-test.txt ./ 8 | RUN pip install --no-cache-dir -r requirements-test.txt 9 | RUN mkdir -p oaff/testing 10 | COPY data oaff/testing/data 11 | COPY integration_tests oaff/testing/integration_tests 12 | 13 | -------------------------------------------------------------------------------- /oaff/testing/data/load/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging import getLogger 3 | from typing import Final, List, Tuple 4 | 5 | import psycopg2 # type: ignore 6 | 7 | LOGGER: Final = getLogger(__file__) 8 | 9 | 10 | def query(sql: str, src_name: str = None) -> List[Tuple]: 11 | connection = _get_connection(src_name) 12 | cursor = connection.cursor() 13 | cursor.execute(sql) 14 | result = cursor.fetchall() 15 | cursor.close() 16 | _release_connection(connection) 17 | 18 | return result 19 | 20 | 21 | def update_db(statement: str, src_name: str = None) -> None: 22 | connection = _get_connection(src_name) 23 | cursor = connection.cursor() 24 | cursor.execute(statement) 25 | connection.commit() 26 | cursor.close() 27 | _release_connection(connection) 28 | 29 | 30 | def check_connection(src_name: str = None) -> None: 31 | _release_connection(_get_connection(src_name)) 32 | 33 | 34 | def _get_connection(src_name: str = None) -> psycopg2.extensions.connection: 35 | suffix = f"_{src_name}" if src_name is not None else "" 36 | try: 37 | return psycopg2.connect( 38 | host=os.environ.get(f"APP_POSTGRESQL_HOST{suffix}", "localhost"), 39 | port=int(os.environ.get(f"APP_POSTGRESQL_PORT{suffix}", 5432)), 40 | dbname=os.environ.get(f"APP_POSTGRESQL_DBNAME{suffix}", "postgres"), 41 | user=os.environ.get(f"APP_POSTGRESQL_USER{suffix}", "postgres"), 42 | password=os.environ.get(f"APP_POSTGRESQL_PASSWORD{suffix}", "postgres"), 43 | ) 44 | except Exception as e: 45 | LOGGER.error(f"Error establishing DB connection: {e}") 46 | raise e 47 | 48 | 49 | def _release_connection(connection: psycopg2.extensions.connection) -> None: 50 | try: 51 | connection.close() 52 | except Exception as e: 53 | LOGGER.warning(f"Problem encountered when closing database connection: {e}") 54 | -------------------------------------------------------------------------------- /oaff/testing/data/load/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | if [ -z "$APP_POSTGRESQL_SOURCE_NAMES" ]; then 5 | PROFILE_NAME=$APP_POSTGRESQL_PROFILE 6 | PGPASSWORD=${APP_POSTGRESQL_PASSWORD:-postgres} psql -h ${APP_POSTGRESQL_HOST:-localhost} -U ${APP_POSTGRESQL_USER:-postgres} -f /sql/$PROFILE_NAME/all.sql 7 | else 8 | SOURCE_NAMES=${APP_POSTGRESQL_SOURCE_NAMES//,/$'\n'} 9 | for SOURCE_NAME in $SOURCE_NAMES 10 | do 11 | PROFILE_VAR_NAME=APP_POSTGRESQL_PROFILE_$SOURCE_NAME 12 | PASSWORD_VAR_NAME=APP_POSTGRESQL_PASSWORD_$SOURCE_NAME 13 | HOST_VAR_NAME=APP_POSTGRESQL_HOST_$SOURCE_NAME 14 | USER_VAR_NAME=APP_POSTGRESQL_USER_$SOURCE_NAME 15 | PGPASSWORD=${!PASSWORD_VAR_NAME:-postgres} psql -h ${!HOST_VAR_NAME:-localhost} -U ${!USER_VAR_NAME:-postgres} -f /sql/${!PROFILE_VAR_NAME}/all.sql 16 | done 17 | fi 18 | -------------------------------------------------------------------------------- /oaff/testing/data/loader/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.5-alpine3.13 2 | 3 | RUN apk add --no-cache --update \ 4 | bash \ 5 | postgresql-client 6 | 7 | RUN apk add --no-cache --update --virtual .build-deps gcc libc-dev make python3-dev postgresql-dev \ 8 | && pip install psycopg2-binary==2.8.6 \ 9 | && apk del .build-deps 10 | 11 | COPY sql /sql 12 | COPY load /load 13 | 14 | CMD [ "/load/setup.sh" ] -------------------------------------------------------------------------------- /oaff/testing/data/sql/schema/schema_management.sql: -------------------------------------------------------------------------------- 1 | DROP SCHEMA IF EXISTS tiger CASCADE; 2 | DROP SCHEMA IF EXISTS tiger_data CASCADE; 3 | DROP SCHEMA IF EXISTS topology CASCADE; -------------------------------------------------------------------------------- /oaff/testing/data/sql/stac_hybrid/all.sql: -------------------------------------------------------------------------------- 1 | \ir ../schema/schema_management.sql 2 | \ir collections.sql 3 | \ir neighbourhoods-4326.sql -------------------------------------------------------------------------------- /oaff/testing/data/sql/stac_hybrid/collections.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO oaff.collections (id, title, description, keywords, license, providers, extent, temporal, schema_name, table_name) VALUES 2 | ( 3 | 'baltimore-csa' 4 | , 'Baltimore Community Statistical Areas 2017' 5 | , 'Community Statistical Areas for Baltimore, Maryland, from 2017. Used by the Baltimore Neighbourhood Health Profile report.' 6 | , '{"Baltimore","Health"}' 7 | , NULL 8 | , '[{"url": "https://health.baltimorecity.gov/neighborhoods/neighborhood-health-profile-reports", "name": "Baltimore City Health Department", "roles": ["processor", "producer"]}, {"url": "https://sparkgeo.com", "name": "Sparkgeo", "roles": ["processor"]}, {"url": "https://planetarycomputer.microsoft.com", "name": "Microsoft", "roles": ["host", "processor"]}]' 9 | , '{"spatial": {"bbox": [[-76.711405, 39.197233, -76.529674, 39.372]]}, "temporal": {"interval": [["2017-01-01T00:00:00.000000Z", "2017-12-31T23:59:59.999999Z"]]}}' 10 | , '[{"type": "range", "start_field": "valid_from", "end_field": "valid_to"}]' 11 | , 'public' 12 | , 'neighbourhoods' 13 | ); -------------------------------------------------------------------------------- /oaff/testing/data/sql/stac_hybrid/neighbourhoods-4326-schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS neighbourhoods ( 2 | id SERIAL PRIMARY KEY 3 | , name VARCHAR(100) NOT NULL UNIQUE 4 | , boundary GEOMETRY(MULTIPOLYGON, 4326) NOT NULL 5 | , valid_from TIMESTAMPTZ 6 | , valid_to TIMESTAMPTZ 7 | ) 8 | ; -------------------------------------------------------------------------------- /oaff/testing/data/sql/stac_hybrid/neighbourhoods-4326.sql: -------------------------------------------------------------------------------- 1 | \ir neighbourhoods-4326-schema.sql 2 | \ir neighbourhoods-4326-data.sql -------------------------------------------------------------------------------- /oaff/testing/integration_tests/common.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from http import HTTPStatus 3 | 4 | from pytz import timezone 5 | 6 | 7 | def reconfigure(test_app): 8 | reconfigure_response = test_app.post("/control/reconfigure") 9 | assert reconfigure_response.status_code == HTTPStatus.OK 10 | 11 | 12 | def get_collection_id_for(test_app, collection_title: str) -> str: 13 | response = test_app.get("/collections?format=json").json() 14 | return list( 15 | filter( 16 | lambda collection: collection["title"] == collection_title, 17 | response["collections"], 18 | ) 19 | )[0]["id"] 20 | 21 | 22 | def get_item_id_for(test_app, collection_id: str, feature_name: str) -> str: 23 | return list( 24 | filter( 25 | lambda feature: feature["properties"]["name"] == feature_name, 26 | test_app.get(f"/collections/{collection_id}/items?format=json").json()[ 27 | "features" 28 | ], 29 | ) 30 | )[0]["id"] 31 | 32 | 33 | def utc_datetime(*args, **kwargs) -> datetime: 34 | return tz_datetime("UTC", *args, **kwargs) 35 | 36 | 37 | def tz_datetime(tz: str, *args, **kwargs) -> datetime: 38 | return timezone(tz).localize(datetime(*args, **kwargs)) 39 | -------------------------------------------------------------------------------- /oaff/testing/integration_tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from oaff.fastapi.api.main import app 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def test_app(): 9 | with TestClient(app) as client: 10 | yield client 11 | -------------------------------------------------------------------------------- /oaff/testing/integration_tests/test_collection_items_pg_all.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, Dict, Final, List 3 | 4 | from oaff.app.util import datetime_as_rfc3339 5 | from oaff.testing.integration_tests.common import ( 6 | get_collection_id_for, 7 | reconfigure, 8 | utc_datetime, 9 | ) 10 | from oaff.testing.integration_tests.common_pg import ( 11 | create_common, 12 | drop_common, 13 | insert_table_pnt_4326_1_instant_utc_with, 14 | table_pnt_4326_1_instant_utc, 15 | truncate_common, 16 | ) 17 | 18 | SOURCE_NAME: Final = "stac" 19 | ITEMS_URL: Final = "".join( 20 | [ 21 | "/collections/{collection_id}/items", 22 | "?format=json", 23 | "&limit={limit}&offset={offset}&datetime={datetime}&bbox={bbox}", 24 | ] 25 | ) 26 | 27 | 28 | def setup_module(): 29 | os.environ["APP_DATA_SOURCE_TYPES"] = "postgresql" 30 | os.environ["APP_POSTGRESQL_SOURCE_NAMES"] = SOURCE_NAME 31 | os.environ[f"APP_POSTGRESQL_MAC_{SOURCE_NAME}"] = "0" 32 | create_common(SOURCE_NAME) 33 | 34 | 35 | def teardown_module(): 36 | drop_common(SOURCE_NAME) 37 | 38 | 39 | def teardown_function(): 40 | truncate_common(SOURCE_NAME) 41 | 42 | 43 | def test_range_param_and_bbox_and_constraints_no_limit(test_app): 44 | reconfigure(test_app) 45 | insert_table_pnt_4326_1_instant_utc_with( 46 | SOURCE_NAME, 47 | utc_datetime(2020, 1, 1, 0, 0, 0), 48 | "POINT(1 1)", 49 | ) 50 | insert_table_pnt_4326_1_instant_utc_with( 51 | SOURCE_NAME, 52 | utc_datetime(2020, 1, 1, 0, 0, 0), 53 | "POINT(10 10)", 54 | ) 55 | insert_table_pnt_4326_1_instant_utc_with( 56 | SOURCE_NAME, 57 | utc_datetime(2020, 1, 2, 0, 0, 0), 58 | "POINT(2 2)", 59 | ) 60 | result = _get_collection_items( 61 | test_app, 62 | table_pnt_4326_1_instant_utc, 63 | "/".join( 64 | [ 65 | datetime_as_rfc3339(utc_datetime(2019, 12, 31, 23, 59, 59)), 66 | datetime_as_rfc3339(utc_datetime(2021, 1, 1, 0, 0, 0)), 67 | ] 68 | ), 69 | "0,0,3,3", 70 | 3, 71 | 0, 72 | ) 73 | assert len(result) == 2 74 | 75 | 76 | def test_range_param_and_bbox_and_constraints_limit(test_app): 77 | reconfigure(test_app) 78 | insert_table_pnt_4326_1_instant_utc_with( 79 | SOURCE_NAME, 80 | utc_datetime(2020, 1, 1, 0, 0, 0), 81 | "POINT(1 1)", 82 | ) 83 | insert_table_pnt_4326_1_instant_utc_with( 84 | SOURCE_NAME, 85 | utc_datetime(2020, 1, 1, 0, 0, 0), 86 | "POINT(10 10)", 87 | ) 88 | insert_table_pnt_4326_1_instant_utc_with( 89 | SOURCE_NAME, 90 | utc_datetime(2020, 1, 2, 0, 0, 0), 91 | "POINT(2 2)", 92 | ) 93 | result = _get_collection_items( 94 | test_app, 95 | table_pnt_4326_1_instant_utc, 96 | "/".join( 97 | [ 98 | datetime_as_rfc3339(utc_datetime(2019, 12, 31, 23, 59, 59)), 99 | datetime_as_rfc3339(utc_datetime(2021, 1, 1, 0, 0, 0)), 100 | ] 101 | ), 102 | "0,0,3,3", 103 | 1, 104 | 0, 105 | ) 106 | assert len(result) == 1 107 | 108 | 109 | def test_range_param_and_bbox_and_constraints_offset(test_app): 110 | reconfigure(test_app) 111 | insert_table_pnt_4326_1_instant_utc_with( 112 | SOURCE_NAME, 113 | utc_datetime(2020, 1, 1, 0, 0, 0), 114 | "POINT(1 1)", 115 | ) 116 | insert_table_pnt_4326_1_instant_utc_with( 117 | SOURCE_NAME, 118 | utc_datetime(2020, 1, 1, 0, 0, 0), 119 | "POINT(10 10)", 120 | ) 121 | insert_table_pnt_4326_1_instant_utc_with( 122 | SOURCE_NAME, 123 | utc_datetime(2020, 1, 2, 0, 0, 0), 124 | "POINT(2 2)", 125 | ) 126 | result = _get_collection_items( 127 | test_app, 128 | table_pnt_4326_1_instant_utc, 129 | "/".join( 130 | [ 131 | datetime_as_rfc3339(utc_datetime(2019, 12, 31, 23, 59, 59)), 132 | datetime_as_rfc3339(utc_datetime(2021, 1, 1, 0, 0, 0)), 133 | ] 134 | ), 135 | "0,0,3,3", 136 | 1, 137 | 3, 138 | ) 139 | assert len(result) == 0 140 | 141 | 142 | def _get_collection_items( 143 | test_app, 144 | collection_title: str, 145 | datetime_param: str, 146 | bbox_param: str, 147 | limit_param: int, 148 | offset_param: int, 149 | ) -> List[Dict[str, Any]]: 150 | collection_id = get_collection_id_for(test_app, collection_title) 151 | return test_app.get( 152 | ITEMS_URL.format( 153 | collection_id=collection_id, 154 | datetime=datetime_param, 155 | bbox=bbox_param, 156 | limit=limit_param, 157 | offset=offset_param, 158 | ) 159 | ).json()["features"] 160 | -------------------------------------------------------------------------------- /oaff/testing/integration_tests/test_collection_pg_basic_nomac.py: -------------------------------------------------------------------------------- 1 | import os 2 | from http import HTTPStatus 3 | from typing import Final 4 | from uuid import uuid4 5 | 6 | import pytest 7 | from bs4 import BeautifulSoup 8 | 9 | from oaff.testing.integration_tests.common import reconfigure 10 | from oaff.testing.integration_tests.common_pg import ( 11 | create_common, 12 | drop_common, 13 | insert_table_mply_4326_basic, 14 | table_mply_4326, 15 | truncate_common, 16 | ) 17 | 18 | BASE_URL: Final = "/collections/{0}?format=" 19 | JSON_URL: Final = f"{BASE_URL}json" 20 | HTML_URL: Final = f"{BASE_URL}html" 21 | SOURCE_NAME: Final = "stac" 22 | 23 | 24 | def setup_module(): 25 | drop_common(SOURCE_NAME) 26 | os.environ["APP_DATA_SOURCE_TYPES"] = "postgresql" 27 | os.environ["APP_POSTGRESQL_SOURCE_NAMES"] = SOURCE_NAME 28 | os.environ[f"APP_POSTGRESQL_MAC_{SOURCE_NAME}"] = "0" 29 | create_common(SOURCE_NAME) 30 | 31 | 32 | def teardown_module(): 33 | drop_common(SOURCE_NAME) 34 | 35 | 36 | def setup_function(): 37 | insert_table_mply_4326_basic(SOURCE_NAME) 38 | 39 | 40 | def teardown_function(): 41 | truncate_common(SOURCE_NAME) 42 | 43 | 44 | def test_basic(test_app): 45 | reconfigure(test_app) 46 | response = test_app.get(JSON_URL.format(_get_collection_id(test_app))) 47 | assert response.status_code == HTTPStatus.OK 48 | 49 | 50 | def test_bbox(test_app): 51 | reconfigure(test_app) 52 | collection = test_app.get(JSON_URL.format(_get_collection_id(test_app))).json() 53 | collection_bbox = collection["extent"]["spatial"]["bbox"][0] 54 | assert collection_bbox[0] == pytest.approx(0, 0.000001) 55 | assert collection_bbox[1] == pytest.approx(0, 0.000001) 56 | assert collection_bbox[2] == pytest.approx(1, 0.000001) 57 | assert collection_bbox[3] == pytest.approx(1, 0.000001) 58 | 59 | 60 | def test_self_links_json(test_app): 61 | reconfigure(test_app) 62 | collection_id = _get_collection_id(test_app) 63 | links = test_app.get(JSON_URL.format(collection_id)).json()["links"] 64 | assert ( 65 | len( 66 | list( 67 | filter( 68 | lambda link: link["rel"] == "alternate" 69 | and link["type"] == "text/html" 70 | and link["href"].endswith(HTML_URL.format(collection_id)), 71 | links, 72 | ) 73 | ) 74 | ) 75 | == 1 76 | ) 77 | assert ( 78 | len( 79 | list( 80 | filter( 81 | lambda link: link["rel"] == "self" 82 | and link["type"] == "application/json" 83 | and link["href"].endswith(JSON_URL.format(collection_id)), 84 | links, 85 | ) 86 | ) 87 | ) 88 | == 1 89 | ) 90 | 91 | 92 | def test_self_links_html(test_app): 93 | reconfigure(test_app) 94 | collection_id = _get_collection_id(test_app) 95 | soup = BeautifulSoup( 96 | str(test_app.get(HTML_URL.format(collection_id)).content), "html.parser" 97 | ) 98 | self_link = soup.find_all("a", attrs={"class": "link-self"}) 99 | assert len(self_link) == 1 100 | assert self_link[0].attrs["href"].endswith(HTML_URL.format(collection_id)) 101 | alt_link = soup.find_all("a", attrs={"class": "link-alternate"}) 102 | assert len(alt_link) == 1 103 | assert alt_link[0].attrs["href"].endswith(JSON_URL.format(collection_id)) 104 | 105 | 106 | def test_missing_collection(test_app): 107 | response = test_app.get(JSON_URL.format(str(uuid4()))) 108 | assert response.status_code == HTTPStatus.NOT_FOUND 109 | 110 | 111 | def test_html_present(test_app): 112 | # only purpose of this test is to verify that the 113 | # negative HTML tests that follow are valid 114 | reconfigure(test_app) 115 | soup = BeautifulSoup( 116 | str(test_app.get(HTML_URL.format(_get_collection_id(test_app))).content), 117 | "html.parser", 118 | ) 119 | assert soup.find("div", attrs={"class": "title-container"}) is not None 120 | 121 | 122 | def test_unconfigured_license_json(test_app): 123 | reconfigure(test_app) 124 | assert ( 125 | test_app.get(JSON_URL.format(_get_collection_id(test_app))).json()["license"] 126 | is None 127 | ) 128 | 129 | 130 | def test_unconfigured_license_html(test_app): 131 | reconfigure(test_app) 132 | soup = BeautifulSoup( 133 | str(test_app.get(HTML_URL.format(_get_collection_id(test_app))).content), 134 | "html.parser", 135 | ) 136 | assert soup.find("div", attrs={"class": "license-container"}) is None 137 | 138 | 139 | def test_unconfigured_keywords_json(test_app): 140 | reconfigure(test_app) 141 | assert ( 142 | test_app.get(JSON_URL.format(_get_collection_id(test_app))).json()["keywords"] 143 | is None 144 | ) 145 | 146 | 147 | def test_unconfigured_keywords_html(test_app): 148 | reconfigure(test_app) 149 | soup = BeautifulSoup( 150 | str(test_app.get(HTML_URL.format(_get_collection_id(test_app))).content), 151 | "html.parser", 152 | ) 153 | assert soup.find("div", attrs={"class": "keywords-container"}) is None 154 | 155 | 156 | def test_unconfigured_providers_json(test_app): 157 | reconfigure(test_app) 158 | assert ( 159 | test_app.get(JSON_URL.format(_get_collection_id(test_app))).json()["providers"] 160 | is None 161 | ) 162 | 163 | 164 | def test_unconfigured_providers_html(test_app): 165 | reconfigure(test_app) 166 | soup = BeautifulSoup( 167 | str(test_app.get(HTML_URL.format(_get_collection_id(test_app))).content), 168 | "html.parser", 169 | ) 170 | assert soup.find("div", attrs={"class": "providers-container"}) is None 171 | 172 | 173 | def _get_collection_id(test_app): 174 | return list( 175 | filter( 176 | lambda collection: collection["title"] == table_mply_4326, 177 | test_app.get("/collections?format=json").json()["collections"], 178 | ) 179 | )[0]["id"] 180 | -------------------------------------------------------------------------------- /oaff/testing/integration_tests/test_collection_pg_interval_mac.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import Final 4 | from uuid import uuid4 5 | 6 | from pytz import timezone 7 | 8 | from oaff.app.data.sources.postgresql.stac_hybrid.settings import OAFF_SCHEMA_NAME 9 | from oaff.app.util import datetime_as_rfc3339 10 | from oaff.testing.data.load.db import update_db 11 | from oaff.testing.integration_tests.common import reconfigure 12 | from oaff.testing.integration_tests.common_pg import ( 13 | create_common, 14 | drop_common, 15 | insert_table_pnt_4326_2_instants_utc_with, 16 | table_pnt_4326_2_instants_utc, 17 | temporal_config, 18 | truncate_common, 19 | ) 20 | 21 | SOURCE_NAME: Final = "stac" 22 | JSON_URL: Final = "/collections?format=json" 23 | 24 | 25 | def setup_module(): 26 | os.environ["APP_DATA_SOURCE_TYPES"] = "postgresql" 27 | os.environ["APP_POSTGRESQL_SOURCE_NAMES"] = SOURCE_NAME 28 | os.environ[f"APP_POSTGRESQL_MAC_{SOURCE_NAME}"] = "1" 29 | create_common(SOURCE_NAME) 30 | 31 | 32 | def teardown_module(): 33 | drop_common(SOURCE_NAME) 34 | 35 | 36 | def teardown_function(): 37 | truncate_common(SOURCE_NAME) 38 | update_db(f'TRUNCATE TABLE "{OAFF_SCHEMA_NAME}".collections', SOURCE_NAME) 39 | 40 | 41 | def test_interval_range_utc(test_app): 42 | range_declared_start = timezone("UTC").localize(datetime(2017, 1, 1)) 43 | range_declared_end = timezone("UTC").localize( 44 | datetime(2017, 12, 31, 23, 59, 59, 999999) 45 | ) 46 | range_1_start_utc = timezone("UTC").localize(datetime(2020, 8, 4, 15, 2, 59)) 47 | range_1_end_utc = timezone("UTC").localize(datetime(2021, 8, 4, 15, 2, 59)) 48 | insert_table_pnt_4326_2_instants_utc_with( 49 | SOURCE_NAME, range_1_start_utc, range_1_end_utc 50 | ) 51 | collection_id = str(uuid4()) 52 | extent = f'{{"spatial": {{"bbox": [[-76.711405, 39.197233, -76.529674, 39.372]]}}, "temporal": {{"interval": [["{datetime_as_rfc3339(range_declared_start)}", "{datetime_as_rfc3339(range_declared_end)}"]]}}}}' # noqa: E501 53 | collection_schema_name = "public" 54 | collection_table_name = table_pnt_4326_2_instants_utc 55 | 56 | update_db( 57 | f""" 58 | INSERT INTO {OAFF_SCHEMA_NAME}.collections ( 59 | id, title, description, keywords, license, 60 | providers, extent, schema_name, table_name, 61 | temporal 62 | ) VALUES ( 63 | '{collection_id}' 64 | , '{str(uuid4())}' 65 | , '{str(uuid4())}' 66 | , '{{"{str(uuid4())}"}}' 67 | , '{str(uuid4())}' 68 | , '[{{"url": "{str(uuid4())}", "name": "{str(uuid4())}"}}]' 69 | , '{extent}' 70 | , '{collection_schema_name}' 71 | , '{collection_table_name}' 72 | , '{temporal_config[collection_table_name]}' 73 | ) 74 | """, 75 | SOURCE_NAME, 76 | ) 77 | reconfigure(test_app) 78 | response_content = test_app.get(JSON_URL).json() 79 | overridden = list( 80 | filter( 81 | lambda collection: collection["id"] == collection_id, 82 | response_content["collections"], 83 | ) 84 | )[0] 85 | assert len(overridden["extent"]["temporal"]["interval"]) == 1 86 | assert overridden["extent"]["temporal"]["interval"][0] == [ 87 | datetime_as_rfc3339(range_declared_start), 88 | datetime_as_rfc3339(range_declared_end), 89 | ] 90 | -------------------------------------------------------------------------------- /oaff/testing/integration_tests/test_collections_list_pg_mac.py: -------------------------------------------------------------------------------- 1 | import os 2 | from json import loads 3 | from typing import Final 4 | from uuid import uuid4 5 | 6 | import pytest 7 | 8 | from oaff.app.data.sources.postgresql.stac_hybrid.settings import OAFF_SCHEMA_NAME 9 | from oaff.testing.data.load.db import update_db 10 | from oaff.testing.integration_tests.common import reconfigure 11 | from oaff.testing.integration_tests.common_pg import ( 12 | create_common, 13 | drop_common, 14 | insert_table_mply_4326_basic, 15 | insert_table_pnt_4326_basic, 16 | table_mply_4326, 17 | table_pnt_4326, 18 | truncate_common, 19 | ) 20 | 21 | SOURCE_NAME: Final = "stac" 22 | JSON_URL: Final = "/collections?format=json" 23 | 24 | 25 | def setup_module(): 26 | os.environ["APP_DATA_SOURCE_TYPES"] = "postgresql" 27 | os.environ["APP_POSTGRESQL_SOURCE_NAMES"] = SOURCE_NAME 28 | os.environ[f"APP_POSTGRESQL_MAC_{SOURCE_NAME}"] = "1" 29 | create_common(SOURCE_NAME) 30 | 31 | 32 | def teardown_module(): 33 | drop_common(SOURCE_NAME) 34 | 35 | 36 | def teardown_function(): 37 | truncate_common(SOURCE_NAME) 38 | update_db(f'TRUNCATE TABLE "{OAFF_SCHEMA_NAME}".collections', SOURCE_NAME) 39 | 40 | 41 | def test_collections_partial_override(test_app): 42 | insert_table_mply_4326_basic(SOURCE_NAME) 43 | insert_table_pnt_4326_basic(SOURCE_NAME) 44 | collection_id = str(uuid4()) 45 | title = str(uuid4()) 46 | description = str(uuid4()) 47 | keywords = '{{"{0}", "{1}"}}'.format(str(uuid4()), str(uuid4())) 48 | license = str(uuid4()) 49 | providers = f'[{{"url": "{str(uuid4())}", "name": "{str(uuid4())}"}}]' 50 | extent = '{"spatial": {"bbox": [[-76.711405, 39.197233, -76.529674, 39.372]]}, "temporal": {"interval": [["2017-01-01T00:00:00.000000Z", "2017-12-31T23:59:59.999999Z"]]}}' # noqa: E501 51 | collection_schema_name = "public" 52 | collection_table_name = table_mply_4326 53 | update_db( 54 | f""" 55 | INSERT INTO {OAFF_SCHEMA_NAME}.collections ( 56 | id, title, description, keywords, license, 57 | providers, extent, schema_name, table_name 58 | ) VALUES ( 59 | '{collection_id}' 60 | , '{title}' 61 | , '{description}' 62 | , '{keywords}' 63 | , '{license}' 64 | , '{providers}' 65 | , '{extent}' 66 | , '{collection_schema_name}' 67 | , '{collection_table_name}' 68 | ) 69 | """, 70 | SOURCE_NAME, 71 | ) 72 | reconfigure(test_app) 73 | response_content = test_app.get(JSON_URL).json() 74 | overridden = list( 75 | filter( 76 | lambda collection: collection["id"] == collection_id, 77 | response_content["collections"], 78 | ) 79 | )[0] 80 | assert overridden["title"] == title 81 | assert overridden["description"] == description 82 | assert overridden["extent"] == loads(extent) 83 | # ensure non-overridden table's derived properties still provided 84 | original = list( 85 | filter( 86 | lambda collection: collection["title"] == table_pnt_4326, 87 | response_content["collections"], 88 | ) 89 | )[0] 90 | assert original["title"] == table_pnt_4326 91 | assert original["description"] is None 92 | original_bbox = original["extent"]["spatial"]["bbox"][0] 93 | assert original_bbox[0] == pytest.approx(0, 0.000001) 94 | assert original_bbox[1] == pytest.approx(1, 0.000001) 95 | assert original_bbox[2] == pytest.approx(0, 0.000001) 96 | assert original_bbox[3] == pytest.approx(1, 0.000001) 97 | assert original["extent"]["temporal"]["interval"] == [[None, None]] 98 | -------------------------------------------------------------------------------- /oaff/testing/integration_tests/test_feature_pg.py: -------------------------------------------------------------------------------- 1 | import os 2 | from http import HTTPStatus 3 | from typing import Final 4 | from uuid import uuid4 5 | 6 | from bs4 import BeautifulSoup 7 | 8 | from oaff.testing.data.load.db import query, update_db 9 | from oaff.testing.integration_tests.common import get_collection_id_for, reconfigure 10 | from oaff.testing.integration_tests.common_pg import ( 11 | create_common, 12 | drop_common, 13 | insert_table_pnt_4326_float_id_with, 14 | insert_table_pnt_4326_str_id_with, 15 | table_mply_4326, 16 | table_pnt_4326_float_id, 17 | table_pnt_4326_str_id, 18 | truncate_common, 19 | ) 20 | 21 | SOURCE_NAME: Final = "stac" 22 | ITEM_1_NAME: Final = str(uuid4()) 23 | ITEM_2_NAME: Final = str(uuid4()) 24 | BASE_URL: Final = "/collections/{collection_id}/items/{feature_id}" 25 | JSON_URL: Final = f"{BASE_URL}?format=json" 26 | HTML_URL: Final = f"{BASE_URL}?format=html" 27 | 28 | 29 | def setup_module(): 30 | drop_common(SOURCE_NAME) 31 | os.environ["APP_DATA_SOURCE_TYPES"] = "postgresql" 32 | os.environ["APP_POSTGRESQL_SOURCE_NAMES"] = SOURCE_NAME 33 | os.environ[f"APP_POSTGRESQL_MAC_{SOURCE_NAME}"] = "0" 34 | create_common(SOURCE_NAME) 35 | 36 | 37 | def teardown_module(): 38 | drop_common(SOURCE_NAME) 39 | 40 | 41 | def teardown_function(): 42 | truncate_common(SOURCE_NAME) 43 | 44 | 45 | def test_feature_int_id_json(test_app): 46 | _item_setup_int_id(test_app) 47 | response = test_app.get( 48 | JSON_URL.format( 49 | collection_id=get_collection_id_for(test_app, table_mply_4326), 50 | feature_id=_get_feature_id_by_name(ITEM_2_NAME), 51 | ) 52 | ) 53 | assert response.status_code == HTTPStatus.OK 54 | assert response.json()["properties"]["name"] == ITEM_2_NAME 55 | assert response.json()["geometry"]["coordinates"] == [ 56 | [[[0, 0], [0, 2], [2, 2], [2, 0], [0, 0]]] 57 | ] 58 | 59 | 60 | def test_missing_feature_int_id_json(test_app): 61 | _item_setup_int_id(test_app) 62 | response = test_app.get( 63 | JSON_URL.format( 64 | collection_id=get_collection_id_for(test_app, table_mply_4326), 65 | feature_id=2147483647, 66 | ) 67 | ) 68 | assert response.status_code == HTTPStatus.NOT_FOUND 69 | 70 | 71 | def test_missing_collection_json(test_app): 72 | _item_setup_int_id(test_app) 73 | response = test_app.get( 74 | JSON_URL.format( 75 | collection_id=str(uuid4()), 76 | feature_id=_get_feature_id_by_name(ITEM_1_NAME), 77 | ) 78 | ) 79 | assert response.status_code == HTTPStatus.NOT_FOUND 80 | 81 | 82 | def test_feature_int_id_html(test_app): 83 | _item_setup_int_id(test_app) 84 | response = test_app.get( 85 | HTML_URL.format( 86 | collection_id=get_collection_id_for(test_app, table_mply_4326), 87 | feature_id=_get_feature_id_by_name(ITEM_1_NAME), 88 | ) 89 | ) 90 | assert response.status_code == HTTPStatus.OK 91 | soup = BeautifulSoup(str(response.content), "html.parser") 92 | feature_id = soup.find_all("h2", attrs={"class": "feature-id-title"}) 93 | assert len(feature_id) == 1 94 | assert feature_id[0].text == str(_get_feature_id_by_name(ITEM_1_NAME)) 95 | 96 | 97 | def test_missing_feature_int_id_html(test_app): 98 | _item_setup_int_id(test_app) 99 | response = test_app.get( 100 | HTML_URL.format( 101 | collection_id=get_collection_id_for(test_app, table_mply_4326), 102 | feature_id=2147483647, 103 | ) 104 | ) 105 | assert response.status_code == HTTPStatus.NOT_FOUND 106 | 107 | 108 | def test_missing_collection_html(test_app): 109 | _item_setup_int_id(test_app) 110 | response = test_app.get( 111 | HTML_URL.format( 112 | collection_id=str(uuid4()), 113 | feature_id=_get_feature_id_by_name(ITEM_1_NAME), 114 | ) 115 | ) 116 | assert response.status_code == HTTPStatus.NOT_FOUND 117 | 118 | 119 | def test_feature_float_id_json(test_app): 120 | reconfigure(test_app) 121 | id = 1.1 122 | insert_table_pnt_4326_float_id_with(SOURCE_NAME, id) 123 | response = test_app.get( 124 | JSON_URL.format( 125 | collection_id=get_collection_id_for(test_app, table_pnt_4326_float_id), 126 | feature_id=id, 127 | ) 128 | ) 129 | assert response.status_code == HTTPStatus.OK 130 | assert response.json()["id"] == id 131 | 132 | 133 | def test_feature_str_id_json(test_app): 134 | reconfigure(test_app) 135 | id = str(uuid4()) 136 | insert_table_pnt_4326_str_id_with(SOURCE_NAME, id) 137 | response = test_app.get( 138 | JSON_URL.format( 139 | collection_id=get_collection_id_for(test_app, table_pnt_4326_str_id), 140 | feature_id=id, 141 | ) 142 | ) 143 | assert response.status_code == HTTPStatus.OK 144 | assert response.json()["id"] == id 145 | 146 | 147 | def _item_setup_int_id(test_app): 148 | update_db( 149 | f""" 150 | INSERT INTO {table_mply_4326} (name, boundary) VALUES 151 | ('{ITEM_1_NAME}', ST_GeomFromText('MULTIPOLYGON ((( 152 | 0 0, 0 1, 1 1, 1 0, 0 0 153 | )))', 4326)) 154 | """, 155 | SOURCE_NAME, 156 | ) 157 | update_db( 158 | f""" 159 | INSERT INTO {table_mply_4326} (name, boundary) VALUES 160 | ('{ITEM_2_NAME}', ST_GeomFromText('MULTIPOLYGON ((( 161 | 0 0, 0 2, 2 2, 2 0, 0 0 162 | )))', 4326)) 163 | """, 164 | SOURCE_NAME, 165 | ) 166 | reconfigure(test_app) 167 | 168 | 169 | def _get_feature_id_by_name(name: str): 170 | return query(f"SELECT id FROM {table_mply_4326} WHERE name = '{name}'", SOURCE_NAME)[ 171 | 0 172 | ][0] 173 | -------------------------------------------------------------------------------- /oaff/testing/requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest==6.2.4 2 | beautifulsoup4==4.9.3 3 | requests==2.25.1 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 90 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | 6 | ( 7 | /( 8 | \.eggs # exclude a few common directories in the 9 | | \.git # root of the project 10 | | \.hg 11 | | \.mypy_cache 12 | | \.tox 13 | | \.venv 14 | | _build 15 | | buck-out 16 | | build 17 | | dist 18 | | lib 19 | )/ 20 | ) 21 | ''' 22 | 23 | [tool.isort] 24 | atomic=true 25 | multi_line_output=3 26 | include_trailing_comma=true 27 | force_grid_wrap=0 28 | combine_as_imports=true 29 | line_length=90 30 | known_third_party='django,flask' 31 | known_first_party='sample_lib' 32 | indent=4 33 | sections='FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER' 34 | no_lines_before='LOCALFOLDER' 35 | 36 | [tool.mypy] 37 | exclude="^(tests|testing|data|scripts)$" 38 | namespace_packages=true 39 | explicit_package_bases=true 40 | ignore_missing_imports=true 41 | install_types=true 42 | non_interactive=true 43 | no_warn_no_return=true 44 | no_strict_optional=true 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # the following requirements can be installed without a build step on an Alpine base image 2 | fastapi==0.65.2 3 | uvicorn==0.13.4 4 | Jinja2==3.0.1 5 | iso8601==0.1.14 6 | pytz==2021.1 7 | aiofiles==0.7.0 8 | pyproj==2.6.1 9 | 10 | # postgresql data source deps 11 | databases==0.4.3 12 | SQLAlchemy==1.3.24 13 | geoalchemy2==0.8.4 14 | alembic==1.6.5 -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | # the following requirements are needed to support a local development environment 2 | pre-commit==2.12.1 3 | safety==1.10.3 4 | isort==5.8.0 5 | black==21.5b1 6 | flake8==3.9.2 7 | mypy==0.910 8 | fastapi==0.65.2 9 | uvicorn==0.13.4 10 | pytest==6.2.4 11 | pytest-mock==3.6.1 12 | requests==2.25.1 13 | GDAL 14 | Jinja2==3.0.1 15 | Babel==2.9.1 16 | iso8601==0.1.14 17 | pytz==2021.1 18 | aiofiles==0.7.0 19 | beautifulsoup4==4.9.3 20 | pyproj==2.6.1 21 | 22 | # postgresql data source deps 23 | databases==0.4.3 24 | SQLAlchemy==1.3.24 25 | geoalchemy2==0.8.4 26 | alembic==1.6.5 -------------------------------------------------------------------------------- /scripts/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | pip install flake8==3.9.2 mypy==0.910 6 | cd $(dirname $0)/.. 7 | flake8 . 8 | mypy . 9 | scripts/test --no-cache -------------------------------------------------------------------------------- /scripts/console: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function usage() { 4 | if [[ "${1}" ]]; then 5 | echo "${1}" 6 | fi 7 | echo -n \ 8 | "Usage: $(basename "$0") [api|postgres] 9 | Enters container console. 10 | 11 | Options: 12 | (None) - Do nothing 13 | api - Enter API console (bash) 14 | postgres - Enter Postgres console (bash) 15 | " 16 | } 17 | 18 | SERVICE_NAME='' 19 | while [[ $# -gt 0 ]]; do case $1 in 20 | api) 21 | SERVICE_NAME=api 22 | shift 23 | ;; 24 | postgres) 25 | SERVICE_NAME=postgres 26 | shift 27 | ;; 28 | --help) 29 | usage 30 | exit 0 31 | shift 32 | ;; 33 | *) 34 | shift 35 | ;; 36 | esac done 37 | 38 | if [[ -z "$SERVICE_NAME" ]]; then 39 | echo "Invalid argument" 40 | exit 1 41 | fi 42 | docker-compose exec $SERVICE_NAME /bin/bash -------------------------------------------------------------------------------- /scripts/debug_test_start: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker-compose -f docker-compose.yml -f docker-compose.test.yml -p test up -d 4 | -------------------------------------------------------------------------------- /scripts/debug_test_stop: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker-compose -p test down 4 | -------------------------------------------------------------------------------- /scripts/demo_data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker-compose run loader 6 | docker-compose exec api curl -q -X POST http://localhost/control/reconfigure > /dev/null -------------------------------------------------------------------------------- /scripts/format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | isort $(dirname $0)/.. 4 | black --config $(dirname $0)/../pyproject.toml .. -------------------------------------------------------------------------------- /scripts/logs: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function usage() { 4 | if [[ "${1}" ]]; then 5 | echo "${1}" 6 | fi 7 | echo -n \ 8 | "Usage: $(basename "$0") [api|postgres] 9 | Follows logs for container. 10 | 11 | Options: 12 | (None) - Do nothing 13 | api - Follow API logs 14 | postgres - Follow postgres logs 15 | " 16 | } 17 | 18 | SERVICE_NAME='' 19 | while [[ $# -gt 0 ]]; do case $1 in 20 | api) 21 | SERVICE_NAME=api 22 | shift 23 | ;; 24 | postgres) 25 | SERVICE_NAME=postgres 26 | shift 27 | ;; 28 | --help) 29 | usage 30 | exit 0 31 | shift 32 | ;; 33 | *) 34 | shift 35 | ;; 36 | esac done 37 | 38 | if [[ -z "$SERVICE_NAME" ]]; then 39 | echo "Invalid argument" 40 | exit 1 41 | fi 42 | docker-compose logs -f $SERVICE_NAME -------------------------------------------------------------------------------- /scripts/server: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker-compose build 6 | docker-compose up -d api 7 | 8 | echo "http://localhost:8008/docs" -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | pip install -r $(dirname $0)/../requirements_dev.txt 6 | pre-commit install 7 | docker-compose build -------------------------------------------------------------------------------- /scripts/stop: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker-compose down -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | alias dco_test='docker-compose -f docker-compose.yml -f docker-compose.test.yml -p test' 4 | 5 | cd $(dirname $0)/.. 6 | docker-compose build $1 api # $1 for --no-cache parameter passed by scripts/cibuild 7 | dco_test build $1 8 | dco_test up -d 9 | dco_test exec -T api python -m pytest oaff 10 | EXIT_CODE=$? 11 | dco_test down 12 | 13 | exit $EXIT_CODE 14 | -------------------------------------------------------------------------------- /scripts/update: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker-compose build -------------------------------------------------------------------------------- /scripts/update_i18n: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd $(dirname $0)/.. 6 | pybabel extract --mapping babel.cfg --output-file=oaff/app/oaff/app/i18n/translations.pot . 7 | pybabel update --domain=translations --input-file=oaff/app/oaff/app/i18n/translations.pot --output-dir=oaff/app/oaff/app/i18n/locale 8 | pybabel compile --domain=translations --directory=oaff/app/oaff/app/i18n/locale --use-fuzzy --------------------------------------------------------------------------------