Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-132231 / 25.10 / Schema + documentation for API events #15478

Merged
merged 6 commits into from
Jan 28, 2025
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
2 changes: 1 addition & 1 deletion src/middlewared/middlewared/alert/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
__all__ = [
"UnavailableException", "AlertClass", "OneShotAlertClass", "SimpleOneShotAlertClass", "DismissableAlertClass",
"AlertCategory", "AlertLevel", "Alert", "AlertSource", "ThreadedAlertSource", "AlertService",
"ThreadedAlertService", "ProThreadedAlertService", "format_alerts", "ellipsis"
"ThreadedAlertService", "ProThreadedAlertService", "format_alerts", "ellipsis", "alert_category_names",
]

logger = logging.getLogger(__name__)
Expand Down
1 change: 1 addition & 0 deletions src/middlewared/middlewared/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .base.event import Event
from .base.decorator import *

API_LOADING_FORBIDDEN = False
1 change: 1 addition & 0 deletions src/middlewared/middlewared/api/base/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .event import * # noqa
from .excluded import * # noqa
from .model import * # noqa
from .types import * # noqa
Expand Down
21 changes: 21 additions & 0 deletions src/middlewared/middlewared/api/base/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from dataclasses import dataclass

from .model import BaseModel


@dataclass(slots=True, frozen=True, kw_only=True)
class Event:
yocalebo marked this conversation as resolved.
Show resolved Hide resolved
"""
Represents a middleware API event
"""

# event name
name: str
# event description
description: str
# list of roles than can subscribe to event
roles: list[str]
# data models for different event types (ADDED, CHANGED, REMOVED)
models: dict[str, type[BaseModel]]
# whether this event is private
private: bool = False
33 changes: 31 additions & 2 deletions src/middlewared/middlewared/api/base/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from middlewared.utils.lang import undefined


__all__ = ["BaseModel", "ForUpdateMetaclass", "query_result", "query_result_item",
"single_argument_args", "single_argument_result"]
__all__ = ["BaseModel", "ForUpdateMetaclass", "query_result", "query_result_item", "added_event_model",
"changed_event_model", "removed_event_model", "single_argument_args", "single_argument_result"]


class BaseModel(PydanticBaseModel):
Expand Down Expand Up @@ -225,3 +225,32 @@ def query_result_item(item):
__module__=item.__module__,
__cls_kwargs__={"metaclass": ForUpdateMetaclass},
)


def added_event_model(item):
return create_model(
item.__name__.removesuffix("Entry") + "AddedEvent",
__base__=(BaseModel,),
__module__=item.__module__,
id=typing.Annotated[item.model_fields["id"].annotation, Field()],
fields=typing.Annotated[item, Field()],
)


def changed_event_model(item):
return create_model(
item.__name__.removesuffix("Entry") + "ChangedEvent",
__base__=(BaseModel,),
__module__=item.__module__,
id=typing.Annotated[item.model_fields["id"].annotation, Field()],
fields=typing.Annotated[item, Field()],
)


def removed_event_model(item):
return create_model(
item.__name__.removesuffix("Entry") + "RemovedEvent",
__base__=(BaseModel,),
__module__=item.__module__,
id=typing.Annotated[item.model_fields["id"].annotation, Field()],
)
4 changes: 3 additions & 1 deletion src/middlewared/middlewared/api/base/server/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from .event import Event
from .method import Method


class API:
def __init__(self, version: str, methods: list[Method]):
def __init__(self, version: str, methods: list[Method], events: list[Event]):
self.version = version
self.methods = methods
self.events = events
44 changes: 43 additions & 1 deletion src/middlewared/middlewared/api/base/server/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@

from ..jsonschema import replace_refs
from .api import API
from .event import Event
from .method import Method


class APIDump(BaseModel):
version: str
methods: list["APIDumpMethod"]
events: list["APIDumpEvent"]


class APIDumpMethod(BaseModel):
Expand All @@ -20,13 +22,20 @@ class APIDumpMethod(BaseModel):
schemas: dict


class APIDumpEvent(BaseModel):
name: str
yocalebo marked this conversation as resolved.
Show resolved Hide resolved
roles: list[str]
doc: str | None
schemas: dict


class APIDumper:
def __init__(self, version: str, api: API):
self.version = version
self.api = api

def dump(self):
return APIDump(version=self.version, methods=self._dump_methods())
return APIDump(version=self.version, methods=self._dump_methods(), events=self._dump_events())

def _dump_methods(self):
result = []
Expand Down Expand Up @@ -96,3 +105,36 @@ def _dump_method_schemas(self, method: Method):
},
},
}

def _dump_events(self):
result = []
for event in self.api.events:
if event.event["private"]:
continue

