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.