1 | # Licensed to the StackStorm, Inc ('StackStorm') under one or more |
||
2 | # contributor license agreements. See the NOTICE file distributed with |
||
3 | # this work for additional information regarding copyright ownership. |
||
4 | # The ASF licenses this file to You under the Apache License, Version 2.0 |
||
5 | # (the "License"); you may not use this file except in compliance with |
||
6 | # the License. You may obtain a copy of the License at |
||
7 | # |
||
8 | # http://www.apache.org/licenses/LICENSE-2.0 |
||
9 | # |
||
10 | # Unless required by applicable law or agreed to in writing, software |
||
11 | # distributed under the License is distributed on an "AS IS" BASIS, |
||
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||
13 | # See the License for the specific language governing permissions and |
||
14 | # limitations under the License. |
||
15 | |||
16 | from __future__ import absolute_import |
||
17 | import ast |
||
18 | import copy |
||
19 | import json |
||
20 | import uuid |
||
21 | |||
22 | import requests |
||
23 | from requests.auth import HTTPBasicAuth |
||
24 | from oslo_config import cfg |
||
25 | |||
26 | from st2common.runners.base import ActionRunner |
||
27 | from st2common.runners.base import get_metadata as get_runner_metadata |
||
28 | from st2common import __version__ as st2_version |
||
29 | from st2common import log as logging |
||
30 | from st2common.constants.action import LIVEACTION_STATUS_SUCCEEDED |
||
31 | from st2common.constants.action import LIVEACTION_STATUS_FAILED |
||
32 | from st2common.constants.action import LIVEACTION_STATUS_TIMED_OUT |
||
33 | import six |
||
34 | from six.moves import range |
||
0 ignored issues
–
show
|
|||
35 | |||
36 | __all__ = [ |
||
37 | 'HttpRunner', |
||
38 | |||
39 | 'HTTPClient', |
||
40 | |||
41 | 'get_runner', |
||
42 | 'get_metadata' |
||
43 | ] |
||
44 | |||
45 | LOG = logging.getLogger(__name__) |
||
46 | SUCCESS_STATUS_CODES = [code for code in range(200, 207)] |
||
47 | |||
48 | # Lookup constants for runner params |
||
49 | RUNNER_ON_BEHALF_USER = 'user' |
||
50 | RUNNER_URL = 'url' |
||
51 | RUNNER_HEADERS = 'headers' # Debatable whether this should be action params. |
||
52 | RUNNER_COOKIES = 'cookies' |
||
53 | RUNNER_ALLOW_REDIRECTS = 'allow_redirects' |
||
54 | RUNNER_HTTP_PROXY = 'http_proxy' |
||
55 | RUNNER_HTTPS_PROXY = 'https_proxy' |
||
56 | RUNNER_VERIFY_SSL_CERT = 'verify_ssl_cert' |
||
57 | RUNNER_USERNAME = 'username' |
||
58 | RUNNER_PASSWORD = 'password' |
||
59 | |||
60 | # Lookup constants for action params |
||
61 | ACTION_AUTH = 'auth' |
||
62 | ACTION_BODY = 'body' |
||
63 | ACTION_TIMEOUT = 'timeout' |
||
64 | ACTION_METHOD = 'method' |
||
65 | ACTION_QUERY_PARAMS = 'params' |
||
66 | FILE_NAME = 'file_name' |
||
67 | FILE_CONTENT = 'file_content' |
||
68 | FILE_CONTENT_TYPE = 'file_content_type' |
||
69 | |||
70 | RESPONSE_BODY_PARSE_FUNCTIONS = { |
||
71 | 'application/json': json.loads |
||
72 | } |
||
73 | |||
74 | |||
75 | class HttpRunner(ActionRunner): |
||
76 | def __init__(self, runner_id): |
||
77 | super(HttpRunner, self).__init__(runner_id=runner_id) |
||
78 | self._on_behalf_user = cfg.CONF.system_user.user |
||
79 | self._timeout = 60 |
||
80 | |||
81 | def pre_run(self): |
||
82 | super(HttpRunner, self).pre_run() |
||
83 | |||
84 | LOG.debug('Entering HttpRunner.pre_run() for liveaction_id="%s"', self.liveaction_id) |
||
85 | self._on_behalf_user = self.runner_parameters.get(RUNNER_ON_BEHALF_USER, |
||
86 | self._on_behalf_user) |
||
87 | self._url = self.runner_parameters.get(RUNNER_URL, None) |
||
88 | self._headers = self.runner_parameters.get(RUNNER_HEADERS, {}) |
||
89 | |||
90 | self._cookies = self.runner_parameters.get(RUNNER_COOKIES, None) |
||
91 | self._allow_redirects = self.runner_parameters.get(RUNNER_ALLOW_REDIRECTS, False) |
||
92 | self._username = self.runner_parameters.get(RUNNER_USERNAME, None) |
||
93 | self._password = self.runner_parameters.get(RUNNER_PASSWORD, None) |
||
94 | self._http_proxy = self.runner_parameters.get(RUNNER_HTTP_PROXY, None) |
||
95 | self._https_proxy = self.runner_parameters.get(RUNNER_HTTPS_PROXY, None) |
||
96 | self._verify_ssl_cert = self.runner_parameters.get(RUNNER_VERIFY_SSL_CERT, None) |
||
97 | |||
98 | def run(self, action_parameters): |
||
99 | client = self._get_http_client(action_parameters) |
||
100 | |||
101 | try: |
||
102 | result = client.run() |
||
103 | except requests.exceptions.Timeout as e: |
||
104 | result = {'error': str(e)} |
||
105 | status = LIVEACTION_STATUS_TIMED_OUT |
||
106 | else: |
||
107 | status = HttpRunner._get_result_status(result.get('status_code', None)) |
||
108 | |||
109 | return (status, result, None) |
||
110 | |||
111 | def _get_http_client(self, action_parameters): |
||
112 | body = action_parameters.get(ACTION_BODY, None) |
||
113 | timeout = float(action_parameters.get(ACTION_TIMEOUT, self._timeout)) |
||
114 | method = action_parameters.get(ACTION_METHOD, None) |
||
115 | params = action_parameters.get(ACTION_QUERY_PARAMS, None) |
||
116 | auth = action_parameters.get(ACTION_AUTH, {}) |
||
117 | |||
118 | file_name = action_parameters.get(FILE_NAME, None) |
||
119 | file_content = action_parameters.get(FILE_CONTENT, None) |
||
120 | file_content_type = action_parameters.get(FILE_CONTENT_TYPE, None) |
||
121 | |||
122 | # Include our user agent and action name so requests can be tracked back |
||
123 | headers = copy.deepcopy(self._headers) if self._headers else {} |
||
124 | headers['User-Agent'] = 'st2/v%s' % (st2_version) |
||
125 | headers['X-Stanley-Action'] = self.action_name |
||
126 | |||
127 | if file_name and file_content: |
||
128 | files = {} |
||
129 | |||
130 | if file_content_type: |
||
131 | value = (file_content, file_content_type) |
||
132 | else: |
||
133 | value = (file_content) |
||
134 | |||
135 | files[file_name] = value |
||
136 | else: |
||
137 | files = None |
||
138 | |||
139 | proxies = {} |
||
140 | |||
141 | if self._http_proxy: |
||
142 | proxies['http'] = self._http_proxy |
||
143 | |||
144 | if self._https_proxy: |
||
145 | proxies['https'] = self._https_proxy |
||
146 | |||
147 | return HTTPClient(url=self._url, method=method, body=body, params=params, |
||
148 | headers=headers, cookies=self._cookies, auth=auth, |
||
149 | timeout=timeout, allow_redirects=self._allow_redirects, |
||
150 | proxies=proxies, files=files, verify=self._verify_ssl_cert, |
||
151 | username=self._username, password=self._password) |
||
152 | |||
153 | @staticmethod |
||
154 | def _get_result_status(status_code): |
||
155 | return LIVEACTION_STATUS_SUCCEEDED if status_code in SUCCESS_STATUS_CODES \ |
||
156 | else LIVEACTION_STATUS_FAILED |
||
157 | |||
158 | |||
159 | class HTTPClient(object): |
||
160 | def __init__(self, url=None, method=None, body='', params=None, headers=None, cookies=None, |
||
161 | auth=None, timeout=60, allow_redirects=False, proxies=None, |
||
162 | files=None, verify=False, username=None, password=None): |
||
163 | if url is None: |
||
164 | raise Exception('URL must be specified.') |
||
165 | |||
166 | if method is None: |
||
167 | if files or body: |
||
168 | method = 'POST' |
||
169 | else: |
||
170 | method = 'GET' |
||
171 | |||
172 | headers = headers or {} |
||
173 | normalized_headers = self._normalize_headers(headers=headers) |
||
174 | if body and 'content-length' not in normalized_headers: |
||
175 | headers['Content-Length'] = str(len(body)) |
||
176 | |||
177 | self.url = url |
||
178 | self.method = method |
||
179 | self.headers = headers |
||
180 | self.body = body |
||
181 | self.params = params |
||
182 | self.headers = headers |
||
183 | self.cookies = cookies |
||
184 | self.auth = auth |
||
185 | self.timeout = timeout |
||
186 | self.allow_redirects = allow_redirects |
||
187 | self.proxies = proxies |
||
188 | self.files = files |
||
189 | self.verify = verify |
||
190 | self.username = username |
||
191 | self.password = password |
||
192 | |||
193 | def run(self): |
||
194 | results = {} |
||
195 | resp = None |
||
196 | json_content = self._is_json_content() |
||
197 | |||
198 | try: |
||
199 | if json_content: |
||
200 | # cast params (body) to dict |
||
201 | data = self._cast_object(self.body) |
||
202 | |||
203 | try: |
||
204 | data = json.dumps(data) |
||
205 | except ValueError: |
||
206 | msg = 'Request body (%s) can\'t be parsed as JSON' % (data) |
||
207 | raise ValueError(msg) |
||
208 | else: |
||
209 | data = self.body |
||
210 | |||
211 | if self.username or self.password: |
||
212 | self.auth = HTTPBasicAuth(self.username, self.password) |
||
213 | |||
214 | resp = requests.request( |
||
215 | self.method, |
||
216 | self.url, |
||
217 | params=self.params, |
||
218 | data=data, |
||
219 | headers=self.headers, |
||
220 | cookies=self.cookies, |
||
221 | auth=self.auth, |
||
222 | timeout=self.timeout, |
||
223 | allow_redirects=self.allow_redirects, |
||
224 | proxies=self.proxies, |
||
225 | files=self.files, |
||
226 | verify=self.verify |
||
227 | ) |
||
228 | |||
229 | headers = dict(resp.headers) |
||
230 | body, parsed = self._parse_response_body(headers=headers, body=resp.text) |
||
231 | |||
232 | results['status_code'] = resp.status_code |
||
233 | results['body'] = body |
||
234 | results['parsed'] = parsed # flag which indicates if body has been parsed |
||
235 | results['headers'] = headers |
||
236 | return results |
||
237 | except Exception as e: |
||
238 | LOG.exception('Exception making request to remote URL: %s, %s', self.url, e) |
||
239 | raise |
||
240 | finally: |
||
241 | if resp: |
||
242 | resp.close() |
||
243 | |||
244 | def _parse_response_body(self, headers, body): |
||
245 | """ |
||
246 | :param body: Response body. |
||
247 | :type body: ``str`` |
||
248 | |||
249 | :return: (parsed body, flag which indicates if body has been parsed) |
||
250 | :rtype: (``object``, ``bool``) |
||
251 | """ |
||
252 | body = body or '' |
||
253 | headers = self._normalize_headers(headers=headers) |
||
254 | content_type = headers.get('content-type', None) |
||
255 | parsed = False |
||
256 | |||
257 | if not content_type: |
||
258 | return (body, parsed) |
||
259 | |||
260 | # The header can also contain charset which we simply discard |
||
261 | content_type = content_type.split(';')[0] |
||
262 | parse_func = RESPONSE_BODY_PARSE_FUNCTIONS.get(content_type, None) |
||
263 | |||
264 | if not parse_func: |
||
265 | return (body, parsed) |
||
266 | |||
267 | LOG.debug('Parsing body with content type: %s', content_type) |
||
268 | |||
269 | try: |
||
270 | body = parse_func(body) |
||
271 | except Exception: |
||
272 | LOG.exception('Failed to parse body') |
||
273 | else: |
||
274 | parsed = True |
||
275 | |||
276 | return (body, parsed) |
||
277 | |||
278 | def _normalize_headers(self, headers): |
||
279 | """ |
||
280 | Normalize the header keys by lowercasing all the keys. |
||
281 | """ |
||
282 | result = {} |
||
283 | for key, value in headers.items(): |
||
284 | result[key.lower()] = value |
||
285 | |||
286 | return result |
||
287 | |||
288 | def _is_json_content(self): |
||
289 | normalized = self._normalize_headers(self.headers) |
||
290 | return normalized.get('content-type', None) == 'application/json' |
||
291 | |||
292 | def _cast_object(self, value): |
||
293 | if isinstance(value, str) or isinstance(value, six.text_type): |
||
294 | try: |
||
295 | return json.loads(value) |
||
296 | except: |
||
297 | return ast.literal_eval(value) |
||
298 | else: |
||
299 | return value |
||
300 | |||
301 | |||
302 | def get_runner(): |
||
303 | return HttpRunner(str(uuid.uuid4())) |
||
304 | |||
305 | |||
306 | def get_metadata(): |
||
307 | return get_runner_metadata('http_runner') |
||
308 |
It is generally discouraged to redefine built-ins as this makes code very hard to read.