Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ repos:
]
additional_dependencies: [
"types-attrs",
"types-requests"
"types-requests",
"types-redis"
]
- repo: https://github.com/PyCQA/pydocstyle
rev: 6.1.1
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Added

- Added Redis caching configuration for navigation pagination support, enabling proper `prev` and `next` links in paginated responses. [#466](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/466)

### Changed

### Fixed
Expand Down
26 changes: 26 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM python:3.13-slim

RUN apt-get update && apt-get install -y \
build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY README.md .
COPY stac_fastapi/opensearch/setup.py stac_fastapi/opensearch/
COPY stac_fastapi/core/setup.py stac_fastapi/core/
COPY stac_fastapi/sfeos_helpers/setup.py stac_fastapi/sfeos_helpers/


RUN pip install --no-cache-dir --upgrade pip setuptools wheel

COPY stac_fastapi/ stac_fastapi/

RUN pip install --no-cache-dir ./stac_fastapi/core
RUN pip install --no-cache-dir ./stac_fastapi/sfeos_helpers
RUN pip install --no-cache-dir ./stac_fastapi/opensearch[server]

EXPOSE 8080

CMD ["uvicorn", "stac_fastapi.opensearch.app:app", "--host", "0.0.0.0", "--port", "8080"]
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,29 @@ You can customize additional settings in your `.env` file:
> [!NOTE]
> The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, `ES_VERIFY_CERTS` and `ES_TIMEOUT` apply to both Elasticsearch and OpenSearch backends, so there is no need to rename the key names to `OS_` even if you're using OpenSearch.

**Redis for Navigation:**
These Redis configuration variables enable proper navigation functionality in STAC FastAPI. The Redis cache stores navigation state for paginated results, allowing the system to maintain previous page links using tokens. The configuration supports either Redis Sentinel or Redis:

| Variable | Description | Default | Required |
|------------------------------|--------------------------------------------------------------------------------------|--------------------------|---------------------------------------------------------------------------------------------|
| **Redis Sentinel** | | | |
| `REDIS_SENTINEL_HOSTS` | Comma-separated list of Redis Sentinel hostnames/IP addresses. | `""` | Conditional (required if using Sentinel) |
| `REDIS_SENTINEL_PORTS` | Comma-separated list of Redis Sentinel ports (must match order). | `"26379"` | Conditional (required if using Sentinel) |
| `REDIS_SENTINEL_MASTER_NAME` | Name of the Redis master node in Sentinel configuration. | `"master"` | Conditional (required if using Sentinel) |
| **Redis** | | | |
| `REDIS_HOST` | Redis server hostname or IP address for Redis configuration. | `""` | Conditional (required for standalone Redis) |
| `REDIS_PORT` | Redis server port for Redis configuration. | `6379` | Conditional (required for standalone Redis) |
| **Both** | | | |
| `REDIS_DB` | Redis database number to use for caching. | `0` (Sentinel) / `0` (Standalone) | Optional |
| `REDIS_MAX_CONNECTIONS` | Maximum number of connections in the Redis connection pool. | `10` | Optional |
| `REDIS_RETRY_TIMEOUT` | Enable retry on timeout for Redis operations. | `true` | Optional |
| `REDIS_DECODE_RESPONSES` | Automatically decode Redis responses to strings. | `true` | Optional |
| `REDIS_CLIENT_NAME` | Client name identifier for Redis connections. | `"stac-fastapi-app"` | Optional |
| `REDIS_HEALTH_CHECK_INTERVAL`| Interval in seconds for Redis health checks. | `30` | Optional |

> [!NOTE]
> Use either the Sentinel configuration (`REDIS_SENTINEL_HOSTS`, `REDIS_SENTINEL_PORTS`, `REDIS_SENTINEL_MASTER_NAME`) OR the Redis configuration (`REDIS_HOST`, `REDIS_PORT`), but not both.

## Datetime-Based Index Management

### Overview
Expand Down Expand Up @@ -693,3 +716,4 @@ The system uses a precise naming convention:
- Ensures fair resource allocation among all clients

- **Examples**: Implementation examples are available in the [examples/rate_limit](examples/rate_limit) directory.

1 change: 1 addition & 0 deletions dockerfiles/Dockerfile.dev.es
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ COPY . /app
RUN pip install --no-cache-dir -e ./stac_fastapi/core
RUN pip install --no-cache-dir -e ./stac_fastapi/sfeos_helpers
RUN pip install --no-cache-dir -e ./stac_fastapi/elasticsearch[dev,server]
RUN pip install --no-cache-dir redis types-redis
1 change: 1 addition & 0 deletions stac_fastapi/core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"pygeofilter~=0.3.1",
"jsonschema~=4.0.0",
"slowapi~=0.1.9",
"redis==6.4.0",
]

setup(
Expand Down
86 changes: 86 additions & 0 deletions stac_fastapi/core/stac_fastapi/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
from stac_fastapi.core.base_settings import ApiBaseSettings
from stac_fastapi.core.datetime_utils import format_datetime_range
from stac_fastapi.core.models.links import PagingLinks
from stac_fastapi.core.redis_utils import (
connect_redis_sentinel,
get_prev_link,
save_self_link,
)
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
from stac_fastapi.core.session import Session
from stac_fastapi.core.utilities import filter_fields
Expand Down Expand Up @@ -333,6 +338,13 @@ async def all_collections(
if q is not None:
q_list = [q] if isinstance(q, str) else q

current_url = str(request.url)
redis = None
try:
redis = await connect_redis_sentinel()
except Exception:
redis = None

# Parse the query parameter if provided
parsed_query = None
if query is not None:
Expand Down Expand Up @@ -426,6 +438,22 @@ async def all_collections(
},
]

if redis:
if next_token:
await save_self_link(redis, next_token, current_url)

prev_link = await get_prev_link(redis, token)
if prev_link:
links.insert(
0,
{
"rel": "previous",
"type": "application/json",
"method": "GET",
"href": prev_link,
},
)

if next_token:
next_link = PagingLinks(next=next_token, request=request).link_next()
links.append(next_link)
Expand Down Expand Up @@ -744,6 +772,10 @@ async def post_search(
HTTPException: If there is an error with the cql2_json filter.
"""
base_url = str(request.base_url)
try:
redis = await connect_redis_sentinel()
except Exception:
redis = None

search = self.database.make_search()

Expand Down Expand Up @@ -850,6 +882,60 @@ async def post_search(
]
links = await PagingLinks(request=request, next=next_token).get_links()

collection_links = []
if (
items
and search_request.collections
and len(search_request.collections) == 1
):
collection_id = search_request.collections[0]
collection_links.extend(
[
{
"rel": "collection",
"type": "application/json",
"href": urljoin(base_url, f"collections/{collection_id}"),
},
{
"rel": "parent",
"type": "application/json",
"href": urljoin(base_url, f"collections/{collection_id}"),
},
]
)
links.extend(collection_links)

if redis:
self_link = str(request.url)
await save_self_link(redis, next_token, self_link)

prev_link = await get_prev_link(redis, token_param)
if prev_link:
method = "GET"
body = None
for link in links:
if link.get("rel") == "next":
if "method" in link:
method = link["method"]
if "body" in link:
body = {**link["body"]}
body.pop("token", None)
break
else:
method = request.method

prev_link_data = {
"rel": "previous",
"type": "application/json",
"method": method,
"href": prev_link,
}

if body:
prev_link_data["body"] = body

links.insert(0, prev_link_data)

return stac_types.ItemCollection(
type="FeatureCollection",
features=items,
Expand Down
123 changes: 123 additions & 0 deletions stac_fastapi/core/stac_fastapi/core/redis_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Utilities for connecting to and managing Redis connections."""

from typing import Optional

from pydantic_settings import BaseSettings
from redis import asyncio as aioredis
from redis.asyncio.sentinel import Sentinel

redis_pool: Optional[aioredis.Redis] = None


class RedisSentinelSettings(BaseSettings):
"""Configuration for connecting to Redis Sentinel."""

REDIS_SENTINEL_HOSTS: str = ""
REDIS_SENTINEL_PORTS: str = "26379"
REDIS_SENTINEL_MASTER_NAME: str = "master"
REDIS_DB: int = 0

REDIS_MAX_CONNECTIONS: int = 10
REDIS_RETRY_TIMEOUT: bool = True
REDIS_DECODE_RESPONSES: bool = True
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
REDIS_HEALTH_CHECK_INTERVAL: int = 30


class RedisSettings(BaseSettings):
"""Configuration for connecting Redis."""

REDIS_HOST: str = ""
REDIS_PORT: int = 6379
REDIS_DB: int = 0

REDIS_MAX_CONNECTIONS: int = 10
REDIS_RETRY_TIMEOUT: bool = True
REDIS_DECODE_RESPONSES: bool = True
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
REDIS_HEALTH_CHECK_INTERVAL: int = 30


# Select the Redis or Redis Sentinel configuration
redis_settings: BaseSettings = RedisSentinelSettings()


async def connect_redis_sentinel(
settings: Optional[RedisSentinelSettings] = None,
) -> Optional[aioredis.Redis]:
"""Return Redis Sentinel connection."""
global redis_pool
settings = settings or redis_settings

if (
not settings.REDIS_SENTINEL_HOSTS
or not settings.REDIS_SENTINEL_PORTS
or not settings.REDIS_SENTINEL_MASTER_NAME
):
return None

hosts = [h.strip() for h in settings.REDIS_SENTINEL_HOSTS.split(",") if h.strip()]
ports = [
int(p.strip()) for p in settings.REDIS_SENTINEL_PORTS.split(",") if p.strip()
]

if redis_pool is None:
try:
sentinel = Sentinel(
[(host, port) for host, port in zip(hosts, ports)],
decode_responses=settings.REDIS_DECODE_RESPONSES,
)
master = sentinel.master_for(
service_name=settings.REDIS_SENTINEL_MASTER_NAME,
db=settings.REDIS_DB,
decode_responses=settings.REDIS_DECODE_RESPONSES,
retry_on_timeout=settings.REDIS_RETRY_TIMEOUT,
client_name=settings.REDIS_CLIENT_NAME,
max_connections=settings.REDIS_MAX_CONNECTIONS,
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL,
)
redis_pool = master

except Exception:
return None

return redis_pool


async def connect_redis(settings: Optional[RedisSettings] = None) -> aioredis.Redis:
"""Return Redis connection."""
global redis_pool
settings = settings or redis_settings

if not settings.REDIS_HOST or not settings.REDIS_PORT:
return None

if redis_pool is None:
pool = aioredis.ConnectionPool(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
max_connections=settings.REDIS_MAX_CONNECTIONS,
decode_responses=settings.REDIS_DECODE_RESPONSES,
retry_on_timeout=settings.REDIS_RETRY_TIMEOUT,
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL,
)
redis_pool = aioredis.Redis(
connection_pool=pool, client_name=settings.REDIS_CLIENT_NAME
)
return redis_pool


async def save_self_link(
redis: aioredis.Redis, token: Optional[str], self_href: str
) -> None:
"""Add the self link for next page as prev link for the current token."""
if token:
await redis.setex(f"nav:self:{token}", 1800, self_href)


async def get_prev_link(redis: aioredis.Redis, token: Optional[str]) -> Optional[str]:
"""Pull the prev page link for the current token."""
if not token:
return None
return await redis.get(f"nav:self:{token}")
1 change: 1 addition & 0 deletions stac_fastapi/elasticsearch/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"elasticsearch[async]~=8.18.0",
"uvicorn~=0.23.0",
"starlette>=0.35.0,<0.36.0",
"redis==6.4.0",
]

extra_reqs = {
Expand Down
1 change: 1 addition & 0 deletions stac_fastapi/opensearch/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"opensearch-py[async]~=2.8.0",
"uvicorn~=0.23.0",
"starlette>=0.35.0,<0.36.0",
"redis==6.4.0",
]

extra_reqs = {
Expand Down
2 changes: 1 addition & 1 deletion stac_fastapi/sfeos_helpers/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
desc = f.read()

install_requires = [
"redis==6.4.0",
"stac-fastapi.core==6.5.1",
]

setup(
name="sfeos_helpers",
description="Helper library for the Elasticsearch and Opensearch stac-fastapi backends.",
Expand Down
Loading