Skip to content

feat(broadcasting): BroadcastEvent.emit(), Broadcast facade, ChannelAuthRegistry, ReverbProvider auto-wiring#112

Merged
bedus-creation merged 2 commits into
mainfrom
feat/broadcasting-event-facade-provider
Jun 12, 2026
Merged

feat(broadcasting): BroadcastEvent.emit(), Broadcast facade, ChannelAuthRegistry, ReverbProvider auto-wiring#112
bedus-creation merged 2 commits into
mainfrom
feat/broadcasting-event-facade-provider

Conversation

@bedus-creation

@bedus-creation bedus-creation commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR completes task #155 (remove Broadcast facade) plus reviewer-identified blocking fixes:

Blocking fixes (from code review)

  • BroadcastEvent.emit() is now async and resolves BroadcastManager directly from the Application container (app().make("broadcasting")) — no facade in the call chain. Events were previously silently dropped because emit() was sync but called an async dispatcher.
  • test_emit_calls_broadcast_dispatch now uses AsyncMock for the async dispatch, patches at fastapi_startkit.broadcasting.event.app (module-level attribute), and is decorated with @pytest.mark.asyncio.

Facade removal (task #155)

  • Deleted facades/Broadcast.py and facades/Broadcast.pyi
  • Removed Broadcast import from facades/__init__.py
  • No Broadcast facade anywhere in the broadcasting package

New direct API

  • channel() decorator exported from fastapi_startkit.broadcasting — users import it as from fastapi_startkit.broadcasting import channel instead of via a facade
  • stubs/channels.py updated to use the new direct import

Channel equality (reviewer minor)

  • Added __eq__ and __hash__ to Channel — two instances with the same name are now equal and usable in sets / as dict keys
  • Tests added in test_channels.py

Auth endpoint robustness (reviewer minor)

  • /broadcasting/auth user resolution now checks callable(user_attr) before calling it, avoiding TypeError when the auth service exposes user as a property rather than a method

Test plan

  • 59 broadcasting-specific tests pass (pytest tests/broadcasting/ -v)
  • Full suite: 1290 passed, 7 skipped, 0 failures
  • ruff check and ruff format --check clean

🤖 Generated with Claude Code

…uthRegistry, ReverbProvider routes

Implements tasks #147, #148, #149, #150.

## BroadcastEvent (Task #147)
- Add `payload: dict` and `name: str | None` class-level attributes
- `broadcast_as()` uses `self.name` when set, falls back to class name
- `broadcast_with()` uses `self.payload` when non-empty, falls back to instance attrs
- Add `emit()` method — delegates to `Broadcast.dispatch(self)`

## Broadcast facade + ChannelAuthRegistry (Task #148)
- New `broadcasting/auth.py` — `ChannelAuthRegistry` with `{wildcard}` pattern
  matching, type-hint casting, and async-callback support
- `BroadcastManager` gains `dispatch()`, `emit()`, and `channel()` decorator
- Fix `Broadcast` facade key from `"broadcast"` → `"broadcasting"`
- Rewrite `Broadcast.pyi` stub with full typed API

## ReverbProvider auto-wiring (Task #149)
- `boot()` now mounts the Pusher-protocol WebSocket at `/app/{app_key}`
- Mounts `POST /broadcasting/auth` for Laravel Echo auth handshake
- Auto-loads `routes/channels.py` from the application base path
- Publishes `stubs/channels.py` as `routes/channels.py` via `artisan publish`

## routes/channels.py stub (Task #150)
- Add `broadcasting/stubs/channels.py` to the framework package (publishable)

Tests: 18 new tests covering emit(), ChannelAuthRegistry, and manager.channel()
All 869 suite tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bedus-creation

Copy link
Copy Markdown
Contributor Author

Code Review — Request Changes

Overall this is a solid implementation that correctly extends the existing broadcasting/ package. Architecture, auth-registry pattern matching, BroadcastManager wiring, and provider auto-loading all look good. All 51 broadcasting tests pass. However there are two blocking issues and two minor ones that must be addressed before merging.


🔴 Blocker 1 — BroadcastEvent.emit() is NOT async (silent event drop)

File: fastapi_startkit/src/fastapi_startkit/broadcasting/event.py

# Current (WRONG)
def emit(self) -> None:
    from fastapi_startkit.facades.Broadcast import Broadcast  # noqa: PLC0415
    Broadcast.dispatch(self)   # ← creates a coroutine, never awaits it

BroadcastManager.dispatch() is declared as async def dispatch(). Calling it without await creates a coroutine object that is immediately discarded — the event is silently never broadcast. Python also emits a RuntimeWarning: coroutine 'BroadcastManager.dispatch' was never awaited at runtime.

Fix: Make emit() async and await the dispatch call.

# Correct
async def emit(self) -> None:
    from fastapi_startkit.facades.Broadcast import Broadcast  # noqa: PLC0415
    await Broadcast.dispatch(self)

PR #116 has this correct — direct cherry-pick.


🔴 Blocker 2 — Test test_emit_calls_broadcast_dispatch masks the bug above

File: fastapi_startkit/tests/broadcasting/test_event_emit.py

def test_emit_calls_broadcast_dispatch():
    mock_broadcast = MagicMock()
    mock_broadcast.dispatch = MagicMock()   # ← plain Mock, not AsyncMock

    with patch("fastapi_startkit.facades.Broadcast.Broadcast", mock_broadcast):
        event.emit()   # ← not awaited, test passes regardless of await in impl

    mock_broadcast.dispatch.assert_called_once_with(event)

The test is synchronous and uses MagicMock() instead of AsyncMock() for dispatch. This means it passes whether or not emit() correctly awaits the call. It does not prove the broadcast actually fires in an async context.

Fix:

@pytest.mark.asyncio
async def test_emit_calls_broadcast_dispatch():
    from unittest.mock import AsyncMock, patch

    event = SimpleEvent()
    mock_broadcast = MagicMock()
    mock_broadcast.dispatch = AsyncMock()

    with patch("fastapi_startkit.facades.Broadcast.Broadcast", mock_broadcast):
        await event.emit()

    mock_broadcast.dispatch.assert_awaited_once_with(event)

🟡 Minor 1 — Missing __eq__ / __hash__ on Channel

File: fastapi_startkit/src/fastapi_startkit/broadcasting/channels.py

Without these, two Channel("orders") objects are not equal and can't be used in sets or as dict keys. PR #116 adds them cleanly. Cherry-pick:

class Channel:
    def __init__(self, name: str):
        self.name = name

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self.name!r})"

    def __eq__(self, other: object) -> bool:
        return isinstance(other, Channel) and self.name == other.name

    def __hash__(self) -> int:
        return hash((self.__class__.__name__, self.name))

🟡 Minor 2 — Fragile user resolution in /broadcasting/auth

File: fastapi_startkit/src/fastapi_startkit/broadcasting/provider.py

auth_service = application.make("auth")
user = getattr(auth_service, "user", lambda: None)()  # ← assumes .user is callable

If the auth service exposes user as a property (very common), getattr returns the property's value, and () then tries to call it as a function — raising TypeError and falling through to user = None, silently denying the authenticated user.

Fix:

try:
    auth_service = application.make("auth")
    user_attr = getattr(auth_service, "user", None)
    user = user_attr() if callable(user_attr) else user_attr
except Exception:
    user = None

Summary

# Severity File Issue
1 🔴 Blocking broadcasting/event.py emit() must be async def with await Broadcast.dispatch(self)
2 🔴 Blocking tests/broadcasting/test_event_emit.py Use AsyncMock + assert_awaited_once_with
3 🟡 Minor broadcasting/channels.py Add __eq__ / __hash__ to Channel
4 🟡 Minor broadcasting/provider.py Safe user resolution in auth endpoint

Please fix items 1–2 before merge. Items 3–4 are highly recommended.

@bedus-creation

Copy link
Copy Markdown
Contributor Author

Additional Required Change — Task #155: Remove Broadcast facade

New blocking requirement added by PM (Task #155): The framework does not use facades for broadcasting. The Broadcast facade must be removed before this PR can merge.

🔴 Blocker 5 — Remove facades/Broadcast.py and facades/Broadcast.pyi

What to delete:

  • fastapi_startkit/src/fastapi_startkit/facades/Broadcast.py
  • fastapi_startkit/src/fastapi_startkit/facades/Broadcast.pyi

Replace BroadcastEvent.emit() with direct container resolution:

The existing broadcasting/helpers.py already shows the correct pattern — use app().make('broadcasting'):

# broadcasting/event.py — corrected
async def emit(self) -> None:
    """Dispatch this event via the BroadcastManager."""
    from fastapi_startkit.application import app  # noqa: PLC0415
    await app().make("broadcasting").dispatch(self)

Export channel() directly from fastapi_startkit.broadcasting:

# fastapi_startkit/broadcasting/__init__.py — add:
from .manager import channel   # or expose via a module-level helper

So users can write:

from fastapi_startkit.broadcasting import channel

@channel("orders.{order_id}")
async def authorize_orders(user, order_id: int) -> bool:
    return user.id == order_id

Update stubs/channels.py to import from fastapi_startkit.broadcasting rather than the facade:

# stubs/channels.py — replace
# from fastapi_startkit.facades.Broadcast import Broadcast
from fastapi_startkit.broadcasting import channel

@channel("private-notifications")
async def authorize_notifications(user) -> bool:
    return user is not None

Full blocking checklist for PR #112

# Issue
1 emit() must be async def with await
2 Test must use AsyncMock + assert_awaited_once_with
3 Add __eq__/__hash__ to Channel
4 Fix fragile user resolution in /broadcasting/auth
5 Remove facades/Broadcast.py + .pyi; replace with direct container access

…uality, direct channel decorator

- Remove facades/Broadcast.py and Broadcast.pyi entirely; remove from
  facades/__init__.py — no Broadcast facade in the broadcasting package
- Make BroadcastEvent.emit() async; resolves BroadcastManager directly
  from the Application container (app().make("broadcasting")), no facade
- Add module-level channel() decorator in helpers.py, exported from
  fastapi_startkit.broadcasting — users now import it directly:
  `from fastapi_startkit.broadcasting import channel`
- Add __eq__ and __hash__ to Channel so two Channel instances with the
  same name compare equal and are usable in sets / as dict keys
- Fix /broadcasting/auth user resolution: check callable(user_attr)
  before calling it, avoiding TypeError on property-based auth services
- Update stubs/channels.py to use `from fastapi_startkit.broadcasting import channel`
- Fix test_emit_calls_broadcast_dispatch: use AsyncMock for async emit(),
  patch at the module attribute level (event.app), add @pytest.mark.asyncio
- Add Channel __eq__/__hash__ tests to test_channels.py
- Update all docstrings that referenced the Broadcast facade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bedus-creation bedus-creation merged commit 9203b9a into main Jun 12, 2026
3 checks passed
bedus-creation added a commit that referenced this pull request Jun 12, 2026
Broadcast.pyi was deleted in main (PR #112 removed the Broadcast facade)
and modified in this branch. Accepting main's deletion since #116 is
superseded by the broadcasting/ package in main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant