├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── cicd.yml │ ├── deploy_mkdocs.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── assets ├── STAC-01.png ├── VITO.png ├── am-logo-black.png ├── elasticsearch.png ├── fastapi.svg ├── hh-logo-blue.png ├── opensearch.svg ├── python.png └── sfeos.png ├── compose.docs.yml ├── compose.yml ├── data_loader.py ├── dockerfiles ├── Dockerfile.ci.es ├── Dockerfile.ci.os ├── Dockerfile.deploy.es ├── Dockerfile.deploy.os ├── Dockerfile.dev.es ├── Dockerfile.dev.os └── Dockerfile.docs ├── docs ├── mkdocs.yml └── src │ ├── aggregation.md │ ├── contributing.md │ ├── index.md │ ├── release-notes.md │ ├── stylesheets │ └── extra.css │ └── tips-and-tricks.md ├── elasticsearch └── config │ └── elasticsearch.yml ├── examples ├── README.md ├── auth │ ├── README.md │ ├── compose.basic_auth.yml │ ├── compose.oauth2.yml │ ├── compose.route_dependencies.yml │ ├── keycloak │ │ └── stac-realm.json │ └── route_dependencies │ │ └── route_dependencies.json ├── pip_docker │ ├── Dockerfile │ ├── compose.yml │ ├── elasticsearch │ │ └── config │ │ │ └── elasticsearch.yml │ └── scripts │ │ └── wait-for-it-es.sh ├── postman_collections │ └── stac-fastapi-elasticsearch.postman_collection.json └── rate_limit │ └── compose.rate_limit.yml ├── opensearch └── config │ └── opensearch.yml ├── sample_data ├── collection.json └── sentinel-s2-l2a-cogs_0_100.json ├── scripts └── wait-for-it-es.sh ├── stac_fastapi ├── core │ ├── README.md │ ├── pytest.ini │ ├── setup.cfg │ ├── setup.py │ └── stac_fastapi │ │ └── core │ │ ├── __init__.py │ │ ├── base_database_logic.py │ │ ├── base_settings.py │ │ ├── basic_auth.py │ │ ├── core.py │ │ ├── datetime_utils.py │ │ ├── extensions │ │ ├── __init__.py │ │ ├── aggregation.py │ │ ├── fields.py │ │ ├── filter.py │ │ └── query.py │ │ ├── models │ │ ├── __init__.py │ │ ├── links.py │ │ └── search.py │ │ ├── rate_limit.py │ │ ├── route_dependencies.py │ │ ├── serializers.py │ │ ├── session.py │ │ ├── utilities.py │ │ └── version.py ├── elasticsearch │ ├── README.md │ ├── pytest.ini │ ├── setup.cfg │ ├── setup.py │ └── stac_fastapi │ │ └── elasticsearch │ │ ├── __init__.py │ │ ├── app.py │ │ ├── config.py │ │ ├── database_logic.py │ │ └── version.py ├── opensearch │ ├── README.md │ ├── pytest.ini │ ├── setup.cfg │ ├── setup.py │ └── stac_fastapi │ │ └── opensearch │ │ ├── __init__.py │ │ ├── app.py │ │ ├── config.py │ │ ├── database_logic.py │ │ └── version.py ├── sfeos_helpers │ ├── README.md │ ├── setup.cfg │ ├── setup.py │ └── stac_fastapi │ │ └── sfeos_helpers │ │ ├── aggregation │ │ ├── README.md │ │ ├── __init__.py │ │ ├── client.py │ │ └── format.py │ │ ├── database │ │ ├── README.md │ │ ├── __init__.py │ │ ├── datetime.py │ │ ├── document.py │ │ ├── index.py │ │ ├── mapping.py │ │ ├── query.py │ │ └── utils.py │ │ ├── filter │ │ ├── README.md │ │ ├── __init__.py │ │ ├── client.py │ │ ├── cql2.py │ │ └── transform.py │ │ ├── mappings.py │ │ └── version.py └── tests │ ├── __init__.py │ ├── api │ ├── __init__.py │ └── test_api.py │ ├── basic_auth │ └── test_basic_auth.py │ ├── clients │ ├── __init__.py │ └── test_es_os.py │ ├── config │ └── test_config_settings.py │ ├── conftest.py │ ├── data │ ├── test_collection.json │ └── test_item.json │ ├── database │ ├── __init__.py │ └── test_database.py │ ├── extensions │ ├── __init__.py │ ├── cql2 │ │ ├── example01.json │ │ ├── example04.json │ │ ├── example05a.json │ │ ├── example06b.json │ │ ├── example08.json │ │ ├── example09.json │ │ ├── example1.json │ │ ├── example10.json │ │ ├── example14.json │ │ ├── example15.json │ │ ├── example17.json │ │ ├── example18.json │ │ ├── example19.json │ │ ├── example2.json │ │ ├── example20.json │ │ ├── example21.json │ │ └── example22.json │ ├── test_aggregation.py │ ├── test_bulk_transactions.py │ ├── test_cql2_like_to_es.py │ └── test_filter.py │ ├── rate_limit │ └── test_rate_limit.py │ ├── resources │ ├── __init__.py │ ├── test_collection.py │ ├── test_conformance.py │ ├── test_item.py │ └── test_mgmt.py │ └── route_dependencies │ ├── __init__.py │ └── test_route_dependencies.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | elasticsearch/snapshots/ 2 | .venv 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: pip 12 | directory: "/stac_fastapi/elasticsearch" 13 | schedule: 14 | interval: daily 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Related Issue(s):** 2 | 3 | - # 4 | 5 | **Description:** 6 | 7 | 8 | **PR Checklist:** 9 | 10 | - [ ] Code is formatted and linted (run `pre-commit run --all-files`) 11 | - [ ] Tests pass (run `make test`) 12 | - [ ] Documentation has been updated to reflect changes, if applicable 13 | - [ ] Changes are added to the changelog -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: sfeos 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | - features/** 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 20 16 | 17 | services: 18 | elasticsearch_8_svc: 19 | image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 20 | env: 21 | cluster.name: stac-cluster 22 | node.name: es01 23 | network.host: 0.0.0.0 24 | transport.host: 0.0.0.0 25 | discovery.type: single-node 26 | http.port: 9200 27 | xpack.license.self_generated.type: basic 28 | xpack.security.enabled: false 29 | xpack.security.transport.ssl.enabled: false 30 | ES_JAVA_OPTS: -Xms512m -Xmx1g 31 | ports: 32 | - 9200:9200 33 | 34 | elasticsearch_7_svc: 35 | image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1 36 | env: 37 | cluster.name: stac-cluster 38 | node.name: es01 39 | network.host: 0.0.0.0 40 | transport.host: 0.0.0.0 41 | discovery.type: single-node 42 | http.port: 9400 43 | xpack.license.self_generated.type: basic 44 | xpack.security.enabled: false 45 | xpack.security.transport.ssl.enabled: false 46 | ES_JAVA_OPTS: -Xms512m -Xmx1g 47 | ports: 48 | - 9400:9400 49 | 50 | opensearch_2_11: 51 | image: opensearchproject/opensearch:2.11.1 52 | env: 53 | cluster.name: stac-cluster 54 | node.name: os01 55 | network.host: 0.0.0.0 56 | transport.host: 0.0.0.0 57 | discovery.type: single-node 58 | http.port: 9202 59 | http.cors.enabled: true 60 | plugins.security.disabled: true 61 | plugins.security.ssl.http.enabled: true 62 | OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m 63 | ports: 64 | - 9202:9202 65 | 66 | strategy: 67 | matrix: 68 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13"] 69 | backend: [ "elasticsearch7", "elasticsearch8", "opensearch"] 70 | 71 | name: Python ${{ matrix.python-version }} testing with ${{ matrix.backend }} 72 | 73 | steps: 74 | - name: Check out repository code 75 | uses: actions/checkout@v4 76 | 77 | - name: Setup Python 78 | uses: actions/setup-python@v5 79 | with: 80 | python-version: ${{ matrix.python-version }} 81 | cache: 'pip' 82 | cache-dependency-path: | 83 | **/setup.py 84 | 85 | - name: Lint code 86 | if: ${{ matrix.python-version == 3.11 }} 87 | run: | 88 | python -m pip install pre-commit 89 | pre-commit run --all-files 90 | 91 | - name: Install pipenv 92 | run: | 93 | python -m pip install --upgrade pipenv wheel 94 | 95 | - name: Install core library stac-fastapi 96 | run: | 97 | pip install ./stac_fastapi/core 98 | 99 | - name: Install helpers library stac-fastapi 100 | run: | 101 | pip install ./stac_fastapi/sfeos_helpers 102 | 103 | - name: Install elasticsearch stac-fastapi 104 | run: | 105 | pip install ./stac_fastapi/elasticsearch[dev,server] 106 | 107 | - name: Install opensearch stac-fastapi 108 | run: | 109 | pip install ./stac_fastapi/opensearch[dev,server] 110 | 111 | - name: Install pytest-timeout 112 | run: | 113 | pip install pytest-timeout 114 | 115 | - name: Run test suite 116 | run: | 117 | pipenv run pytest -svvv --timeout=300 118 | env: 119 | ENVIRONMENT: testing 120 | ES_PORT: ${{ matrix.backend == 'elasticsearch7' && '9400' || matrix.backend == 'elasticsearch8' && '9200' || '9202' }} 121 | ES_HOST: 172.17.0.1 122 | ES_USE_SSL: false 123 | ES_VERIFY_CERTS: false 124 | BACKEND: ${{ matrix.backend == 'elasticsearch7' && 'elasticsearch' || matrix.backend == 'elasticsearch8' && 'elasticsearch' || 'opensearch' }} 125 | -------------------------------------------------------------------------------- /.github/workflows/deploy_mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | # Rebuild website when docs have changed or code has changed 9 | - "README.md" 10 | - "docs/**" 11 | - "**.py" 12 | workflow_dispatch: 13 | 14 | jobs: 15 | build: 16 | name: Deploy docs 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout main 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Python 3.9 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: 3.9 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install \ 32 | stac_fastapi/core \ 33 | stac_fastapi/sfeos_helpers \ 34 | stac_fastapi/elasticsearch[docs] \ 35 | stac_fastapi/opensearch \ 36 | 37 | - name: update API docs 38 | run: | 39 | pdocs as_markdown \ 40 | --output_dir docs/src/api/ \ 41 | --exclude_source \ 42 | --overwrite \ 43 | stac_fastapi 44 | env: 45 | APP_PORT: 8082 46 | ES_PORT: 9202 47 | 48 | - name: Deploy docs 49 | run: mkdocs gh-deploy --force -f docs/mkdocs.yml -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" # Triggers when a tag like 'v3.2.0' is pushed 7 | 8 | jobs: 9 | build-and-publish-pypi: 10 | name: Build and Publish Packages 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 3.10 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.10" 20 | 21 | - name: Install build dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | 26 | - name: Build and publish stac-fastapi-core 27 | working-directory: stac_fastapi/core 28 | env: 29 | TWINE_USERNAME: "__token__" 30 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 31 | run: | 32 | # Build package 33 | python setup.py sdist bdist_wheel 34 | 35 | # Publish to PyPI 36 | twine upload dist/* 37 | 38 | - name: Build and publish sfeos_helpers 39 | working-directory: stac_fastapi/sfeos_helpers 40 | env: 41 | TWINE_USERNAME: "__token__" 42 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 43 | run: | 44 | # Build package 45 | python setup.py sdist bdist_wheel 46 | 47 | # Publish to PyPI 48 | twine upload dist/* 49 | 50 | - name: Build and publish stac-fastapi-elasticsearch 51 | working-directory: stac_fastapi/elasticsearch 52 | env: 53 | TWINE_USERNAME: "__token__" 54 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 55 | run: | 56 | # Build package 57 | python setup.py sdist bdist_wheel 58 | 59 | # Publish to PyPI 60 | twine upload dist/* 61 | 62 | - name: Build and publish stac-fastapi-opensearch 63 | working-directory: stac_fastapi/opensearch 64 | env: 65 | TWINE_USERNAME: "__token__" 66 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 67 | run: | 68 | # Build package 69 | python setup.py sdist bdist_wheel 70 | 71 | # Publish to PyPI 72 | twine upload dist/* 73 | 74 | build-and-push-images: 75 | name: Build and Push Docker Images 76 | runs-on: ubuntu-latest 77 | permissions: 78 | contents: read 79 | packages: write 80 | 81 | steps: 82 | - name: Checkout repository 83 | uses: actions/checkout@v4 84 | 85 | - name: Set up QEMU 86 | uses: docker/setup-qemu-action@v3 87 | 88 | - name: Set up Docker Buildx 89 | uses: docker/setup-buildx-action@v3 90 | 91 | - name: Log in to GitHub Container Registry 92 | uses: docker/login-action@v3 93 | with: 94 | registry: ghcr.io 95 | username: ${{ github.actor }} 96 | password: ${{ secrets.GITHUB_TOKEN }} 97 | 98 | - name: Extract metadata for Elasticsearch image 99 | id: meta-es 100 | uses: docker/metadata-action@v5 101 | with: 102 | images: ghcr.io/${{ github.repository_owner }}/stac-fastapi-es 103 | tags: | 104 | type=raw,value=latest 105 | type=ref,event=tag 106 | 107 | - name: Push Elasticsearch image 108 | uses: docker/build-push-action@v6 109 | with: 110 | context: . 111 | file: dockerfiles/Dockerfile.ci.es 112 | platforms: linux/amd64,linux/arm64 113 | push: true 114 | tags: ${{ steps.meta-es.outputs.tags }} 115 | labels: ${{ steps.meta-es.outputs.labels }} 116 | cache-from: type=gha 117 | cache-to: type=gha,mode=max 118 | 119 | - name: Extract metadata for OpenSearch image 120 | id: meta-os 121 | uses: docker/metadata-action@v5 122 | with: 123 | images: ghcr.io/${{ github.repository_owner }}/stac-fastapi-os 124 | tags: | 125 | type=raw,value=latest 126 | type=ref,event=tag 127 | 128 | - name: Push OpenSearch image 129 | uses: docker/build-push-action@v6 130 | with: 131 | context: . 132 | file: dockerfiles/Dockerfile.ci.os 133 | platforms: linux/amd64,linux/arm64 134 | push: true 135 | tags: ${{ steps.meta-os.outputs.tags }} 136 | labels: ${{ steps.meta-os.outputs.labels }} 137 | cache-from: type=gha 138 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | elasticsearch/snapshots/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | /esdata 134 | 135 | # Virtualenv 136 | venv 137 | 138 | /docs/src/api/* 139 | 140 | .DS_Store 141 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 5.12.0 4 | hooks: 5 | - id: isort 6 | - repo: https://github.com/psf/black 7 | rev: 22.10.0 8 | hooks: 9 | - id: black 10 | args: [ '--safe' ] 11 | - repo: https://github.com/pycqa/flake8 12 | rev: 6.0.0 13 | hooks: 14 | - id: flake8 15 | args: [ 16 | # E501 let black handle all line length decisions 17 | # W503 black conflicts with "line break before operator" rule 18 | # E203 black conflicts with "whitespace before ':'" rule 19 | '--ignore=E501,W503,E203,C901,E231' ] 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v0.991 22 | hooks: 23 | - id: mypy 24 | exclude: /tests/ 25 | # --strict 26 | args: [ 27 | --no-strict-optional, 28 | --ignore-missing-imports, 29 | --implicit-reexport, 30 | --explicit-package-bases, 31 | ] 32 | additional_dependencies: [ 33 | "types-attrs", 34 | "types-requests" 35 | ] 36 | - repo: https://github.com/PyCQA/pydocstyle 37 | rev: 6.1.1 38 | hooks: 39 | - id: pydocstyle 40 | exclude: '.*(test|alembic|scripts).*' 41 | #args: [ 42 | # Don't require docstrings for tests 43 | #'--match=(?!test|alembic|scripts).*\.py', 44 | #] -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Issues and pull requests are more than welcome. 4 | 5 | 6 | ### Development Environment Setup 7 | 8 | To install the classes in your local Python env, run: 9 | 10 | ```shell 11 | pip install -e 'stac_fastapi/elasticsearch[dev]' 12 | ``` 13 | 14 | or 15 | 16 | ```shell 17 | pip install -e 'stac_fastapi/opensearch[dev]' 18 | ``` 19 | 20 | ### Pre-commit 21 | 22 | Install [pre-commit](https://pre-commit.com/#install). 23 | 24 | Prior to commit, run: 25 | 26 | ```shell 27 | pre-commit install 28 | pre-commit run --all-files 29 | ``` 30 | 31 | ### Testing 32 | 33 | ```shell 34 | make test 35 | ``` 36 | Test against OpenSearch only 37 | 38 | ```shell 39 | make test-opensearch 40 | ``` 41 | 42 | Test against Elasticsearch only 43 | 44 | ```shell 45 | make test-elasticsearch 46 | ``` 47 | 48 | ### Docs 49 | 50 | ```shell 51 | make docs 52 | ``` 53 | 54 | Hot-reloading docs locally: 55 | 56 | ```shell 57 | mkdocs serve -f docs/mkdocs.yml 58 | ``` 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jonathan Healy 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!make 2 | APP_HOST ?= 0.0.0.0 3 | EXTERNAL_APP_PORT ?= 8080 4 | 5 | ES_APP_PORT ?= 8080 6 | ES_HOST ?= docker.for.mac.localhost 7 | ES_PORT ?= 9200 8 | 9 | OS_APP_PORT ?= 8082 10 | OS_HOST ?= docker.for.mac.localhost 11 | OS_PORT ?= 9202 12 | 13 | run_es = docker compose \ 14 | run \ 15 | -p ${EXTERNAL_APP_PORT}:${ES_APP_PORT} \ 16 | -e PY_IGNORE_IMPORTMISMATCH=1 \ 17 | -e APP_HOST=${APP_HOST} \ 18 | -e APP_PORT=${ES_APP_PORT} \ 19 | app-elasticsearch 20 | 21 | run_os = docker compose \ 22 | run \ 23 | -p ${EXTERNAL_APP_PORT}:${OS_APP_PORT} \ 24 | -e PY_IGNORE_IMPORTMISMATCH=1 \ 25 | -e APP_HOST=${APP_HOST} \ 26 | -e APP_PORT=${OS_APP_PORT} \ 27 | app-opensearch 28 | 29 | .PHONY: image-deploy-es 30 | image-deploy-es: 31 | docker build -f dockerfiles/Dockerfile.dev.es -t stac-fastapi-elasticsearch:latest . 32 | 33 | .PHONY: image-deploy-os 34 | image-deploy-os: 35 | docker build -f dockerfiles/Dockerfile.dev.os -t stac-fastapi-opensearch:latest . 36 | 37 | .PHONY: run-deploy-locally 38 | run-deploy-locally: 39 | docker run -it -p 8080:8080 \ 40 | -e ES_HOST=${ES_HOST} \ 41 | -e ES_PORT=${ES_PORT} \ 42 | -e ES_USER=${ES_USER} \ 43 | -e ES_PASS=${ES_PASS} \ 44 | stac-fastapi-elasticsearch:latest 45 | 46 | .PHONY: image-dev 47 | image-dev: 48 | docker compose build 49 | 50 | .PHONY: docker-run-es 51 | docker-run-es: image-dev 52 | $(run_es) 53 | 54 | .PHONY: docker-run-os 55 | docker-run-os: image-dev 56 | $(run_os) 57 | 58 | .PHONY: docker-shell-es 59 | docker-shell-es: 60 | $(run_es) /bin/bash 61 | 62 | .PHONY: docker-shell-os 63 | docker-shell-os: 64 | $(run_os) /bin/bash 65 | 66 | .PHONY: test-elasticsearch 67 | test-elasticsearch: 68 | -$(run_es) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh elasticsearch:9200 && cd stac_fastapi/tests/ && pytest' 69 | docker compose down 70 | 71 | .PHONY: test-opensearch 72 | test-opensearch: 73 | -$(run_os) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh opensearch:9202 && cd stac_fastapi/tests/ && pytest' 74 | docker compose down 75 | 76 | .PHONY: test 77 | test: 78 | -$(run_es) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh elasticsearch:9200 && cd stac_fastapi/tests/ && pytest --cov=stac_fastapi --cov-report=term-missing' 79 | docker compose down 80 | 81 | -$(run_os) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh opensearch:9202 && cd stac_fastapi/tests/ && pytest --cov=stac_fastapi --cov-report=term-missing' 82 | docker compose down 83 | 84 | .PHONY: run-database-es 85 | run-database-es: 86 | docker compose run --rm elasticsearch 87 | 88 | .PHONY: run-database-os 89 | run-database-os: 90 | docker compose run --rm opensearch 91 | 92 | .PHONY: pybase-install 93 | pybase-install: 94 | pip install wheel && \ 95 | pip install -e ./stac_fastapi/api[dev] && \ 96 | pip install -e ./stac_fastapi/types[dev] && \ 97 | pip install -e ./stac_fastapi/extensions[dev] && \ 98 | pip install -e ./stac_fastapi/core && \ 99 | pip install -e ./stac_fastapi/sfeos_helpers 100 | 101 | .PHONY: install-es 102 | install-es: pybase-install 103 | pip install -e ./stac_fastapi/elasticsearch[dev,server] 104 | 105 | .PHONY: install-os 106 | install-os: pybase-install 107 | pip install -e ./stac_fastapi/opensearch[dev,server] 108 | 109 | .PHONY: docs-image 110 | docs-image: 111 | docker compose -f compose.docs.yml \ 112 | build 113 | 114 | .PHONY: docs 115 | docs: docs-image 116 | docker compose -f compose.docs.yml \ 117 | run docs -------------------------------------------------------------------------------- /assets/STAC-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/assets/STAC-01.png -------------------------------------------------------------------------------- /assets/VITO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/assets/VITO.png -------------------------------------------------------------------------------- /assets/am-logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/assets/am-logo-black.png -------------------------------------------------------------------------------- /assets/elasticsearch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/assets/elasticsearch.png -------------------------------------------------------------------------------- /assets/fastapi.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/hh-logo-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/assets/hh-logo-blue.png -------------------------------------------------------------------------------- /assets/opensearch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | OpenSearch S logo 4 | Search engine software fork of Elasticsearch 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/assets/python.png -------------------------------------------------------------------------------- /assets/sfeos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/assets/sfeos.png -------------------------------------------------------------------------------- /compose.docs.yml: -------------------------------------------------------------------------------- 1 | services: 2 | docs: 3 | container_name: stac-fastapi-docs-dev 4 | build: 5 | context: . 6 | dockerfile: dockerfiles/Dockerfile.docs 7 | platform: linux/amd64 8 | environment: 9 | - APP_PORT=8082 10 | - ES_PORT=9202 11 | volumes: 12 | - .:/opt/src -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app-elasticsearch: 3 | container_name: stac-fastapi-es 4 | image: stac-utils/stac-fastapi-es 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: dockerfiles/Dockerfile.dev.es 9 | environment: 10 | - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch 11 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend 12 | - STAC_FASTAPI_VERSION=5.0.0a1 13 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-elasticsearch 14 | - APP_HOST=0.0.0.0 15 | - APP_PORT=8080 16 | - RELOAD=true 17 | - ENVIRONMENT=local 18 | - WEB_CONCURRENCY=10 19 | - ES_HOST=elasticsearch 20 | - ES_PORT=9200 21 | - ES_USE_SSL=false 22 | - ES_VERIFY_CERTS=false 23 | - BACKEND=elasticsearch 24 | ports: 25 | - "8080:8080" 26 | volumes: 27 | - ./stac_fastapi:/app/stac_fastapi 28 | - ./scripts:/app/scripts 29 | - ./esdata:/usr/share/elasticsearch/data 30 | depends_on: 31 | - elasticsearch 32 | command: 33 | bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app" 34 | 35 | app-opensearch: 36 | container_name: stac-fastapi-os 37 | image: stac-utils/stac-fastapi-os 38 | restart: always 39 | build: 40 | context: . 41 | dockerfile: dockerfiles/Dockerfile.dev.os 42 | environment: 43 | - STAC_FASTAPI_TITLE=stac-fastapi-opensearch 44 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend 45 | - STAC_FASTAPI_VERSION=5.0.0a1 46 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-opensearch 47 | - APP_HOST=0.0.0.0 48 | - APP_PORT=8082 49 | - RELOAD=true 50 | - ENVIRONMENT=local 51 | - WEB_CONCURRENCY=10 52 | - ES_HOST=opensearch 53 | - ES_PORT=9202 54 | - ES_USE_SSL=false 55 | - ES_VERIFY_CERTS=false 56 | - BACKEND=opensearch 57 | - STAC_FASTAPI_RATE_LIMIT=200/minute 58 | ports: 59 | - "8082:8082" 60 | volumes: 61 | - ./stac_fastapi:/app/stac_fastapi 62 | - ./scripts:/app/scripts 63 | - ./osdata:/usr/share/opensearch/data 64 | depends_on: 65 | - opensearch 66 | command: 67 | bash -c "./scripts/wait-for-it-es.sh os-container:9202 && python -m stac_fastapi.opensearch.app" 68 | 69 | elasticsearch: 70 | container_name: es-container 71 | image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0} 72 | hostname: elasticsearch 73 | environment: 74 | ES_JAVA_OPTS: -Xms512m -Xmx1g 75 | volumes: 76 | - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 77 | - ./elasticsearch/snapshots:/usr/share/elasticsearch/snapshots 78 | ports: 79 | - "9200:9200" 80 | 81 | opensearch: 82 | container_name: os-container 83 | image: opensearchproject/opensearch:${OPENSEARCH_VERSION:-2.11.1} 84 | hostname: opensearch 85 | environment: 86 | - discovery.type=single-node 87 | - plugins.security.disabled=true 88 | - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m 89 | volumes: 90 | - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml 91 | - ./opensearch/snapshots:/usr/share/opensearch/snapshots 92 | ports: 93 | - "9202:9202" 94 | -------------------------------------------------------------------------------- /data_loader.py: -------------------------------------------------------------------------------- 1 | """Data Loader CLI STAC_API Ingestion Tool.""" 2 | import json 3 | import os 4 | 5 | import click 6 | import requests 7 | 8 | 9 | def load_data(data_dir, filename): 10 | """Load json data from a file within the specified data directory.""" 11 | filepath = os.path.join(data_dir, filename) 12 | if not os.path.exists(filepath): 13 | click.secho(f"File not found: {filepath}", fg="red", err=True) 14 | raise click.Abort() 15 | with open(filepath) as file: 16 | return json.load(file) 17 | 18 | 19 | def load_collection(base_url, collection_id, data_dir): 20 | """Load a STAC collection into the database.""" 21 | collection = load_data(data_dir, "collection.json") 22 | collection["id"] = collection_id 23 | try: 24 | resp = requests.post(f"{base_url}/collections", json=collection) 25 | if resp.status_code == 200 or resp.status_code == 201: 26 | click.echo(f"Status code: {resp.status_code}") 27 | click.echo(f"Added collection: {collection['id']}") 28 | elif resp.status_code == 409: 29 | click.echo(f"Status code: {resp.status_code}") 30 | click.echo(f"Collection: {collection['id']} already exists") 31 | else: 32 | click.echo(f"Status code: {resp.status_code}") 33 | click.echo( 34 | f"Error writing {collection['id']} collection. Message: {resp.text}" 35 | ) 36 | except requests.ConnectionError: 37 | click.secho("Failed to connect", fg="red", err=True) 38 | 39 | 40 | def load_items(base_url, collection_id, use_bulk, data_dir): 41 | """Load STAC items into the database based on the method selected.""" 42 | # Attempt to dynamically find a suitable feature collection file 43 | feature_files = [ 44 | file 45 | for file in os.listdir(data_dir) 46 | if file.endswith(".json") and file != "collection.json" 47 | ] 48 | if not feature_files: 49 | click.secho( 50 | "No feature collection files found in the specified directory.", 51 | fg="red", 52 | err=True, 53 | ) 54 | raise click.Abort() 55 | feature_collection_file = feature_files[ 56 | 0 57 | ] # Use the first found feature collection file 58 | feature_collection = load_data(data_dir, feature_collection_file) 59 | 60 | load_collection(base_url, collection_id, data_dir) 61 | if use_bulk: 62 | load_items_bulk_insert(base_url, collection_id, feature_collection, data_dir) 63 | else: 64 | load_items_one_by_one(base_url, collection_id, feature_collection, data_dir) 65 | 66 | 67 | def load_items_one_by_one(base_url, collection_id, feature_collection, data_dir): 68 | """Load STAC items into the database one by one.""" 69 | for feature in feature_collection["features"]: 70 | try: 71 | feature["collection"] = collection_id 72 | resp = requests.post( 73 | f"{base_url}/collections/{collection_id}/items", json=feature 74 | ) 75 | if resp.status_code == 200: 76 | click.echo(f"Status code: {resp.status_code}") 77 | click.echo(f"Added item: {feature['id']}") 78 | elif resp.status_code == 409: 79 | click.echo(f"Status code: {resp.status_code}") 80 | click.echo(f"Item: {feature['id']} already exists") 81 | except requests.ConnectionError: 82 | click.secho("Failed to connect", fg="red", err=True) 83 | 84 | 85 | def load_items_bulk_insert(base_url, collection_id, feature_collection, data_dir): 86 | """Load STAC items into the database via bulk insert.""" 87 | try: 88 | for i, _ in enumerate(feature_collection["features"]): 89 | feature_collection["features"][i]["collection"] = collection_id 90 | resp = requests.post( 91 | f"{base_url}/collections/{collection_id}/items", json=feature_collection 92 | ) 93 | if resp.status_code == 200: 94 | click.echo(f"Status code: {resp.status_code}") 95 | click.echo("Bulk inserted items successfully.") 96 | elif resp.status_code == 204: 97 | click.echo(f"Status code: {resp.status_code}") 98 | click.echo("Bulk update successful, no content returned.") 99 | elif resp.status_code == 409: 100 | click.echo(f"Status code: {resp.status_code}") 101 | click.echo("Conflict detected, some items might already exist.") 102 | except requests.ConnectionError: 103 | click.secho("Failed to connect", fg="red", err=True) 104 | 105 | 106 | @click.command() 107 | @click.option("--base-url", required=True, help="Base URL of the STAC API") 108 | @click.option( 109 | "--collection-id", 110 | default="test-collection", 111 | help="ID of the collection to which items are added", 112 | ) 113 | @click.option("--use-bulk", is_flag=True, help="Use bulk insert method for items") 114 | @click.option( 115 | "--data-dir", 116 | type=click.Path(exists=True), 117 | default="sample_data/", 118 | help="Directory containing collection.json and feature collection file", 119 | ) 120 | def main(base_url, collection_id, use_bulk, data_dir): 121 | """Load STAC items into the database.""" 122 | load_items(base_url, collection_id, use_bulk, data_dir) 123 | 124 | 125 | if __name__ == "__main__": 126 | main() 127 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.ci.es: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y --no-install-recommends \ 7 | gcc \ 8 | curl \ 9 | && apt-get clean && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | COPY . /app/ 13 | 14 | RUN pip3 install --no-cache-dir -e ./stac_fastapi/core && \ 15 | pip3 install --no-cache-dir -e ./stac_fastapi/sfeos_helpers && \ 16 | pip3 install --no-cache-dir ./stac_fastapi/elasticsearch[server] 17 | 18 | USER root 19 | 20 | CMD ["python", "-m", "stac_fastapi.elasticsearch.app"] -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.ci.os: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y --no-install-recommends \ 7 | gcc \ 8 | curl \ 9 | && apt-get clean && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | COPY . /app/ 13 | 14 | RUN pip3 install --no-cache-dir -e ./stac_fastapi/core && \ 15 | pip3 install --no-cache-dir -e ./stac_fastapi/sfeos_helpers && \ 16 | pip3 install --no-cache-dir ./stac_fastapi/opensearch[server] 17 | 18 | USER root 19 | 20 | CMD ["python", "-m", "stac_fastapi.opensearch.app"] -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.deploy.es: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | RUN apt-get update && \ 4 | apt-get -y upgrade && \ 5 | apt-get -y install gcc && \ 6 | apt-get clean && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt 10 | 11 | WORKDIR /app 12 | 13 | COPY . /app 14 | 15 | RUN pip install --no-cache-dir -e ./stac_fastapi/core 16 | RUN pip install --no-cache-dir -e ./stac_fastapi/sfeos_helpers 17 | RUN pip install --no-cache-dir ./stac_fastapi/elasticsearch[server] 18 | 19 | EXPOSE 8080 20 | 21 | CMD ["uvicorn", "stac_fastapi.elasticsearch.app:app", "--host", "0.0.0.0", "--port", "8080"] 22 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.deploy.os: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | RUN apt-get update && \ 4 | apt-get -y upgrade && \ 5 | apt-get -y install gcc && \ 6 | apt-get clean && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt 10 | 11 | WORKDIR /app 12 | 13 | COPY . /app 14 | 15 | RUN pip install --no-cache-dir -e ./stac_fastapi/core 16 | RUN pip install --no-cache-dir -e ./stac_fastapi/sfeos_helpers 17 | RUN pip install --no-cache-dir ./stac_fastapi/opensearch[server] 18 | 19 | EXPOSE 8080 20 | 21 | CMD ["uvicorn", "stac_fastapi.opensearch.app:app", "--host", "0.0.0.0", "--port", "8080"] 22 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.dev.es: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | 4 | # update apt pkgs, and install build-essential for ciso8601 5 | RUN apt-get update && \ 6 | apt-get -y upgrade && \ 7 | apt-get install -y build-essential git && \ 8 | apt-get clean && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # update certs used by Requests 12 | ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt 13 | 14 | WORKDIR /app 15 | 16 | COPY . /app 17 | 18 | RUN pip install --no-cache-dir -e ./stac_fastapi/core 19 | RUN pip install --no-cache-dir -e ./stac_fastapi/sfeos_helpers 20 | RUN pip install --no-cache-dir -e ./stac_fastapi/elasticsearch[dev,server] 21 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.dev.os: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | 4 | # update apt pkgs, and install build-essential for ciso8601 5 | RUN apt-get update && \ 6 | apt-get -y upgrade && \ 7 | apt-get install -y build-essential && \ 8 | apt-get clean && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # update certs used by Requests 12 | ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt 13 | 14 | WORKDIR /app 15 | 16 | COPY . /app 17 | 18 | RUN pip install --no-cache-dir -e ./stac_fastapi/core 19 | RUN pip install --no-cache-dir -e ./stac_fastapi/sfeos_helpers 20 | RUN pip install --no-cache-dir -e ./stac_fastapi/opensearch[dev,server] 21 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.docs: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | # build-essential is required to build a wheel for ciso8601 4 | RUN apt update && apt install -y build-essential 5 | 6 | RUN python -m pip install --upgrade pip 7 | RUN python -m pip install mkdocs mkdocs-material pdocs 8 | 9 | COPY . /opt/src 10 | 11 | WORKDIR /opt/src 12 | 13 | RUN python -m pip install \ 14 | stac_fastapi/core \ 15 | stac_fastapi/sfeos_helpers \ 16 | stac_fastapi/elasticsearch \ 17 | stac_fastapi/opensearch 18 | 19 | CMD ["pdocs", \ 20 | "as_markdown", \ 21 | "--output_dir", \ 22 | "docs/src/api/", \ 23 | "--exclude_source", \ 24 | "--overwrite", \ 25 | "stac_fastapi"] -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: stac-fastapi-elasticsearch-opensearch 2 | site_description: STAC FastAPI Elasticsearch and Opensearch backends. 3 | 4 | # Repository 5 | repo_name: "stac-utils/stac-fastapi-elasticsearch-opensearch" 6 | repo_url: "https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch" 7 | edit_uri: "blob/main/docs/src/" 8 | 9 | docs_dir: 'src' 10 | site_dir: 'build' 11 | 12 | # Social links 13 | extra: 14 | social: 15 | - icon: "fontawesome/brands/github" 16 | link: "https://github.com/stac-utils" 17 | 18 | # Layout 19 | nav: 20 | - Home: "index.md" 21 | - Tips and Tricks: tips-and-tricks.md 22 | - API: 23 | - stac_fastapi.elasticsearch: 24 | - index: api/stac_fastapi/elasticsearch/index.md 25 | - app: api/stac_fastapi/elasticsearch/app.md 26 | - config: api/stac_fastapi/elasticsearch/config.md 27 | - database_logic: api/stac_fastapi/elasticsearch/database_logic.md 28 | - version: api/stac_fastapi/elasticsearch/version.md 29 | - stac_fastapi.opensearch: 30 | - index: api/stac_fastapi/opensearch/index.md 31 | - app: api/stac_fastapi/opensearch/app.md 32 | - config: api/stac_fastapi/opensearch/config.md 33 | - database_logic: api/stac_fastapi/opensearch/database_logic.md 34 | - version: api/stac_fastapi/opensearch/version.md 35 | - sfeos_helpers: 36 | - index: api/sfeos_helpers/index.md 37 | - aggregation: 38 | - module: api/sfeos_helpers/aggregation/index.md 39 | - client: api/sfeos_helpers/aggregation/client.md 40 | - format: api/sfeos_helpers/aggregation/format.md 41 | - database: 42 | - module: api/sfeos_helpers/database/index.md 43 | - datetime: api/sfeos_helpers/database/datetime.md 44 | - document: api/sfeos_helpers/database/document.md 45 | - index: api/sfeos_helpers/database/index.md 46 | - mapping: api/sfeos_helpers/database/mapping.md 47 | - query: api/sfeos_helpers/database/query.md 48 | - utils: api/sfeos_helpers/database/utils.md 49 | - filter: 50 | - module: api/sfeos_helpers/filter/index.md 51 | - client: api/sfeos_helpers/filter/client.md 52 | - cql2: api/sfeos_helpers/filter/cql2.md 53 | - transform: api/sfeos_helpers/filter/transform.md 54 | - mappings: api/sfeos_helpers/mappings.md 55 | - version: api/sfeos_helpers/version.md 56 | - stac_fastapi.core: 57 | - index: api/stac_fastapi/core/index.md 58 | - base_database_logic: api/stac_fastapi/core/base_database_logic.md 59 | - base_settings: api/stac_fastapi/core/base_settings.md 60 | - basic_auth: api/stac_fastapi/core/basic_auth.md 61 | - core: api/stac_fastapi/core/core.md 62 | - datetime_utils: api/stac_fastapi/core/datetime_utils.md 63 | - extensions: 64 | - module: api/stac_fastapi/core/extensions/index.md 65 | - aggregation: api/stac_fastapi/core/extensions/aggregation.md 66 | - fields: api/stac_fastapi/core/extensions/fields.md 67 | - filter: api/stac_fastapi/core/extensions/filter.md 68 | - query: api/stac_fastapi/core/extensions/query.md 69 | - models: 70 | - module: api/stac_fastapi/core/models/index.md 71 | - links: api/stac_fastapi/core/models/links.md 72 | - search: api/stac_fastapi/core/models/search.md 73 | - rate_limit: api/stac_fastapi/core/rate_limit.md 74 | - route_dependencies: api/stac_fastapi/core/route_dependencies.md 75 | - serializers: api/stac_fastapi/core/serializers.md 76 | - session: api/stac_fastapi/core/session.md 77 | - utilities: api/stac_fastapi/core/utilities.md 78 | - version: api/stac_fastapi/core/version.md 79 | - Aggregation: "aggregation.md" 80 | - Development - Contributing: "contributing.md" 81 | - Release Notes: "release-notes.md" 82 | 83 | plugins: 84 | - search 85 | 86 | # Theme 87 | theme: 88 | icon: 89 | logo: "material/home" 90 | repo: "fontawesome/brands/github" 91 | name: "material" 92 | language: "en" 93 | font: 94 | text: "Nunito Sans" 95 | code: "Fira Code" 96 | 97 | extra_css: 98 | - stylesheets/extra.css 99 | 100 | # These extensions are chosen to be a superset of Pandoc's Markdown. 101 | # This way, I can write in Pandoc's Markdown and have it be supported here. 102 | # https://pandoc.org/MANUAL.html 103 | markdown_extensions: 104 | - admonition 105 | - attr_list 106 | - codehilite: 107 | guess_lang: false 108 | - def_list 109 | - footnotes 110 | - pymdownx.arithmatex 111 | - pymdownx.betterem 112 | - pymdownx.caret: 113 | insert: false 114 | - pymdownx.details 115 | - pymdownx.emoji 116 | - pymdownx.escapeall: 117 | hardbreak: true 118 | nbsp: true 119 | - pymdownx.magiclink: 120 | hide_protocol: true 121 | repo_url_shortener: true 122 | - pymdownx.smartsymbols 123 | - pymdownx.superfences 124 | - pymdownx.tasklist: 125 | custom_checkbox: true 126 | - pymdownx.tilde 127 | - toc: 128 | permalink: true -------------------------------------------------------------------------------- /docs/src/aggregation.md: -------------------------------------------------------------------------------- 1 | ## Aggregation 2 | 3 | Stac-fastapi-elasticsearch-opensearch supports the STAC API [Aggregation Extension](https://github.com/stac-api-extensions/aggregation). This enables aggregation of points and geometries, as well as frequency distribution aggregation of any other property including dates. Aggregations can be defined at the root Catalog level (`/aggregations`) and at the Collection level (`//aggregations`). The [Filter Extension](https://github.com/stac-api-extensions/filter) is also fully supported, enabling aggregated returns of search queries. Any query made with `/search` may also be executed with `/aggregate`, provided that the relevant aggregation fields are available, 4 | 5 | A field named `aggregations` should be added to the Collection object for the collection for which the aggregations are available, for example: 6 | 7 | Available aggregations are: 8 | 9 | - total_count (count of total items) 10 | - collection_frequency (Item `collection` field) 11 | - platform_frequency (Item.Properties.platform) 12 | - cloud_cover_frequency (Item.Properties.eo:cloud_cover) 13 | - datetime_frequency (Item.Properties.datetime, monthly interval) 14 | - datetime_min (earliest Item.Properties.datetime) 15 | - datetime_max (latest Item.Properties.datetime) 16 | - sun_elevation_frequency (Item.Properties.view:sun_elevation) 17 | - sun_azimuth_frequency (Item.Properties.view:sun_azimuth) 18 | - off_nadir_frequency (Item.Properties.view:off_nadir) 19 | - grid_code_frequency (Item.Properties.grid:code) 20 | - centroid_geohash_grid_frequency ([geohash grid](https://opensearch.org/docs/latest/aggregations/bucket/geohash-grid/) on Item.Properties.proj:centroid) 21 | - centroid_geohex_grid_frequency ([geohex grid](https://opensearch.org/docs/latest/aggregations/bucket/geohex-grid/) on Item.Properties.proj:centroid) 22 | - centroid_geotile_grid_frequency (geotile on Item.Properties.proj:centroid) 23 | - geometry_geohash_grid_frequency ([geohash grid](https://opensearch.org/docs/latest/aggregations/bucket/geohash-grid/) on Item.geometry) 24 | - geometry_geotile_grid_frequency ([geotile grid](https://opensearch.org/docs/latest/aggregations/bucket/geotile-grid/) on Item.geometry) 25 | 26 | Support for additional fields and new aggregations can be added in the [OpenSearch database_logic.py](../../stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py) and [ElasticSearch database_logic.py](../../stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py) files. 27 | 28 | ```json 29 | "aggregations": [ 30 | { 31 | "name": "total_count", 32 | "data_type": "integer" 33 | }, 34 | { 35 | "name": "datetime_max", 36 | "data_type": "datetime" 37 | }, 38 | { 39 | "name": "datetime_min", 40 | "data_type": "datetime" 41 | }, 42 | { 43 | "name": "datetime_frequency", 44 | "data_type": "frequency_distribution", 45 | "frequency_distribution_data_type": "datetime" 46 | }, 47 | { 48 | "name": "sun_elevation_frequency", 49 | "data_type": "frequency_distribution", 50 | "frequency_distribution_data_type": "numeric" 51 | }, 52 | { 53 | "name": "platform_frequency", 54 | "data_type": "frequency_distribution", 55 | "frequency_distribution_data_type": "string" 56 | }, 57 | { 58 | "name": "sun_azimuth_frequency", 59 | "data_type": "frequency_distribution", 60 | "frequency_distribution_data_type": "numeric" 61 | }, 62 | { 63 | "name": "off_nadir_frequency", 64 | "data_type": "frequency_distribution", 65 | "frequency_distribution_data_type": "numeric" 66 | }, 67 | { 68 | "name": "cloud_cover_frequency", 69 | "data_type": "frequency_distribution", 70 | "frequency_distribution_data_type": "numeric" 71 | }, 72 | { 73 | "name": "grid_code_frequency", 74 | "data_type": "frequency_distribution", 75 | "frequency_distribution_data_type": "string" 76 | }, 77 | { 78 | "name": "centroid_geohash_grid_frequency", 79 | "data_type": "frequency_distribution", 80 | "frequency_distribution_data_type": "string" 81 | }, 82 | { 83 | "name": "centroid_geohex_grid_frequency", 84 | "data_type": "frequency_distribution", 85 | "frequency_distribution_data_type": "string" 86 | }, 87 | { 88 | "name": "centroid_geotile_grid_frequency", 89 | "data_type": "frequency_distribution", 90 | "frequency_distribution_data_type": "string" 91 | }, 92 | { 93 | "name": "geometry_geohash_grid_frequency", 94 | "data_type": "frequency_distribution", 95 | "frequency_distribution_data_type": "numeric" 96 | }, 97 | { 98 | "name": "geometry_geotile_grid_frequency", 99 | "data_type": "frequency_distribution", 100 | "frequency_distribution_data_type": "string" 101 | } 102 | ] 103 | ``` 104 | 105 | 106 | -------------------------------------------------------------------------------- /docs/src/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /docs/src/release-notes.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /docs/src/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: rgb(13, 118, 160); 3 | } 4 | 5 | /* Control the size of the main logo */ 6 | img[src*="sfeos.png"] { 7 | max-width: 100%; 8 | height: auto; 9 | width: auto !important; 10 | max-height: 200px; 11 | } 12 | 13 | /* Control the size of sponsor logos */ 14 | img[src*="logo"], img[src*="VITO.png"] { 15 | max-height: 60px !important; 16 | width: auto !important; 17 | height: auto !important; 18 | } 19 | 20 | /* Control the size of technology logos */ 21 | img[src*="STAC-01.png"], 22 | img[src*="python.png"], 23 | img[src*="fastapi.svg"], 24 | img[src*="elasticsearch.png"], 25 | img[src*="opensearch.svg"] { 26 | max-height: 50px !important; 27 | width: auto !important; 28 | height: auto !important; 29 | } 30 | 31 | /* Make sure all images are responsive and don't overflow */ 32 | img { 33 | max-width: 100%; 34 | height: auto; 35 | } -------------------------------------------------------------------------------- /docs/src/tips-and-tricks.md: -------------------------------------------------------------------------------- 1 | # Tips and Tricks 2 | 3 | This page contains a few 'tips and tricks' for working with **sfeos**. 4 | -------------------------------------------------------------------------------- /elasticsearch/config/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | ## Cluster Settings 2 | cluster.name: stac-cluster 3 | node.name: es01 4 | network.host: 0.0.0.0 5 | transport.host: 0.0.0.0 6 | discovery.type: single-node 7 | http.port: 9200 8 | 9 | path: 10 | repo: 11 | - /usr/share/elasticsearch/snapshots 12 | 13 | ## License 14 | xpack.license.self_generated.type: basic 15 | 16 | # Security 17 | xpack.security.enabled: false 18 | xpack.security.transport.ssl.enabled: false -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # stac-fastapi.elasticsearch (sfes) deployment examples 2 | 3 | ## Deploy sfes from pip in docker 4 | 5 | ```shell 6 | cd pip_docker 7 | docker-compose up 8 | ``` 9 | -------------------------------------------------------------------------------- /examples/auth/compose.basic_auth.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app-elasticsearch: 3 | container_name: stac-fastapi-es 4 | image: stac-utils/stac-fastapi-es 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: dockerfiles/Dockerfile.dev.es 9 | environment: 10 | - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch 11 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend 12 | - STAC_FASTAPI_VERSION=5.0.0a1 13 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-elasticsearch 14 | - APP_HOST=0.0.0.0 15 | - APP_PORT=8080 16 | - RELOAD=true 17 | - ENVIRONMENT=local 18 | - WEB_CONCURRENCY=10 19 | - ES_HOST=elasticsearch 20 | - ES_PORT=9200 21 | - ES_USE_SSL=false 22 | - ES_VERIFY_CERTS=false 23 | - BACKEND=elasticsearch 24 | - STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"*","path":"*"}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"admin","password":"admin"}]}}]},{"routes":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"reader","password":"reader"}]}}]}] 25 | ports: 26 | - "8080:8080" 27 | volumes: 28 | - ../../stac_fastapi:/app/stac_fastapi 29 | - ../../scripts:/app/scripts 30 | - ../../esdata:/usr/share/elasticsearch/data 31 | depends_on: 32 | - elasticsearch 33 | command: 34 | bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app" 35 | 36 | app-opensearch: 37 | container_name: stac-fastapi-os 38 | image: stac-utils/stac-fastapi-os 39 | restart: always 40 | build: 41 | context: . 42 | dockerfile: dockerfiles/Dockerfile.dev.os 43 | environment: 44 | - STAC_FASTAPI_TITLE=stac-fastapi-opensearch 45 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend 46 | - STAC_FASTAPI_VERSION=5.0.0a1 47 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-opensearch 48 | - APP_HOST=0.0.0.0 49 | - APP_PORT=8082 50 | - RELOAD=true 51 | - ENVIRONMENT=local 52 | - WEB_CONCURRENCY=10 53 | - ES_HOST=opensearch 54 | - ES_PORT=9202 55 | - ES_USE_SSL=false 56 | - ES_VERIFY_CERTS=false 57 | - BACKEND=opensearch 58 | - STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"*","path":"*"}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"admin","password":"admin"}]}}]},{"routes":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"reader","password":"reader"}]}}]}] 59 | ports: 60 | - "8082:8082" 61 | volumes: 62 | - ../../stac_fastapi:/app/stac_fastapi 63 | - ../../scripts:/app/scripts 64 | - ../../osdata:/usr/share/opensearch/data 65 | depends_on: 66 | - opensearch 67 | command: 68 | bash -c "./scripts/wait-for-it-es.sh os-container:9202 && python -m stac_fastapi.opensearch.app" 69 | 70 | elasticsearch: 71 | container_name: es-container 72 | image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0} 73 | hostname: elasticsearch 74 | environment: 75 | ES_JAVA_OPTS: -Xms512m -Xmx1g 76 | volumes: 77 | - ../../elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 78 | - ../../elasticsearch/snapshots:/usr/share/elasticsearch/snapshots 79 | ports: 80 | - "9200:9200" 81 | 82 | opensearch: 83 | container_name: os-container 84 | image: opensearchproject/opensearch:${OPENSEARCH_VERSION:-2.11.1} 85 | hostname: opensearch 86 | environment: 87 | - discovery.type=single-node 88 | - plugins.security.disabled=true 89 | - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m 90 | volumes: 91 | - ../../opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml 92 | - ../../opensearch/snapshots:/usr/share/opensearch/snapshots 93 | ports: 94 | - "9202:9202" 95 | -------------------------------------------------------------------------------- /examples/auth/compose.oauth2.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app-elasticsearch: 3 | container_name: stac-fastapi-es 4 | image: stac-utils/stac-fastapi-es 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: dockerfiles/Dockerfile.dev.es 9 | environment: 10 | - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch 11 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend 12 | - STAC_FASTAPI_VERSION=5.0.0a1 13 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-elasticsearch 14 | - APP_HOST=0.0.0.0 15 | - APP_PORT=8080 16 | - RELOAD=true 17 | - ENVIRONMENT=local 18 | - WEB_CONCURRENCY=10 19 | - ES_HOST=elasticsearch 20 | - ES_PORT=9200 21 | - ES_USE_SSL=false 22 | - ES_VERIFY_CERTS=false 23 | - BACKEND=elasticsearch 24 | - STAC_FASTAPI_ROUTE_DEPENDENCIES=/app/route_dependencies/route_dependencies.json 25 | ports: 26 | - "8080:8080" 27 | volumes: 28 | - ../../stac_fastapi:/app/stac_fastapi 29 | - ./route_dependencies:/app/route_dependencies 30 | - ../../scripts:/app/scripts 31 | - ../../esdata:/usr/share/elasticsearch/data 32 | depends_on: 33 | - elasticsearch 34 | command: 35 | bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app" 36 | 37 | app-opensearch: 38 | container_name: stac-fastapi-os 39 | image: stac-utils/stac-fastapi-os 40 | restart: always 41 | build: 42 | context: . 43 | dockerfile: dockerfiles/Dockerfile.dev.os 44 | environment: 45 | - STAC_FASTAPI_TITLE=stac-fastapi-opensearch 46 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend 47 | - STAC_FASTAPI_VERSION=5.0.0a1 48 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-opensearch 49 | - APP_HOST=0.0.0.0 50 | - APP_PORT=8082 51 | - RELOAD=true 52 | - ENVIRONMENT=local 53 | - WEB_CONCURRENCY=10 54 | - ES_HOST=opensearch 55 | - ES_PORT=9202 56 | - ES_USE_SSL=false 57 | - ES_VERIFY_CERTS=false 58 | - BACKEND=opensearch 59 | - STAC_FASTAPI_ROUTE_DEPENDENCIES=/app/route_dependencies/route_dependencies.json 60 | ports: 61 | - "8082:8082" 62 | volumes: 63 | - ../../stac_fastapi:/app/stac_fastapi 64 | - ../../scripts:/app/scripts 65 | - ../../osdata:/usr/share/opensearch/data 66 | depends_on: 67 | - opensearch 68 | command: 69 | bash -c "./scripts/wait-for-it-es.sh os-container:9202 && python -m stac_fastapi.opensearch.app" 70 | 71 | elasticsearch: 72 | container_name: es-container 73 | image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0} 74 | hostname: elasticsearch 75 | environment: 76 | ES_JAVA_OPTS: -Xms512m -Xmx1g 77 | volumes: 78 | - ../../elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 79 | - ../../elasticsearch/snapshots:/usr/share/elasticsearch/snapshots 80 | ports: 81 | - "9200:9200" 82 | 83 | opensearch: 84 | container_name: os-container 85 | image: opensearchproject/opensearch:${OPENSEARCH_VERSION:-2.11.1} 86 | hostname: opensearch 87 | environment: 88 | - discovery.type=single-node 89 | - plugins.security.disabled=true 90 | - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m 91 | volumes: 92 | - ../../opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml 93 | - ../../opensearch/snapshots:/usr/share/opensearch/snapshots 94 | ports: 95 | - "9202:9202" 96 | 97 | postgres: 98 | image: postgres:15 99 | container_name: postgres 100 | hostname: keycloakdb 101 | environment: 102 | - POSTGRES_DB=keycloak 103 | - POSTGRES_USER=keycloak 104 | - POSTGRES_PASSWORD=password 105 | volumes: 106 | - postgres:/var/lib/postgresql/data 107 | 108 | keycloak: 109 | image: quay.io/keycloak/keycloak:25.0.0 110 | container_name: keycloak 111 | ports: 112 | - 8083:8083 113 | environment: 114 | - KEYCLOAK_IMPORT=/tmp/keycloak-realm.json 115 | - KEYCLOAK_ADMIN=admin 116 | - KEYCLOAK_ADMIN_PASSWORD=admin 117 | - KC_HTTP_PORT=8083 118 | - KC_DB=postgres 119 | - KC_DB_URL=jdbc:postgresql://keycloakdb:5432/keycloak 120 | - KC_DB_USERNAME=keycloak 121 | - KC_DB_PASSWORD=password 122 | volumes: 123 | - ./keycloak/stac-realm.json:/opt/keycloak/data/import 124 | command: start-dev --import-realm 125 | depends_on: 126 | - postgres 127 | 128 | volumes: 129 | postgres: -------------------------------------------------------------------------------- /examples/auth/compose.route_dependencies.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app-elasticsearch: 3 | container_name: stac-fastapi-es 4 | image: stac-utils/stac-fastapi-es 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: dockerfiles/Dockerfile.dev.es 9 | environment: 10 | - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch 11 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend 12 | - STAC_FASTAPI_VERSION=5.0.0a1 13 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-elasticsearch 14 | - APP_HOST=0.0.0.0 15 | - APP_PORT=8080 16 | - RELOAD=true 17 | - ENVIRONMENT=local 18 | - WEB_CONCURRENCY=10 19 | - ES_HOST=elasticsearch 20 | - ES_PORT=9200 21 | - ES_USE_SSL=false 22 | - ES_VERIFY_CERTS=false 23 | - BACKEND=elasticsearch 24 | - STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"GET","path":"/collections"}],"dependencies":[{"method":"conftest.must_be_bob"}]}] 25 | ports: 26 | - "8080:8080" 27 | volumes: 28 | - ../../stac_fastapi:/app/stac_fastapi 29 | - ../../scripts:/app/scripts 30 | - ../../esdata:/usr/share/elasticsearch/data 31 | depends_on: 32 | - elasticsearch 33 | command: 34 | bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app" 35 | 36 | app-opensearch: 37 | container_name: stac-fastapi-os 38 | image: stac-utils/stac-fastapi-os 39 | restart: always 40 | build: 41 | context: . 42 | dockerfile: dockerfiles/Dockerfile.dev.os 43 | environment: 44 | - STAC_FASTAPI_TITLE=stac-fastapi-opensearch 45 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend 46 | - STAC_FASTAPI_VERSION=5.0.0a1 47 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-opensearch 48 | - APP_HOST=0.0.0.0 49 | - APP_PORT=8082 50 | - RELOAD=true 51 | - ENVIRONMENT=local 52 | - WEB_CONCURRENCY=10 53 | - ES_HOST=opensearch 54 | - ES_PORT=9202 55 | - ES_USE_SSL=false 56 | - ES_VERIFY_CERTS=false 57 | - BACKEND=opensearch 58 | - STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"GET","path":"/collections"}],"dependencies":[{"method":"conftest.must_be_bob"}]}] 59 | ports: 60 | - "8082:8082" 61 | volumes: 62 | - ../../stac_fastapi:/app/stac_fastapi 63 | - ../../scripts:/app/scripts 64 | - ../../osdata:/usr/share/opensearch/data 65 | depends_on: 66 | - opensearch 67 | command: 68 | bash -c "./scripts/wait-for-it-es.sh os-container:9202 && python -m stac_fastapi.opensearch.app" 69 | 70 | elasticsearch: 71 | container_name: es-container 72 | image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0} 73 | hostname: elasticsearch 74 | environment: 75 | ES_JAVA_OPTS: -Xms512m -Xmx1g 76 | volumes: 77 | - ../../elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 78 | - ../../elasticsearch/snapshots:/usr/share/elasticsearch/snapshots 79 | ports: 80 | - "9200:9200" 81 | 82 | opensearch: 83 | container_name: os-container 84 | image: opensearchproject/opensearch:${OPENSEARCH_VERSION:-2.11.1} 85 | hostname: opensearch 86 | environment: 87 | - discovery.type=single-node 88 | - plugins.security.disabled=true 89 | - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m 90 | volumes: 91 | - ../../opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml 92 | - ../../opensearch/snapshots:/usr/share/opensearch/snapshots 93 | ports: 94 | - "9202:9202" 95 | -------------------------------------------------------------------------------- /examples/auth/route_dependencies/route_dependencies.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "routes": [ 4 | { 5 | "method": "*", 6 | "path": "*" 7 | } 8 | ], 9 | "dependencies": [ 10 | { 11 | "method": "fastapi.security.OAuth2PasswordBearer", 12 | "kwargs": { 13 | "tokenUrl": "http://keycloak:8083/auth/realms/stac/protocol/openid-connect/token" 14 | } 15 | } 16 | ] 17 | }, 18 | { 19 | "routes": [ 20 | { 21 | "path": "/collections/{collection_id}/items/{item_id}", 22 | "method": "GET" 23 | }, 24 | { 25 | "path": "/search", 26 | "method": [ 27 | "GET", 28 | "POST" 29 | ] 30 | }, 31 | { 32 | "path": "/collections", 33 | "method": "GET" 34 | } 35 | ], 36 | "dependencies": [ 37 | { 38 | "method": "stac_fastapi.core.basic_auth.BasicAuth", 39 | "kwargs": { 40 | "credentials": [ 41 | { 42 | "username": "reader", 43 | "password": "reader" 44 | } 45 | ] 46 | } 47 | } 48 | ] 49 | } 50 | ] -------------------------------------------------------------------------------- /examples/pip_docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | 4 | # update apt pkgs, and install build-essential for ciso8601 5 | RUN apt-get update && \ 6 | apt-get -y upgrade && \ 7 | apt-get install -y build-essential && \ 8 | apt-get clean && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # update certs used by Requests 12 | ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt 13 | 14 | WORKDIR /app 15 | 16 | COPY . /app 17 | 18 | RUN pip install stac-fastapi.elasticsearch==2.2.0 -------------------------------------------------------------------------------- /examples/pip_docker/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app-elasticsearch: 3 | container_name: stac-fastapi-es 4 | image: stac-utils/stac-fastapi-es 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | platform: linux/amd64 10 | environment: 11 | - APP_HOST=0.0.0.0 12 | - APP_PORT=8080 13 | - RELOAD=true 14 | - ENVIRONMENT=local 15 | - WEB_CONCURRENCY=10 16 | - ES_HOST=172.17.0.1 17 | - ES_PORT=9200 18 | - ES_USE_SSL=false 19 | - ES_VERIFY_CERTS=false 20 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-elasticsearch 21 | ports: 22 | - "8080:8080" 23 | volumes: 24 | - ./stac_fastapi:/app/stac_fastapi 25 | - ./scripts:/app/scripts 26 | depends_on: 27 | - elasticsearch 28 | command: 29 | bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app" 30 | 31 | elasticsearch: 32 | container_name: es-container 33 | image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0} 34 | environment: 35 | ES_JAVA_OPTS: -Xms512m -Xmx1g 36 | volumes: 37 | - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 38 | - ./elasticsearch/snapshots:/usr/share/elasticsearch/snapshots 39 | ports: 40 | - "9200:9200" 41 | 42 | networks: 43 | default: 44 | name: stac-fastapi-network -------------------------------------------------------------------------------- /examples/pip_docker/elasticsearch/config/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | ## Cluster Settings 2 | cluster.name: stac-cluster 3 | node.name: es01 4 | network.host: 0.0.0.0 5 | transport.host: 0.0.0.0 6 | discovery.type: single-node 7 | http.port: 9200 8 | 9 | path: 10 | repo: 11 | - /usr/share/elasticsearch/snapshots 12 | 13 | ## License 14 | xpack.license.self_generated.type: basic 15 | 16 | # Security 17 | xpack.security.enabled: false 18 | xpack.security.transport.ssl.enabled: false -------------------------------------------------------------------------------- /examples/pip_docker/scripts/wait-for-it-es.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | ###################################################### 5 | # Copied from https://github.com/vishnubob/wait-for-it 6 | ###################################################### 7 | 8 | WAITFORIT_cmdname=${0##*/} 9 | 10 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 11 | 12 | usage() 13 | { 14 | cat << USAGE >&2 15 | Usage: 16 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 17 | -h HOST | --host=HOST Host or IP under test 18 | -p PORT | --port=PORT TCP port under test 19 | Alternatively, you specify the host and port as host:port 20 | -s | --strict Only execute subcommand if the test succeeds 21 | -q | --quiet Don't output any status messages 22 | -t TIMEOUT | --timeout=TIMEOUT 23 | Timeout in seconds, zero for no timeout 24 | -- COMMAND ARGS Execute command with args after the test finishes 25 | USAGE 26 | exit 1 27 | } 28 | 29 | wait_for() 30 | { 31 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 32 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 33 | else 34 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 35 | fi 36 | WAITFORIT_start_ts=$(date +%s) 37 | while : 38 | do 39 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 40 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 41 | WAITFORIT_result=$? 42 | else 43 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 44 | WAITFORIT_result=$? 45 | fi 46 | if [[ $WAITFORIT_result -eq 0 ]]; then 47 | WAITFORIT_end_ts=$(date +%s) 48 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 49 | break 50 | fi 51 | sleep 1 52 | done 53 | return $WAITFORIT_result 54 | } 55 | 56 | wait_for_wrapper() 57 | { 58 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 59 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 60 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 61 | else 62 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 63 | fi 64 | WAITFORIT_PID=$! 65 | trap "kill -INT -$WAITFORIT_PID" INT 66 | wait $WAITFORIT_PID 67 | WAITFORIT_RESULT=$? 68 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 69 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 70 | fi 71 | return $WAITFORIT_RESULT 72 | } 73 | 74 | # process arguments 75 | while [[ $# -gt 0 ]] 76 | do 77 | case "$1" in 78 | *:* ) 79 | WAITFORIT_hostport=(${1//:/ }) 80 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 81 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 82 | shift 1 83 | ;; 84 | --child) 85 | WAITFORIT_CHILD=1 86 | shift 1 87 | ;; 88 | -q | --quiet) 89 | WAITFORIT_QUIET=1 90 | shift 1 91 | ;; 92 | -s | --strict) 93 | WAITFORIT_STRICT=1 94 | shift 1 95 | ;; 96 | -h) 97 | WAITFORIT_HOST="$2" 98 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 99 | shift 2 100 | ;; 101 | --host=*) 102 | WAITFORIT_HOST="${1#*=}" 103 | shift 1 104 | ;; 105 | -p) 106 | WAITFORIT_PORT="$2" 107 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 108 | shift 2 109 | ;; 110 | --port=*) 111 | WAITFORIT_PORT="${1#*=}" 112 | shift 1 113 | ;; 114 | -t) 115 | WAITFORIT_TIMEOUT="$2" 116 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 117 | shift 2 118 | ;; 119 | --timeout=*) 120 | WAITFORIT_TIMEOUT="${1#*=}" 121 | shift 1 122 | ;; 123 | --) 124 | shift 125 | WAITFORIT_CLI=("$@") 126 | break 127 | ;; 128 | --help) 129 | usage 130 | ;; 131 | *) 132 | echoerr "Unknown argument: $1" 133 | usage 134 | ;; 135 | esac 136 | done 137 | 138 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 139 | echoerr "Error: you need to provide a host and port to test." 140 | usage 141 | fi 142 | 143 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-45} 144 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 145 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 146 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 147 | 148 | # Check to see if timeout is from busybox? 149 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 150 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 151 | 152 | WAITFORIT_BUSYTIMEFLAG="" 153 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 154 | WAITFORIT_ISBUSY=1 155 | # Check if busybox timeout uses -t flag 156 | # (recent Alpine versions don't support -t anymore) 157 | if timeout &>/dev/stdout | grep -q -e '-t '; then 158 | WAITFORIT_BUSYTIMEFLAG="-t" 159 | fi 160 | else 161 | WAITFORIT_ISBUSY=0 162 | fi 163 | 164 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | exit $WAITFORIT_RESULT 168 | else 169 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 170 | wait_for_wrapper 171 | WAITFORIT_RESULT=$? 172 | else 173 | wait_for 174 | WAITFORIT_RESULT=$? 175 | fi 176 | fi 177 | 178 | if [[ $WAITFORIT_CLI != "" ]]; then 179 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 180 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 181 | exit $WAITFORIT_RESULT 182 | fi 183 | exec "${WAITFORIT_CLI[@]}" 184 | else 185 | exit $WAITFORIT_RESULT 186 | fi -------------------------------------------------------------------------------- /examples/rate_limit/compose.rate_limit.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app-elasticsearch: 3 | container_name: stac-fastapi-es 4 | image: stac-utils/stac-fastapi-es 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: dockerfiles/Dockerfile.dev.es 9 | environment: 10 | - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch 11 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend 12 | - STAC_FASTAPI_VERSION=5.0.0a1 13 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-elasticsearch 14 | - APP_HOST=0.0.0.0 15 | - APP_PORT=8080 16 | - RELOAD=true 17 | - ENVIRONMENT=local 18 | - WEB_CONCURRENCY=10 19 | - ES_HOST=elasticsearch 20 | - ES_PORT=9200 21 | - ES_USE_SSL=false 22 | - ES_VERIFY_CERTS=false 23 | - BACKEND=elasticsearch 24 | - STAC_FASTAPI_RATE_LIMIT=500/minute 25 | ports: 26 | - "8080:8080" 27 | volumes: 28 | - ./stac_fastapi:/app/stac_fastapi 29 | - ./scripts:/app/scripts 30 | - ./esdata:/usr/share/elasticsearch/data 31 | depends_on: 32 | - elasticsearch 33 | command: 34 | bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app" 35 | 36 | app-opensearch: 37 | container_name: stac-fastapi-os 38 | image: stac-utils/stac-fastapi-os 39 | restart: always 40 | build: 41 | context: . 42 | dockerfile: dockerfiles/Dockerfile.dev.os 43 | environment: 44 | - STAC_FASTAPI_TITLE=stac-fastapi-opensearch 45 | - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend 46 | - STAC_FASTAPI_VERSION=5.0.0a1 47 | - STAC_FASTAPI_LANDING_PAGE_ID=stac-fastapi-opensearch 48 | - APP_HOST=0.0.0.0 49 | - APP_PORT=8082 50 | - RELOAD=true 51 | - ENVIRONMENT=local 52 | - WEB_CONCURRENCY=10 53 | - ES_HOST=opensearch 54 | - ES_PORT=9202 55 | - ES_USE_SSL=false 56 | - ES_VERIFY_CERTS=false 57 | - BACKEND=opensearch 58 | - STAC_FASTAPI_RATE_LIMIT=200/minute 59 | ports: 60 | - "8082:8082" 61 | volumes: 62 | - ./stac_fastapi:/app/stac_fastapi 63 | - ./scripts:/app/scripts 64 | - ./osdata:/usr/share/opensearch/data 65 | depends_on: 66 | - opensearch 67 | command: 68 | bash -c "./scripts/wait-for-it-es.sh os-container:9202 && python -m stac_fastapi.opensearch.app" 69 | 70 | elasticsearch: 71 | container_name: es-container 72 | image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0} 73 | hostname: elasticsearch 74 | environment: 75 | ES_JAVA_OPTS: -Xms512m -Xmx1g 76 | volumes: 77 | - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 78 | - ./elasticsearch/snapshots:/usr/share/elasticsearch/snapshots 79 | ports: 80 | - "9200:9200" 81 | 82 | opensearch: 83 | container_name: os-container 84 | image: opensearchproject/opensearch:${OPENSEARCH_VERSION:-2.11.1} 85 | hostname: opensearch 86 | environment: 87 | - discovery.type=single-node 88 | - plugins.security.disabled=true 89 | - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m 90 | volumes: 91 | - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml 92 | - ./opensearch/snapshots:/usr/share/opensearch/snapshots 93 | ports: 94 | - "9202:9202" 95 | -------------------------------------------------------------------------------- /opensearch/config/opensearch.yml: -------------------------------------------------------------------------------- 1 | ## Cluster Settings 2 | cluster.name: stac-cluster 3 | node.name: os01 4 | network.host: 0.0.0.0 5 | transport.host: 0.0.0.0 6 | discovery.type: single-node 7 | http.port: 9202 8 | http.cors.enabled: true 9 | http.cors.allow-headers: X-Requested-With,Content-Type,Content-Length,Accept,Authorization 10 | 11 | path: 12 | repo: 13 | - /usr/share/opensearch/snapshots 14 | 15 | # Security 16 | plugins.security.disabled: true 17 | plugins.security.ssl.http.enabled: true 18 | 19 | node.max_local_storage_nodes: 3 20 | -------------------------------------------------------------------------------- /scripts/wait-for-it-es.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | ###################################################### 5 | # Copied from https://github.com/vishnubob/wait-for-it 6 | ###################################################### 7 | 8 | WAITFORIT_cmdname=${0##*/} 9 | 10 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 11 | 12 | usage() 13 | { 14 | cat << USAGE >&2 15 | Usage: 16 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 17 | -h HOST | --host=HOST Host or IP under test 18 | -p PORT | --port=PORT TCP port under test 19 | Alternatively, you specify the host and port as host:port 20 | -s | --strict Only execute subcommand if the test succeeds 21 | -q | --quiet Don't output any status messages 22 | -t TIMEOUT | --timeout=TIMEOUT 23 | Timeout in seconds, zero for no timeout 24 | -- COMMAND ARGS Execute command with args after the test finishes 25 | USAGE 26 | exit 1 27 | } 28 | 29 | wait_for() 30 | { 31 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 32 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 33 | else 34 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 35 | fi 36 | WAITFORIT_start_ts=$(date +%s) 37 | while : 38 | do 39 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 40 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 41 | WAITFORIT_result=$? 42 | else 43 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 44 | WAITFORIT_result=$? 45 | fi 46 | if [[ $WAITFORIT_result -eq 0 ]]; then 47 | WAITFORIT_end_ts=$(date +%s) 48 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 49 | break 50 | fi 51 | sleep 1 52 | done 53 | return $WAITFORIT_result 54 | } 55 | 56 | wait_for_wrapper() 57 | { 58 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 59 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 60 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 61 | else 62 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 63 | fi 64 | WAITFORIT_PID=$! 65 | trap "kill -INT -$WAITFORIT_PID" INT 66 | wait $WAITFORIT_PID 67 | WAITFORIT_RESULT=$? 68 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 69 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 70 | fi 71 | return $WAITFORIT_RESULT 72 | } 73 | 74 | # process arguments 75 | while [[ $# -gt 0 ]] 76 | do 77 | case "$1" in 78 | *:* ) 79 | WAITFORIT_hostport=(${1//:/ }) 80 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 81 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 82 | shift 1 83 | ;; 84 | --child) 85 | WAITFORIT_CHILD=1 86 | shift 1 87 | ;; 88 | -q | --quiet) 89 | WAITFORIT_QUIET=1 90 | shift 1 91 | ;; 92 | -s | --strict) 93 | WAITFORIT_STRICT=1 94 | shift 1 95 | ;; 96 | -h) 97 | WAITFORIT_HOST="$2" 98 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 99 | shift 2 100 | ;; 101 | --host=*) 102 | WAITFORIT_HOST="${1#*=}" 103 | shift 1 104 | ;; 105 | -p) 106 | WAITFORIT_PORT="$2" 107 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 108 | shift 2 109 | ;; 110 | --port=*) 111 | WAITFORIT_PORT="${1#*=}" 112 | shift 1 113 | ;; 114 | -t) 115 | WAITFORIT_TIMEOUT="$2" 116 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 117 | shift 2 118 | ;; 119 | --timeout=*) 120 | WAITFORIT_TIMEOUT="${1#*=}" 121 | shift 1 122 | ;; 123 | --) 124 | shift 125 | WAITFORIT_CLI=("$@") 126 | break 127 | ;; 128 | --help) 129 | usage 130 | ;; 131 | *) 132 | echoerr "Unknown argument: $1" 133 | usage 134 | ;; 135 | esac 136 | done 137 | 138 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 139 | echoerr "Error: you need to provide a host and port to test." 140 | usage 141 | fi 142 | 143 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-45} 144 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 145 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 146 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 147 | 148 | # Check to see if timeout is from busybox? 149 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 150 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 151 | 152 | WAITFORIT_BUSYTIMEFLAG="" 153 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 154 | WAITFORIT_ISBUSY=1 155 | # Check if busybox timeout uses -t flag 156 | # (recent Alpine versions don't support -t anymore) 157 | if timeout &>/dev/stdout | grep -q -e '-t '; then 158 | WAITFORIT_BUSYTIMEFLAG="-t" 159 | fi 160 | else 161 | WAITFORIT_ISBUSY=0 162 | fi 163 | 164 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | exit $WAITFORIT_RESULT 168 | else 169 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 170 | wait_for_wrapper 171 | WAITFORIT_RESULT=$? 172 | else 173 | wait_for 174 | WAITFORIT_RESULT=$? 175 | fi 176 | fi 177 | 178 | if [[ $WAITFORIT_CLI != "" ]]; then 179 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 180 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 181 | exit $WAITFORIT_RESULT 182 | fi 183 | exec "${WAITFORIT_CLI[@]}" 184 | else 185 | exit $WAITFORIT_RESULT 186 | fi -------------------------------------------------------------------------------- /stac_fastapi/core/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /stac_fastapi/core/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = -sv 4 | asyncio_mode = auto -------------------------------------------------------------------------------- /stac_fastapi/core/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: stac_fastapi.core.version.__version__ 3 | -------------------------------------------------------------------------------- /stac_fastapi/core/setup.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi: core elasticsearch/ opensearch module.""" 2 | 3 | from setuptools import find_namespace_packages, setup 4 | 5 | with open("README.md") as f: 6 | desc = f.read() 7 | 8 | install_requires = [ 9 | "fastapi~=0.109.0", 10 | "attrs>=23.2.0", 11 | "pydantic>=2.4.1,<3.0.0", 12 | "stac_pydantic~=3.1.0", 13 | "stac-fastapi.api==5.2.0", 14 | "stac-fastapi.extensions==5.2.0", 15 | "stac-fastapi.types==5.2.0", 16 | "orjson~=3.9.0", 17 | "overrides~=7.4.0", 18 | "geojson-pydantic~=1.0.0", 19 | "pygeofilter~=0.3.1", 20 | "jsonschema~=4.0.0", 21 | "slowapi~=0.1.9", 22 | ] 23 | 24 | setup( 25 | name="stac_fastapi_core", 26 | description="Core library for the Elasticsearch and Opensearch stac-fastapi backends.", 27 | long_description=desc, 28 | long_description_content_type="text/markdown", 29 | python_requires=">=3.9", 30 | classifiers=[ 31 | "Intended Audience :: Developers", 32 | "Intended Audience :: Information Technology", 33 | "Intended Audience :: Science/Research", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Programming Language :: Python :: 3.13", 39 | "License :: OSI Approved :: MIT License", 40 | ], 41 | url="https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch", 42 | license="MIT", 43 | packages=find_namespace_packages(), 44 | zip_safe=False, 45 | install_requires=install_requires, 46 | ) 47 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core library.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/base_database_logic.py: -------------------------------------------------------------------------------- 1 | """Base database logic.""" 2 | 3 | import abc 4 | from typing import Any, Dict, Iterable, Optional 5 | 6 | 7 | class BaseDatabaseLogic(abc.ABC): 8 | """ 9 | Abstract base class for database logic. 10 | 11 | This class defines the basic structure and operations for database interactions. 12 | Subclasses must provide implementations for these methods. 13 | """ 14 | 15 | @abc.abstractmethod 16 | async def get_all_collections( 17 | self, token: Optional[str], limit: int 18 | ) -> Iterable[Dict[str, Any]]: 19 | """Retrieve a list of all collections from the database.""" 20 | pass 21 | 22 | @abc.abstractmethod 23 | async def get_one_item(self, collection_id: str, item_id: str) -> Dict: 24 | """Retrieve a single item from the database.""" 25 | pass 26 | 27 | @abc.abstractmethod 28 | async def create_item(self, item: Dict, refresh: bool = False) -> None: 29 | """Create an item in the database.""" 30 | pass 31 | 32 | @abc.abstractmethod 33 | async def delete_item( 34 | self, item_id: str, collection_id: str, refresh: bool = False 35 | ) -> None: 36 | """Delete an item from the database.""" 37 | pass 38 | 39 | @abc.abstractmethod 40 | async def create_collection(self, collection: Dict, refresh: bool = False) -> None: 41 | """Create a collection in the database.""" 42 | pass 43 | 44 | @abc.abstractmethod 45 | async def find_collection(self, collection_id: str) -> Dict: 46 | """Find a collection in the database.""" 47 | pass 48 | 49 | @abc.abstractmethod 50 | async def delete_collection( 51 | self, collection_id: str, refresh: bool = False 52 | ) -> None: 53 | """Delete a collection from the database.""" 54 | pass 55 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/base_settings.py: -------------------------------------------------------------------------------- 1 | """Base settings.""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | 6 | class ApiBaseSettings(ABC): 7 | """Abstract base class for API settings.""" 8 | 9 | @abstractmethod 10 | def create_client(self): 11 | """Create a database client.""" 12 | pass 13 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/basic_auth.py: -------------------------------------------------------------------------------- 1 | """Basic Authentication Module.""" 2 | 3 | import logging 4 | import secrets 5 | 6 | from fastapi import Depends, HTTPException, status 7 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 8 | from typing_extensions import Annotated 9 | 10 | _LOGGER = logging.getLogger("uvicorn.default") 11 | _SECURITY = HTTPBasic() 12 | 13 | 14 | class BasicAuth: 15 | """Apply basic authentication to the provided FastAPI application \ 16 | based on environment variables for username, password, and endpoints.""" 17 | 18 | def __init__(self, credentials: list) -> None: 19 | """Generate basic_auth property.""" 20 | self.basic_auth = {} 21 | for credential in credentials: 22 | self.basic_auth[credential["username"]] = credential 23 | 24 | async def __call__( 25 | self, 26 | credentials: Annotated[HTTPBasicCredentials, Depends(_SECURITY)], 27 | ) -> str: 28 | """Check if the provided credentials match the expected \ 29 | username and password stored in basic_auth. 30 | 31 | Args: 32 | credentials (HTTPBasicCredentials): The HTTP basic authentication credentials. 33 | 34 | Returns: 35 | str: The username if authentication is successful. 36 | 37 | Raises: 38 | HTTPException: If authentication fails due to incorrect username or password. 39 | """ 40 | user = self.basic_auth.get(credentials.username) 41 | 42 | if not user: 43 | raise HTTPException( 44 | status_code=status.HTTP_401_UNAUTHORIZED, 45 | detail="Incorrect username or password", 46 | headers={"WWW-Authenticate": "Basic"}, 47 | ) 48 | 49 | # Compare the provided username and password with the correct ones using compare_digest 50 | if not secrets.compare_digest( 51 | credentials.username.encode("utf-8"), user.get("username").encode("utf-8") 52 | ) or not secrets.compare_digest( 53 | credentials.password.encode("utf-8"), user.get("password").encode("utf-8") 54 | ): 55 | raise HTTPException( 56 | status_code=status.HTTP_401_UNAUTHORIZED, 57 | detail="Incorrect username or password", 58 | headers={"WWW-Authenticate": "Basic"}, 59 | ) 60 | 61 | return credentials.username 62 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/datetime_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions to handle datetime parsing.""" 2 | from datetime import datetime, timezone 3 | 4 | from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime 5 | 6 | 7 | def format_datetime_range(date_str: str) -> str: 8 | """ 9 | Convert a datetime range string into a normalized UTC string for API requests using rfc3339_str_to_datetime. 10 | 11 | Args: 12 | date_str (str): A string containing two datetime values separated by a '/'. 13 | 14 | Returns: 15 | str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None. 16 | """ 17 | 18 | def normalize(dt): 19 | dt = dt.strip() 20 | if not dt or dt == "..": 21 | return ".." 22 | dt_obj = rfc3339_str_to_datetime(dt) 23 | dt_utc = dt_obj.astimezone(timezone.utc) 24 | return dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ") 25 | 26 | if not isinstance(date_str, str): 27 | return "../.." 28 | if "/" not in date_str: 29 | return f"{normalize(date_str)}/{normalize(date_str)}" 30 | try: 31 | start, end = date_str.split("/", 1) 32 | except Exception: 33 | return "../.." 34 | return f"{normalize(start)}/{normalize(end)}" 35 | 36 | 37 | # Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394 38 | def datetime_to_str(dt: datetime, timespec: str = "auto") -> str: 39 | """Convert a :class:`datetime.datetime` instance to an ISO8601 string in the `RFC 3339, section 5.6. 40 | 41 | `__ format required by 42 | the :stac-spec:`STAC Spec `. 43 | 44 | Args: 45 | dt : The datetime to convert. 46 | timespec: An optional argument that specifies the number of additional 47 | terms of the time to include. Valid options are 'auto', 'hours', 48 | 'minutes', 'seconds', 'milliseconds' and 'microseconds'. The default value 49 | is 'auto'. 50 | Returns: 51 | str: The ISO8601 (RFC 3339) formatted string representing the datetime. 52 | """ 53 | if dt.tzinfo is None: 54 | dt = dt.replace(tzinfo=timezone.utc) 55 | 56 | timestamp = dt.isoformat(timespec=timespec) 57 | zulu = "+00:00" 58 | if timestamp.endswith(zulu): 59 | timestamp = f"{timestamp[: -len(zulu)]}Z" 60 | 61 | return timestamp 62 | 63 | 64 | def now_in_utc() -> datetime: 65 | """Return a datetime value of now with the UTC timezone applied.""" 66 | return datetime.now(timezone.utc) 67 | 68 | 69 | def now_to_rfc3339_str() -> str: 70 | """Return an RFC 3339 string representing now.""" 71 | return datetime_to_str(now_in_utc()) 72 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """elasticsearch extensions modifications.""" 2 | 3 | from .query import Operator, QueryableTypes, QueryExtension 4 | 5 | __all__ = ["Operator", "QueryableTypes", "QueryExtension"] 6 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py: -------------------------------------------------------------------------------- 1 | """Request model for the Aggregation extension.""" 2 | 3 | from typing import Literal, Optional 4 | 5 | import attr 6 | from fastapi import Path 7 | from typing_extensions import Annotated 8 | 9 | from stac_fastapi.extensions.core.aggregation.request import ( 10 | AggregationExtensionGetRequest, 11 | AggregationExtensionPostRequest, 12 | ) 13 | from stac_fastapi.extensions.core.filter.request import ( 14 | FilterExtensionGetRequest, 15 | FilterExtensionPostRequest, 16 | ) 17 | 18 | FilterLang = Literal["cql-json", "cql2-json", "cql2-text"] 19 | 20 | 21 | @attr.s 22 | class EsAggregationExtensionGetRequest( 23 | AggregationExtensionGetRequest, FilterExtensionGetRequest 24 | ): 25 | """Implementation specific query parameters for aggregation precision.""" 26 | 27 | collection_id: Optional[ 28 | Annotated[str, Path(description="Collection ID")] 29 | ] = attr.ib(default=None) 30 | 31 | centroid_geohash_grid_frequency_precision: Optional[int] = attr.ib(default=None) 32 | centroid_geohex_grid_frequency_precision: Optional[int] = attr.ib(default=None) 33 | centroid_geotile_grid_frequency_precision: Optional[int] = attr.ib(default=None) 34 | geometry_geohash_grid_frequency_precision: Optional[int] = attr.ib(default=None) 35 | geometry_geotile_grid_frequency_precision: Optional[int] = attr.ib(default=None) 36 | datetime_frequency_interval: Optional[str] = attr.ib(default=None) 37 | 38 | 39 | class EsAggregationExtensionPostRequest( 40 | AggregationExtensionPostRequest, FilterExtensionPostRequest 41 | ): 42 | """Implementation specific query parameters for aggregation precision.""" 43 | 44 | centroid_geohash_grid_frequency_precision: Optional[int] = None 45 | centroid_geohex_grid_frequency_precision: Optional[int] = None 46 | centroid_geotile_grid_frequency_precision: Optional[int] = None 47 | geometry_geohash_grid_frequency_precision: Optional[int] = None 48 | geometry_geotile_grid_frequency_precision: Optional[int] = None 49 | datetime_frequency_interval: Optional[str] = None 50 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/extensions/fields.py: -------------------------------------------------------------------------------- 1 | """Fields extension.""" 2 | 3 | from typing import Optional, Set 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | from stac_fastapi.extensions.core import FieldsExtension as FieldsExtensionBase 8 | from stac_fastapi.extensions.core.fields import request 9 | 10 | 11 | class PostFieldsExtension(request.PostFieldsExtension): 12 | """PostFieldsExtension.""" 13 | 14 | # Set defaults if needed 15 | # include : Optional[Set[str]] = Field( 16 | # default_factory=lambda: { 17 | # "id", 18 | # "type", 19 | # "stac_version", 20 | # "geometry", 21 | # "bbox", 22 | # "links", 23 | # "assets", 24 | # "properties.datetime", 25 | # "collection", 26 | # } 27 | # ) 28 | include: Optional[Set[str]] = set() 29 | exclude: Optional[Set[str]] = set() 30 | 31 | 32 | class FieldsExtensionPostRequest(BaseModel): 33 | """Additional fields and schema for the POST request.""" 34 | 35 | fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension()) 36 | 37 | 38 | class FieldsExtension(FieldsExtensionBase): 39 | """Override the POST model.""" 40 | 41 | POST = FieldsExtensionPostRequest 42 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/extensions/filter.py: -------------------------------------------------------------------------------- 1 | """Filter extension logic for conversion.""" 2 | 3 | # """ 4 | # Implements Filter Extension. 5 | 6 | # Basic CQL2 (AND, OR, NOT), comparison operators (=, <>, <, <=, >, >=), and IS NULL. 7 | # The comparison operators are allowed against string, numeric, boolean, date, and datetime types. 8 | 9 | # Advanced comparison operators (http://www.opengis.net/spec/cql2/1.0/req/advanced-comparison-operators) 10 | # defines the LIKE, IN, and BETWEEN operators. 11 | 12 | # Basic Spatial Operators (http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators) 13 | # defines spatial operators (S_INTERSECTS, S_CONTAINS, S_WITHIN, S_DISJOINT). 14 | # """ 15 | 16 | from enum import Enum 17 | from typing import Any, Dict 18 | 19 | DEFAULT_QUERYABLES: Dict[str, Dict[str, Any]] = { 20 | "id": { 21 | "description": "ID", 22 | "$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/properties/id", 23 | }, 24 | "collection": { 25 | "description": "Collection", 26 | "$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/then/properties/collection", 27 | }, 28 | "geometry": { 29 | "description": "Geometry", 30 | "$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/1/oneOf/0/properties/geometry", 31 | }, 32 | "datetime": { 33 | "description": "Acquisition Timestamp", 34 | "$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/datetime", 35 | }, 36 | "created": { 37 | "description": "Creation Timestamp", 38 | "$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/created", 39 | }, 40 | "updated": { 41 | "description": "Creation Timestamp", 42 | "$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/updated", 43 | }, 44 | "cloud_cover": { 45 | "description": "Cloud Cover", 46 | "$ref": "https://stac-extensions.github.io/eo/v1.0.0/schema.json#/definitions/fields/properties/eo:cloud_cover", 47 | }, 48 | "cloud_shadow_percentage": { 49 | "title": "Cloud Shadow Percentage", 50 | "description": "Cloud Shadow Percentage", 51 | "type": "number", 52 | "minimum": 0, 53 | "maximum": 100, 54 | }, 55 | "nodata_pixel_percentage": { 56 | "title": "No Data Pixel Percentage", 57 | "description": "No Data Pixel Percentage", 58 | "type": "number", 59 | "minimum": 0, 60 | "maximum": 100, 61 | }, 62 | } 63 | 64 | 65 | class LogicalOp(str, Enum): 66 | """Enumeration for logical operators used in constructing Elasticsearch queries.""" 67 | 68 | AND = "and" 69 | OR = "or" 70 | NOT = "not" 71 | 72 | 73 | class ComparisonOp(str, Enum): 74 | """Enumeration for comparison operators used in filtering queries according to CQL2 standards.""" 75 | 76 | EQ = "=" 77 | NEQ = "<>" 78 | LT = "<" 79 | LTE = "<=" 80 | GT = ">" 81 | GTE = ">=" 82 | IS_NULL = "isNull" 83 | 84 | 85 | class AdvancedComparisonOp(str, Enum): 86 | """Enumeration for advanced comparison operators like 'like', 'between', and 'in'.""" 87 | 88 | LIKE = "like" 89 | BETWEEN = "between" 90 | IN = "in" 91 | 92 | 93 | class SpatialOp(str, Enum): 94 | """Enumeration for spatial operators as per CQL2 standards.""" 95 | 96 | S_INTERSECTS = "s_intersects" 97 | S_CONTAINS = "s_contains" 98 | S_WITHIN = "s_within" 99 | S_DISJOINT = "s_disjoint" 100 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/extensions/query.py: -------------------------------------------------------------------------------- 1 | """STAC SQLAlchemy specific query search model. 2 | 3 | # TODO: replace with stac-pydantic 4 | """ 5 | 6 | import logging 7 | import operator 8 | from dataclasses import dataclass 9 | from enum import auto 10 | from types import DynamicClassAttribute 11 | from typing import Any, Callable, Dict, Optional 12 | 13 | from pydantic import BaseModel, model_validator 14 | from stac_pydantic.utils import AutoValueEnum 15 | 16 | from stac_fastapi.extensions.core.query import QueryExtension as QueryExtensionBase 17 | 18 | logger = logging.getLogger("uvicorn") 19 | logger.setLevel(logging.INFO) 20 | 21 | 22 | class Operator(str, AutoValueEnum): 23 | """Defines the set of operators supported by the API.""" 24 | 25 | eq = auto() 26 | ne = auto() 27 | lt = auto() 28 | lte = auto() 29 | gt = auto() 30 | gte = auto() 31 | 32 | # TODO: These are defined in the spec but aren't currently implemented by the api 33 | # startsWith = auto() 34 | # endsWith = auto() 35 | # contains = auto() 36 | # in = auto() 37 | 38 | @DynamicClassAttribute 39 | def operator(self) -> Callable[[Any, Any], bool]: 40 | """Return python operator.""" 41 | return getattr(operator, self._value_) 42 | 43 | 44 | class Queryables(str, AutoValueEnum): 45 | """Queryable fields.""" 46 | 47 | ... 48 | 49 | 50 | @dataclass 51 | class QueryableTypes: 52 | """Defines a set of queryable fields.""" 53 | 54 | ... 55 | 56 | 57 | class QueryExtensionPostRequest(BaseModel): 58 | """Queryable validation. 59 | 60 | Add queryables validation to the POST request 61 | to raise errors for unsupported querys. 62 | """ 63 | 64 | query: Optional[Dict[str, Dict[Operator, Any]]] = None 65 | 66 | @model_validator(mode="before") 67 | def validate_query_fields(cls, values: Dict) -> Dict: 68 | """Validate query fields.""" 69 | ... 70 | 71 | 72 | class QueryExtension(QueryExtensionBase): 73 | """Query Extenson. 74 | 75 | Override the POST request model to add validation against 76 | supported fields 77 | """ 78 | 79 | POST = QueryExtensionPostRequest 80 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/models/__init__.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi.elasticsearch.models module.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/models/search.py: -------------------------------------------------------------------------------- 1 | """Unused search model.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/rate_limit.py: -------------------------------------------------------------------------------- 1 | """Rate limiting middleware.""" 2 | 3 | import logging 4 | import os 5 | from typing import Optional 6 | 7 | from fastapi import FastAPI, Request 8 | from slowapi import Limiter, _rate_limit_exceeded_handler 9 | from slowapi.errors import RateLimitExceeded 10 | from slowapi.middleware import SlowAPIMiddleware 11 | from slowapi.util import get_remote_address 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def get_limiter(key_func=get_remote_address): 17 | """Create and return a Limiter instance for rate limiting.""" 18 | return Limiter(key_func=key_func) 19 | 20 | 21 | def setup_rate_limit( 22 | app: FastAPI, rate_limit: Optional[str] = None, key_func=get_remote_address 23 | ): 24 | """Set up rate limiting middleware.""" 25 | RATE_LIMIT = rate_limit or os.getenv("STAC_FASTAPI_RATE_LIMIT") 26 | 27 | if not RATE_LIMIT: 28 | logger.info("Rate limiting is disabled") 29 | return 30 | 31 | logger.info(f"Setting up rate limit with RATE_LIMIT={RATE_LIMIT}") 32 | 33 | limiter = get_limiter(key_func) 34 | app.state.limiter = limiter 35 | app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 36 | app.add_middleware(SlowAPIMiddleware) 37 | 38 | @app.middleware("http") 39 | @limiter.limit(RATE_LIMIT) 40 | async def rate_limit_middleware(request: Request, call_next): 41 | response = await call_next(request) 42 | return response 43 | 44 | logger.info("Rate limit setup complete") 45 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/session.py: -------------------------------------------------------------------------------- 1 | """database session management.""" 2 | import logging 3 | 4 | import attr 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @attr.s 10 | class Session: 11 | """Database session management.""" 12 | 13 | @classmethod 14 | def create_from_env(cls): 15 | """Create from environment.""" 16 | ... 17 | 18 | @classmethod 19 | def create_from_settings(cls, settings): 20 | """Create a Session object from settings.""" 21 | ... 22 | 23 | def __attrs_post_init__(self): 24 | """Post init handler.""" 25 | ... 26 | -------------------------------------------------------------------------------- /stac_fastapi/core/stac_fastapi/core/version.py: -------------------------------------------------------------------------------- 1 | """library version.""" 2 | __version__ = "5.0.0a1" 3 | -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = -sv 4 | asyncio_mode = auto -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: stac_fastapi.elasticsearch.version.__version__ 3 | -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/setup.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi: elasticsearch module.""" 2 | 3 | from setuptools import find_namespace_packages, setup 4 | 5 | with open("README.md") as f: 6 | desc = f.read() 7 | 8 | install_requires = [ 9 | "stac-fastapi-core==5.0.0a1", 10 | "sfeos-helpers==5.0.0a1", 11 | "elasticsearch[async]~=8.18.0", 12 | "uvicorn~=0.23.0", 13 | "starlette>=0.35.0,<0.36.0", 14 | ] 15 | 16 | extra_reqs = { 17 | "dev": [ 18 | "pytest~=7.0.0", 19 | "pytest-cov~=4.0.0", 20 | "pytest-asyncio~=0.21.0", 21 | "pre-commit~=3.0.0", 22 | "requests>=2.32.0,<3.0.0", 23 | "ciso8601~=2.3.0", 24 | "httpx>=0.24.0,<0.28.0", 25 | ], 26 | "docs": ["mkdocs~=1.4.0", "mkdocs-material~=9.0.0", "pdocs~=1.2.0"], 27 | "server": ["uvicorn[standard]~=0.23.0"], 28 | } 29 | 30 | setup( 31 | name="stac_fastapi_elasticsearch", 32 | description="An implementation of STAC API based on the FastAPI framework with both Elasticsearch and Opensearch.", 33 | long_description=desc, 34 | long_description_content_type="text/markdown", 35 | python_requires=">=3.9", 36 | classifiers=[ 37 | "Intended Audience :: Developers", 38 | "Intended Audience :: Information Technology", 39 | "Intended Audience :: Science/Research", 40 | "Programming Language :: Python :: 3.9", 41 | "Programming Language :: Python :: 3.10", 42 | "Programming Language :: Python :: 3.11", 43 | "Programming Language :: Python :: 3.12", 44 | "Programming Language :: Python :: 3.13", 45 | "License :: OSI Approved :: MIT License", 46 | ], 47 | url="https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch", 48 | license="MIT", 49 | packages=find_namespace_packages(exclude=["alembic", "tests", "scripts"]), 50 | zip_safe=False, 51 | install_requires=install_requires, 52 | tests_require=extra_reqs["dev"], 53 | extras_require=extra_reqs, 54 | entry_points={ 55 | "console_scripts": [ 56 | "stac-fastapi-elasticsearch=stac_fastapi.elasticsearch.app:run" 57 | ] 58 | }, 59 | ) 60 | -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/__init__.py: -------------------------------------------------------------------------------- 1 | """elasticsearch submodule.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py: -------------------------------------------------------------------------------- 1 | """FastAPI application.""" 2 | 3 | import logging 4 | import os 5 | from contextlib import asynccontextmanager 6 | 7 | from fastapi import FastAPI 8 | 9 | from stac_fastapi.api.app import StacApi 10 | from stac_fastapi.api.models import create_get_request_model, create_post_request_model 11 | from stac_fastapi.core.core import ( 12 | BulkTransactionsClient, 13 | CoreClient, 14 | TransactionsClient, 15 | ) 16 | from stac_fastapi.core.extensions import QueryExtension 17 | from stac_fastapi.core.extensions.aggregation import ( 18 | EsAggregationExtensionGetRequest, 19 | EsAggregationExtensionPostRequest, 20 | ) 21 | from stac_fastapi.core.extensions.fields import FieldsExtension 22 | from stac_fastapi.core.rate_limit import setup_rate_limit 23 | from stac_fastapi.core.route_dependencies import get_route_dependencies 24 | from stac_fastapi.core.session import Session 25 | from stac_fastapi.core.utilities import get_bool_env 26 | from stac_fastapi.elasticsearch.config import ElasticsearchSettings 27 | from stac_fastapi.elasticsearch.database_logic import ( 28 | DatabaseLogic, 29 | create_collection_index, 30 | create_index_templates, 31 | ) 32 | from stac_fastapi.extensions.core import ( 33 | AggregationExtension, 34 | FilterExtension, 35 | FreeTextExtension, 36 | SortExtension, 37 | TokenPaginationExtension, 38 | TransactionExtension, 39 | ) 40 | from stac_fastapi.extensions.third_party import BulkTransactionExtension 41 | from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient 42 | from stac_fastapi.sfeos_helpers.filter import EsAsyncBaseFiltersClient 43 | 44 | logging.basicConfig(level=logging.INFO) 45 | logger = logging.getLogger(__name__) 46 | 47 | TRANSACTIONS_EXTENSIONS = get_bool_env("ENABLE_TRANSACTIONS_EXTENSIONS", default=True) 48 | logger.info("TRANSACTIONS_EXTENSIONS is set to %s", TRANSACTIONS_EXTENSIONS) 49 | 50 | settings = ElasticsearchSettings() 51 | session = Session.create_from_settings(settings) 52 | 53 | database_logic = DatabaseLogic() 54 | 55 | filter_extension = FilterExtension( 56 | client=EsAsyncBaseFiltersClient(database=database_logic) 57 | ) 58 | filter_extension.conformance_classes.append( 59 | "http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators" 60 | ) 61 | 62 | aggregation_extension = AggregationExtension( 63 | client=EsAsyncBaseAggregationClient( 64 | database=database_logic, session=session, settings=settings 65 | ) 66 | ) 67 | aggregation_extension.POST = EsAggregationExtensionPostRequest 68 | aggregation_extension.GET = EsAggregationExtensionGetRequest 69 | 70 | search_extensions = [ 71 | FieldsExtension(), 72 | QueryExtension(), 73 | SortExtension(), 74 | TokenPaginationExtension(), 75 | filter_extension, 76 | FreeTextExtension(), 77 | ] 78 | 79 | if TRANSACTIONS_EXTENSIONS: 80 | search_extensions.insert( 81 | 0, 82 | TransactionExtension( 83 | client=TransactionsClient( 84 | database=database_logic, session=session, settings=settings 85 | ), 86 | settings=settings, 87 | ), 88 | ) 89 | search_extensions.insert( 90 | 1, 91 | BulkTransactionExtension( 92 | client=BulkTransactionsClient( 93 | database=database_logic, 94 | session=session, 95 | settings=settings, 96 | ) 97 | ), 98 | ) 99 | 100 | extensions = [aggregation_extension] + search_extensions 101 | 102 | database_logic.extensions = [type(ext).__name__ for ext in extensions] 103 | 104 | post_request_model = create_post_request_model(search_extensions) 105 | 106 | api = StacApi( 107 | title=os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"), 108 | description=os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"), 109 | api_version=os.getenv("STAC_FASTAPI_VERSION", "5.0.0a1"), 110 | settings=settings, 111 | extensions=extensions, 112 | client=CoreClient( 113 | database=database_logic, 114 | session=session, 115 | post_request_model=post_request_model, 116 | landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), 117 | ), 118 | search_get_request_model=create_get_request_model(search_extensions), 119 | search_post_request_model=post_request_model, 120 | route_dependencies=get_route_dependencies(), 121 | ) 122 | 123 | 124 | @asynccontextmanager 125 | async def lifespan(app: FastAPI): 126 | """Lifespan handler for FastAPI app. Initializes index templates and collections at startup.""" 127 | await create_index_templates() 128 | await create_collection_index() 129 | yield 130 | 131 | 132 | app = api.app 133 | app.router.lifespan_context = lifespan 134 | app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "") 135 | # Add rate limit 136 | setup_rate_limit(app, rate_limit=os.getenv("STAC_FASTAPI_RATE_LIMIT")) 137 | 138 | 139 | def run() -> None: 140 | """Run app from command line using uvicorn if available.""" 141 | try: 142 | import uvicorn 143 | 144 | uvicorn.run( 145 | "stac_fastapi.elasticsearch.app:app", 146 | host=settings.app_host, 147 | port=settings.app_port, 148 | log_level="info", 149 | reload=settings.reload, 150 | ) 151 | except ImportError: 152 | raise RuntimeError("Uvicorn must be installed in order to use command") 153 | 154 | 155 | if __name__ == "__main__": 156 | run() 157 | 158 | 159 | def create_handler(app): 160 | """Create a handler to use with AWS Lambda if mangum available.""" 161 | try: 162 | from mangum import Mangum 163 | 164 | return Mangum(app) 165 | except ImportError: 166 | return None 167 | 168 | 169 | handler = create_handler(app) 170 | -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/config.py: -------------------------------------------------------------------------------- 1 | """API configuration.""" 2 | 3 | import logging 4 | import os 5 | import ssl 6 | from typing import Any, Dict, Set, Union 7 | 8 | import certifi 9 | from elasticsearch._async.client import AsyncElasticsearch 10 | 11 | from elasticsearch import Elasticsearch # type: ignore[attr-defined] 12 | from stac_fastapi.core.base_settings import ApiBaseSettings 13 | from stac_fastapi.core.utilities import get_bool_env 14 | from stac_fastapi.sfeos_helpers.database import validate_refresh 15 | from stac_fastapi.types.config import ApiSettings 16 | 17 | 18 | def _es_config() -> Dict[str, Any]: 19 | # Determine the scheme (http or https) 20 | use_ssl = get_bool_env("ES_USE_SSL", default=True) 21 | scheme = "https" if use_ssl else "http" 22 | 23 | # Configure the hosts parameter with the correct scheme 24 | es_hosts = os.getenv( 25 | "ES_HOST", "localhost" 26 | ).strip() # Default to localhost if ES_HOST is not set 27 | es_port = os.getenv("ES_PORT", "9200") # Default to 9200 if ES_PORT is not set 28 | 29 | # Validate ES_HOST 30 | if not es_hosts: 31 | raise ValueError("ES_HOST environment variable is empty or invalid.") 32 | 33 | hosts = [f"{scheme}://{host.strip()}:{es_port}" for host in es_hosts.split(",")] 34 | 35 | # Initialize the configuration dictionary 36 | config: Dict[str, Any] = { 37 | "hosts": hosts, 38 | "headers": {"accept": "application/vnd.elasticsearch+json; compatible-with=8"}, 39 | } 40 | 41 | # Handle API key 42 | if api_key := os.getenv("ES_API_KEY"): 43 | if isinstance(config["headers"], dict): 44 | headers = {**config["headers"], "x-api-key": api_key} 45 | 46 | else: 47 | config["headers"] = {"x-api-key": api_key} 48 | 49 | config["headers"] = headers 50 | 51 | http_compress = get_bool_env("ES_HTTP_COMPRESS", default=True) 52 | if http_compress: 53 | config["http_compress"] = True 54 | 55 | # Handle authentication 56 | if (u := os.getenv("ES_USER")) and (p := os.getenv("ES_PASS")): 57 | config["http_auth"] = (u, p) 58 | 59 | # Explicitly exclude SSL settings when not using SSL 60 | if not use_ssl: 61 | return config 62 | 63 | # Include SSL settings if using https 64 | config["ssl_version"] = ssl.TLSVersion.TLSv1_3 65 | config["verify_certs"] = get_bool_env("ES_VERIFY_CERTS", default=True) 66 | 67 | # Include CA Certificates if verifying certs 68 | if config["verify_certs"]: 69 | config["ca_certs"] = os.getenv("CURL_CA_BUNDLE", certifi.where()) 70 | 71 | return config 72 | 73 | 74 | _forbidden_fields: Set[str] = {"type"} 75 | 76 | 77 | class ElasticsearchSettings(ApiSettings, ApiBaseSettings): 78 | """ 79 | API settings. 80 | 81 | Set enable_direct_response via the ENABLE_DIRECT_RESPONSE environment variable. 82 | If enabled, all API routes use direct response for maximum performance, but ALL FastAPI dependencies (including authentication, custom status codes, and validation) are disabled. 83 | Default is False for safety. 84 | """ 85 | 86 | forbidden_fields: Set[str] = _forbidden_fields 87 | indexed_fields: Set[str] = {"datetime"} 88 | enable_response_models: bool = False 89 | enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) 90 | raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) 91 | 92 | @property 93 | def database_refresh(self) -> Union[bool, str]: 94 | """ 95 | Get the value of the DATABASE_REFRESH environment variable. 96 | 97 | Returns: 98 | Union[bool, str]: The value of DATABASE_REFRESH, which can be True, False, or "wait_for". 99 | """ 100 | value = os.getenv("DATABASE_REFRESH", "false") 101 | return validate_refresh(value) 102 | 103 | @property 104 | def create_client(self): 105 | """Create es client.""" 106 | return Elasticsearch(**_es_config()) 107 | 108 | 109 | class AsyncElasticsearchSettings(ApiSettings, ApiBaseSettings): 110 | """ 111 | API settings. 112 | 113 | Set enable_direct_response via the ENABLE_DIRECT_RESPONSE environment variable. 114 | If enabled, all API routes use direct response for maximum performance, but ALL FastAPI dependencies (including authentication, custom status codes, and validation) are disabled. 115 | Default is False for safety. 116 | """ 117 | 118 | forbidden_fields: Set[str] = _forbidden_fields 119 | indexed_fields: Set[str] = {"datetime"} 120 | enable_response_models: bool = False 121 | enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) 122 | raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) 123 | 124 | @property 125 | def database_refresh(self) -> Union[bool, str]: 126 | """ 127 | Get the value of the DATABASE_REFRESH environment variable. 128 | 129 | Returns: 130 | Union[bool, str]: The value of DATABASE_REFRESH, which can be True, False, or "wait_for". 131 | """ 132 | value = os.getenv("DATABASE_REFRESH", "false") 133 | return validate_refresh(value) 134 | 135 | @property 136 | def create_client(self): 137 | """Create async elasticsearch client.""" 138 | return AsyncElasticsearch(**_es_config()) 139 | 140 | 141 | # Warn at import if direct response is enabled (applies to either settings class) 142 | if ( 143 | ElasticsearchSettings().enable_direct_response 144 | or AsyncElasticsearchSettings().enable_direct_response 145 | ): 146 | logging.basicConfig(level=logging.WARNING) 147 | logging.warning( 148 | "ENABLE_DIRECT_RESPONSE is True: All FastAPI dependencies (including authentication) are DISABLED for all routes!" 149 | ) 150 | -------------------------------------------------------------------------------- /stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py: -------------------------------------------------------------------------------- 1 | """library version.""" 2 | __version__ = "5.0.0a1" 3 | -------------------------------------------------------------------------------- /stac_fastapi/opensearch/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /stac_fastapi/opensearch/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = -sv 4 | asyncio_mode = auto -------------------------------------------------------------------------------- /stac_fastapi/opensearch/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: stac_fastapi.opensearch.version.__version__ 3 | -------------------------------------------------------------------------------- /stac_fastapi/opensearch/setup.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi: opensearch module.""" 2 | 3 | from setuptools import find_namespace_packages, setup 4 | 5 | with open("README.md") as f: 6 | desc = f.read() 7 | 8 | install_requires = [ 9 | "stac-fastapi-core==5.0.0a1", 10 | "sfeos-helpers==5.0.0a1", 11 | "opensearch-py~=2.8.0", 12 | "opensearch-py[async]~=2.8.0", 13 | "uvicorn~=0.23.0", 14 | "starlette>=0.35.0,<0.36.0", 15 | ] 16 | 17 | extra_reqs = { 18 | "dev": [ 19 | "pytest~=7.0.0", 20 | "pytest-cov~=4.0.0", 21 | "pytest-asyncio~=0.21.0", 22 | "pre-commit~=3.0.0", 23 | "requests>=2.32.0,<3.0.0", 24 | "ciso8601~=2.3.0", 25 | "httpx>=0.24.0,<0.28.0", 26 | ], 27 | "docs": ["mkdocs~=1.4.0", "mkdocs-material~=9.0.0", "pdocs~=1.2.0"], 28 | "server": ["uvicorn[standard]~=0.23.0"], 29 | } 30 | 31 | setup( 32 | name="stac_fastapi_opensearch", 33 | description="Opensearch stac-fastapi backend.", 34 | long_description=desc, 35 | long_description_content_type="text/markdown", 36 | python_requires=">=3.9", 37 | classifiers=[ 38 | "Intended Audience :: Developers", 39 | "Intended Audience :: Information Technology", 40 | "Intended Audience :: Science/Research", 41 | "Programming Language :: Python :: 3.9", 42 | "Programming Language :: Python :: 3.10", 43 | "Programming Language :: Python :: 3.11", 44 | "Programming Language :: Python :: 3.12", 45 | "Programming Language :: Python :: 3.13", 46 | "License :: OSI Approved :: MIT License", 47 | ], 48 | url="https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch", 49 | license="MIT", 50 | packages=find_namespace_packages(), 51 | zip_safe=False, 52 | install_requires=install_requires, 53 | extras_require=extra_reqs, 54 | entry_points={ 55 | "console_scripts": ["stac-fastapi-opensearch=stac_fastapi.opensearch.app:run"] 56 | }, 57 | ) 58 | -------------------------------------------------------------------------------- /stac_fastapi/opensearch/stac_fastapi/opensearch/__init__.py: -------------------------------------------------------------------------------- 1 | """opensearch submodule.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/opensearch/stac_fastapi/opensearch/app.py: -------------------------------------------------------------------------------- 1 | """FastAPI application.""" 2 | 3 | import logging 4 | import os 5 | from contextlib import asynccontextmanager 6 | 7 | from fastapi import FastAPI 8 | 9 | from stac_fastapi.api.app import StacApi 10 | from stac_fastapi.api.models import create_get_request_model, create_post_request_model 11 | from stac_fastapi.core.core import ( 12 | BulkTransactionsClient, 13 | CoreClient, 14 | TransactionsClient, 15 | ) 16 | from stac_fastapi.core.extensions import QueryExtension 17 | from stac_fastapi.core.extensions.aggregation import ( 18 | EsAggregationExtensionGetRequest, 19 | EsAggregationExtensionPostRequest, 20 | ) 21 | from stac_fastapi.core.extensions.fields import FieldsExtension 22 | from stac_fastapi.core.rate_limit import setup_rate_limit 23 | from stac_fastapi.core.route_dependencies import get_route_dependencies 24 | from stac_fastapi.core.session import Session 25 | from stac_fastapi.core.utilities import get_bool_env 26 | from stac_fastapi.extensions.core import ( 27 | AggregationExtension, 28 | FilterExtension, 29 | FreeTextExtension, 30 | SortExtension, 31 | TokenPaginationExtension, 32 | TransactionExtension, 33 | ) 34 | from stac_fastapi.extensions.third_party import BulkTransactionExtension 35 | from stac_fastapi.opensearch.config import OpensearchSettings 36 | from stac_fastapi.opensearch.database_logic import ( 37 | DatabaseLogic, 38 | create_collection_index, 39 | create_index_templates, 40 | ) 41 | from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient 42 | from stac_fastapi.sfeos_helpers.filter import EsAsyncBaseFiltersClient 43 | 44 | logging.basicConfig(level=logging.INFO) 45 | logger = logging.getLogger(__name__) 46 | 47 | TRANSACTIONS_EXTENSIONS = get_bool_env("ENABLE_TRANSACTIONS_EXTENSIONS", default=True) 48 | logger.info("TRANSACTIONS_EXTENSIONS is set to %s", TRANSACTIONS_EXTENSIONS) 49 | 50 | settings = OpensearchSettings() 51 | session = Session.create_from_settings(settings) 52 | 53 | database_logic = DatabaseLogic() 54 | 55 | filter_extension = FilterExtension( 56 | client=EsAsyncBaseFiltersClient(database=database_logic) 57 | ) 58 | filter_extension.conformance_classes.append( 59 | "http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators" 60 | ) 61 | 62 | aggregation_extension = AggregationExtension( 63 | client=EsAsyncBaseAggregationClient( 64 | database=database_logic, session=session, settings=settings 65 | ) 66 | ) 67 | aggregation_extension.POST = EsAggregationExtensionPostRequest 68 | aggregation_extension.GET = EsAggregationExtensionGetRequest 69 | 70 | search_extensions = [ 71 | FieldsExtension(), 72 | QueryExtension(), 73 | SortExtension(), 74 | TokenPaginationExtension(), 75 | filter_extension, 76 | FreeTextExtension(), 77 | ] 78 | 79 | 80 | if TRANSACTIONS_EXTENSIONS: 81 | search_extensions.insert( 82 | 0, 83 | TransactionExtension( 84 | client=TransactionsClient( 85 | database=database_logic, session=session, settings=settings 86 | ), 87 | settings=settings, 88 | ), 89 | ) 90 | search_extensions.insert( 91 | 1, 92 | BulkTransactionExtension( 93 | client=BulkTransactionsClient( 94 | database=database_logic, 95 | session=session, 96 | settings=settings, 97 | ) 98 | ), 99 | ) 100 | 101 | extensions = [aggregation_extension] + search_extensions 102 | 103 | database_logic.extensions = [type(ext).__name__ for ext in extensions] 104 | 105 | post_request_model = create_post_request_model(search_extensions) 106 | 107 | api = StacApi( 108 | title=os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"), 109 | description=os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"), 110 | api_version=os.getenv("STAC_FASTAPI_VERSION", "5.0.0a1"), 111 | settings=settings, 112 | extensions=extensions, 113 | client=CoreClient( 114 | database=database_logic, 115 | session=session, 116 | post_request_model=post_request_model, 117 | landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), 118 | ), 119 | search_get_request_model=create_get_request_model(search_extensions), 120 | search_post_request_model=post_request_model, 121 | route_dependencies=get_route_dependencies(), 122 | ) 123 | 124 | 125 | @asynccontextmanager 126 | async def lifespan(app: FastAPI): 127 | """Lifespan handler for FastAPI app. Initializes index templates and collections at startup.""" 128 | await create_index_templates() 129 | await create_collection_index() 130 | yield 131 | 132 | 133 | app = api.app 134 | app.router.lifespan_context = lifespan 135 | app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "") 136 | # Add rate limit 137 | setup_rate_limit(app, rate_limit=os.getenv("STAC_FASTAPI_RATE_LIMIT")) 138 | 139 | 140 | def run() -> None: 141 | """Run app from command line using uvicorn if available.""" 142 | try: 143 | import uvicorn 144 | 145 | uvicorn.run( 146 | "stac_fastapi.opensearch.app:app", 147 | host=settings.app_host, 148 | port=settings.app_port, 149 | log_level="info", 150 | reload=settings.reload, 151 | ) 152 | except ImportError: 153 | raise RuntimeError("Uvicorn must be installed in order to use command") 154 | 155 | 156 | if __name__ == "__main__": 157 | run() 158 | 159 | 160 | def create_handler(app): 161 | """Create a handler to use with AWS Lambda if mangum available.""" 162 | try: 163 | from mangum import Mangum 164 | 165 | return Mangum(app) 166 | except ImportError: 167 | return None 168 | 169 | 170 | handler = create_handler(app) 171 | -------------------------------------------------------------------------------- /stac_fastapi/opensearch/stac_fastapi/opensearch/config.py: -------------------------------------------------------------------------------- 1 | """API configuration.""" 2 | import logging 3 | import os 4 | import ssl 5 | from typing import Any, Dict, Set, Union 6 | 7 | import certifi 8 | from opensearchpy import AsyncOpenSearch, OpenSearch 9 | 10 | from stac_fastapi.core.base_settings import ApiBaseSettings 11 | from stac_fastapi.core.utilities import get_bool_env 12 | from stac_fastapi.sfeos_helpers.database import validate_refresh 13 | from stac_fastapi.types.config import ApiSettings 14 | 15 | 16 | def _es_config() -> Dict[str, Any]: 17 | # Determine the scheme (http or https) 18 | use_ssl = get_bool_env("ES_USE_SSL", default=True) 19 | scheme = "https" if use_ssl else "http" 20 | 21 | # Configure the hosts parameter with the correct scheme 22 | es_hosts = os.getenv( 23 | "ES_HOST", "localhost" 24 | ).strip() # Default to localhost if ES_HOST is not set 25 | es_port = os.getenv("ES_PORT", "9200") # Default to 9200 if ES_PORT is not set 26 | 27 | # Validate ES_HOST 28 | if not es_hosts: 29 | raise ValueError("ES_HOST environment variable is empty or invalid.") 30 | 31 | hosts = [f"{scheme}://{host.strip()}:{es_port}" for host in es_hosts.split(",")] 32 | 33 | # Initialize the configuration dictionary 34 | config: Dict[str, Any] = { 35 | "hosts": hosts, 36 | "headers": {"accept": "application/json", "Content-Type": "application/json"}, 37 | } 38 | 39 | http_compress = get_bool_env("ES_HTTP_COMPRESS", default=True) 40 | if http_compress: 41 | config["http_compress"] = True 42 | 43 | # Handle authentication 44 | if (u := os.getenv("ES_USER")) and (p := os.getenv("ES_PASS")): 45 | config["http_auth"] = (u, p) 46 | 47 | if api_key := os.getenv("ES_API_KEY"): 48 | if isinstance(config["headers"], dict): 49 | headers = {**config["headers"], "x-api-key": api_key} 50 | 51 | else: 52 | config["headers"] = {"x-api-key": api_key} 53 | 54 | config["headers"] = headers 55 | 56 | # Explicitly exclude SSL settings when not using SSL 57 | if not use_ssl: 58 | return config 59 | 60 | # Include SSL settings if using https 61 | config["ssl_version"] = ssl.PROTOCOL_SSLv23 62 | config["verify_certs"] = get_bool_env("ES_VERIFY_CERTS", default=True) 63 | 64 | # Include CA Certificates if verifying certs 65 | if config["verify_certs"]: 66 | config["ca_certs"] = os.getenv("CURL_CA_BUNDLE", certifi.where()) 67 | 68 | return config 69 | 70 | 71 | _forbidden_fields: Set[str] = {"type"} 72 | 73 | 74 | class OpensearchSettings(ApiSettings, ApiBaseSettings): 75 | """ 76 | API settings. 77 | 78 | Set enable_direct_response via the ENABLE_DIRECT_RESPONSE environment variable. 79 | If enabled, all API routes use direct response for maximum performance, but ALL FastAPI dependencies (including authentication, custom status codes, and validation) are disabled. 80 | Default is False for safety. 81 | """ 82 | 83 | forbidden_fields: Set[str] = _forbidden_fields 84 | indexed_fields: Set[str] = {"datetime"} 85 | enable_response_models: bool = False 86 | enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) 87 | raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) 88 | 89 | @property 90 | def database_refresh(self) -> Union[bool, str]: 91 | """ 92 | Get the value of the DATABASE_REFRESH environment variable. 93 | 94 | Returns: 95 | Union[bool, str]: The value of DATABASE_REFRESH, which can be True, False, or "wait_for". 96 | """ 97 | value = os.getenv("DATABASE_REFRESH", "false") 98 | return validate_refresh(value) 99 | 100 | @property 101 | def create_client(self): 102 | """Create es client.""" 103 | return OpenSearch(**_es_config()) 104 | 105 | 106 | class AsyncOpensearchSettings(ApiSettings, ApiBaseSettings): 107 | """ 108 | API settings. 109 | 110 | Set enable_direct_response via the ENABLE_DIRECT_RESPONSE environment variable. 111 | If enabled, all API routes use direct response for maximum performance, but ALL FastAPI dependencies (including authentication, custom status codes, and validation) are disabled. 112 | Default is False for safety. 113 | """ 114 | 115 | forbidden_fields: Set[str] = _forbidden_fields 116 | indexed_fields: Set[str] = {"datetime"} 117 | enable_response_models: bool = False 118 | enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) 119 | raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) 120 | 121 | @property 122 | def database_refresh(self) -> Union[bool, str]: 123 | """ 124 | Get the value of the DATABASE_REFRESH environment variable. 125 | 126 | Returns: 127 | Union[bool, str]: The value of DATABASE_REFRESH, which can be True, False, or "wait_for". 128 | """ 129 | value = os.getenv("DATABASE_REFRESH", "false") 130 | return validate_refresh(value) 131 | 132 | @property 133 | def create_client(self): 134 | """Create async elasticsearch client.""" 135 | return AsyncOpenSearch(**_es_config()) 136 | 137 | 138 | # Warn at import if direct response is enabled (applies to either settings class) 139 | if ( 140 | OpensearchSettings().enable_direct_response 141 | or AsyncOpensearchSettings().enable_direct_response 142 | ): 143 | logging.basicConfig(level=logging.WARNING) 144 | logging.warning( 145 | "ENABLE_DIRECT_RESPONSE is True: All FastAPI dependencies (including authentication) are DISABLED for all routes!" 146 | ) 147 | -------------------------------------------------------------------------------- /stac_fastapi/opensearch/stac_fastapi/opensearch/version.py: -------------------------------------------------------------------------------- 1 | """library version.""" 2 | __version__ = "5.0.0a1" 3 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: stac_fastapi.sfeos_helpers.version.__version__ 3 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/setup.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi: helpers elasticsearch/ opensearch module.""" 2 | 3 | from setuptools import find_namespace_packages, setup 4 | 5 | with open("README.md") as f: 6 | desc = f.read() 7 | 8 | install_requires = [ 9 | "stac-fastapi.core==5.0.0a1", 10 | ] 11 | 12 | setup( 13 | name="sfeos_helpers", 14 | description="Helper library for the Elasticsearch and Opensearch stac-fastapi backends.", 15 | long_description=desc, 16 | long_description_content_type="text/markdown", 17 | python_requires=">=3.9", 18 | classifiers=[ 19 | "Intended Audience :: Developers", 20 | "Intended Audience :: Information Technology", 21 | "Intended Audience :: Science/Research", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "License :: OSI Approved :: MIT License", 28 | ], 29 | url="https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch", 30 | license="MIT", 31 | packages=find_namespace_packages(), 32 | zip_safe=False, 33 | install_requires=install_requires, 34 | ) 35 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/aggregation/README.md: -------------------------------------------------------------------------------- 1 | # STAC FastAPI Aggregation Package 2 | 3 | This package contains shared aggregation functionality used by both the Elasticsearch and OpenSearch implementations of STAC FastAPI. It helps reduce code duplication and ensures consistent behavior between the two implementations. 4 | 5 | ## Package Structure 6 | 7 | The aggregation package is organized into three main modules: 8 | 9 | - **client.py**: Contains the base aggregation client implementation 10 | - `EsAsyncBaseAggregationClient`: The main class that implements the STAC aggregation extension for Elasticsearch/OpenSearch 11 | - Methods for handling aggregation requests, validating parameters, and formatting responses 12 | 13 | - **format.py**: Contains functions for formatting aggregation responses 14 | - `frequency_agg`: Formats frequency distribution aggregation responses 15 | - `metric_agg`: Formats metric aggregation responses 16 | 17 | - **__init__.py**: Package initialization and exports 18 | - Exports the main classes and functions for use by other modules 19 | 20 | ## Features 21 | 22 | The aggregation package provides the following features: 23 | 24 | - Support for various aggregation types: 25 | - Datetime frequency 26 | - Collection frequency 27 | - Property frequency 28 | - Geospatial grid aggregations (geohash, geohex, geotile) 29 | - Metric aggregations (min, max, etc.) 30 | 31 | - Parameter validation: 32 | - Precision validation for geospatial aggregations 33 | - Interval validation for datetime aggregations 34 | 35 | - Response formatting: 36 | - Consistent response structure 37 | - Proper typing and documentation 38 | 39 | ## Usage 40 | 41 | The aggregation package is used by the Elasticsearch and OpenSearch implementations to provide aggregation functionality for STAC API. The main entry point is the `EsAsyncBaseAggregationClient` class, which is instantiated in the respective app.py files. 42 | 43 | Example: 44 | ```python 45 | from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient 46 | 47 | # Create an instance of the aggregation client 48 | aggregation_client = EsAsyncBaseAggregationClient(database) 49 | 50 | # Register the aggregation extension with the API 51 | api = StacApi( 52 | ..., 53 | extensions=[ 54 | ..., 55 | AggregationExtension(client=aggregation_client), 56 | ], 57 | ) -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/aggregation/__init__.py: -------------------------------------------------------------------------------- 1 | """Shared aggregation extension methods for stac-fastapi elasticsearch and opensearch backends. 2 | 3 | This module provides shared functionality for implementing the STAC API Aggregation Extension 4 | with Elasticsearch and OpenSearch. It includes: 5 | 6 | 1. Functions for formatting aggregation responses 7 | 2. Helper functions for handling aggregation parameters 8 | 3. Base implementation of the AsyncBaseAggregationClient for Elasticsearch/OpenSearch 9 | 10 | The aggregation package is organized as follows: 11 | - client.py: Aggregation client implementation 12 | - format.py: Response formatting functions 13 | 14 | When adding new functionality to this package, consider: 15 | 1. Will this code be used by both Elasticsearch and OpenSearch implementations? 16 | 2. Is the functionality stable and unlikely to diverge between implementations? 17 | 3. Is the function well-documented with clear input/output contracts? 18 | 19 | Function Naming Conventions: 20 | - Function names should be descriptive and indicate their purpose 21 | - Parameter names should be consistent across similar functions 22 | """ 23 | 24 | from .client import EsAsyncBaseAggregationClient 25 | from .format import frequency_agg, metric_agg 26 | 27 | __all__ = [ 28 | "EsAsyncBaseAggregationClient", 29 | "frequency_agg", 30 | "metric_agg", 31 | ] 32 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/aggregation/format.py: -------------------------------------------------------------------------------- 1 | """Formatting functions for aggregation responses.""" 2 | 3 | from datetime import datetime 4 | from typing import Any, Dict 5 | 6 | from stac_fastapi.core.datetime_utils import datetime_to_str 7 | from stac_fastapi.extensions.core.aggregation.types import Aggregation 8 | 9 | 10 | def frequency_agg(es_aggs: Dict[str, Any], name: str, data_type: str) -> Aggregation: 11 | """Format an aggregation for a frequency distribution aggregation. 12 | 13 | Args: 14 | es_aggs: The Elasticsearch/OpenSearch aggregation response 15 | name: The name of the aggregation 16 | data_type: The data type of the aggregation 17 | 18 | Returns: 19 | Aggregation: A formatted aggregation response 20 | """ 21 | buckets = [] 22 | for bucket in es_aggs.get(name, {}).get("buckets", []): 23 | bucket_data = { 24 | "key": bucket.get("key_as_string") or bucket.get("key"), 25 | "data_type": data_type, 26 | "frequency": bucket.get("doc_count"), 27 | "to": bucket.get("to"), 28 | "from": bucket.get("from"), 29 | } 30 | buckets.append(bucket_data) 31 | return Aggregation( 32 | name=name, 33 | data_type="frequency_distribution", 34 | overflow=es_aggs.get(name, {}).get("sum_other_doc_count", 0), 35 | buckets=buckets, 36 | ) 37 | 38 | 39 | def metric_agg(es_aggs: Dict[str, Any], name: str, data_type: str) -> Aggregation: 40 | """Format an aggregation for a metric aggregation. 41 | 42 | Args: 43 | es_aggs: The Elasticsearch/OpenSearch aggregation response 44 | name: The name of the aggregation 45 | data_type: The data type of the aggregation 46 | 47 | Returns: 48 | Aggregation: A formatted aggregation response 49 | """ 50 | value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get(name, {}).get( 51 | "value" 52 | ) 53 | # ES 7.x does not return datetimes with a 'value_as_string' field 54 | if "datetime" in name and isinstance(value, float): 55 | value = datetime_to_str(datetime.fromtimestamp(value / 1e3)) 56 | return Aggregation( 57 | name=name, 58 | data_type=data_type, 59 | value=value, 60 | ) 61 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/README.md: -------------------------------------------------------------------------------- 1 | # STAC FastAPI Database Package 2 | 3 | This package contains shared database operations used by both the Elasticsearch and OpenSearch 4 | implementations of STAC FastAPI. It helps reduce code duplication and ensures consistent behavior 5 | between the two implementations. 6 | 7 | ## Package Structure 8 | 9 | The database package is organized into five main modules: 10 | 11 | - **index.py**: Contains functions for managing indices 12 | - [create_index_templates_shared](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database_logic_helpers.py:15:0-48:33): Creates index templates for Collections and Items 13 | - [delete_item_index_shared](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database_logic_helpers.py:128:0-153:30): Deletes an item index for a collection 14 | - [index_by_collection_id](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/utilities.py:86:0-100:5): Translates a collection ID into an index name 15 | - [index_alias_by_collection_id](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/utilities.py:103:0-115:5): Translates a collection ID into an index alias 16 | - [indices](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/utilities.py:118:0-132:5): Gets a comma-separated string of index names 17 | 18 | - **query.py**: Contains functions for building and manipulating queries 19 | - [apply_free_text_filter_shared](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database_logic_helpers.py:51:0-74:16): Applies a free text filter to a search 20 | - [apply_intersects_filter_shared](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database_logic_helpers.py:77:0-104:5): Creates a geo_shape filter for intersecting geometry 21 | - [populate_sort_shared](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database_logic_helpers.py:107:0-125:16): Creates a sort configuration for queries 22 | 23 | - **mapping.py**: Contains functions for working with mappings 24 | - [get_queryables_mapping_shared](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database_logic_helpers.py:156:0-185:27): Retrieves mapping of Queryables for search 25 | 26 | - **document.py**: Contains functions for working with documents 27 | - [mk_item_id](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/utilities.py:140:0-150:5): Creates a document ID for an Item 28 | - [mk_actions](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/utilities.py:153:0-175:5): Creates bulk actions for indexing items 29 | 30 | - **utils.py**: Contains utility functions for database operations 31 | - [validate_refresh](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/utilities.py:41:0-78:5): Validates the refresh parameter value 32 | 33 | ## Usage 34 | 35 | Import the necessary components from the database package: 36 | 37 | ```python 38 | from stac_fastapi.sfeos_helpers.database import ( 39 | # Index operations 40 | create_index_templates_shared, 41 | delete_item_index_shared, 42 | index_alias_by_collection_id, 43 | index_by_collection_id, 44 | indices, 45 | 46 | # Query operations 47 | apply_free_text_filter_shared, 48 | apply_intersects_filter_shared, 49 | populate_sort_shared, 50 | 51 | # Mapping operations 52 | get_queryables_mapping_shared, 53 | 54 | # Document operations 55 | mk_item_id, 56 | mk_actions, 57 | 58 | # Utility functions 59 | validate_refresh, 60 | ) 61 | ``` 62 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/__init__.py: -------------------------------------------------------------------------------- 1 | """Shared database operations for stac-fastapi elasticsearch and opensearch backends. 2 | 3 | This module provides shared database functionality used by both the Elasticsearch and OpenSearch 4 | implementations of STAC FastAPI. It includes: 5 | 6 | 1. Index management functions for creating and deleting indices 7 | 2. Query building functions for constructing search queries 8 | 3. Mapping functions for working with Elasticsearch/OpenSearch mappings 9 | 4. Document operations for working with documents 10 | 5. Utility functions for database operations 11 | 6. Datetime utilities for query formatting 12 | 13 | The database package is organized as follows: 14 | - index.py: Index management functions 15 | - query.py: Query building functions 16 | - mapping.py: Mapping functions 17 | - document.py: Document operations 18 | - utils.py: Utility functions 19 | - datetime.py: Datetime utilities for query formatting 20 | 21 | When adding new functionality to this package, consider: 22 | 1. Will this code be used by both Elasticsearch and OpenSearch implementations? 23 | 2. Is the functionality stable and unlikely to diverge between implementations? 24 | 3. Is the function well-documented with clear input/output contracts? 25 | 26 | Function Naming Conventions: 27 | - All shared functions should end with `_shared` to clearly indicate they're meant to be used by both implementations 28 | - Function names should be descriptive and indicate their purpose 29 | - Parameter names should be consistent across similar functions 30 | """ 31 | 32 | # Re-export all functions for backward compatibility 33 | from .datetime import return_date 34 | from .document import mk_actions, mk_item_id 35 | from .index import ( 36 | create_index_templates_shared, 37 | delete_item_index_shared, 38 | index_alias_by_collection_id, 39 | index_by_collection_id, 40 | indices, 41 | ) 42 | from .mapping import get_queryables_mapping_shared 43 | from .query import ( 44 | apply_free_text_filter_shared, 45 | apply_intersects_filter_shared, 46 | populate_sort_shared, 47 | ) 48 | from .utils import get_bool_env, validate_refresh 49 | 50 | __all__ = [ 51 | # Index operations 52 | "create_index_templates_shared", 53 | "delete_item_index_shared", 54 | "index_alias_by_collection_id", 55 | "index_by_collection_id", 56 | "indices", 57 | # Query operations 58 | "apply_free_text_filter_shared", 59 | "apply_intersects_filter_shared", 60 | "populate_sort_shared", 61 | # Mapping operations 62 | "get_queryables_mapping_shared", 63 | # Document operations 64 | "mk_item_id", 65 | "mk_actions", 66 | # Utility functions 67 | "validate_refresh", 68 | "get_bool_env", 69 | # Datetime utilities 70 | "return_date", 71 | ] 72 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/datetime.py: -------------------------------------------------------------------------------- 1 | """Elasticsearch/OpenSearch-specific datetime utilities. 2 | 3 | This module provides datetime utility functions specifically designed for 4 | Elasticsearch and OpenSearch query formatting. 5 | """ 6 | 7 | from datetime import datetime as datetime_type 8 | from typing import Dict, Optional, Union 9 | 10 | from stac_fastapi.types.rfc3339 import DateTimeType 11 | 12 | 13 | def return_date( 14 | interval: Optional[Union[DateTimeType, str]] 15 | ) -> Dict[str, Optional[str]]: 16 | """ 17 | Convert a date interval to an Elasticsearch/OpenSearch query format. 18 | 19 | This function converts a date interval (which may be a datetime, a tuple of one or two datetimes, 20 | a string representing a datetime or range, or None) into a dictionary for filtering 21 | search results with Elasticsearch/OpenSearch. 22 | 23 | This function ensures the output dictionary contains 'gte' and 'lte' keys, 24 | even if they are set to None, to prevent KeyError in the consuming logic. 25 | 26 | Args: 27 | interval (Optional[Union[DateTimeType, str]]): The date interval, which might be a single datetime, 28 | a tuple with one or two datetimes, a string, or None. 29 | 30 | Returns: 31 | dict: A dictionary representing the date interval for use in filtering search results, 32 | always containing 'gte' and 'lte' keys. 33 | """ 34 | result: Dict[str, Optional[str]] = {"gte": None, "lte": None} 35 | 36 | if interval is None: 37 | return result 38 | 39 | if isinstance(interval, str): 40 | if "/" in interval: 41 | parts = interval.split("/") 42 | result["gte"] = parts[0] if parts[0] != ".." else None 43 | result["lte"] = parts[1] if len(parts) > 1 and parts[1] != ".." else None 44 | else: 45 | converted_time = interval if interval != ".." else None 46 | result["gte"] = result["lte"] = converted_time 47 | return result 48 | 49 | if isinstance(interval, datetime_type): 50 | datetime_iso = interval.isoformat() 51 | result["gte"] = result["lte"] = datetime_iso 52 | elif isinstance(interval, tuple): 53 | start, end = interval 54 | # Ensure datetimes are converted to UTC and formatted with 'Z' 55 | if start: 56 | result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" 57 | if end: 58 | result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" 59 | 60 | return result 61 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/document.py: -------------------------------------------------------------------------------- 1 | """Document operations for Elasticsearch/OpenSearch. 2 | 3 | This module provides functions for working with documents in Elasticsearch/OpenSearch, 4 | including document ID generation and bulk action creation. 5 | """ 6 | 7 | from typing import Any, Dict, List 8 | 9 | from stac_fastapi.sfeos_helpers.database.index import index_alias_by_collection_id 10 | from stac_fastapi.types.stac import Item 11 | 12 | 13 | def mk_item_id(item_id: str, collection_id: str) -> str: 14 | """Create the document id for an Item in Elasticsearch. 15 | 16 | Args: 17 | item_id (str): The id of the Item. 18 | collection_id (str): The id of the Collection that the Item belongs to. 19 | 20 | Returns: 21 | str: The document id for the Item, combining the Item id and the Collection id, separated by a `|` character. 22 | """ 23 | return f"{item_id}|{collection_id}" 24 | 25 | 26 | def mk_actions(collection_id: str, processed_items: List[Item]) -> List[Dict[str, Any]]: 27 | """Create Elasticsearch bulk actions for a list of processed items. 28 | 29 | Args: 30 | collection_id (str): The identifier for the collection the items belong to. 31 | processed_items (List[Item]): The list of processed items to be bulk indexed. 32 | 33 | Returns: 34 | List[Dict[str, Union[str, Dict]]]: The list of bulk actions to be executed, 35 | each action being a dictionary with the following keys: 36 | - `_index`: the index to store the document in. 37 | - `_id`: the document's identifier. 38 | - `_source`: the source of the document. 39 | """ 40 | index_alias = index_alias_by_collection_id(collection_id) 41 | return [ 42 | { 43 | "_index": index_alias, 44 | "_id": mk_item_id(item["id"], item["collection"]), 45 | "_source": item, 46 | } 47 | for item in processed_items 48 | ] 49 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/index.py: -------------------------------------------------------------------------------- 1 | """Index management functions for Elasticsearch/OpenSearch. 2 | 3 | This module provides functions for creating and managing indices in Elasticsearch/OpenSearch. 4 | """ 5 | 6 | from functools import lru_cache 7 | from typing import Any, List, Optional 8 | 9 | from stac_fastapi.sfeos_helpers.mappings import ( 10 | _ES_INDEX_NAME_UNSUPPORTED_CHARS_TABLE, 11 | COLLECTIONS_INDEX, 12 | ES_COLLECTIONS_MAPPINGS, 13 | ES_ITEMS_MAPPINGS, 14 | ES_ITEMS_SETTINGS, 15 | ITEM_INDICES, 16 | ITEMS_INDEX_PREFIX, 17 | ) 18 | 19 | 20 | @lru_cache(256) 21 | def index_by_collection_id(collection_id: str) -> str: 22 | """ 23 | Translate a collection id into an Elasticsearch index name. 24 | 25 | Args: 26 | collection_id (str): The collection id to translate into an index name. 27 | 28 | Returns: 29 | str: The index name derived from the collection id. 30 | """ 31 | cleaned = collection_id.translate(_ES_INDEX_NAME_UNSUPPORTED_CHARS_TABLE) 32 | return ( 33 | f"{ITEMS_INDEX_PREFIX}{cleaned.lower()}_{collection_id.encode('utf-8').hex()}" 34 | ) 35 | 36 | 37 | @lru_cache(256) 38 | def index_alias_by_collection_id(collection_id: str) -> str: 39 | """ 40 | Translate a collection id into an Elasticsearch index alias. 41 | 42 | Args: 43 | collection_id (str): The collection id to translate into an index alias. 44 | 45 | Returns: 46 | str: The index alias derived from the collection id. 47 | """ 48 | cleaned = collection_id.translate(_ES_INDEX_NAME_UNSUPPORTED_CHARS_TABLE) 49 | return f"{ITEMS_INDEX_PREFIX}{cleaned}" 50 | 51 | 52 | def indices(collection_ids: Optional[List[str]]) -> str: 53 | """ 54 | Get a comma-separated string of index names for a given list of collection ids. 55 | 56 | Args: 57 | collection_ids: A list of collection ids. 58 | 59 | Returns: 60 | A string of comma-separated index names. If `collection_ids` is empty, returns the default indices. 61 | """ 62 | return ( 63 | ",".join(map(index_alias_by_collection_id, collection_ids)) 64 | if collection_ids 65 | else ITEM_INDICES 66 | ) 67 | 68 | 69 | async def create_index_templates_shared(settings: Any) -> None: 70 | """Create index templates for Elasticsearch/OpenSearch Collection and Item indices. 71 | 72 | Args: 73 | settings (Any): The settings object containing the client configuration. 74 | Must have a create_client attribute that returns an Elasticsearch/OpenSearch client. 75 | 76 | Returns: 77 | None: This function doesn't return any value but creates index templates in the database. 78 | 79 | Notes: 80 | This function creates two index templates: 81 | 1. A template for the Collections index with the appropriate mappings 82 | 2. A template for the Items indices with both settings and mappings 83 | 84 | These templates ensure that any new indices created with matching patterns 85 | will automatically have the correct structure. 86 | """ 87 | client = settings.create_client 88 | await client.indices.put_index_template( 89 | name=f"template_{COLLECTIONS_INDEX}", 90 | body={ 91 | "index_patterns": [f"{COLLECTIONS_INDEX}*"], 92 | "template": {"mappings": ES_COLLECTIONS_MAPPINGS}, 93 | }, 94 | ) 95 | await client.indices.put_index_template( 96 | name=f"template_{ITEMS_INDEX_PREFIX}", 97 | body={ 98 | "index_patterns": [f"{ITEMS_INDEX_PREFIX}*"], 99 | "template": {"settings": ES_ITEMS_SETTINGS, "mappings": ES_ITEMS_MAPPINGS}, 100 | }, 101 | ) 102 | await client.close() 103 | 104 | 105 | async def delete_item_index_shared(settings: Any, collection_id: str) -> None: 106 | """Delete the index for items in a collection. 107 | 108 | Args: 109 | settings (Any): The settings object containing the client configuration. 110 | Must have a create_client attribute that returns an Elasticsearch/OpenSearch client. 111 | collection_id (str): The ID of the collection whose items index will be deleted. 112 | 113 | Returns: 114 | None: This function doesn't return any value but deletes an item index in the database. 115 | 116 | Notes: 117 | This function deletes an item index and its alias. It first resolves the alias to find 118 | the actual index name, then deletes both the alias and the index. 119 | """ 120 | client = settings.create_client 121 | 122 | name = index_alias_by_collection_id(collection_id) 123 | resolved = await client.indices.resolve_index(name=name) 124 | if "aliases" in resolved and resolved["aliases"]: 125 | [alias] = resolved["aliases"] 126 | await client.indices.delete_alias(index=alias["indices"], name=alias["name"]) 127 | await client.indices.delete(index=alias["indices"]) 128 | else: 129 | await client.indices.delete(index=name) 130 | await client.close() 131 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/mapping.py: -------------------------------------------------------------------------------- 1 | """Mapping functions for Elasticsearch/OpenSearch. 2 | 3 | This module provides functions for working with Elasticsearch/OpenSearch mappings. 4 | """ 5 | 6 | from typing import Any, Dict 7 | 8 | 9 | async def get_queryables_mapping_shared( 10 | mappings: Dict[str, Dict[str, Any]], collection_id: str = "*" 11 | ) -> Dict[str, str]: 12 | """Retrieve mapping of Queryables for search. 13 | 14 | Args: 15 | mappings (Dict[str, Dict[str, Any]]): The mapping information returned from 16 | Elasticsearch/OpenSearch client's indices.get_mapping() method. 17 | Expected structure is {index_name: {"mappings": {...}}}. 18 | collection_id (str, optional): The id of the Collection the Queryables 19 | belongs to. Defaults to "*". 20 | 21 | Returns: 22 | Dict[str, str]: A dictionary containing the Queryables mappings, where keys are 23 | field names and values are the corresponding paths in the Elasticsearch/OpenSearch 24 | document structure. 25 | """ 26 | queryables_mapping = {} 27 | 28 | for mapping in mappings.values(): 29 | fields = mapping["mappings"].get("properties", {}) 30 | properties = fields.pop("properties", {}).get("properties", {}).keys() 31 | 32 | for field_key in fields: 33 | queryables_mapping[field_key] = field_key 34 | 35 | for property_key in properties: 36 | queryables_mapping[property_key] = f"properties.{property_key}" 37 | 38 | return queryables_mapping 39 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py: -------------------------------------------------------------------------------- 1 | """Query building functions for Elasticsearch/OpenSearch. 2 | 3 | This module provides functions for building and manipulating Elasticsearch/OpenSearch queries. 4 | """ 5 | 6 | from typing import Any, Dict, List, Optional 7 | 8 | from stac_fastapi.sfeos_helpers.mappings import Geometry 9 | 10 | 11 | def apply_free_text_filter_shared( 12 | search: Any, free_text_queries: Optional[List[str]] 13 | ) -> Any: 14 | """Create a free text query for Elasticsearch/OpenSearch. 15 | 16 | Args: 17 | search (Any): The search object to apply the query to. 18 | free_text_queries (Optional[List[str]]): A list of text strings to search for in the properties. 19 | 20 | Returns: 21 | Any: The search object with the free text query applied, or the original search 22 | object if no free_text_queries were provided. 23 | 24 | Notes: 25 | This function creates a query_string query that searches for the specified text strings 26 | in all properties of the documents. The query strings are joined with OR operators. 27 | """ 28 | if free_text_queries is not None: 29 | free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries) 30 | search = search.query( 31 | "query_string", query=f'properties.\\*:"{free_text_query_string}"' 32 | ) 33 | 34 | return search 35 | 36 | 37 | def apply_intersects_filter_shared( 38 | intersects: Geometry, 39 | ) -> Dict[str, Dict]: 40 | """Create a geo_shape filter for intersecting geometry. 41 | 42 | Args: 43 | intersects (Geometry): The intersecting geometry, represented as a GeoJSON-like object. 44 | 45 | Returns: 46 | Dict[str, Dict]: A dictionary containing the geo_shape filter configuration 47 | that can be used with Elasticsearch/OpenSearch Q objects. 48 | 49 | Notes: 50 | This function creates a geo_shape filter configuration to find documents that intersect 51 | with the specified geometry. The returned dictionary should be wrapped in a Q object 52 | when applied to a search. 53 | """ 54 | return { 55 | "geo_shape": { 56 | "geometry": { 57 | "shape": { 58 | "type": intersects.type.lower(), 59 | "coordinates": intersects.coordinates, 60 | }, 61 | "relation": "intersects", 62 | } 63 | } 64 | } 65 | 66 | 67 | def populate_sort_shared(sortby: List) -> Optional[Dict[str, Dict[str, str]]]: 68 | """Create a sort configuration for Elasticsearch/OpenSearch queries. 69 | 70 | Args: 71 | sortby (List): A list of sort specifications, each containing a field and direction. 72 | 73 | Returns: 74 | Optional[Dict[str, Dict[str, str]]]: A dictionary mapping field names to sort direction 75 | configurations, or None if no sort was specified. 76 | 77 | Notes: 78 | This function transforms a list of sort specifications into the format required by 79 | Elasticsearch/OpenSearch for sorting query results. The returned dictionary can be 80 | directly used in search requests. 81 | """ 82 | if sortby: 83 | return {s.field: {"order": s.direction} for s in sortby} 84 | else: 85 | return None 86 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for database operations in Elasticsearch/OpenSearch. 2 | 3 | This module provides utility functions for working with database operations 4 | in Elasticsearch/OpenSearch, such as parameter validation. 5 | """ 6 | 7 | import logging 8 | from typing import Union 9 | 10 | from stac_fastapi.core.utilities import get_bool_env 11 | 12 | 13 | def validate_refresh(value: Union[str, bool]) -> str: 14 | """ 15 | Validate the `refresh` parameter value. 16 | 17 | Args: 18 | value (Union[str, bool]): The `refresh` parameter value, which can be a string or a boolean. 19 | 20 | Returns: 21 | str: The validated value of the `refresh` parameter, which can be "true", "false", or "wait_for". 22 | """ 23 | logger = logging.getLogger(__name__) 24 | 25 | # Handle boolean-like values using get_bool_env 26 | if isinstance(value, bool) or value in { 27 | "true", 28 | "false", 29 | "1", 30 | "0", 31 | "yes", 32 | "no", 33 | "y", 34 | "n", 35 | }: 36 | is_true = get_bool_env("DATABASE_REFRESH", default=value) 37 | return "true" if is_true else "false" 38 | 39 | # Normalize to lowercase for case-insensitivity 40 | value = value.lower() 41 | 42 | # Handle "wait_for" explicitly 43 | if value == "wait_for": 44 | return "wait_for" 45 | 46 | # Log a warning for invalid values and default to "false" 47 | logger.warning( 48 | f"Invalid value for `refresh`: '{value}'. Expected 'true', 'false', or 'wait_for'. Defaulting to 'false'." 49 | ) 50 | return "false" 51 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter/README.md: -------------------------------------------------------------------------------- 1 | # STAC FastAPI Filter Package 2 | 3 | This package contains shared filter extension functionality used by both the Elasticsearch and OpenSearch 4 | implementations of STAC FastAPI. It helps reduce code duplication and ensures consistent behavior 5 | between the two implementations. 6 | 7 | ## Package Structure 8 | 9 | The filter package is organized into three main modules: 10 | 11 | - **cql2.py**: Contains functions for converting CQL2 patterns to Elasticsearch/OpenSearch compatible formats 12 | - [cql2_like_to_es](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter.py:59:0-75:5): Converts CQL2 "LIKE" characters to Elasticsearch "wildcard" characters 13 | - [_replace_like_patterns](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter.py:51:0-56:71): Helper function for pattern replacement 14 | 15 | - **transform.py**: Contains functions for transforming CQL2 queries to Elasticsearch/OpenSearch query DSL 16 | - [to_es_field](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter.py:83:0-93:47): Maps field names using queryables mapping 17 | - [to_es](cci:1://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter.py:96:0-201:13): Transforms CQL2 query structures to Elasticsearch/OpenSearch query DSL 18 | 19 | - **client.py**: Contains the base filter client implementation 20 | - [EsAsyncBaseFiltersClient](cci:2://file:///home/computer/Code/stac-fastapi-elasticsearch-opensearch/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter.py:209:0-293:25): Base class for implementing the STAC filter extension 21 | 22 | ## Usage 23 | 24 | Import the necessary components from the filter package: 25 | 26 | ```python 27 | from stac_fastapi.sfeos_helpers.filter import cql2_like_to_es, to_es, EsAsyncBaseFiltersClient -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter/__init__.py: -------------------------------------------------------------------------------- 1 | """Shared filter extension methods for stac-fastapi elasticsearch and opensearch backends. 2 | 3 | This module provides shared functionality for implementing the STAC API Filter Extension 4 | with Elasticsearch and OpenSearch. It includes: 5 | 6 | 1. Functions for converting CQL2 queries to Elasticsearch/OpenSearch query DSL 7 | 2. Helper functions for field mapping and query transformation 8 | 3. Base implementation of the AsyncBaseFiltersClient for Elasticsearch/OpenSearch 9 | 10 | The filter package is organized as follows: 11 | - cql2.py: CQL2 pattern conversion helpers 12 | - transform.py: Query transformation functions 13 | - client.py: Filter client implementation 14 | 15 | When adding new functionality to this package, consider: 16 | 1. Will this code be used by both Elasticsearch and OpenSearch implementations? 17 | 2. Is the functionality stable and unlikely to diverge between implementations? 18 | 3. Is the function well-documented with clear input/output contracts? 19 | 20 | Function Naming Conventions: 21 | - Function names should be descriptive and indicate their purpose 22 | - Parameter names should be consistent across similar functions 23 | """ 24 | 25 | from .client import EsAsyncBaseFiltersClient 26 | 27 | # Re-export the main functions and classes for backward compatibility 28 | from .cql2 import ( 29 | _replace_like_patterns, 30 | cql2_like_patterns, 31 | cql2_like_to_es, 32 | valid_like_substitutions, 33 | ) 34 | from .transform import to_es, to_es_field 35 | 36 | __all__ = [ 37 | "cql2_like_patterns", 38 | "valid_like_substitutions", 39 | "cql2_like_to_es", 40 | "_replace_like_patterns", 41 | "to_es_field", 42 | "to_es", 43 | "EsAsyncBaseFiltersClient", 44 | ] 45 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter/client.py: -------------------------------------------------------------------------------- 1 | """Filter client implementation for Elasticsearch/OpenSearch.""" 2 | 3 | from collections import deque 4 | from typing import Any, Dict, Optional 5 | 6 | import attr 7 | 8 | from stac_fastapi.core.base_database_logic import BaseDatabaseLogic 9 | from stac_fastapi.core.extensions.filter import DEFAULT_QUERYABLES 10 | from stac_fastapi.extensions.core.filter.client import AsyncBaseFiltersClient 11 | from stac_fastapi.sfeos_helpers.mappings import ES_MAPPING_TYPE_TO_JSON 12 | 13 | 14 | @attr.s 15 | class EsAsyncBaseFiltersClient(AsyncBaseFiltersClient): 16 | """Defines a pattern for implementing the STAC filter extension.""" 17 | 18 | database: BaseDatabaseLogic = attr.ib() 19 | 20 | async def get_queryables( 21 | self, collection_id: Optional[str] = None, **kwargs 22 | ) -> Dict[str, Any]: 23 | """Get the queryables available for the given collection_id. 24 | 25 | If collection_id is None, returns the intersection of all 26 | queryables over all collections. 27 | 28 | This base implementation returns a blank queryable schema. This is not allowed 29 | under OGC CQL but it is allowed by the STAC API Filter Extension 30 | 31 | https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables 32 | 33 | Args: 34 | collection_id (str, optional): The id of the collection to get queryables for. 35 | **kwargs: additional keyword arguments 36 | 37 | Returns: 38 | Dict[str, Any]: A dictionary containing the queryables for the given collection. 39 | """ 40 | queryables: Dict[str, Any] = { 41 | "$schema": "https://json-schema.org/draft/2019-09/schema", 42 | "$id": "https://stac-api.example.com/queryables", 43 | "type": "object", 44 | "title": "Queryables for STAC API", 45 | "description": "Queryable names for the STAC API Item Search filter.", 46 | "properties": DEFAULT_QUERYABLES, 47 | "additionalProperties": True, 48 | } 49 | if not collection_id: 50 | return queryables 51 | 52 | properties: Dict[str, Any] = queryables["properties"] 53 | queryables.update( 54 | { 55 | "properties": properties, 56 | "additionalProperties": False, 57 | } 58 | ) 59 | 60 | mapping_data = await self.database.get_items_mapping(collection_id) 61 | mapping_properties = next(iter(mapping_data.values()))["mappings"]["properties"] 62 | stack = deque(mapping_properties.items()) 63 | 64 | while stack: 65 | field_name, field_def = stack.popleft() 66 | 67 | # Iterate over nested fields 68 | field_properties = field_def.get("properties") 69 | if field_properties: 70 | # Fields in Item Properties should be exposed with their un-prefixed names, 71 | # and not require expressions to prefix them with properties, 72 | # e.g., eo:cloud_cover instead of properties.eo:cloud_cover. 73 | if field_name == "properties": 74 | stack.extend(field_properties.items()) 75 | else: 76 | stack.extend( 77 | (f"{field_name}.{k}", v) for k, v in field_properties.items() 78 | ) 79 | 80 | # Skip non-indexed or disabled fields 81 | field_type = field_def.get("type") 82 | if not field_type or not field_def.get("enabled", True): 83 | continue 84 | 85 | # Generate field properties 86 | field_result = DEFAULT_QUERYABLES.get(field_name, {}) 87 | properties[field_name] = field_result 88 | 89 | field_name_human = field_name.replace("_", " ").title() 90 | field_result.setdefault("title", field_name_human) 91 | 92 | field_type_json = ES_MAPPING_TYPE_TO_JSON.get(field_type, field_type) 93 | field_result.setdefault("type", field_type_json) 94 | 95 | if field_type in {"date", "date_nanos"}: 96 | field_result.setdefault("format", "date-time") 97 | 98 | return queryables 99 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter/cql2.py: -------------------------------------------------------------------------------- 1 | """CQL2 pattern conversion helpers for Elasticsearch/OpenSearch.""" 2 | 3 | import re 4 | 5 | cql2_like_patterns = re.compile(r"\\.|[%_]|\\$") 6 | valid_like_substitutions = { 7 | "\\\\": "\\", 8 | "\\%": "%", 9 | "\\_": "_", 10 | "%": "*", 11 | "_": "?", 12 | } 13 | 14 | 15 | def _replace_like_patterns(match: re.Match) -> str: 16 | pattern = match.group() 17 | try: 18 | return valid_like_substitutions[pattern] 19 | except KeyError: 20 | raise ValueError(f"'{pattern}' is not a valid escape sequence") 21 | 22 | 23 | def cql2_like_to_es(string: str) -> str: 24 | """ 25 | Convert CQL2 "LIKE" characters to Elasticsearch "wildcard" characters. 26 | 27 | Args: 28 | string (str): The string containing CQL2 wildcard characters. 29 | 30 | Returns: 31 | str: The converted string with Elasticsearch compatible wildcards. 32 | 33 | Raises: 34 | ValueError: If an invalid escape sequence is encountered. 35 | """ 36 | return cql2_like_patterns.sub( 37 | repl=_replace_like_patterns, 38 | string=string, 39 | ) 40 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter/transform.py: -------------------------------------------------------------------------------- 1 | """Query transformation functions for Elasticsearch/OpenSearch.""" 2 | 3 | from typing import Any, Dict 4 | 5 | from stac_fastapi.core.extensions.filter import ( 6 | AdvancedComparisonOp, 7 | ComparisonOp, 8 | LogicalOp, 9 | SpatialOp, 10 | ) 11 | 12 | from .cql2 import cql2_like_to_es 13 | 14 | 15 | def to_es_field(queryables_mapping: Dict[str, Any], field: str) -> str: 16 | """ 17 | Map a given field to its corresponding Elasticsearch field according to a predefined mapping. 18 | 19 | Args: 20 | field (str): The field name from a user query or filter. 21 | 22 | Returns: 23 | str: The mapped field name suitable for Elasticsearch queries. 24 | """ 25 | return queryables_mapping.get(field, field) 26 | 27 | 28 | def to_es(queryables_mapping: Dict[str, Any], query: Dict[str, Any]) -> Dict[str, Any]: 29 | """ 30 | Transform a simplified CQL2 query structure to an Elasticsearch compatible query DSL. 31 | 32 | Args: 33 | query (Dict[str, Any]): The query dictionary containing 'op' and 'args'. 34 | 35 | Returns: 36 | Dict[str, Any]: The corresponding Elasticsearch query in the form of a dictionary. 37 | """ 38 | if query["op"] in [LogicalOp.AND, LogicalOp.OR, LogicalOp.NOT]: 39 | bool_type = { 40 | LogicalOp.AND: "must", 41 | LogicalOp.OR: "should", 42 | LogicalOp.NOT: "must_not", 43 | }[query["op"]] 44 | return { 45 | "bool": { 46 | bool_type: [ 47 | to_es(queryables_mapping, sub_query) for sub_query in query["args"] 48 | ] 49 | } 50 | } 51 | 52 | elif query["op"] in [ 53 | ComparisonOp.EQ, 54 | ComparisonOp.NEQ, 55 | ComparisonOp.LT, 56 | ComparisonOp.LTE, 57 | ComparisonOp.GT, 58 | ComparisonOp.GTE, 59 | ]: 60 | range_op = { 61 | ComparisonOp.LT: "lt", 62 | ComparisonOp.LTE: "lte", 63 | ComparisonOp.GT: "gt", 64 | ComparisonOp.GTE: "gte", 65 | } 66 | 67 | field = to_es_field(queryables_mapping, query["args"][0]["property"]) 68 | value = query["args"][1] 69 | if isinstance(value, dict) and "timestamp" in value: 70 | value = value["timestamp"] 71 | if query["op"] == ComparisonOp.EQ: 72 | return {"range": {field: {"gte": value, "lte": value}}} 73 | elif query["op"] == ComparisonOp.NEQ: 74 | return { 75 | "bool": { 76 | "must_not": [{"range": {field: {"gte": value, "lte": value}}}] 77 | } 78 | } 79 | else: 80 | return {"range": {field: {range_op[query["op"]]: value}}} 81 | else: 82 | if query["op"] == ComparisonOp.EQ: 83 | return {"term": {field: value}} 84 | elif query["op"] == ComparisonOp.NEQ: 85 | return {"bool": {"must_not": [{"term": {field: value}}]}} 86 | else: 87 | return {"range": {field: {range_op[query["op"]]: value}}} 88 | 89 | elif query["op"] == ComparisonOp.IS_NULL: 90 | field = to_es_field(queryables_mapping, query["args"][0]["property"]) 91 | return {"bool": {"must_not": {"exists": {"field": field}}}} 92 | 93 | elif query["op"] == AdvancedComparisonOp.BETWEEN: 94 | field = to_es_field(queryables_mapping, query["args"][0]["property"]) 95 | gte, lte = query["args"][1], query["args"][2] 96 | if isinstance(gte, dict) and "timestamp" in gte: 97 | gte = gte["timestamp"] 98 | if isinstance(lte, dict) and "timestamp" in lte: 99 | lte = lte["timestamp"] 100 | return {"range": {field: {"gte": gte, "lte": lte}}} 101 | 102 | elif query["op"] == AdvancedComparisonOp.IN: 103 | field = to_es_field(queryables_mapping, query["args"][0]["property"]) 104 | values = query["args"][1] 105 | if not isinstance(values, list): 106 | raise ValueError(f"Arg {values} is not a list") 107 | return {"terms": {field: values}} 108 | 109 | elif query["op"] == AdvancedComparisonOp.LIKE: 110 | field = to_es_field(queryables_mapping, query["args"][0]["property"]) 111 | pattern = cql2_like_to_es(query["args"][1]) 112 | return {"wildcard": {field: {"value": pattern, "case_insensitive": True}}} 113 | 114 | elif query["op"] in [ 115 | SpatialOp.S_INTERSECTS, 116 | SpatialOp.S_CONTAINS, 117 | SpatialOp.S_WITHIN, 118 | SpatialOp.S_DISJOINT, 119 | ]: 120 | field = to_es_field(queryables_mapping, query["args"][0]["property"]) 121 | geometry = query["args"][1] 122 | 123 | relation_mapping = { 124 | SpatialOp.S_INTERSECTS: "intersects", 125 | SpatialOp.S_CONTAINS: "contains", 126 | SpatialOp.S_WITHIN: "within", 127 | SpatialOp.S_DISJOINT: "disjoint", 128 | } 129 | 130 | relation = relation_mapping[query["op"]] 131 | return {"geo_shape": {field: {"shape": geometry, "relation": relation}}} 132 | 133 | return {} 134 | -------------------------------------------------------------------------------- /stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py: -------------------------------------------------------------------------------- 1 | """library version.""" 2 | __version__ = "5.0.0a1" 3 | -------------------------------------------------------------------------------- /stac_fastapi/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/stac_fastapi/tests/__init__.py -------------------------------------------------------------------------------- /stac_fastapi/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/stac_fastapi/tests/api/__init__.py -------------------------------------------------------------------------------- /stac_fastapi/tests/basic_auth/test_basic_auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_get_search_not_authenticated(app_client_basic_auth, ctx): 8 | """Test public endpoint [GET /search] without authentication""" 9 | if not os.getenv("BASIC_AUTH"): 10 | pytest.skip() 11 | params = {"id": ctx.item["id"]} 12 | 13 | response = await app_client_basic_auth.get("/search", params=params) 14 | 15 | assert response.status_code == 200, response 16 | assert response.json()["features"][0]["geometry"] == ctx.item["geometry"] 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_post_search_authenticated(app_client_basic_auth, ctx): 21 | """Test protected endpoint [POST /search] with reader authentication""" 22 | if not os.getenv("BASIC_AUTH"): 23 | pytest.skip() 24 | params = {"id": ctx.item["id"]} 25 | headers = {"Authorization": "Basic cmVhZGVyOnJlYWRlcg=="} 26 | 27 | response = await app_client_basic_auth.post("/search", json=params, headers=headers) 28 | 29 | assert response.status_code == 200, response 30 | assert response.json()["features"][0]["geometry"] == ctx.item["geometry"] 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_delete_resource_anonymous( 35 | app_client_basic_auth, 36 | ): 37 | """Test protected endpoint [DELETE /collections/{collection_id}] without authentication""" 38 | if not os.getenv("BASIC_AUTH"): 39 | pytest.skip() 40 | 41 | response = await app_client_basic_auth.delete("/collections/test-collection") 42 | 43 | assert response.status_code == 401 44 | assert response.json() == {"detail": "Not authenticated"} 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_delete_resource_invalid_credentials(app_client_basic_auth, ctx): 49 | """Test protected endpoint [DELETE /collections/{collection_id}] with invalid credentials""" 50 | if not os.getenv("BASIC_AUTH"): 51 | pytest.skip() 52 | 53 | headers = {"Authorization": "Basic YWRtaW46cGFzc3dvcmQ="} 54 | 55 | response = await app_client_basic_auth.delete( 56 | f"/collections/{ctx.collection['id']}", headers=headers 57 | ) 58 | 59 | assert response.status_code == 401 60 | assert response.json() == {"detail": "Incorrect username or password"} 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_delete_resource_insufficient_permissions(app_client_basic_auth, ctx): 65 | """Test protected endpoint [DELETE /collections/{collection_id}] with reader user which has insufficient permissions""" 66 | if not os.getenv("BASIC_AUTH"): 67 | pytest.skip() 68 | 69 | headers = {"Authorization": "Basic cmVhZGVyOnJlYWRlcg=="} 70 | 71 | response = await app_client_basic_auth.delete( 72 | f"/collections/{ctx.collection['id']}", headers=headers 73 | ) 74 | 75 | assert response.status_code == 403 76 | assert response.json() == { 77 | "detail": "Insufficient permissions for [DELETE /collections/{collection_id}]" 78 | } 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_delete_resource_sufficient_permissions(app_client_basic_auth, ctx): 83 | """Test protected endpoint [DELETE /collections/{collection_id}] with admin user which has sufficient permissions""" 84 | if not os.getenv("BASIC_AUTH"): 85 | pytest.skip() 86 | 87 | headers = {"Authorization": "Basic YWRtaW46YWRtaW4="} 88 | 89 | response = await app_client_basic_auth.delete( 90 | f"/collections/{ctx.collection['id']}", headers=headers 91 | ) 92 | 93 | assert response.status_code == 204 94 | -------------------------------------------------------------------------------- /stac_fastapi/tests/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/stac_fastapi/tests/clients/__init__.py -------------------------------------------------------------------------------- /stac_fastapi/tests/config/test_config_settings.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import pytest 4 | 5 | 6 | def get_settings_class(): 7 | """ 8 | Try to import ElasticsearchSettings or OpenSearchSettings, whichever is available. 9 | Returns a tuple: (settings_class, config_module) 10 | """ 11 | try: 12 | config = importlib.import_module("stac_fastapi.elasticsearch.config") 13 | importlib.reload(config) 14 | return config.ElasticsearchSettings, config 15 | except ModuleNotFoundError: 16 | try: 17 | config = importlib.import_module("stac_fastapi.opensearch.config") 18 | importlib.reload(config) 19 | return config.OpensearchSettings, config 20 | except ModuleNotFoundError: 21 | pytest.skip( 22 | "Neither Elasticsearch nor OpenSearch config module is available." 23 | ) 24 | 25 | 26 | def test_enable_direct_response_true(monkeypatch): 27 | """Test that ENABLE_DIRECT_RESPONSE env var enables direct response config.""" 28 | monkeypatch.setenv("ENABLE_DIRECT_RESPONSE", "true") 29 | settings_class, _ = get_settings_class() 30 | settings = settings_class() 31 | assert settings.enable_direct_response is True 32 | 33 | 34 | def test_enable_direct_response_false(monkeypatch): 35 | """Test that ENABLE_DIRECT_RESPONSE env var disables direct response config.""" 36 | monkeypatch.setenv("ENABLE_DIRECT_RESPONSE", "false") 37 | settings_class, _ = get_settings_class() 38 | settings = settings_class() 39 | assert settings.enable_direct_response is False 40 | 41 | 42 | def test_database_refresh_true(monkeypatch): 43 | """Test that DATABASE_REFRESH env var enables database refresh.""" 44 | monkeypatch.setenv("DATABASE_REFRESH", "true") 45 | settings_class, _ = get_settings_class() 46 | settings = settings_class() 47 | assert settings.database_refresh == "true" 48 | 49 | 50 | def test_database_refresh_false(monkeypatch): 51 | """Test that DATABASE_REFRESH env var disables database refresh.""" 52 | monkeypatch.setenv("DATABASE_REFRESH", "false") 53 | settings_class, _ = get_settings_class() 54 | settings = settings_class() 55 | assert settings.database_refresh == "false" 56 | 57 | 58 | def test_database_refresh_wait_for(monkeypatch): 59 | """Test that DATABASE_REFRESH env var sets database refresh to 'wait_for'.""" 60 | monkeypatch.setenv("DATABASE_REFRESH", "wait_for") 61 | settings_class, _ = get_settings_class() 62 | settings = settings_class() 63 | assert settings.database_refresh == "wait_for" 64 | -------------------------------------------------------------------------------- /stac_fastapi/tests/data/test_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "test-collection", 3 | "stac_extensions": ["https://stac-extensions.github.io/eo/v1.0.0/schema.json"], 4 | "type": "Collection", 5 | "description": "Landat 8 imagery radiometrically calibrated and orthorectified using gound points and Digital Elevation Model (DEM) data to correct relief displacement.", 6 | "stac_version": "1.0.0", 7 | "license": "PDDL-1.0", 8 | "summaries": { 9 | "platform": ["landsat-8"], 10 | "instruments": ["oli", "tirs"], 11 | "gsd": [30] 12 | }, 13 | "extent": { 14 | "spatial": { 15 | "bbox": [ 16 | [ 17 | -180.0, 18 | -90.0, 19 | 180.0, 20 | 90.0 21 | ] 22 | ] 23 | }, 24 | "temporal": { 25 | "interval": [ 26 | [ 27 | "2013-06-01", 28 | null 29 | ] 30 | ] 31 | } 32 | }, 33 | "aggregations": [ 34 | { 35 | "name": "total_count", 36 | "data_type": "integer" 37 | }, 38 | { 39 | "name": "datetime_max", 40 | "data_type": "datetime" 41 | }, 42 | { 43 | "name": "datetime_min", 44 | "data_type": "datetime" 45 | }, 46 | { 47 | "name": "datetime_frequency", 48 | "data_type": "frequency_distribution", 49 | "frequency_distribution_data_type": "datetime" 50 | }, 51 | { 52 | "name": "sun_elevation_frequency", 53 | "data_type": "frequency_distribution", 54 | "frequency_distribution_data_type": "numeric" 55 | }, 56 | { 57 | "name": "platform_frequency", 58 | "data_type": "frequency_distribution", 59 | "frequency_distribution_data_type": "string" 60 | }, 61 | { 62 | "name": "sun_azimuth_frequency", 63 | "data_type": "frequency_distribution", 64 | "frequency_distribution_data_type": "numeric" 65 | }, 66 | { 67 | "name": "off_nadir_frequency", 68 | "data_type": "frequency_distribution", 69 | "frequency_distribution_data_type": "numeric" 70 | }, 71 | { 72 | "name": "cloud_cover_frequency", 73 | "data_type": "frequency_distribution", 74 | "frequency_distribution_data_type": "numeric" 75 | }, 76 | { 77 | "name": "grid_code_frequency", 78 | "data_type": "frequency_distribution", 79 | "frequency_distribution_data_type": "string" 80 | }, 81 | { 82 | "name": "centroid_geohash_grid_frequency", 83 | "data_type": "frequency_distribution", 84 | "frequency_distribution_data_type": "string" 85 | }, 86 | { 87 | "name": "centroid_geohex_grid_frequency", 88 | "data_type": "frequency_distribution", 89 | "frequency_distribution_data_type": "string" 90 | }, 91 | { 92 | "name": "centroid_geotile_grid_frequency", 93 | "data_type": "frequency_distribution", 94 | "frequency_distribution_data_type": "string" 95 | }, 96 | { 97 | "name": "geometry_geohash_grid_frequency", 98 | "data_type": "frequency_distribution", 99 | "frequency_distribution_data_type": "numeric" 100 | }, 101 | { 102 | "name": "geometry_geotile_grid_frequency", 103 | "data_type": "frequency_distribution", 104 | "frequency_distribution_data_type": "string" 105 | } 106 | ], 107 | "links": [ 108 | { 109 | "href": "http://localhost:8081/collections/landsat-8-l1", 110 | "rel": "self", 111 | "type": "application/json" 112 | }, 113 | { 114 | "href": "http://localhost:8081/", 115 | "rel": "parent", 116 | "type": "application/json" 117 | }, 118 | { 119 | "href": "http://localhost:8081/collections/landsat-8-l1/items", 120 | "rel": "item", 121 | "type": "application/geo+json" 122 | }, 123 | { 124 | "href": "http://localhost:8081/", 125 | "rel": "root", 126 | "type": "application/json" 127 | } 128 | ], 129 | "title": "Landsat 8 L1", 130 | "keywords": [ 131 | "landsat", 132 | "earth observation", 133 | "usgs" 134 | ], 135 | "providers": [ 136 | { 137 | "name": "USGS", 138 | "roles": [ 139 | "producer" 140 | ], 141 | "url": "https://landsat.usgs.gov/" 142 | }, 143 | { 144 | "name": "Planet Labs", 145 | "roles": [ 146 | "processor" 147 | ], 148 | "url": "https://github.com/landsat-pds/landsat_ingestor" 149 | }, 150 | { 151 | "name": "AWS", 152 | "roles": [ 153 | "host" 154 | ], 155 | "url": "https://landsatonaws.com/" 156 | }, 157 | { 158 | "name": "Development Seed", 159 | "roles": [ 160 | "processor" 161 | ], 162 | "url": "https://github.com/sat-utils/sat-api" 163 | }, 164 | { 165 | "name": "Earth Search by Element84", 166 | "description": "API of Earth on AWS datasets", 167 | "roles": [ 168 | "host" 169 | ], 170 | "url": "https://element84.com" 171 | } 172 | ] 173 | } -------------------------------------------------------------------------------- /stac_fastapi/tests/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/stac_fastapi/tests/database/__init__.py -------------------------------------------------------------------------------- /stac_fastapi/tests/database/test_database.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | from stac_pydantic import api 5 | 6 | from stac_fastapi.sfeos_helpers.database import index_alias_by_collection_id 7 | from stac_fastapi.sfeos_helpers.mappings import ( 8 | COLLECTIONS_INDEX, 9 | ES_COLLECTIONS_MAPPINGS, 10 | ES_ITEMS_MAPPINGS, 11 | ) 12 | 13 | from ..conftest import MockRequest, database 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_index_mapping_collections(ctx): 18 | response = await database.client.indices.get_mapping(index=COLLECTIONS_INDEX) 19 | if not isinstance(response, dict): 20 | response = response.body 21 | actual_mappings = next(iter(response.values()))["mappings"] 22 | assert ( 23 | actual_mappings["dynamic_templates"] 24 | == ES_COLLECTIONS_MAPPINGS["dynamic_templates"] 25 | ) 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_index_mapping_items(txn_client, load_test_data): 30 | collection = load_test_data("test_collection.json") 31 | collection["id"] = str(uuid.uuid4()) 32 | await txn_client.create_collection( 33 | api.Collection(**collection), request=MockRequest 34 | ) 35 | response = await database.client.indices.get_mapping( 36 | index=index_alias_by_collection_id(collection["id"]) 37 | ) 38 | if not isinstance(response, dict): 39 | response = response.body 40 | actual_mappings = next(iter(response.values()))["mappings"] 41 | assert ( 42 | actual_mappings["dynamic_templates"] == ES_ITEMS_MAPPINGS["dynamic_templates"] 43 | ) 44 | await txn_client.delete_collection(collection["id"]) 45 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/stac_fastapi/tests/extensions/__init__.py -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example01.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "=", 3 | "args": [ 4 | { 5 | "property": "scene_id" 6 | }, 7 | "LC82030282019133LGN00" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example04.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": "<", 6 | "args": [ 7 | { 8 | "property": "cloud_cover" 9 | }, 10 | 0.1 11 | ] 12 | }, 13 | { 14 | "op": "=", 15 | "args": [ 16 | { 17 | "property": "landsat:wrs_row" 18 | }, 19 | 28 20 | ] 21 | }, 22 | { 23 | "op": "=", 24 | "args": [ 25 | { 26 | "property": "landsat:wrs_path" 27 | }, 28 | 203 29 | ] 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example05a.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "or", 3 | "args": [ 4 | { 5 | "op": "=", 6 | "args": [ 7 | { 8 | "property": "cloud_cover" 9 | }, 10 | 0.1 11 | ] 12 | }, 13 | { 14 | "op": "=", 15 | "args": [ 16 | { 17 | "property": "cloud_cover" 18 | }, 19 | 0.2 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example06b.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": ">=", 6 | "args": [ 7 | { 8 | "property": "cloud_cover" 9 | }, 10 | 0.1 11 | ] 12 | }, 13 | { 14 | "op": "<=", 15 | "args": [ 16 | { 17 | "property": "cloud_cover" 18 | }, 19 | 0.2 20 | ] 21 | }, 22 | { 23 | "op": "=", 24 | "args": [ 25 | { 26 | "property": "landsat:wrs_row" 27 | }, 28 | 28 29 | ] 30 | }, 31 | { 32 | "op": "=", 33 | "args": [ 34 | { 35 | "property": "landsat:wrs_path" 36 | }, 37 | 203 38 | ] 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example08.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": "=", 6 | "args": [ 7 | { 8 | "property": "beamMode" 9 | }, 10 | "ScanSAR Narrow" 11 | ] 12 | }, 13 | { 14 | "op": "=", 15 | "args": [ 16 | { 17 | "property": "swathDirection" 18 | }, 19 | "ascending" 20 | ] 21 | }, 22 | { 23 | "op": "=", 24 | "args": [ 25 | { 26 | "property": "polarization" 27 | }, 28 | "HH+VV+HV+VH" 29 | ] 30 | }, 31 | { 32 | "op": "s_intersects", 33 | "args": [ 34 | { 35 | "property": "footprint" 36 | }, 37 | { 38 | "type": "Polygon", 39 | "coordinates": [ 40 | [ 41 | [ 42 | -77.117938, 43 | 38.93686 44 | ], 45 | [ 46 | -77.040604, 47 | 39.995648 48 | ], 49 | [ 50 | -76.910536, 51 | 38.892912 52 | ], 53 | [ 54 | -77.039359, 55 | 38.791753 56 | ], 57 | [ 58 | -77.047906, 59 | 38.841462 60 | ], 61 | [ 62 | -77.034183, 63 | 38.840655 64 | ], 65 | [ 66 | -77.033142, 67 | 38.85749 68 | ], 69 | [ 70 | -77.117938, 71 | 38.93686 72 | ] 73 | ] 74 | ] 75 | } 76 | ] 77 | } 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example09.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": ">", 3 | "args": [ 4 | { 5 | "property": "floors" 6 | }, 7 | 5 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example1.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": "=", 6 | "args": [ 7 | {"property": "id"}, 8 | "LC08_L1TP_060247_20180905_20180912_01_T1_L1TP" 9 | ] 10 | }, 11 | {"op": "=", "args": [{"property": "collection"}, "landsat8_l1tp"]}, 12 | { 13 | "op": ">", 14 | "args": [ 15 | {"property": "properties.datetime"}, 16 | {"timestamp": "2022-04-29T00:00:00Z"} 17 | ] 18 | }, 19 | {"op": "<", "args": [{"property": "properties.eo:cloud_cover"}, 10]}, 20 | { 21 | "op": "s_intersects", 22 | "args": [ 23 | {"property": "geometry"}, 24 | { 25 | "type": "Polygon", 26 | "coordinates": [ 27 | [ 28 | [36.319836, 32.288087], 29 | [36.320041, 32.288032], 30 | [36.320210, 32.288402], 31 | [36.320008, 32.288458], 32 | [36.319836, 32.288087] 33 | ] 34 | ] 35 | } 36 | ] 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example10.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "<=", 3 | "args": [ 4 | { 5 | "property": "taxes" 6 | }, 7 | 500 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example14.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "=", 3 | "args": [ 4 | { 5 | "property": "swimming_pool" 6 | }, 7 | true 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example15.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": ">", 6 | "args": [ 7 | { 8 | "property": "floor" 9 | }, 10 | 5 11 | ] 12 | }, 13 | { 14 | "op": "=", 15 | "args": [ 16 | { 17 | "property": "swimming_pool" 18 | }, 19 | true 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example17.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "or", 3 | "args": [ 4 | { 5 | "op": "and", 6 | "args": [ 7 | { 8 | "op": ">", 9 | "args": [ 10 | { 11 | "property": "floors" 12 | }, 13 | 5 14 | ] 15 | }, 16 | { 17 | "op": "=", 18 | "args": [ 19 | { 20 | "property": "material" 21 | }, 22 | "brick" 23 | ] 24 | } 25 | ] 26 | }, 27 | { 28 | "op": "=", 29 | "args": [ 30 | { 31 | "property": "swimming_pool" 32 | }, 33 | true 34 | ] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example18.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "or", 3 | "args": [ 4 | { 5 | "op": "not", 6 | "args": [ 7 | { 8 | "op": "<", 9 | "args": [ 10 | { 11 | "property": "floors" 12 | }, 13 | 5 14 | ] 15 | } 16 | ] 17 | }, 18 | { 19 | "op": "=", 20 | "args": [ 21 | { 22 | "property": "swimming_pool" 23 | }, 24 | true 25 | ] 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example19.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "like", 3 | "args": [ 4 | { 5 | "property": "scene_id" 6 | }, 7 | "LC82030282019133%" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example2.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": "=", 6 | "args": [ 7 | {"property": "id"}, 8 | "LC08_L1TP_060247_20180905_20180912_01_T1_L1TP" 9 | ] 10 | }, 11 | { 12 | "op": "=", 13 | "args": [ 14 | {"property": "collection"}, 15 | "landsat8_l1tp" 16 | ] 17 | }, 18 | { 19 | "op": "between", 20 | "args": [ 21 | {"property": "properties.datetime"}, 22 | {"timestamp": "2022-04-01T00:00:00Z"}, 23 | {"timestamp": "2022-04-30T23:59:59Z"} 24 | ] 25 | }, 26 | { 27 | "op": "<", 28 | "args": [ 29 | {"property": "properties.eo:cloud_cover"}, 30 | 10 31 | ] 32 | }, 33 | { 34 | "op": "s_intersects", 35 | "args": [ 36 | {"property": "geometry"}, 37 | { 38 | "type": "Polygon", 39 | "coordinates": [ 40 | [ 41 | [36.319836, 32.288087], 42 | [36.320041, 32.288032], 43 | [36.320210, 32.288402], 44 | [36.320008, 32.288458], 45 | [36.319836, 32.288087] 46 | ] 47 | ] 48 | } 49 | ] 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example20.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "like", 3 | "args": [ 4 | { 5 | "property": "scene_id" 6 | }, 7 | "LC82030282019133LGN0_" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example21.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": "between", 6 | "args": [ 7 | { 8 | "property": "cloud_cover" 9 | }, 10 | 0.1, 11 | 0.2 12 | ] 13 | }, 14 | { 15 | "op": "=", 16 | "args": [ 17 | { 18 | "property": "landsat:wrs_row" 19 | }, 20 | 28 21 | ] 22 | }, 23 | { 24 | "op": "=", 25 | "args": [ 26 | { 27 | "property": "landsat:wrs_path" 28 | }, 29 | 203 30 | ] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/cql2/example22.json: -------------------------------------------------------------------------------- 1 | { 2 | "op": "and", 3 | "args": [ 4 | { 5 | "op": "in", 6 | "args": [ 7 | {"property": "id"}, 8 | ["LC08_L1TP_060247_20180905_20180912_01_T1_L1TP"] 9 | ] 10 | }, 11 | {"op": "=", "args": [{"property": "collection"}, "landsat8_l1tp"]} 12 | ] 13 | } -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/test_bulk_transactions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from copy import deepcopy 4 | 5 | import pytest 6 | from pydantic import ValidationError 7 | 8 | from stac_fastapi.extensions.third_party.bulk_transactions import Items 9 | from stac_fastapi.types.errors import ConflictError 10 | 11 | from ..conftest import MockRequest, create_item 12 | 13 | if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch": 14 | from stac_fastapi.opensearch.config import OpensearchSettings as SearchSettings 15 | else: 16 | from stac_fastapi.elasticsearch.config import ( 17 | ElasticsearchSettings as SearchSettings, 18 | ) 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_bulk_item_insert(ctx, core_client, txn_client, bulk_txn_client): 23 | items = {} 24 | for _ in range(10): 25 | _item = deepcopy(ctx.item) 26 | _item["id"] = str(uuid.uuid4()) 27 | items[_item["id"]] = _item 28 | 29 | # fc = es_core.item_collection(coll["id"], request=MockStarletteRequest) 30 | # assert len(fc["features"]) == 0 31 | 32 | bulk_txn_client.bulk_item_insert(Items(items=items), refresh=True) 33 | 34 | fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) 35 | assert len(fc["features"]) >= 10 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_bulk_item_insert_with_raise_on_error( 40 | ctx, core_client, txn_client, bulk_txn_client 41 | ): 42 | """ 43 | Test bulk_item_insert behavior with RAISE_ON_BULK_ERROR set to true and false. 44 | 45 | This test verifies that when RAISE_ON_BULK_ERROR is set to true, a ConflictError 46 | is raised for conflicting items. When set to false, the operation logs errors 47 | and continues gracefully. 48 | """ 49 | 50 | # Insert an initial item to set up a conflict 51 | initial_item = deepcopy(ctx.item) 52 | initial_item["id"] = str(uuid.uuid4()) 53 | await create_item(txn_client, initial_item) 54 | 55 | # Verify the initial item is inserted 56 | fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) 57 | assert len(fc["features"]) >= 1 58 | 59 | # Create conflicting items (same ID as the initial item) 60 | conflicting_items = {initial_item["id"]: deepcopy(initial_item)} 61 | 62 | # Test with RAISE_ON_BULK_ERROR set to true 63 | os.environ["RAISE_ON_BULK_ERROR"] = "true" 64 | bulk_txn_client.database.sync_settings = SearchSettings() 65 | 66 | with pytest.raises(ConflictError): 67 | bulk_txn_client.bulk_item_insert(Items(items=conflicting_items), refresh=True) 68 | 69 | # Test with RAISE_ON_BULK_ERROR set to false 70 | os.environ["RAISE_ON_BULK_ERROR"] = "false" 71 | bulk_txn_client.database.sync_settings = SearchSettings() # Reinitialize settings 72 | result = bulk_txn_client.bulk_item_insert( 73 | Items(items=conflicting_items), refresh=True 74 | ) 75 | 76 | # Validate the results 77 | assert "Successfully added/updated 1 Items" in result 78 | 79 | # Clean up the inserted item 80 | await txn_client.delete_item(initial_item["id"], ctx.item["collection"]) 81 | 82 | 83 | @pytest.mark.asyncio 84 | async def test_feature_collection_insert( 85 | core_client, 86 | txn_client, 87 | ctx, 88 | ): 89 | features = [] 90 | for _ in range(10): 91 | _item = deepcopy(ctx.item) 92 | _item["id"] = str(uuid.uuid4()) 93 | features.append(_item) 94 | 95 | feature_collection = {"type": "FeatureCollection", "features": features} 96 | 97 | await create_item(txn_client, feature_collection) 98 | 99 | fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) 100 | assert len(fc["features"]) >= 10 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_bulk_item_insert_validation_error(ctx, core_client, bulk_txn_client): 105 | items = {} 106 | # Add 9 valid items 107 | for _ in range(9): 108 | _item = deepcopy(ctx.item) 109 | _item["id"] = str(uuid.uuid4()) 110 | items[_item["id"]] = _item 111 | 112 | # Add 1 invalid item (e.g., missing "datetime") 113 | invalid_item = deepcopy(ctx.item) 114 | invalid_item["id"] = str(uuid.uuid4()) 115 | invalid_item["properties"].pop( 116 | "datetime", None 117 | ) # Remove datetime to make it invalid 118 | items[invalid_item["id"]] = invalid_item 119 | 120 | # The bulk insert should raise a ValidationError due to the invalid item 121 | with pytest.raises(ValidationError): 122 | bulk_txn_client.bulk_item_insert(Items(items=items), refresh=True) 123 | 124 | 125 | @pytest.mark.asyncio 126 | async def test_feature_collection_insert_validation_error( 127 | core_client, 128 | txn_client, 129 | ctx, 130 | ): 131 | features = [] 132 | # Add 9 valid items 133 | for _ in range(9): 134 | _item = deepcopy(ctx.item) 135 | _item["id"] = str(uuid.uuid4()) 136 | features.append(_item) 137 | 138 | # Add 1 invalid item (e.g., missing "datetime") 139 | invalid_item = deepcopy(ctx.item) 140 | invalid_item["id"] = str(uuid.uuid4()) 141 | invalid_item["properties"].pop( 142 | "datetime", None 143 | ) # Remove datetime to make it invalid 144 | features.append(invalid_item) 145 | 146 | feature_collection = {"type": "FeatureCollection", "features": features} 147 | 148 | # Assert that a ValidationError is raised due to the invalid item 149 | with pytest.raises(ValidationError): 150 | await create_item(txn_client, feature_collection) 151 | -------------------------------------------------------------------------------- /stac_fastapi/tests/extensions/test_cql2_like_to_es.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stac_fastapi.sfeos_helpers.filter import cql2_like_to_es 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "cql2_value, expected_es_value", 8 | ( 9 | # no-op 10 | ("", ""), 11 | # backslash 12 | ("\\\\", "\\"), 13 | # percent 14 | ("%", "*"), 15 | (r"\%", "%"), 16 | (r"\\%", r"\*"), 17 | (r"\\\%", r"\%"), 18 | # underscore 19 | ("_", "?"), 20 | (r"\_", "_"), 21 | (r"\\_", r"\?"), 22 | (r"\\\_", r"\_"), 23 | ), 24 | ) 25 | def test_cql2_like_to_es_success(cql2_value: str, expected_es_value: str) -> None: 26 | """Verify CQL2 LIKE query strings are converted correctly.""" 27 | 28 | assert cql2_like_to_es(cql2_value) == expected_es_value 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "cql2_value", 33 | ( 34 | pytest.param("\\", id="trailing backslash escape"), 35 | pytest.param("\\1", id="invalid escape sequence"), 36 | ), 37 | ) 38 | def test_cql2_like_to_es_invalid(cql2_value: str) -> None: 39 | """Verify that incomplete or invalid escape sequences are rejected. 40 | 41 | CQL2 currently doesn't appear to define how to handle invalid escape sequences. 42 | This test assumes that undefined behavior is caught. 43 | """ 44 | 45 | with pytest.raises(ValueError): 46 | cql2_like_to_es(cql2_value) 47 | -------------------------------------------------------------------------------- /stac_fastapi/tests/rate_limit/test_rate_limit.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from httpx import AsyncClient 5 | from slowapi.errors import RateLimitExceeded 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_rate_limit(app_client_rate_limit: AsyncClient, ctx): 12 | expected_status_codes = [200, 200, 429, 429, 429] 13 | 14 | for i, expected_status_code in enumerate(expected_status_codes): 15 | try: 16 | response = await app_client_rate_limit.get("/collections") 17 | status_code = response.status_code 18 | except RateLimitExceeded: 19 | status_code = 429 20 | 21 | logger.info(f"Request {i + 1}: Status code {status_code}") 22 | assert ( 23 | status_code == expected_status_code 24 | ), f"Expected status code {expected_status_code}, but got {status_code}" 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_rate_limit_no_limit(app_client: AsyncClient, ctx): 29 | expected_status_codes = [200, 200, 200, 200, 200] 30 | 31 | for i, expected_status_code in enumerate(expected_status_codes): 32 | response = await app_client.get("/collections") 33 | status_code = response.status_code 34 | 35 | logger.info(f"Request {i + 1}: Status code {status_code}") 36 | assert ( 37 | status_code == expected_status_code 38 | ), f"Expected status code {expected_status_code}, but got {status_code}" 39 | -------------------------------------------------------------------------------- /stac_fastapi/tests/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/stac_fastapi/tests/resources/__init__.py -------------------------------------------------------------------------------- /stac_fastapi/tests/resources/test_conformance.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import pytest 4 | import pytest_asyncio 5 | 6 | 7 | @pytest_asyncio.fixture 8 | async def response(app_client): 9 | return await app_client.get("/") 10 | 11 | 12 | @pytest.fixture 13 | def response_json(response): 14 | return response.json() 15 | 16 | 17 | def get_link(landing_page, rel_type): 18 | return next( 19 | filter(lambda link: link["rel"] == rel_type, landing_page["links"]), None 20 | ) 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_landing_page_health(response): 25 | """Test landing page""" 26 | assert response.status_code == 200 27 | assert response.headers["content-type"] == "application/json" 28 | 29 | 30 | # Parameters for test_landing_page_links test below. 31 | # Each tuple has the following values (in this order): 32 | # - Rel type of link to test 33 | # - Expected MIME/Media Type 34 | # - Expected relative path 35 | link_tests = [ 36 | ("root", "application/json", "/"), 37 | ("conformance", "application/json", "/conformance"), 38 | ("service-doc", "text/html", "/api.html"), 39 | ("service-desc", "application/vnd.oai.openapi+json;version=3.0", "/api"), 40 | ] 41 | 42 | 43 | @pytest.mark.asyncio 44 | @pytest.mark.parametrize("rel_type,expected_media_type,expected_path", link_tests) 45 | async def test_landing_page_links( 46 | response_json, app_client, rel_type, expected_media_type, expected_path 47 | ): 48 | link = get_link(response_json, rel_type) 49 | 50 | assert link is not None, f"Missing {rel_type} link in landing page" 51 | assert link.get("type") == expected_media_type 52 | 53 | link_path = urllib.parse.urlsplit(link.get("href")).path 54 | assert link_path == expected_path 55 | 56 | resp = await app_client.get(link_path) 57 | assert resp.status_code == 200 58 | 59 | 60 | # This endpoint currently returns a 404 for empty result sets, but testing for this response 61 | # code here seems meaningless since it would be the same as if the endpoint did not exist. Once 62 | # https://github.com/stac-utils/stac-fastapi/pull/227 has been merged we can add this to the 63 | # parameterized tests above. 64 | @pytest.mark.asyncio 65 | async def test_search_link(response_json): 66 | search_link = get_link(response_json, "search") 67 | 68 | assert search_link is not None 69 | assert search_link.get("type") == "application/geo+json" 70 | 71 | search_path = urllib.parse.urlsplit(search_link.get("href")).path 72 | assert search_path == "/search" 73 | -------------------------------------------------------------------------------- /stac_fastapi/tests/resources/test_mgmt.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_ping_no_param(app_client): 6 | """ 7 | Test ping endpoint with a mocked client. 8 | Args: 9 | app_client (TestClient): mocked client fixture 10 | """ 11 | res = await app_client.get("/_mgmt/ping") 12 | assert res.status_code == 200 13 | assert res.json() == {"message": "PONG"} 14 | -------------------------------------------------------------------------------- /stac_fastapi/tests/route_dependencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/56f4f7b52ad0108f02d732a2baac0b010e51c25f/stac_fastapi/tests/route_dependencies/__init__.py -------------------------------------------------------------------------------- /stac_fastapi/tests/route_dependencies/test_route_dependencies.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_not_authenticated(route_dependencies_client): 6 | """Test protected endpoint [GET /collections] without permissions""" 7 | 8 | response = await route_dependencies_client.get("/collections") 9 | 10 | assert response.status_code == 401 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_authenticated(route_dependencies_client): 15 | """Test protected endpoint [GET /collections] with permissions""" 16 | 17 | response = await route_dependencies_client.get( 18 | "/collections", 19 | auth=("bob", "dobbs"), 20 | ) 21 | 22 | assert response.status_code == 200 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Linter configs 2 | [flake8] 3 | ignore = D203 4 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist 5 | max-complexity = 12 6 | max-line-length = 90 7 | 8 | ;[mypy] 9 | ;no_strict_optional = true 10 | ;ignore_missing_imports = True 11 | 12 | [tool:isort] 13 | profile=black 14 | known_first_party = stac_fastapi 15 | known_third_party = rasterio,stac-pydantic,sqlalchemy,geoalchemy2,fastapi 16 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER --------------------------------------------------------------------------------