PUT/PATCH: the Verb Legacy

While it's not strictly the case, PUT and PATCH are fairly often used interchangeably on REST APIs. In the previous section, we implemented the POST verb, which is typically used for creating a brand new entity on the remote service. In contrast, PUT and PATCH are most typically used to update an entity that already exists. Considering that they are often used synonymously, we're going to go ahead and implement these at the same time.

Tests

Another fortunate bit is that put and patch have the same signature as post. That being the case, let's go ahead and copypasta the post test in maury/tests/test_client.py and modify it for our new methods:

    @requests_mock.Mocker()
    def test_put(self, m):
        # The happy path
        m.put('https://api.engineyard.com/sausages', text='{"sausages":"gold"}')

        c = Client(token = 'faketoken')
        result = c.put('sausages')
        self.assertTrue(result.ok)
        self.assertEqual(result.body, {'sausages' : 'gold'})

        # The happy path with params
        m.put(
                'https://api.engineyard.com/sausages?color=gold',
                text='{"sausages":"yep"}')

        result = c.put('sausages', params = {'color' : 'gold'})
        self.assertTrue(result.ok)
        self.assertEqual(result.body, {'sausages' : 'yep'})

        # A wild API error appears!
        m.put(
                'https://api.engineyard.com/ed209',
                status_code = 500,
                text = 'Drop your weapon. You have 20 seconds to comply.')

        result = c.put('ed209')
        self.assertFalse(result.ok)
        self.assertFalse(result.error == None)

        # PEBCAK
        m.put(
                'https://api.engineyard.com/404',
                status_code = 404,
                text = 'You are now staring into the void. It is staring back.')

        result = c.put('404')
        self.assertFalse(result.ok)
        self.assertFalse(result.error == None)

    @requests_mock.Mocker()
    def test_patch(self, m):
        # The happy path
        m.patch('https://api.engineyard.com/sausages', text='{"sausages":"gold"}')

        c = Client(token = 'faketoken')
        result = c.patch('sausages')
        self.assertTrue(result.ok)
        self.assertEqual(result.body, {'sausages' : 'gold'})

        # The happy path with params
        m.patch(
                'https://api.engineyard.com/sausages?color=gold',
                text='{"sausages":"yep"}')

        result = c.patch('sausages', params = {'color' : 'gold'})
        self.assertTrue(result.ok)
        self.assertEqual(result.body, {'sausages' : 'yep'})

        # A wild API error appears!
        m.patch(
                'https://api.engineyard.com/ed209',
                status_code = 500,
                text = 'Drop your weapon. You have 20 seconds to comply.')

        result = c.patch('ed209')
        self.assertFalse(result.ok)
        self.assertFalse(result.error == None)

        # PEBCAK
        m.patch(
                'https://api.engineyard.com/404',
                status_code = 404,
                text = 'You are now staring into the void. It is staring back.')

        result = c.patch('404')
        self.assertFalse(result.ok)
        self.assertFalse(result.error == None)

As expected, these tests fail, as we don't yet have either the put or patch mehods in our client.

Implementing the Methods

So, let's crack open maury/client.py again, copypasta the post method, and modify it to fit our new methods:

    def put(self, path, params = None, data = None):
        """Perform an HTTP PUT on the API.
        
        Given an endpoint path, a dictionary of parameters, and a dictionary of
        PUT data, send the request to the API and return the result.

        Positional arguments:
        path -- the path of the API endpoint you wish to PUT

        Keyword arguments:
        params -- a dictionary of query params (default: None)
        data -- a dictionary of PUT data (default: None)
        """

        r = requests.put(self.__construct_request_url(path),
                params = params,
                json = data,
                headers = self.__headers)

        return self.__process_response(r)

    def patch(self, path, params = None, data = None):
        """Perform an HTTP PATCH on the API.
        
        Given an endpoint path, a dictionary of parameters, and a dictionary of
        PATCH data, send the request to the API and return the result.

        Positional arguments:
        path -- the path of the API endpoint you wish to PATCH

        Keyword arguments:
        params -- a dictionary of query params (default: None)
        data -- a dictionary of PATCH data (default: None)
        """

        r = requests.patch(self.__construct_request_url(path),
                params = params,
                json = data,
                headers = self.__headers)

        return self.__process_response(r)

With that, our tests pass, and we've now implemented 80% of the HTTP verbs that we need to meet our requirements. That means it's time to take another look and decide if we should refactor further.

Refactoring: Request Handler

It might seem obvious that since we've already made a common method to handle the processing of an API response that it would also make sense to break out a method that performs the requests in question.

That is a fine observation, and it is indeed what I would usually do myself. I'm not going to do so here, but let's explore it a bit.

In languages with which I have a higher degree of familiarity, I do HTTP requests the "hard" way. That is, so far, we've been doing things the easy way with methods like request.get(). What I'll usually do is to wrap the HTTP module's generic method is to send a request to the server, specifying the verb along the way. Then, my verb-related methods just dispatch to that wrapper.

In Python, it would look something like this (docstrings shortened for brevity):

    def __make_request(self, verb, path, params = None, data = None):
        """Send an HTTP request to the server."""

        # send the request to the server, return the processed response

    def get(self, path, params = None):
        """Perform an HTTP GET on the API."""

        return self.__make_request('GET', path, params = params)

    def post(self, path, params = None, data = None):
        """Perform an HTTP POST on the API."""

        return self.__make_request('POST', path, params = params, data = data)

    def put(self, path, params = None, data = None):
        """Perform an HTTP PUT on the API."""

        return self.__make_request('PUT', path, params = params, data = data)

    def patch(self, path, params = None, data = None):
        """Perform an HTTP PATCH on the API."""

        return self.__make_request('PATCH', path, params = params, data = data)

Granted, if we run our test suite right now, we get just a ton of errors, because we changed the verbs. The requests package, contrary to my initial thoughts, will allow us to do things the hard way pretty easily. So, let's fill in that __make_request method:

    def __make_request(self, verb, path, params = None, data = None):
        """Send an HTTP request to the server."""

        response = requests.request(
                # Uppercase the verb, just in case
                verb.upper(),
                self._construct_request_url(path),
                params = params,
                json = data,
                headers = self.__headers,
                allow_redirects = True
                )

        return self.__process_response(response)

Okay, that's actually not so bad. The only reason that it leaves a bad taste in my mouth at all is that finding requests.request() was a bit difficult. It is mentioned in the docs (under the Advanced Usage section), but that's not how I found it. I actually ended up digging through the source until I figured out how requests.get() works, then working backwards.

As it were, that's also where I got the allow_redirects argument. All of the verb-related methods in the requests package set this argument to True by default before dispatching to the request() method, so it seems a good idea for us to do so as well. Another fun tidbit: our driver's verb implementations now work almost identically to the way that the verbs in the requests package do.

I rather like this design. Our public verb methods don't do any heavy lifting. Instead, they dispatch to private (not really, but by Python social convention) methods that do all of the heavy lifting.