Skip to content

Class Based Operations

This is just a proposal and it is not present in library code, but eventually this can be a part of Django Ninja.

Please consider adding likes/dislikes or comments in github issue to express your feeling about this proposal

Problem

An API operation is a callable which takes a request and parameters and returns a response, but it is often a case in real world when you need to reuse the same pieces of code in multiple operations.

Let's take the following example:

  • we have a Todo application with Projects and Tasks
  • each project has multiple tasks
  • each project may also have an owner (user)
  • users should not be able to access projects they do not own

Model structure is something like this:

class Project(models.Model):
    title = models.CharField(max_length=100)
    owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)

class Task(models.Model):
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
    title = models.CharField(max_length=100)
    completed = models.BooleanField()

Now, let's create a few API operations for it:

  • a list of tasks for the project
  • some task details
  • a 'complete task' action

The code should validate that a user can only access his/her own project's tasks (otherwise, return 404)

It can be something like this:

router = Router()

@router.get('/project/{project_id}/tasks/', response=List[TaskOut])
def task_list(request):
    user_projects = request.user.project_set
    project = get_object_or_404(user_projects, id=project_id))
    return project.task_set.all()

@router.get('/project/{project_id}/tasks/{task_id}/', response=TaskOut)
def details(request, task_id: int):
    user_projects = request.user.project_set
    project = get_object_or_404(user_projects, id=project_id))
    user_tasks = project.task_set.all()
    return get_object_or_404(user_tasks, id=task_id)


@router.post('/project/{project_id}/tasks/{task_id}/complete', response=TaskOut)
def complete(request, task_id: int):
    user_projects = request.user.project_set
    project = get_object_or_404(user_projects, id=project_id))
    user_tasks = project.task_set.all()
    task = get_object_or_404(user_tasks, id=task_id)
    task.completed = True
    task.save()
    return task

As you can see, these lines are getting repeated pretty often to check permission:

user_projects = request.user.project_set
project = get_object_or_404(user_projects, id=project_id))

You can extract it to a function, but it will just make it 3 lines smaller, and it will still be pretty polluted ...

Solution

The proposal is to have alternative called "Class Based Operation" where you can decorate the entire class with a path decorator:

from ninja import Router


router = Router()


@router.path('/project/{project_id}/tasks')
class Tasks:
    def __init__(self, request, project_id=int):
        user_projects = request.user.project_set
        self.project = get_object_or_404(user_projects, id=project_id))
        self.tasks = self.project.task_set.all()

    @router.get('/', response=List[TaskOut])
    def task_list(self, request):
        return self.tasks

    @router.get('/{task_id}/', response=TaskOut)
    def details(self, request, task_id: int):
        return get_object_or_404(self.tasks, id=task_id)

    @router.post('/{task_id}/complete', response=TaskOut)
    def complete(self, request, task_id: int):
        task = get_object_or_404(self.tasks, id=task_id)
        task.completed = True
        task.save()
        return task

All common initiation and permission checks are placed in the constructor:

@router.path('/project/{project_id}/tasks')
class Tasks:
    def __init__(self, request, project_id=int):
        user_projects = request.user.project_set
        self.project = get_object_or_404(user_projects, id=project_id))
        self.tasks = self.project.task_set.all()

This makes the main business operation focus only on tasks (exposed as the self.tasks attribute)

You can use both api and router instances to support class paths.

Issue

The __init__ method:

def __init__(self, request, project_id=int):

Python doesn't support the async keyword for __init__, so to support async operations we need some other method for initialization, but __init__ sounds the most logical.

Your thoughts/proposals

Please give you thoughts/likes/dislikes about this proposal in the github issue