Skip to content

GraphQL Subscriptions via Strawberry + Channels #764

Description

@DokaIzk

Title: feat: implement GraphQL subscription support for real-time event streaming

Labels: enhancement feature api
Complexity: high
Branch: feat/graphql-subscriptions
Depends on: Issue #11


Problem Context

Issue #11 added raw WebSocket subscriptions at ws://host/ws/events/<contract_id>/. However, clients using Apollo Client or other GraphQL clients expect a typed subscription operation over the standard graphql-ws protocol — not a custom JSON wire format. This issue upgrades the real-time layer to first-class GraphQL subscriptions using Strawberry's AsyncGenerator subscription API over Django Channels.


Scope

Included:

  • Strawberry @strawberry.subscription resolver for contractEvents(contractId: String!)
  • graphql-ws protocol support via Strawberry's Channels integration
  • Subscription publishes events via the same Redis channel layer used by Issue Add real-time WebSocket event subscriptions using Django Channels #11
  • Rate-limit: maximum 5 concurrent subscriptions per IP
  • Schema updated with subscription type

Not included:

  • Subscription authentication (Phase 2)
  • Subscription filtering beyond contractId (Phase 2)

Implementation Guidelines

Files to update:

  • django-backend/soroscan/ingest/schema.py — add Subscription type with contract_events resolver
  • django-backend/soroscan/asgi.py — mount Strawberry GraphqlWsConsumer at /graphql/
  • django-backend/soroscan/ingest/tasks.py — publish to channel group after event persist (already done in Add real-time WebSocket event subscriptions using Django Channels #11, reuse)
  • django-backend/requirements.txt — ensure strawberry-graphql[channels] is installed

Subscription resolver:

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def contract_events(
        self, info: strawberry.types.Info, contract_id: str
    ) -> AsyncGenerator[ContractEventType, None]:
        channel_layer = get_channel_layer()
        channel_name = await channel_layer.new_channel()
        await channel_layer.group_add(f"events_{contract_id}", channel_name)
        try:
            while True:
                message = await channel_layer.receive(channel_name)
                yield ContractEventType(**message['data'])
        finally:
            await channel_layer.group_discard(f"events_{contract_id}", channel_name)

ASGI routing update:

from strawberry.channels import GraphqlWsConsumer

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    "websocket": AllowedHostsOriginValidator(
        AuthMiddlewareStack(
            URLRouter([
                path("graphql/", GraphqlWsConsumer.as_asgi(schema=schema)),
                path("ws/events/<str:contract_id>/", EventConsumer.as_asgi()),
            ])
        )
    ),
})

Constraints:

  • Subscription must clean up channel membership on client disconnect
  • Must not break existing HTTP GraphQL queries at /graphql/
  • Use AsyncGenerator — not Iterator — to avoid blocking the event loop

Acceptance Criteria

  • subscription { contractEvents(contractId: "X") { id eventType timestamp } } works over graphql-ws
  • Apollo Client useSubscription hook connects and receives events
  • Channel group membership is cleaned up on client disconnect
  • Existing HTTP GraphQL queries continue to work unchanged
  • Integration test verifies subscription receives event after it is persisted


Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions