Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7c02860
feature/1 added runtime configuration of CORS
captaincoordinates Jan 25, 2022
602e7b5
feature/1 updated documentation
captaincoordinates Jan 26, 2022
d2bc362
feature/1 doc updates
captaincoordinates Jan 26, 2022
1e4edd1
feature/1 PR feedback and additional test
captaincoordinates Jan 27, 2022
15c0866
feature/1 removed unwanted async on test
captaincoordinates Jan 29, 2022
1a3c55b
feature/1 updated with PR feedback from stac-fastapi
captaincoordinates Feb 1, 2022
ee69347
feature/1 updated documentation
captaincoordinates Feb 1, 2022
939ae13
feature/1 updated documentation
captaincoordinates Feb 1, 2022
973c68c
Merge branch 'master' of https://github.com/stac-utils/stac-fastapi i…
captaincoordinates Feb 1, 2022
1007d52
feature/1 follow pydantic configuration standard
captaincoordinates Feb 2, 2022
9aeb28b
feature/1 fix docs build
captaincoordinates Feb 2, 2022
1d47323
Merge remote-tracking branch 'upstream/master' into feature/1
captaincoordinates Feb 18, 2022
3ff4d10
feature/1 add CORS tests to api tests
captaincoordinates Feb 18, 2022
6f99709
feature/1 removed unnecessary tests
captaincoordinates Feb 18, 2022
2569aee
feature/1 added runtime configuration of CORS
captaincoordinates Jan 25, 2022
4162933
feature/1 updated documentation
captaincoordinates Jan 26, 2022
8736d15
feature/1 PR feedback and additional test
captaincoordinates Jan 27, 2022
1cc8506
feature/1 removed unwanted async on test
captaincoordinates Jan 29, 2022
f0e69b5
feature/1 updated with PR feedback from stac-fastapi
captaincoordinates Feb 1, 2022
76f467c
feature/1 updated documentation
captaincoordinates Feb 1, 2022
0dc532f
feature/1 updated documentation
captaincoordinates Feb 1, 2022
205eb6f
feature/1 follow pydantic configuration standard
captaincoordinates Feb 2, 2022
bfffddd
feature/1 fix docs build
captaincoordinates Feb 2, 2022
a0fa5fc
feature/1 add CORS tests to api tests
captaincoordinates Feb 18, 2022
99fdd85
feature/1 removed unnecessary tests
captaincoordinates Feb 18, 2022
8699c34
Fix intermittent error while loading test data
moradology Apr 19, 2022
e23b37e
Merge branch 'master' of https://github.com/stac-utils/stac-fastapi i…
captaincoordinates Apr 28, 2022
a749a6a
Merge branch 'feature/1' of https://github.com/moradology/stac-fastap…
captaincoordinates Apr 28, 2022
b0e740e
Rename settings (#7)
moradology May 13, 2022
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,7 @@ docs/api/*
.envrc

# Virtualenv
venv
venv

# IDE
.vscode
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

* Added ability to configure CORS middleware via environment variables ([#341](https://github.com/stac-utils/stac-fastapi/pull/341))

### Changed

### Removed
Expand Down
13 changes: 4 additions & 9 deletions docs/tips-and-tricks.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,15 @@ This page contains a few 'tips and tricks' for getting stac-fastapi working in v
CORS (Cross-Origin Resource Sharing) support may be required to use stac-fastapi in certain situations. For example, if you are running
[stac-browser](https://github.com/radiantearth/stac-browser) to browse the STAC catalog created by stac-fastapi, then you will need to enable CORS support.

To do this, edit `stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py` (or the equivalent in the `pgstac` folder) and add the following import:

To do this, configure environment variables for the configuration options described in [FastAPI docs](https://fastapi.tiangolo.com/tutorial/cors/) using a `cors_` prefix e.g.
```
from fastapi.middleware.cors import CORSMiddleware
cors_allow_credentials=true [or 1]
```

and then edit the `api = StacApi(...` call to add the following parameter:

Sequences, such as `allow_origins`, should be in JSON format e.g.
```
middlewares=[lambda app: CORSMiddleware(app, allow_origins=["*"])]
cors_allow_origins='["http://domain.one", "http://domain.two"]'
```

If needed, you can edit the `allow_origins` parameter to only allow CORS requests from specific origins.

## Enable the Context extension
The Context STAC extension provides information on the number of items matched and returned from a STAC search. This is required by various other STAC-related tools, such as the pystac command-line client. To enable the extension, edit `stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py` (or the equivalent in the `pgstac` folder) and add the following import:

Expand Down
5 changes: 4 additions & 1 deletion stac_fastapi/api/stac_fastapi/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from starlette.responses import JSONResponse, Response

from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from stac_fastapi.api.middleware import CORSMiddleware
from stac_fastapi.api.models import (
APIRequest,
CollectionUri,
Expand Down Expand Up @@ -87,7 +88,9 @@ class StacApi:
)
pagination_extension = attr.ib(default=TokenPaginationExtension)
response_class: Type[Response] = attr.ib(default=JSONResponse)
middlewares: List = attr.ib(default=attr.Factory(lambda: [BrotliMiddleware]))
middlewares: List = attr.ib(
default=attr.Factory(lambda: [BrotliMiddleware, CORSMiddleware])
)

def get_extension(self, extension: Type[ApiExtension]) -> Optional[ApiExtension]:
"""Get an extension.
Expand Down
21 changes: 21 additions & 0 deletions stac_fastapi/api/stac_fastapi/api/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""Application settings."""
import enum
from logging import getLogger
from typing import Final, Sequence

from pydantic import BaseSettings, Field

logger: Final = getLogger(__file__)


# TODO: Move to stac-pydantic
Expand All @@ -22,3 +28,18 @@ class AddOns(enum.Enum):
"""Enumeration of available third party add ons."""

bulk_transaction = "bulk-transaction"


class Settings(BaseSettings):
"""API settings."""

allow_origins: Sequence[str] = Field(("*",), env="cors_allow_origins")
allow_methods: Sequence[str] = Field(("*",), env="cors_allow_methods")
allow_headers: Sequence[str] = Field(("*",), env="cors_allow_headers")
allow_credentials: bool = Field(False, env="cors_allow_credentials")
allow_origin_regex: str = Field(None, env="cors_allow_origin_regex")
expose_headers: Sequence[str] = Field(("*",), env="cors_expose_headers")
max_age: int = Field(600, env="cors_max_age")


settings: Final = Settings()
74 changes: 73 additions & 1 deletion stac_fastapi/api/stac_fastapi/api/middleware.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
"""api middleware."""

from typing import Callable
from logging import getLogger
from typing import Callable, Final, Optional, Sequence

from fastapi import APIRouter, FastAPI
from fastapi.middleware import cors
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.routing import Match
from starlette.types import ASGIApp

from stac_fastapi.api.config import settings

logger: Final = getLogger(__file__)


def router_middleware(app: FastAPI, router: APIRouter):
Expand All @@ -29,3 +36,68 @@ async def _middleware(request: Request, call_next):
return func

return deco


class CORSMiddleware(cors.CORSMiddleware):
"""Starlette CORS Middleware with configuration."""

def __init__(
self,
app: ASGIApp,
allow_origins: Optional[Sequence[str]] = None,
allow_methods: Optional[Sequence[str]] = None,
allow_headers: Optional[Sequence[str]] = None,
allow_credentials: Optional[bool] = None,
allow_origin_regex: Optional[str] = None,
expose_headers: Optional[Sequence[str]] = None,
max_age: Optional[int] = None,
) -> None:
"""Create CORSMiddleware Object."""
allow_origins = (
settings.allow_origins if allow_origins is None else allow_origins
)
allow_methods = (
settings.allow_methods if allow_methods is None else allow_methods
)
allow_headers = (
settings.allow_headers if allow_headers is None else allow_headers
)
allow_credentials = (
settings.allow_credentials
if allow_credentials is None
else allow_credentials
)
allow_origin_regex = (
settings.allow_origin_regex
if allow_origin_regex is None
else allow_origin_regex
)
if allow_origin_regex is not None:
logger.info("allow_origin_regex present and will override allow_origins")
allow_origins = ""
expose_headers = (
settings.expose_headers if expose_headers is None else expose_headers
)
max_age = settings.max_age if max_age is None else max_age
logger.debug(
f"""
CORS configuration
allow_origins: {allow_origins}
allow_methods: {allow_methods}
allow_headers: {allow_headers}
allow_credentials: {allow_credentials}
allow_origin_regex: {allow_origin_regex}
expose_headers: {expose_headers}
max_age: {max_age}
"""
)
super().__init__(
app,
allow_origins=allow_origins,
allow_methods=allow_methods,
allow_headers=allow_headers,
allow_credentials=allow_credentials,
allow_origin_regex=allow_origin_regex,
expose_headers=expose_headers,
max_age=max_age,
)
60 changes: 60 additions & 0 deletions stac_fastapi/pgstac/tests/api/cors_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from copy import deepcopy
from json import dumps
from typing import Final

from stac_fastapi.api.config import settings

settings_fallback = deepcopy(settings)
cors_origin_1: Final = "http://permit.one"
cors_origin_2: Final = "http://permit.two"
cors_origin_3: Final = "http://permit.three"
cors_origin_deny: Final = "http://deny.me"


def cors_permit_1():
settings.allow_origins = dumps((cors_origin_1,))


def cors_permit_2():
settings.allow_origins = dumps((cors_origin_2,))


def cors_permit_3():
settings.allow_origins = dumps((cors_origin_3,))


def cors_permit_12():
settings.allow_origins = dumps((cors_origin_1, cors_origin_2))


def cors_permit_123_regex():
settings.allow_origin_regex = "http\\://permit\\..+"


def cors_deny():
settings.allow_origins = dumps((cors_origin_deny,))


def cors_disable_get():
settings.allow_methods = dumps(
(
"HEAD",
"POST",
"PUT",
"DELETE",
"CONNECT",
"OPTIONS",
"TRACE",
"PATCH",
)
)


def cors_clear_config():
settings.allow_origins = settings_fallback.allow_origins
settings.allow_methods = settings_fallback.allow_methods
settings.allow_headers = settings_fallback.allow_headers
settings.allow_credentials = settings_fallback.allow_credentials
settings.allow_origin_regex = settings_fallback.allow_origin_regex
settings.expose_headers = settings_fallback.expose_headers
settings.max_age = settings_fallback.max_age
65 changes: 65 additions & 0 deletions stac_fastapi/pgstac/tests/api/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
from datetime import datetime, timedelta
from http import HTTPStatus

import pytest
from tests.api.cors_support import (
cors_clear_config,
cors_deny,
cors_origin_1,
cors_origin_deny,
cors_permit_1,
cors_permit_12,
cors_permit_123_regex,
)

STAC_CORE_ROUTES = [
"GET /",
Expand All @@ -23,6 +33,10 @@
]


def teardown_function():
cors_clear_config()


@pytest.mark.asyncio
async def test_post_search_content_type(app_client):
params = {"limit": 1}
Expand Down Expand Up @@ -302,3 +316,54 @@ async def test_search_line_string_intersects(
assert resp.status_code == 200
resp_json = resp.json()
assert len(resp_json["features"]) == 1


@pytest.mark.asyncio
async def test_with_default_cors_origin(app_client):
resp = await app_client.get("/", headers={"Origin": cors_origin_1})
assert resp.status_code == HTTPStatus.OK
assert resp.headers["access-control-allow-origin"] == "*"


@pytest.mark.asyncio
@pytest.mark.parametrize("app_client", [{"setup_func": cors_permit_1}], indirect=True)
async def test_with_match_cors_single(app_client):
resp = await app_client.get("/", headers={"Origin": cors_origin_1})
assert resp.status_code == HTTPStatus.OK
assert resp.headers["access-control-allow-origin"] == cors_origin_1


@pytest.mark.asyncio
@pytest.mark.parametrize("app_client", [{"setup_func": cors_permit_12}], indirect=True)
async def test_with_match_cors_double(app_client):
resp = await app_client.get("/", headers={"Origin": cors_origin_1})
assert resp.status_code == HTTPStatus.OK
assert resp.headers["access-control-allow-origin"] == cors_origin_1


@pytest.mark.asyncio
@pytest.mark.parametrize(
"app_client", [{"setup_func": cors_permit_123_regex}], indirect=True
)
async def test_with_match_cors_all_regex_match(app_client):
resp = await app_client.get("/", headers={"Origin": cors_origin_1})
assert resp.status_code == HTTPStatus.OK
assert resp.headers["access-control-allow-origin"] == cors_origin_1


@pytest.mark.asyncio
@pytest.mark.parametrize(
"app_client", [{"setup_func": cors_permit_123_regex}], indirect=True
)
async def test_with_match_cors_all_regex_mismatch(app_client):
resp = await app_client.get("/", headers={"Origin": cors_origin_deny})
assert resp.status_code == HTTPStatus.OK
assert "access-control-allow-origin" not in resp.headers


@pytest.mark.asyncio
@pytest.mark.parametrize("app_client", [{"setup_func": cors_deny}], indirect=True)
async def test_with_mismatch_cors_origin(app_client):
resp = await app_client.get("/", headers={"Origin": cors_origin_1})
assert resp.status_code == HTTPStatus.OK
assert "access-control-allow-origin" not in resp.headers
25 changes: 12 additions & 13 deletions stac_fastapi/pgstac/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
import json
import os
import time
from typing import Callable, Dict

import asyncpg
Expand Down Expand Up @@ -92,8 +91,7 @@ async def pgstac(pg):
await conn.close()


@pytest.fixture(scope="session")
def api_client(pg):
def _api_client_provider():
print("creating client with settings")

extensions = [
Expand All @@ -118,23 +116,24 @@ def api_client(pg):
return api


@pytest.mark.asyncio
@pytest.fixture(scope="session")
async def app(api_client):
time.time()
app = api_client.app
await connect_to_db(app)

yield app

await close_db_connection(app)
def api_client(pg):
return _api_client_provider()


@pytest.mark.asyncio
@pytest.fixture(scope="session")
async def app_client(app):
async def app_client(pg, request):
# support custom behaviours driven by fixture caller
if hasattr(request, "param"):
setup_func = request.param.get("setup_func")
if setup_func is not None:
setup_func()
app = _api_client_provider().app
async with AsyncClient(app=app, base_url="http://test") as c:
await connect_to_db(app)
yield c
await close_db_connection(app)


@pytest.fixture
Expand Down
Loading