├── .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 |
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
--------------------------------------------------------------------------------