├── .dockerignore ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── cicd.yaml │ ├── deploy_mkdocs.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.docs ├── LICENSE ├── Makefile ├── README.md ├── RELEASING.md ├── VERSION ├── compose.docs.yml ├── docs ├── mkdocs.yml └── src │ ├── api │ └── stac_fastapi │ │ ├── api │ │ ├── app.md │ │ ├── config.md │ │ ├── errors.md │ │ ├── index.md │ │ ├── middleware.md │ │ ├── models.md │ │ ├── openapi.md │ │ └── routes.md │ │ ├── extensions │ │ ├── core │ │ │ ├── aggregation │ │ │ │ ├── aggregation.md │ │ │ │ ├── client.md │ │ │ │ ├── index.md │ │ │ │ ├── request.md │ │ │ │ └── types.md │ │ │ ├── collection_search │ │ │ │ ├── client.md │ │ │ │ ├── collection_search.md │ │ │ │ ├── index.md │ │ │ │ └── request.md │ │ │ ├── fields │ │ │ │ ├── fields.md │ │ │ │ ├── index.md │ │ │ │ └── request.md │ │ │ ├── filter │ │ │ │ ├── filter.md │ │ │ │ ├── index.md │ │ │ │ └── request.md │ │ │ ├── free_text │ │ │ │ ├── free_text.md │ │ │ │ ├── index.md │ │ │ │ └── request.md │ │ │ ├── index.md │ │ │ ├── pagination │ │ │ │ ├── index.md │ │ │ │ ├── offset_pagination.md │ │ │ │ ├── pagination.md │ │ │ │ ├── request.md │ │ │ │ └── token_pagination.md │ │ │ ├── query │ │ │ │ ├── index.md │ │ │ │ ├── query.md │ │ │ │ └── request.md │ │ │ ├── sort │ │ │ │ ├── index.md │ │ │ │ ├── request.md │ │ │ │ └── sort.md │ │ │ └── transaction.md │ │ ├── index.md │ │ └── third_party │ │ │ ├── bulk_transactions.md │ │ │ └── index.md │ │ ├── index.md │ │ └── types │ │ ├── config.md │ │ ├── conformance.md │ │ ├── core.md │ │ ├── errors.md │ │ ├── extension.md │ │ ├── index.md │ │ ├── links.md │ │ ├── requests.md │ │ ├── rfc3339.md │ │ ├── search.md │ │ └── stac.md │ ├── benchmarks.html │ ├── contributing.md │ ├── index.md │ ├── migrations │ ├── v3.0.0.md │ └── v4.0.0.md │ ├── release-notes.md │ ├── stylesheets │ └── extra.css │ └── tips-and-tricks.md ├── pyproject.toml ├── scripts └── publish └── stac_fastapi ├── api ├── README.md ├── setup.cfg ├── setup.py ├── stac_fastapi │ └── api │ │ ├── __init__.py │ │ ├── app.py │ │ ├── config.py │ │ ├── errors.py │ │ ├── middleware.py │ │ ├── models.py │ │ ├── openapi.py │ │ ├── py.typed │ │ ├── routes.py │ │ └── version.py └── tests │ ├── benchmarks.py │ ├── conftest.py │ ├── test_api.py │ ├── test_app.py │ ├── test_app_prefix.py │ ├── test_middleware.py │ └── test_models.py ├── extensions ├── README.md ├── setup.cfg ├── setup.py ├── stac_fastapi │ └── extensions │ │ ├── __init__.py │ │ ├── core │ │ ├── __init__.py │ │ ├── aggregation │ │ │ ├── __init__.py │ │ │ ├── aggregation.py │ │ │ ├── client.py │ │ │ ├── request.py │ │ │ └── types.py │ │ ├── collection_search │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── collection_search.py │ │ │ └── request.py │ │ ├── fields │ │ │ ├── __init__.py │ │ │ ├── fields.py │ │ │ └── request.py │ │ ├── filter │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── filter.py │ │ │ └── request.py │ │ ├── free_text │ │ │ ├── __init__.py │ │ │ ├── free_text.py │ │ │ └── request.py │ │ ├── pagination │ │ │ ├── __init__.py │ │ │ ├── offset_pagination.py │ │ │ ├── pagination.py │ │ │ ├── request.py │ │ │ └── token_pagination.py │ │ ├── query │ │ │ ├── __init__.py │ │ │ ├── query.py │ │ │ └── request.py │ │ ├── sort │ │ │ ├── __init__.py │ │ │ ├── request.py │ │ │ └── sort.py │ │ └── transaction.py │ │ ├── py.typed │ │ ├── third_party │ │ ├── __init__.py │ │ └── bulk_transactions.py │ │ └── version.py └── tests │ ├── test_aggregation.py │ ├── test_collection_search.py │ ├── test_filter.py │ ├── test_free_text.py │ ├── test_pagination.py │ ├── test_query.py │ └── test_transaction.py └── types ├── README.md ├── setup.cfg ├── setup.py ├── stac_fastapi └── types │ ├── __init__.py │ ├── config.py │ ├── conformance.py │ ├── core.py │ ├── errors.py │ ├── extension.py │ ├── links.py │ ├── py.typed │ ├── requests.py │ ├── rfc3339.py │ ├── search.py │ ├── stac.py │ └── version.py └── tests ├── test_config.py ├── test_limit.py └── test_rfc3339.py /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | .coverage 6 | .coverage.* 7 | .vscode 8 | coverage.xml 9 | *.log 10 | .git 11 | .envrc 12 | 13 | venv -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: weekly 11 | - package-ecosystem: pip 12 | directory: "/stac_fastapi/api" 13 | schedule: 14 | interval: weekly 15 | - package-ecosystem: pip 16 | directory: "/stac_fastapi/types" 17 | schedule: 18 | interval: weekly 19 | - package-ecosystem: pip 20 | directory: "/stac_fastapi/extensions" 21 | schedule: 22 | interval: weekly 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Related Issue(s):** 2 | 3 | - # 4 | 5 | **Description:** 6 | 7 | **PR Checklist:** 8 | 9 | - [ ] `pre-commit` hooks pass locally 10 | - [ ] Tests pass (run `make test`) 11 | - [ ] Documentation has been updated to reflect changes, if applicable, and docs build successfully (run `make docs`) 12 | - [ ] Changes are added to the [CHANGELOG](https://github.com/stac-utils/stac-fastapi/blob/main/CHANGES.md). 13 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yaml: -------------------------------------------------------------------------------- 1 | name: stac-fastapi 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 14 | timeout-minutes: 20 15 | 16 | steps: 17 | - name: Check out repository code 18 | uses: actions/checkout@v4 19 | 20 | # Setup Python (faster than using Python container) 21 | - name: Setup Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Lint code 27 | if: ${{ matrix.python-version == 3.13 }} 28 | run: | 29 | python -m pip install pre-commit 30 | pre-commit run --all-files 31 | 32 | - name: Install types 33 | run: | 34 | python -m pip install ./stac_fastapi/types[dev] 35 | 36 | - name: Install core api 37 | run: | 38 | python -m pip install ./stac_fastapi/api[dev] 39 | 40 | - name: Install Extensions 41 | run: | 42 | python -m pip install ./stac_fastapi/extensions[dev] 43 | 44 | - name: Test 45 | run: python -m pytest -svvv 46 | env: 47 | ENVIRONMENT: testing 48 | 49 | test-docs: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Test generating docs 54 | run: make docs 55 | 56 | benchmark: 57 | needs: [test] 58 | runs-on: ubuntu-latest 59 | steps: 60 | - name: Check out repository code 61 | uses: actions/checkout@v4 62 | 63 | - name: Setup Python 64 | uses: actions/setup-python@v5 65 | with: 66 | python-version: "3.13" 67 | 68 | - name: Install types 69 | run: | 70 | python -m pip install ./stac_fastapi/types[dev] 71 | 72 | - name: Install core api 73 | run: | 74 | python -m pip install ./stac_fastapi/api[dev,benchmark] 75 | 76 | - name: Install extensions 77 | run: | 78 | python -m pip install ./stac_fastapi/extensions 79 | 80 | - name: Run Benchmark 81 | run: python -m pytest stac_fastapi/api/tests/benchmarks.py --benchmark-only --benchmark-columns 'min, max, mean, median' --benchmark-json output.json 82 | 83 | - name: Store and benchmark result 84 | if: github.repository == 'stac-utils/stac-fastapi' 85 | uses: benchmark-action/github-action-benchmark@v1 86 | with: 87 | name: STAC FastAPI Benchmarks 88 | tool: 'pytest' 89 | output-file-path: output.json 90 | alert-threshold: '130%' 91 | comment-on-alert: true 92 | fail-on-alert: false 93 | # GitHub API token to make a commit comment 94 | github-token: ${{ secrets.GITHUB_TOKEN }} 95 | gh-pages-branch: 'gh-benchmarks' 96 | # Make a commit only if main 97 | auto-push: ${{ github.ref == 'refs/heads/main' }} 98 | -------------------------------------------------------------------------------- /.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.11 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: 3.11 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install \ 32 | stac_fastapi/types[docs] \ 33 | stac_fastapi/api[docs] \ 34 | stac_fastapi/extensions[docs] \ 35 | 36 | - name: Deploy docs 37 | run: mkdocs gh-deploy --force -f docs/mkdocs.yml 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | release: 10 | name: release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 3.x 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.x" 19 | 20 | - name: Install release dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel twine 24 | 25 | - name: Build and publish package 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_STACUTILS_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_STACUTILS_PASSWORD }} 29 | run: | 30 | scripts/publish 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | 3 | PIP_EXTRA_INDEX_URL 4 | !tests/resources/*.jpg 5 | **.pyc 6 | **.log 7 | *.mat 8 | target/* 9 | src/local/* 10 | src/local-test/* 11 | *.iml 12 | .idea/ 13 | model/ 14 | .DS_Store 15 | #config.yaml 16 | **.save 17 | *.jpg 18 | **.save.* 19 | **.bak 20 | .DS_Store 21 | .mvn/ 22 | 23 | # Byte-compiled / optimized / DLL files 24 | __pycache__/ 25 | *.py[cod] 26 | *$py.class 27 | 28 | # C extensions 29 | *.so 30 | 31 | # user specific overrides 32 | tests/tests.ini 33 | tests/logging.ini 34 | 35 | # Distribution / packaging 36 | .Python 37 | env/ 38 | venv/ 39 | build/ 40 | develop-eggs/ 41 | dist/ 42 | downloads/ 43 | eggs/ 44 | .eggs/ 45 | lib/ 46 | lib64/ 47 | parts/ 48 | sdist/ 49 | var/ 50 | wheels/ 51 | *.egg-info/ 52 | .installed.cfg 53 | *.egg 54 | 55 | # PyInstaller 56 | # Usually these files are written by a python script from a template 57 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 58 | *.manifest 59 | *.spec 60 | 61 | # Installer logs 62 | pip-log.txt 63 | pip-delete-this-directory.txt 64 | 65 | # Unit test / coverage reports 66 | htmlcov/ 67 | .coverage 68 | .coverage.* 69 | .cache 70 | nosetests.xml 71 | coverage.xml 72 | *,cover 73 | .hypothesis/ 74 | 75 | # Translations 76 | *.mo 77 | *.pot 78 | 79 | # Django stuff: 80 | *.log 81 | local_settings.py 82 | 83 | # Flask stuff: 84 | instance/ 85 | .webassets-cache 86 | 87 | # Scrapy stuff: 88 | .scrapy 89 | 90 | # Sphinx documentation 91 | docs/_build/ 92 | 93 | # PyBuilder 94 | target/ 95 | 96 | # Jupyter Notebook 97 | .ipynb_checkpoints 98 | 99 | # pyenv 100 | .python-version 101 | 102 | # celery beat schedule file 103 | celerybeat-schedule 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # dotenv 109 | **/.env 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # skaffold temporary build/deploy files 122 | build.out 123 | 124 | # pdocs 125 | docs/api/* 126 | 127 | # Direnv 128 | .envrc 129 | 130 | # Virtualenv 131 | venv 132 | .venv/ 133 | 134 | # IDE 135 | .vscode -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: "v0.2.2" 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --exit-non-zero-on-fix] 7 | - id: ruff-format 8 | 9 | - repo: https://github.com/pre-commit/mirrors-mypy 10 | rev: v1.15.0 11 | hooks: 12 | - id: mypy 13 | language_version: python 14 | exclude: tests/.* 15 | additional_dependencies: 16 | - types-attrs 17 | - pydantic 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Issues and pull requests are more than welcome. 4 | 5 | **dev install** 6 | 7 | ```bash 8 | git clone https://github.com/stac-utils/stac-fastapi.git 9 | cd stac-fastapi 10 | python -m pip install -e stac_fastapi/api[dev] 11 | ``` 12 | 13 | **pre-commit** 14 | 15 | This repo is set to use `pre-commit` to run *ruff*, *pydocstring* and mypy when committing new code. 16 | 17 | ```bash 18 | pre-commit install 19 | ``` 20 | 21 | ### Docs 22 | 23 | ```bash 24 | git clone https://github.com/stac-utils/stac-fastapi.git 25 | cd stac-fastapi 26 | python pip install -e stac_fastapi/api["docs"] 27 | ``` 28 | 29 | Hot-reloading docs: 30 | 31 | ```bash 32 | $ mkdocs serve -f docs/mkdocs.yml 33 | ``` 34 | 35 | To manually deploy docs (note you should never need to do this because GitHub 36 | Actions deploys automatically for new commits.): 37 | 38 | ```bash 39 | # deploy 40 | $ mkdocs gh-deploy -f docs/mkdocs.yml 41 | ``` 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim AS base 2 | 3 | # Any python libraries that require system libraries to be installed will likely 4 | # need the following packages in order to build 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 | ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt 12 | 13 | FROM base AS builder 14 | 15 | WORKDIR /app 16 | 17 | COPY . /app 18 | 19 | RUN python -m pip install \ 20 | -e ./stac_fastapi/types[dev] \ 21 | -e ./stac_fastapi/api[dev] \ 22 | -e ./stac_fastapi/extensions[dev] 23 | -------------------------------------------------------------------------------- /Dockerfile.docs: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | # build-essential is required to build a wheel for ciso8601 4 | RUN apt update && apt install -y build-essential && \ 5 | apt-get clean && \ 6 | rm -rf /var/lib/apt/lists/* 7 | 8 | RUN python -m pip install --upgrade pip 9 | 10 | COPY . /opt/src 11 | 12 | WORKDIR /opt/src 13 | 14 | RUN python -m pip install \ 15 | "stac_fastapi/types[docs]" \ 16 | "stac_fastapi/api[docs]" \ 17 | "stac_fastapi/extensions[docs]" 18 | 19 | CMD ["mkdocs", "build", "-f", "docs/mkdocs.yml"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Arturo AI 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 | .PHONY: image 2 | image: 3 | docker build . 4 | 5 | .PHONY: install 6 | install: 7 | python -m pip install wheel && \ 8 | python -m pip install -e ./stac_fastapi/types[dev] && \ 9 | python -m pip install -e ./stac_fastapi/api[dev] && \ 10 | python -m pip install -e ./stac_fastapi/extensions[dev] 11 | 12 | .PHONY: docs-image 13 | docs-image: 14 | docker compose -f compose.docs.yml \ 15 | build 16 | 17 | .PHONY: docs 18 | docs: docs-image 19 | docker compose -f compose.docs.yml \ 20 | run docs 21 | 22 | .PHONY: test 23 | test: image 24 | python -m pytest . 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | SpatioTemporal Asset Catalog (STAC) logo 5 |

FastAPI implemention of the STAC API spec.

6 |

7 |

8 | 9 | Test 10 | 11 | 12 | License 13 | 14 |

