第08章 - stac-fastapi 服务开发

stac-fastapi 不是一个开箱可用的 STAC 服务,它是一个装配框架:把 HTTP 协议层(路由、参数解析、OpenAPI、错误映射)与业务层(怎么存、怎么查)拆开,中间用抽象基类对接。所以同一个 API 层可以接 PostgreSQL(pgstac)、接 Elasticsearch/OpenSearch,也可以接你自己写的内存字典。

这个设计的好处是:换后端不用改路由,加扩展不用改后端。代价是第一次读代码时会迷路——一个 /search 请求要穿过 request model、endpoint factory、client 方法、response model 四层,任何一层配错都表现为“参数没生效”。

这一章先把三层结构讲清楚,再用一个内存后端把整条链路跑通。这个后端只用于理解与测试,生产用第 9 章的 pgstac。

8.1 三层结构与两个抽象

8.1.1 包结构

7.1.1 把代码拆成三个独立发布的包:

包 职责 关键模块
stac-fastapi.types 抽象接口与数据模型 core.py(BaseCoreClient / AsyncBaseCoreClient)、search.py、stac.py、extension.py、config.py、conformance.py
stac-fastapi.api FastAPI 装配层 app.py(StacApi 工厂)、models.py、routes.py、errors.py、middleware.py
stac-fastapi.extensions 官方扩展实现 transaction、filter、sort、fields、query、pagination、aggregation、bulk_transactions、collection_search、free_text

历史沿革值得知道,因为网上大量示例还是旧结构:6.3.0 把 core 与 third_party 两个扩展目录合并到根;7.0.0 删除了 stac_fastapi.extensions.core 命名空间,扩展要从 stac_fastapi.extensions 直接导入。照着 5.x 的教程写 import 会直接 ImportError。

还有一个容易误解的点:没有 stac_fastapi.core 包。早期的 stac-fastapi-core 已经并入 types,抽象客户端类现在在 stac_fastapi.types.core 里。

8.1.2 请求穿过哪几层

以 POST /search 为例:

HTTP 请求
  ↓
[1] 路由:register_post_search() 注册 /search,response_class=GeoJSONResponse
  ↓
[2] 请求模型:search_post_request_model(默认 BaseSearchPostRequest)
     负责解析 bbox / intersects / datetime / limit / collections / ids
  ↓
[3] endpoint:create_async_endpoint(client.post_search, model)
     把模型实例化成 kwargs,调用 client 的方法
  ↓
[4] 业务:你的 AsyncBaseCoreClient.post_search(search_request, **kwargs)
  ↓
[5] 响应模型:api.ItemCollection(enable_response_models 为 true 时生效)
  ↓
JSON / GeoJSON

第 [2] 层决定哪些参数能被识别;扩展就是往这一层加字段(sort 加 sortby,filter 加 filter 与 filter-lang)。

8.1.3 两个基类

BaseCoreClient 是同步的,AsyncBaseCoreClient 是异步的,两者方法签名一致。必实现的抽象方法有六个:

方法 对应路由
get_search GET /search
post_search POST /search
get_item GET /collections/{collection_id}/items/{item_id}
all_collections GET /collections
get_collection GET /collections/{collection_id}
item_collection GET /collections/{collection_id}/items

landing_page 与 conformance 有默认实现,从 base_conformance_classes 与已注册扩展的 conformance_classes 拼出来,通常不用覆写。

注意 get_search 的签名里 limit 默认值是 10,这与规范一致(默认 10,上限 10000)。BaseSearchPostRequest 的 limit 用 AfterValidator 裁剪到 10000:

def crop(v: PositiveInt) -> PositiveInt:
    limit = 10_000
    if v > limit:
        v = limit
    return v

Limit = Annotated[PositiveInt, AfterValidator(crop)]

也就是说传 limit=999999 不会报错,会被静默裁到 10000。规范要求的行为正是“超过上限按上限处理,不报错”。

8.2 装配一个应用

8.2.1 最小装配

from fastapi import FastAPI
from stac_fastapi.api.app import StacApi
from stac_fastapi.types.config import ApiSettings

