Skip to content

Custom action with @service_action

DRF's @action decorator adds extra routes to a viewset. @service_action wraps @action and routes the call through the same validate-dispatch-render plumbing as the standard mutation actions — input is validated against the spec's input_serializer, the service is dispatched with resolve_callable_kwargs, exceptions are mapped to DRF responses, output is rendered through output_serializer, and the whole call is wrapped in transaction.atomic() (unless the spec opts out).

Example: approve an invoice

from dataclasses import dataclass

from rest_framework_dataclasses.serializers import DataclassSerializer
from rest_framework_services import ServiceSpec, ServiceViewSet, service_action


@dataclass
class ApproveInput:
    note: str = ""


@dataclass
class InvoiceDetail:
    id: int
    customer: str
    amount_cents: int
    status: str


class InvoiceDetailSerializer(DataclassSerializer):
    class Meta:
        dataclass = InvoiceDetail


def approve_invoice(*, instance, data):
    instance.status = "approved"
    instance.note = data.note
    instance.save(update_fields=["status", "note"])
    return instance


class InvoiceViewSet(ServiceViewSet):
    queryset = Invoice.objects.all()
    serializer_class = InvoiceDetailSerializer

    @service_action(
        ServiceSpec(
            service=approve_invoice,
            input_serializer=ApproveInput,
            output_serializer=InvoiceDetailSerializer,
        ),
        detail=True,
        methods=["post"],
    )
    def approve(self, request, pk=None):
        """Approve an invoice."""

Routed as POST /invoices/{pk}/approve/. The decorated method body is not executed — the decorator supplies the handler. Keep the body because:

  • the docstring becomes the action description (visible in the browsable API and DRF spectacular),
  • the function name becomes the URL segment unless you set url_path,
  • it's a place to attach @action-compatible kwargs (url_path, url_name, permission_classes, throttle_classes, serializer_class, …).

Read-only actions

Skip input_serializer for actions that don't take a body. The service just receives instance (and any other kwargs it asks for):

def export_invoice(*, instance):
    return render_pdf(instance)


class InvoiceViewSet(ServiceViewSet):
    @service_action(
        ServiceSpec(service=export_invoice),
        detail=True,
        methods=["get"],
    )
    def export(self, request, pk=None):
        """Render the invoice as PDF."""

Collection-level actions

Set detail=False and the URL is <basename>/<action>/:

@service_action(
    ServiceSpec(service=archive_old_invoices),
    detail=False,
    methods=["post"],
)
def archive_old(self, request):
    """Archive every invoice older than 90 days."""

The service receives request (and not instance). archive_old_invoices can declare any subset of {request, user, view, data} it needs.

Why not just @action?

@action gives you the URL, but you write the dispatch yourself — validation, kwarg pool, exception mapping, atomic wrapping. @service_action does that for you. If you're already paying the cost of writing a service for the standard CRUD actions, custom actions get the same treatment for free.