def refresh_creds(self, credential): """Fetch a new Bearer Token and update the given Credential object.""" response = self.token_post(self.TOKEN_ENDPOINT, dict(grant_type="client_credentials"), allowed_response_codes=[200, 400]) # If you put in the wrong URL, this is where you'll run into # problems, so it's useful to give a helpful error message if # Odilo doesn't provide anything more specific. generic_error = "%s may not be the right base URL. Response document was: %r" % ( self.library_api_base_url, response.content) generic_exception = BadResponseException(self.TOKEN_ENDPOINT, generic_error) try: data = response.json() except ValueError: raise generic_exception if response.status_code == 200: self._update_credential(credential, data) self.token = credential.credential return elif response.status_code == 400: if data and 'errors' in data and len(data['errors']) > 0: error = data['errors'][0] if 'description' in error: message = error['description'] else: message = generic_error raise BadResponseException(self.TOKEN_ENDPOINT, message) raise generic_exception
def test_as_problem_detail_document(self): exception = BadResponseException("http://url/", "What even is this", debug_message="some debug info") document = exception.as_problem_detail_document(debug=True) eq_(502, document.status_code) eq_("Bad response", document.title) eq_( "The server made a request to http://url/, and got an unexpected or invalid response.", document.detail) eq_("What even is this\n\nsome debug info", document.debug_message)
def get_ebook_availability_info(self, media_type='ebook'): """ Gets a list of ebook items this library has access to, through the "availability" endpoint. The response at this endpoint is laconic -- just enough fields per item to identify the item and declare it either available to lend or not. :param media_type 'ebook'/'eaudio' :return A list of dictionary items, each item giving "yes/no" answer on a book's current availability to lend. Example of returned item format: "timeStamp": "2016-10-07T16:11:52.5887333Z" "isbn": "9781420128567" "mediaType": "eBook" "availability": false "titleId": 39764 """ url = "%s/libraries/%s/media/%s/availability" % ( self.base_url, str(self.library_id), media_type) response = self.request(url) try: resplist = response.json() except Exception, e: raise BadResponseException( url, "OneClick availability response not parseable.")
def test_bad_status_code_helper(object): response = MockRequestsResponse(500, content="Internal Server Error!") exc = BadResponseException.bad_status_code("http://url/", response) doc, status_code, headers = exc.as_problem_detail_document( debug=True).response doc = json.loads(doc) assert doc['debug_message'].startswith( "Got status code 500 from external server, cannot continue.")
def get(self, url, extra_headers, exception_on_401=False): """Make an HTTP GET request using the active Bearer Token.""" headers = dict(Authorization="Bearer %s" % self.token) headers.update(extra_headers) status_code, headers, content = self._do_get(url, headers) if status_code == 401: if exception_on_401: # This is our second try. Give up. raise BadResponseException.from_response( url, "Something's wrong with the Overdrive OAuth Bearer Token!", (status_code, headers, content)) else: # Refresh the token and try again. self.check_creds(True) return self.get(url, extra_headers, True) else: return status_code, headers, content
def get_all_catalog(self): """ Gets the entire OneClick catalog for a particular library. Note: This call taxes OneClick's servers, and is to be performed sparingly. The results are returned unpaged. Also, the endpoint returns about as much metadata per item as the media/{isbn} endpoint does. If want more metadata, perform a search. :return A list of dictionaries representation of the response. """ url = "%s/libraries/%s/media/all" % (self.base_url, str(self.library_id)) response = self.request(url) try: resplist = response.json() except Exception, e: raise BadResponseException(url, "OneClick all catalog response not parseable.")
def get_metadata_by_isbn(self, identifier): """ Gets metadata, s.a. publisher, date published, genres, etc for the ebook or eaudio item passed, using isbn to search on. If isbn is not found, the response we get from OneClick is an error message, and we throw an error. :return the json dictionary of the response object """ if not identifier: raise ValueError("Need valid identifier to get metadata.") identifier_string = self.create_identifier_strings([identifier])[0] url = "%s/libraries/%s/media/%s" % (self.base_url, str(self.library_id), identifier_string) response = self.request(url) try: respdict = response.json() except Exception, e: raise BadResponseException(url, "OneClick isbn search response not parseable.")
def get_all_available_through_search(self): """ Gets a list of ebook and eaudio items this library has access to, that are currently available to lend. Uses the "availability" facet of the search function. An alternative to self.get_availability_info(). Calls paged search until done. Uses minimal verbosity for result set. Note: Some libraries can see other libraries' catalogs, even if the patron cannot checkout the items. The library ownership information is in the "interest" fields of the response. :return A dictionary representation of the response, containing catalog count and ebook item - interest pairs. """ page = 0; response = self.search(availability='available', verbosity=self.RESPONSE_VERBOSITY[0]) try: respdict = response.json() except Exception, e: raise BadResponseException("availability_search", "OneClick availability response not parseable.")
def test_helper_constructor(self): response = MockRequestsResponse(102, content="nonsense") exc = BadResponseException.from_response( "http://url/", "Terrible response, just terrible", response) # Turn the exception into a problem detail document, and it's full # of useful information. doc, status_code, headers = exc.as_problem_detail_document( debug=True).response doc = json.loads(doc) eq_('Bad response', doc['title']) eq_( 'The server made a request to http://url/, and got an unexpected or invalid response.', doc['detail']) eq_( u'Terrible response, just terrible\n\nStatus code: 102\nContent: nonsense', doc['debug_message']) # Unless debug is turned off, in which case none of that # information is present. doc, status_code, headers = exc.as_problem_detail_document( debug=False).response assert 'debug_message' not in json.loads(doc)
class OneClickAPI(object): API_VERSION = "v1" PRODUCTION_BASE_URL = "https://api.oneclickdigital.com/" QA_BASE_URL = "https://api.oneclickdigital.us/" # Map simple nicknames to server URLs. SERVER_NICKNAMES = { "production": PRODUCTION_BASE_URL, "qa": QA_BASE_URL, } DATE_FORMAT = "%Y-%m-%d" #ex: 2013-12-27 # a complete response returns the json structure with more data fields than a basic response does RESPONSE_VERBOSITY = { 0: 'basic', 1: 'compact', 2: 'complete', 3: 'extended', 4: 'hypermedia' } log = logging.getLogger("OneClick API") def __init__(self, _db, collection): self._db = _db if collection.protocol != collection.ONE_CLICK: raise ValueError( "Collection protocol is %s, but passed into OneClickAPI!" % collection.protocol) self.library_id = collection.external_account_id.encode("utf8") self.token = collection.external_integration.password.encode("utf8") # Convert the nickname for a server into an actual URL. base_url = collection.external_integration.url or self.PRODUCTION_BASE_URL if base_url in self.SERVER_NICKNAMES: base_url = self.SERVER_NICKNAMES[base_url] self.base_url = (base_url + self.API_VERSION).encode("utf8") # expiration defaults are OneClick-general self.ebook_loan_length = collection.external_integration.setting( 'ebook_loan_length').value or '21' self.eaudio_loan_length = collection.external_integration.setting( 'eaudio_loan_length').value or '21' @classmethod def create_identifier_strings(cls, identifiers): identifier_strings = [] for i in identifiers: if isinstance(i, Identifier): value = i.identifier else: value = i identifier_strings.append(value) return identifier_strings @classmethod def from_config(cls, _db): """Load a OneClickAPI instance for the 'default' OneClick collection. """ library = Library.instance(_db) collections = [ x for x in library.collections if x.protocol == Collection.ONE_CLICK ] if len(collections) == 0: # There are no OneClick collections configured. return None if len(collections) > 1: raise ValueError( "Multiple OneClick collections found for one library. This is not yet supported." ) [collection] = collections return cls(_db, collection) @property def source(self): return DataSource.lookup(self._db, DataSource.ONECLICK) @property def authorization_headers(self): # the token given us by OneClick is already utf/base64-encoded authorization = self.token return dict(Authorization="Basic " + authorization) def _make_request(self, url, method, headers, data=None, params=None, **kwargs): """Actually make an HTTP request.""" return HTTP.request_with_timeout(method, url, headers=headers, data=data, params=params, **kwargs) def request(self, url, method='get', extra_headers={}, data=None, params=None, verbosity='complete'): """Make an HTTP request. """ if verbosity not in self.RESPONSE_VERBOSITY.values(): verbosity = self.RESPONSE_VERBOSITY[2] headers = dict(extra_headers) headers['Content-Type'] = 'application/json' headers['Accept-Media'] = verbosity headers.update(self.authorization_headers) # prevent the code throwing a BadResponseException when OneClick # responds with a 500, because OneClick uses 500s to indicate bad input, # rather than server error. # must list all 9 possibilities to use allowed_response_codes = [ '1xx', '2xx', '3xx', '4xx', '5xx', '6xx', '7xx', '8xx', '9xx' ] # for now, do nothing with disallowed error codes, but in the future might have # some that will warrant repeating the request. disallowed_response_codes = [] response = self._make_request( url=url, method=method, headers=headers, data=data, params=params, allowed_response_codes=allowed_response_codes, disallowed_response_codes=disallowed_response_codes) return response ''' --------------------- Getters and Setters -------------------------- ''' def get_all_available_through_search(self): """ Gets a list of ebook and eaudio items this library has access to, that are currently available to lend. Uses the "availability" facet of the search function. An alternative to self.get_availability_info(). Calls paged search until done. Uses minimal verbosity for result set. Note: Some libraries can see other libraries' catalogs, even if the patron cannot checkout the items. The library ownership information is in the "interest" fields of the response. :return A dictionary representation of the response, containing catalog count and ebook item - interest pairs. """ page = 0 response = self.search(availability='available', verbosity=self.RESPONSE_VERBOSITY[0]) try: respdict = response.json() except Exception, e: raise BadResponseException( "availability_search", "OneClick availability response not parseable.") if not respdict: raise BadResponseException( "availability_search", "OneClick availability response not parseable - has no structure." ) if not ('pageIndex' in respdict and 'pageCount' in respdict): raise BadResponseException( "availability_search", "OneClick availability response not parseable - has no page counts." ) page_index = respdict['pageIndex'] page_count = respdict['pageCount'] while (page_count > (page_index + 1)): page_index += 1 response = self.search(availability='available', verbosity=self.RESPONSE_VERBOSITY[0], page_index=page_index) tempdict = response.json() if not ('items' in tempdict): raise BadResponseException( "availability_search", "OneClick availability response not parseable - has no next dict." ) item_interest_pairs = tempdict['items'] respdict['items'].extend(item_interest_pairs) return respdict
identifier_string = self.create_identifier_strings([identifier])[0] url = "%s/libraries/%s/media/%s" % (self.base_url, str( self.library_id), identifier_string) response = self.request(url) try: respdict = response.json() except Exception, e: raise BadResponseException( url, "OneClick isbn search response not parseable.") if not respdict: # should never happen raise BadResponseException( url, "OneClick isbn search response not parseable - has no respdict." ) if "message" in respdict: message = respdict['message'] if (message.startswith( "Invalid 'MediaType', 'TitleId' or 'ISBN' token value supplied: " ) or message.startswith( "eXtensible Framework was unable to locate the resource")): # we searched for item that's not in library's catalog -- a mistake, but not an exception return None else: # something more serious went wrong error_message = "get_metadata_by_isbn(%s) in library #%s catalog ran into problems: %s" % ( identifier_string, str(self.library_id), error_message) raise BadResponseException(url, message)