stac-fastapi-pgstac 6.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """stac_fastapi.pgstac module."""
@@ -0,0 +1,217 @@
1
+ """FastAPI application using PGStac.
2
+
3
+ Enables the extensions specified as a comma-delimited list in
4
+ the ENABLED_EXTENSIONS environment variable (e.g. `transactions,sort,query`).
5
+ If the variable is not set, enables all extensions.
6
+ """
7
+
8
+ import os
9
+ from contextlib import asynccontextmanager
10
+
11
+ from brotli_asgi import BrotliMiddleware
12
+ from fastapi import APIRouter, FastAPI
13
+ from stac_fastapi.api.app import StacApi
14
+ from stac_fastapi.api.middleware import CORSMiddleware, ProxyHeaderMiddleware
15
+ from stac_fastapi.api.models import (
16
+ EmptyRequest,
17
+ ItemCollectionUri,
18
+ JSONResponse,
19
+ create_get_request_model,
20
+ create_post_request_model,
21
+ create_request_model,
22
+ )
23
+ from stac_fastapi.extensions.core import (
24
+ CollectionSearchExtension,
25
+ CollectionSearchFilterExtension,
26
+ FieldsExtension,
27
+ ItemCollectionFilterExtension,
28
+ OffsetPaginationExtension,
29
+ SearchFilterExtension,
30
+ SortExtension,
31
+ TokenPaginationExtension,
32
+ TransactionExtension,
33
+ )
34
+ from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
35
+ from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
36
+ from stac_fastapi.extensions.core.query import QueryConformanceClasses
37
+ from stac_fastapi.extensions.core.sort import SortConformanceClasses
38
+ from stac_fastapi.extensions.third_party import BulkTransactionExtension
39
+ from starlette.middleware import Middleware
40
+
41
+ from stac_fastapi.pgstac.config import Settings
42
+ from stac_fastapi.pgstac.core import CoreCrudClient, health_check
43
+ from stac_fastapi.pgstac.db import close_db_connection, connect_to_db
44
+ from stac_fastapi.pgstac.extensions import FreeTextExtension, QueryExtension
45
+ from stac_fastapi.pgstac.extensions.filter import FiltersClient
46
+ from stac_fastapi.pgstac.transactions import BulkTransactionsClient, TransactionsClient
47
+ from stac_fastapi.pgstac.types.search import PgstacSearch
48
+
49
+ settings = Settings()
50
+
51
+ # search extensions
52
+ search_extensions_map = {
53
+ "query": QueryExtension(),
54
+ "sort": SortExtension(),
55
+ "fields": FieldsExtension(),
56
+ "filter": SearchFilterExtension(client=FiltersClient()),
57
+ "pagination": TokenPaginationExtension(),
58
+ }
59
+
60
+ # collection_search extensions
61
+ cs_extensions_map = {
62
+ "query": QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
63
+ "sort": SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
64
+ "fields": FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
65
+ "filter": CollectionSearchFilterExtension(client=FiltersClient()),
66
+ "free_text": FreeTextExtension(
67
+ conformance_classes=[FreeTextConformanceClasses.COLLECTIONS],
68
+ ),
69
+ "pagination": OffsetPaginationExtension(),
70
+ }
71
+
72
+ # item_collection extensions
73
+ itm_col_extensions_map = {
74
+ "query": QueryExtension(
75
+ conformance_classes=[QueryConformanceClasses.ITEMS],
76
+ ),
77
+ "sort": SortExtension(
78
+ conformance_classes=[SortConformanceClasses.ITEMS],
79
+ ),
80
+ "fields": FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]),
81
+ "filter": ItemCollectionFilterExtension(client=FiltersClient()),
82
+ "pagination": TokenPaginationExtension(),
83
+ }
84
+
85
+ enabled_extensions = {
86
+ *search_extensions_map.keys(),
87
+ *cs_extensions_map.keys(),
88
+ *itm_col_extensions_map.keys(),
89
+ "collection_search",
90
+ }
91
+
92
+ if ext := os.environ.get("ENABLED_EXTENSIONS"):
93
+ enabled_extensions = set(ext.split(","))
94
+
95
+ application_extensions = []
96
+
97
+ with_transactions = os.environ.get("ENABLE_TRANSACTIONS_EXTENSIONS", "").lower() in [
98
+ "yes",
99
+ "true",
100
+ "1",
101
+ ]
102
+ if with_transactions:
103
+ application_extensions.append(
104
+ TransactionExtension(
105
+ client=TransactionsClient(),
106
+ settings=settings,
107
+ response_class=JSONResponse,
108
+ ),
109
+ )
110
+
111
+ application_extensions.append(
112
+ BulkTransactionExtension(client=BulkTransactionsClient()),
113
+ )
114
+
115
+ # /search models
116
+ search_extensions = [
117
+ extension
118
+ for key, extension in search_extensions_map.items()
119
+ if key in enabled_extensions
120
+ ]
121
+ post_request_model = create_post_request_model(search_extensions, base_model=PgstacSearch)
122
+ get_request_model = create_get_request_model(search_extensions)
123
+ application_extensions.extend(search_extensions)
124
+
125
+ # /collections/{collectionId}/items model
126
+ items_get_request_model = ItemCollectionUri
127
+ itm_col_extensions = [
128
+ extension
129
+ for key, extension in itm_col_extensions_map.items()
130
+ if key in enabled_extensions
131
+ ]
132
+ if itm_col_extensions:
133
+ items_get_request_model = create_request_model(
134
+ model_name="ItemCollectionUri",
135
+ base_model=ItemCollectionUri,
136
+ extensions=itm_col_extensions,
137
+ request_type="GET",
138
+ )
139
+ application_extensions.extend(itm_col_extensions)
140
+
141
+ # /collections model
142
+ collections_get_request_model = EmptyRequest
143
+ if "collection_search" in enabled_extensions:
144
+ cs_extensions = [
145
+ extension
146
+ for key, extension in cs_extensions_map.items()
147
+ if key in enabled_extensions
148
+ ]
149
+ collection_search_extension = CollectionSearchExtension.from_extensions(cs_extensions)
150
+ collections_get_request_model = collection_search_extension.GET
151
+ application_extensions.append(collection_search_extension)
152
+
153
+
154
+ @asynccontextmanager
155
+ async def lifespan(app: FastAPI):
156
+ """FastAPI Lifespan."""
157
+ await connect_to_db(app, add_write_connection_pool=with_transactions)
158
+ yield
159
+ await close_db_connection(app)
160
+
161
+
162
+ api = StacApi(
163
+ app=FastAPI(
164
+ openapi_url=settings.openapi_url,
165
+ docs_url=settings.docs_url,
166
+ redoc_url=None,
167
+ root_path=settings.root_path,
168
+ title=settings.stac_fastapi_title,
169
+ version=settings.stac_fastapi_version,
170
+ description=settings.stac_fastapi_description,
171
+ lifespan=lifespan,
172
+ ),
173
+ router=APIRouter(prefix=settings.prefix_path),
174
+ settings=settings,
175
+ extensions=application_extensions,
176
+ client=CoreCrudClient(pgstac_search_model=post_request_model),
177
+ response_class=JSONResponse,
178
+ items_get_request_model=items_get_request_model,
179
+ search_get_request_model=get_request_model,
180
+ search_post_request_model=post_request_model,
181
+ collections_get_request_model=collections_get_request_model,
182
+ middlewares=[
183
+ Middleware(BrotliMiddleware),
184
+ Middleware(ProxyHeaderMiddleware),
185
+ Middleware(
186
+ CORSMiddleware,
187
+ allow_origins=settings.cors_origins,
188
+ allow_origin_regex=settings.cors_origin_regex,
189
+ allow_methods=settings.cors_methods,
190
+ allow_credentials=settings.cors_credentials,
191
+ allow_headers=settings.cors_headers,
192
+ ),
193
+ ],
194
+ health_check=health_check,
195
+ )
196
+ app = api.app
197
+
198
+
199
+ def run():
200
+ """Run app from command line using uvicorn if available."""
201
+ try:
202
+ import uvicorn
203
+
204
+ uvicorn.run(
205
+ "stac_fastapi.pgstac.app:app",
206
+ host=settings.app_host,
207
+ port=settings.app_port,
208
+ log_level="info",
209
+ reload=settings.reload,
210
+ root_path=os.getenv("UVICORN_ROOT_PATH", ""),
211
+ )
212
+ except ImportError as e:
213
+ raise RuntimeError("Uvicorn must be installed in order to use command") from e
214
+
215
+
216
+ if __name__ == "__main__":
217
+ run()
@@ -0,0 +1,225 @@
1
+ """Postgres API configuration."""
2
+
3
+ import warnings
4
+ from typing import Annotated, Any, List, Optional, Sequence, Type
5
+ from urllib.parse import quote_plus as quote
6
+
7
+ from pydantic import BaseModel, BeforeValidator, Field, model_validator
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+ from stac_fastapi.types.config import ApiSettings
10
+ from typing_extensions import Self
11
+
12
+ from stac_fastapi.pgstac.types.base_item_cache import (
13
+ BaseItemCache,
14
+ DefaultBaseItemCache,
15
+ )
16
+
17
+ DEFAULT_INVALID_ID_CHARS = [
18
+ ":",
19
+ "/",
20
+ "?",
21
+ "#",
22
+ "[",
23
+ "]",
24
+ "@",
25
+ "!",
26
+ "$",
27
+ "&",
28
+ "'",
29
+ "(",
30
+ ")",
31
+ "*",
32
+ "+",
33
+ ",",
34
+ ";",
35
+ "=",
36
+ ]
37
+
38
+
39
+ class ServerSettings(BaseModel):
40
+ """Server runtime parameters.
41
+
42
+ Attributes:
43
+ search_path: Postgres search path. Defaults to "pgstac,public".
44
+ application_name: PgSTAC Application name. Defaults to 'pgstac'.
45
+ """
46
+
47
+ search_path: str = "pgstac,public"
48
+ application_name: str = "pgstac"
49
+
50
+ model_config = SettingsConfigDict(extra="allow")
51
+
52
+
53
+ class PostgresSettings(BaseSettings):
54
+ """Postgres-specific API settings.
55
+
56
+ Attributes:
57
+ pguser: postgres username.
58
+ pgpassword: postgres password.
59
+ pghost: hostname for the connection.
60
+ pgport: database port.
61
+ pgdatabase: database name.
62
+
63
+ """
64
+
65
+ postgres_user: Annotated[
66
+ Optional[str],
67
+ Field(
68
+ deprecated="`postgres_user` is deprecated, please use `pguser`", default=None
69
+ ),
70
+ ]
71
+ postgres_pass: Annotated[
72
+ Optional[str],
73
+ Field(
74
+ deprecated="`postgres_pass` is deprecated, please use `pgpassword`",
75
+ default=None,
76
+ ),
77
+ ]
78
+ postgres_host_reader: Annotated[
79
+ Optional[str],
80
+ Field(
81
+ deprecated="`postgres_host_reader` is deprecated, please use `pghost`",
82
+ default=None,
83
+ ),
84
+ ]
85
+ postgres_host_writer: Annotated[
86
+ Optional[str],
87
+ Field(
88
+ deprecated="`postgres_host_writer` is deprecated, please use `pghost`",
89
+ default=None,
90
+ ),
91
+ ]
92
+ postgres_port: Annotated[
93
+ Optional[int],
94
+ Field(
95
+ deprecated="`postgres_port` is deprecated, please use `pgport`", default=None
96
+ ),
97
+ ]
98
+ postgres_dbname: Annotated[
99
+ Optional[str],
100
+ Field(
101
+ deprecated="`postgres_dbname` is deprecated, please use `pgdatabase`",
102
+ default=None,
103
+ ),
104
+ ]
105
+
106
+ pguser: str
107
+ pgpassword: str
108
+ pghost: str
109
+ pgport: int
110
+ pgdatabase: str
111
+
112
+ db_min_conn_size: int = 1
113
+ db_max_conn_size: int = 10
114
+ db_max_queries: int = 50000
115
+ db_max_inactive_conn_lifetime: float = 300
116
+
117
+ server_settings: ServerSettings = ServerSettings()
118
+
119
+ model_config = {"env_file": ".env", "extra": "ignore"}
120
+
121
+ @model_validator(mode="before")
122
+ @classmethod
123
+ def _pg_settings_compat(cls, data: Any) -> Any:
124
+ if isinstance(data, dict):
125
+ compat = {
126
+ "postgres_user": "pguser",
127
+ "postgres_pass": "pgpassword",
128
+ "postgres_host_reader": "pghost",
129
+ "postgres_host_writer": "pghost",
130
+ "postgres_port": "pgport",
131
+ "postgres_dbname": "pgdatabase",
132
+ }
133
+ for old_key, new_key in compat.items():
134
+ if val := data.get(old_key, None):
135
+ warnings.warn(
136
+ f"`{old_key}` is deprecated, please use `{new_key}`",
137
+ DeprecationWarning,
138
+ stacklevel=1,
139
+ )
140
+ data[new_key] = val
141
+
142
+ if (pgh_reader := data.get("postgres_host_reader")) and (
143
+ pgh_writer := data.get("postgres_host_writer")
144
+ ):
145
+ if pgh_reader != pgh_writer:
146
+ raise ValueError(
147
+ "In order to use different host values for reading and writing "
148
+ "you must explicitly provide write_postgres_settings to the connect_to_db function"
149
+ )
150
+
151
+ return data
152
+
153
+ @property
154
+ def connection_string(self):
155
+ """Create reader psql connection string."""
156
+ return f"postgresql://{self.pguser}:{quote(self.pgpassword)}@{self.pghost}:{self.pgport}/{self.pgdatabase}"
157
+
158
+
159
+ def str_to_list(value: Any) -> Any:
160
+ if isinstance(value, str):
161
+ return [v.strip() for v in value.split(",")]
162
+ return value
163
+
164
+
165
+ class Settings(ApiSettings):
166
+ """API settings.
167
+
168
+ Attributes:
169
+ prefix_path: An optional path prefix for the underyling FastAPI router.
170
+ use_api_hydrate: perform hydration of stac items within stac-fastapi.
171
+ invalid_id_chars: list of characters that are not allowed in item or collection ids.
172
+
173
+ """
174
+
175
+ prefix_path: str = ""
176
+ use_api_hydrate: bool = False
177
+ """
178
+ When USE_API_HYDRATE=TRUE, PgSTAC database will receive `NO_HYDRATE=TRUE`
179
+
180
+ | use_api_hydrate | nohydrate | Hydration |
181
+ | --- | --- | --- |
182
+ | False | False | PgSTAC |
183
+ | True | True | API |
184
+
185
+ ref: https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations
186
+ """
187
+ exclude_hydrate_markers: bool = True
188
+ """
189
+ In some case, PgSTAC can return `DO_NOT_MERGE_MARKER` markers (`𒍟※`).
190
+ If `EXCLUDE_HYDRATE_MARKERS=TRUE` and `USE_API_HYDRATE=TRUE`, stac-fastapi-pgstac
191
+ will exclude those values from the responses.
192
+ """
193
+
194
+ invalid_id_chars: List[str] = DEFAULT_INVALID_ID_CHARS
195
+ base_item_cache: Type[BaseItemCache] = DefaultBaseItemCache
196
+
197
+ validate_extensions: bool = False
198
+ """
199
+ Validate `stac_extensions` schemas against submitted data when creating or updated STAC objects.
200
+
201
+ Implies that the `Transactions` extension is enabled.
202
+ """
203
+
204
+ cors_origins: Annotated[Sequence[str], BeforeValidator(str_to_list)] = ("*",)
205
+ cors_origin_regex: Optional[str] = None
206
+ cors_methods: Annotated[Sequence[str], BeforeValidator(str_to_list)] = (
207
+ "GET",
208
+ "POST",
209
+ "OPTIONS",
210
+ )
211
+ cors_credentials: bool = False
212
+ cors_headers: Annotated[Sequence[str], BeforeValidator(str_to_list)] = (
213
+ "Content-Type",
214
+ )
215
+
216
+ testing: bool = False
217
+
218
+ @model_validator(mode="after")
219
+ def check_origins(self) -> Self:
220
+ if self.cors_origin_regex and "*" in self.cors_origins:
221
+ raise ValueError(
222
+ "Conflicting options found in API settings: `cors_origin_regex` and `*` in `cors_origins`"
223
+ )
224
+
225
+ return self