Skip to content

Commit

Permalink
Merge pull request #329 from canvas-medical/csande/docs-cors
Browse files Browse the repository at this point in the history
Fixed CORS issue with Swagger and Redoc; fixed a broken error check
csande authored Jan 26, 2025

Unverified

This commit is not signed, but one or more authors requires that any commit attributed to them is signed.
2 parents 7dc35a0 + ecaf608 commit b99e75c
Showing 7 changed files with 223 additions and 21 deletions.
5 changes: 5 additions & 0 deletions fhirstarter/examples/config.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
[app.external-documentation-examples]
enabled = true
cache-size = 2048 # number of entries
cache-ttl-hours = 6

[search-parameters.Patient.nickname]
type = "string"
description = "Nickname"
84 changes: 75 additions & 9 deletions fhirstarter/fhirstarter.py
Original file line number Diff line number Diff line change
@@ -15,16 +15,21 @@
from typing import (
Any,
Callable,
Collection,
Coroutine,
DefaultDict,
Dict,
List,
MutableMapping,
Set,
Union,
cast,
)
from urllib.parse import parse_qs, urlencode
from urllib.parse import parse_qs, urlencode, urlparse

import httpx
from asyncache import cachedmethod
from cachetools import TTLCache
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.exceptions import RequestValidationError
from pydantic.error_wrappers import display_errors
@@ -64,13 +69,6 @@
update_route_args,
)

# Suppress warnings from base fhir.resources class
logging.getLogger("fhir.resources.core.fhirabstractmodel").setLevel(logging.WARNING + 1)

CapabilityStatementModifier = Callable[
[MutableMapping[str, Any], Request, Response], MutableMapping[str, Any]
]

_INTERACTION_ORDER = {
"read": 1,
"vread": 2,
@@ -83,6 +81,19 @@
"search-type": 9,
}

_DEFAULT_EXTERNAL_EXAMPLES_ENABLED = True
_DEFAULT_EXTERNAL_EXAMPLES_CACHE_SIZE = 2_048
_DEFAULT_EXTERNAL_EXAMPLES_CACHE_TTL_HOURS = 6

# Suppress warnings from base fhir.resources class
logging.getLogger("fhir.resources.core.fhirabstractmodel").setLevel(logging.WARNING + 1)

CapabilityStatementModifier = Callable[
[MutableMapping[str, Any], Request, Response], MutableMapping[str, Any]
]

_HTTP_CLIENT = httpx.AsyncClient()


class FHIRStarter(FastAPI):
"""
@@ -118,6 +129,7 @@ def __init__(

self._search_parameters = SearchParameters(config.get("search-parameters"))
else:
config = {}
self._search_parameters = SearchParameters()

self._capabilities: DefaultDict[str, Dict[str, TypeInteraction]] = defaultdict(
@@ -131,6 +143,30 @@ def __init__(

self._add_capabilities_route()

# Set up the TTL cache to store external documentation examples
external_examples_config = config.get("app", {}).get(
"external-documentation-examples", {}
)
self._external_examples_enabled = external_examples_config.get(
"enabled", _DEFAULT_EXTERNAL_EXAMPLES_ENABLED
)
self._external_examples_cache = TTLCache(
maxsize=external_examples_config.get(
"cache-size", _DEFAULT_EXTERNAL_EXAMPLES_CACHE_SIZE
),
ttl=external_examples_config.get(
"cache-ttl-hours", _DEFAULT_EXTERNAL_EXAMPLES_CACHE_TTL_HOURS
)
* 3600,
)

# Add the external example proxy route
self._allowed_external_example_urls: Set[str] = set()
if self._external_examples_enabled and not (
"openapi_url" in kwargs and kwargs["openapi_url"] is None
):
self._add_external_example_proxy_route()

self.middleware("http")(_transform_search_type_post_request)
self.middleware("http")(_transform_null_response_body)
self.middleware("http")(_set_content_type_header)
@@ -380,7 +416,11 @@ def openapi(self) -> Dict[str, Any]:
return self.openapi_schema

openapi_schema = super().openapi()
adjust_schema(openapi_schema)
external_example_urls = adjust_schema(
openapi_schema, self._external_examples_enabled
)

self._allowed_external_example_urls = external_example_urls

return openapi_schema

@@ -411,6 +451,32 @@ def capability_statement_handler(
response_model_exclude_none=True,
)(capability_statement_handler)

def _add_external_example_proxy_route(self) -> None:
"""Add the /_example route to proxy external documentation examples."""

async def example(value: str) -> Dict[str, Any]:
return await self._example(value, self._allowed_external_example_urls)

self.get(
"/_example",
include_in_schema=False,
)(example)

@cachedmethod(
lambda self: self._external_examples_cache,
key=lambda app, url, allowed_urls: url,
)
async def _example(self, url: str, allowed_urls: Collection[str]) -> Dict[str, Any]:
"""Fetch the external documentation example if the provided URL is in the allow list."""
if urlparse(url).scheme != "https" or url not in allowed_urls:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

try:
response = await _HTTP_CLIENT.get(url)
return response.json()
except Exception:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

def _add_route(self, interaction: TypeInteraction[ResourceType]) -> None:
"""
Add a route based on the FHIR interaction type.
36 changes: 30 additions & 6 deletions fhirstarter/openapi.py
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@
List,
Mapping,
MutableMapping,
Set,
Tuple,
Union,
cast,
@@ -67,9 +68,12 @@ def _search_type_operations(
yield operation_id, operation


def adjust_schema(openapi_schema: MutableMapping[str, Any]) -> None:
def adjust_schema(
openapi_schema: MutableMapping[str, Any], include_external_examples: bool
) -> Set[str]:
"""
Adjust the OpenAPI schema to make it more FHIR-friendly.
Adjust the OpenAPI schema to make it more FHIR-friendly. Return a set containing all the URLs
for external documentation examples.
Remove some default schemas that are not needed nor used, and change all content types that
are set to "application/json" to instead be "application/fhir+json". Make a few additional
@@ -85,10 +89,14 @@ def adjust_schema(openapi_schema: MutableMapping[str, Any]) -> None:
# the tasks so that all the schemas are there for subsequent actions.
_add_schemas(openapi_schema)

examples = _get_examples(openapi_schema)
examples, external_example_urls = _get_examples(
openapi_schema, include_external_examples
)
for operation_id, operation in _operations(openapi_schema):
_adjust_operation(operation_id, operation, examples)

return external_example_urls


def _inline_search_post_schemas(openapi_schema: MutableMapping[str, Any]) -> None:
"""
@@ -138,14 +146,16 @@ def _add_schemas(openapi_schema: MutableMapping[str, Any]) -> None:


def _get_examples(
openapi_schema: MutableMapping[str, Any]
) -> Dict[str, Dict[str, Any]]:
openapi_schema: MutableMapping[str, Any],
include_external_examples: bool,
) -> Tuple[Dict[str, Dict[str, Any]], Set[str]]:
"""
Gather examples for all scenarios: request and response bodies for interactions;
resource-specific Bundle examples for search interactions; and OperationOutcome examples for
errors.
"""
examples: DefaultDict[str, Any] = defaultdict(dict)
external_example_urls = set()