from mybackend import MemoryClient

settings = ApiSettings(
    stac_fastapi_title="我的影像目录",
    stac_fastapi_description="示例 STAC API",
    stac_fastapi_version="1.0.0",
    stac_fastapi_landing_id="my-catalog",
)

api = StacApi(
    settings=settings,
    client=MemoryClient(),
    extensions=[],
)

app: FastAPI = api.app

StacApi 的 attrs_post_init 做了一串事,顺序有意义:

# 1. settings 注入 client;2. 设置全局 settings 单例
self.client.title = self.title
self.client.description = self.description
Settings.set(self.settings)

# 3. 注册核心路由;4. 逐个注册扩展
self.register_core()
for ext in self.extensions:
    ext.register(self.app)

第 1 步意味着:client 的 title/description 由 StacApi 覆盖,你在 client 里设的会被冲掉。想让落地页显示正确标题,必须通过 settings 或 StacApi 的参数传。

8.2.2 默认中间件

不传 middlewares 时,StacApi 会装三个:

中间件 作用
BrotliMiddleware 响应压缩,JSON 通常能压到 1/5
CORSMiddleware 默认 allow_origins=[“*”]、methods=[“OPTIONS”,”POST”,”GET”]、headers=[“Content-Type”]、max_age=600
ProxyHeaderMiddleware 处理 X-Forwarded-*,让生成的链接用对外域名

CORS 的默认 headers 只有 Content-Type——这意味着浏览器带 Authorization 或 Range 的预检会失败。私有部署必须自己传 middlewares:

from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware

middlewares = [
    Middleware(
        CORSMiddleware,
        allow_origins=["https://app.example.com"],
        allow_methods=["GET", "POST", "OPTIONS"],
        allow_headers=["Content-Type", "Authorization", "Range"],
        expose_headers=["Content-Range", "Accept-Ranges"],
        max_age=600,
    ),
]

注意 7.0.0 移除了 stac_fastapi.api.middleware 里的 CORSMiddleware 子类,要用 starlette 的官方实现。

8.2.3 健康检查

StacApi 会自动加两个管理端点:/_mgmt/ping 返回 {“message”: “PONG”},/_mgmt/health 返回 health_check() 的结果,默认 {“status”: “UP”}。health_check 可以是同步或异步函数,生产上应该换成数据库探活(stac-fastapi-pgstac 自带 health_check,传给 StacApi 即可)。

8.3 写一个内存后端

下面这个实现覆盖六个核心方法,能跑通 GET/POST /search、集合与单条读取。它把数据放在字典里,用于理解链路与写单元测试。

# mybackend.py
from typing import Any

import attr
from stac_fastapi.types.core import AsyncBaseCoreClient
from stac_fastapi.types.stac import Item, ItemCollection, Collection, Collections
from stac_fastapi.types.search import BaseSearchPostRequest


def _bbox_hit(item_bbox, query_bbox) -> bool:
    if not item_bbox or not query_bbox:
        return True
    if len(query_bbox) == 4:
        w, s, e, n = query_bbox
    else:
        w, s, _, e, n, _ = query_bbox
    return not (item_bbox[2] < w or item_bbox[0] > e
                or item_bbox[3] < s or item_bbox[1] > n)


@attr.s
class MemoryClient(AsyncBaseCoreClient):
    """极简内存后端,仅用于演示与测试。"""

    collections: dict[str, dict] = attr.ib(factory=dict)
    items: dict[tuple[str, str], dict] = attr.ib(factory=dict)

    def seed(self, collection: dict, items: list[dict]) -> None:
        cid = collection["id"]
        self.collections[cid] = collection
        for it in items:
            self.items[(cid, it["id"])] = it

    async def all_collections(self, **kwargs) -> Collections:
        return Collections(
            collections=list(self.collections.values()),
            links=[],
            numberMatched=len(self.collections),
            numberReturned=len(self.collections),
        )

    async def get_collection(self, collection_id: str, **kwargs) -> Collection:
        if collection_id not in self.collections:
            raise NotFoundError(f"collection {collection_id} not found")
        return Collection(**self.collections[collection_id])

    async def get_item(self, item_id: str, collection_id: str, **kwargs) -> Item:
        key = (collection_id, item_id)
        if key not in self.items:
            raise NotFoundError(f"item {item_id} not found")
        return Item(**self.items[key])

    async def item_collection(
        self, collection_id: str, bbox=None, datetime=None,
        limit: int = 10, token: str | None = None, **kwargs,
    ) -> ItemCollection:
        rows = [v for (cid, _), v in self.items.items() if cid == collection_id]
        rows = [r for r in rows if _bbox_hit(r.get("bbox"), bbox)]
        return ItemCollection(
            type="FeatureCollection",
            features=rows[:limit],
            links=[],
            numberReturned=len(rows[:limit]),
            numberMatched=len(rows),
        )

    async def get_search(
        self, collections=None, ids=None, bbox=None, intersects=None,
        datetime=None, limit: int | None = 10, **kwargs,
    ) -> ItemCollection:
        rows = list(self.items.values())
        if collections:
            rows = [r for r in rows if r.get("collection") in collections]
        if ids:
            rows = [r for r in rows if r["id"] in ids]
        rows = [r for r in rows if _bbox_hit(r.get("bbox"), bbox)]
        n = limit or 10
        return ItemCollection(
            type="FeatureCollection",
            features=rows[:n],
            links=[],
            numberReturned=len(rows[:n]),
            numberMatched=len(rows),
        )

    async def post_search(
        self, search_request: BaseSearchPostRequest, **kwargs
    ) -> ItemCollection:
        return await self.get_search(
            collections=search_request.collections,
            ids=search_request.ids,
            bbox=search_request.bbox,
            intersects=search_request.intersects,
            datetime=search_request.datetime,
            limit=search_request.limit,
        )

几个实现细节:

用 attr.s 而不是 dataclass。 基类用 attrs 定义,子类也用 attrs 才能正确继承字段。混用 dataclass 会丢掉 base_conformance_classes 与 extensions 两个字段,落地页的 conformsTo 会空。

返回 stac_fastapi.types.stac 的模型。 这些是 TypedDict 风格的类型,不是 pydantic 模型,运行时不做校验;真正的校验发生在 enable_response_models 打开时,由 stac_pydantic 的模型做。

异常映射。 抛 NotFoundError 会被 DEFAULT_STATUS_CODES 映射成 404。自定义异常要同时注册映射,否则变成 500。

8.3.1 启动

# main.py
from fastapi import FastAPI
from stac_fastapi.api.app import StacApi
from stac_fastapi.types.config import ApiSettings

from mybackend import MemoryClient

settings = ApiSettings(stac_fastapi_title="内存示例")
client = MemoryClient()
client.seed(
    collection={
        "type": "Collection", "stac_version": "1.1.0", "id": "demo",
        "description": "示例集合", "license": "CC-BY-4.0",
        "extent": {"spatial": {"bbox": [[100, 20, 110, 30]]},
                   "temporal": {"interval": [["2023-01-01T00:00:00Z", None]]}},
        "links": [],
    },
    items=[{
        "type": "Feature", "stac_version": "1.1.0", "id": "demo-001", "collection": "demo",
        "geometry": {"type": "Polygon", "coordinates": [
            [[100, 20], [110, 20], [110, 30], [100, 30], [100, 20]]]},
        "bbox": [100, 20, 110, 30],
        "properties": {"datetime": "2023-05-01T00:00:00Z"}, "links": [], "assets": {},
    }],
)

api = StacApi(settings=settings, client=client, extensions=[])
app: FastAPI = api.app
uvicorn main:app --reload --port 8082
curl -s "http://localhost:8082/search?limit=1" | python -m json.tool
# {
#     "type": "FeatureCollection",
#     "features": [{"type": "Feature", "id": "demo-001", ...}],
#     "links": [],
#     "numberReturned": 1,
#     "numberMatched": 1
# }

到这里链路已经通了。接下来看扩展怎么接进来。

8.4 扩展机制

8.4.1 扩展做了什么

一个 ApiExtension 至少要有 register(app) 方法,通常还会带:

属性 作用
GET / POST 该扩展给请求模型加的字段(pydantic / attrs 模型)
conformance_classes 会出现在 /conformance 与落地页 conformsTo 里
schema_href 扩展的 JSON Schema 地址
register(app) 注册路由、往请求模型注入字段

以 TransactionExtension 为例,它的 register 会挂八个路由:

POST|PUT|PATCH|DELETE  /collections
POST|PUT|PATCH|DELETE  /collections/{collection_id}/items[/{item_id}]

注意它同时实现了 Items 与 Collections 两套 conformance URI:

class TransactionConformanceClasses(StrEnum):
    ITEMS = "https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction"
    COLLECTIONS = "https://api.stacspec.org/v1.0.0/collections/extensions/transaction"

8.4.2 挂上事务扩展

from stac_fastapi.extensions import TransactionExtension
from stac_fastapi.api.models import JSONResponse

from mybackend import MemoryTransactionsClient

api = StacApi(
    settings=settings,
    client=client,
    extensions=[
        TransactionExtension(
            client=MemoryTransactionsClient(store=client),
            settings=settings,
            response_class=JSONResponse,
        )
    ],
)

MemoryTransactionsClient 要继承 BaseTransactionsClient 并实现八个方法。签名容易记错,尤其是 delete_item:

def create_item(self, collection_id: str, item: Item, **kwargs): ...
def update_item(self, collection_id: str, item_id: str, item: Item, **kwargs): ...
def patch_item(self, collection_id: str, item_id: str, patch, **kwargs): ...
def delete_item(self, item_id: str, collection_id: str, **kwargs): ...   # 注意顺序
def create_collection(self, collection: Collection, **kwargs): ...
def update_collection(self, collection_id: str, collection: Collection, **kwargs): ...
def patch_collection(self, collection_id: str, patch, **kwargs): ...
def delete_collection(self, collection_id: str, **kwargs): ...

delete_item 的参数顺序是 (item_id, collection_id),与 get_item 的 (item_id, collection_id) 一致,但与 update_item 的 (collection_id, item_id, item) 相反。这是源码里的既成事实,不要“顺手改对”。

8.4.3 事务扩展的 Location 头

7.1.0 修了一个规范符合性问题:POST 创建单个 Item 或 Collection 时,要返回 Location 头指向新资源。实现里包了一层(下面省略了状态码与异常分支):

async def create_item(request, response, request_data=Depends(PostItem)):
    item = await endpoint(request, request_data)
    # 单条 Item 成功创建时补 Location;ItemCollection 不补
    if request_data.item.type == "Feature" and "location" not in response.headers:
        response.headers["Location"] = _location_url(
            request, "Get Item", collection_id=..., item_id=item["id"])
    return item

如果你的后端在 create_item 里直接返回了 Response 对象(而不是 dict),Location 头会加到那个 Response 上;返回 dict 时加到框架的 response 上。两种都支持,但混用容易漏。

8.4.4 写一个自定义扩展

假设要给落地页加一个 /stats 端点,返回集合数:

import attr
from fastapi import FastAPI
from stac_fastapi.types.extension import ApiExtension

@attr.s
class StatsExtension(ApiExtension):
    client: object = attr.ib(default=None)

    conformance_classes: list[str] = attr.ib(default=[])
    schema_href: str | None = attr.ib(default=None)

    def register(self, app: FastAPI) -> None:
        @app.get("/stats")
        async def stats():
            return {"collections": len(self.client.collections)}

api = StacApi(settings=settings, client=client, extensions=[StatsExtension(client=client)])

注意两点:conformance_classes 用 attr.ib 而不是类属性,否则 attrs 生成的 init 会漏掉它;register 里直接 app.get 会绕开 StacApi 的 response_class,需要统一响应格式时用 APIRouter 加 response_class。

8.5 配置与环境变量

ApiSettings 的字段全部可以用环境变量覆盖(pydantic-settings,大小写不敏感):

