diff --git a/backend/druks/core/services.py b/backend/druks/core/services.py index 4239a9a6..6ad770b5 100644 --- a/backend/druks/core/services.py +++ b/backend/druks/core/services.py @@ -16,7 +16,6 @@ class GitHubApp(Service): name = GITHUB - title = "GitHub" description = ( "The GitHub App druks acts as — it receives webhooks and writes branches, " "pull requests, and comments. Create it from here, or paste an existing " @@ -66,7 +65,6 @@ async def verify(cls, settings: Settings) -> dict[str, Any]: class Linear(Service): name = "linear" - title = "Linear" description = ( "The Linear identity druks reads and updates tickets as; its webhook " "secret verifies inbound deliveries." @@ -97,7 +95,6 @@ async def verify(cls, settings: Settings) -> dict[str, Any]: class Jira(Service): name = "jira" - title = "Jira" description = ( "The Jira Cloud identity druks reads and updates tickets as; its webhook " "secret authenticates Automation deliveries." diff --git a/backend/druks/services/base.py b/backend/druks/services/base.py index dde80cb5..5714140c 100644 --- a/backend/druks/services/base.py +++ b/backend/druks/services/base.py @@ -89,10 +89,14 @@ class Service: """ name: ClassVar[str] + # The connect card's heading — the platform derives it from ``name``. title: ClassVar[str] description: ClassVar[str] = "" # Whether doctor fails when this service is not connected. required: ClassVar[bool] = True + # True marks a shared provider base — subclasses inherit its declarations + # and register; the base itself never does. + abstract: ClassVar[bool] = False settings_model: ClassVar[type[BaseModel]] # Set both endpoints when the registered app is an OAuth client; # ``get_oauth_client()`` then hands back the connected identity as a @@ -113,17 +117,27 @@ class Service: def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) + if cls.__dict__.get("abstract"): + if "name" in cls.__dict__: + raise TypeError( + f"{cls.__name__} sets both `abstract` and `name` — an abstract " + "base never registers. Drop one." + ) + return name = getattr(cls, "name", None) if not name: raise TypeError(f"{cls.__name__} must set a `name`") - if not getattr(cls, "title", None): - raise TypeError(f"{cls.__name__} must set a `title` — the connect card's heading") + if "title" in cls.__dict__: + raise TypeError( + f"{cls.__name__} declares a `title` — the card heading derives " + "from `name`. Drop it." + ) if not NAME_RE.match(name): raise TypeError( f"service name {name!r} must match {NAME_RE.pattern!r} — it keys the " "service_identities row and the connect wire" ) - declared = cls.__dict__.get("Settings") + declared = getattr(cls, "Settings", None) if not isinstance(declared, type) or not issubclass(declared, BaseModel): raise TypeError(f"{cls.__name__}.Settings must be a pydantic model") if bool(cls.authorization_endpoint) != bool(cls.token_endpoint): @@ -133,6 +147,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: f"{cls.__name__}.Settings must declare client_id and client_secret " "fields — get_oauth_client() reads the OAuth client from them" ) + cls.title = name.replace("_", " ").title() cls.settings_model = declared services.register(cls) diff --git a/backend/tests/test_extension_appless_load.py b/backend/tests/test_extension_appless_load.py index 9d0a1940..93396215 100644 --- a/backend/tests/test_extension_appless_load.py +++ b/backend/tests/test_extension_appless_load.py @@ -85,7 +85,6 @@ async def run(self, widget: str) -> None: class Probemail(Service): name = "probemail" - title = "Probemail" class Settings(BaseModel): account: str = Field(title="Account") diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 7885b0fa..132a0ab3 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -390,7 +390,6 @@ def test_get_oauth_client_reads_the_connected_identity(declared_services, druks_ class Acme(Service): name = "acme" - title = "Acme OAuth app" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" basic_auth = True @@ -423,7 +422,6 @@ def test_oauth_service_declarations_fail_loudly(declared_services): class Keyless(Service): name = "keyless" - title = "Keyless" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" @@ -434,7 +432,6 @@ class Settings(BaseModel): class HalfDeclared(Service): name = "half_declared" - title = "Half declared" token_endpoint = "https://acme.test/token" class Settings(BaseModel): @@ -443,7 +440,6 @@ class Settings(BaseModel): class Plain(Service): name = "plain_service" - title = "Plain" class Settings(BaseModel): api_key: SecretStr @@ -452,13 +448,39 @@ class Settings(BaseModel): Plain.get_oauth_client() +def test_abstract_base_shares_declarations_without_registering(declared_services): + from druks.extensions.registry import services + from druks.services import Service + from pydantic import BaseModel, SecretStr + + class AcmeBase(Service): + abstract = True + authorization_endpoint = "https://acme.test/authorize" + token_endpoint = "https://acme.test/token" + + class Settings(BaseModel): + client_id: str + client_secret: SecretStr + + class Mail(AcmeBase): + name = "acme_mail" + + assert services.get("acme_mail") is Mail + assert Mail.settings_model is AcmeBase.Settings + + with pytest.raises(TypeError, match="abstract"): + + class Named(Service): + abstract = True + name = "named_base" + + def test_with_scopes_declares_the_union_and_reads_connections(declared_services, monkeypatch): from druks.services import Service from pydantic import BaseModel, SecretStr class Acme(Service): name = "acme" - title = "Acme OAuth app" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" @@ -507,7 +529,6 @@ async def test_get_identity_without_a_declared_endpoint_is_empty(declared_servic class Quiet(Service): name = "quiet_provider" - title = "Quiet" authorization_endpoint = "https://quiet.test/authorize" token_endpoint = "https://quiet.test/token" @@ -524,7 +545,6 @@ def test_with_scopes_requires_oauth_endpoints(declared_services): class Plain(Service): name = "plain_no_oauth" - title = "Plain" class Settings(BaseModel): api_key: SecretStr @@ -543,7 +563,6 @@ def acme(declared_services, monkeypatch): class Acme(Service): name = "acme" - title = "Acme OAuth app" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" identity_endpoint = "https://acme.test/whoami" diff --git a/docs/writing-an-extension.md b/docs/writing-an-extension.md index 948f4225..fd8bb941 100644 --- a/docs/writing-an-extension.md +++ b/docs/writing-an-extension.md @@ -687,9 +687,9 @@ accounts") — and a credential only your extension posts with belongs in your extension settings instead. Declare one class in `services.py` and the platform does the rest: it renders -the connect card in Settings, verifies and stores the paste (`SecretStr` -fields land encrypted, plain fields become identity facts), and reports -`druks doctor` state: +the connect card in Settings (the heading derives from `name`), verifies and +stores the paste (`SecretStr` fields land encrypted, plain fields become +identity facts), and reports `druks doctor` state: ```python from pydantic import BaseModel, Field, SecretStr @@ -699,7 +699,6 @@ from druks.services import Service, ServiceConnectError class Gmail(Service): name = "gmail" - title = "Google OAuth client" description = "The appliance's own OAuth client — every mailbox authenticates against it." class Settings(BaseModel): @@ -745,7 +744,6 @@ fields: ```python class Acme(Service): name = "acme" - title = "Acme OAuth app" authorization_endpoint = "https://acme.example/oauth/authorize" token_endpoint = "https://acme.example/oauth/token" # True = HTTP Basic on the token endpoint. False = secret in the body. @@ -780,6 +778,34 @@ shape. Override `get_identity` for them: return payload["data"] ``` +One provider can back several services — Google backs both Gmail and Google +Calendar, and each keeps its own card and its own key. Share the provider's +declarations through an abstract base. Set `abstract = True`: the base never +registers, and each subclass inherits everything it declares, `Settings` +included: + +```python +class GoogleOauth(Service): + abstract = True + authorization_endpoint = "https://accounts.google.com/o/oauth2/v2/auth" + token_endpoint = "https://oauth2.googleapis.com/token" + extra_authorize_params = {"access_type": "offline", "prompt": "consent"} + identity_endpoint = "https://openidconnect.googleapis.com/v1/userinfo" + identity_scopes = ("openid", "email") + + class Settings(BaseModel): + client_id: str = Field(title="Client ID") + client_secret: SecretStr = Field(title="Client secret") + + +class Gmail(GoogleOauth): + name = "gmail" + + +class Calendar(GoogleOauth): + name = "google_calendar" +``` + Declare your extension's use of the service, with the scopes your calls need: