Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions backend/druks/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down
21 changes: 18 additions & 3 deletions backend/druks/services/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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)

Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_extension_appless_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
35 changes: 27 additions & 8 deletions backend/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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):
Expand All @@ -443,7 +440,6 @@ class Settings(BaseModel):

class Plain(Service):
name = "plain_service"
title = "Plain"

class Settings(BaseModel):
api_key: SecretStr
Expand All @@ -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"

Expand Down Expand Up @@ -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"

Expand All @@ -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
Expand All @@ -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"
Expand Down
36 changes: 31 additions & 5 deletions docs/writing-an-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:

Expand Down