Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
177a48a
prepare dependencies for CollectionSearchExtension and FreeTextExtension
fmigneault May 1, 2025
9a2ffb9
update enabled experimental extensions
fmigneault May 1, 2025
6616793
Merge remote-tracking branch 'origin/main' into more-stac-extensions
fmigneault May 2, 2025
55e72f8
add changes
fmigneault Jun 9, 2025
1cab6ea
fix lint
fmigneault Jun 9, 2025
1a155bb
resolve extension conflicts
fmigneault Jul 7, 2025
7cf889f
remove unused imports
fmigneault Jul 7, 2025
0973afb
Merge remote-tracking branch 'origin/main' into more-stac-extensions
fmigneault Jul 7, 2025
19b6941
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 7, 2025
fa3a777
Update CHANGES.md
fmigneault Jul 7, 2025
7328534
Update CHANGES.md
fmigneault Jul 7, 2025
1887752
fix lint
fmigneault Jul 7, 2025
38fd3f4
Merge branch 'more-stac-extensions' of github.com:crim-ca/stac-app in…
fmigneault Jul 7, 2025
f89b277
Merge branch 'stac-fastapi-6' into more-stac-extensions
fmigneault Jul 16, 2025
21aee0e
use stac-extensions validation (depends on https://github.com/stac-ut…
fmigneault Jul 17, 2025
5ce01b7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2025
586ee85
fix free text search extensions (basic and advanced simultaneously)
fmigneault Aug 15, 2025
a58158f
Merge branch 'more-stac-extensions' of github.com:crim-ca/stac-app in…
fmigneault Aug 15, 2025
4c4dbc5
update changelog
fmigneault Aug 15, 2025
6da300b
Merge branch 'main' into more-stac-extensions
fmigneault Aug 15, 2025
d0ef01d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 15, 2025
fb9d835
add changelog entry
fmigneault Aug 15, 2025
f97347e
fix pre-commit linting
fmigneault Aug 15, 2025
fa15226
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 15, 2025
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
45 changes: 45 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,51 @@
[Unreleased](https://github.com/crim-ca/stac-app/tree/master)
------------------------------------------------------------------------------------------------------------------

# Changed

- Add `CollectionSearchExtension` base class to support the `pgstac` `collection_search` operator
for `GET /collections` request.

_**NOTE**_: <br>
Because this extension relies on a specific SQL function `collection_search` and its adjusted feature
for parameter `q`, [`pgstac>=0.9.2`](https://stac-utils.github.io/pgstac/release-notes/#v092) is required. This
means the underlying PostgreSQL version **MUST** be migrated to 17.

_**NOTE**_: <br>
The `CollectionSearchPostExtension` is *purposely* omitted as it would conflict with the `Transaction` extension
that both uses the same `POST /collections` endpoint for search and collection creation respectively.

- Extended search parameters using `FreeTextAdvancedExtension` to allow
free-form `q` parameter text search of the collection or its items
across `description`, `title`, `keywords`.

The ["advanced"](https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#advanced) portion of
the extension allows additional operators such as `OR`, `AND`, `+`, `-` and `()` within the text search to form
complex search criteria.

To ease user experience, ["basic"](https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#basic)
free-text search is also supported seamlessly, to allow simpler queries. Normally, both extensions would conflict
between each other using the same `q` parameter, but little additional logic makes it possible to support both.

Enabled requests using `q`:

- `POST /search` with `{"q": ["term", "other"]}` (basic free-text search form)
- `POST /search` with `{"q": "term OR other"}` (advanced free-text search form)
- `GET /search?q=term,other`
- `GET /search?q=term OR other`
- `GET /collections?q=term,other`
- `GET /collections?q=term OR other`
- `GET /collections/{collectionId}/items?q=term,other`
- `GET /collections/{collectionId}/items?q=term OR other`

For the same `Transaction` extension reason as above, `POST` cannot be used elsewhere than on `/search` endpoint.

- Extended search parameters using `FreeTextAdvancedExtension` to allow

- Enabled `Settings(validate_extensions=True)` when configuring the `StacAPI` application.
This ensures that, when a STAC Collection or Item is POST'ed to the API, all the `stac_extensions` that it declares
will also be validated against their respective schemas, rather than limiting itself only to core STAC definitions.

# Fixed

- Fix breaking PG connection setting when using ``stac-fastapi>=6``.
Expand Down
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
stac-fastapi.api==5.2.0
stac-fastapi.pgstac==5.0.2
uvicorn==0.34.2
stac-fastapi.api==6.0.0
stac-fastapi.pgstac[validation]==6.0.0
uvicorn==0.35.0
151 changes: 129 additions & 22 deletions src/stac_app.py
Original file line number Diff line number Diff line change
@@ -1,83 +1,190 @@
"""FastAPI application using PGStac."""

# Based on stac-fastapi/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py
# Based on https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/app.py
import logging
import os
import time
from typing import Optional, Type, cast
from typing import Annotated, Optional, Type, Union, cast

import asyncpg
from buildpg import render
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import ORJSONResponse
from packaging.version import Version
from pydantic import BaseModel, Field
from pydantic.functional_serializers import PlainSerializer
from stac_fastapi.api.app import StacApi
from stac_fastapi.api.models import (
ItemCollectionUri,
create_get_request_model,
create_post_request_model,
create_request_model,
)
from stac_fastapi.api.version import __version__ as stac_fastapi_version
from stac_fastapi.extensions.core import (
CollectionSearchFilterExtension,
FieldsExtension,
FilterExtension,
ItemCollectionFilterExtension,
PaginationExtension,
QueryExtension,
SortExtension,
TokenPaginationExtension,
TransactionExtension,
)
from stac_fastapi.extensions.core.collection_search import CollectionSearchExtension
from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
from stac_fastapi.extensions.core.free_text import FreeTextAdvancedExtension, FreeTextConformanceClasses
from stac_fastapi.extensions.core.query import QueryConformanceClasses
from stac_fastapi.extensions.core.sort import SortConformanceClasses
from stac_fastapi.pgstac.config import Settings
from stac_fastapi.pgstac.core import CoreCrudClient
from stac_fastapi.pgstac.db import close_db_connection, connect_to_db
from stac_fastapi.pgstac.extensions.filter import FiltersClient
from stac_fastapi.pgstac.transactions import TransactionsClient
from stac_fastapi.pgstac.types.search import PgstacSearch
from stac_fastapi.types.search import APIRequest
from stac_fastapi.types.search import APIRequest, BaseSearchGetRequest, BaseSearchPostRequest

# hijack uvicorn's logger (otherwise log messages won't be visible)
logger = logging.getLogger("uvicorn.error")

settings = Settings()
settings = Settings(validate_extensions=True)
settings.openapi_url = os.environ.get("OPENAPI_URL", "/api")
settings.docs_url = os.environ.get("DOCS_URL", "/api.html")

items_get_request_model = cast(
Type[APIRequest],
create_request_model(
"ItemCollectionURI",
base_model=ItemCollectionUri,
mixins=[TokenPaginationExtension().GET],

class FreeTextCombinedExtensionPostRequest(BaseModel):
"""Free-text Extension POST request model allowing for either Basic or Advanced formats."""

q: Annotated[
Optional[Union[str, list[str]]],
PlainSerializer(
lambda x: " OR ".join(x) if isinstance(x, list) else x,
return_type=str,
when_used="json",
),
] = Field(
None,
description=(
"Parameter to perform free-text queries against STAC metadata. "
"Basic free-text search is performed when using an array of words. "
"Advanced free-text search is performed when using a string containing the expression."
),
)


class FreeTextCombinedExtension(FreeTextAdvancedExtension):
"""Free-Text Search extension that allows simultaneous use of Basic and Advanced formats."""

# POST needs override to deal with basic:list[str] vs advanced:str
# GET uses 'q: str' for both basic and advanced
POST = FreeTextCombinedExtensionPostRequest


# /search
search_extensions = [
QueryExtension(),
SortExtension(),
FieldsExtension(),
FreeTextCombinedExtension(
conformance_classes=[
# both basic/advanced are handled simultaneously with the same query parameters and their respective formats
# however, only one of the extension class is added explicitly to avoid parameter conflict when loading the API
FreeTextConformanceClasses.SEARCH,
FreeTextConformanceClasses.SEARCH_ADVANCED,
]
),
FilterExtension(client=FiltersClient()),
PaginationExtension(),
]
search_get_request_model = cast(
Union[Type[APIRequest], Type[BaseSearchGetRequest]], create_get_request_model(search_extensions)
)
search_post_request_model = cast(
Union[Type[APIRequest], Type[BaseSearchPostRequest]],
create_post_request_model(search_extensions, base_model=PgstacSearch),
)

extensions = [
# object creation/update/delete operations
transaction_extensions = [
TransactionExtension(
client=TransactionsClient(),
settings=settings,
response_class=ORJSONResponse,
),
QueryExtension(),
SortExtension(),
FieldsExtension(),
FilterExtension(client=FiltersClient()),
]

# /collections
collection_base_extensions = [
QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
FreeTextAdvancedExtension(
conformance_classes=[
# both basic/advanced are handled simultaneously with the same query parameters and their respective formats
# however, only one of the extension class is added explicitly to avoid parameter conflict when loading the API
FreeTextConformanceClasses.COLLECTIONS,
FreeTextConformanceClasses.COLLECTIONS_ADVANCED,
]
),
TokenPaginationExtension(),
]
# NOTE:
# Using only the 'GET /collections' for search, since 'POST /collections' search
# would conflict with Transaction extension to create/update/delete collections.
collection_search_extension = CollectionSearchExtension.from_extensions(
collection_base_extensions
+ [
CollectionSearchFilterExtension(client=FiltersClient()),
],
)
# collection_search_extension = CollectionSearchPostExtension.from_extensions( # GET + POST
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this left on purpose or should it be removed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is left on purpose to highlight the conflicting endpoints, since the extension must be only partially enabled.
(✅ POST /search, ✅ GET /search, ❌ POST /collections, ✅ GET /collections?q=...)

# collection_base_extensions,
# client=CollectionSearchPostClient(),
# settings=settings,
# )
collections_get_request_model = cast(
Union[Type[APIRequest], Type[CollectionSearchExtension]], collection_search_extension.GET
)
collection_extensions = collection_base_extensions + [collection_search_extension]

# /collections/{collectionID}/items
items_extensions = [
QueryExtension(conformance_classes=[QueryConformanceClasses.ITEMS]),
SortExtension(conformance_classes=[SortConformanceClasses.ITEMS]),
FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]),
FreeTextAdvancedExtension(
conformance_classes=[
# both basic/advanced are handled simultaneously with the same query parameters and their respective formats
# however, only one of the extension class is added explicitly to avoid parameter conflict when loading the API
FreeTextConformanceClasses.ITEMS,
FreeTextConformanceClasses.ITEMS_ADVANCED,
]
),
ItemCollectionFilterExtension(client=FiltersClient()),
TokenPaginationExtension(),
PaginationExtension(),
]
items_get_request_model = cast(
Type[APIRequest],
create_get_request_model(
extensions=items_extensions,
base_model=ItemCollectionUri,
),
)

app_extensions = search_extensions + transaction_extensions + collection_extensions + items_extensions

post_request_model = create_post_request_model(extensions, base_model=PgstacSearch)
router_prefix = os.environ.get("ROUTER_PREFIX")
router_prefix_str = router_prefix.rstrip("/") if router_prefix else ""

THIS_DIR = os.path.dirname(os.path.abspath(__file__))

api = StacApi(
settings=settings,
extensions=extensions,
client=CoreCrudClient(pgstac_search_model=post_request_model),
search_get_request_model=create_get_request_model(extensions),
search_post_request_model=post_request_model,
extensions=app_extensions,
client=CoreCrudClient(pgstac_search_model=search_post_request_model),
search_get_request_model=search_get_request_model,
search_post_request_model=search_post_request_model,
collections_get_request_model=collections_get_request_model,
items_get_request_model=items_get_request_model,
response_class=ORJSONResponse,
title=(os.getenv("STAC_FASTAPI_TITLE") or "Data Analytics for Canadian Climate Services STAC API"),
Expand Down