4

I am in the process of rewriting the backend of an internal website from PHP to Django (using REST framework).

Both versions (PHP and Django) need to be deployed concurrently for a while, and we have a set of software tools that interact with the legacy website through a simple AJAX API. All requests are done with the GET method.

My approach so far to make requests work on both sites was to make a simple adapter app, routed to 'http://<site-name>/ajax.php' to simulate the call to the Ajax controller. Said app contains one simple function based view which retrieves data from the incoming request to determine which corresponding Django view to call on the incoming request (basically what the Ajax controller does on the PHP version).

It does work, but I encountered a problem. One of my API actions was a simple entry creation in a DB table. So I defined my DRF viewset using some generic mixins:

class MyViewSet(MyGenericViewSet, CreateModelMixin):
    # ...

This adds a create action routed to POST requests on the page. Exactly what I need. Except my incoming requests are using GET method... I could write my own create action and make it accept GET requests, but in the long run, our tools will adapt to the Django API and the adapter app will no longer be needed so I would rather have "clean" view sets and models. It makes more sense to use POST for such an action.

In my adapter app view, I naively tried this:

request.method = "POST"
request.POST = request.GET

Before handing the request to the create view. As expected it did not work and I got a CSRF authentication failure message, although my adapter app view has a @csrf_exempt decorator...

I know I might be trying to fit triangle in squares here, but is there a way to make this work without rewriting my own create action ?

John Moutafis
  • 22,254
  • 11
  • 68
  • 112
Valentin B.
  • 602
  • 6
  • 18
  • Are you using a session authentication ? If that is the case you should include the csrf token. – Elio Maisonneuve May 14 '19 at 09:44
  • your adapter view might authorize the request without the token, but I guess your viewset doesn't – Elio Maisonneuve May 14 '19 at 09:49
  • also, notice that a GET request will be considered as safe by drf and therefore needing less authentication by default than a POST request. That is why you should be extra carefull about security when going around the default behaviour of drf – Elio Maisonneuve May 14 '19 at 09:54
  • @ElioMaisonneuve Yes I am aware of that. I realise I did not mention it in my question, but this is for an internal website available only on a local network so security considerations are less of an issue. – Valentin B. May 14 '19 at 12:28
  • Have you tried to remove the csrf protection also on `MyViewSet`? – Elio Maisonneuve May 14 '19 at 16:46
  • @ElioMaisonneuve How can I do this ? The `csrf_exempt` decorator only acts on function based views to my understanding. – Valentin B. May 15 '19 at 15:21
  • if you absolutly need session authentication, try this: https://stackoverflow.com/a/30875830/5438372 – Elio Maisonneuve May 16 '19 at 03:02

4 Answers4

2

You can define a custom create method in your ViewSet, without overriding the original one, by utilizing the @action decorator that can accept GET requests and do the creation:

class MyViewSet(MyGenericViewSet, CreateModelMixin):
    ...
    @action(methods=['get'], detail=False, url_path='create-from-get')
    def create_from_get(self, request, *args, **kwargs):
        # Do your object creation here.

You will need a Router in your urls to connect the action automatically to your urls (A SimpleRouter will most likely do).
In your urls.py:

router = SimpleRouter()
router.register('something', MyViewSet, base_name='something')

urlpatterns = [
    ...
    path('my_api/', include(router.urls)),
    ...
]

Now you have an action that can create a model instance from a GET request (you need to add the logic that does that creation though) and you can access it with the following url:

your_domain/my_api/something/create-from-get

When you don't need this endpoint anymore, simply delete this part of the code and the action seizes to exist (or you can keep it for legacy reasons, that is up to you)!