if not event.event["models"]:
continue

result.append(self._dump_event(event))

return sorted(result, key=lambda event: event.name)

def _dump_event(self, event: Event):
return APIDumpEvent(
name=event.name,
roles=event.event["roles"],
doc=event.event["description"],
schemas=self._dump_event_schemas(event),
)

def _dump_event_schemas(self, event: Event):
properties = {}
for name, model in event.event["models"].items():
schema = model.model_json_schema()
schema = replace_refs(schema, schema.get("$defs", {}))
properties[name] = schema

return {
"type": "object",
"properties": properties,
}
19 changes: 19 additions & 0 deletions src/middlewared/middlewared/api/base/server/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from middlewared.main import Middleware


class Event:
yocalebo marked this conversation as resolved.
Show resolved Hide resolved
"""
Represents a middleware API event used in JSON-RPC server.
"""

def __init__(self, middleware: "Middleware", name: str):
"""
:param middleware: `Middleware` instance
:param name: event name
"""
self.middleware = middleware
self.name = name
self.event = self.middleware.events.get_event(self.name)
15 changes: 15 additions & 0 deletions src/middlewared/middlewared/api/v25_04_0/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'AlertListCategoriesResult', 'AlertListPoliciesArgs', 'AlertListPoliciesResult', 'AlertRestoreArgs',
'AlertRestoreResult', 'AlertOneshotCreateArgs', 'AlertOneshotCreateResult', 'AlertOneshotDeleteArgs',
'AlertOneshotDeleteResult', 'AlertClassesEntry', 'AlertClassesUpdateArgs', 'AlertClassesUpdateResult', 'Alert',
'AlertListAddedEvent', 'AlertListChangedEvent', 'AlertListRemovedEvent',
]


Expand Down Expand Up @@ -119,3 +120,17 @@ class AlertClassesUpdateArgs(BaseModel):

class AlertClassesUpdateResult(BaseModel):
result: AlertClassesEntry


class AlertListAddedEvent(BaseModel):
id: int
fields: Alert


class AlertListChangedEvent(BaseModel):
id: int
fields: Alert


class AlertListRemovedEvent(BaseModel):
id: int
18 changes: 9 additions & 9 deletions src/middlewared/middlewared/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def __init__(self, role_manager: RoleManager):
self._events: typing.Dict[str, dict[str, typing.Any]] = {}
self.__events_private: typing.Set[str] = set()

