├── .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 |
5 |
FastAPI implemention of the STAC API spec.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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). | [](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. | [](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. | [](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 |
--------------------------------------------------------------------------------