John Moutafis
  • 22,254
  • 11
  • 68
  • 112
  • Thank you for your answer. This is a solution I was contemplating (I have already used the `action` decorator) but I'd rather keep all the code specific to the old AJAX API in the adapter app, i.e. without adding/modifying anything in the ViewSets of my other apps. – Valentin B. May 14 '19 at 07:11
  • @ValentinB. Is the adapter app a part of your Django project or exists as a separate project? – John Moutafis May 14 '19 at 07:42
  • it is part of the same Django project. – Valentin B. May 14 '19 at 08:50
  • @ValentinB. Then you can create this action above inside your corresponding `adapter` app view to initialize the creation of an object from a GET request. Alternatively, have you considered creating a separate adapter (like a small Flask app) to use as a medium between your legacy code and the new Django app? – John Moutafis May 14 '19 at 08:58
  • A middleware app could be an option but it would require knowledge and skills that I am not willing to acquire right now. Really I was hoping there would be a way to re-build a post request from my get request, passing the parameters of the latter as the body of the former. – Valentin B. May 14 '19 at 12:31
  • @ValentinB. Since you receive a `GET` request to your Django app (through the `adapter` app), it doesn't make much sense to do another `POST` request esoterically to another View method. It is better (and actually cleaner) to make a method that accepts a `GET` with parameters and through those parameters creates a new object, at least until you can ditch your legacy code. So I will stay with my initial answer and you should consider if it makes more sense to have this `action` on the `adapter` or on the corresponding model's app. – John Moutafis May 14 '19 at 12:40
  • Yes I understand that, but I was really fishing for a hack so that when the time comes and our software is updated for the correct API using post, no changes are needed server side for it to work and all the legacy API remains limited to the adapter app so that it can be unplugged easily. But maybe it's not worth the hassle and I should do what you suggest. – Valentin B. May 14 '19 at 13:43
  • @ValentinB. That is a sad truth with legacy code :(. The other way you can achieve what you want is to create a separate Flask (or even Sanic) app to function as the `adapter` (but you said you don't have the time for that). Good luck mate :) – John Moutafis May 14 '19 at 14:30
  • I ended up doing pretty much what you suggested, which was creating a view inside the adapter app. It's not very DRY compliant but since the adapter is supposed to be removed at one point I think it's the best compromise. I will post the solution and award you the bounty since you lead me to this path. Thanks a lot ! – Valentin B. May 16 '19 at 07:33
1

With the advice from all answers pointing to creating another view, this is what I ended up doing. Inside adapter/views.py:

from rest_framework.settings import api_settings
from rest_framework.decorators import api_view, renderer_classes
from rest_framework.response import Response
from rest_framework import status

from mycoreapp.renderers import MyJSONRenderer
from myapp.views import MyViewSet

@api_view(http_method_names=["GET"])
@renderer_classes((MyJSONRenderer,))
def create_entity_from_get(request, *args, **kwargs):
    """This view exists for compatibility with the old API only. 
    Use 'POST' method directly to create a new entity."""
    query_params_copy = request.query_params.copy()
    # This is just some adjustments to make the legacy request params work with the serializer
    query_params_copy["foo"] = {"name": request.query_params.get("foo", None)}
    query_params_copy["bar"] = {"name": request.query_params.get("bar", None)}
    serializer = MyViewSet.serializer_class(data=query_params_copy)
    serializer.is_valid(raise_exception=True)
    serializer.save()
    try:
        headers = {'Location': str(serializer.data[api_settings.URL_FIELD_NAME])}
    except (TypeError, KeyError):
        headers = {}
    return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

Of course I have obfuscated the names of everything specific to my project. Basically I reproduced almost exactly (except for a few tweaks to my query params) what happens in the create, perform_create and get_success_header methods of the DRF mixin CreateModelMixin in a single function based DRF view. Being just a standalone function it can sit in my adapter app views so that all legacy API code is sitting in one place only, which was my intent with this question.

Valentin B.
  • 602
  • 6
  • 18
0

You can write a method for your viewset (custom_get) which will be called when a GET call is made to your url, and call your create method from there.

class MyViewSet(MyGenericViewSet, CreateModelMixin):
    ...
    def custom_get(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)

And in your urls.py, for your viewset, you can define that this method needs to be called on a GET call.

#urls.py
urlpatterns = [
    ...
    url(r'^your-url/$', MyViewSet.as_view({'get': 'custom_get'}), name='url-name'),
]

Aman Garg
  • 2,507
  • 1
  • 11
  • 21
  • Thank you for your answer ! However just like John Moutafis' answer it does not quite meet what I am trying to do here, see the comment on his answer. – Valentin B. May 14 '19 at 07:12
0

As per REST architectural principles request method GET is only intended to retrieve the information. So, we should not perform a create operation with request method GET. To perform the create operation use request method POST.

Temporary Fix to your question

from rest_framework import generics, status

class CreateAPIView(generics.CreateView):

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.query_params)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self.get_success_headers(serializer.data)
        return Response(
            serializer.data,
            status=status.HTTP_201_CREATED,
            headers=headers)

    def get(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)

Please refer below references for more information.
https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html
https://learnbatta.com/blog/introduction-to-restful-apis-72/

anjaneyulubatta505
  • 10,713
  • 1
  • 52
  • 62
  • I know that, and this is exactly why I want the new site to use the generic actions. My problem is mostly that I have to have both sites running using our old API for some time before we use the right method in our tools. – Valentin B. May 14 '19 at 08:50
  • @ValentinB. updated the solution you can check it now. – anjaneyulubatta505 May 14 '19 at 09:10