def register(self, name: str, description: str, private: bool, returns, new_style_returns, no_auth_required,
def register(self, name: str, description: str, private: bool, returns, models, no_auth_required,
no_authz_required, roles: typing.Iterable[str]):
if name in self._events:
raise ValueError(f'Event {name!r} already registered.')
Expand All @@ -23,7 +23,7 @@ def register(self, name: str, description: str, private: bool, returns, new_styl
'description': description,
'accepts': [],
'returns': [returns] if returns else [Any(name, null=True)],
'new_style_returns': new_style_returns,
'models': models,
'no_auth_required': no_auth_required,
'no_authz_required': no_authz_required,
'roles': self.role_manager.roles_for_event(name),
Expand All @@ -32,18 +32,18 @@ def register(self, name: str, description: str, private: bool, returns, new_styl
self.__events_private.add(name)

def get_event(self, name: str) -> typing.Optional[dict[str, typing.Any]]:
return self._events.get(name)
return {
'private': name in self.__events_private,
'wildcard_subscription': True,
**self._events.get(name),
}

def __contains__(self, name):
return name in self._events

def __iter__(self):
for k, v in self._events.items():
yield k, {
'private': k in self.__events_private,
'wildcard_subscription': True,
**v,
}
for k in self._events:
yield k, self.get_event(k)


class EventSourceMetabase(type):
Expand Down
19 changes: 11 additions & 8 deletions src/middlewared/middlewared/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .api.base.handler.version import APIVersion, APIVersionsAdapter
from .api.base.server.api import API
from .api.base.server.doc import APIDumper
from .api.base.server.event import Event
from .api.base.server.legacy_api_method import LegacyAPIMethod
from .api.base.server.method import Method
from .api.base.server.ws_handler.base import BaseWebSocketHandler
Expand Down Expand Up @@ -193,7 +194,11 @@ def _create_api(self, version: str, method_factory: typing.Callable[["Middleware

methods.append(method_factory(self, method_name))

return API(version, methods)
events = []
for name, event in self.events:
events.append(Event(self, name))

return API(version, methods, events)

def _add_api_route(self, version: str, api: API):
self.app.router.add_route('GET', f'/api/{version}', RpcWebSocketHandler(self, api.methods))
Expand Down Expand Up @@ -1046,7 +1051,6 @@ def get_events(self):
'wildcard_subscription': False,
'accepts': n[1].ACCEPTS,
'returns': n[1].RETURNS,
'new_style_returns': None,
}
),
self.event_source_manager.event_sources.items()
Expand All @@ -1059,15 +1063,14 @@ def event_subscribe(self, name, handler):
"""
self.__event_subs[name].append(handler)

def event_register(self, name, description, *, private=False, returns=None, new_style_returns=None,
no_auth_required=False, no_authz_required=False, roles=None):
def event_register(self, name, description, *, private=False, returns=None, models=None, no_auth_required=False,
no_authz_required=False, roles=None):
"""
All events middleware can send should be registered, so they are properly documented
and can be browsed in documentation page without source code inspection.
All middleware events should be registered, so they are properly documented
and can be browsed in the API documentation without having to inspect source code.
"""
roles = roles or []
self.events.register(name, description, private, returns, new_style_returns, no_auth_required,
no_authz_required, roles)
self.events.register(name, description, private, returns, models, no_auth_required, no_authz_required, roles)

def send_event(self, name, event_type: str, **kwargs):
should_send_event = kwargs.pop('should_send_event', None)
Expand Down
21 changes: 16 additions & 5 deletions src/middlewared/middlewared/plugins/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@
ProThreadedAlertService,
)
from middlewared.alert.base import UnavailableException, AlertService as _AlertService
from middlewared.api import api_method
from middlewared.api import api_method, Event
from middlewared.api.current import (
AlertDismissArgs, AlertDismissResult, AlertListArgs, AlertListResult, AlertListCategoriesArgs,
AlertListCategoriesResult, AlertListPoliciesArgs, AlertListPoliciesResult, AlertRestoreArgs, AlertRestoreResult,
AlertOneshotCreateArgs, AlertOneshotCreateResult, AlertOneshotDeleteArgs, AlertOneshotDeleteResult,
AlertClassesEntry, AlertClassesUpdateArgs, AlertClassesUpdateResult, AlertServiceCreateArgs,
AlertServiceCreateResult, AlertServiceUpdateArgs, AlertServiceUpdateResult, AlertServiceDeleteArgs,
AlertServiceDeleteResult, AlertServiceTestArgs, AlertServiceTestResult, AlertServiceEntry,
AlertServiceDeleteResult, AlertServiceTestArgs, AlertServiceTestResult, AlertServiceEntry, AlertListAddedEvent,
AlertListChangedEvent, AlertListRemovedEvent,
)
from middlewared.schema import Bool, Dict, Int, Str
from middlewared.schema import Bool, Str
from middlewared.service import (
ConfigService, CRUDService, Service, ValidationErrors,
job, periodic, private,
Expand Down Expand Up @@ -212,6 +213,18 @@ class AlertService(Service):

class Config:
cli_namespace = "system.alert"
events = [
Event(
name="alert.list",
description="Sent on alert changes.",
roles=["ALERT_LIST_READ"],
models={
"ADDED": AlertListAddedEvent,
"CHANGED": AlertListChangedEvent,
"REMOVED": AlertListRemovedEvent,
},
),
]

def __init__(self, middleware):
super().__init__(middleware)
Expand Down Expand Up @@ -1230,8 +1243,6 @@ async def _event_system(middleware, event_type, args):


async def setup(middleware):
middleware.event_register("alert.list", "Sent on alert changes.", roles=["ALERT_LIST_READ"])

await middleware.call("alert.load")
await middleware.call("alert.initialize")

Expand Down
1 change: 1 addition & 0 deletions src/middlewared/middlewared/service/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def service_config(klass, config):
'entry': None,
'event_register': True,
'event_send': True,
'events': [],
'service': None,
'service_verb': 'reload',
'service_verb_sync': True,
Expand Down
22 changes: 0 additions & 22 deletions src/middlewared/middlewared/service/core_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,28 +493,6 @@ def get_json_schema(self, schema, args_descriptions_doc):

return schema

@accepts()
def get_events(self):
"""
Returns metadata for every possible event emitted from websocket server.
"""
events = {}
for name, attrs in self.middleware.get_events():
if attrs['private']:
continue

events[name] = {
'description': attrs['description'],
'wildcard_subscription': attrs['wildcard_subscription'],
'accepts': self.get_json_schema(list(filter(bool, attrs['accepts'])), attrs['description']),
'returns': (
get_json_schema(attrs['new_style_returns']) if attrs['new_style_returns']
else self.get_json_schema(list(filter(bool, attrs['returns'])), attrs['description'])
),
}

return events

@private
async def call_hook(self, name, args, kwargs=None):
kwargs = kwargs or {}
Expand Down
Loading
Loading