15 | 16 | --- 17 | 18 | **Documentation**: [https://stac-utils.github.io/stac-fastapi/](https://stac-utils.github.io/stac-fastapi/) 19 | 20 | **Source Code**: [https://github.com/stac-utils/stac-fastapi](https://github.com/stac-utils/stac-fastapi) 21 | 22 | --- 23 | 24 | Python library for building a STAC-compliant FastAPI application. 25 | 26 | `stac-fastapi` was initially developed by [arturo-ai](https://github.com/arturo-ai). 27 | 28 | The project contains several namespace packages: 29 | 30 | | Package | Description | Version | 31 | | ------- |------------- | ------- | 32 | | [**stac_fastapi.api**](https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/api) | An API layer which enforces the [stac-api-spec](https://github.com/radiantearth/stac-api-spec). | [![stac-fastapi.api](https://img.shields.io/pypi/v/stac-fastapi.api?color=%2334D058&label=pypi)](https://pypi.org/project/stac-fastapi.api) | 33 | | [**stac_fastapi.extensions**](https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/extensions) | Abstract base classes for [STAC API extensions](https://github.com/radiantearth/stac-api-spec/blob/master/extensions.md) and third-party extensions. | [![stac-fastapi.extensions](https://img.shields.io/pypi/v/stac-fastapi.extensions?color=%2334D058&label=pypi)](https://pypi.org/project/stac-fastapi.extensions) | 34 | | [**stac_fastapi.types**](https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types) | Shared types and abstract base classes used by the library. | [![stac-fastapi.types](https://img.shields.io/pypi/v/stac-fastapi.types?color=%2334D058&label=pypi)](https://pypi.org/project/stac-fastapi.types) | 35 | 36 | #### Backends 37 | 38 | In addition to the packages in this repository, a server implemention will also require the selection of a backend to 39 | connect with a database for STAC metadata storage. There are several different backend options, and each has their own 40 | repository. 41 | 42 | The two most widely-used and supported backends are: 43 | 44 | - [stac-fastapi-pgstac](https://github.com/stac-utils/stac-fastapi-pgstac): [PostgreSQL](https://github.com/postgres/postgres) + [PostGIS](https://github.com/postgis/postgis) via [PgSTAC](https://github.com/stac-utils/pgstac). 45 | - [stac-fastapi-elasticsearch-opensearch](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch): [Elasticsearch](https://github.com/elastic/elasticsearch) or [OpenSearch](https://github.com/opensearch-project/OpenSearch) 46 | 47 | Other implementations include: 48 | 49 | - [stac-fastapi-mongo](https://github.com/Healy-Hyperspatial/stac-fastapi-mongo): [MongoDB](https://github.com/mongodb/mongo) 50 | - [stac-fastapi-geoparquet)](https://github.com/stac-utils/stac-fastapi-geoparquet): [GeoParquet](https://geoparquet.org) via [stacrs](https://github.com/stac-utils/stacrs) (experimental) 51 | - [stac-fastapi-duckdb](https://github.com/Healy-Hyperspatial/stac-fastapi-duckdb): [DuckDB](https://github.com/duckdb/duckdb) (experimental) 52 | - [stac-fastapi-sqlalchemy](https://github.com/stac-utils/stac-fastapi-sqlalchemy): [PostgreSQL](https://github.com/postgres/postgres) + [PostGIS](https://github.com/postgis/postgis) via [SQLAlchemy](https://www.sqlalchemy.org/) (abandoned in favor of stac-fastapi-pgstac) 53 | 54 | ## Response Model Validation 55 | 56 | A common question when using this package is how request and response types are validated? 57 | 58 | This package uses [`stac-pydantic`](https://github.com/stac-utils/stac-pydantic) to validate and document STAC objects. However, by default, validation of response types is turned off and the API will simply forward responses without validating them against the Pydantic model first. This decision was made with the assumption that responses usually come from a (typed) database and can be considered safe. Extra validation would only increase latency, in particular for large payloads. 59 | 60 | To turn on response validation, set `ENABLE_RESPONSE_MODELS` to `True`. Either as an environment variable or directly in the `ApiSettings`. 61 | 62 | With the introduction of Pydantic 2, the extra [time it takes to validate models became negatable](https://github.com/stac-utils/stac-fastapi/pull/625#issuecomment-2045824578). While `ENABLE_RESPONSE_MODELS` still defaults to `False` there should be no penalty for users to turn on this feature but users discretion is advised. 63 | 64 | ## Installation 65 | 66 | ```bash 67 | # Install from PyPI 68 | python -m pip install stac-fastapi.types stac-fastapi.api stac-fastapi.extensions 69 | 70 | # Install a backend of your choice 71 | python -m pip install stac-fastapi.pgstac 72 | ``` 73 | 74 | Other backends may be available from other sources, search [PyPI](https://pypi.org/) for more. 75 | 76 | ## Development 77 | 78 | Install the packages in editable mode: 79 | 80 | ```shell 81 | python -m pip install \ 82 | -e 'stac_fastapi/types[dev]' \ 83 | -e 'stac_fastapi/api[dev]' \ 84 | -e 'stac_fastapi/extensions[dev]' 85 | ``` 86 | 87 | To run the tests: 88 | 89 | ```shell 90 | python -m pytest 91 | ``` 92 | 93 | ## Releasing 94 | 95 | See [RELEASING.md](./RELEASING.md). 96 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | This is a checklist for releasing a new version of **stac-fastapi**. 4 | 5 | 1. Determine the next version. We currently do not have published versioning guidelines, but there is some text on the subject here: . 6 | 2. Create a release branch named `release/vX.Y.Z`, where `X.Y.Z` is the new version. 7 | 3. Search and replace all instances of the current version number with the new version. As of this writing, there's 3 different `version.py` files, and one `VERSION` file, in the repo. 8 | 9 | Note: You can use [`bump-my-version`](https://github.com/callowayproject/bump-my-version) CLI 10 | ``` 11 | bump-my-version bump --new-version 3.1.0 12 | ``` 13 | 14 | 4. Update [CHANGES.md](./CHANGES.md) for the new version. Add the appropriate header, and update the links at the bottom of the file. 15 | 5. Audit CHANGES.md for completeness and accuracy. Also, ensure that the changes in this version are appropriate for the version number change (i.e. if you're making breaking changes, you should be increasing the `MAJOR` version number). 16 | 6. (optional) If you have permissions, run `scripts/publish --test` to test your PyPI publish. If successful, the published packages will be available on . 17 | 7. Push your release branch, create a PR, and get approval. 18 | 8. Once the PR is merged, create a new (annotated, signed) tag on the appropriate commit. Name the tag `X.Y.Z`, and include `vX.Y.Z` as its annotation message. 19 | 9. Push your tag to Github, which will kick off the [publishing workflow](.github/workflows/publish.yml). 20 | 10. Create a [new release](https://github.com/stac-utils/stac-fastapi/releases/new) targeting the new tag, and use the "Generate release notes" feature to populate the description. Publish the release and mark it as the latest. 21 | 11. Publicize the release via the appropriate social channels, including [Gitter](https://matrix.to/#/#SpatioTemporal-Asset-Catalog_python:gitter.im). 22 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 5.2.1 2 | -------------------------------------------------------------------------------- /compose.docs.yml: -------------------------------------------------------------------------------- 1 | services: 2 | docs: 3 | container_name: stac-fastapi-docs-dev 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.docs 7 | platform: linux/amd64 8 | volumes: 9 | - .:/opt/src 10 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: stac-fastapi 2 | site_description: STAC FastAPI. 3 | 4 | # Repository 5 | repo_name: "stac-utils/stac-fastapi" 6 | repo_url: "https://github.com/stac-utils/stac-fastapi" 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 | - packages: api/stac_fastapi/index.md 24 | - stac_fastapi.api: 25 | - module: api/stac_fastapi/api/index.md 26 | - app: api/stac_fastapi/api/app.md 27 | - config: api/stac_fastapi/api/config.md 28 | - errors: api/stac_fastapi/api/errors.md 29 | - middleware: api/stac_fastapi/api/middleware.md 30 | - models: api/stac_fastapi/api/models.md 31 | - openapi: api/stac_fastapi/api/openapi.md 32 | - routes: api/stac_fastapi/api/routes.md 33 | - stac_fastapi.extensions: 34 | - module: api/stac_fastapi/extensions/index.md 35 | - core: 36 | - module: api/stac_fastapi/extensions/core/index.md 37 | - aggregation: 38 | - module: api/stac_fastapi/extensions/core/aggregation/index.md 39 | - aggregation: api/stac_fastapi/extensions/core/aggregation/aggregation.md 40 | - client: api/stac_fastapi/extensions/core/aggregation/client.md 41 | - request: api/stac_fastapi/extensions/core/aggregation/request.md 42 | - types: api/stac_fastapi/extensions/core/aggregation/types.md 43 | - collection_search: 44 | - module: api/stac_fastapi/extensions/core/collection_search/index.md 45 | - collection_search: api/stac_fastapi/extensions/core/collection_search/collection_search.md 46 | - client: api/stac_fastapi/extensions/core/collection_search/client.md 47 | - request: api/stac_fastapi/extensions/core/collection_search/request.md 48 | - fields: 49 | - module: api/stac_fastapi/extensions/core/fields/index.md 50 | - fields: api/stac_fastapi/extensions/core/fields/fields.md 51 | - request: api/stac_fastapi/extensions/core/fields/request.md 52 | - filter: 53 | - module: api/stac_fastapi/extensions/core/filter/index.md 54 | - filter: api/stac_fastapi/extensions/core/filter/filter.md 55 | - request: api/stac_fastapi/extensions/core/filter/request.md 56 | - free_text: 57 | - module: api/stac_fastapi/extensions/core/free_text/index.md 58 | - free_text: api/stac_fastapi/extensions/core/free_text/free_text.md 59 | - request: api/stac_fastapi/extensions/core/free_text/request.md 60 | - pagination: 61 | - module: api/stac_fastapi/extensions/core/pagination/index.md 62 | - pagination: api/stac_fastapi/extensions/core/pagination/pagination.md 63 | - offset_pagination: api/stac_fastapi/extensions/core/pagination/offset_pagination.md 64 | - token_pagination: api/stac_fastapi/extensions/core/pagination/token_pagination.md 65 | - request: api/stac_fastapi/extensions/core/pagination/request.md 66 | - query: 67 | - module: api/stac_fastapi/extensions/core/query/index.md 68 | - query: api/stac_fastapi/extensions/core/query/query.md 69 | - request: api/stac_fastapi/extensions/core/query/request.md 70 | - sort: 71 | - module: api/stac_fastapi/extensions/core/sort/index.md 72 | - request: api/stac_fastapi/extensions/core/sort/request.md 73 | - sort: api/stac_fastapi/extensions/core/sort/sort.md 74 | - transaction: api/stac_fastapi/extensions/core/transaction.md 75 | - third_party: 76 | - module: api/stac_fastapi/extensions/third_party/index.md 77 | - bulk_transactions: api/stac_fastapi/extensions/third_party/bulk_transactions.md 78 | - stac_fastapi.types: 79 | - module: api/stac_fastapi/types/index.md 80 | - config: api/stac_fastapi/types/config.md 81 | - conformance: api/stac_fastapi/types/conformance.md 82 | - core: api/stac_fastapi/types/core.md 83 | - errors: api/stac_fastapi/types/errors.md 84 | - extension: api/stac_fastapi/types/extension.md 85 | - links: api/stac_fastapi/types/links.md 86 | - requests: api/stac_fastapi/types/requests.md 87 | - rfc3339: api/stac_fastapi/types/rfc3339.md 88 | - search: api/stac_fastapi/types/search.md 89 | - stac: api/stac_fastapi/types/stac.md 90 | - Migration Guides: 91 | - v2.5 -> v3.0: migrations/v3.0.0.md 92 | - v3.0 -> v4.0: migrations/v4.0.0.md 93 | - Performance Benchmarks: benchmarks.html 94 | - Development - Contributing: "contributing.md" 95 | - Release Notes: "release-notes.md" 96 | 97 | plugins: 98 | - search 99 | - mkdocstrings: 100 | enable_inventory: true 101 | handlers: 102 | python: 103 | paths: [src] 104 | options: 105 | docstring_section_style: list 106 | docstring_style: google 107 | line_length: 100 108 | separate_signature: true 109 | show_root_heading: true 110 | show_signature_annotations: true 111 | show_source: false 112 | show_symbol_type_toc: true 113 | signature_crossrefs: true 114 | extensions: 115 | - griffe_inherited_docstrings 116 | inventories: 117 | - https://docs.python.org/3/objects.inv 118 | - https://docs.pydantic.dev/latest/objects.inv 119 | - https://fastapi.tiangolo.com/objects.inv 120 | - https://www.starlette.io/objects.inv 121 | - https://www.attrs.org/en/stable/objects.inv 122 | 123 | # Theme 124 | theme: 125 | icon: 126 | logo: "material/home" 127 | repo: "fontawesome/brands/github" 128 | name: "material" 129 | language: "en" 130 | font: 131 | text: "Nunito Sans" 132 | code: "Fira Code" 133 | 134 | extra_css: 135 | - stylesheets/extra.css 136 | 137 | # These extensions are chosen to be a superset of Pandoc's Markdown. 138 | # This way, I can write in Pandoc's Markdown and have it be supported here. 139 | # https://pandoc.org/MANUAL.html 140 | markdown_extensions: 141 | - admonition 142 | - attr_list 143 | - codehilite: 144 | guess_lang: false 145 | - def_list 146 | - footnotes 147 | - pymdownx.arithmatex 148 | - pymdownx.betterem 149 | - pymdownx.caret: 150 | insert: false 151 | - pymdownx.details 152 | - pymdownx.emoji 153 | - pymdownx.escapeall: 154 | hardbreak: true 155 | nbsp: true 156 | - pymdownx.magiclink: 157 | hide_protocol: true 158 | repo_url_shortener: true 159 | - pymdownx.smartsymbols 160 | - pymdownx.superfences 161 | - pymdownx.tasklist: 162 | custom_checkbox: true 163 | - pymdownx.tilde 164 | - toc: 165 | permalink: true 166 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/app.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.api.app 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/config.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.api.config 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/errors.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.api.errors 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.api 2 | 3 | Api submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.api.app](app.md) 8 | * [stac_fastapi.api.config](config.md) 9 | * [stac_fastapi.api.errors](errors.md) 10 | * [stac_fastapi.api.middleware](middleware.md) 11 | * [stac_fastapi.api.models](models.md) 12 | * [stac_fastapi.api.openapi](openapi.md) 13 | * [stac_fastapi.api.routes](routes.md) 14 | 15 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/middleware.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.api.middleware 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/models.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.api.models 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/openapi.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.api.openapi 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/api/routes.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.api.routes 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/aggregation/aggregation.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.extensions.core.aggregation.aggregation 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/aggregation/client.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.extensions.core.aggregation.client 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/aggregation/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.aggregation 2 | 3 | Aggregation Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.aggregation.aggregation](aggregation.md) 8 | * [stac_fastapi.extensions.core.aggregation.client](client.md) 9 | * [stac_fastapi.extensions.core.aggregation.request](request.md) 10 | * [stac_fastapi.extensions.core.aggregation.types](types.md) 11 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/aggregation/request.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.extensions.core.aggregation.request 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/aggregation/types.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.extensions.core.aggregation.types 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/collection_search/client.md: -------------------------------------------------------------------------------- 1 | 2 | ::: stac_fastapi.extensions.core.collection_search.client 3 | options: 4 | show_source: true 5 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/collection_search/collection_search.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.collection_search.collection_search 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/collection_search/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.collection_search 2 | 3 | Collection-Search Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.collection_search.collection_search](collection_search.md) 8 | * [stac_fastapi.extensions.core.collection_search.client](client.md) 9 | * [stac_fastapi.extensions.core.collection_search.request](request.md) 10 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/collection_search/request.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ::: stac_fastapi.extensions.core.collection_search.request 5 | options: 6 | show_source: true 7 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/fields/fields.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.fields.fields 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/fields/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.fields 2 | 3 | Fields Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.fields.fields](fields.md) 8 | * [stac_fastapi.extensions.core.fields.request](request.md) 9 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/fields/request.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ::: stac_fastapi.extensions.core.fields.request 5 | options: 6 | show_source: true 7 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/filter/filter.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.filter.filter 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/filter/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.filter 2 | 3 | Filter Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.filter.filter](filter.md) 8 | * [stac_fastapi.extensions.core.filter.request](request.md) 9 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/filter/request.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ::: stac_fastapi.extensions.core.filter.request 5 | options: 6 | show_source: true 7 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/free_text/free_text.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.free_text.free_text 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/free_text/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.free_text 2 | 3 | Free-Text Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.free_text.free_text](free_text.md) 8 | * [stac_fastapi.extensions.core.free_text.request](request.md) 9 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/free_text/request.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ::: stac_fastapi.extensions.core.free_text.request 5 | options: 6 | show_source: true 7 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions 2 | 3 | Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.fields](fields/index.md) 8 | * [stac_fastapi.extensions.core.filter](filter/index.md) 9 | * [stac_fastapi.extensions.core.free_text](free_text/index.md) 10 | * [stac_fastapi.extensions.core.pagination](pagination/index.md) 11 | * [stac_fastapi.extensions.core.query](query/index.md) 12 | * [stac_fastapi.extensions.core.sort](sort/index.md) 13 | * [stac_fastapi.extensions.core.transaction](transaction.md) 14 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/pagination/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.pagination 2 | 3 | Pagination Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.pagination.pagination](pagination.md) 8 | * [stac_fastapi.extensions.core.pagination.offset_pagination](offset_pagination.md) 9 | * [stac_fastapi.extensions.core.pagination.token_pagination](token_pagination.md) 10 | * [stac_fastapi.extensions.core.pagination.request](request.md) 11 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/pagination/offset_pagination.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.pagination.offset_pagination 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/pagination/pagination.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.pagination.pagination 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/pagination/request.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ::: stac_fastapi.extensions.core.pagination.request 5 | options: 6 | show_source: true 7 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/pagination/token_pagination.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.pagination.token_pagination 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/query/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.query 2 | 3 | Query Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.query.query](query.md) 8 | * [stac_fastapi.extensions.core.query.request](request.md) 9 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/query/query.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.query.query 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/query/request.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ::: stac_fastapi.extensions.core.query.request 5 | options: 6 | show_source: true 7 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/sort/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.core.sort 2 | 3 | Sort Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core.sort.sort](sort.md) 8 | * [stac_fastapi.extensions.core.sort.request](request.md) 9 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/sort/request.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ::: stac_fastapi.extensions.core.sort.request 5 | options: 6 | show_source: true 7 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/sort/sort.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ::: stac_fastapi.extensions.core.sort.sort 4 | options: 5 | show_source: true 6 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/core/transaction.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.extensions.core.transaction 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions 2 | 3 | Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.core](core/index.md) 8 | * [stac_fastapi.extensions.third_party](third_party/index.md) 9 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.extensions.third_party.bulk_transactions 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/extensions/third_party/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.extensions.third_party 2 | 3 | Third Party Extensions submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.extensions.third_party.bulk_transactions](bulk_transactions.md) 8 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/index.md: -------------------------------------------------------------------------------- 1 | # Namespace stac_fastapi 2 | 3 | ## Sub-modules 4 | 5 | * [stac_fastapi.api](api/index.md) 6 | * [stac_fastapi.extensions](extensions/index.md) 7 | * [stac_fastapi.types](types/index.md) 8 | 9 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/config.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.config 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/conformance.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.conformance 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/core.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.core 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/errors.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.errors 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/extension.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.extension 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/index.md: -------------------------------------------------------------------------------- 1 | # Module stac_fastapi.types 2 | 3 | Types submodule. 4 | 5 | ## Sub-modules 6 | 7 | * [stac_fastapi.types.config](config.md) 8 | * [stac_fastapi.types.conformance](conformance.md) 9 | * [stac_fastapi.types.core](core.md) 10 | * [stac_fastapi.types.errors](errors.md) 11 | * [stac_fastapi.types.extension](extension.md) 12 | * [stac_fastapi.types.links](links.md) 13 | * [stac_fastapi.types.requests](requests.md) 14 | * [stac_fastapi.types.rfc3339](rfc3339.md) 15 | * [stac_fastapi.types.search](search.md) 16 | * [stac_fastapi.types.stac](stac.md) 17 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/links.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.links 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/requests.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.requests 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/rfc3339.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.rfc3339 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/search.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.search 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/api/stac_fastapi/types/stac.md: -------------------------------------------------------------------------------- 1 | ::: stac_fastapi.types.stac 2 | options: 3 | show_source: true 4 | -------------------------------------------------------------------------------- /docs/src/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /docs/src/migrations/v4.0.0.md: -------------------------------------------------------------------------------- 1 | # stac-fastapi v4.0 Migration Guide 2 | 3 | This document aims to help you update your application from **stac-fastapi** 3.0 to 4.0 4 | 5 | ## CHANGELOG 6 | ### Changed 7 | 8 | * use `string` type instead of python `datetime.datetime` for datetime parameter in `BaseSearchGetRequest`, `ItemCollectionUri` and `BaseCollectionSearchGetRequest` GET models 9 | * rename `filter` to `filter_expr` for `FilterExtensionGetRequest` and `FilterExtensionPostRequest` attributes to avoid conflict with python filter method 10 | * remove `post_request_model` attribute in `BaseCoreClient` and `AsyncBaseCoreClient` 11 | * remove `python3.8` support 12 | 13 | ### Fixed 14 | 15 | * Support multiple proxy servers in the `forwarded` header in `ProxyHeaderMiddleware` ([#782](https://github.com/stac-utils/stac-fastapi/pull/782)) 16 | 17 | ## Datetime type in GET request models 18 | 19 | While the POST request models are created using stac-pydantic, the GET request models are python `attrs` classes (~dataclasses). 20 | In 4.0, we've decided to change how the `datetime` attribute was defined in `BaseSearchGetRequest`, `ItemCollectionUri` and `BaseCollectionSearchGetRequest` models to match 21 | the `datetime` definition/validation done by the pydantic model. This mostly mean that the datetime attribute forwarded to the GET endpoints will now be of type string (forwarded from the user input). 22 | 23 | ```python 24 | from starlette.testclient import TestClient 25 | from stac_fastapi.api.app import StacApi 26 | from stac_fastapi.types.config import ApiSettings 27 | from stac_fastapi.types.core import BaseCoreClient 28 | 29 | class DummyCoreClient(BaseCoreClient): 30 | def all_collections(self, *args, **kwargs): 31 | raise NotImplementedError 32 | 33 | def get_collection(self, *args, **kwargs): 34 | raise NotImplementedError 35 | 36 | def get_item(self, *args, **kwargs): 37 | raise NotImplementedError 38 | 39 | def get_search(self, *args, datetime = None, **kwargs): 40 | # Return True if datetime is a string 41 | return isinstance(datetime, str) 42 | 43 | def post_search(self, *args, **kwargs): 44 | raise NotImplementedError 45 | 46 | def item_collection(self, *args, **kwargs): 47 | raise NotImplementedError 48 | 49 | api = StacApi( 50 | settings=ApiSettings(enable_response_models=False), 51 | client=DummyCoreClient(), 52 | extensions=[], 53 | ) 54 | 55 | 56 | # before 57 | with TestClient(api.app) as client: 58 | response = client.get( 59 | "/search", 60 | params={ 61 | "datetime": "2020-01-01T00:00:00.00001Z", 62 | }, 63 | ) 64 | assert response.json() == False 65 | 66 | # now 67 | with TestClient(api.app) as client: 68 | response = client.get( 69 | "/search", 70 | params={ 71 | "datetime": "2020-01-01T00:00:00.00001Z", 72 | }, 73 | ) 74 | assert response.json() == True 75 | ``` 76 | 77 | #### Start/End dates 78 | 79 | Following stac-pydantic's `Search` model, we've added class attributes to easily retrieve the `parsed` dates: 80 | 81 | ```python 82 | from stac_fastapi.types.search import BaseSearchGetRequest 83 | 84 | # Interval 85 | search = BaseSearchGetRequest(datetime="2020-01-01T00:00:00.00001Z/2020-01-02T00:00:00.00001Z") 86 | 87 | search.parse_datetime() 88 | >>> (datetime.datetime(2020, 1, 1, 0, 0, 0, 10, tzinfo=datetime.timezone.utc), datetime.datetime(2020, 1, 2, 0, 0, 0, 10, tzinfo=datetime.timezone.utc)) 89 | 90 | search.start_date 91 | >>> datetime.datetime(2020, 1, 1, 0, 0, 0, 10, tzinfo=datetime.timezone.utc) 92 | 93 | search.end_date 94 | >>> datetime.datetime(2020, 1, 2, 0, 0, 0, 10, tzinfo=datetime.timezone.utc) 95 | 96 | # Single date 97 | search = BaseSearchGetRequest(datetime="2020-01-01T00:00:00.00001Z") 98 | 99 | search.parse_datetime() 100 | >>> datetime.datetime(2020, 1, 1, 0, 0, 0, 10, tzinfo=datetime.timezone.utc) 101 | 102 | search.start_date 103 | >>> datetime.datetime(2020, 1, 1, 0, 0, 0, 10, tzinfo=datetime.timezone.utc) 104 | 105 | search.end_date 106 | >>> None 107 | ``` 108 | 109 | ## Filter extension 110 | 111 | We've renamed the `filter` attribute to `filter_expr` in the `FilterExtensionGetRequest` and `FilterExtensionPostRequest` models to avoid any conflict with python `filter` method. This change means GET endpoints with the filter extension enabled will receive `filter_expr=` option instead of `filter=`. Same for POST endpoints where the `body` will now have a `.filter_expr` instead of a `filter` attribute. 112 | 113 | Note: This change does not affect the `input` because we use `aliases`. 114 | 115 | ```python 116 | from starlette.testclient import TestClient 117 | from stac_fastapi.api.app import StacApi 118 | from stac_fastapi.api.models import create_get_request_model, create_post_request_model 119 | from stac_fastapi.extensions.core import FilterExtension 120 | from stac_fastapi.types.config import ApiSettings 121 | from stac_fastapi.types.core import BaseCoreClient 122 | 123 | class DummyCoreClient(BaseCoreClient): 124 | def all_collections(self, *args, **kwargs): 125 | raise NotImplementedError 126 | 127 | def get_collection(self, *args, **kwargs): 128 | raise NotImplementedError 129 | 130 | def get_item(self, *args, **kwargs): 131 | raise NotImplementedError 132 | 133 | def get_search(self, *args, **kwargs): 134 | return kwargs 135 | 136 | def post_search(self, *args, **kwargs): 137 | return args[0].model_dump() 138 | 139 | def item_collection(self, *args, **kwargs): 140 | raise NotImplementedError 141 | 142 | extensions = [FilterExtension()] 143 | api = StacApi( 144 | settings=ApiSettings(enable_response_models=False), 145 | client=DummyCoreClient(), 146 | extensions=extensions, 147 | search_get_request_model=create_get_request_model(extensions), 148 | search_post_request_model=create_post_request_model(extensions), 149 | ) 150 | 151 | 152 | # before 153 | with TestClient(api.app) as client: 154 | response = client.post( 155 | "/search", 156 | json={ 157 | "filter": {"op": "=", "args": [{"property": "test_property"}, "test-value"]}, 158 | }, 159 | ) 160 | assert response.json()["filter"] 161 | 162 | response = client.get( 163 | "/search", 164 | params={ 165 | "filter": "id='item_id' AND collection='collection_id'", 166 | }, 167 | ) 168 | assert response.json()["filter"] 169 | 170 | # now 171 | with TestClient(api.app) as client: 172 | response = client.post( 173 | "/search", 174 | json={ 175 | "filter": {"op": "=", "args": [{"property": "test_property"}, "test-value"]}, 176 | }, 177 | ) 178 | assert response.json()["filter_expr"] 179 | 180 | response = client.get( 181 | "/search", 182 | params={ 183 | "filter": "id='item_id' AND collection='collection_id'", 184 | }, 185 | ) 186 | assert response.json()["filter_expr"] 187 | ``` 188 | 189 | 190 | -------------------------------------------------------------------------------- /docs/src/release-notes.md: -------------------------------------------------------------------------------- 1 | ../../CHANGES.md -------------------------------------------------------------------------------- /docs/src/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: rgb(13, 118, 160); 3 | } 4 | -------------------------------------------------------------------------------- /docs/src/tips-and-tricks.md: -------------------------------------------------------------------------------- 1 | # Tips and Tricks 2 | 3 | This page contains a few 'tips and tricks' for getting **stac-fastapi** working in various situations. 4 | 5 | ## Avoid FastAPI (slow) serialization 6 | 7 | When not using Pydantic validation for responses, FastAPI will still use a complex (slow) [serialization process](https://github.com/fastapi/fastapi/discussions/8165). 8 | 9 | Starting with stac-fastapi `5.2.0`, we've added `ENABLE_DIRECT_RESPONSE` option to by-pass the default FastAPI serialization by wrapping the endpoint responses into `starlette.Response` classes. 10 | 11 | Ref: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/347 12 | 13 | ## Application Middlewares 14 | 15 | By default the `StacApi` class will enable 3 Middlewares (`BrotliMiddleware`, `CORSMiddleware` and `ProxyHeaderMiddleware`). You may want to overwrite the defaults configuration by editing your backend's `app.py`: 16 | 17 | ```python 18 | from starlette.middleware import Middleware 19 | 20 | from stac_fastapi.api.app import StacApi 21 | from stac_fastapi.api.middleware import CORSMiddleware 22 | 23 | api = StacApi( 24 | ... 25 | middlewares=[ 26 | Middleware(CORSMiddleware, allow_origins=["https://myendpoints.io"]) 27 | ], 28 | ... 29 | ) 30 | ``` 31 | 32 | ## Set API title, description and version 33 | 34 | For the landing page, you can set the API title, description and version using environment variables. 35 | 36 | - `STAC_FASTAPI_VERSION` (string) is the version number of your API instance (this is not the STAC version). 37 | - `STAC FASTAPI_TITLE` (string) should be a self-explanatory title for your API. 38 | - `STAC FASTAPI_DESCRIPTION` (string) should be a good description for your API. It can contain CommonMark. 39 | - `STAC_FASTAPI_LANDING_ID` (string) is a unique identifier for your Landing page. 40 | 41 | 42 | ## Default `includes` in Fields extension (POST request) 43 | 44 | The [**Fields** API extension](https://github.com/stac-api-extensions/fields) enables to filter in/out STAC Items keys (e.g `geometry`). The default behavior is to not filter out anything, but this can be overridden by providing a custom `FieldsExtensionPostRequest` class: 45 | 46 | ```python 47 | from typing import Optional, Set 48 | 49 | import attr 50 | from stac_fastapi.extensions.core import FieldsExtension as FieldsExtensionBase 51 | from stac_fastapi.extensions.core.fields import request 52 | from pydantic import BaseModel, Field 53 | 54 | 55 | class PostFieldsExtension(requests.PostFieldsExtension): 56 | include: Optional[Set[str]] = Field( 57 | default_factory=lambda: { 58 | "id", 59 | "type", 60 | "stac_version", 61 | "geometry", 62 | "bbox", 63 | "links", 64 | "assets", 65 | "properties.datetime", 66 | "collection", 67 | } 68 | ) 69 | exclude: Optional[Set[str]] = set() 70 | 71 | 72 | class FieldsExtensionPostRequest(BaseModel): 73 | """Additional fields and schema for the POST request.""" 74 | 75 | fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension()) 76 | 77 | 78 | class FieldsExtension(FieldsExtensionBase): 79 | """Override the POST model""" 80 | 81 | POST = FieldsExtensionPostRequest 82 | 83 | 84 | from stac_fastapi.api.app import StacApi 85 | 86 | stac = StacApi( 87 | extensions=[ 88 | FieldsExtension() 89 | ] 90 | ) 91 | ``` 92 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py39" # minimum supported version 3 | line-length = 90 4 | 5 | [tool.ruff.lint] 6 | select = [ 7 | "C9", 8 | "D1", 9 | "E", 10 | "F", 11 | "I", 12 | "W", 13 | ] 14 | 15 | [tool.ruff.lint.per-file-ignores] 16 | "**/tests/**/*.py" = ["D1"] 17 | 18 | [tool.ruff.lint.isort] 19 | known-first-party = ["stac_fastapi"] 20 | known-third-party = ["stac_pydantic", "fastapi"] 21 | section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] 22 | 23 | [tool.ruff.format] 24 | quote-style = "double" 25 | 26 | [tool.mypy] 27 | ignore_missing_imports = true 28 | namespace_packages = true 29 | explicit_package_bases = true 30 | exclude = ["tests", ".venv"] 31 | 32 | [tool.bumpversion] 33 | current_version = "5.2.1" 34 | parse = """(?x) 35 | (?P\\d+)\\. 36 | (?P\\d+)\\. 37 | (?P\\d+) 38 | (?: 39 | (?Pa|b|rc) # pre-release label 40 | (?P\\d+) # pre-release version number 41 | )? # pre-release section is optional 42 | (?: 43 | \\.post 44 | (?P\\d+) # post-release version number 45 | )? # post-release section is optional 46 | """ 47 | serialize = [ 48 | "{major}.{minor}.{patch}.post{post_n}", 49 | "{major}.{minor}.{patch}{pre_l}{pre_n}", 50 | "{major}.{minor}.{patch}", 51 | ] 52 | 53 | search = "{current_version}" 54 | replace = "{new_version}" 55 | regex = false 56 | tag = false 57 | commit = true 58 | 59 | [[tool.bumpversion.files]] 60 | filename = "VERSION" 61 | search = "{current_version}" 62 | replace = "{new_version}" 63 | 64 | [[tool.bumpversion.files]] 65 | filename = "stac_fastapi/api/stac_fastapi/api/version.py" 66 | search = '__version__ = "{current_version}"' 67 | replace = '__version__ = "{new_version}"' 68 | 69 | [[tool.bumpversion.files]] 70 | filename = "stac_fastapi/extensions/stac_fastapi/extensions/version.py" 71 | search = '__version__ = "{current_version}"' 72 | replace = '__version__ = "{new_version}"' 73 | 74 | [[tool.bumpversion.files]] 75 | filename = "stac_fastapi/types/stac_fastapi/types/version.py" 76 | search = '__version__ = "{current_version}"' 77 | replace = '__version__ = "{new_version}"' 78 | -------------------------------------------------------------------------------- /scripts/publish: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | # Import shared variables 10 | SUBPACKAGE_DIRS=( 11 | "stac_fastapi/types" 12 | "stac_fastapi/extensions" 13 | "stac_fastapi/api" 14 | ) 15 | 16 | function usage() { 17 | echo -n \ 18 | "Usage: $(basename "$0") 19 | Publish all stac-fastapi packages. 20 | 21 | Options: 22 | --test Publish to test pypi. Requires a 'testpypi' repository 23 | be defined in your .pypirc; 24 | See https://packaging.python.org/guides/using-testpypi/#using-testpypi-with-pip 25 | " 26 | } 27 | 28 | POSITIONAL=() 29 | while [[ $# -gt 0 ]] 30 | do 31 | key="$1" 32 | case $key in 33 | 34 | --help) 35 | usage 36 | exit 0 37 | shift 38 | ;; 39 | 40 | --test) 41 | TEST_PYPI="--repository testpypi" 42 | shift 43 | ;; 44 | 45 | *) # unknown option 46 | POSITIONAL+=("$1") # save it in an array for later 47 | shift # past argument 48 | ;; 49 | esac 50 | done 51 | set -- "${POSITIONAL[@]}" # restore positional parameters 52 | 53 | # Fail if this isn't CI and we aren't publishing to test pypi 54 | if [ -z "${TEST_PYPI}" ] && [ -z "${CI}" ]; then 55 | echo "Only CI can publish to pypi" 56 | exit 1 57 | fi 58 | 59 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 60 | for PACKAGE_DIR in "${SUBPACKAGE_DIRS[@]}" 61 | do 62 | echo ${PACKAGE_DIR} 63 | pushd ./${PACKAGE_DIR} 64 | rm -rf dist 65 | python setup.py sdist bdist_wheel 66 | twine upload ${TEST_PYPI} dist/* 67 | popd 68 | 69 | done 70 | fi -------------------------------------------------------------------------------- /stac_fastapi/api/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi/423b5871280f46a91b76a008bdbff19373db3d13/stac_fastapi/api/README.md -------------------------------------------------------------------------------- /stac_fastapi/api/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: stac_fastapi.api.version.__version__ 3 | -------------------------------------------------------------------------------- /stac_fastapi/api/setup.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi: api 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 | "brotli_asgi", 10 | "stac-fastapi.types~=5.2", 11 | ] 12 | 13 | extra_reqs = { 14 | "dev": [ 15 | "httpx", 16 | "pytest", 17 | "pytest-cov", 18 | "pytest-asyncio", 19 | "pre-commit", 20 | "requests", 21 | ], 22 | "benchmark": [ 23 | "pytest-benchmark", 24 | ], 25 | "docs": [ 26 | "black>=23.10.1", 27 | "mkdocs>=1.4.3", 28 | "mkdocs-jupyter>=0.24.5", 29 | "mkdocs-material[imaging]>=9.5", 30 | "griffe-inherited-docstrings>=1.0.0", 31 | "mkdocstrings[python]>=0.25.1", 32 | ], 33 | } 34 | 35 | 36 | setup( 37 | name="stac-fastapi.api", 38 | description="An implementation of STAC API based on the FastAPI framework.", 39 | long_description=desc, 40 | long_description_content_type="text/markdown", 41 | python_requires=">=3.9", 42 | classifiers=[ 43 | "Intended Audience :: Developers", 44 | "Intended Audience :: Information Technology", 45 | "Intended Audience :: Science/Research", 46 | "Programming Language :: Python :: 3.9", 47 | "Programming Language :: Python :: 3.10", 48 | "Programming Language :: Python :: 3.11", 49 | "Programming Language :: Python :: 3.12", 50 | "Programming Language :: Python :: 3.13", 51 | "License :: OSI Approved :: MIT License", 52 | ], 53 | keywords="STAC FastAPI COG", 54 | author="Arturo Engineering", 55 | author_email="engineering@arturo.ai", 56 | url="https://github.com/stac-utils/stac-fastapi", 57 | license="MIT", 58 | packages=find_namespace_packages(exclude=["alembic", "tests", "scripts"]), 59 | zip_safe=False, 60 | install_requires=install_requires, 61 | tests_require=extra_reqs["dev"], 62 | extras_require=extra_reqs, 63 | ) 64 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Api submodule.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/config.py: -------------------------------------------------------------------------------- 1 | """Application settings.""" 2 | 3 | import enum 4 | 5 | 6 | # TODO: Move to stac-pydantic 7 | # Does that make sense now? The shift to json schema rather than a well-known 8 | # enumeration makes that less obvious. 9 | class ApiExtensions(enum.Enum): 10 | """Enumeration of available stac api extensions. 11 | 12 | Ref: https://github.com/stac-api-extensions 13 | """ 14 | 15 | fields = "fields" 16 | filter = "filter" 17 | query = "query" 18 | sort = "sort" 19 | transaction = "transaction" 20 | aggregation = "aggregation" 21 | collection_search = "collection-search" 22 | free_text = "free-text" 23 | 24 | 25 | class AddOns(enum.Enum): 26 | """Enumeration of available third party add ons.""" 27 | 28 | bulk_transaction = "bulk-transaction" 29 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/errors.py: -------------------------------------------------------------------------------- 1 | """Error handling.""" 2 | 3 | import logging 4 | from typing import Callable, Dict, Type, TypedDict 5 | 6 | from fastapi import FastAPI 7 | from fastapi.encoders import jsonable_encoder 8 | from fastapi.exceptions import RequestValidationError, ResponseValidationError 9 | from starlette import status 10 | from starlette.requests import Request 11 | 12 | from stac_fastapi.api.models import JSONResponse 13 | from stac_fastapi.types.errors import ( 14 | ConflictError, 15 | DatabaseError, 16 | ForeignKeyError, 17 | InvalidQueryParameter, 18 | NotFoundError, 19 | ) 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | DEFAULT_STATUS_CODES = { 25 | NotFoundError: status.HTTP_404_NOT_FOUND, 26 | ConflictError: status.HTTP_409_CONFLICT, 27 | ForeignKeyError: status.HTTP_424_FAILED_DEPENDENCY, 28 | DatabaseError: status.HTTP_424_FAILED_DEPENDENCY, 29 | Exception: status.HTTP_500_INTERNAL_SERVER_ERROR, 30 | InvalidQueryParameter: status.HTTP_400_BAD_REQUEST, 31 | ResponseValidationError: status.HTTP_500_INTERNAL_SERVER_ERROR, 32 | } 33 | 34 | 35 | class ErrorResponse(TypedDict): 36 | """A JSON error response returned by the API. 37 | 38 | The STAC API spec expects that `code` and `description` are both present in 39 | the payload. 40 | 41 | Attributes: 42 | code: A code representing the error, semantics are up to implementor. 43 | description: A description of the error. 44 | """ 45 | 46 | code: str 47 | description: str 48 | 49 | 50 | def exception_handler_factory(status_code: int) -> Callable: 51 | """Create a FastAPI exception handler for a particular status code. 52 | 53 | Args: 54 | status_code: HTTP status code. 55 | 56 | Returns: 57 | callable: an exception handler. 58 | """ 59 | 60 | def handler(request: Request, exc: Exception): 61 | """I handle exceptions!!.""" 62 | logger.error(exc, exc_info=True) 63 | return JSONResponse( 64 | content=ErrorResponse(code=exc.__class__.__name__, description=str(exc)), 65 | status_code=status_code, 66 | ) 67 | 68 | return handler 69 | 70 | 71 | def add_exception_handlers( 72 | app: FastAPI, status_codes: Dict[Type[Exception], int] 73 | ) -> None: 74 | """Add exception handlers to the FastAPI application. 75 | 76 | Args: 77 | app: the FastAPI application. 78 | status_codes: mapping between exceptions and status codes. 79 | 80 | Returns: 81 | None 82 | """ 83 | for exc, code in status_codes.items(): 84 | app.add_exception_handler(exc, exception_handler_factory(code)) 85 | 86 | # By default FastAPI will return 422 status codes for invalid requests 87 | # But the STAC api spec suggests returning a 400 in this case 88 | def request_validation_exception_handler( 89 | request: Request, exc: RequestValidationError 90 | ) -> JSONResponse: 91 | return JSONResponse( 92 | content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}), 93 | status_code=status.HTTP_400_BAD_REQUEST, 94 | ) 95 | 96 | app.add_exception_handler( 97 | RequestValidationError, request_validation_exception_handler 98 | ) 99 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/middleware.py: -------------------------------------------------------------------------------- 1 | """Api middleware.""" 2 | 3 | import contextlib 4 | import re 5 | import typing 6 | from http.client import HTTP_PORT, HTTPS_PORT 7 | from typing import List, Optional, Tuple 8 | 9 | from starlette.middleware.cors import CORSMiddleware as _CORSMiddleware 10 | from starlette.types import ASGIApp, Receive, Scope, Send 11 | 12 | 13 | class CORSMiddleware(_CORSMiddleware): 14 | """Subclass of Starlette's standard CORS middleware with default values set to those 15 | recommended by the STAC API spec. 16 | 17 | https://github.com/radiantearth/stac-api-spec/blob/914cf8108302e2ec734340080a45aaae4859bb63/implementation.md#cors 18 | """ 19 | 20 | def __init__( 21 | self, 22 | app: ASGIApp, 23 | allow_origins: typing.Sequence[str] = ("*",), 24 | allow_methods: typing.Sequence[str] = ( 25 | "OPTIONS", 26 | "POST", 27 | "GET", 28 | ), 29 | allow_headers: typing.Sequence[str] = ("Content-Type",), 30 | allow_credentials: bool = False, 31 | allow_origin_regex: typing.Optional[str] = None, 32 | expose_headers: typing.Sequence[str] = (), 33 | max_age: int = 600, 34 | ) -> None: 35 | """Create CORS middleware.""" 36 | super().__init__( 37 | app, 38 | allow_origins, 39 | allow_methods, 40 | allow_headers, 41 | allow_credentials, 42 | allow_origin_regex, 43 | expose_headers, 44 | max_age, 45 | ) 46 | 47 | 48 | _PROTO_HEADER_REGEX = re.compile(r"proto=(?Phttp(s)?)") 49 | _HOST_HEADER_REGEX = re.compile(r"host=(?P[\w.-]+)(:(?P\d{1,5}))?") 50 | 51 | 52 | class ProxyHeaderMiddleware: 53 | """Account for forwarding headers when deriving base URL. 54 | 55 | Prioritise standard Forwarded header, look for non-standard X-Forwarded-* if missing. 56 | Default to what can be derived from the URL if no headers provided. Middleware updates 57 | the host header that is interpreted by starlette when deriving Request.base_url. 58 | """ 59 | 60 | def __init__(self, app: ASGIApp): 61 | """Create proxy header middleware.""" 62 | self.app = app 63 | 64 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 65 | """Call from stac-fastapi framework.""" 66 | if scope["type"] == "http": 67 | proto, domain, port = self._get_forwarded_url_parts(scope) 68 | scope["scheme"] = proto 69 | if domain is not None: 70 | port_suffix = "" 71 | if port is not None: 72 | if (proto == "http" and port != HTTP_PORT) or ( 73 | proto == "https" and port != HTTPS_PORT 74 | ): 75 | port_suffix = f":{port}" 76 | 77 | scope["headers"] = self._replace_header_value_by_name( 78 | scope, 79 | "host", 80 | f"{domain}{port_suffix}", 81 | ) 82 | 83 | await self.app(scope, receive, send) 84 | 85 | def _get_forwarded_url_parts(self, scope: Scope) -> Tuple[str, str, str]: 86 | proto = scope.get("scheme", "http") 87 | header_host = self._get_header_value_by_name(scope, "host") 88 | if header_host is None: 89 | domain, port = scope.get("server") 90 | else: 91 | header_host_parts = header_host.split(":") 92 | if len(header_host_parts) == 2: 93 | domain, port = header_host_parts 94 | else: 95 | domain = header_host_parts[0] 96 | port = None 97 | 98 | port_str = None # make sure it is defined in all paths since we access it later 99 | 100 | if forwarded := self._get_header_value_by_name(scope, "forwarded"): 101 | for proxy in forwarded.split(","): 102 | if proto_expr := _PROTO_HEADER_REGEX.search(proxy): 103 | proto = proto_expr.group("proto") 104 | if host_expr := _HOST_HEADER_REGEX.search(proxy): 105 | domain = host_expr.group("host") 106 | port_str = host_expr.group("port") # None if not present in the match 107 | 108 | else: 109 | domain = self._get_header_value_by_name(scope, "x-forwarded-host", domain) 110 | proto = self._get_header_value_by_name(scope, "x-forwarded-proto", proto) 111 | port_str = self._get_header_value_by_name(scope, "x-forwarded-port", port) 112 | 113 | with contextlib.suppress(ValueError): # ignore ports that are not valid integers 114 | port = int(port_str) if port_str is not None else port 115 | 116 | return (proto, domain, port) 117 | 118 | def _get_header_value_by_name( 119 | self, scope: Scope, header_name: str, default_value: Optional[str] = None 120 | ) -> Optional[str]: 121 | headers = scope["headers"] 122 | candidates = [ 123 | value.decode() for key, value in headers if key.decode() == header_name 124 | ] 125 | return candidates[0] if len(candidates) == 1 else default_value 126 | 127 | @staticmethod 128 | def _replace_header_value_by_name( 129 | scope: Scope, header_name: str, new_value: str 130 | ) -> List[Tuple[str, str]]: 131 | return [ 132 | (name, value) 133 | for name, value in scope["headers"] 134 | if name.decode() != header_name 135 | ] + [(str.encode(header_name), str.encode(new_value))] 136 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/models.py: -------------------------------------------------------------------------------- 1 | """Api request/response models.""" 2 | 3 | from typing import List, Literal, Optional, Type, Union 4 | 5 | import attr 6 | from fastapi import Path, Query 7 | from pydantic import BaseModel, create_model 8 | from stac_pydantic.shared import BBox 9 | from typing_extensions import Annotated 10 | 11 | from stac_fastapi.types.extension import ApiExtension 12 | from stac_fastapi.types.search import ( 13 | APIRequest, 14 | BaseSearchGetRequest, 15 | BaseSearchPostRequest, 16 | DatetimeMixin, 17 | DateTimeQueryType, 18 | Limit, 19 | _bbox_converter, 20 | _validate_datetime, 21 | ) 22 | 23 | try: 24 | import orjson # noqa 25 | from fastapi.responses import ORJSONResponse as JSONResponse 26 | except ImportError: # pragma: nocover 27 | from starlette.responses import JSONResponse 28 | 29 | 30 | def create_request_model( 31 | model_name="SearchGetRequest", 32 | base_model: Union[Type[BaseModel], APIRequest] = BaseSearchGetRequest, 33 | extensions: Optional[List[ApiExtension]] = None, 34 | mixins: Optional[Union[List[BaseModel], List[APIRequest]]] = None, 35 | request_type: Optional[str] = "GET", 36 | ) -> Union[Type[BaseModel], APIRequest]: 37 | """Create a pydantic model for validating request bodies.""" 38 | fields = {} 39 | extension_models = [] 40 | 41 | # Check extensions for additional parameters to search 42 | for extension in extensions or []: 43 | if extension_model := extension.get_request_model(request_type): 44 | extension_models.append(extension_model) 45 | 46 | mixins = mixins or [] 47 | 48 | models = [base_model] + extension_models + mixins 49 | 50 | # Handle GET requests 51 | if all([issubclass(m, APIRequest) for m in models]): 52 | return attr.make_class(model_name, attrs={}, bases=tuple(models)) 53 | 54 | # Handle POST requests 55 | elif all([issubclass(m, BaseModel) for m in models]): 56 | for model in models: 57 | for k, field_info in model.model_fields.items(): 58 | fields[k] = (field_info.annotation, field_info) 59 | 60 | return create_model(model_name, **fields, __base__=base_model) # type: ignore 61 | 62 | raise TypeError("Mixed Request Model types. Check extension request types.") 63 | 64 | 65 | def create_get_request_model( 66 | extensions: Optional[List[ApiExtension]], 67 | base_model: BaseSearchGetRequest = BaseSearchGetRequest, 68 | ) -> APIRequest: 69 | """Wrap create_request_model to create the GET request model.""" 70 | 71 | return create_request_model( 72 | "SearchGetRequest", 73 | base_model=base_model, 74 | extensions=extensions, 75 | request_type="GET", 76 | ) 77 | 78 | 79 | def create_post_request_model( 80 | extensions: Optional[List[ApiExtension]], 81 | base_model: BaseSearchPostRequest = BaseSearchPostRequest, 82 | ) -> Type[BaseModel]: 83 | """Wrap create_request_model to create the POST request model.""" 84 | return create_request_model( 85 | "SearchPostRequest", 86 | base_model=base_model, 87 | extensions=extensions, 88 | request_type="POST", 89 | ) 90 | 91 | 92 | @attr.s 93 | class CollectionUri(APIRequest): 94 | """Get or delete collection.""" 95 | 96 | collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() 97 | 98 | 99 | @attr.s 100 | class ItemUri(APIRequest): 101 | """Get or delete item.""" 102 | 103 | collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() 104 | item_id: Annotated[str, Path(description="Item ID")] = attr.ib() 105 | 106 | 107 | @attr.s 108 | class EmptyRequest(APIRequest): 109 | """Empty request.""" 110 | 111 | ... 112 | 113 | 114 | @attr.s 115 | class ItemCollectionUri(APIRequest, DatetimeMixin): 116 | """Get item collection.""" 117 | 118 | collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() 119 | limit: Annotated[ 120 | Optional[Limit], 121 | Query( 122 | description="Limits the number of results that are included in each page of the response (capped to 10_000)." # noqa: E501 123 | ), 124 | ] = attr.ib(default=10) 125 | bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) # type: ignore 126 | datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) 127 | 128 | 129 | class GeoJSONResponse(JSONResponse): 130 | """JSON with custom, vendor content-type.""" 131 | 132 | media_type = "application/geo+json" 133 | 134 | 135 | class JSONSchemaResponse(JSONResponse): 136 | """JSON with custom, vendor content-type.""" 137 | 138 | media_type = "application/schema+json" 139 | 140 | 141 | class HealthCheck(BaseModel, extra="allow"): 142 | """health check response model.""" 143 | 144 | status: Literal["UP", "DOWN"] 145 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/openapi.py: -------------------------------------------------------------------------------- 1 | """openapi.""" 2 | 3 | from fastapi import FastAPI 4 | from starlette.requests import Request 5 | from starlette.responses import Response 6 | from starlette.routing import Route, request_response 7 | 8 | 9 | def update_openapi(app: FastAPI) -> FastAPI: 10 | """Update OpenAPI response content-type. 11 | 12 | This function modifies the openapi route to comply with the STAC API spec's required 13 | content-type response header. 14 | """ 15 | # Find the route for the openapi_url in the app 16 | openapi_route: Route = next( 17 | route for route in app.router.routes if route.path == app.openapi_url 18 | ) 19 | # Store the old endpoint function so we can call it from the patched function 20 | old_endpoint = openapi_route.endpoint 21 | 22 | # Create a patched endpoint function that modifies the content type of the response 23 | async def patched_openapi_endpoint(req: Request) -> Response: 24 | # Get the response from the old endpoint function 25 | response = await old_endpoint(req) 26 | # Update the content type header in place 27 | response.headers["content-type"] = "application/vnd.oai.openapi+json;version=3.0" 28 | # Return the updated response 29 | return response 30 | 31 | # When a Route is accessed the `handle` function calls `self.app`. Which is 32 | # the endpoint function wrapped with `request_response`. So we need to wrap 33 | # our patched function and replace the existing app with it. 34 | openapi_route.app = request_response(patched_openapi_endpoint) 35 | 36 | # return the patched app 37 | return app 38 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi/423b5871280f46a91b76a008bdbff19373db3d13/stac_fastapi/api/stac_fastapi/api/py.typed -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/routes.py: -------------------------------------------------------------------------------- 1 | """Route factories.""" 2 | 3 | import copy 4 | import functools 5 | import inspect 6 | from typing import Any, Awaitable, Callable, Dict, List, Optional, Type, TypedDict, Union 7 | 8 | from fastapi import Depends, FastAPI, params 9 | from fastapi.datastructures import DefaultPlaceholder 10 | from fastapi.dependencies.utils import get_dependant, get_parameterless_sub_dependant 11 | from fastapi.routing import APIRoute 12 | from pydantic import BaseModel 13 | from starlette.concurrency import run_in_threadpool 14 | from starlette.requests import Request 15 | from starlette.responses import Response 16 | from starlette.routing import BaseRoute, Match, request_response 17 | from starlette.status import HTTP_204_NO_CONTENT 18 | 19 | from stac_fastapi.api.models import APIRequest 20 | 21 | 22 | def _wrap_response(resp: Any) -> Any: 23 | if resp is not None: 24 | return resp 25 | else: # None is returned as 204 No Content 26 | return Response(status_code=HTTP_204_NO_CONTENT) 27 | 28 | 29 | def sync_to_async(func): 30 | """Run synchronous function asynchronously in a background thread.""" 31 | 32 | @functools.wraps(func) 33 | async def run(*args, **kwargs): 34 | return await run_in_threadpool(func, *args, **kwargs) 35 | 36 | return run 37 | 38 | 39 | def create_async_endpoint( 40 | func: Callable, 41 | request_model: Union[Type[APIRequest], Type[BaseModel], Dict], 42 | ) -> Callable[[Any, Any], Awaitable[Any]]: 43 | """Wrap a function in a coroutine which may be used to create a FastAPI endpoint. 44 | 45 | Synchronous functions are executed asynchronously using a background thread. 46 | """ 47 | 48 | if not inspect.iscoroutinefunction(func): 49 | func = sync_to_async(func) 50 | 51 | _endpoint: Callable[[Any, Any], Awaitable[Any]] 52 | 53 | if isinstance(request_model, dict): 54 | 55 | async def _endpoint(request: Request, request_data: Dict[str, Any]): 56 | """Endpoint.""" 57 | return _wrap_response(await func(request_data, request=request)) 58 | 59 | elif issubclass(request_model, APIRequest): 60 | 61 | async def _endpoint(request: Request, request_data=Depends(request_model)): 62 | """Endpoint.""" 63 | return _wrap_response(await func(request=request, **request_data.kwargs())) 64 | 65 | elif issubclass(request_model, BaseModel): 66 | 67 | async def _endpoint(request: Request, request_data: request_model): # type: ignore 68 | """Endpoint.""" 69 | return _wrap_response(await func(request_data, request=request)) 70 | 71 | else: 72 | raise ValueError(f"Unsupported type for request model {type(request_model)}") 73 | 74 | return _endpoint 75 | 76 | 77 | class Scope(TypedDict, total=False): 78 | """More strict version of Starlette's Scope.""" 79 | 80 | # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 81 | path: str 82 | method: str 83 | type: Optional[str] 84 | 85 | 86 | def add_route_dependencies( 87 | routes: List[BaseRoute], scopes: List[Scope], dependencies: List[params.Depends] 88 | ) -> None: 89 | """Add dependencies to routes. 90 | 91 | Allows a developer to add dependencies to a route after the route has been 92 | defined. 93 | 94 | "*" can be used for path or method to match all allowed routes. 95 | 96 | Returns: 97 | None 98 | """ 99 | for scope in scopes: 100 | _scope = copy.deepcopy(scope) 101 | for route in routes: 102 | if scope["path"] == "*": 103 | _scope["path"] = route.path 104 | 105 | if scope["method"] == "*": 106 | _scope["method"] = list(route.methods)[0] 107 | 108 | match, _ = route.matches({"type": "http", **_scope}) 109 | if match != Match.FULL: 110 | continue 111 | 112 | # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect 113 | if not hasattr(route, "dependant"): 114 | continue 115 | 116 | # Mimicking how APIRoute handles dependencies: 117 | # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 118 | for depends in dependencies[::-1]: 119 | route.dependant.dependencies.insert( 120 | 0, 121 | get_parameterless_sub_dependant( 122 | depends=depends, path=route.path_format 123 | ), 124 | ) 125 | 126 | # Register dependencies directly on route so that they aren't ignored if 127 | # the routes are later associated with an app (e.g. 128 | # app.include_router(router)) 129 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 130 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 131 | route.dependencies.extend(dependencies) 132 | 133 | 134 | def add_direct_response(app: FastAPI) -> None: 135 | """ 136 | Setup FastAPI application's endpoints to return Response Object directly, avoiding 137 | Pydantic validation and FastAPI (slow) serialization. 138 | 139 | ref: https://gist.github.com/Zaczero/00f3a2679ebc0a25eb938ed82bc63553 140 | """ 141 | 142 | def wrap_endpoint(endpoint: Callable, cls: Type[Response]): 143 | @functools.wraps(endpoint) 144 | async def wrapper(*args, **kwargs): 145 | content = await endpoint(*args, **kwargs) 146 | return content if isinstance(content, Response) else cls(content) 147 | 148 | return wrapper 149 | 150 | for route in app.routes: 151 | if not isinstance(route, APIRoute): 152 | continue 153 | 154 | response_class = route.response_class 155 | if isinstance(response_class, DefaultPlaceholder): 156 | response_class = response_class.value 157 | 158 | if issubclass(response_class, Response): 159 | route.endpoint = wrap_endpoint(route.endpoint, response_class) 160 | route.dependant = get_dependant(path=route.path_format, call=route.endpoint) 161 | route.app = request_response(route.get_route_handler()) 162 | -------------------------------------------------------------------------------- /stac_fastapi/api/stac_fastapi/api/version.py: -------------------------------------------------------------------------------- 1 | """Library version.""" 2 | 3 | __version__ = "5.2.1" 4 | -------------------------------------------------------------------------------- /stac_fastapi/api/tests/benchmarks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional, Union 3 | 4 | import pytest 5 | from stac_pydantic.api.utils import link_factory 6 | from starlette.testclient import TestClient 7 | 8 | from stac_fastapi.api.app import StacApi 9 | from stac_fastapi.types import stac as stac_types 10 | from stac_fastapi.types.config import ApiSettings 11 | from stac_fastapi.types.core import BaseCoreClient, BaseSearchPostRequest, NumType 12 | 13 | collection_links = link_factory.CollectionLinks("/", "test").create_links() 14 | item_links = link_factory.ItemLinks("/", "test", "test").create_links() 15 | 16 | 17 | collections = [ 18 | stac_types.Collection( 19 | id=f"test_collection_{n}", 20 | type="Collection", 21 | title="Test Collection", 22 | description="A test collection", 23 | keywords=["test"], 24 | license="proprietary", 25 | extent={ 26 | "spatial": {"bbox": [[-180, -90, 180, 90]]}, 27 | "temporal": {"interval": [["2000-01-01T00:00:00Z", None]]}, 28 | }, 29 | links=collection_links.model_dump(exclude_none=True), 30 | ) 31 | for n in range(0, 10) 32 | ] 33 | 34 | items = [ 35 | stac_types.Item( 36 | id=f"test_item_{n}", 37 | type="Feature", 38 | geometry={"type": "Point", "coordinates": [0, 0]}, 39 | bbox=[-180, -90, 180, 90], 40 | properties={"datetime": "2000-01-01T00:00:00Z"}, 41 | links=item_links.model_dump(exclude_none=True), 42 | assets={}, 43 | ) 44 | for n in range(0, 1000) 45 | ] 46 | 47 | 48 | class CoreClient(BaseCoreClient): 49 | def post_search( 50 | self, search_request: BaseSearchPostRequest, **kwargs 51 | ) -> stac_types.ItemCollection: 52 | raise NotImplementedError 53 | 54 | def get_search( 55 | self, 56 | collections: Optional[List[str]] = None, 57 | ids: Optional[List[str]] = None, 58 | bbox: Optional[List[NumType]] = None, 59 | intersects: Optional[str] = None, 60 | datetime: Optional[Union[str, datetime]] = None, 61 | limit: Optional[int] = 10, 62 | **kwargs, 63 | ) -> stac_types.ItemCollection: 64 | raise NotImplementedError 65 | 66 | def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: 67 | raise NotImplementedError 68 | 69 | def all_collections(self, **kwargs) -> stac_types.Collections: 70 | return stac_types.Collections( 71 | collections=collections, 72 | links=[ 73 | {"href": "test", "rel": "root"}, 74 | {"href": "test", "rel": "self"}, 75 | {"href": "test", "rel": "parent"}, 76 | ], 77 | ) 78 | 79 | def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: 80 | return collections[0] 81 | 82 | def item_collection( 83 | self, 84 | collection_id: str, 85 | bbox: Optional[List[Union[float, int]]] = None, 86 | datetime: Optional[Union[str, datetime]] = None, 87 | limit: int = 10, 88 | token: str = None, 89 | **kwargs, 90 | ) -> stac_types.ItemCollection: 91 | return stac_types.ItemCollection( 92 | type="FeatureCollection", 93 | features=items[0:limit], 94 | links=[ 95 | {"href": "test", "rel": "root"}, 96 | {"href": "test", "rel": "self"}, 97 | {"href": "test", "rel": "parent"}, 98 | ], 99 | ) 100 | 101 | 102 | @pytest.fixture(autouse=True) 103 | def client_validation() -> TestClient: 104 | settings = ApiSettings(enable_response_models=True) 105 | app = StacApi(settings=settings, client=CoreClient()) 106 | with TestClient(app.app) as client: 107 | yield client 108 | 109 | 110 | @pytest.fixture(autouse=True) 111 | def client_no_validation() -> TestClient: 112 | settings = ApiSettings(enable_response_models=False) 113 | app = StacApi(settings=settings, client=CoreClient()) 114 | with TestClient(app.app) as client: 115 | yield client 116 | 117 | 118 | @pytest.mark.parametrize("limit", [1, 10, 50, 100, 200, 250, 1000]) 119 | @pytest.mark.parametrize("validate", [True, False]) 120 | def test_benchmark_items( 121 | benchmark, client_validation, client_no_validation, validate, limit 122 | ): 123 | """Benchmark items endpoint.""" 124 | params = {"limit": limit} 125 | 126 | def f(p): 127 | if validate: 128 | return client_validation.get("/collections/fake_collection/items", params=p) 129 | else: 130 | return client_no_validation.get( 131 | "/collections/fake_collection/items", params=p 132 | ) 133 | 134 | benchmark.group = "Items With Model validation" if validate else "Items" 135 | benchmark.name = ( 136 | f"Items With Model validation ({limit})" 137 | if validate 138 | else f"Items Limit: ({limit})" 139 | ) 140 | benchmark.fullname = ( 141 | f"Items With Model validation ({limit})" 142 | if validate 143 | else f"Items Limit: ({limit})" 144 | ) 145 | 146 | response = benchmark(f, params) 147 | assert response.status_code == 200 148 | 149 | 150 | @pytest.mark.parametrize("validate", [True, False]) 151 | def test_benchmark_collection( 152 | benchmark, client_validation, client_no_validation, validate 153 | ): 154 | """Benchmark items endpoint.""" 155 | 156 | def f(): 157 | if validate: 158 | return client_validation.get("/collections/fake_collection") 159 | else: 160 | return client_no_validation.get("/collections/fake_collection") 161 | 162 | benchmark.group = "Collection With Model validation" if validate else "Collection" 163 | benchmark.name = "Collection With Model validation" if validate else "Collection" 164 | benchmark.fullname = "Collection With Model validation" if validate else "Collection" 165 | 166 | response = benchmark(f) 167 | assert response.status_code == 200 168 | 169 | 170 | @pytest.mark.parametrize("validate", [True, False]) 171 | def test_benchmark_collections( 172 | benchmark, client_validation, client_no_validation, validate 173 | ): 174 | """Benchmark items endpoint.""" 175 | 176 | def f(): 177 | if validate: 178 | return client_validation.get("/collections") 179 | else: 180 | return client_no_validation.get("/collections") 181 | 182 | benchmark.group = "Collections With Model validation" if validate else "Collections" 183 | benchmark.name = "Collections With Model validation" if validate else "Collections" 184 | benchmark.fullname = ( 185 | "Collections With Model validation" if validate else "Collections" 186 | ) 187 | 188 | response = benchmark(f) 189 | assert response.status_code == 200 190 | -------------------------------------------------------------------------------- /stac_fastapi/api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional, Union 3 | 4 | import pytest 5 | from stac_pydantic import Collection, Item 6 | from stac_pydantic.api.utils import link_factory 7 | 8 | from stac_fastapi.types import core, stac 9 | from stac_fastapi.types.core import NumType 10 | from stac_fastapi.types.search import BaseSearchPostRequest 11 | 12 | collection_links = link_factory.CollectionLinks("/", "test").create_links() 13 | item_links = link_factory.ItemLinks("/", "test", "test").create_links() 14 | 15 | 16 | @pytest.fixture 17 | def _collection(): 18 | return Collection( 19 | type="Collection", 20 | id="test_collection", 21 | title="Test Collection", 22 | description="A test collection", 23 | keywords=["test"], 24 | license="proprietary", 25 | extent={ 26 | "spatial": {"bbox": [[-180, -90, 180, 90]]}, 27 | "temporal": {"interval": [["2000-01-01T00:00:00Z", None]]}, 28 | }, 29 | links=collection_links, 30 | ) 31 | 32 | 33 | @pytest.fixture 34 | def collection(_collection: Collection): 35 | return _collection.model_dump_json() 36 | 37 | 38 | @pytest.fixture 39 | def collection_dict(_collection: Collection): 40 | return _collection.model_dump(mode="json") 41 | 42 | 43 | @pytest.fixture 44 | def _item(): 45 | return Item( 46 | id="test_item", 47 | type="Feature", 48 | geometry={"type": "Point", "coordinates": [0, 0]}, 49 | bbox=[-180, -90, 180, 90], 50 | properties={"datetime": "2000-01-01T00:00:00Z"}, 51 | links=item_links, 52 | assets={}, 53 | ) 54 | 55 | 56 | @pytest.fixture 57 | def item(_item: Item): 58 | return _item.model_dump_json() 59 | 60 | 61 | @pytest.fixture 62 | def item_dict(_item: Item): 63 | return _item.model_dump(mode="json") 64 | 65 | 66 | @pytest.fixture 67 | def TestCoreClient(collection_dict, item_dict): 68 | class CoreClient(core.BaseCoreClient): 69 | def post_search( 70 | self, search_request: BaseSearchPostRequest, **kwargs 71 | ) -> stac.ItemCollection: 72 | return stac.ItemCollection( 73 | type="FeatureCollection", features=[stac.Item(**item_dict)] 74 | ) 75 | 76 | def get_search( 77 | self, 78 | collections: Optional[List[str]] = None, 79 | ids: Optional[List[str]] = None, 80 | bbox: Optional[List[NumType]] = None, 81 | intersects: Optional[str] = None, 82 | datetime: Optional[Union[str, datetime]] = None, 83 | limit: Optional[int] = 10, 84 | **kwargs, 85 | ) -> stac.ItemCollection: 86 | return stac.ItemCollection( 87 | type="FeatureCollection", features=[stac.Item(**item_dict)] 88 | ) 89 | 90 | def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: 91 | return stac.Item(**item_dict) 92 | 93 | def all_collections(self, **kwargs) -> stac.Collections: 94 | return stac.Collections( 95 | collections=[stac.Collection(**collection_dict)], 96 | links=[ 97 | {"href": "test", "rel": "root"}, 98 | {"href": "test", "rel": "self"}, 99 | {"href": "test", "rel": "parent"}, 100 | ], 101 | ) 102 | 103 | def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: 104 | return stac.Collection(**collection_dict) 105 | 106 | def item_collection( 107 | self, 108 | collection_id: str, 109 | bbox: Optional[List[Union[float, int]]] = None, 110 | datetime: Optional[Union[str, datetime]] = None, 111 | limit: int = 10, 112 | token: str = None, 113 | **kwargs, 114 | ) -> stac.ItemCollection: 115 | return stac.ItemCollection( 116 | type="FeatureCollection", features=[stac.Item(**item_dict)] 117 | ) 118 | 119 | return CoreClient 120 | 121 | 122 | @pytest.fixture 123 | def AsyncTestCoreClient(collection_dict, item_dict): 124 | class AsyncCoreClient(core.AsyncBaseCoreClient): 125 | async def post_search( 126 | self, search_request: BaseSearchPostRequest, **kwargs 127 | ) -> stac.ItemCollection: 128 | return stac.ItemCollection( 129 | type="FeatureCollection", features=[stac.Item(**item_dict)] 130 | ) 131 | 132 | async def get_search( 133 | self, 134 | collections: Optional[List[str]] = None, 135 | ids: Optional[List[str]] = None, 136 | bbox: Optional[List[NumType]] = None, 137 | intersects: Optional[str] = None, 138 | datetime: Optional[Union[str, datetime]] = None, 139 | limit: Optional[int] = 10, 140 | **kwargs, 141 | ) -> stac.ItemCollection: 142 | return stac.ItemCollection( 143 | type="FeatureCollection", features=[stac.Item(**item_dict)] 144 | ) 145 | 146 | async def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: 147 | return stac.Item(**item_dict) 148 | 149 | async def all_collections(self, **kwargs) -> stac.Collections: 150 | return stac.Collections( 151 | collections=[stac.Collection(**collection_dict)], 152 | links=[ 153 | {"href": "test", "rel": "root"}, 154 | {"href": "test", "rel": "self"}, 155 | {"href": "test", "rel": "parent"}, 156 | ], 157 | ) 158 | 159 | async def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: 160 | return stac.Collection(**collection_dict) 161 | 162 | async def item_collection( 163 | self, 164 | collection_id: str, 165 | bbox: Optional[List[Union[float, int]]] = None, 166 | datetime: Optional[Union[str, datetime]] = None, 167 | limit: int = 10, 168 | token: str = None, 169 | **kwargs, 170 | ) -> stac.ItemCollection: 171 | return stac.ItemCollection( 172 | type="FeatureCollection", features=[stac.Item(**item_dict)] 173 | ) 174 | 175 | return AsyncCoreClient 176 | -------------------------------------------------------------------------------- /stac_fastapi/api/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from fastapi import Depends, FastAPI, HTTPException 5 | from fastapi.testclient import TestClient 6 | from pydantic import ValidationError 7 | 8 | from stac_fastapi.api.models import create_get_request_model, create_post_request_model 9 | from stac_fastapi.extensions.core import FieldsExtension, FilterExtension, SortExtension 10 | from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest 11 | 12 | 13 | def test_create_get_request_model(): 14 | request_model = create_get_request_model( 15 | extensions=[FilterExtension(), FieldsExtension()], 16 | base_model=BaseSearchGetRequest, 17 | ) 18 | 19 | model = request_model( 20 | collections="test1,test2", 21 | ids="test1,test2", 22 | bbox="0,0,1,1", 23 | intersects=json.dumps( 24 | { 25 | "type": "Polygon", 26 | "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], 27 | } 28 | ), 29 | datetime="2020-01-01T00:00:00.00001Z", 30 | limit=10, 31 | filter_expr="test==test", 32 | filter_crs="epsg:4326", 33 | filter_lang="cql2-text", 34 | ) 35 | 36 | assert model.collections == ["test1", "test2"] 37 | assert model.filter_expr == "test==test" 38 | assert model.filter_crs == "epsg:4326" 39 | d = model.start_date 40 | assert d.microsecond == 10 41 | assert not model.end_date 42 | 43 | model = request_model(bbox="0,0,0,1,1,1") 44 | assert model.bbox == (0.0, 0.0, 0.0, 1.0, 1.0, 1.0) 45 | 46 | with pytest.raises(HTTPException): 47 | request_model(bbox="a,b") 48 | 49 | with pytest.raises(HTTPException): 50 | request_model(bbox="0,0,0,1,1") 51 | 52 | model = request_model( 53 | datetime="2020-01-01T00:00:00.00001Z/2020-01-02T00:00:00.00001Z", 54 | ) 55 | assert model.start_date 56 | assert model.end_date 57 | 58 | # invalid datetime format 59 | with pytest.raises(HTTPException): 60 | request_model(datetime="yo") 61 | 62 | # Wrong order 63 | with pytest.raises(HTTPException): 64 | request_model(datetime="2020-01-02T00:00:00.00001Z/2020-01-01T00:00:00.00001Z") 65 | 66 | app = FastAPI() 67 | 68 | @app.get("/test") 69 | def route(model=Depends(request_model)): 70 | return model 71 | 72 | with TestClient(app) as client: 73 | resp = client.get( 74 | "/test", 75 | params={ 76 | "collections": "test1,test2", 77 | "filter": "test=test", 78 | "filter-crs": "epsg:4326", 79 | "filter-lang": "cql2-text", 80 | }, 81 | ) 82 | assert resp.status_code == 200 83 | response_dict = resp.json() 84 | assert response_dict["collections"] == ["test1", "test2"] 85 | assert response_dict["filter_expr"] == "test=test" 86 | assert response_dict["filter_crs"] == "epsg:4326" 87 | assert response_dict["filter_lang"] == "cql2-text" 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "filter_val,passes", 92 | [(None, True), ({"test": "test"}, True), ([], False)], 93 | ) 94 | def test_create_post_request_model(filter_val, passes): 95 | request_model = create_post_request_model( 96 | extensions=[FilterExtension(), FieldsExtension()], 97 | base_model=BaseSearchPostRequest, 98 | ) 99 | 100 | if not passes: 101 | with pytest.raises(ValidationError): 102 | model = request_model(filter=filter_val) 103 | else: 104 | model = request_model.model_validate( 105 | { 106 | "collections": ["test1", "test2"], 107 | "ids": ["test1", "test2"], 108 | "bbox": [0, 0, 1, 1], 109 | "datetime": "2020-01-01T00:00:00.00001Z", 110 | "limit": 10, 111 | "filter": filter_val, 112 | "filter-crs": "epsg:4326", 113 | "filter-lang": "cql2-json", 114 | } 115 | ) 116 | 117 | assert model.collections == ["test1", "test2"] 118 | assert model.filter_expr == filter_val 119 | assert model.filter_crs == "epsg:4326" 120 | assert model.datetime == "2020-01-01T00:00:00.00001Z" 121 | 122 | with pytest.raises(ValidationError): 123 | request_model(datetime="yo") 124 | 125 | 126 | @pytest.mark.parametrize( 127 | "sortby,passes", 128 | [ 129 | (None, True), 130 | ( 131 | [ 132 | {"field": "test", "direction": "asc"}, 133 | {"field": "test2", "direction": "desc"}, 134 | ], 135 | True, 136 | ), 137 | ({"field": "test", "direction": "desc"}, False), 138 | ("test", False), 139 | ], 140 | ) 141 | def test_create_post_request_model_nested_fields(sortby, passes): 142 | request_model = create_post_request_model( 143 | extensions=[SortExtension()], 144 | base_model=BaseSearchPostRequest, 145 | ) 146 | 147 | if not passes: 148 | with pytest.raises(ValidationError): 149 | model = request_model(sortby=sortby) 150 | else: 151 | model = request_model( 152 | collections=["test1", "test2"], 153 | ids=["test1", "test2"], 154 | bbox=[0, 0, 1, 1], 155 | datetime="2020-01-01T00:00:00Z", 156 | limit=10, 157 | sortby=sortby, 158 | ) 159 | 160 | assert model.collections == ["test1", "test2"] 161 | if model.sortby is None: 162 | assert sortby is None 163 | else: 164 | assert model.model_dump(mode="json")["sortby"] == sortby 165 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi/423b5871280f46a91b76a008bdbff19373db3d13/stac_fastapi/extensions/README.md -------------------------------------------------------------------------------- /stac_fastapi/extensions/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: stac_fastapi.extensions.version.__version__ 3 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/setup.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi: extensions module.""" 2 | 3 | 4 | from setuptools import find_namespace_packages, setup 5 | 6 | with open("README.md") as f: 7 | desc = f.read() 8 | 9 | install_requires = [ 10 | "stac-fastapi.types~=5.2", 11 | "stac-fastapi.api~=5.2", 12 | ] 13 | 14 | extra_reqs = { 15 | "dev": [ 16 | "pytest", 17 | "pytest-cov", 18 | "pytest-asyncio", 19 | "pre-commit", 20 | "requests", 21 | ], 22 | "docs": [ 23 | "black>=23.10.1", 24 | "mkdocs>=1.4.3", 25 | "mkdocs-jupyter>=0.24.5", 26 | "mkdocs-material[imaging]>=9.5", 27 | "griffe-inherited-docstrings>=1.0.0", 28 | "mkdocstrings[python]>=0.25.1", 29 | ], 30 | } 31 | 32 | 33 | setup( 34 | name="stac-fastapi.extensions", 35 | description="An implementation of STAC API based on the FastAPI framework.", 36 | long_description=desc, 37 | long_description_content_type="text/markdown", 38 | python_requires=">=3.9", 39 | classifiers=[ 40 | "Intended Audience :: Developers", 41 | "Intended Audience :: Information Technology", 42 | "Intended Audience :: Science/Research", 43 | "Programming Language :: Python :: 3.9", 44 | "Programming Language :: Python :: 3.10", 45 | "Programming Language :: Python :: 3.11", 46 | "Programming Language :: Python :: 3.12", 47 | "Programming Language :: Python :: 3.13", 48 | "License :: OSI Approved :: MIT License", 49 | ], 50 | keywords="STAC FastAPI COG", 51 | author="Arturo Engineering", 52 | author_email="engineering@arturo.ai", 53 | url="https://github.com/stac-utils/stac-fastapi", 54 | license="MIT", 55 | packages=find_namespace_packages(exclude=["alembic", "tests", "scripts"]), 56 | zip_safe=False, 57 | install_requires=install_requires, 58 | tests_require=extra_reqs["dev"], 59 | extras_require=extra_reqs, 60 | ) 61 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """Extensions submodule.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py: -------------------------------------------------------------------------------- 1 | """stac_api.extensions.core module.""" 2 | 3 | from .aggregation import AggregationExtension 4 | from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension 5 | from .fields import FieldsExtension 6 | from .filter import ( 7 | CollectionSearchFilterExtension, 8 | FilterExtension, 9 | ItemCollectionFilterExtension, 10 | SearchFilterExtension, 11 | ) 12 | from .free_text import FreeTextAdvancedExtension, FreeTextExtension 13 | from .pagination import ( 14 | OffsetPaginationExtension, 15 | PaginationExtension, 16 | TokenPaginationExtension, 17 | ) 18 | from .query import QueryExtension 19 | from .sort import SortExtension 20 | from .transaction import TransactionExtension 21 | 22 | __all__ = ( 23 | "AggregationExtension", 24 | "FieldsExtension", 25 | "FilterExtension", 26 | "FreeTextExtension", 27 | "FreeTextAdvancedExtension", 28 | "OffsetPaginationExtension", 29 | "PaginationExtension", 30 | "QueryExtension", 31 | "SortExtension", 32 | "TokenPaginationExtension", 33 | "TransactionExtension", 34 | "CollectionSearchExtension", 35 | "CollectionSearchPostExtension", 36 | "SearchFilterExtension", 37 | "ItemCollectionFilterExtension", 38 | "CollectionSearchFilterExtension", 39 | ) 40 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/__init__.py: -------------------------------------------------------------------------------- 1 | """Aggregation extension module.""" 2 | 3 | from .aggregation import AggregationConformanceClasses, AggregationExtension 4 | 5 | __all__ = ["AggregationExtension", "AggregationConformanceClasses"] 6 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/aggregation.py: -------------------------------------------------------------------------------- 1 | """Aggregation Extension.""" 2 | from enum import Enum 3 | from typing import List, Union 4 | 5 | import attr 6 | from fastapi import APIRouter, FastAPI 7 | 8 | from stac_fastapi.api.models import CollectionUri, EmptyRequest 9 | from stac_fastapi.api.routes import create_async_endpoint 10 | from stac_fastapi.types.extension import ApiExtension 11 | 12 | from .client import AsyncBaseAggregationClient, BaseAggregationClient 13 | from .request import AggregationExtensionGetRequest, AggregationExtensionPostRequest 14 | 15 | 16 | class AggregationConformanceClasses(str, Enum): 17 | """Conformance classes for the Aggregation extension. 18 | 19 | See 20 | https://github.com/stac-api-extensions/aggregation 21 | """ 22 | 23 | AGGREGATION = "https://api.stacspec.org/v0.3.0/aggregation" 24 | 25 | 26 | @attr.s 27 | class AggregationExtension(ApiExtension): 28 | """Aggregation Extension. 29 | 30 | The purpose of the Aggregation Extension is to provide an endpoint similar to 31 | the Search endpoint (/search), but which will provide aggregated information 32 | on matching Items rather than the Items themselves. This is highly influenced 33 | by the Elasticsearch and OpenSearch aggregation endpoint, but with a more 34 | regular structure for responses. 35 | 36 | The Aggregation extension adds several endpoints which allow the retrieval of 37 | available aggregation fields and aggregation buckets based on a seearch query: 38 | GET /aggregations 39 | POST /aggregations 40 | GET /collections/{collection_id}/aggregations 41 | POST /collections/{collection_id}/aggregations 42 | GET /aggregate 43 | POST /aggregate 44 | GET /collections/{collection_id}/aggregate 45 | POST /collections/{collection_id}/aggregate 46 | 47 | https://github.com/stac-api-extensions/aggregation/blob/main/README.md 48 | 49 | Attributes: 50 | conformance_classes: Conformance classes provided by the extension 51 | """ 52 | 53 | GET = AggregationExtensionGetRequest 54 | POST = AggregationExtensionPostRequest 55 | 56 | client: Union[AsyncBaseAggregationClient, BaseAggregationClient] = attr.ib( 57 | factory=BaseAggregationClient 58 | ) 59 | 60 | conformance_classes: List[str] = attr.ib( 61 | default=[AggregationConformanceClasses.AGGREGATION] 62 | ) 63 | router: APIRouter = attr.ib(factory=APIRouter) 64 | 65 | def register(self, app: FastAPI) -> None: 66 | """Register the extension with a FastAPI application. 67 | 68 | Args: 69 | app: target FastAPI application. 70 | 71 | Returns: 72 | None 73 | """ 74 | self.router.prefix = app.state.router_prefix 75 | self.router.add_api_route( 76 | name="Aggregations", 77 | path="/aggregations", 78 | methods=["GET", "POST"], 79 | endpoint=create_async_endpoint(self.client.get_aggregations, EmptyRequest), 80 | ) 81 | self.router.add_api_route( 82 | name="Collection Aggregations", 83 | path="/collections/{collection_id}/aggregations", 84 | methods=["GET", "POST"], 85 | endpoint=create_async_endpoint(self.client.get_aggregations, CollectionUri), 86 | ) 87 | self.router.add_api_route( 88 | name="Aggregate", 89 | path="/aggregate", 90 | methods=["GET"], 91 | endpoint=create_async_endpoint(self.client.aggregate, self.GET), 92 | ) 93 | self.router.add_api_route( 94 | name="Aggregate", 95 | path="/aggregate", 96 | methods=["POST"], 97 | endpoint=create_async_endpoint(self.client.aggregate, self.POST), 98 | ) 99 | self.router.add_api_route( 100 | name="Collection Aggregate", 101 | path="/collections/{collection_id}/aggregate", 102 | methods=["GET"], 103 | endpoint=create_async_endpoint(self.client.aggregate, self.GET), 104 | ) 105 | self.router.add_api_route( 106 | name="Collection Aggregate", 107 | path="/collections/{collection_id}/aggregate", 108 | methods=["POST"], 109 | endpoint=create_async_endpoint(self.client.aggregate, self.POST), 110 | ) 111 | app.include_router(self.router, tags=["Aggregation Extension"]) 112 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/client.py: -------------------------------------------------------------------------------- 1 | """Aggregation extensions clients.""" 2 | 3 | import abc 4 | from typing import List, Optional, Union 5 | 6 | import attr 7 | from geojson_pydantic.geometries import Geometry 8 | from stac_pydantic.shared import BBox 9 | 10 | from stac_fastapi.types.rfc3339 import DateTimeType 11 | 12 | from .types import Aggregation, AggregationCollection 13 | 14 | 15 | @attr.s 16 | class BaseAggregationClient(abc.ABC): 17 | """Defines a pattern for implementing the STAC aggregation extension.""" 18 | 19 | # BUCKET = Bucket 20 | # AGGREGAION = Aggregation 21 | # AGGREGATION_COLLECTION = AggregationCollection 22 | 23 | def get_aggregations( 24 | self, collection_id: Optional[str] = None, **kwargs 25 | ) -> AggregationCollection: 26 | """Get the aggregations available for the given collection_id. 27 | 28 | If collection_id is None, returns the available aggregations over all 29 | collections. 30 | """ 31 | return AggregationCollection( 32 | type="AggregationCollection", 33 | aggregations=[Aggregation(name="total_count", data_type="integer")], 34 | links=[ 35 | { 36 | "rel": "root", 37 | "type": "application/json", 38 | "href": "https://example.org/", 39 | }, 40 | { 41 | "rel": "self", 42 | "type": "application/json", 43 | "href": "https://example.org/aggregations", 44 | }, 45 | ], 46 | ) 47 | 48 | def aggregate( 49 | self, collection_id: Optional[str] = None, **kwargs 50 | ) -> AggregationCollection: 51 | """Return the aggregation buckets for a given search result""" 52 | return AggregationCollection( 53 | type="AggregationCollection", 54 | aggregations=[], 55 | links=[ 56 | { 57 | "rel": "root", 58 | "type": "application/json", 59 | "href": "https://example.org/", 60 | }, 61 | { 62 | "rel": "self", 63 | "type": "application/json", 64 | "href": "https://example.org/aggregations", 65 | }, 66 | ], 67 | ) 68 | 69 | 70 | @attr.s 71 | class AsyncBaseAggregationClient(abc.ABC): 72 | """Defines an async pattern for implementing the STAC aggregation extension.""" 73 | 74 | # BUCKET = Bucket 75 | # AGGREGAION = Aggregation 76 | # AGGREGATION_COLLECTION = AggregationCollection 77 | 78 | async def get_aggregations( 79 | self, collection_id: Optional[str] = None, **kwargs 80 | ) -> AggregationCollection: 81 | """Get the aggregations available for the given collection_id. 82 | 83 | If collection_id is None, returns the available aggregations over all 84 | collections. 85 | """ 86 | return AggregationCollection( 87 | type="AggregationCollection", 88 | aggregations=[Aggregation(name="total_count", data_type="integer")], 89 | links=[ 90 | { 91 | "rel": "root", 92 | "type": "application/json", 93 | "href": "https://example.org/", 94 | }, 95 | { 96 | "rel": "self", 97 | "type": "application/json", 98 | "href": "https://example.org/aggregations", 99 | }, 100 | ], 101 | ) 102 | 103 | async def aggregate( 104 | self, 105 | collection_id: Optional[str] = None, 106 | aggregations: Optional[Union[str, List[str]]] = None, 107 | collections: Optional[List[str]] = None, 108 | ids: Optional[List[str]] = None, 109 | bbox: Optional[BBox] = None, 110 | intersects: Optional[Geometry] = None, 111 | datetime: Optional[DateTimeType] = None, 112 | limit: Optional[int] = 10, 113 | **kwargs, 114 | ) -> AggregationCollection: 115 | """Return the aggregation buckets for a given search result""" 116 | return AggregationCollection( 117 | type="AggregationCollection", 118 | aggregations=[], 119 | links=[ 120 | { 121 | "rel": "root", 122 | "type": "application/json", 123 | "href": "https://example.org/", 124 | }, 125 | { 126 | "rel": "self", 127 | "type": "application/json", 128 | "href": "https://example.org/aggregations", 129 | }, 130 | ], 131 | ) 132 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py: -------------------------------------------------------------------------------- 1 | """Request model for the Aggregation extension.""" 2 | 3 | from typing import List, Optional 4 | 5 | import attr 6 | from fastapi import Query 7 | from pydantic import Field 8 | from typing_extensions import Annotated 9 | 10 | from stac_fastapi.types.search import ( 11 | BaseSearchGetRequest, 12 | BaseSearchPostRequest, 13 | str2list, 14 | ) 15 | 16 | 17 | def _agg_converter( 18 | val: Annotated[ 19 | Optional[str], 20 | Query(description="A list of aggregations to compute and return."), 21 | ] = None, 22 | ) -> Optional[List[str]]: 23 | return str2list(val) 24 | 25 | 26 | @attr.s 27 | class AggregationExtensionGetRequest(BaseSearchGetRequest): 28 | """Aggregation Extension GET request model.""" 29 | 30 | aggregations: Optional[List[str]] = attr.ib(default=None, converter=_agg_converter) 31 | 32 | 33 | class AggregationExtensionPostRequest(BaseSearchPostRequest): 34 | """Aggregation Extension POST request model.""" 35 | 36 | aggregations: Optional[List[str]] = Field( 37 | default=None, 38 | description="A list of aggregations to compute and return.", 39 | ) 40 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py: -------------------------------------------------------------------------------- 1 | """Aggregation Extension types.""" 2 | 3 | from typing import Any, Dict, List, Literal, Optional, Union 4 | 5 | from typing_extensions import NotRequired, TypedDict 6 | 7 | from stac_fastapi.types.rfc3339 import DateTimeType 8 | 9 | Bucket = TypedDict( 10 | "Bucket", 11 | { 12 | "key": str, 13 | "data_type": str, 14 | "frequency": NotRequired[Dict], 15 | # we can't use the `class Bucket` notation because `from` is a reserved key 16 | "from": NotRequired[Union[int, float]], 17 | "to": NotRequired[Optional[Union[int, float]]], 18 | }, 19 | ) 20 | 21 | 22 | class Aggregation(TypedDict): 23 | """A STAC aggregation.""" 24 | 25 | name: str 26 | data_type: str 27 | buckets: NotRequired[List[Bucket]] 28 | overflow: NotRequired[int] 29 | value: NotRequired[Union[str, int, DateTimeType]] 30 | 31 | 32 | class AggregationCollection(TypedDict): 33 | """STAC Item Aggregation Collection.""" 34 | 35 | type: Literal["AggregationCollection"] 36 | aggregations: List[Aggregation] 37 | links: List[Dict[str, Any]] 38 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py: -------------------------------------------------------------------------------- 1 | """Collection-Search extension module.""" 2 | 3 | from .collection_search import ( 4 | CollectionSearchConformanceClasses, 5 | CollectionSearchExtension, 6 | CollectionSearchPostExtension, 7 | ) 8 | 9 | __all__ = [ 10 | "CollectionSearchExtension", 11 | "CollectionSearchPostExtension", 12 | "CollectionSearchConformanceClasses", 13 | ] 14 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/client.py: -------------------------------------------------------------------------------- 1 | """collection-search extensions clients.""" 2 | 3 | import abc 4 | 5 | import attr 6 | 7 | from stac_fastapi.types.stac import ItemCollection 8 | 9 | from .request import BaseCollectionSearchPostRequest 10 | 11 | 12 | @attr.s 13 | class AsyncBaseCollectionSearchClient(abc.ABC): 14 | """Defines a pattern for implementing the STAC collection-search POST extension.""" 15 | 16 | @abc.abstractmethod 17 | async def post_all_collections( 18 | self, 19 | search_request: BaseCollectionSearchPostRequest, 20 | **kwargs, 21 | ) -> ItemCollection: 22 | """Get all available collections. 23 | 24 | Called with `POST /collections`. 25 | 26 | Returns: 27 | A list of collections. 28 | 29 | """ 30 | ... 31 | 32 | 33 | @attr.s 34 | class BaseCollectionSearchClient(abc.ABC): 35 | """Defines a pattern for implementing the STAC collection-search POST extension.""" 36 | 37 | @abc.abstractmethod 38 | def post_all_collections( 39 | self, search_request: BaseCollectionSearchPostRequest, **kwargs 40 | ) -> ItemCollection: 41 | """Get all available collections. 42 | 43 | Called with `POST /collections`. 44 | 45 | Returns: 46 | A list of collections. 47 | 48 | """ 49 | ... 50 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py: -------------------------------------------------------------------------------- 1 | """Request models for the Collection-Search extension.""" 2 | 3 | from datetime import datetime as dt 4 | from typing import List, Optional, Tuple, cast 5 | 6 | import attr 7 | from fastapi import Query 8 | from pydantic import BaseModel, Field, PrivateAttr, ValidationInfo, field_validator 9 | from stac_pydantic.shared import BBox, SearchDatetime 10 | from typing_extensions import Annotated 11 | 12 | from stac_fastapi.types.search import ( 13 | APIRequest, 14 | DatetimeMixin, 15 | DateTimeQueryType, 16 | Limit, 17 | _bbox_converter, 18 | _validate_datetime, 19 | ) 20 | 21 | 22 | @attr.s 23 | class BaseCollectionSearchGetRequest(APIRequest, DatetimeMixin): 24 | """Basics additional Collection-Search parameters for the GET request.""" 25 | 26 | bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) # type: ignore 27 | datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) 28 | limit: Annotated[ 29 | Optional[Limit], 30 | Query( 31 | description="Limits the number of results that are included in each page of the response." # noqa: E501 32 | ), 33 | ] = attr.ib(default=10) 34 | 35 | 36 | class BaseCollectionSearchPostRequest(BaseModel): 37 | """Collection-Search POST model.""" 38 | 39 | bbox: Optional[BBox] = Field( 40 | default=None, 41 | description="Only return items intersecting this bounding box. Mutually exclusive with **intersects**.", # noqa: E501 42 | json_schema_extra={ 43 | "examples": [ 44 | # user-provided 45 | None, 46 | # Montreal 47 | "-73.896103,45.364690,-73.413734,45.674283", 48 | ], 49 | }, 50 | ) 51 | datetime: Optional[str] = Field( 52 | default=None, 53 | description="""Only return items that have a temporal property that intersects this value.\n 54 | Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 55 | json_schema_extra={ 56 | "examples": [ 57 | # user-provided 58 | None, 59 | # single datetime 60 | "2018-02-12T23:20:50Z", 61 | # closed inverval 62 | "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z", 63 | # open interval FROM 64 | "2018-02-12T00:00:00Z/..", 65 | # open interval TO 66 | "../2018-03-18T12:31:12Z", 67 | ], 68 | }, 69 | ) 70 | limit: Optional[Limit] = Field( 71 | 10, 72 | description="Limits the number of results that are included in each page of the response (capped to 10_000).", # noqa: E501 73 | ) 74 | 75 | # Private properties to store the parsed datetime values. 76 | # Not part of the model schema. 77 | _start_date: Optional[dt] = PrivateAttr(default=None) 78 | _end_date: Optional[dt] = PrivateAttr(default=None) 79 | 80 | # Properties to return the private values 81 | @property 82 | def start_date(self) -> Optional[dt]: 83 | """start date.""" 84 | return self._start_date 85 | 86 | @property 87 | def end_date(self) -> Optional[dt]: 88 | """end date.""" 89 | return self._end_date 90 | 91 | @field_validator("bbox") 92 | @classmethod 93 | def validate_bbox(cls, v: BBox) -> BBox: 94 | """validate bbox.""" 95 | if v: 96 | # Validate order 97 | if len(v) == 4: 98 | xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v) 99 | else: 100 | xmin, ymin, min_elev, xmax, ymax, max_elev = cast( 101 | Tuple[int, int, int, int, int, int], v 102 | ) 103 | if max_elev < min_elev: 104 | raise ValueError( 105 | "Maximum elevation must greater than minimum elevation" 106 | ) 107 | # Validate against WGS84 108 | if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90: 109 | raise ValueError("Bounding box must be within (-180, -90, 180, 90)") 110 | 111 | if ymax < ymin: 112 | raise ValueError( 113 | "Maximum longitude must be greater than minimum longitude" 114 | ) 115 | 116 | return v 117 | 118 | @field_validator("datetime", mode="after") 119 | @classmethod 120 | def validate_datetime( 121 | cls, value: Optional[str], info: ValidationInfo 122 | ) -> Optional[str]: 123 | """validate datetime.""" 124 | # Split on "/" and replace no value or ".." with None 125 | if value is None: 126 | return value 127 | values = [v if v and v != ".." else None for v in value.split("/")] 128 | 129 | # If there are more than 2 dates, it's invalid 130 | if len(values) > 2: 131 | raise ValueError( 132 | """Invalid datetime range. Too many values. """ 133 | """Must match format: {begin_date}/{end_date}""" 134 | ) 135 | 136 | # If there is only one date, duplicate to use for both start and end dates 137 | if len(values) == 1: 138 | values = [values[0], values[0]] 139 | 140 | # Cast because pylance gets confused by the type adapter and annotated type 141 | dates = cast( 142 | List[Optional[dt]], 143 | [ 144 | # Use the type adapter to validate the datetime strings, 145 | # strict is necessary due to pydantic issues #8736 and #8762 146 | SearchDatetime.validate_strings(v, strict=True) if v else None 147 | for v in values 148 | ], 149 | ) 150 | 151 | # If there is a start and end date, 152 | # check that the start date is before the end date 153 | if dates[0] and dates[1] and dates[0] > dates[1]: 154 | raise ValueError( 155 | "Invalid datetime range. Begin date after end date. " 156 | "Must match format: {begin_date}/{end_date}" 157 | ) 158 | 159 | # Store the parsed dates 160 | info.data["_start_date"] = dates[0] 161 | info.data["_end_date"] = dates[1] 162 | 163 | # Return the original string value 164 | return value 165 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py: -------------------------------------------------------------------------------- 1 | """Fields extension module.""" 2 | 3 | from .fields import FieldsConformanceClasses, FieldsExtension 4 | 5 | __all__ = ["FieldsExtension", "FieldsConformanceClasses"] 6 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py: -------------------------------------------------------------------------------- 1 | """Fields extension.""" 2 | 3 | from enum import Enum 4 | from typing import List, Optional 5 | 6 | import attr 7 | from fastapi import FastAPI 8 | 9 | from stac_fastapi.types.extension import ApiExtension 10 | 11 | from .request import FieldsExtensionGetRequest, FieldsExtensionPostRequest 12 | 13 | 14 | class FieldsConformanceClasses(str, Enum): 15 | """Conformance classes for the Fields extension. 16 | 17 | See https://github.com/stac-api-extensions/fields 18 | 19 | """ 20 | 21 | SEARCH = "https://api.stacspec.org/v1.0.0/item-search#fields" 22 | ITEMS = "https://api.stacspec.org/v1.0.0/ogcapi-features#fields" 23 | COLLECTIONS = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields" 24 | 25 | 26 | @attr.s 27 | class FieldsExtension(ApiExtension): 28 | """Fields Extension. 29 | 30 | The Fields extension adds functionality to the `/search` endpoint which 31 | allows the caller to include or exclude specific from the API response. 32 | Registering this extension with the application has the added effect of 33 | removing the `ItemCollection` response model from the `/search` endpoint, as 34 | the Fields extension allows the API to return potentially invalid responses 35 | by excluding fields which are required by the STAC spec, such as geometry. 36 | 37 | https://github.com/stac-api-extensions/fields 38 | 39 | Attributes: 40 | default_includes (set): defines the default set of included fields. 41 | conformance_classes (list): Defines the list of conformance classes for 42 | the extension 43 | """ 44 | 45 | GET = FieldsExtensionGetRequest 46 | POST = FieldsExtensionPostRequest 47 | 48 | conformance_classes: List[str] = attr.ib( 49 | factory=lambda: [ 50 | FieldsConformanceClasses.SEARCH, 51 | ] 52 | ) 53 | schema_href: Optional[str] = attr.ib(default=None) 54 | 55 | def register(self, app: FastAPI) -> None: 56 | """Register the extension with a FastAPI application. 57 | 58 | Args: 59 | app (fastapi.FastAPI): target FastAPI application. 60 | 61 | Returns: 62 | None 63 | """ 64 | pass 65 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py: -------------------------------------------------------------------------------- 1 | """Request models for the fields extension.""" 2 | 3 | from typing import Dict, List, Optional, Set 4 | 5 | import attr 6 | from fastapi import Query 7 | from pydantic import BaseModel, Field 8 | from typing_extensions import Annotated 9 | 10 | from stac_fastapi.types.search import APIRequest, str2list 11 | 12 | 13 | class PostFieldsExtension(BaseModel): 14 | """FieldsExtension. 15 | 16 | Attributes: 17 | include: set of fields to include. 18 | exclude: set of fields to exclude. 19 | """ 20 | 21 | include: Optional[Set[str]] = set() 22 | exclude: Optional[Set[str]] = set() 23 | 24 | @staticmethod 25 | def _get_field_dict(fields: Optional[Set[str]]) -> Dict: 26 | """Pydantic include/excludes notation. 27 | 28 | Internal method to create a dictionary for advanced include or exclude 29 | of pydantic fields on model export 30 | Ref: https://pydantic-docs.helpmanual.io/usage/exporting_models/#advanced-include-and-exclude 31 | """ 32 | field_dict = {} 33 | for field in fields or []: 34 | if "." in field: 35 | parent, key = field.split(".") 36 | if parent not in field_dict: 37 | field_dict[parent] = {key} 38 | else: 39 | if field_dict[parent] is not ...: 40 | field_dict[parent].add(key) 41 | else: 42 | field_dict[field] = ... # type:ignore 43 | 44 | return field_dict 45 | 46 | 47 | def _fields_converter( 48 | val: Annotated[ 49 | Optional[str], 50 | Query( 51 | description="Include or exclude fields from items body.", 52 | openapi_examples={ 53 | "user-provided": {"value": None}, 54 | "datetime": {"value": "properties.datetime"}, 55 | }, 56 | ), 57 | ] = None, 58 | ) -> Optional[List[str]]: 59 | return str2list(val) 60 | 61 | 62 | @attr.s 63 | class FieldsExtensionGetRequest(APIRequest): 64 | """Additional fields for the GET request.""" 65 | 66 | fields: Optional[List[str]] = attr.ib(default=None, converter=_fields_converter) 67 | 68 | 69 | class FieldsExtensionPostRequest(BaseModel): 70 | """Additional fields and schema for the POST request.""" 71 | 72 | fields: Optional[PostFieldsExtension] = Field( 73 | PostFieldsExtension(), 74 | description="Include or exclude fields from items body.", 75 | ) 76 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py: -------------------------------------------------------------------------------- 1 | """Filter extension module.""" 2 | 3 | from .filter import ( 4 | CollectionSearchFilterExtension, 5 | FilterConformanceClasses, 6 | FilterExtension, 7 | ItemCollectionFilterExtension, 8 | SearchFilterExtension, 9 | ) 10 | 11 | __all__ = [ 12 | "FilterConformanceClasses", 13 | "FilterExtension", 14 | "SearchFilterExtension", 15 | "ItemCollectionFilterExtension", 16 | "CollectionSearchFilterExtension", 17 | ] 18 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/filter/client.py: -------------------------------------------------------------------------------- 1 | """Filter extensions clients.""" 2 | 3 | import abc 4 | from typing import Any, Dict, Optional 5 | 6 | import attr 7 | 8 | 9 | @attr.s 10 | class AsyncBaseFiltersClient(abc.ABC): 11 | """Defines a pattern for implementing the STAC filter extension.""" 12 | 13 | async def get_queryables( 14 | self, collection_id: Optional[str] = None, **kwargs 15 | ) -> Dict[str, Any]: 16 | """Get the queryables available for the given collection_id. 17 | 18 | If collection_id is None, returns the intersection of all queryables over all 19 | collections. 20 | 21 | This base implementation returns a blank queryable schema. This is not allowed 22 | under OGC CQL but it is allowed by the STAC API Filter Extension 23 | https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables 24 | """ 25 | return { 26 | "$schema": "https://json-schema.org/draft/2020-12/schema", 27 | "$id": "https://example.org/queryables", 28 | "type": "object", 29 | "title": "Queryables for Example STAC API", 30 | "description": "Queryable names for the example STAC API Item Search filter.", 31 | "properties": {}, 32 | } 33 | 34 | 35 | @attr.s 36 | class BaseFiltersClient(abc.ABC): 37 | """Defines a pattern for implementing the STAC filter extension.""" 38 | 39 | def get_queryables( 40 | self, collection_id: Optional[str] = None, **kwargs 41 | ) -> Dict[str, Any]: 42 | """Get the queryables available for the given collection_id. 43 | 44 | If collection_id is None, returns the intersection of all queryables over all 45 | collections. 46 | 47 | This base implementation returns a blank queryable schema. This is not allowed 48 | under OGC CQL but it is allowed by the STAC API Filter Extension 49 | https://github.com/stac-api-extensions/filter#queryables 50 | """ 51 | return { 52 | "$schema": "https://json-schema.org/draft/2020-12/schema", 53 | "$id": "https://example.org/queryables", 54 | "type": "object", 55 | "title": "Queryables for Example STAC API", 56 | "description": "Queryable names for the example STAC API Item Search filter.", 57 | "properties": {}, 58 | } 59 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py: -------------------------------------------------------------------------------- 1 | """Filter extension request models.""" 2 | 3 | from typing import Any, Dict, Literal, Optional 4 | 5 | import attr 6 | from fastapi import Query 7 | from pydantic import BaseModel, Field 8 | from typing_extensions import Annotated 9 | 10 | from stac_fastapi.types.search import APIRequest 11 | 12 | FilterLang = Literal["cql2-json", "cql2-text"] 13 | 14 | 15 | @attr.s 16 | class FilterExtensionGetRequest(APIRequest): 17 | """Filter extension GET request model.""" 18 | 19 | filter_expr: Annotated[ 20 | Optional[str], 21 | Query( 22 | alias="filter", 23 | description="""A CQL2 filter expression for filtering items.\n 24 | Supports `CQL2-JSON` as defined in https://docs.ogc.org/is/21-065r2/21-065r2.htmln 25 | Remember to URL encode the CQL2-JSON if using GET""", 26 | openapi_examples={ 27 | "user-provided": {"value": None}, 28 | "landsat8-item": { 29 | "value": "id='LC08_L1TP_060247_20180905_20180912_01_T1_L1TP' AND collection='landsat8_l1tp'" # noqa: E501 30 | }, 31 | }, 32 | ), 33 | ] = attr.ib(default=None) 34 | filter_crs: Annotated[ 35 | Optional[str], 36 | Query( 37 | alias="filter-crs", 38 | description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`", # noqa: E501 39 | ), 40 | ] = attr.ib(default=None) 41 | filter_lang: Annotated[ 42 | Optional[FilterLang], 43 | Query( 44 | alias="filter-lang", 45 | description="The CQL filter encoding that the 'filter' value uses.", 46 | ), 47 | ] = attr.ib(default="cql2-text") 48 | 49 | 50 | class FilterExtensionPostRequest(BaseModel): 51 | """Filter extension POST request model.""" 52 | 53 | filter_expr: Optional[Dict[str, Any]] = Field( 54 | None, 55 | alias="filter", 56 | description="A CQL filter expression for filtering items.", 57 | json_schema_extra={ 58 | "examples": [ 59 | # user-provided 60 | None, 61 | # landsat8-item 62 | { 63 | "op": "and", 64 | "args": [ 65 | { 66 | "op": "=", 67 | "args": [ 68 | {"property": "id"}, 69 | "LC08_L1TP_060247_20180905_20180912_01_T1_L1TP", 70 | ], 71 | }, 72 | { 73 | "op": "=", 74 | "args": [{"property": "collection"}, "landsat8_l1tp"], 75 | }, 76 | ], 77 | }, 78 | ], 79 | }, 80 | ) 81 | filter_crs: Optional[str] = Field( 82 | None, 83 | alias="filter-crs", 84 | description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`", # noqa: E501 85 | ) 86 | filter_lang: Optional[Literal["cql2-json"]] = Field( 87 | "cql2-json", 88 | alias="filter-lang", 89 | description="The CQL filter encoding that the 'filter' value uses.", 90 | ) 91 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py: -------------------------------------------------------------------------------- 1 | """Query extension module.""" 2 | 3 | from .free_text import ( 4 | FreeTextAdvancedExtension, 5 | FreeTextConformanceClasses, 6 | FreeTextExtension, 7 | ) 8 | 9 | __all__ = [ 10 | "FreeTextExtension", 11 | "FreeTextAdvancedExtension", 12 | "FreeTextConformanceClasses", 13 | ] 14 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/free_text.py: -------------------------------------------------------------------------------- 1 | """Free-text extension.""" 2 | 3 | from enum import Enum 4 | from typing import List, Optional 5 | 6 | import attr 7 | from fastapi import FastAPI 8 | 9 | from stac_fastapi.types.extension import ApiExtension 10 | 11 | from .request import ( 12 | FreeTextAdvancedExtensionGetRequest, 13 | FreeTextAdvancedExtensionPostRequest, 14 | FreeTextExtensionGetRequest, 15 | FreeTextExtensionPostRequest, 16 | ) 17 | 18 | 19 | class FreeTextConformanceClasses(str, Enum): 20 | """Conformance classes for the Free-Text extension. 21 | 22 | See https://github.com/stac-api-extensions/freetext-search 23 | 24 | """ 25 | 26 | # https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#basic 27 | SEARCH = "https://api.stacspec.org/v1.0.0-rc.1/item-search#free-text" 28 | ITEMS = "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features#free-text" 29 | COLLECTIONS = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" 30 | 31 | # https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#advanced 32 | SEARCH_ADVANCED = ( 33 | "https://api.stacspec.org/v1.0.0-rc.1/item-search#advanced-free-text" 34 | ) 35 | ITEMS_ADVANCED = ( 36 | "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features#advanced-free-text" 37 | ) 38 | COLLECTIONS_ADVANCED = ( 39 | "https://api.stacspec.org/v1.0.0-rc.1/collection-search#advanced-free-text" 40 | ) 41 | 42 | 43 | @attr.s 44 | class FreeTextExtension(ApiExtension): 45 | """Free-text Extension. 46 | 47 | The Free-text extension adds an additional `q` parameter to `/search` requests which 48 | allows the caller to perform free-text queries against STAC metadata. 49 | 50 | https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#basic 51 | 52 | """ 53 | 54 | GET = FreeTextExtensionGetRequest 55 | POST = FreeTextExtensionPostRequest 56 | 57 | conformance_classes: List[str] = attr.ib( 58 | default=[ 59 | FreeTextConformanceClasses.SEARCH, 60 | ] 61 | ) 62 | schema_href: Optional[str] = attr.ib(default=None) 63 | 64 | def register(self, app: FastAPI) -> None: 65 | """Register the extension with a FastAPI application. 66 | 67 | Args: 68 | app: target FastAPI application. 69 | 70 | Returns: 71 | None 72 | """ 73 | pass 74 | 75 | 76 | @attr.s 77 | class FreeTextAdvancedExtension(ApiExtension): 78 | """Free-text Extension. 79 | 80 | The Free-text extension adds an additional `q` parameter to `/search` requests which 81 | allows the caller to perform free-text queries against STAC metadata. 82 | 83 | https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#advanced 84 | 85 | """ 86 | 87 | GET = FreeTextAdvancedExtensionGetRequest 88 | POST = FreeTextAdvancedExtensionPostRequest 89 | 90 | conformance_classes: List[str] = attr.ib( 91 | default=[ 92 | FreeTextConformanceClasses.SEARCH_ADVANCED, 93 | ] 94 | ) 95 | schema_href: Optional[str] = attr.ib(default=None) 96 | 97 | def register(self, app: FastAPI) -> None: 98 | """Register the extension with a FastAPI application. 99 | 100 | Args: 101 | app: target FastAPI application. 102 | 103 | Returns: 104 | None 105 | """ 106 | pass 107 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py: -------------------------------------------------------------------------------- 1 | """Request model for the Free-text extension.""" 2 | 3 | from typing import List, Optional 4 | 5 | import attr 6 | from fastapi import Query 7 | from pydantic import BaseModel, Field 8 | from typing_extensions import Annotated 9 | 10 | from stac_fastapi.types.search import APIRequest 11 | 12 | 13 | def _ft_converter( 14 | val: Annotated[ 15 | Optional[str], 16 | Query( 17 | description="Parameter to perform free-text queries against STAC metadata", 18 | openapi_examples={ 19 | "user-provided": {"value": None}, 20 | "Coastal": {"value": "ocean,coast"}, 21 | }, 22 | ), 23 | ] = None, 24 | ) -> Optional[List[str]]: 25 | if val: 26 | return val.split(",") 27 | return None 28 | 29 | 30 | @attr.s 31 | class FreeTextExtensionGetRequest(APIRequest): 32 | """Free-text Extension GET request model.""" 33 | 34 | q: Optional[List[str]] = attr.ib(default=None, converter=_ft_converter) 35 | 36 | 37 | class FreeTextExtensionPostRequest(BaseModel): 38 | """Free-text Extension POST request model.""" 39 | 40 | q: Optional[List[str]] = Field( 41 | None, 42 | description="Parameter to perform free-text queries against STAC metadata", 43 | ) 44 | 45 | 46 | @attr.s 47 | class FreeTextAdvancedExtensionGetRequest(APIRequest): 48 | """Free-text Extension GET request model.""" 49 | 50 | q: Annotated[ 51 | Optional[str], 52 | Query( 53 | description="Parameter to perform free-text queries against STAC metadata", 54 | openapi_examples={ 55 | "user-provided": {"value": None}, 56 | "Coastal": {"value": "ocean,coast"}, 57 | }, 58 | ), 59 | ] = attr.ib(default=None) 60 | 61 | 62 | class FreeTextAdvancedExtensionPostRequest(BaseModel): 63 | """Free-text Extension POST request model.""" 64 | 65 | q: Optional[str] = Field( 66 | None, 67 | description="Parameter to perform free-text queries against STAC metadata", 68 | ) 69 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/__init__.py: -------------------------------------------------------------------------------- 1 | """Pagination classes as extensions.""" 2 | 3 | from .offset_pagination import OffsetPaginationExtension 4 | from .pagination import PaginationExtension 5 | from .token_pagination import TokenPaginationExtension 6 | 7 | __all__ = ["OffsetPaginationExtension", "PaginationExtension", "TokenPaginationExtension"] 8 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/offset_pagination.py: -------------------------------------------------------------------------------- 1 | """Offset Pagination API extension.""" 2 | 3 | from typing import List, Optional 4 | 5 | import attr 6 | from fastapi import FastAPI 7 | 8 | from stac_fastapi.types.extension import ApiExtension 9 | 10 | from .request import GETOffsetPagination, POSTOffsetPagination 11 | 12 | 13 | @attr.s 14 | class OffsetPaginationExtension(ApiExtension): 15 | """Offset Pagination. 16 | 17 | Though not strictly an extension, the chosen pagination will modify the form of the 18 | request object. By making pagination an extension class, we can use 19 | create_request_model to dynamically add the correct pagination parameter to the 20 | request model for OpenAPI generation. 21 | """ 22 | 23 | GET = GETOffsetPagination 24 | POST = POSTOffsetPagination 25 | 26 | conformance_classes: List[str] = attr.ib(factory=list) 27 | schema_href: Optional[str] = attr.ib(default=None) 28 | 29 | def register(self, app: FastAPI) -> None: 30 | """Register the extension with a FastAPI application. 31 | 32 | Args: 33 | app: target FastAPI application. 34 | 35 | Returns: 36 | None 37 | """ 38 | pass 39 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py: -------------------------------------------------------------------------------- 1 | """Pagination API extension.""" 2 | 3 | from typing import List, Optional 4 | 5 | import attr 6 | from fastapi import FastAPI 7 | 8 | from stac_fastapi.types.extension import ApiExtension 9 | 10 | from .request import GETPagination, POSTPagination 11 | 12 | 13 | @attr.s 14 | class PaginationExtension(ApiExtension): 15 | """Token Pagination. 16 | 17 | Though not strictly an extension, the chosen pagination will modify the form of the 18 | request object. By making pagination an extension class, we can use 19 | create_request_model to dynamically add the correct pagination parameter to the 20 | request model for OpenAPI generation. 21 | """ 22 | 23 | GET = GETPagination 24 | POST = POSTPagination 25 | 26 | conformance_classes: List[str] = attr.ib(factory=list) 27 | schema_href: Optional[str] = attr.ib(default=None) 28 | 29 | def register(self, app: FastAPI) -> None: 30 | """Register the extension with a FastAPI application. 31 | 32 | Args: 33 | app: target FastAPI application. 34 | 35 | Returns: 36 | None 37 | """ 38 | pass 39 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py: -------------------------------------------------------------------------------- 1 | """Pagination extension request models.""" 2 | 3 | from typing import Optional 4 | 5 | import attr 6 | from fastapi import Query 7 | from pydantic import BaseModel 8 | from typing_extensions import Annotated 9 | 10 | from stac_fastapi.types.search import APIRequest 11 | 12 | 13 | @attr.s 14 | class GETTokenPagination(APIRequest): 15 | """Token pagination for GET requests.""" 16 | 17 | token: Annotated[Optional[str], Query()] = attr.ib(default=None) 18 | 19 | 20 | class POSTTokenPagination(BaseModel): 21 | """Token pagination model for POST requests.""" 22 | 23 | token: Optional[str] = None 24 | 25 | 26 | @attr.s 27 | class GETPagination(APIRequest): 28 | """Page based pagination for GET requests.""" 29 | 30 | page: Annotated[Optional[str], Query()] = attr.ib(default=None) 31 | 32 | 33 | class POSTPagination(BaseModel): 34 | """Page based pagination for POST requests.""" 35 | 36 | page: Optional[str] = None 37 | 38 | 39 | @attr.s 40 | class GETOffsetPagination(APIRequest): 41 | """Offset pagination for GET requests.""" 42 | 43 | offset: Annotated[Optional[int], Query()] = attr.ib(default=None) 44 | 45 | 46 | class POSTOffsetPagination(BaseModel): 47 | """Offset pagination model for POST requests.""" 48 | 49 | offset: Optional[int] = None 50 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py: -------------------------------------------------------------------------------- 1 | """Token pagination API extension.""" 2 | 3 | from typing import List, Optional 4 | 5 | import attr 6 | from fastapi import FastAPI 7 | 8 | from stac_fastapi.types.extension import ApiExtension 9 | 10 | from .request import GETTokenPagination, POSTTokenPagination 11 | 12 | 13 | @attr.s 14 | class TokenPaginationExtension(ApiExtension): 15 | """Token Pagination. 16 | 17 | Though not strictly an extension, the chosen pagination will modify the form of the 18 | request object. By making pagination an extension class, we can use 19 | create_request_model to dynamically add the correct pagination parameter to the 20 | request model for OpenAPI generation. 21 | """ 22 | 23 | GET = GETTokenPagination 24 | POST = POSTTokenPagination 25 | 26 | conformance_classes: List[str] = attr.ib(factory=list) 27 | schema_href: Optional[str] = attr.ib(default=None) 28 | 29 | def register(self, app: FastAPI) -> None: 30 | """Register the extension with a FastAPI application. 31 | 32 | Args: 33 | app: target FastAPI application. 34 | 35 | Returns: 36 | None 37 | """ 38 | pass 39 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/query/__init__.py: -------------------------------------------------------------------------------- 1 | """Query extension module.""" 2 | 3 | from .query import QueryConformanceClasses, QueryExtension 4 | 5 | __all__ = ["QueryExtension", "QueryConformanceClasses"] 6 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py: -------------------------------------------------------------------------------- 1 | """Query extension.""" 2 | 3 | from enum import Enum 4 | from typing import List, Optional 5 | 6 | import attr 7 | from fastapi import FastAPI 8 | 9 | from stac_fastapi.types.extension import ApiExtension 10 | 11 | from .request import QueryExtensionGetRequest, QueryExtensionPostRequest 12 | 13 | 14 | class QueryConformanceClasses(str, Enum): 15 | """Conformance classes for the Query extension. 16 | 17 | See https://github.com/stac-api-extensions/query 18 | """ 19 | 20 | SEARCH = "https://api.stacspec.org/v1.0.0/item-search#query" 21 | ITEMS = "https://api.stacspec.org/v1.0.0/ogcapi-features#query" 22 | COLLECTIONS = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#query" 23 | 24 | 25 | @attr.s 26 | class QueryExtension(ApiExtension): 27 | """Query Extension. 28 | 29 | The Query extension adds an additional `query` parameter to `/search` requests which 30 | allows the caller to perform queries against item metadata (ex. find all images with 31 | cloud cover less than 15%). 32 | https://github.com/stac-api-extensions/query 33 | """ 34 | 35 | GET = QueryExtensionGetRequest 36 | POST = QueryExtensionPostRequest 37 | 38 | conformance_classes: List[str] = attr.ib( 39 | factory=lambda: [ 40 | QueryConformanceClasses.SEARCH, 41 | ] 42 | ) 43 | schema_href: Optional[str] = attr.ib(default=None) 44 | 45 | def register(self, app: FastAPI) -> None: 46 | """Register the extension with a FastAPI application. 47 | 48 | Args: 49 | app: target FastAPI application. 50 | 51 | Returns: 52 | None 53 | """ 54 | pass 55 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py: -------------------------------------------------------------------------------- 1 | """Request model for the Query extension.""" 2 | 3 | from typing import Any, Dict, Optional 4 | 5 | import attr 6 | from fastapi import Query 7 | from pydantic import BaseModel, Field 8 | from typing_extensions import Annotated 9 | 10 | from stac_fastapi.types.search import APIRequest 11 | 12 | 13 | @attr.s 14 | class QueryExtensionGetRequest(APIRequest): 15 | """Query Extension GET request model.""" 16 | 17 | query: Annotated[ 18 | Optional[str], 19 | Query( 20 | description="Allows additional filtering based on the properties of Item objects", # noqa: E501 21 | openapi_examples={ 22 | "user-provided": {"value": None}, 23 | "cloudy": {"value": '{"eo:cloud_cover": {"gte": 95}}'}, 24 | }, 25 | ), 26 | ] = attr.ib(default=None) 27 | 28 | 29 | class QueryExtensionPostRequest(BaseModel): 30 | """Query Extension POST request model.""" 31 | 32 | query: Optional[Dict[str, Dict[str, Any]]] = Field( 33 | None, 34 | description="Allows additional filtering based on the properties of Item objects", # noqa: E501 35 | json_schema_extra={ 36 | "examples": [ 37 | # user-provided 38 | None, 39 | # cloudy 40 | '{"eo:cloud_cover": {"gte": 95}}', 41 | ], 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/sort/__init__.py: -------------------------------------------------------------------------------- 1 | """Sort extension module.""" 2 | 3 | from .sort import SortConformanceClasses, SortExtension 4 | 5 | __all__ = ["SortExtension", "SortConformanceClasses"] 6 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py: -------------------------------------------------------------------------------- 1 | """Request model for the Sort Extension.""" 2 | 3 | from typing import List, Optional 4 | 5 | import attr 6 | from fastapi import Query 7 | from pydantic import BaseModel, Field 8 | from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel 9 | from typing_extensions import Annotated 10 | 11 | from stac_fastapi.types.search import APIRequest, str2list 12 | 13 | 14 | def _sort_converter( 15 | val: Annotated[ 16 | Optional[str], 17 | Query( 18 | description="An array of property names, prefixed by either '+' for ascending or '-' for descending. If no prefix is provided, '+' is assumed.", # noqa: E501 19 | openapi_examples={ 20 | "user-provided": {"value": None}, 21 | "resolution": {"value": "-gsd"}, 22 | "resolution-and-dates": {"value": "-gsd,-datetime"}, 23 | }, 24 | ), 25 | ], 26 | ) -> Optional[List[str]]: 27 | return str2list(val) 28 | 29 | 30 | @attr.s 31 | class SortExtensionGetRequest(APIRequest): 32 | """Sortby Parameter for GET requests.""" 33 | 34 | sortby: Optional[List[str]] = attr.ib(default=None, converter=_sort_converter) 35 | 36 | 37 | class SortExtensionPostRequest(BaseModel): 38 | """Sortby parameter for POST requests.""" 39 | 40 | sortby: Optional[List[PostSortModel]] = Field( 41 | None, 42 | description="An array of property (field) names, and direction in form of '{'field': '', 'direction':''}'", # noqa: E501 43 | json_schema_extra={ 44 | "examples": [ 45 | # user-provided 46 | None, 47 | # creation-time 48 | [ 49 | { 50 | "field": "properties.created", 51 | "direction": "asc", 52 | } 53 | ], 54 | ], 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py: -------------------------------------------------------------------------------- 1 | """Sort extension.""" 2 | 3 | from enum import Enum 4 | from typing import List, Optional 5 | 6 | import attr 7 | from fastapi import FastAPI 8 | 9 | from stac_fastapi.types.extension import ApiExtension 10 | 11 | from .request import SortExtensionGetRequest, SortExtensionPostRequest 12 | 13 | 14 | class SortConformanceClasses(str, Enum): 15 | """Conformance classes for the Sort extension. 16 | 17 | See https://github.com/stac-api-extensions/sort 18 | 19 | """ 20 | 21 | SEARCH = "https://api.stacspec.org/v1.0.0/item-search#sort" 22 | ITEMS = "https://api.stacspec.org/v1.0.0/ogcapi-features#sort" 23 | COLLECTIONS = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort" 24 | 25 | 26 | @attr.s 27 | class SortExtension(ApiExtension): 28 | """Sort Extension. 29 | 30 | The Sort extension adds the `sortby` parameter to the `/search` endpoint, allowing the 31 | caller to specify the sort order of the returned items. 32 | https://github.com/stac-api-extensions/sort 33 | """ 34 | 35 | GET = SortExtensionGetRequest 36 | POST = SortExtensionPostRequest 37 | 38 | conformance_classes: List[str] = attr.ib( 39 | factory=lambda: [ 40 | SortConformanceClasses.SEARCH, 41 | ] 42 | ) 43 | schema_href: Optional[str] = attr.ib(default=None) 44 | 45 | def register(self, app: FastAPI) -> None: 46 | """Register the extension with a FastAPI application. 47 | 48 | Args: 49 | app: target FastAPI application. 50 | 51 | Returns: 52 | None 53 | """ 54 | pass 55 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi/423b5871280f46a91b76a008bdbff19373db3d13/stac_fastapi/extensions/stac_fastapi/extensions/py.typed -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py: -------------------------------------------------------------------------------- 1 | """stac_api.extensions.third_party module.""" 2 | 3 | from .bulk_transactions import BulkTransactionExtension 4 | 5 | __all__ = ("BulkTransactionExtension",) 6 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py: -------------------------------------------------------------------------------- 1 | """Bulk transactions extension.""" 2 | 3 | import abc 4 | from enum import Enum 5 | from typing import Any, Dict, List, Optional, Union 6 | 7 | import attr 8 | from fastapi import APIRouter, FastAPI 9 | from pydantic import BaseModel 10 | 11 | from stac_fastapi.api.models import create_request_model 12 | from stac_fastapi.api.routes import create_async_endpoint 13 | from stac_fastapi.types.extension import ApiExtension 14 | 15 | 16 | class BulkTransactionMethod(str, Enum): 17 | """Bulk Transaction Methods.""" 18 | 19 | INSERT = "insert" 20 | UPSERT = "upsert" 21 | 22 | 23 | class Items(BaseModel): 24 | """A group of STAC Item objects, in the form of a dictionary from Item.id -> Item.""" 25 | 26 | items: Dict[str, Any] 27 | method: BulkTransactionMethod = BulkTransactionMethod.INSERT 28 | 29 | def __iter__(self): 30 | """Return an iterable of STAC Item objects.""" 31 | return iter(self.items.values()) 32 | 33 | 34 | @attr.s # type: ignore 35 | class BaseBulkTransactionsClient(abc.ABC): 36 | """BulkTransactionsClient.""" 37 | 38 | @staticmethod 39 | def _chunks(lst, n): 40 | """Yield successive n-sized chunks from list. 41 | 42 | https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks 43 | """ 44 | for i in range(0, len(lst), n): 45 | yield lst[i : i + n] 46 | 47 | @abc.abstractmethod 48 | def bulk_item_insert( 49 | self, 50 | items: Items, 51 | chunk_size: Optional[int] = None, 52 | **kwargs, 53 | ) -> str: 54 | """Bulk creation of items. 55 | 56 | Args: 57 | items: list of items. 58 | chunk_size: number of items processed at a time. 59 | 60 | Returns: 61 | Message indicating the status of the insert. 62 | """ 63 | raise NotImplementedError 64 | 65 | 66 | @attr.s # type: ignore 67 | class AsyncBaseBulkTransactionsClient(abc.ABC): 68 | """BulkTransactionsClient.""" 69 | 70 | @abc.abstractmethod 71 | async def bulk_item_insert( 72 | self, 73 | items: Items, 74 | **kwargs, 75 | ) -> str: 76 | """Bulk creation of items. 77 | 78 | Args: 79 | items: list of items. 80 | 81 | Returns: 82 | Message indicating the status of the insert. 83 | """ 84 | raise NotImplementedError 85 | 86 | 87 | @attr.s 88 | class BulkTransactionExtension(ApiExtension): 89 | """Bulk Transaction Extension. 90 | 91 | Bulk Transaction extension adds the `POST 92 | /collections/{collection_id}/bulk_items` endpoint to the application for 93 | efficient bulk insertion of items. The input to this is an object with an 94 | attribute "items", that has a value that is an object with a group of 95 | attributes that are the ids of each Item, and the value is the Item entity. 96 | 97 | Optionally, clients can specify a "method" attribute that is either "insert" 98 | or "upsert". If "insert", then the items will be inserted if they do not 99 | exist, and an error will be returned if they do. If "upsert", then the items 100 | will be inserted if they do not exist, and updated if they do. This defaults 101 | to "insert". 102 | 103 | { 104 | "items": { 105 | "id1": { "type": "Feature", ... }, 106 | "id2": { "type": "Feature", ... }, 107 | "id3": { "type": "Feature", ... } 108 | }, 109 | "method": "insert" 110 | } 111 | """ 112 | 113 | client: Union[AsyncBaseBulkTransactionsClient, BaseBulkTransactionsClient] = attr.ib() 114 | conformance_classes: List[str] = attr.ib(default=list()) 115 | schema_href: Optional[str] = attr.ib(default=None) 116 | 117 | def register(self, app: FastAPI) -> None: 118 | """Register the extension with a FastAPI application. 119 | 120 | Args: 121 | app: target FastAPI application. 122 | 123 | Returns: 124 | None 125 | """ 126 | items_request_model = create_request_model("Items", base_model=Items) 127 | 128 | router = APIRouter(prefix=app.state.router_prefix) 129 | router.add_api_route( 130 | name="Bulk Create Item", 131 | path="/collections/{collection_id}/bulk_items", 132 | response_model=str, 133 | response_model_exclude_unset=True, 134 | response_model_exclude_none=True, 135 | methods=["POST"], 136 | endpoint=create_async_endpoint( 137 | self.client.bulk_item_insert, items_request_model 138 | ), 139 | ) 140 | app.include_router(router, tags=["Bulk Transaction Extension"]) 141 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/stac_fastapi/extensions/version.py: -------------------------------------------------------------------------------- 1 | """Library version.""" 2 | 3 | __version__ = "5.2.1" 4 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/tests/test_aggregation.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import pytest 4 | from fastapi import Depends, FastAPI 5 | from starlette.testclient import TestClient 6 | 7 | from stac_fastapi.api.app import StacApi 8 | from stac_fastapi.extensions.core import AggregationExtension 9 | from stac_fastapi.extensions.core.aggregation.client import BaseAggregationClient 10 | from stac_fastapi.extensions.core.aggregation.request import ( 11 | AggregationExtensionGetRequest, 12 | ) 13 | from stac_fastapi.extensions.core.aggregation.types import ( 14 | Aggregation, 15 | AggregationCollection, 16 | ) 17 | from stac_fastapi.types.config import ApiSettings 18 | from stac_fastapi.types.core import BaseCoreClient 19 | 20 | 21 | class DummyCoreClient(BaseCoreClient): 22 | def all_collections(self, *args, **kwargs): 23 | return {"collections": [], "links": []} 24 | 25 | def get_collection(self, *args, **kwargs): 26 | raise NotImplementedError 27 | 28 | def get_item(self, *args, **kwargs): 29 | raise NotImplementedError 30 | 31 | def get_search(self, *args, **kwargs): 32 | raise NotImplementedError 33 | 34 | def post_search(self, *args, **kwargs): 35 | raise NotImplementedError 36 | 37 | def item_collection(self, *args, **kwargs): 38 | raise NotImplementedError 39 | 40 | 41 | @pytest.fixture 42 | def client( 43 | core_client: DummyCoreClient, aggregations_client: BaseAggregationClient 44 | ) -> Iterator[TestClient]: 45 | settings = ApiSettings() 46 | api = StacApi( 47 | settings=settings, 48 | client=core_client, 49 | extensions=[ 50 | AggregationExtension(client=aggregations_client), 51 | ], 52 | ) 53 | with TestClient(api.app) as client: 54 | yield client 55 | 56 | 57 | @pytest.fixture 58 | def core_client() -> DummyCoreClient: 59 | return DummyCoreClient() 60 | 61 | 62 | @pytest.fixture 63 | def aggregations_client() -> BaseAggregationClient: 64 | return BaseAggregationClient() 65 | 66 | 67 | def test_agg_get_query(): 68 | """test AggregationExtensionGetRequest model.""" 69 | app = FastAPI() 70 | 71 | @app.get("/test") 72 | def test(query=Depends(AggregationExtensionGetRequest)): 73 | return query 74 | 75 | with TestClient(app) as client: 76 | response = client.get("/test") 77 | assert response.is_success 78 | params = response.json() 79 | assert not params["collections"] 80 | assert not params["aggregations"] 81 | 82 | response = client.get( 83 | "/test", 84 | params={ 85 | "collections": "collection1,collection2", 86 | "aggregations": "prop1,prop2", 87 | }, 88 | ) 89 | assert response.is_success 90 | params = response.json() 91 | assert params["collections"] == ["collection1", "collection2"] 92 | assert params["aggregations"] == ["prop1", "prop2"] 93 | 94 | 95 | def test_landing(client: TestClient) -> None: 96 | landing = client.get("/") 97 | assert landing.status_code == 200, landing.text 98 | assert "Aggregate" in [link.get("title") for link in landing.json()["links"]] 99 | assert "Aggregations" in [link.get("title") for link in landing.json()["links"]] 100 | 101 | 102 | def test_get_aggregate(client: TestClient) -> None: 103 | response = client.get("/aggregate") 104 | assert response.is_success, response.text 105 | assert response.json()["aggregations"] == [] 106 | assert AggregationCollection( 107 | type="AggregationCollection", aggregations=response.json()["aggregations"] 108 | ) 109 | 110 | 111 | def test_post_aggregations(client: TestClient) -> None: 112 | response = client.post("/aggregations") 113 | assert response.is_success, response.text 114 | assert response.json()["aggregations"] == [ 115 | {"name": "total_count", "data_type": "integer"} 116 | ] 117 | assert AggregationCollection( 118 | type="AggregationCollection", 119 | aggregations=[Aggregation(**response.json()["aggregations"][0])], 120 | ) 121 | 122 | 123 | def test_post_aggregate(client: TestClient) -> None: 124 | response = client.post("/aggregate", content="{}") 125 | assert response.is_success, response.text 126 | assert response.json()["aggregations"] == [] 127 | assert AggregationCollection( 128 | type="AggregationCollection", aggregations=response.json()["aggregations"] 129 | ) 130 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/tests/test_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import pytest 4 | from fastapi import APIRouter 5 | from starlette.testclient import TestClient 6 | 7 | from stac_fastapi.api.app import StacApi 8 | from stac_fastapi.api.models import create_get_request_model, create_post_request_model 9 | from stac_fastapi.extensions.core import FilterExtension 10 | from stac_fastapi.extensions.core.filter import ( 11 | CollectionSearchFilterExtension, 12 | ItemCollectionFilterExtension, 13 | SearchFilterExtension, 14 | ) 15 | from stac_fastapi.types.config import ApiSettings 16 | from stac_fastapi.types.core import BaseCoreClient 17 | 18 | 19 | class DummyCoreClient(BaseCoreClient): 20 | def all_collections(self, *args, **kwargs): 21 | raise NotImplementedError 22 | 23 | def get_collection(self, *args, **kwargs): 24 | raise NotImplementedError 25 | 26 | def get_item(self, *args, **kwargs): 27 | raise NotImplementedError 28 | 29 | def get_search(self, *args, **kwargs): 30 | _ = kwargs.pop("request", None) 31 | return kwargs 32 | 33 | def post_search(self, *args, **kwargs): 34 | return args[0].model_dump() 35 | 36 | def item_collection(self, *args, **kwargs): 37 | raise NotImplementedError 38 | 39 | 40 | @pytest.fixture(autouse=True) 41 | def client() -> Iterator[TestClient]: 42 | settings = ApiSettings() 43 | extensions = [FilterExtension()] 44 | api = StacApi( 45 | settings=settings, 46 | client=DummyCoreClient(), 47 | extensions=extensions, 48 | search_get_request_model=create_get_request_model(extensions), 49 | search_post_request_model=create_post_request_model(extensions), 50 | ) 51 | with TestClient(api.app) as client: 52 | yield client 53 | 54 | 55 | @pytest.fixture(autouse=True) 56 | def client_multit_ext() -> Iterator[TestClient]: 57 | settings = ApiSettings() 58 | extensions = [ 59 | SearchFilterExtension(), 60 | ItemCollectionFilterExtension(), 61 | # Technically `CollectionSearchFilterExtension` 62 | # shouldn't be registered to the application but to the collection-search class 63 | CollectionSearchFilterExtension(), 64 | ] 65 | 66 | api = StacApi( 67 | settings=settings, 68 | client=DummyCoreClient(), 69 | extensions=extensions, 70 | search_get_request_model=create_get_request_model([SearchFilterExtension()]), 71 | search_post_request_model=create_post_request_model([SearchFilterExtension()]), 72 | ) 73 | with TestClient(api.app) as client: 74 | yield client 75 | 76 | 77 | @pytest.mark.parametrize("client_name", ["client", "client_multit_ext"]) 78 | def test_filter_endpoints_conformances(client_name, request): 79 | """Make sure conformances classes are set.""" 80 | client = request.getfixturevalue(client_name) 81 | 82 | response = client.get("/conformance") 83 | assert response.is_success, response.json() 84 | response_dict = response.json() 85 | conf = response_dict["conformsTo"] 86 | assert ( 87 | "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter" in conf 88 | ) 89 | assert "https://api.stacspec.org/v1.0.0-rc.2/item-search#filter" in conf 90 | assert client.get("/queryables").is_success 91 | assert client.get("/collections/collection_id/queryables").is_success 92 | 93 | 94 | def test_filter_conformances_collection_search(client_multit_ext): 95 | """Make sure conformances classes are set.""" 96 | response = client_multit_ext.get("/conformance") 97 | assert response.is_success, response.json() 98 | response_dict = response.json() 99 | conf = response_dict["conformsTo"] 100 | assert ( 101 | "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter" in conf 102 | ) 103 | assert "https://api.stacspec.org/v1.0.0-rc.2/item-search#filter" in conf 104 | assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter" in conf 105 | 106 | 107 | @pytest.mark.parametrize("client_name", ["client", "client_multit_ext"]) 108 | def test_search_filter_post_filter_lang_default(client_name, request): 109 | """Test search POST endpoint with filter ext.""" 110 | client = request.getfixturevalue(client_name) 111 | 112 | response = client.post( 113 | "/search", 114 | json={ 115 | "collections": ["test"], 116 | "filter": {"op": "=", "args": [{"property": "test_property"}, "test-value"]}, 117 | }, 118 | ) 119 | assert response.is_success, response.json() 120 | response_dict = response.json() 121 | assert response_dict["filter_expr"] 122 | assert response_dict["filter_lang"] == "cql2-json" 123 | 124 | 125 | @pytest.mark.parametrize("client_name", ["client", "client_multit_ext"]) 126 | def test_search_filter_get(client_name, request): 127 | """Test search GET endpoint with filter ext.""" 128 | client = request.getfixturevalue(client_name) 129 | 130 | response = client.get( 131 | "/search", 132 | params={ 133 | "filter": "id='item_id' AND collection='collection_id'", 134 | }, 135 | ) 136 | assert response.is_success, response.json() 137 | response_dict = response.json() 138 | assert not response_dict["collections"] 139 | assert response_dict["filter_expr"] == "id='item_id' AND collection='collection_id'" 140 | assert not response_dict["filter_crs"] 141 | assert response_dict["filter_lang"] == "cql2-text" 142 | 143 | response = client.get( 144 | "/search", 145 | params={ 146 | "filter": {"op": "=", "args": [{"property": "id"}, "test-item"]}, 147 | "filter-lang": "cql2-json", 148 | }, 149 | ) 150 | assert response.is_success, response.json() 151 | response_dict = response.json() 152 | assert not response_dict["collections"] 153 | assert ( 154 | response_dict["filter_expr"] 155 | == "{'op': '=', 'args': [{'property': 'id'}, 'test-item']}" 156 | ) 157 | assert not response_dict["filter_crs"] 158 | assert response_dict["filter_lang"] == "cql2-json" 159 | 160 | response = client.get( 161 | "/search", 162 | params={ 163 | "collections": "collection1,collection2", 164 | }, 165 | ) 166 | assert response.is_success, response.json() 167 | response_dict = response.json() 168 | assert response_dict["collections"] == ["collection1", "collection2"] 169 | 170 | 171 | @pytest.mark.parametrize("prefix", ["", "/a_prefix"]) 172 | def test_multi_ext_prefix(prefix): 173 | settings = ApiSettings() 174 | extensions = [ 175 | SearchFilterExtension(), 176 | ItemCollectionFilterExtension(), 177 | # Technically `CollectionSearchFilterExtension` 178 | # shouldn't be registered to the application but to the collection-search class 179 | CollectionSearchFilterExtension(), 180 | ] 181 | 182 | api = StacApi( 183 | settings=settings, 184 | router=APIRouter(prefix=prefix), 185 | client=DummyCoreClient(), 186 | extensions=extensions, 187 | search_get_request_model=create_get_request_model([SearchFilterExtension()]), 188 | search_post_request_model=create_post_request_model([SearchFilterExtension()]), 189 | ) 190 | with TestClient(api.app, base_url="http://stac.io") as client: 191 | queryables = client.get(f"{prefix}/queryables") 192 | assert queryables.status_code == 200, queryables.json() 193 | assert queryables.headers["content-type"] == "application/schema+json" 194 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import pytest 4 | from starlette.testclient import TestClient 5 | 6 | from stac_fastapi.api.app import StacApi 7 | from stac_fastapi.api.models import ( 8 | EmptyRequest, 9 | create_post_request_model, 10 | create_request_model, 11 | ) 12 | from stac_fastapi.extensions.core import ( 13 | OffsetPaginationExtension, 14 | PaginationExtension, 15 | TokenPaginationExtension, 16 | ) 17 | from stac_fastapi.types.config import ApiSettings 18 | from stac_fastapi.types.core import BaseCoreClient 19 | from stac_fastapi.types.search import BaseSearchGetRequest 20 | 21 | 22 | class DummyCoreClient(BaseCoreClient): 23 | def all_collections(self, *args, **kwargs): 24 | _ = kwargs.pop("request", None) 25 | return args, kwargs 26 | 27 | def get_collection(self, *args, **kwargs): 28 | _ = kwargs.pop("request", None) 29 | return args, kwargs 30 | 31 | def get_item(self, *args, **kwargs): 32 | _ = kwargs.pop("request", None) 33 | return args, kwargs 34 | 35 | def get_search(self, *args, **kwargs): 36 | _ = kwargs.pop("request", None) 37 | return args, kwargs 38 | 39 | def post_search(self, *args, **kwargs): 40 | _ = kwargs.pop("request", None) 41 | return args[0].model_dump(), kwargs 42 | 43 | def item_collection(self, *args, **kwargs): 44 | _ = kwargs.pop("request", None) 45 | return args, kwargs 46 | 47 | 48 | collections_get_request_model = create_request_model( 49 | model_name="CollectionsGetRequest", 50 | base_model=EmptyRequest, 51 | mixins=[ 52 | OffsetPaginationExtension().GET, 53 | ], 54 | request_type="GET", 55 | ) 56 | 57 | items_get_request_model = create_request_model( 58 | model_name="ItemsGetRequest", 59 | base_model=EmptyRequest, 60 | mixins=[ 61 | PaginationExtension().GET, 62 | ], 63 | request_type="GET", 64 | ) 65 | 66 | search_get_request_model = create_request_model( 67 | model_name="SearchGetRequest", 68 | base_model=BaseSearchGetRequest, 69 | mixins=[ 70 | TokenPaginationExtension().GET, 71 | ], 72 | request_type="GET", 73 | ) 74 | 75 | 76 | @pytest.fixture 77 | def client() -> Iterator[TestClient]: 78 | settings = ApiSettings() 79 | 80 | api = StacApi( 81 | settings=settings, 82 | client=DummyCoreClient(), 83 | extensions=[], 84 | collections_get_request_model=collections_get_request_model, 85 | items_get_request_model=items_get_request_model, 86 | search_get_request_model=search_get_request_model, 87 | search_post_request_model=create_post_request_model([]), 88 | ) 89 | with TestClient(api.app) as client: 90 | yield client 91 | 92 | 93 | def test_pagination_extension(client: TestClient): 94 | """Test endpoints with pagination extensions.""" 95 | # OffsetPaginationExtension 96 | response = client.get("/collections") 97 | assert response.is_success, response.json() 98 | arg, kwargs = response.json() 99 | assert "offset" in kwargs 100 | assert kwargs["offset"] is None 101 | 102 | response = client.get("/collections", params={"offset": 1}) 103 | assert response.is_success, response.json() 104 | arg, kwargs = response.json() 105 | assert "offset" in kwargs 106 | assert kwargs["offset"] == 1 107 | 108 | # PaginationExtension 109 | response = client.get("/collections/a_collection/items") 110 | assert response.is_success, response.json() 111 | arg, kwargs = response.json() 112 | assert "page" in kwargs 113 | assert kwargs["page"] is None 114 | 115 | response = client.get("/collections/a_collection/items", params={"page": "1"}) 116 | assert response.is_success, response.json() 117 | arg, kwargs = response.json() 118 | assert "page" in kwargs 119 | assert kwargs["page"] == "1" 120 | 121 | # TokenPaginationExtension 122 | response = client.get("/search") 123 | assert response.is_success, response.json() 124 | arg, kwargs = response.json() 125 | assert "token" in kwargs 126 | assert kwargs["token"] is None 127 | 128 | response = client.get("/search", params={"token": "atoken"}) 129 | assert response.is_success, response.json() 130 | arg, kwargs = response.json() 131 | assert "token" in kwargs 132 | assert kwargs["token"] == "atoken" 133 | -------------------------------------------------------------------------------- /stac_fastapi/extensions/tests/test_query.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Iterator 3 | from urllib.parse import quote_plus, unquote_plus 4 | 5 | import pytest 6 | from starlette.testclient import TestClient 7 | 8 | from stac_fastapi.api.app import StacApi 9 | from stac_fastapi.api.models import create_get_request_model, create_post_request_model 10 | from stac_fastapi.extensions.core import QueryExtension 11 | from stac_fastapi.types.config import ApiSettings 12 | from stac_fastapi.types.core import BaseCoreClient 13 | 14 | 15 | class DummyCoreClient(BaseCoreClient): 16 | def all_collections(self, *args, **kwargs): 17 | raise NotImplementedError 18 | 19 | def get_collection(self, *args, **kwargs): 20 | raise NotImplementedError 21 | 22 | def get_item(self, *args, **kwargs): 23 | raise NotImplementedError 24 | 25 | def get_search(self, *args, **kwargs): 26 | return kwargs.pop("query", None) 27 | 28 | def post_search(self, *args, **kwargs): 29 | return args[0].query 30 | 31 | def item_collection(self, *args, **kwargs): 32 | raise NotImplementedError 33 | 34 | 35 | @pytest.fixture 36 | def client() -> Iterator[TestClient]: 37 | settings = ApiSettings() 38 | extensions = [QueryExtension()] 39 | 40 | api = StacApi( 41 | settings=settings, 42 | client=DummyCoreClient(), 43 | extensions=extensions, 44 | search_get_request_model=create_get_request_model(extensions), 45 | search_post_request_model=create_post_request_model(extensions), 46 | ) 47 | with TestClient(api.app) as client: 48 | yield client 49 | 50 | 51 | def test_search_query_get(client: TestClient): 52 | """Test search GET endpoints with query ext.""" 53 | response = client.get( 54 | "/search", 55 | params={"collections": ["test"]}, 56 | ) 57 | assert response.is_success 58 | assert not response.text 59 | 60 | response = client.get( 61 | "/search", 62 | params={ 63 | "collections": ["test"], 64 | "query": quote_plus( 65 | json.dumps({"eo:cloud_cover": {"gte": 95}}), 66 | ), 67 | }, 68 | ) 69 | assert response.is_success, response.json() 70 | query = json.loads(unquote_plus(response.json())) 71 | assert query["eo:cloud_cover"] == {"gte": 95} 72 | 73 | 74 | def test_search_query_post(client: TestClient): 75 | """Test search POST endpoints with query ext.""" 76 | response = client.post( 77 | "/search", 78 | json={ 79 | "collections": ["test"], 80 | }, 81 | ) 82 | 83 | assert response.is_success 84 | assert not response.text 85 | 86 | response = client.post( 87 | "/search", 88 | json={ 89 | "collections": ["test"], 90 | "query": {"eo:cloud_cover": {"gte": 95}}, 91 | }, 92 | ) 93 | 94 | assert response.is_success, response.json() 95 | assert response.json()["eo:cloud_cover"] == {"gte": 95} 96 | -------------------------------------------------------------------------------- /stac_fastapi/types/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi/423b5871280f46a91b76a008bdbff19373db3d13/stac_fastapi/types/README.md -------------------------------------------------------------------------------- /stac_fastapi/types/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: stac_fastapi.types.version.__version__ 3 | -------------------------------------------------------------------------------- /stac_fastapi/types/setup.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi: types 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-settings>=2", 12 | "stac_pydantic>=3.3.0,<4.0", 13 | "iso8601>=1.0.2,<2.2.0", 14 | ] 15 | 16 | extra_reqs = { 17 | "dev": [ 18 | "pytest", 19 | "pytest-cov", 20 | "pytest-asyncio", 21 | "pre-commit", 22 | "requests", 23 | ], 24 | "docs": [ 25 | "black>=23.10.1", 26 | "mkdocs>=1.4.3", 27 | "mkdocs-jupyter>=0.24.5", 28 | "mkdocs-material[imaging]>=9.5", 29 | "griffe-inherited-docstrings>=1.0.0", 30 | "mkdocstrings[python]>=0.25.1", 31 | ], 32 | } 33 | 34 | 35 | setup( 36 | name="stac-fastapi.types", 37 | description="An implementation of STAC API based on the FastAPI framework.", 38 | long_description=desc, 39 | long_description_content_type="text/markdown", 40 | python_requires=">=3.9", 41 | classifiers=[ 42 | "Intended Audience :: Developers", 43 | "Intended Audience :: Information Technology", 44 | "Intended Audience :: Science/Research", 45 | "Programming Language :: Python :: 3.9", 46 | "Programming Language :: Python :: 3.10", 47 | "Programming Language :: Python :: 3.11", 48 | "Programming Language :: Python :: 3.12", 49 | "Programming Language :: Python :: 3.13", 50 | "License :: OSI Approved :: MIT License", 51 | ], 52 | keywords="STAC FastAPI COG", 53 | author="Arturo Engineering", 54 | author_email="engineering@arturo.ai", 55 | url="https://github.com/stac-utils/stac-fastapi", 56 | license="MIT", 57 | packages=find_namespace_packages(exclude=["alembic", "tests", "scripts"]), 58 | zip_safe=False, 59 | install_requires=install_requires, 60 | tests_require=extra_reqs["dev"], 61 | extras_require=extra_reqs, 62 | ) 63 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/__init__.py: -------------------------------------------------------------------------------- 1 | """Backend submodule.""" 2 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/config.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi.types.config module.""" 2 | 3 | from typing import Optional 4 | 5 | from pydantic import model_validator 6 | from pydantic_settings import BaseSettings, SettingsConfigDict 7 | from typing_extensions import Self 8 | 9 | 10 | class ApiSettings(BaseSettings): 11 | """ApiSettings. 12 | 13 | Defines api configuration, potentially through environment variables. 14 | See https://pydantic-docs.helpmanual.io/usage/settings/. 15 | Attributes: 16 | environment: name of the environment (ex. dev/prod). 17 | debug: toggles debug mode. 18 | forbidden_fields: set of fields defined by STAC but not included in the database. 19 | indexed_fields: 20 | set of fields which are usually in `item.properties` but are indexed 21 | as distinct columns in the database. 22 | """ 23 | 24 | stac_fastapi_title: str = "stac-fastapi" 25 | stac_fastapi_description: str = "stac-fastapi" 26 | stac_fastapi_version: str = "0.1" 27 | stac_fastapi_landing_id: str = "stac-fastapi" 28 | 29 | app_host: str = "0.0.0.0" 30 | app_port: int = 8000 31 | reload: bool = True 32 | 33 | # Enable Pydantic validation for output Response 34 | enable_response_models: bool = False 35 | 36 | # Enable direct `Response` from endpoint, skipping validation and serialization 37 | enable_direct_response: bool = False 38 | 39 | openapi_url: str = "/api" 40 | docs_url: str = "/api.html" 41 | root_path: str = "" 42 | 43 | model_config = SettingsConfigDict(env_file=".env", extra="allow") 44 | 45 | @model_validator(mode="after") 46 | def check_incompatible_options(self) -> Self: 47 | """Check for incompatible options.""" 48 | if self.enable_response_models and self.enable_direct_response: 49 | raise ValueError( 50 | "`enable_reponse_models` and `enable_direct_response` options are incompatible" # noqa: E501 51 | ) 52 | 53 | return self 54 | 55 | 56 | class Settings: 57 | """Holds the global instance of settings.""" 58 | 59 | _instance: Optional[ApiSettings] = None 60 | 61 | @classmethod 62 | def set(cls, base_settings: ApiSettings): 63 | """Set the global settings.""" 64 | cls._instance = base_settings 65 | 66 | @classmethod 67 | def get(cls) -> ApiSettings: 68 | """Get the settings. 69 | 70 | If they have not yet been set, throws an exception. 71 | """ 72 | if cls._instance is None: 73 | raise ValueError("Settings have not yet been set.") 74 | return cls._instance 75 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/conformance.py: -------------------------------------------------------------------------------- 1 | """Conformance Classes.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class STACConformanceClasses(str, Enum): 7 | """Conformance classes for the STAC API spec.""" 8 | 9 | CORE = "https://api.stacspec.org/v1.0.0/core" 10 | OGC_API_FEAT = "https://api.stacspec.org/v1.0.0/ogcapi-features" 11 | COLLECTIONS = "https://api.stacspec.org/v1.0.0/collections" 12 | ITEM_SEARCH = "https://api.stacspec.org/v1.0.0/item-search" 13 | 14 | 15 | class OAFConformanceClasses(str, Enum): 16 | """Conformance classes for OGC API - Features.""" 17 | 18 | CORE = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core" 19 | OPEN_API = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30" 20 | GEOJSON = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson" 21 | 22 | 23 | BASE_CONFORMANCE_CLASSES = [ 24 | STACConformanceClasses.CORE, 25 | STACConformanceClasses.OGC_API_FEAT, 26 | STACConformanceClasses.COLLECTIONS, 27 | STACConformanceClasses.ITEM_SEARCH, 28 | OAFConformanceClasses.CORE, 29 | OAFConformanceClasses.OPEN_API, 30 | OAFConformanceClasses.GEOJSON, 31 | ] 32 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/errors.py: -------------------------------------------------------------------------------- 1 | """stac_fastapi.types.errors module.""" 2 | 3 | 4 | class StacApiError(Exception): 5 | """Generic API error.""" 6 | 7 | pass 8 | 9 | 10 | class ConflictError(StacApiError): 11 | """Database conflict.""" 12 | 13 | pass 14 | 15 | 16 | class NotFoundError(StacApiError): 17 | """Resource not found.""" 18 | 19 | pass 20 | 21 | 22 | class ForeignKeyError(StacApiError): 23 | """Foreign key error (collection does not exist).""" 24 | 25 | pass 26 | 27 | 28 | class DatabaseError(StacApiError): 29 | """Generic database errors.""" 30 | 31 | pass 32 | 33 | 34 | class InvalidQueryParameter(StacApiError): 35 | """Error for unknown or invalid query parameters. 36 | 37 | Used to capture errors that should respond according to 38 | http://docs.opengeospatial.org/is/17-069r3/17-069r3.html#query_parameters 39 | """ 40 | 41 | pass 42 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/extension.py: -------------------------------------------------------------------------------- 1 | """Base api extension.""" 2 | 3 | import abc 4 | from typing import List, Optional 5 | 6 | import attr 7 | from fastapi import FastAPI 8 | from pydantic import BaseModel 9 | 10 | 11 | @attr.s 12 | class ApiExtension(abc.ABC): 13 | """Abstract base class for defining API extensions.""" 14 | 15 | GET = None 16 | POST = None 17 | 18 | def get_request_model(self, verb: str = "GET") -> Optional[BaseModel]: 19 | """Return the request model for the extension.method. 20 | 21 | The model can differ based on HTTP verb 22 | """ 23 | return getattr(self, verb) 24 | 25 | conformance_classes: List[str] = attr.ib(factory=list) 26 | schema_href: Optional[str] = attr.ib(default=None) 27 | 28 | @abc.abstractmethod 29 | def register(self, app: FastAPI) -> None: 30 | """Register the extension with a FastAPI application. 31 | 32 | Args: 33 | app: target FastAPI application. 34 | 35 | Returns: 36 | None 37 | """ 38 | pass 39 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/links.py: -------------------------------------------------------------------------------- 1 | """Link helpers.""" 2 | 3 | from typing import Any, Dict, List 4 | from urllib.parse import urljoin 5 | 6 | import attr 7 | from stac_pydantic.links import Relations 8 | from stac_pydantic.shared import MimeTypes 9 | 10 | # These can be inferred from the item/collection so they aren't included in the database 11 | # Instead they are dynamically generated when querying the database using the 12 | # classes defined below 13 | INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root", "items"] 14 | 15 | 16 | def filter_links(links: List[Dict]) -> List[Dict]: 17 | """Remove inferred links.""" 18 | return [link for link in links if link["rel"] not in INFERRED_LINK_RELS] 19 | 20 | 21 | def resolve_links(links: list, base_url: str) -> List[Dict]: 22 | """Convert relative links to absolute links.""" 23 | filtered_links = filter_links(links) 24 | for link in filtered_links: 25 | link.update({"href": urljoin(base_url, link["href"])}) 26 | return filtered_links 27 | 28 | 29 | @attr.s 30 | class BaseLinks: 31 | """Create inferred links common to collections and items.""" 32 | 33 | collection_id: str = attr.ib() 34 | base_url: str = attr.ib() 35 | 36 | def root(self) -> Dict[str, Any]: 37 | """Return the catalog root.""" 38 | return dict(rel=Relations.root, type=MimeTypes.json, href=self.base_url) 39 | 40 | 41 | @attr.s 42 | class CollectionLinks(BaseLinks): 43 | """Create inferred links specific to collections.""" 44 | 45 | def self(self) -> Dict[str, Any]: 46 | """Create the `self` link.""" 47 | return dict( 48 | rel=Relations.self, 49 | type=MimeTypes.json, 50 | href=urljoin(self.base_url, f"collections/{self.collection_id}"), 51 | ) 52 | 53 | def parent(self) -> Dict[str, Any]: 54 | """Create the `parent` link.""" 55 | return dict(rel=Relations.parent, type=MimeTypes.json, href=self.base_url) 56 | 57 | def items(self) -> Dict[str, Any]: 58 | """Create the `items` link.""" 59 | return dict( 60 | rel="items", 61 | type=MimeTypes.geojson, 62 | href=urljoin(self.base_url, f"collections/{self.collection_id}/items"), 63 | ) 64 | 65 | def create_links(self) -> List[Dict[str, Any]]: 66 | """Return all inferred links.""" 67 | return [self.self(), self.parent(), self.items(), self.root()] 68 | 69 | 70 | @attr.s 71 | class ItemLinks(BaseLinks): 72 | """Create inferred links specific to items.""" 73 | 74 | item_id: str = attr.ib() 75 | 76 | def self(self) -> Dict[str, Any]: 77 | """Create the `self` link.""" 78 | return dict( 79 | rel=Relations.self, 80 | type=MimeTypes.geojson, 81 | href=urljoin( 82 | self.base_url, 83 | f"collections/{self.collection_id}/items/{self.item_id}", 84 | ), 85 | ) 86 | 87 | def parent(self) -> Dict[str, Any]: 88 | """Create the `parent` link.""" 89 | return dict( 90 | rel=Relations.parent, 91 | type=MimeTypes.json, 92 | href=urljoin(self.base_url, f"collections/{self.collection_id}"), 93 | ) 94 | 95 | def collection(self) -> Dict[str, Any]: 96 | """Create the `collection` link.""" 97 | return dict( 98 | rel=Relations.collection, 99 | type=MimeTypes.json, 100 | href=urljoin(self.base_url, f"collections/{self.collection_id}"), 101 | ) 102 | 103 | def create_links(self) -> List[Dict[str, Any]]: 104 | """Return all inferred links.""" 105 | links = [ 106 | self.self(), 107 | self.parent(), 108 | self.collection(), 109 | self.root(), 110 | ] 111 | return links 112 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-fastapi/423b5871280f46a91b76a008bdbff19373db3d13/stac_fastapi/types/stac_fastapi/types/py.typed -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/requests.py: -------------------------------------------------------------------------------- 1 | """Requests helpers.""" 2 | 3 | from starlette.requests import Request 4 | 5 | 6 | def get_base_url(request: Request) -> str: 7 | """Get base URL with respect of APIRouter prefix.""" 8 | app = request.app 9 | if not app.state.router_prefix: 10 | return str(request.base_url) 11 | else: 12 | return "{}{}/".format(str(request.base_url), app.state.router_prefix.lstrip("/")) 13 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/rfc3339.py: -------------------------------------------------------------------------------- 1 | """rfc3339.""" 2 | 3 | import re 4 | from datetime import datetime, timezone 5 | from typing import Optional, Tuple, Union 6 | 7 | import iso8601 8 | from fastapi import HTTPException 9 | 10 | RFC33339_PATTERN = ( 11 | r"^(\d\d\d\d)\-(\d\d)\-(\d\d)(T|t)(\d\d):(\d\d):(\d\d)([.]\d+)?" 12 | r"(Z|([-+])(\d\d):(\d\d))$" 13 | ) 14 | 15 | DateTimeType = Union[ 16 | datetime, 17 | Tuple[datetime, datetime], 18 | Tuple[datetime, None], 19 | Tuple[None, datetime], 20 | ] 21 | 22 | 23 | # Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394 24 | def datetime_to_str(dt: datetime, timespec: str = "auto") -> str: 25 | """Converts a :class:`datetime.datetime` instance to an ISO8601 string in the 26 | `RFC 3339, section 5.6 27 | `__ format required by 28 | the :stac-spec:`STAC Spec `. 29 | 30 | Args: 31 | dt : The datetime to convert. 32 | timespec: An optional argument that specifies the number of additional 33 | terms of the time to include. Valid options are 'auto', 'hours', 34 | 'minutes', 'seconds', 'milliseconds' and 'microseconds'. The default value 35 | is 'auto'. 36 | 37 | Returns: 38 | str: The ISO8601 (RFC 3339) formatted string representing the datetime. 39 | """ 40 | if dt.tzinfo is None: 41 | dt = dt.replace(tzinfo=timezone.utc) 42 | 43 | timestamp = dt.isoformat(timespec=timespec) 44 | zulu = "+00:00" 45 | if timestamp.endswith(zulu): 46 | timestamp = f"{timestamp[: -len(zulu)]}Z" 47 | 48 | return timestamp 49 | 50 | 51 | def rfc3339_str_to_datetime(s: str) -> datetime: 52 | """Convert a string conforming to RFC 3339 to a :class:`datetime.datetime`. 53 | 54 | Uses :meth:`iso8601.parse_date` under the hood. 55 | 56 | Args: 57 | s (str) : The string to convert to :class:`datetime.datetime`. 58 | 59 | Returns: 60 | str: The datetime represented by the ISO8601 (RFC 3339) formatted string. 61 | 62 | Raises: 63 | ValueError: If the string is not a valid RFC 3339 string. 64 | """ 65 | # Uppercase the string 66 | s = s.upper() 67 | 68 | # Match against RFC3339 regex. 69 | result = re.match(RFC33339_PATTERN, s) 70 | if not result: 71 | raise ValueError("Invalid RFC3339 datetime.") 72 | 73 | # Parse with pyiso8601 74 | return iso8601.parse_date(s) 75 | 76 | 77 | def parse_single_date(date_str: str) -> datetime: 78 | """ 79 | Parse a single RFC3339 date string into a datetime object. 80 | 81 | Args: 82 | date_str (str): A string representing the date in RFC3339 format. 83 | 84 | Returns: 85 | datetime: A datetime object parsed from the date_str. 86 | 87 | Raises: 88 | ValueError: If the date_str is empty or contains the placeholder '..'. 89 | """ 90 | if ".." in date_str or not date_str: 91 | raise ValueError("Invalid date format.") 92 | return rfc3339_str_to_datetime(date_str) 93 | 94 | 95 | def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: 96 | """ 97 | Extract a single datetime object or a tuple of datetime objects from an 98 | interval string defined by the OGC API. The interval can either be a 99 | single datetime or a range with start and end datetime. 100 | 101 | Args: 102 | interval (Optional[str]): The interval string to convert to datetime objects, 103 | or None if no datetime is specified. 104 | 105 | Returns: 106 | Optional[DateTimeType]: A single datetime.datetime object, a tuple of 107 | datetime.datetime objects, or None if input is None. 108 | 109 | Raises: 110 | HTTPException: If the string is not valid for various reasons such as being empty, 111 | having more than one slash, or if date formats are invalid. 112 | """ 113 | if interval is None: 114 | return None 115 | 116 | if not interval: 117 | raise HTTPException(status_code=400, detail="Empty interval string is invalid.") 118 | 119 | values = interval.split("/") 120 | if len(values) > 2: 121 | raise HTTPException( 122 | status_code=400, 123 | detail="Interval string contains more than one forward slash.", 124 | ) 125 | 126 | try: 127 | start = parse_single_date(values[0]) if values[0] not in ["..", ""] else None 128 | if len(values) == 1: 129 | return start 130 | 131 | end = ( 132 | parse_single_date(values[1]) 133 | if len(values) > 1 and values[1] not in ["..", ""] 134 | else None 135 | ) 136 | except (ValueError, iso8601.ParseError) as e: 137 | raise HTTPException(status_code=400, detail=str(e)) 138 | 139 | if start is None and end is None: 140 | raise HTTPException( 141 | status_code=400, detail="Double open-ended intervals are not allowed." 142 | ) 143 | if start is not None and end is not None and start > end: 144 | raise HTTPException( 145 | status_code=400, detail="Start datetime cannot be before end datetime." 146 | ) 147 | 148 | return start, end # type: ignore 149 | 150 | 151 | def now_in_utc() -> datetime: 152 | """Return a datetime value of now with the UTC timezone applied.""" 153 | return datetime.now(timezone.utc) 154 | 155 | 156 | def now_to_rfc3339_str() -> str: 157 | """Return an RFC 3339 string representing now.""" 158 | return datetime_to_str(now_in_utc()) 159 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/stac.py: -------------------------------------------------------------------------------- 1 | """STAC types.""" 2 | 3 | import json 4 | from typing import Any, Dict, List, Literal, Optional, Union 5 | 6 | from pydantic import ConfigDict, Field 7 | from stac_pydantic.shared import BBox, StacBaseModel 8 | from typing_extensions import NotRequired, TypedDict 9 | 10 | NumType = Union[float, int] 11 | 12 | 13 | class Catalog(TypedDict): 14 | """STAC Catalog.""" 15 | 16 | type: str 17 | stac_version: str 18 | stac_extensions: NotRequired[List[str]] 19 | id: str 20 | title: NotRequired[str] 21 | description: str 22 | links: List[Dict[str, Any]] 23 | 24 | 25 | class LandingPage(Catalog): 26 | """STAC Landing Page.""" 27 | 28 | conformsTo: List[str] 29 | 30 | 31 | class Conformance(TypedDict): 32 | """STAC Conformance Classes.""" 33 | 34 | conformsTo: List[str] 35 | 36 | 37 | class Collection(Catalog): 38 | """STAC Collection.""" 39 | 40 | keywords: List[str] 41 | license: str 42 | providers: List[Dict[str, Any]] 43 | extent: Dict[str, Any] 44 | summaries: Dict[str, Any] 45 | assets: Dict[str, Any] 46 | 47 | 48 | class Item(TypedDict): 49 | """STAC Item.""" 50 | 51 | type: Literal["Feature"] 52 | stac_version: str 53 | stac_extensions: NotRequired[List[str]] 54 | id: str 55 | geometry: Dict[str, Any] 56 | bbox: BBox 57 | properties: Dict[str, Any] 58 | links: List[Dict[str, Any]] 59 | assets: Dict[str, Any] 60 | collection: str 61 | 62 | 63 | class ItemCollection(TypedDict): 64 | """STAC Item Collection.""" 65 | 66 | type: Literal["FeatureCollection"] 67 | features: List[Item] 68 | links: List[Dict[str, Any]] 69 | numberMatched: NotRequired[int] 70 | numberReturned: NotRequired[int] 71 | 72 | 73 | class Collections(TypedDict): 74 | """All collections endpoint. 75 | https://github.com/radiantearth/stac-api-spec/tree/master/collections 76 | """ 77 | 78 | collections: List[Collection] 79 | links: List[Dict[str, Any]] 80 | numberMatched: NotRequired[int] 81 | numberReturned: NotRequired[int] 82 | 83 | 84 | class PatchAddReplaceTest(StacBaseModel): 85 | """Add, Replace or Test Operation.""" 86 | 87 | model_config = ConfigDict( 88 | json_schema_extra={ 89 | "examples": [ 90 | {"op": "add", "path": "/properties/foo", "value": "bar"}, 91 | {"op": "replace", "path": "/properties/foo", "value": "bar"}, 92 | {"op": "test", "path": "/properties/foo", "value": "bar"}, 93 | ] 94 | } 95 | ) 96 | 97 | path: str 98 | op: Literal["add", "replace", "test"] 99 | value: Any 100 | 101 | @property 102 | def json_value(self) -> str: 103 | """JSON dump of value field. 104 | 105 | Returns: 106 | str: JSON-ised value 107 | """ 108 | return json.dumps(self.value) 109 | 110 | 111 | class PatchRemove(StacBaseModel): 112 | """Remove Operation.""" 113 | 114 | model_config = ConfigDict( 115 | json_schema_extra={ 116 | "examples": [ 117 | { 118 | "op": "remove", 119 | "path": "/properties/foo", 120 | } 121 | ] 122 | } 123 | ) 124 | 125 | path: str 126 | op: Literal["remove"] 127 | 128 | 129 | class PatchMoveCopy(StacBaseModel): 130 | """Move or Copy Operation.""" 131 | 132 | model_config = ConfigDict( 133 | populate_by_name=True, 134 | json_schema_extra={ 135 | "examples": [ 136 | { 137 | "op": "copy", 138 | "path": "/properties/foo", 139 | "from": "/properties/bar", 140 | }, 141 | { 142 | "op": "move", 143 | "path": "/properties/foo", 144 | "from": "/properties/bar", 145 | }, 146 | ] 147 | }, 148 | ) 149 | 150 | path: str 151 | op: Literal["move", "copy"] 152 | from_: str = Field(alias="from") 153 | 154 | 155 | PatchOperation = Union[PatchAddReplaceTest, PatchMoveCopy, PatchRemove] 156 | 157 | 158 | class BasePartial(StacBaseModel): 159 | """Base Partial Class.""" 160 | 161 | @staticmethod 162 | def merge_to_operations(data: Dict) -> List[PatchOperation]: 163 | """Convert merge operation to list of RF6902 operations. 164 | 165 | Args: 166 | data: dictionary to convert. 167 | 168 | Returns: 169 | List: list of RF6902 operations. 170 | """ 171 | operations = [] 172 | 173 | for key, value in data.copy().items(): 174 | if value is None: 175 | operations.append(PatchRemove(op="remove", path=f"/{key}")) 176 | 177 | elif isinstance(value, dict): 178 | nested_operations = BasePartial.merge_to_operations(value) 179 | 180 | for nested_operation in nested_operations: 181 | nested_operation.path = f"/{key}{nested_operation.path}" 182 | operations.append(nested_operation) 183 | 184 | else: 185 | operations.append( 186 | PatchAddReplaceTest(op="add", path=f"/{key}", value=value) 187 | ) 188 | 189 | return operations 190 | 191 | def operations(self) -> List[PatchOperation]: 192 | """Equivalent RF6902 operations to merge of Partial. 193 | 194 | Returns: 195 | List[PatchOperation]: Equivalent list of RF6902 operations 196 | """ 197 | return self.merge_to_operations(self.model_dump()) 198 | 199 | 200 | class PartialCollection(BasePartial): 201 | """Partial STAC Collection.""" 202 | 203 | type: Optional[str] = None 204 | stac_version: Optional[str] = None 205 | stac_extensions: Optional[List[str]] = None 206 | id: Optional[str] = None 207 | title: Optional[str] = None 208 | description: Optional[str] = None 209 | links: Optional[Dict[str, Any]] = None 210 | keywords: Optional[List[str]] = None 211 | license: Optional[str] = None 212 | providers: Optional[List[Dict[str, Any]]] = None 213 | extent: Optional[Dict[str, Any]] = None 214 | summaries: Optional[Dict[str, Any]] = None 215 | assets: Optional[Dict[str, Any]] = None 216 | 217 | 218 | class PartialItem(BasePartial): 219 | """Partial STAC Item.""" 220 | 221 | type: Optional[Literal["Feature"]] = None 222 | stac_version: Optional[str] = None 223 | stac_extensions: Optional[List[str]] = None 224 | id: Optional[str] = None 225 | geometry: Optional[Dict[str, Any]] = None 226 | bbox: Optional[BBox] = None 227 | properties: Optional[Dict[str, Any]] = None 228 | links: Optional[List[Dict[str, Any]]] = None 229 | assets: Optional[Dict[str, Any]] = None 230 | collection: Optional[str] = None 231 | -------------------------------------------------------------------------------- /stac_fastapi/types/stac_fastapi/types/version.py: -------------------------------------------------------------------------------- 1 | """Library version.""" 2 | 3 | __version__ = "5.2.1" 4 | -------------------------------------------------------------------------------- /stac_fastapi/types/tests/test_config.py: -------------------------------------------------------------------------------- 1 | """test config classes.""" 2 | 3 | import pytest 4 | from pydantic import ValidationError 5 | 6 | from stac_fastapi.types.config import ApiSettings 7 | 8 | 9 | def test_incompatible_options(): 10 | """test incompatible output model options.""" 11 | settings = ApiSettings( 12 | enable_response_models=True, 13 | enable_direct_response=False, 14 | ) 15 | assert settings.enable_response_models 16 | assert not settings.enable_direct_response 17 | 18 | settings = ApiSettings( 19 | enable_response_models=False, 20 | enable_direct_response=True, 21 | ) 22 | assert not settings.enable_response_models 23 | assert settings.enable_direct_response 24 | 25 | with pytest.raises(ValidationError): 26 | ApiSettings( 27 | enable_response_models=True, 28 | enable_direct_response=True, 29 | ) 30 | -------------------------------------------------------------------------------- /stac_fastapi/types/tests/test_limit.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends, FastAPI 3 | from fastapi.testclient import TestClient 4 | from pydantic import ValidationError 5 | 6 | from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest 7 | 8 | 9 | @pytest.mark.parametrize("value", [0, -1]) 10 | def test_limit_ge(value): 11 | with pytest.raises(ValidationError): 12 | BaseSearchPostRequest(limit=value) 13 | 14 | 15 | @pytest.mark.parametrize("value", [1, 10_000]) 16 | def test_limit(value): 17 | search = BaseSearchPostRequest(limit=value) 18 | assert search.limit == value 19 | 20 | 21 | @pytest.mark.parametrize("value", [10_001, 100_000, 1_000_000]) 22 | def test_limit_le(value): 23 | search = BaseSearchPostRequest(limit=value) 24 | assert search.limit == 10_000 25 | 26 | 27 | def test_limit_get_request(): 28 | """test GET model.""" 29 | 30 | app = FastAPI() 31 | 32 | @app.get("/test") 33 | def route(model=Depends(BaseSearchGetRequest)): 34 | return model 35 | 36 | with TestClient(app) as client: 37 | resp = client.get( 38 | "/test", 39 | params={ 40 | "limit": 10, 41 | }, 42 | ) 43 | assert resp.status_code == 200 44 | response_dict = resp.json() 45 | assert response_dict["limit"] == 10 46 | 47 | resp = client.get( 48 | "/test", 49 | params={ 50 | "limit": 100_000, 51 | }, 52 | ) 53 | assert resp.status_code == 200 54 | response_dict = resp.json() 55 | assert response_dict["limit"] == 10_000 56 | -------------------------------------------------------------------------------- /stac_fastapi/types/tests/test_rfc3339.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import pytest 4 | from fastapi import HTTPException 5 | 6 | from stac_fastapi.types.rfc3339 import ( 7 | now_in_utc, 8 | now_to_rfc3339_str, 9 | rfc3339_str_to_datetime, 10 | str_to_interval, 11 | ) 12 | 13 | invalid_datetimes = [ 14 | "1985-04-12", # date only 15 | "1937-01-01T12:00:27.87+0100", # invalid TZ format, no sep : 16 | "37-01-01T12:00:27.87Z", # invalid year, must be 4 digits 17 | "1985-12-12T23:20:50.52", # no TZ 18 | "21985-12-12T23:20:50.52Z", # year must be 4 digits 19 | "1985-13-12T23:20:50.52Z", # month > 12 20 | "1985-12-32T23:20:50.52Z", # day > 31 21 | "1985-12-01T25:20:50.52Z", # hour > 24 22 | "1985-12-01T00:60:50.52Z", # minute > 59 23 | "1985-12-01T00:06:61.52Z", # second > 60 24 | "1985-04-12T23:20:50.Z", # fractional sec . but no frac secs 25 | "1985-04-12T23:20:50,Z", # fractional sec , but no frac secs 26 | "1990-12-31T23:59:61Z", # second > 60 w/o fractional seconds 27 | "1985-04-12T23:20:50,52Z", # comma as frac sec sep allowed in ISO8601 but not RFC3339 28 | ] 29 | 30 | valid_datetimes = [ 31 | "1985-04-12T23:20:50.52Z", 32 | "1996-12-19T16:39:57-00:00", 33 | "1996-12-19T16:39:57+00:00", 34 | "1996-12-19T16:39:57-08:00", 35 | "1996-12-19T16:39:57+08:00", 36 | "1937-01-01T12:00:27.87+01:00", 37 | "1985-04-12T23:20:50.52Z", 38 | "1937-01-01T12:00:27.8710+01:00", 39 | "1937-01-01T12:00:27.8+01:00", 40 | "1937-01-01T12:00:27.8Z", 41 | "2020-07-23T00:00:00.000+03:00", 42 | "2020-07-23T00:00:00+03:00", 43 | "1985-04-12t23:20:50.000z", 44 | "2020-07-23T00:00:00Z", 45 | "2020-07-23T00:00:00.0Z", 46 | "2020-07-23T00:00:00.01Z", 47 | "2020-07-23T00:00:00.012Z", 48 | "2020-07-23T00:00:00.0123Z", 49 | "2020-07-23T00:00:00.01234Z", 50 | "2020-07-23T00:00:00.012345Z", 51 | "2020-07-23T00:00:00.0123456Z", 52 | "2020-07-23T00:00:00.01234567Z", 53 | "2020-07-23T00:00:00.012345678Z", 54 | ] 55 | 56 | invalid_intervals = [ 57 | "/" 58 | "../" 59 | "/.." 60 | "../.." 61 | "/1984-04-12T23:20:50.52Z/1985-04-12T23:20:50.52Z", # extra start / 62 | "1984-04-12T23:20:50.52Z/1985-04-12T23:20:50.52Z/", # extra end / 63 | "1986-04-12T23:20:50.52Z/1985-04-12T23:20:50.52Z", # start > end 64 | ] 65 | 66 | valid_intervals = [ 67 | "../1985-04-12T23:20:50.52Z", 68 | "1985-04-12T23:20:50.52Z/..", 69 | "/1985-04-12T23:20:50.52Z", 70 | "1985-04-12T23:20:50.52Z/", 71 | "1985-04-12T23:20:50.52Z/1986-04-12T23:20:50.52Z", 72 | "1985-04-12T23:20:50.52+01:00/1986-04-12T23:20:50.52+01:00", 73 | "1985-04-12T23:20:50.52-01:00/1986-04-12T23:20:50.52-01:00", 74 | ] 75 | 76 | 77 | @pytest.mark.parametrize("test_input", invalid_datetimes) 78 | def test_parse_invalid_str_to_datetime(test_input): 79 | with pytest.raises(ValueError): 80 | rfc3339_str_to_datetime(test_input) 81 | 82 | 83 | @pytest.mark.parametrize("test_input", valid_datetimes) 84 | def test_parse_valid_str_to_datetime(test_input): 85 | assert rfc3339_str_to_datetime(test_input) 86 | 87 | 88 | @pytest.mark.parametrize("test_input", invalid_intervals) 89 | def test_str_to_interval_with_invalid_interval(test_input): 90 | with pytest.raises(HTTPException) as exc_info: 91 | str_to_interval(test_input) 92 | assert ( 93 | exc_info.value.status_code == 400 94 | ), "str_to_interval should return a 400 status code for invalid interval" 95 | 96 | 97 | @pytest.mark.parametrize("test_input", invalid_datetimes) 98 | def test_str_to_interval_with_invalid_datetime(test_input): 99 | with pytest.raises(HTTPException) as exc_info: 100 | str_to_interval(test_input) 101 | assert ( 102 | exc_info.value.status_code == 400 103 | ), "str_to_interval should return a 400 status code for invalid datetime" 104 | 105 | 106 | @pytest.mark.parametrize("test_input", valid_intervals) 107 | def test_str_to_interval_with_valid_interval(test_input): 108 | assert isinstance( 109 | str_to_interval(test_input), tuple 110 | ), "str_to_interval should return tuple for multi-value input" 111 | 112 | 113 | @pytest.mark.parametrize("test_input", valid_datetimes) 114 | def test_str_to_interval_with_valid_datetime(test_input): 115 | assert isinstance( 116 | str_to_interval(test_input), datetime 117 | ), "str_to_interval should return single datetime for single-value input" 118 | 119 | 120 | def test_str_to_interval_with_none(): 121 | """Test that str_to_interval returns None when provided with None.""" 122 | assert ( 123 | str_to_interval(None) is None 124 | ), "str_to_interval should return None when input is None" 125 | 126 | 127 | def test_now_functions() -> None: 128 | now1 = now_in_utc() 129 | now2 = now_in_utc() 130 | 131 | assert now1 < now2 132 | assert now1.tzinfo == timezone.utc 133 | 134 | rfc3339_str_to_datetime(now_to_rfc3339_str()) 135 | --------------------------------------------------------------------------------