wemake-services / wemake-django-rest

Create Django REST APIs the right way, no magic intended
MIT License
11 stars 1 forks source link

Consider using `Success` and `Error` types #6

Open sobolevn opened 5 years ago

sobolevn commented 5 years ago

Previously I had an idea to use attribute controller: Callable[[RequestPayload], ResponsePayload], but I have failed to come up with correct way to handle headers and status codes. And this feature is crucial.

So, I had spent some time thinking about it. Here's a quick recap:

  1. I can not use Request and Response classes inside a business logic controller. This will make my business logic dirty and will enforce bad practices of mixin my representation, interface layer, and the logic itself.
  2. I was thinking about providing good error support (#5), and making it declarative. So, why not try Enum? It is declarative, typed, easy yo use. I don't see any reasons why we should not do that.
class HTTPErrors(enum.Enum):
    client_error = HTTP400Exception  # default
    page_not_found = CustomHTTP404Exception  # custom implementation
    ...
  1. We can use these two ideas in the following way:
StatusCode = NewType(int)
Headers = NewType(Dict[str, str])  # can also be TypedDict with defined schema, still not sure

ResponseType = Union[Error[HTTPErrors], Success[ResponsePayload, StatusCode, Headers]]

controller: Callable[[RequestPayload], ResponseType]
sobolevn commented 5 years ago

Or maybe predefine all Success cases as well?

sobolevn commented 5 years ago

This could possibly look like so:

@dataclass
class Post(object):
     title: str
     content: str

class SuccessCases(ResponseEnum):  # typed abstract subclass of enum.Enum
    created = inline_factory(Post, 201, {'Location': 'What to do with the value!?'})
    untouched = SomeDefinitionWIthClass

class ErrorCases(ResponseEnum):
    not_found = inline_error_factory(404, {})
sobolevn commented 5 years ago

Still don't like this part with headers and default values for it. Not all headers can have a default value. Not all headers should. But, providing declarative headers with values seems like a hard task to me.

Why not lazy templates? Maybe we can go with something like {'Location': '/posts/{{ instance.pk }}'}. And then just format the template with instance and request context?

proofit404 commented 5 years ago

Maybe it is unrelated. This is the current state of the @dry-python project.

# views.py

from dependencies import Injector, Package, operation
from dependencies.contrib.rest_framework import model_view_set

implemented = Package("implemented")

@model_view_set
class Products(Injector):

    edit = implemented.EditProduct.edit

    @operation
    def update(edit, instance, validated_data, user, invalid_request, permission_denied):

        result = edit.run(product=instance, edits=validated_data, user=user)
        if result.is_success:
            return result.value
        elif result.failed_because(edit.failures.invalid_input):
            invalid_request()
        elif result.failed_because(edit.failures.forbidden):
            permission_denied()
# implemented.py

from dependencies import Injector, Package

services = Package("services")
repositories = Package("repositories")

class EditProduct(Injector):

    edit = services.EditProduct.edit
    update_product_record = repositories.update_model_instance
# services.py

from dataclasses import dataclass
from enum import Enum, auto
from typing import Callable, Dict

from django.db.models import Model
from stories import Failure, Result, Success, arguments, story

@dataclass
class EditProduct:
    @story
    @arguments("product", "edits", "user")
    def edit(I):

        I.validate_input
        I.check_permissions
        I.persist_edits
        I.show_product

    # Steps.

    def validate_input(self, ctx):

        if False:
            return Failure(Errors.invalid_input)
        else:
            return Success()

    def check_permissions(self, ctx):

        if False:
            return Failure(Errors.forbidden)
        else:
            return Success()

    def persist_edits(self, ctx):

        product = self.update_product_record(ctx.product, ctx.edits)
        return Success(updated_product=product)

    def show_product(self, ctx):

        return Result(ctx.updated_product)

    # Dependencies.

    update_product_record: Callable[[Model, Dict], Model]

@EditProduct.edit.failures
class Errors(Enum):

    invalid_input = auto()
    forbidden = auto()
# repositories.py

def update_model_instance(instance, data):

    for attr, value in data.items():
        setattr(instance, attr, value)
    instance.save(update_fields=data)

As you can see, we define failure protocol for the story.

Then in the view layer we check what kind of failure business object returns.

Based on business logic error we decide what API method we should call.

proofit404 commented 5 years ago

This manual check with lots of elif make me sad very often...

It doesn't validate that all failure variants were covered.

I think to add something like this with stories bind method.

# views.py

from dependencies import Injector, Package, this
from dependencies.contrib.rest_framework import model_view_set

implemented = Package("implemented")

@model_view_set
class Products(Injector):

    update = implemented.EditProduct.edit.bind

    product = this.instance
    edits = this.validated_data
    user = this.user

    on_invalid_input = this.invalid_request
    on_forbidden = this.permission_denied
sobolevn commented 5 years ago
@operation
    def update(edit, instance, validated_data, user, invalid_request, permission_denied):

        result = edit.run(product=instance, edits=validated_data, user=user)
        if result.is_success:
            return result.value
        elif result.failed_because(edit.failures.invalid_input):
            invalid_request()
        elif result.failed_because(edit.failures.forbidden):
            permission_denied()

Sorry, did get what invalid_request and permission_denied are in this context.

proofit404 commented 5 years ago

This ara shortcuts for common actions in django rest framework. They came from @model_view_set. You can define your own transport actions in the same Injector class next to the edit attribute.