Source code for geodetic_engine.georepository.config

"""Connection settings for a Georepository instance.

Kept separate from the proj.db build configuration so that the API client can
be used on its own, and so that nothing about one organisation's instance is
compiled into the package.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from typing import Final

import httpx

from geodetic_engine.georepository.errors import GeorepositoryConfigError

logger = logging.getLogger(__name__)

# Declared as the API's OAuth2 scope by the Georepository OpenAPI document.
DEFAULT_SCOPE: Final = "GeoRepositoryAPI_Scope"

# The API's own default page size is 10, which would mean an excessive number of
# round trips when enumerating a whole collection.
DEFAULT_PAGE_SIZE: Final = 500

# The API path this client appends itself; an operator who copies the URL out of
# a browser or an API document usually has it included already.
_API_SUFFIX: Final = re.compile(r"/api(/v1)?/?$")


[docs] @dataclass(frozen=True, slots=True) class GeorepositoryConfig: """How to reach and authenticate against a Georepository instance. Attributes: api_url: Base URL of the instance, without the ``/api`` suffix. Must be https, since client credentials are sent on every token request. client_id: OAuth2 client identifier. client_secret: OAuth2 client secret. token_url: OAuth2 token endpoint. Defaults to ``{api_url}/auth/connect/token``. It is a separate setting because the token endpoint is not described by the OpenAPI document and cannot be assumed to sit under the API host. scope: Scope requested for the client credentials grant. page_size: Results requested per page. request_timeout: Per-request timeout in seconds. include_deprecated: Ask the API to include deprecated objects. """ api_url: str client_id: str client_secret: str token_url: str = "" scope: str = DEFAULT_SCOPE page_size: int = DEFAULT_PAGE_SIZE request_timeout: float = 60.0 include_deprecated: bool = True def __post_init__(self) -> None: object.__setattr__(self, "api_url", self.api_url.rstrip("/")) if not self.api_url: raise GeorepositoryConfigError( "the Georepository instance URL is required and has no default" ) if _API_SUFFIX.search(self.api_url): trimmed = _API_SUFFIX.sub("", self.api_url) logger.warning( "api_url %r includes the API path, which is added automatically; " "using %r", self.api_url, trimmed, ) object.__setattr__(self, "api_url", trimmed) if not self.api_url.startswith("https://"): raise GeorepositoryConfigError( f"the Georepository URL must be https, got {self.api_url!r}" ) if not self.client_id or not self.client_secret: raise GeorepositoryConfigError( "both a client id and a client secret are required" ) if not self.token_url: object.__setattr__(self, "token_url", f"{self.api_url}/auth/connect/token") for name, value in (("api_url", self.api_url), ("token_url", self.token_url)): try: url = httpx.URL(value) except httpx.InvalidURL as exc: raise GeorepositoryConfigError(f"invalid {name}") from exc if url.scheme != "https" or not url.host or url.userinfo or url.fragment: raise GeorepositoryConfigError( f"{name} must be an absolute https URL without userinfo or fragment" ) if self.page_size < 1: raise GeorepositoryConfigError("page_size must be positive") def __repr__(self) -> str: """Render without secrets, so the configuration can be logged.""" return ( f"GeorepositoryConfig(api_url={self.api_url!r}, " f"scope={self.scope!r}, page_size={self.page_size!r}, " "client_id='***', client_secret='***')" )
[docs] def endpoint(self, name: str) -> str: """Return the absolute URL of a v1 collection endpoint. Args: name: Endpoint name such as ``GeodeticCoordRefSystem``. Returns: Absolute URL, for example ``https://example.org/api/v1/GeodeticCoordRefSystem``. """ return f"{self.api_url}/api/v1/{name}"