Enforce http(s) schema on service base_url fields
Add a shared ServiceBaseUrl type (BeforeValidator + Field description) in integrations/base.py and apply it to base_url across all six service configs (grafana, prometheus, alertmanager, jellyfin, jellyseerr, nextcloud). Missing http:// or https:// schema now fails fast with a clear 422 instead of breaking HTTP clients silently. Tests cover reject/accept cases; REQUIREMENTS updated.
This commit is contained in:
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class AlertmanagerConfig(ServiceConfigBase):
|
||||
"""Non-secret Alertmanager connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,37 @@ map. There is no runtime plugin loading.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
|
||||
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("base_url must be a string starting with http:// or https://")
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("base_url must not be empty")
|
||||
lowered = text.lower()
|
||||
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
||||
raise ValueError("base_url must start with http:// or https:// (include the schema)")
|
||||
return text
|
||||
|
||||
|
||||
#: Shared annotated type for service ``base_url`` fields. applying the validator
|
||||
#: uniformly across every integration so missing schemas are rejected at the
|
||||
#: config boundary with a helpful message.
|
||||
ServiceBaseUrl = Annotated[
|
||||
str,
|
||||
Field(description="Absolute URL including the http:// or https:// schema."),
|
||||
BeforeValidator(_validate_service_base_url),
|
||||
]
|
||||
|
||||
|
||||
class ServiceConfigBase(BaseModel):
|
||||
@@ -26,6 +54,9 @@ class ServiceConfigBase(BaseModel):
|
||||
|
||||
Subclass this in each integration module and declare the connection fields.
|
||||
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
||||
|
||||
Connection URLs should use the :data:`ServiceBaseUrl` type so the
|
||||
``http(s)://`` schema is enforced consistently across integrations.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class JellyfinConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyfin connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
@@ -17,7 +18,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class JellyseerrConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyseerr connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class NextcloudConfig(ServiceConfigBase):
|
||||
"""Non-secret Nextcloud connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
username: str = ""
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class PrometheusConfig(ServiceConfigBase):
|
||||
"""Non-secret Prometheus connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user