blob: 9a184c580596e1a6d783460bff964de6466d40a8 [file] [log] [blame]
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +01001# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""MAAS OAuth API connection library."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = [
16 'MAASClient',
17 'MAASDispatcher',
18 'MAASOAuth',
19 ]
20
21import gzip
22from io import BytesIO
23import urllib2
24
25from apiclient.encode_json import encode_json_data
26from apiclient.multipart import encode_multipart_data
27from apiclient.utils import urlencode
28import oauth.oauth as oauth
29
30
31class MAASOAuth:
32 """Helper class to OAuth-sign an HTTP request."""
33
34 def __init__(self, consumer_key, resource_token, resource_secret):
35 resource_tok_string = "oauth_token_secret=%s&oauth_token=%s" % (
36 resource_secret, resource_token)
37 self.resource_token = oauth.OAuthToken.from_string(resource_tok_string)
38 self.consumer_token = oauth.OAuthConsumer(consumer_key, "")
39
40 def sign_request(self, url, headers):
41 """Sign a request.
42
43 @param url: The URL to which the request is to be sent.
44 @param headers: The headers in the request. These will be updated
45 with the signature.
46 """
47 oauth_request = oauth.OAuthRequest.from_consumer_and_token(
48 self.consumer_token, token=self.resource_token, http_url=url)
49 oauth_request.sign_request(
50 oauth.OAuthSignatureMethod_PLAINTEXT(), self.consumer_token,
51 self.resource_token)
52 headers.update(oauth_request.to_header())
53
54
55class NoAuth:
56 """Anonymous authentication class for making unauthenticated requests."""
57
58 def __init__(self, *args, **kwargs):
59 pass
60
61 def sign_request(self, *args, **kwargs):
62 """Go through the motions of signing a request.
63
64 Since this class does not really authenticate, this does nothing.
65 """
66
67
68class RequestWithMethod(urllib2.Request):
69 """Enhances urllib2.Request so an http method can be supplied."""
70 def __init__(self, *args, **kwargs):
71 self._method = kwargs.pop('method', None)
72 urllib2.Request.__init__(self, *args, **kwargs)
73
74 def get_method(self):
75 return (
76 self._method if self._method
77 else super(RequestWithMethod, self).get_method())
78
79
80class MAASDispatcher:
81 """Helper class to connect to a MAAS server using blocking requests.
82
83 Be careful when changing its API: this class is designed so that it
84 can be replaced with a Twisted-enabled alternative. See the MAAS
85 provider in Juju for the code this would require.
86 """
87
88 def dispatch_query(self, request_url, headers, method="GET", data=None):
89 """Synchronously dispatch an OAuth-signed request to L{request_url}.
90
91 :param request_url: The URL to which the request is to be sent.
92 :param headers: Headers to include in the request.
93 :type headers: A dict.
94 :param method: The HTTP method, e.g. C{GET}, C{POST}, etc.
95 An AssertionError is raised if trying to pass data for a GET.
96 :param data: The data to send, if any.
97 :type data: A byte string.
98
99 :return: A open file-like object that contains the response.
100 """
101 headers = dict(headers)
102 # header keys are case insensitive, so we have to pass over them
103 set_accept_encoding = False
104 for key in headers:
105 if key.lower() == 'accept-encoding':
106 # The user already supplied a requested encoding, so just pass
107 # it along.
108 break
109 else:
110 set_accept_encoding = True
111 headers['Accept-encoding'] = 'gzip'
112 req = RequestWithMethod(request_url, data, headers, method=method)
113 res = urllib2.urlopen(req)
114 # If we set the Accept-encoding header, then we decode the header for
115 # the caller.
116 is_gzip = (
117 set_accept_encoding
118 and res.info().get('Content-Encoding') == 'gzip')
119 if is_gzip:
120 # Workaround python's gzip failure, gzip.GzipFile wants to be able
121 # to seek the file object.
122 res_content_io = BytesIO(res.read())
123 ungz = gzip.GzipFile(mode='rb', fileobj=res_content_io)
124 res = urllib2.addinfourl(ungz, res.headers, res.url, res.code)
125 return res
126
127
128class MAASClient:
129 """Base class for connecting to MAAS servers.
130
131 All "path" parameters can be either a string describing an absolute
132 resource path, or a sequence of items that, when represented as unicode,
133 make up the elements of the resource's path. So `['nodes', node_id]`
134 is equivalent to `"nodes/%s" % node_id`.
135 """
136
137 def __init__(self, auth, dispatcher, base_url):
138 """Intialise the client.
139
140 :param auth: A `MAASOAuth` to sign requests.
141 :param dispatcher: An object implementing the MAASOAuthConnection
142 base class.
143 :param base_url: The base URL for the MAAS server, e.g.
144 http://my.maas.com:5240/
145 """
146 self.dispatcher = dispatcher
147 self.auth = auth
148 self.url = base_url
149
150 def _make_url(self, path):
151 """Compose an absolute URL to `path`.
152
153 :param path: Either a string giving a path to the desired resource,
154 or a sequence of items that make up the path.
155 :return: An absolute URL leading to `path`.
156 """
157 assert not isinstance(path, bytes)
158 if not isinstance(path, unicode):
159 assert not any(isinstance(element, bytes) for element in path)
160 path = '/'.join(unicode(element) for element in path)
161 # urljoin is very sensitive to leading slashes and when spurious
162 # slashes appear it removes path parts. This is why joining is
163 # done manually here.
164 return self.url.rstrip("/") + "/" + path.lstrip("/")
165
166 def _formulate_get(self, path, params=None):
167 """Return URL and headers for a GET request.
168
169 This is similar to _formulate_change, except parameters are encoded
170 into the URL.
171
172 :param path: Path to the object to issue a GET on.
173 :param params: Optional dict of parameter values.
174 :return: A tuple: URL and headers for the request.
175 """
176 url = self._make_url(path)
177 if params is not None and len(params) > 0:
178 url += "?" + urlencode(params.items())
179 headers = {}
180 self.auth.sign_request(url, headers)
181 return url, headers
182
183 def _formulate_change(self, path, params, as_json=False):
184 """Return URL, headers, and body for a non-GET request.
185
186 This is similar to _formulate_get, except parameters are encoded as
187 a multipart form body.
188
189 :param path: Path to the object to issue a GET on.
190 :param params: A dict of parameter values.
191 :param as_json: Encode params as application/json instead of
192 multipart/form-data. Only use this if you know the API already
193 supports JSON requests.
194 :return: A tuple: URL, headers, and body for the request.
195 """
196 url = self._make_url(path)
197 if 'op' in params:
198 params = dict(params)
199 op = params.pop('op')
200 url += '?' + urlencode([('op', op)])
201 if as_json:
202 body, headers = encode_json_data(params)
203 else:
204 body, headers = encode_multipart_data(params, {})
205 self.auth.sign_request(url, headers)
206 return url, headers, body
207
208 def get(self, path, op=None, **kwargs):
209 """Dispatch a GET.
210
211 :param op: Optional: named GET operation to invoke. If given, any
212 keyword arguments are passed to the named operation.
213 :return: The result of the dispatch_query call on the dispatcher.
214 """
215 if op is not None:
216 kwargs['op'] = op
217 url, headers = self._formulate_get(path, kwargs)
218 return self.dispatcher.dispatch_query(
219 url, method="GET", headers=headers)
220
221 def post(self, path, op, as_json=False, **kwargs):
222 """Dispatch POST method `op` on `path`, with the given parameters.
223
224 :param as_json: Instead of POSTing the content as multipart/form-data
225 POST it as application/json
226 :return: The result of the dispatch_query call on the dispatcher.
227 """
228 kwargs['op'] = op
229 url, headers, body = self._formulate_change(
230 path, kwargs, as_json=as_json)
231 return self.dispatcher.dispatch_query(
232 url, method="POST", headers=headers, data=body)
233
234 def put(self, path, **kwargs):
235 """Dispatch a PUT on the resource at `path`."""
236 url, headers, body = self._formulate_change(path, kwargs)
237 return self.dispatcher.dispatch_query(
238 url, method="PUT", headers=headers, data=body)
239
240 def delete(self, path):
241 """Dispatch a DELETE on the resource at `path`."""
242 url, headers, body = self._formulate_change(path, {})
243 return self.dispatcher.dispatch_query(
244 url, method="DELETE", headers=headers)