# Get all resource examples from the models and the FHIR specification
for schema_name, schema in openapi_schema["components"]["schemas"].items():
@@ -165,6 +175,20 @@ def _get_examples(
examples["Bundle"][schema_name] = create_bundle_example(schema_example)
elif is_resource_type(resource_type):
resource_examples = load_examples(resource_type)

# If external examples are not to be included, remove them
if not include_external_examples:
example_keys = list(resource_examples.keys())
for example_key in example_keys:
if "externalValue" in resource_examples[example_key]:
del resource_examples[example_key]

# Replace all examples provided by an externalValue with a proxy URL
for example in resource_examples.values():
if value := example.get("externalValue"):
example["externalValue"] = f"/_example?value={value}"
external_example_urls.add(value)

examples[schema_name]["examples"] = resource_examples
examples["Bundle"][schema_name] = create_bundle_example(
next(iter(resource_examples.values()))["value"]
@@ -195,7 +219,7 @@ def _get_examples(
severity="error", code=code, details_text=details_text
)

return examples
return examples, external_example_urls


def _adjust_operation(
2 changes: 1 addition & 1 deletion fhirstarter/providers.py
Original file line number Diff line number Diff line change
@@ -208,7 +208,7 @@ def _check_resource_type_module(resource_type: Type[Resource]) -> None:
module
), f"Unable to determine FHIR sequence of resource {resource_type.get_resource_type()}"

if FHIR_SEQUENCE in ("R4", "R4"):
if FHIR_SEQUENCE in ("R4", "R5"):
assert not module.startswith(
("fhir.resources.STU3", "fhir.resources.R4B")
), f"Resource types from {module} cannot be used with FHIR sequence {FHIR_SEQUENCE}"
5 changes: 5 additions & 0 deletions fhirstarter/tests/config.py
Original file line number Diff line number Diff line change
@@ -149,6 +149,11 @@ def patient_search_type(
def app(provider: FHIRProvider) -> TestClient:
"""Create a FHIRStarter app, add the provider, reset the database, and return a TestClient."""
config_file_contents = """
[app.external-documentation-examples]
enabled = true
cache-size = 2048 # number of entries
cache-ttl-hours = 6
[search-parameters.Patient.nickname]
type = "string"
description = "Nickname"
105 changes: 102 additions & 3 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fhirstarter"
version = "2.4.2"
version = "2.4.3"
description = "An ASGI FHIR API framework built on top of FastAPI and FHIR Resources"
authors = ["Christopher Sande <christopher.sande@canvasmedical.com>"]
maintainers = ["Canvas Medical Engineering <engineering@canvasmedical.com>"]
@@ -36,8 +36,11 @@ classifiers = [

[tool.poetry.dependencies]
python = ">=3.8.0,<3.13.0"
asyncache = "^0.3.1"
cachetools = "^5.5.1"
fastapi = ">=0.103.1,<0.116.0"
"fhir.resources" = {version = ">=6.4.0", extras = ["xml"]}
httpx = ">=0.24.1,<0.29.0"
orjson = "^3.10.3"
pydantic = ">=1.10.12,<2.0.0"
python-multipart = ">=0.0.6,<0.0.21"
@@ -48,12 +51,12 @@ tzdata = { version = ">=2023.3,<2026.0", markers = "platform_system == 'Windows'
black = ">=23.7,<25.0"
beautifulsoup4 = "^4.12.2"
funcy = "^2.0"
httpx = ">=0.24.1,<0.29.0"
isort = "^5.12.0"
jsonpatch = "^1.33"
mypy = "^1.5.1"
pytest = ">=7.4,<9.0"
requests = "^2.31.0"
types-cachetools = "^5.5.0.20240820"
types-requests = "^2.31.0.2"
uvicorn = [
{ version = ">=0.23.2,<0.30.0", markers = "platform_system != 'Windows'", extras = ["standard"] },

0 comments on commit b99e75c

Please sign in to comment.