diff --git a/bravado/bitjws_client.py b/bravado/bitjws_client.py new file mode 100644 index 00000000..c81ae080 --- /dev/null +++ b/bravado/bitjws_client.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +""" +The :class:`SwaggerClient` provides an interface for making API calls based on +a swagger spec, and returns responses of python objects which build from the +API response. + +Structure Diagram:: + + +---------------------+ + | | + | SwaggerClient | + | | + +------+--------------+ + | + | has many + | + +------v--------------+ + | | + | Resource +------------------+ + | | | + +------+--------------+ has many | + | | + | has many | + | | + +------v--------------+ +------v--------------+ + | | | | + | Operation | | SwaggerModel | + | | | | + +------+--------------+ +---------------------+ + | + | uses + | + +------v--------------+ + | | + | HttpClient | + | | + +---------------------+ + + +To get a client + +.. code-block:: python + + client = bravado.client.SwaggerClient.from_url(swagger_spec_url) +""" +import functools +import logging +import sys + +from bravado_core.docstring import create_operation_docstring +from bravado_core.exception import MatchingResponseNotFound +from bravado_core.exception import SwaggerMappingError +from bravado_core.formatter import SwaggerFormat # noqa +from bravado_core.param import marshal_param +from bravado_core.response import unmarshal_response +from bravado_core.spec import Spec +import six +from six import iteritems, itervalues + +from bravado.docstring_property import docstring_property +from bravado.exception import HTTPError +from bravado.requests_client import RequestsClient +from bravado.swagger_model import Loader +from bravado.warning import warn_for_deprecated_op + +from bravado.client import * + +import bitjws + +log = logging.getLogger(__name__) + + +class BitJWSResourceDecorator(object): + """ + Wraps :class:`bravado_core.resource.Resource` so that accesses to contained + operations can be instrumented. + """ + + def __init__(self, resource): + """ + :type resource: :class:`bravado_core.resource.Resource` + """ + self.resource = resource + + def __getattr__(self, name): + """ + :rtype: :class:`CallableOperation` + """ + return BitJWSCallableOperation(getattr(self.resource, name)) + + def __dir__(self): + """ + Exposes correct attrs on resource when tab completing in a REPL + """ + return self.resource.__dir__() + + +class BitJWSCallableOperation(object): + """ + Wraps an operation to make it callable and provide a docstring. Calling + the operation uses the configured http_client. + """ + def __init__(self, operation): + """ + :type operation: :class:`bravado_core.operation.Operation` + """ + self.operation = operation + + @docstring_property(__doc__) + def __doc__(self): + return create_operation_docstring(self.operation) + + def __getattr__(self, name): + """ + Forward requests for attrs not found on this decorator to the delegate. + """ + return getattr(self.operation, name) + + def construct_request(self, **op_kwargs): + """ + :param op_kwargs: parameter name/value pairs to passed to the + invocation of the operation. + :return: request in dict form + """ + request_options = op_kwargs.pop('_request_options', {}) + url = self.operation.swagger_spec.api_url.rstrip('/') + self.path_name + request = { + 'method': self.operation.http_method.upper(), + 'url': url, + 'params': {}, # filled in downstream + 'headers': request_options.get('headers', {}), + } + + # Copy over optional request options + for request_option in ('connect_timeout', 'timeout'): + if request_option in request_options: + request[request_option] = request_options[request_option] + + self.construct_params(request, op_kwargs) + return request + + def construct_params(self, request, op_kwargs): + """ + Given the parameters passed to the operation invocation, validates and + marshals the parameters into the provided request dict. + + :type request: dict + :param op_kwargs: the kwargs passed to the operation invocation + :raises: SwaggerMappingError on extra parameters or when a required + parameter is not supplied. + """ + current_params = self.operation.params.copy() + for param_name, param_value in iteritems(op_kwargs): + param = current_params.pop(param_name, None) + if param is None: + raise SwaggerMappingError( + "{0} does not have parameter {1}" + .format(self.operation.operation_id, param_name)) + marshal_param(param, param_value, request) + + # Check required params and non-required params with a 'default' value + for remaining_param in itervalues(current_params): + if remaining_param.required: + raise SwaggerMappingError( + '{0} is a required parameter'.format(remaining_param.name)) + if not remaining_param.required and remaining_param.has_default(): + marshal_param(remaining_param, None, request) + + def __call__(self, **op_kwargs): + """ + Invoke the actual HTTP request and return a future that encapsulates + the HTTP response. + + :rtype: :class:`bravado.http_future.HTTPFuture` + """ + log.debug(u"%s(%s)" % (self.operation.operation_id, op_kwargs)) + warn_for_deprecated_op(self.operation) + request_params = self.construct_request(**op_kwargs) + callback = functools.partial(bitjws_response_callback, operation=self) + return self.operation.swagger_spec.http_client.request(request_params, + callback) + + +def bitjws_response_callback(incoming_response, operation): + """ + So the http_client is finished with its part of processing the response. + This hands the response over to bravado_core for validation and + unmarshalling. + + :type incoming_response: :class:`bravado_core.response.IncomingResponse` + :type operation: :class:`bravado_core.operation.Operation` + :return: Response spec's return value. + :raises: HTTPError + - On 5XX status code, the HTTPError has minimal information. + - On non-2XX status code with no matching response, the HTTPError + contains a detailed error message. + - On non-2XX status code with a matching response, the HTTPError + contains the return value. + """ + raise_on_unexpected(incoming_response) + + print incoming_response.text + print incoming_response._delegate.content.decode('utf8') + try: + swagger_return_value = unmarshal_response(incoming_response, operation) + except MatchingResponseNotFound as e: + six.reraise( + HTTPError, + HTTPError(response=incoming_response, message=str(e)), + sys.exc_info()[2]) + + raise_on_expected(incoming_response, swagger_return_value) + return swagger_return_value + diff --git a/bravado/bitjws_requests_client.py b/bravado/bitjws_requests_client.py new file mode 100644 index 00000000..abc90c08 --- /dev/null +++ b/bravado/bitjws_requests_client.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +import logging +import json +import bitjws +import requests +import requests.auth +from bravado.requests_client import * + +from bravado.http_client import HttpClient +from bravado.http_future import HttpFuture + +log = logging.getLogger(__name__) + + +class BitJWSAuthenticator(Authenticator): + """BitJWS authenticator uses JWS and the CUSTOM-BITCOIN-SIGN algorithm. + + :param host: Host to authenticate for. + :param privkey: Private key as a WIF string + """ + + def __init__(self, host, privkey): + super(BitJWSAuthenticator, self).__init__(host) + self.privkey = bitjws.PrivateKey(bitjws.wif_to_privkey(privkey)) + + def apply(self, request): + if len(request.data) > 0: + data = bitjws.sign_serialize(self.privkey, **json.loads(request.data)) + else: + data = bitjws.sign_serialize(self.privkey, **request.params) + request.params = {} + request.data = data + return request + + +class BitJWSRequestsClient(HttpClient): + """Synchronous HTTP client implementation. + """ + + def __init__(self): + self.session = requests.Session() + self.authenticator = None + + @staticmethod + def separate_params(request_params): + """Splits the passed in dict of request_params into two buckets. + + - sanitized_params are valid kwargs for constructing a + requests.Request(..) + - misc_options are things like timeouts which can't be communicated + to the Requests library via the requests.Request(...) constructor. + + :param request_params: kitchen sink of request params. Treated as a + read-only dict. + :returns: tuple(sanitized_params, misc_options) + """ + sanitized_params = request_params.copy() + misc_options = {} + + if 'connect_timeout' in sanitized_params: + misc_options['connect_timeout'] = \ + sanitized_params.pop('connect_timeout') + + if 'timeout' in sanitized_params: + misc_options['timeout'] = sanitized_params.pop('timeout') + + return sanitized_params, misc_options + + def request(self, request_params, response_callback=None): + """ + :param request_params: complete request data. + :type request_params: dict + :param response_callback: Function to be called on the response + :returns: HTTP Future object + :rtype: :class: `bravado_core.http_future.HttpFuture` + """ + sanitized_params, misc_options = self.separate_params(request_params) + requests_future = RequestsFutureAdapter( + self.session, + self.authenticated_request(sanitized_params), + misc_options) + + return HttpFuture( + requests_future, + BitJWSRequestsResponseAdapter, + response_callback, + ) + + def set_basic_auth(self, host, username, password): + self.authenticator = BasicAuthenticator( + host=host, username=username, password=password) + + def set_api_key(self, host, api_key, param_name=u'api_key'): + self.authenticator = ApiKeyAuthenticator( + host=host, api_key=api_key, param_name=param_name) + + def set_bitjws_key(self, host, privkey): + self.authenticator = BitJWSAuthenticator(host=host, privkey=privkey) + + def authenticated_request(self, request_params): + return self.apply_authentication(requests.Request(**request_params)) + + def apply_authentication(self, request): + if self.authenticator and self.authenticator.matches(request.url): + return self.authenticator.apply(request) + return request + + +class BitJWSRequestsResponseAdapter(IncomingResponse): + """Wraps a requests.models.Response object to provide a uniform interface + to the response innards. + """ + + def __init__(self, requests_lib_response): + """ + :type requests_lib_response: :class:`requests.models.Response` + """ + self._delegate = requests_lib_response + + @property + def status_code(self): + return self._delegate.status_code + + @property + def text(self): + return self._delegate.text + + @property + def reason(self): + return self._delegate.reason + + def json(self, **kwargs): + if 'content-type' in self._delegate.headers and \ + 'json' in self._delegate.headers['content-type']: + jso = self._delegate.json(**kwargs) + else: + rawtext = self.text.decode('utf8') + headers, jso = bitjws.validate_deserialize(rawtext) + return jso + diff --git a/bravado/client.py b/bravado/client.py index 6c4cee3b..b43f9fe7 100644 --- a/bravado/client.py +++ b/bravado/client.py @@ -70,15 +70,18 @@ class SwaggerClient(object): """A client for accessing a Swagger-documented RESTful service. """ - def __init__(self, swagger_spec): + def __init__(self, swagger_spec, resource_decorator=None): """ :param swagger_spec: :class:`bravado_core.spec.Spec` + :param resource_decorator: The ResourceDecorator class to use + :type resource_decorator: ResourceDecorator """ self.swagger_spec = swagger_spec + self.resource_decorator = resource_decorator or ResourceDecorator @classmethod def from_url(cls, spec_url, http_client=None, request_headers=None, - config=None): + config=None, resource_decorator=None): """ Build a :class:`SwaggerClient` from a url to the Swagger specification for a RESTful API. @@ -91,6 +94,8 @@ def from_url(cls, spec_url, http_client=None, request_headers=None, :type request_headers: dict :param config: bravado_core config dict. See bravado_core.spec.CONFIG_DEFAULTS + :param resource_decorator: The ResourceDecorator class to use + :type resource_decorator: ResourceDecorator """ # TODO: better way to customize the request for api calls, so we don't # have to add new kwargs for everything @@ -98,11 +103,12 @@ def from_url(cls, spec_url, http_client=None, request_headers=None, http_client = http_client or RequestsClient() loader = Loader(http_client, request_headers=request_headers) spec_dict = loader.load_spec(spec_url) - return cls.from_spec(spec_dict, spec_url, http_client, config) + return cls.from_spec(spec_dict, spec_url, http_client, config, + resource_decorator) @classmethod def from_spec(cls, spec_dict, origin_url=None, http_client=None, - config=None): + config=None, resource_decorator=None): """ Build a :class:`SwaggerClient` from swagger api docs @@ -110,11 +116,13 @@ def from_spec(cls, spec_dict, origin_url=None, http_client=None, :param origin_url: the url used to retrieve the spec_dict :type origin_url: str :param config: Configuration dict - see spec.CONFIG_DEFAULTS + :param resource_decorator: The ResourceDecorator class to use + :type resource_decorator: ResourceDecorator """ http_client = http_client or RequestsClient() swagger_spec = Spec.from_dict( spec_dict, origin_url, http_client, config) - return cls(swagger_spec) + return cls(swagger_spec, resource_decorator) def get_model(self, model_name): return self.swagger_spec.definitions[model_name] @@ -135,7 +143,7 @@ def __getattr__(self, item): # Wrap bravado-core's Resource and Operation objects in order to # execute a service call via the http_client. - return ResourceDecorator(resource) + return self.resource_decorator(resource) def __dir__(self): return self.swagger_spec.resources.keys()