Esempio n. 1
0
    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
Esempio n. 2
0
 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)
Esempio n. 3
0
    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.")
Esempio n. 4
0
    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.")
Esempio n. 5
0
 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
Esempio n. 6
0
    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.")
Esempio n. 7
0
    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.")
Esempio n. 8
0
    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.")
Esempio n. 9
0
    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)
Esempio n. 10
0
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
Esempio n. 11
0
        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)