diff --git a/lite_bootstrap/instruments/healthchecks_instrument.py b/lite_bootstrap/instruments/healthchecks_instrument.py index 362ba40..368b195 100644 --- a/lite_bootstrap/instruments/healthchecks_instrument.py +++ b/lite_bootstrap/instruments/healthchecks_instrument.py @@ -6,6 +6,8 @@ import typing_extensions +from lite_bootstrap.exceptions import ConfigurationError +from lite_bootstrap.helpers.path import is_valid_path from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument @@ -27,6 +29,13 @@ class HealthChecksConfig(BaseConfig): health_checks_include_in_schema: bool = False health_checks_probes: Mapping[str, HealthProbe] = dataclasses.field(default_factory=dict) + def __post_init__(self) -> None: + super().__post_init__() + path = self.health_checks_path + if path and path != "/" and not is_valid_path(path): + msg = f"Invalid health_checks_path: {path!r}" + raise ConfigurationError(msg) + @property def health_check_data(self) -> HealthCheckTypedDict: return { @@ -41,11 +50,11 @@ def health_check_data(self) -> HealthCheckTypedDict: @dataclasses.dataclass(kw_only=True, slots=True) class HealthChecksInstrument(BaseInstrument[HealthChecksConfigT]): - not_configured_reason = "health_checks_enabled is False" + not_configured_reason = "health_checks_enabled is False or health_checks_path is empty" @classmethod def is_configured(cls, bootstrap_config: "HealthChecksConfig") -> bool: - return bootstrap_config.health_checks_enabled + return bootstrap_config.health_checks_enabled and bool(bootstrap_config.health_checks_path) def render_health_check_data(self, *, health_status: bool = True) -> HealthCheckTypedDict: health_check_data = self.bootstrap_config.health_check_data diff --git a/lite_bootstrap/instruments/prometheus_instrument.py b/lite_bootstrap/instruments/prometheus_instrument.py index cefa993..ef7b08c 100644 --- a/lite_bootstrap/instruments/prometheus_instrument.py +++ b/lite_bootstrap/instruments/prometheus_instrument.py @@ -3,6 +3,7 @@ import dataclasses import typing +from lite_bootstrap.exceptions import ConfigurationError from lite_bootstrap.helpers.path import is_valid_path from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument @@ -12,6 +13,13 @@ class PrometheusConfig(BaseConfig): prometheus_metrics_path: str = "/metrics" prometheus_metrics_include_in_schema: bool = False + def __post_init__(self) -> None: + super().__post_init__() + path = self.prometheus_metrics_path + if path and not is_valid_path(path): + msg = f"Invalid prometheus_metrics_path: {path!r}" + raise ConfigurationError(msg) + @dataclasses.dataclass(kw_only=True, frozen=True) class PrometheusHttpConfig(PrometheusConfig): @@ -29,10 +37,8 @@ def is_prometheus_http_metrics_on(self, *, opentelemetry_records_http_metrics: b @dataclasses.dataclass(kw_only=True, slots=True) class PrometheusInstrument(BaseInstrument[PrometheusConfigT]): - not_configured_reason = "prometheus_metrics_path is empty or not valid" + not_configured_reason = "prometheus_metrics_path is empty" @classmethod def is_configured(cls, bootstrap_config: "PrometheusConfig") -> bool: - return bool(bootstrap_config.prometheus_metrics_path) and is_valid_path( - bootstrap_config.prometheus_metrics_path - ) + return bool(bootstrap_config.prometheus_metrics_path) diff --git a/tests/instruments/test_healthchecks_instrument.py b/tests/instruments/test_healthchecks_instrument.py index b1a29ec..9491cce 100644 --- a/tests/instruments/test_healthchecks_instrument.py +++ b/tests/instruments/test_healthchecks_instrument.py @@ -16,7 +16,9 @@ def test_healthchecks_instrument_configured_by_default() -> None: def test_healthchecks_instrument_not_configured_when_disabled() -> None: config = HealthChecksConfig(health_checks_enabled=False) assert not HealthChecksInstrument.is_configured(config) - assert HealthChecksInstrument.not_configured_reason == "health_checks_enabled is False" + assert ( + HealthChecksInstrument.not_configured_reason == "health_checks_enabled is False or health_checks_path is empty" + ) def test_healthchecks_render_data_default() -> None: diff --git a/tests/instruments/test_prometheus_instrument.py b/tests/instruments/test_prometheus_instrument.py index 4443495..556cb26 100644 --- a/tests/instruments/test_prometheus_instrument.py +++ b/tests/instruments/test_prometheus_instrument.py @@ -9,13 +9,7 @@ def test_prometheus_instrument_configured_with_default_path() -> None: def test_prometheus_instrument_not_configured_with_empty_path() -> None: config = PrometheusConfig(prometheus_metrics_path="") assert not PrometheusInstrument.is_configured(config) - assert PrometheusInstrument.not_configured_reason == "prometheus_metrics_path is empty or not valid" - - -def test_prometheus_instrument_not_configured_with_invalid_path() -> None: - # No leading slash → invalid per is_valid_path regex. - config = PrometheusConfig(prometheus_metrics_path="metrics") - assert not PrometheusInstrument.is_configured(config) + assert PrometheusInstrument.not_configured_reason == "prometheus_metrics_path is empty" def test_prometheus_instrument_configured_with_custom_valid_path() -> None: diff --git a/tests/test_config.py b/tests/test_config.py index f3a31f9..163e2bc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,7 @@ import pytest from lite_bootstrap import FastAPIConfig, FastStreamConfig, FreeConfig, LitestarConfig +from lite_bootstrap.exceptions import ConfigurationError from lite_bootstrap.instruments.base import BaseConfig from tests.conftest import CustomInstrumentor @@ -119,3 +120,13 @@ def test_service_name_and_version_fall_back_for_readers_that_need_a_string( ) -> None: assert config.service_name_or_default == expected_name assert config.service_version_or_default == expected_version + + +def test_invalid_prometheus_metrics_path_raises_at_construction() -> None: + with pytest.raises(ConfigurationError, match="prometheus_metrics_path"): + FastAPIConfig(prometheus_metrics_path="metrics") + + +def test_invalid_health_checks_path_raises_at_construction() -> None: + with pytest.raises(ConfigurationError, match="health_checks_path"): + FastAPIConfig(health_checks_path="health") diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index a17810f..99e674e 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -740,3 +740,11 @@ def test_built_app_title_and_version_fall_back_when_unset() -> None: application = FastAPIConfig().app assert application.title == "micro-service" assert application.version == "1.0.0" + + +def test_empty_observability_paths_disable_routes() -> None: + config = FastAPIConfig(health_checks_path="", prometheus_metrics_path="") + with _bootstrapped(config) as application, TestClient(application) as client: + assert client.get("/health/").status_code == status.HTTP_404_NOT_FOUND + assert client.get("/metrics").status_code == status.HTTP_404_NOT_FOUND + assert client.get("/").status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index a4858e7..92b52de 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -460,7 +460,7 @@ def test_faststream_prometheus_is_skipped_without_metrics_path(broker: RedisBrok ) bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) skipped = dict(bootstrapper.skipped_instruments) - assert skipped[FastStreamPrometheusInstrument] == "prometheus_metrics_path is empty or not valid" + assert skipped[FastStreamPrometheusInstrument] == "prometheus_metrics_path is empty" async def test_faststream_prometheus_serves_the_default_registry(broker: RedisBroker) -> None: