Skip to content

Commit 8f086cb

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/1170-merge-nvi-beheer-db
2 parents 132ad82 + e91f794 commit 8f086cb

62 files changed

Lines changed: 2444 additions & 1605 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app.conf.example‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,14 @@ mtls_key=secrets/ssl/crypto_service_api.key
4949
verify_ca=secrets/ssl/crypto_service_api_ca.cert
5050

5151
[telemetry]
52-
# Telemetry is enabled or not
53-
enabled = False
52+
# Push NVI usage metrics over OTLP or not
53+
metrics_enabled = False
5454
# Endpoint to GRPC server for telemetry
55-
endpoint = http://tracing:4317
55+
endpoint = http://otel-collector:4317
5656
# Service name to use
57-
service_name = Nationale Verwijsindex
58-
# Tracer name to use
59-
tracer_name = zmodules.service.national_referral_index
60-
61-
[stats]
62-
# Statsd is enabled or not
63-
enabled = False
64-
# Host for statsd server
65-
host = 127.0.0.1
66-
# Port for statsd server
67-
port = 8125
68-
# Module name for statsd
69-
module_name = localisation
57+
service_name = nationale-verwijsindex
58+
# Interval in milliseconds between metric exports
59+
metrics_export_interval_ms = 15000
7060

7161
[uvicorn]
7262
# If true, the api docs will be enabled
@@ -98,3 +88,9 @@ ssl_key_file = server.key
9888
# space separated list of expected audiences, one should be present in the authorization authorization header
9989
# usually the value is the domain of the running application
10090
expected_audiences = https://localhost:8501
91+
92+
[pagination]
93+
# Number of entries returned per page when a search does not specify `_count`
94+
default_page_size = 50
95+
# Upper limit for `_count`, larger values are clamped to this value
96+
max_page_size = 100

‎app/application.py‎

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import logging
33
import os
4-
from collections.abc import AsyncIterator
4+
from collections.abc import AsyncIterator, Iterator
55
from contextlib import asynccontextmanager
66
from pathlib import Path
77
from typing import Any
@@ -13,21 +13,22 @@
1313
from gfmodules.logging.middleware import RequestContextMiddleware
1414

1515
from app import container
16-
from app.auth import get_auth_ctx
16+
from app.auth import auth_log_context, get_auth_ctx
1717
from app.config import (
1818
_ENVIRONMENT_CONFIG_PATH_NAME,
1919
_PATH,
2020
get_config,
2121
)
2222
from app.errors.handlers import register_exceptions
23+
from app.logging.context import CLAIM_FIELDS
2324
from app.logging.events import Log
25+
from app.models.fhir.resources.localization_list.request import COUNT_PARAM
2426
from app.routers.default import router as default_router
2527
from app.routers.fhir.base import router as fhir_base_router
2628
from app.routers.fhir.localization_list import router as fhir_list_router
2729
from app.routers.health import router as health_router
2830
from app.routers.localize import router as localization_router
2931
from app.routers.registrations import router as registrations_router
30-
from app.stats import StatsdMiddleware
3132

3233
logger = logging.getLogger(__name__)
3334

@@ -83,7 +84,12 @@ def create_fastapi_app() -> FastAPI:
8384

8485
def setup_logging() -> None:
8586
config = get_config()
86-
gflog.configure(config=config.logging, loglevel=config.app.loglevel, catalogue=Log)
87+
gflog.configure(
88+
config=config.logging,
89+
loglevel=config.app.loglevel,
90+
catalogue=Log,
91+
extra_context_fields=CLAIM_FIELDS,
92+
)
8793

8894

8995
def _read_version() -> str:
@@ -123,6 +129,42 @@ def api_key_headers(document_gf_headers: bool) -> list[Any]:
123129
return [Security(APIKeyHeader(name=header, scheme_name=header, auto_error=False)) for header in headers]
124130

125131

132+
def _count_params(schema: dict[str, Any]) -> Iterator[dict[str, Any]]:
133+
for operations in schema.get("paths", {}).values():
134+
for operation in operations.values():
135+
yield from (
136+
param
137+
for param in operation.get("parameters", [])
138+
if param.get("in") == "query" and param.get("name") == COUNT_PARAM
139+
)
140+
141+
142+
def _set_page_sizes(param: dict[str, Any], default_page_size: int, max_page_size: int) -> None:
143+
param["description"] = (
144+
f"Number of entries per page. Defaults to {default_page_size}, larger values are capped at {max_page_size}"
145+
)
146+
param["schema"]["default"] = default_page_size
147+
for option in param["schema"].get("anyOf", [param["schema"]]):
148+
if option.get("type") == "integer":
149+
option["maximum"] = max_page_size
150+
151+
152+
def _document_page_sizes(app: FastAPI, default_page_size: int, max_page_size: int) -> None:
153+
"""
154+
Shows the configured page sizes on the `_count` parameter in the OpenAPI schema. The maximum is not
155+
validated on the parameter itself: FHIR treats `_count` as a hint, so a larger value is capped instead of rejected.
156+
"""
157+
generate = app.openapi
158+
159+
def openapi() -> dict[str, Any]:
160+
if app.openapi_schema is None:
161+
for param in _count_params(generate()):
162+
_set_page_sizes(param, default_page_size, max_page_size)
163+
return app.openapi_schema # type: ignore[return-value]
164+
165+
app.openapi = openapi # type: ignore[method-assign]
166+
167+
126168
def setup_fastapi() -> FastAPI:
127169
config = get_config()
128170
fastapi = (
@@ -152,12 +194,10 @@ def setup_fastapi() -> FastAPI:
152194
fastapi.include_router(router)
153195

154196
for router in routers:
155-
fastapi.include_router(router, dependencies=[Depends(get_auth_ctx)])
197+
fastapi.include_router(router, dependencies=[Depends(get_auth_ctx), Depends(auth_log_context)])
156198

157199
register_exceptions(fastapi)
158-
159-
if config.stats.enabled:
160-
fastapi.add_middleware(StatsdMiddleware, module_name=config.stats.module_name or "default")
200+
_document_page_sizes(fastapi, config.pagination.default_page_size, config.pagination.max_page_size)
161201

162202
fastapi.add_middleware(
163203
RequestContextMiddleware,

‎app/auth.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import logging
2+
from collections.abc import AsyncIterator
23
from typing import Annotated
34

45
from fastapi import Depends
56
from starlette.requests import Request
67

78
from app import dependencies
9+
from app.logging.context import bind_auth_claims
810
from app.models.auth.context import AuthContext, AuthenticationClaims
911
from app.models.auth.headers import AuthHeaders
1012
from app.models.ura import UraNumber
@@ -40,6 +42,7 @@ def get_auth_ctx(
4042
ura_number=UraNumber(validated_auth_headers.ura),
4143
source_id=validated_auth_headers.source_id,
4244
oin=validated_auth_headers.oin,
45+
acting_organization_name=validated_auth_headers.acting_organization_name,
4346
organization_name=validated_auth_headers.organization_name,
4447
)
4548
ctx = AuthContext(
@@ -49,3 +52,8 @@ def get_auth_ctx(
4952
)
5053
request.state.auth = ctx
5154
return ctx
55+
56+
57+
async def auth_log_context(ctx: Annotated[AuthContext, Depends(get_auth_ctx)]) -> AsyncIterator[None]:
58+
with bind_auth_claims(ctx):
59+
yield

‎app/config.py‎

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import configparser
22
import logging
33
import os
4+
import socket
45
from enum import Enum
5-
from typing import Any
6+
from typing import Any, Self
7+
from uuid import uuid4
68

79
from gfmodules.logging import ConfigLogging as GFConfigLogging
810
from gfmodules.logging.ini import split_comma_separated
9-
from pydantic import BaseModel, Field, SecretStr, ValidationError, field_validator
11+
from pydantic import BaseModel, Field, SecretStr, ValidationError, field_validator, model_validator
1012

1113
logger = logging.getLogger(__name__)
1214

@@ -67,18 +69,16 @@ class ConfigUvicorn(BaseModel):
6769
root_path: str = Field(default="")
6870

6971

72+
def process_instance_id() -> str:
73+
return f"{socket.gethostname()}-{uuid4().hex[:8]}"
74+
75+
7076
class ConfigTelemetry(BaseModel):
71-
enabled: bool = Field(default=False)
77+
metrics_enabled: bool = Field(default=False)
7278
endpoint: str | None = None
7379
service_name: str | None = None
74-
tracer_name: str | None = None
75-
76-
77-
class ConfigStats(BaseModel):
78-
enabled: bool = Field(default=False)
79-
host: str | None = None
80-
port: int | None = None
81-
module_name: str | None = None
80+
instance_id: str = Field(default_factory=process_instance_id)
81+
metrics_export_interval_ms: int = Field(default=15000, gt=0)
8282

8383

8484
class ConfigAuthorizationHeaders(BaseModel):
@@ -96,6 +96,17 @@ def validate_aud(cls, data: Any) -> list[str]:
9696
raise ValueError("Invalid input on `expected_audience`, please check config")
9797

9898

99+
class ConfigPagination(BaseModel):
100+
default_page_size: int = Field(default=50, ge=1)
101+
max_page_size: int = Field(default=100, ge=1)
102+
103+
@model_validator(mode="after")
104+
def validate_page_sizes(self) -> Self:
105+
if self.default_page_size > self.max_page_size:
106+
raise ValueError("`default_page_size` cannot be larger than `max_page_size`")
107+
return self
108+
109+
99110
class ConfigLogging(GFConfigLogging):
100111
_split_console_streams = field_validator("console_streams", mode="before")(split_comma_separated())
101112

@@ -106,9 +117,9 @@ class Config(BaseModel):
106117
database: ConfigDatabase
107118
crypto_service_api: ConfigCryptoServiceApi
108119
telemetry: ConfigTelemetry
109-
stats: ConfigStats
110120
uvicorn: ConfigUvicorn
111121
authorization_headers: ConfigAuthorizationHeaders
122+
pagination: ConfigPagination = Field(default_factory=ConfigPagination)
112123

113124

114125
def read_ini_file(path: str) -> Any:

‎app/container.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
)
1010
from app.db.db import Database
1111
from app.debug.crypto_service_api_client_mock import CryptoServiceApiClientMock
12+
from app.metrics import UsageMetrics
1213
from app.services.auth.header import AuthHeaderService
1314
from app.services.crypto_service_api_client import CryptoServiceApiClient
1415
from app.services.fhir.bundle import BundleService
1516
from app.services.fhir.localization_list import LocalizationListService
1617
from app.services.key_info import KeyInfoService, KeyInfoServiceMock
18+
from app.services.pseudonym_referral_service import PseudonymReferralService
1719
from app.services.pseudonym_resolver import PseudonymResolver
1820
from app.services.referral_service import ReferralService
21+
from app.telemetry import create_meter_provider
1922
from app.utils.load_capability_statement import (
2023
CapabilityStatement,
2124
load_capability_statement,
@@ -28,14 +31,16 @@ def container_config(binder: inject.Binder) -> None:
2831
config = get_config()
2932

3033
binder.bind(Config, config)
34+
meter_provider = create_meter_provider(config.telemetry)
3135

3236
capability_statement = load_capability_statement()
3337
binder.bind(CapabilityStatement, capability_statement)
3438

3539
db = Database(config_database=config.database)
3640
binder.bind(Database, db)
3741

38-
referral_service = ReferralService(database=db)
42+
usage_metrics = UsageMetrics(meter_provider)
43+
referral_service = ReferralService(database=db, usage_metrics=usage_metrics)
3944
binder.bind(ReferralService, referral_service)
4045

4146
key_info_service = create_key_info_service(config.crypto_service_api, db)
@@ -51,11 +56,17 @@ def container_config(binder: inject.Binder) -> None:
5156
crypto_client=crypto_client,
5257
key_info_service=key_info_service,
5358
)
54-
binder.bind(PseudonymResolver, pseudonym_resolver)
59+
pseudonym_referral_service = PseudonymReferralService(
60+
referral_service=referral_service,
61+
pseudonym_resolver=pseudonym_resolver,
62+
)
63+
binder.bind(PseudonymReferralService, pseudonym_referral_service)
5564

5665
localization_list_service = LocalizationListService(
5766
referral_service=referral_service,
58-
pseudonym_resolver=pseudonym_resolver,
67+
pseudonym_referral_service=pseudonym_referral_service,
68+
default_page_size=config.pagination.default_page_size,
69+
max_page_size=config.pagination.max_page_size,
5970
)
6071
binder.bind(LocalizationListService, localization_list_service)
6172

‎app/db/models/referral.py‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import TYPE_CHECKING
33
from uuid import UUID, uuid4
44

5-
from sqlalchemy import TIMESTAMP, ForeignKey, String, UniqueConstraint
5+
from sqlalchemy import TIMESTAMP, ForeignKey, Index, String
66
from sqlalchemy.orm import Mapped, mapped_column, relationship
77
from sqlalchemy.types import Uuid
88

@@ -15,12 +15,9 @@
1515
class ReferralEntity(Base):
1616
__tablename__ = "referrals"
1717
__table_args__ = (
18-
UniqueConstraint(
19-
"ura_number",
20-
"pseudonym",
21-
"source",
22-
name="providers_unique_idx",
23-
),
18+
Index("referrals_ura_pseudonym_source_key", "ura_number", "pseudonym", "source", unique=True),
19+
Index("referrals_pseudonym_idx", "pseudonym"),
20+
Index("referrals_ura_created_at_id_idx", "ura_number", "created_at", "id"),
2421
)
2522

2623
id: Mapped[UUID] = mapped_column("id", Uuid, primary_key=True, default=uuid4)

0 commit comments

Comments
 (0)