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

Crafting [Better] API Clients

Avatar for Ben Lopatin 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.

Avatar for Ben Lopatin

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)