字段 环境变量 默认 说明
stac_fastapi_title STAC_FASTAPI_TITLE stac-fastapi 落地页标题
stac_fastapi_description STAC_FASTAPI_DESCRIPTION stac-fastapi 描述
stac_fastapi_version STAC_FASTAPI_VERSION 0.1 API 版本
stac_fastapi_landing_id STAC_FASTAPI_LANDING_ID stac-fastapi 落地页 id
enable_response_models ENABLE_RESPONSE_MODELS false 输出校验
enable_direct_response ENABLE_DIRECT_RESPONSE false 跳过序列化
openapi_url OPENAPI_URL /api OpenAPI 文档
docs_url DOCS_URL /api.html Swagger UI
root_path ROOT_PATH 空 反向代理前缀

用默认值启动时日志会警告 “Using default values for stac_fastapi_title, … This may impact API discoverability.”(6.4.0 加入),否则所有实例在 STAC Index 里都叫 stac-fastapi。

两个 flag 互斥:同时开会在启动时抛 ValueError。enable_response_models 让 FastAPI 用 stac_pydantic 校验输出(慢但安全),enable_direct_response 直接返回 Response(快但绕过校验)。

8.6 排错

现象 原因 修法
落地页 conformsTo 是空的 client 用了 dataclass,丢了 base_conformance_classes 改用 attr.s
加了 sort 扩展但 sortby 不生效 扩展没加到 search 请求模型 用 create_request_model 或在 StacApi 传 search_post_request_model
所有请求 500,日志报 Settings have not yet been set 直接实例化 client 调方法,没经过 StacApi 先构造 StacApi,或手动 Settings.set()
/search 的 limit 传 20000 返回 10000 正常行为,crop 到上限 无需处理
反向代理后链接是 http://内部IP 没装 ProxyHeaderMiddleware 或没设 root_path 加中间件 + 设 ROOT_PATH
ImportError: cannot import name ‘X’ from ‘stac_fastapi.extensions.core’ 7.0.0 删了该命名空间 从 stac_fastapi.extensions 直接导入
CORS 预检失败 默认 allow_headers 只有 Content-Type 自定义 CORSMiddleware

第三条值得展开:BaseCoreClient 在模块导入时就实例化了 ApiSettings()(api_settings = ApiSettings()),但真正的 Settings.set() 发生在 StacApi 初始化时。如果写单元测试直接 new 一个 client 调 landing_page,会因为没有 request 对象而失败;正确做法是用 TestClient 走 HTTP。

8.7 生产建议

用工厂函数而不是模块级 app。 uvicorn 的 –factory 模式支持延迟初始化,配合 lifespan 管理连接池:

def create_app() -> FastAPI:
    api = StacApi(settings=Settings(), client=CoreClient(), extensions=[...])
    return api.app
uvicorn myapp:create_app --factory --workers 4

worker 数与连接池要对得上。 每个 worker 一个连接池,DB_MAX_CONN_SIZE=10、4 个 worker 就是 40 个连接,Postgres 的 max_connections 默认 100,要留余量。

Brotli 有 CPU 成本。 JSON 压缩比 1/5 到 1/10,但 /search 返回大 FeatureCollection 时会吃 CPU,放到反向代理层做更划算。

给 /search 加超时。 FastAPI 没有请求超时,慢查询会一直占着连接;用 proxy_read_timeout 或 statement_timeout 兜底。

健康检查要探到数据库。 /_mgmt/ping 只证明进程活着,负载均衡不该据此判断可用性。

8.8 本章小结

要点 结论
三层结构 types(抽象)、api(装配)、extensions(扩展)
必实现方法 六个核心方法,同步用 BaseCoreClient,异步用 AsyncBaseCoreClient
装配顺序 注入 settings → 注册核心路由 → 注册扩展 → 中间件
扩展机制 ApiExtension.register(app),可带 GET/POST 模型与 conformance
默认 CORS 只允许 Content-Type,私有部署必须自定义
版本陷阱 7.0.0 删除 extensions.core 命名空间,没有 stac_fastapi.core 包

下一章接 PostgreSQL:pgstac 的表结构、分区策略、迁移与 SQL 函数,以及它为什么能在亿级 Item 上保持检索性能。