Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Crafting [Better] API Clients

Ben Lopatin
February 08, 2015

Crafting [Better] API Clients

This talk is primarily for web developers. It's about understanding how to write an API client that is testable and sensible without being more opinionated than necessary.

A Python-centric talk given at PyTennessee 2015 in Nashville, TN.

Ben Lopatin

February 08, 2015
Tweet

More Decks by Ben Lopatin

Other Decks in Programming

Transcript

  1. class DataResource:
 def __init__(self, id, first_name, last_name):
 self.id = id


    self.first_name = first_name
 self.last_name = last_name
 
 
 resource.first_name
  2. class DataResource(dict):
 
 def update(self, client):
 # Do something cool

    here
 client.update(self.id, self.first_name, self.last_name)

  3. Data or Resources? • If you think about everything as

    a resource, then shouldn’t it have the same methods? • Or is it data and the API is just providing some • You’re not obliged to represent 1:1
  4. Second, we wanted to give you a heads up that

    we're announcing a new feature soon -- additional fields. This will allow people to get congressional districts, state legislative districts, timezones, and school districts with a forward or reverse lookup. We are looking to add more additional fields in the future (namely Census data).
  5. try: 
 response = client.request()
 except APIPaymentError:
 # Email corporate

    accounts payable
 raise
 except APIAuthError:
 # Redirect to account settings
 raise
  6. class SmartyStreetsError(Exception):
 """Unknown SmartyStreets error"""
 
 def __str__(self):
 return self.__doc__


    
 
 class SmartyStreetsInputError(SmartyStreetsError):
 """HTTP 400 Bad input. Required fields missing from input or are malformed."""
 
 
 class SmartyStreetsAuthError(SmartyStreetsError):
 """HTTP 401 Unauthorized. Authentication failure; invalid credentials"""
 
 
 class SmartyStreetsPaymentError(SmartyStreetsError):
 """HTTP 402 Payment required. No active subscription found."""
 
 
 class SmartyStreetsServerError(SmartyStreetsError):
 """HTTP 500 Internal server error. General service failure; retry request."""
  7. @log_requests
 def _req(self, method='get', verb=None, headers={}, params={}, data={}): 
 url

    = self.BASE_URL.format(verb=verb)
 request_headers = {'content-type': 'application/json'}
 request_params = {'api_key': self.API_KEY}
 request_headers.update(headers)
 request_params.update(params)
 return getattr(requests, method)(url, params=request_params,
 headers=request_headers, data=data)
  8. 
 def log_requests(func):
 def decorator(*args, **kwargs):
 request_id = str(uuid.uuid4())
 logger.info("Requesting

    %s" % request_id)
 try:
 resp = func(*args, **kwargs)
 except:
 logger.exception("Request error %s" % request_id)
 raise
 logger.info("Response %s" % request_id)
 return resp
 return decorator
  9. @responses.activate def test_auth_error(self): responses.add(responses.POST, 'https://api.smartystreets.com/street-address', body='', status=401, content_type='application/json') self.assertRaises(SmartyStreetsAuthError, self.client.street_addresses,

    [{}, {}]) @responses.activate def test_payment_error(self): responses.add(responses.POST, 'https://api.smartystreets.com/street-address', body='', status=402, content_type='application/json') self.assertRaises(SmartyStreetsPaymentError, self.client.street_addresses, [{}, {}])
  10. @httpretty.activate def test_auth_error(self): """Ensure an HTTP 403 code raises GeocodioAuthError"""

    httpretty.register_uri(httpretty.GET, “http://api.geocod.io/v1/parse", body="This does not matter", status=403) self.assertRaises(GeocodioAuthError, self.client.parse, "")
  11. 
 class APIData:
 __slots__ = ['id', 'name', 'image']
 
 def

    init(self, id, name, image):
 self.id = id
 self.name = name
 self.image = image

  12. response_iter = (
 grequests.post(
 url=url,
 data=json.dumps(data_chunk),
 params=params,
 headeresponse_iter=headeresponse_iter,
 ) for

    data_chunk in chunker(data, 100)
 )
 
 responses = grequests.imap(response_iter, size=parallelism)
 status_codes = {}
 addresses = AddressCollection([])
 for response in responses:
 if response.status_code not in status_codes.keys():
 status_codes[response.status_code] = 1
 else:
 status_codes[response.status_code] += 1
 
 if response.status_code == 200:
 addresses[0:0] = AddressCollection(response.json())
  13. session = FuturesSession(max_workers=parallelism)
 session.headers = headers
 session.params = params
 


    futures = [
 session.post(url, data=json.dumps(data_chunk)) for data_chunk in chunker(data, 100)
 ]
 
 while not all([f.done() for f in futures]):
 continue
 
 status_codes = {}
 responses = [f.result() for f in futures]
 
 addresses = AddressCollection([])
 for response in responses:
 if response.status_code not in status_codes.keys():
 status_codes[response.status_code] = 1
 else:
 status_codes[response.status_code] += 1
 
 if response.status_code == 200:
 addresses[0:0] = AddressCollection(response.json())
  14. No?

  15. • Create the client *first* then Django integration is a

    bonus. • Mapping models to a distant API. • It’ll be easier for you to maintain and test • Easier for other people to use for other things
  16. class SomeModel(models.Model):
 my_field = models.CharField(max_length=100)
 
 def sync(self, client=None):
 if

    client is None:
 client = APIClient()
 client.update(id=self.id, name=self.my_field)