1#!/usr/bin/python 2# -*- coding: utf-8 -*- 3 4# Copyright: (c) 2013, Romeo Theriault <romeot () hawaii.edu> 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8__metaclass__ = type 9 10 11DOCUMENTATION = r''' 12--- 13module: uri 14short_description: Interacts with webservices 15description: 16 - Interacts with HTTP and HTTPS web services and supports Digest, Basic and WSSE 17 HTTP authentication mechanisms. 18 - For Windows targets, use the M(ansible.windows.win_uri) module instead. 19version_added: "1.1" 20options: 21 url: 22 description: 23 - HTTP or HTTPS URL in the form (http|https)://host.domain[:port]/path 24 type: str 25 required: true 26 dest: 27 description: 28 - A path of where to download the file to (if desired). If I(dest) is a 29 directory, the basename of the file on the remote server will be used. 30 type: path 31 url_username: 32 description: 33 - A username for the module to use for Digest, Basic or WSSE authentication. 34 type: str 35 aliases: [ user ] 36 url_password: 37 description: 38 - A password for the module to use for Digest, Basic or WSSE authentication. 39 type: str 40 aliases: [ password ] 41 body: 42 description: 43 - The body of the http request/response to the web service. If C(body_format) is set 44 to 'json' it will take an already formatted JSON string or convert a data structure 45 into JSON. 46 - If C(body_format) is set to 'form-urlencoded' it will convert a dictionary 47 or list of tuples into an 'application/x-www-form-urlencoded' string. (Added in v2.7) 48 - If C(body_format) is set to 'form-multipart' it will convert a dictionary 49 into 'multipart/form-multipart' body. (Added in v2.10) 50 type: raw 51 body_format: 52 description: 53 - The serialization format of the body. When set to C(json), C(form-multipart), or C(form-urlencoded), encodes 54 the body argument, if needed, and automatically sets the Content-Type header accordingly. 55 - As of C(2.3) it is possible to override the `Content-Type` header, when 56 set to C(json) or C(form-urlencoded) via the I(headers) option. 57 - The 'Content-Type' header cannot be overridden when using C(form-multipart) 58 - C(form-urlencoded) was added in v2.7. 59 - C(form-multipart) was added in v2.10. 60 type: str 61 choices: [ form-urlencoded, json, raw, form-multipart ] 62 default: raw 63 version_added: "2.0" 64 method: 65 description: 66 - The HTTP method of the request or response. 67 - In more recent versions we do not restrict the method at the module level anymore 68 but it still must be a valid method accepted by the service handling the request. 69 type: str 70 default: GET 71 return_content: 72 description: 73 - Whether or not to return the body of the response as a "content" key in 74 the dictionary result no matter it succeeded or failed. 75 - Independently of this option, if the reported Content-type is "application/json", then the JSON is 76 always loaded into a key called C(json) in the dictionary results. 77 type: bool 78 default: no 79 force_basic_auth: 80 description: 81 - Force the sending of the Basic authentication header upon initial request. 82 - The library used by the uri module only sends authentication information when a webservice 83 responds to an initial request with a 401 status. Since some basic auth services do not properly 84 send a 401, logins will fail. 85 type: bool 86 default: no 87 follow_redirects: 88 description: 89 - Whether or not the URI module should follow redirects. C(all) will follow all redirects. 90 C(safe) will follow only "safe" redirects, where "safe" means that the client is only 91 doing a GET or HEAD on the URI to which it is being redirected. C(none) will not follow 92 any redirects. Note that C(yes) and C(no) choices are accepted for backwards compatibility, 93 where C(yes) is the equivalent of C(all) and C(no) is the equivalent of C(safe). C(yes) and C(no) 94 are deprecated and will be removed in some future version of Ansible. 95 type: str 96 choices: ['all', 'no', 'none', 'safe', 'urllib2', 'yes'] 97 default: safe 98 creates: 99 description: 100 - A filename, when it already exists, this step will not be run. 101 type: path 102 removes: 103 description: 104 - A filename, when it does not exist, this step will not be run. 105 type: path 106 status_code: 107 description: 108 - A list of valid, numeric, HTTP status codes that signifies success of the request. 109 type: list 110 elements: int 111 default: [ 200 ] 112 timeout: 113 description: 114 - The socket level timeout in seconds 115 type: int 116 default: 30 117 headers: 118 description: 119 - Add custom HTTP headers to a request in the format of a YAML hash. As 120 of C(2.3) supplying C(Content-Type) here will override the header 121 generated by supplying C(json) or C(form-urlencoded) for I(body_format). 122 type: dict 123 version_added: '2.1' 124 validate_certs: 125 description: 126 - If C(no), SSL certificates will not be validated. 127 - This should only set to C(no) used on personally controlled sites using self-signed certificates. 128 - Prior to 1.9.2 the code defaulted to C(no). 129 type: bool 130 default: yes 131 version_added: '1.9.2' 132 client_cert: 133 description: 134 - PEM formatted certificate chain file to be used for SSL client authentication. 135 - This file can also include the key as well, and if the key is included, I(client_key) is not required 136 type: path 137 version_added: '2.4' 138 client_key: 139 description: 140 - PEM formatted file that contains your private key to be used for SSL client authentication. 141 - If I(client_cert) contains both the certificate and key, this option is not required. 142 type: path 143 version_added: '2.4' 144 ca_path: 145 description: 146 - PEM formatted file that contains a CA certificate to be used for validation 147 type: path 148 version_added: '2.11' 149 src: 150 description: 151 - Path to file to be submitted to the remote server. 152 - Cannot be used with I(body). 153 type: path 154 version_added: '2.7' 155 remote_src: 156 description: 157 - If C(no), the module will search for the C(src) on the controller node. 158 - If C(yes), the module will search for the C(src) on the managed (remote) node. 159 type: bool 160 default: no 161 version_added: '2.7' 162 force: 163 description: 164 - If C(yes) do not get a cached copy. 165 - Alias C(thirsty) has been deprecated and will be removed in 2.13. 166 type: bool 167 default: no 168 aliases: [ thirsty ] 169 use_proxy: 170 description: 171 - If C(no), it will not use a proxy, even if one is defined in an environment variable on the target hosts. 172 type: bool 173 default: yes 174 unix_socket: 175 description: 176 - Path to Unix domain socket to use for connection 177 type: path 178 version_added: '2.8' 179 http_agent: 180 description: 181 - Header to identify as, generally appears in web server logs. 182 type: str 183 default: ansible-httpget 184 use_gssapi: 185 description: 186 - Use GSSAPI to perform the authentication, typically this is for Kerberos or Kerberos through Negotiate 187 authentication. 188 - Requires the Python library L(gssapi,https://github.com/pythongssapi/python-gssapi) to be installed. 189 - Credentials for GSSAPI can be specified with I(url_username)/I(url_password) or with the GSSAPI env var 190 C(KRB5CCNAME) that specified a custom Kerberos credential cache. 191 - NTLM authentication is C(not) supported even if the GSSAPI mech for NTLM has been installed. 192 type: bool 193 default: no 194 version_added: '2.11' 195notes: 196 - The dependency on httplib2 was removed in Ansible 2.1. 197 - The module returns all the HTTP headers in lower-case. 198 - For Windows targets, use the M(ansible.windows.win_uri) module instead. 199seealso: 200- module: ansible.builtin.get_url 201- module: ansible.windows.win_uri 202author: 203- Romeo Theriault (@romeotheriault) 204extends_documentation_fragment: files 205''' 206 207EXAMPLES = r''' 208- name: Check that you can connect (GET) to a page and it returns a status 200 209 uri: 210 url: http://www.example.com 211 212- name: Check that a page returns a status 200 and fail if the word AWESOME is not in the page contents 213 uri: 214 url: http://www.example.com 215 return_content: yes 216 register: this 217 failed_when: "'AWESOME' not in this.content" 218 219- name: Create a JIRA issue 220 uri: 221 url: https://your.jira.example.com/rest/api/2/issue/ 222 user: your_username 223 password: your_pass 224 method: POST 225 body: "{{ lookup('file','issue.json') }}" 226 force_basic_auth: yes 227 status_code: 201 228 body_format: json 229 230- name: Login to a form based webpage, then use the returned cookie to access the app in later tasks 231 uri: 232 url: https://your.form.based.auth.example.com/index.php 233 method: POST 234 body_format: form-urlencoded 235 body: 236 name: your_username 237 password: your_password 238 enter: Sign in 239 status_code: 302 240 register: login 241 242- name: Login to a form based webpage using a list of tuples 243 uri: 244 url: https://your.form.based.auth.example.com/index.php 245 method: POST 246 body_format: form-urlencoded 247 body: 248 - [ name, your_username ] 249 - [ password, your_password ] 250 - [ enter, Sign in ] 251 status_code: 302 252 register: login 253 254- name: Upload a file via multipart/form-multipart 255 uri: 256 url: https://httpbin.org/post 257 method: POST 258 body_format: form-multipart 259 body: 260 file1: 261 filename: /bin/true 262 mime_type: application/octet-stream 263 file2: 264 content: text based file content 265 filename: fake.txt 266 mime_type: text/plain 267 text_form_field: value 268 269- name: Connect to website using a previously stored cookie 270 uri: 271 url: https://your.form.based.auth.example.com/dashboard.php 272 method: GET 273 return_content: yes 274 headers: 275 Cookie: "{{ login.cookies_string }}" 276 277- name: Queue build of a project in Jenkins 278 uri: 279 url: http://{{ jenkins.host }}/job/{{ jenkins.job }}/build?token={{ jenkins.token }} 280 user: "{{ jenkins.user }}" 281 password: "{{ jenkins.password }}" 282 method: GET 283 force_basic_auth: yes 284 status_code: 201 285 286- name: POST from contents of local file 287 uri: 288 url: https://httpbin.org/post 289 method: POST 290 src: file.json 291 292- name: POST from contents of remote file 293 uri: 294 url: https://httpbin.org/post 295 method: POST 296 src: /path/to/my/file.json 297 remote_src: yes 298 299- name: Create workspaces in Log analytics Azure 300 uri: 301 url: https://www.mms.microsoft.com/Embedded/Api/ConfigDataSources/LogManagementData/Save 302 method: POST 303 body_format: json 304 status_code: [200, 202] 305 return_content: true 306 headers: 307 Content-Type: application/json 308 x-ms-client-workspace-path: /subscriptions/{{ sub_id }}/resourcegroups/{{ res_group }}/providers/microsoft.operationalinsights/workspaces/{{ w_spaces }} 309 x-ms-client-platform: ibiza 310 x-ms-client-auth-token: "{{ token_az }}" 311 body: 312 313- name: Pause play until a URL is reachable from this host 314 uri: 315 url: "http://192.0.2.1/some/test" 316 follow_redirects: none 317 method: GET 318 register: _result 319 until: _result.status == 200 320 retries: 720 # 720 * 5 seconds = 1hour (60*60/5) 321 delay: 5 # Every 5 seconds 322 323# There are issues in a supporting Python library that is discussed in 324# https://github.com/ansible/ansible/issues/52705 where a proxy is defined 325# but you want to bypass proxy use on CIDR masks by using no_proxy 326- name: Work around a python issue that doesn't support no_proxy envvar 327 uri: 328 follow_redirects: none 329 validate_certs: false 330 timeout: 5 331 url: "http://{{ ip_address }}:{{ port | default(80) }}" 332 register: uri_data 333 failed_when: false 334 changed_when: false 335 vars: 336 ip_address: 192.0.2.1 337 environment: | 338 { 339 {% for no_proxy in (lookup('env', 'no_proxy') | regex_replace('\s*,\s*', ' ') ).split() %} 340 {% if no_proxy | regex_search('\/') and 341 no_proxy | ipaddr('net') != '' and 342 no_proxy | ipaddr('net') != false and 343 ip_address | ipaddr(no_proxy) is not none and 344 ip_address | ipaddr(no_proxy) != false %} 345 'no_proxy': '{{ ip_address }}' 346 {% elif no_proxy | regex_search(':') != '' and 347 no_proxy | regex_search(':') != false and 348 no_proxy == ip_address + ':' + (port | default(80)) %} 349 'no_proxy': '{{ ip_address }}:{{ port | default(80) }}' 350 {% elif no_proxy | ipaddr('host') != '' and 351 no_proxy | ipaddr('host') != false and 352 no_proxy == ip_address %} 353 'no_proxy': '{{ ip_address }}' 354 {% elif no_proxy | regex_search('^(\*|)\.') != '' and 355 no_proxy | regex_search('^(\*|)\.') != false and 356 no_proxy | regex_replace('\*', '') in ip_address %} 357 'no_proxy': '{{ ip_address }}' 358 {% endif %} 359 {% endfor %} 360 } 361''' 362 363RETURN = r''' 364# The return information includes all the HTTP headers in lower-case. 365content: 366 description: The response body content. 367 returned: status not in status_code or return_content is true 368 type: str 369 sample: "{}" 370cookies: 371 description: The cookie values placed in cookie jar. 372 returned: on success 373 type: dict 374 sample: {"SESSIONID": "[SESSIONID]"} 375 version_added: "2.4" 376cookies_string: 377 description: The value for future request Cookie headers. 378 returned: on success 379 type: str 380 sample: "SESSIONID=[SESSIONID]" 381 version_added: "2.6" 382elapsed: 383 description: The number of seconds that elapsed while performing the download. 384 returned: on success 385 type: int 386 sample: 23 387msg: 388 description: The HTTP message from the request. 389 returned: always 390 type: str 391 sample: OK (unknown bytes) 392redirected: 393 description: Whether the request was redirected. 394 returned: on success 395 type: bool 396 sample: false 397status: 398 description: The HTTP status code from the request. 399 returned: always 400 type: int 401 sample: 200 402url: 403 description: The actual URL used for the request. 404 returned: always 405 type: str 406 sample: https://www.ansible.com/ 407''' 408 409import cgi 410import datetime 411import json 412import os 413import re 414import shutil 415import sys 416import tempfile 417 418from ansible.module_utils.basic import AnsibleModule, sanitize_keys 419from ansible.module_utils.six import PY2, iteritems, string_types 420from ansible.module_utils.six.moves.urllib.parse import urlencode, urlsplit 421from ansible.module_utils._text import to_native, to_text 422from ansible.module_utils.common._collections_compat import Mapping, Sequence 423from ansible.module_utils.urls import fetch_url, prepare_multipart, url_argument_spec 424 425JSON_CANDIDATES = ('text', 'json', 'javascript') 426 427# List of response key names we do not want sanitize_keys() to change. 428NO_MODIFY_KEYS = frozenset( 429 ('msg', 'exception', 'warnings', 'deprecations', 'failed', 'skipped', 430 'changed', 'rc', 'stdout', 'stderr', 'elapsed', 'path', 'location', 431 'content_type') 432) 433 434 435def format_message(err, resp): 436 msg = resp.pop('msg') 437 return err + (' %s' % msg if msg else '') 438 439 440def write_file(module, url, dest, content, resp): 441 # create a tempfile with some test content 442 fd, tmpsrc = tempfile.mkstemp(dir=module.tmpdir) 443 f = open(tmpsrc, 'wb') 444 try: 445 f.write(content) 446 except Exception as e: 447 os.remove(tmpsrc) 448 msg = format_message("Failed to create temporary content file: %s" % to_native(e), resp) 449 module.fail_json(msg=msg, **resp) 450 f.close() 451 452 checksum_src = None 453 checksum_dest = None 454 455 # raise an error if there is no tmpsrc file 456 if not os.path.exists(tmpsrc): 457 os.remove(tmpsrc) 458 msg = format_message("Source '%s' does not exist" % tmpsrc, resp) 459 module.fail_json(msg=msg, **resp) 460 if not os.access(tmpsrc, os.R_OK): 461 os.remove(tmpsrc) 462 msg = format_message("Source '%s' not readable" % tmpsrc, resp) 463 module.fail_json(msg=msg, **resp) 464 checksum_src = module.sha1(tmpsrc) 465 466 # check if there is no dest file 467 if os.path.exists(dest): 468 # raise an error if copy has no permission on dest 469 if not os.access(dest, os.W_OK): 470 os.remove(tmpsrc) 471 msg = format_message("Destination '%s' not writable" % dest, resp) 472 module.fail_json(msg=msg, **resp) 473 if not os.access(dest, os.R_OK): 474 os.remove(tmpsrc) 475 msg = format_message("Destination '%s' not readable" % dest, resp) 476 module.fail_json(msg=msg, **resp) 477 checksum_dest = module.sha1(dest) 478 else: 479 if not os.access(os.path.dirname(dest), os.W_OK): 480 os.remove(tmpsrc) 481 msg = format_message("Destination dir '%s' not writable" % os.path.dirname(dest), resp) 482 module.fail_json(msg=msg, **resp) 483 484 if checksum_src != checksum_dest: 485 try: 486 shutil.copyfile(tmpsrc, dest) 487 except Exception as e: 488 os.remove(tmpsrc) 489 msg = format_message("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(e)), resp) 490 module.fail_json(msg=msg, **resp) 491 492 os.remove(tmpsrc) 493 494 495def url_filename(url): 496 fn = os.path.basename(urlsplit(url)[2]) 497 if fn == '': 498 return 'index.html' 499 return fn 500 501 502def absolute_location(url, location): 503 """Attempts to create an absolute URL based on initial URL, and 504 next URL, specifically in the case of a ``Location`` header. 505 """ 506 507 if '://' in location: 508 return location 509 510 elif location.startswith('/'): 511 parts = urlsplit(url) 512 base = url.replace(parts[2], '') 513 return '%s%s' % (base, location) 514 515 elif not location.startswith('/'): 516 base = os.path.dirname(url) 517 return '%s/%s' % (base, location) 518 519 else: 520 return location 521 522 523def kv_list(data): 524 ''' Convert data into a list of key-value tuples ''' 525 if data is None: 526 return None 527 528 if isinstance(data, Sequence): 529 return list(data) 530 531 if isinstance(data, Mapping): 532 return list(data.items()) 533 534 raise TypeError('cannot form-urlencode body, expect list or dict') 535 536 537def form_urlencoded(body): 538 ''' Convert data into a form-urlencoded string ''' 539 if isinstance(body, string_types): 540 return body 541 542 if isinstance(body, (Mapping, Sequence)): 543 result = [] 544 # Turn a list of lists into a list of tuples that urlencode accepts 545 for key, values in kv_list(body): 546 if isinstance(values, string_types) or not isinstance(values, (Mapping, Sequence)): 547 values = [values] 548 for value in values: 549 if value is not None: 550 result.append((to_text(key), to_text(value))) 551 return urlencode(result, doseq=True) 552 553 return body 554 555 556def uri(module, url, dest, body, body_format, method, headers, socket_timeout, ca_path): 557 # is dest is set and is a directory, let's check if we get redirected and 558 # set the filename from that url 559 redirected = False 560 redir_info = {} 561 r = {} 562 src = module.params['src'] 563 if src: 564 try: 565 headers.update({ 566 'Content-Length': os.stat(src).st_size 567 }) 568 data = open(src, 'rb') 569 except OSError: 570 module.fail_json(msg='Unable to open source file %s' % src, elapsed=0) 571 else: 572 data = body 573 574 kwargs = {} 575 if dest is not None: 576 # Stash follow_redirects, in this block we don't want to follow 577 # we'll reset back to the supplied value soon 578 follow_redirects = module.params['follow_redirects'] 579 module.params['follow_redirects'] = False 580 if os.path.isdir(dest): 581 # first check if we are redirected to a file download 582 _, redir_info = fetch_url(module, url, data=body, 583 headers=headers, 584 method=method, 585 timeout=socket_timeout, unix_socket=module.params['unix_socket']) 586 # if we are redirected, update the url with the location header, 587 # and update dest with the new url filename 588 if redir_info['status'] in (301, 302, 303, 307): 589 url = redir_info['location'] 590 redirected = True 591 dest = os.path.join(dest, url_filename(url)) 592 # if destination file already exist, only download if file newer 593 if os.path.exists(dest): 594 kwargs['last_mod_time'] = datetime.datetime.utcfromtimestamp(os.path.getmtime(dest)) 595 596 # Reset follow_redirects back to the stashed value 597 module.params['follow_redirects'] = follow_redirects 598 599 resp, info = fetch_url(module, url, data=data, headers=headers, 600 method=method, timeout=socket_timeout, unix_socket=module.params['unix_socket'], 601 ca_path=ca_path, 602 **kwargs) 603 604 try: 605 content = resp.read() 606 except AttributeError: 607 # there was no content, but the error read() 608 # may have been stored in the info as 'body' 609 content = info.pop('body', '') 610 611 if src: 612 # Try to close the open file handle 613 try: 614 data.close() 615 except Exception: 616 pass 617 618 r['redirected'] = redirected or info['url'] != url 619 r.update(redir_info) 620 r.update(info) 621 622 return r, content, dest 623 624 625def main(): 626 argument_spec = url_argument_spec() 627 argument_spec.update( 628 dest=dict(type='path'), 629 url_username=dict(type='str', aliases=['user']), 630 url_password=dict(type='str', aliases=['password'], no_log=True), 631 body=dict(type='raw'), 632 body_format=dict(type='str', default='raw', choices=['form-urlencoded', 'json', 'raw', 'form-multipart']), 633 src=dict(type='path'), 634 method=dict(type='str', default='GET'), 635 return_content=dict(type='bool', default=False), 636 follow_redirects=dict(type='str', default='safe', choices=['all', 'no', 'none', 'safe', 'urllib2', 'yes']), 637 creates=dict(type='path'), 638 removes=dict(type='path'), 639 status_code=dict(type='list', elements='int', default=[200]), 640 timeout=dict(type='int', default=30), 641 headers=dict(type='dict', default={}), 642 unix_socket=dict(type='path'), 643 remote_src=dict(type='bool', default=False), 644 ca_path=dict(type='path', default=None), 645 ) 646 647 module = AnsibleModule( 648 argument_spec=argument_spec, 649 add_file_common_args=True, 650 mutually_exclusive=[['body', 'src']], 651 ) 652 653 if module.params.get('thirsty'): 654 module.deprecate('The alias "thirsty" has been deprecated and will be removed, use "force" instead', 655 version='2.13', collection_name='ansible.builtin') 656 657 url = module.params['url'] 658 body = module.params['body'] 659 body_format = module.params['body_format'].lower() 660 method = module.params['method'].upper() 661 dest = module.params['dest'] 662 return_content = module.params['return_content'] 663 creates = module.params['creates'] 664 removes = module.params['removes'] 665 status_code = [int(x) for x in list(module.params['status_code'])] 666 socket_timeout = module.params['timeout'] 667 ca_path = module.params['ca_path'] 668 dict_headers = module.params['headers'] 669 670 if not re.match('^[A-Z]+$', method): 671 module.fail_json(msg="Parameter 'method' needs to be a single word in uppercase, like GET or POST.") 672 673 if body_format == 'json': 674 # Encode the body unless its a string, then assume it is pre-formatted JSON 675 if not isinstance(body, string_types): 676 body = json.dumps(body) 677 if 'content-type' not in [header.lower() for header in dict_headers]: 678 dict_headers['Content-Type'] = 'application/json' 679 elif body_format == 'form-urlencoded': 680 if not isinstance(body, string_types): 681 try: 682 body = form_urlencoded(body) 683 except ValueError as e: 684 module.fail_json(msg='failed to parse body as form_urlencoded: %s' % to_native(e), elapsed=0) 685 if 'content-type' not in [header.lower() for header in dict_headers]: 686 dict_headers['Content-Type'] = 'application/x-www-form-urlencoded' 687 elif body_format == 'form-multipart': 688 try: 689 content_type, body = prepare_multipart(body) 690 except (TypeError, ValueError) as e: 691 module.fail_json(msg='failed to parse body as form-multipart: %s' % to_native(e)) 692 dict_headers['Content-Type'] = content_type 693 694 if creates is not None: 695 # do not run the command if the line contains creates=filename 696 # and the filename already exists. This allows idempotence 697 # of uri executions. 698 if os.path.exists(creates): 699 module.exit_json(stdout="skipped, since '%s' exists" % creates, changed=False) 700 701 if removes is not None: 702 # do not run the command if the line contains removes=filename 703 # and the filename does not exist. This allows idempotence 704 # of uri executions. 705 if not os.path.exists(removes): 706 module.exit_json(stdout="skipped, since '%s' does not exist" % removes, changed=False) 707 708 # Make the request 709 start = datetime.datetime.utcnow() 710 resp, content, dest = uri(module, url, dest, body, body_format, method, 711 dict_headers, socket_timeout, ca_path) 712 resp['elapsed'] = (datetime.datetime.utcnow() - start).seconds 713 resp['status'] = int(resp['status']) 714 resp['changed'] = False 715 716 # Write the file out if requested 717 if dest is not None: 718 if resp['status'] in status_code and resp['status'] != 304: 719 write_file(module, url, dest, content, resp) 720 # allow file attribute changes 721 resp['changed'] = True 722 module.params['path'] = dest 723 file_args = module.load_file_common_arguments(module.params, path=dest) 724 resp['changed'] = module.set_fs_attributes_if_different(file_args, resp['changed']) 725 resp['path'] = dest 726 727 # Transmogrify the headers, replacing '-' with '_', since variables don't 728 # work with dashes. 729 # In python3, the headers are title cased. Lowercase them to be 730 # compatible with the python2 behaviour. 731 uresp = {} 732 for key, value in iteritems(resp): 733 ukey = key.replace("-", "_").lower() 734 uresp[ukey] = value 735 736 if 'location' in uresp: 737 uresp['location'] = absolute_location(url, uresp['location']) 738 739 # Default content_encoding to try 740 content_encoding = 'utf-8' 741 if 'content_type' in uresp: 742 # Handle multiple Content-Type headers 743 charsets = [] 744 content_types = [] 745 for value in uresp['content_type'].split(','): 746 ct, params = cgi.parse_header(value) 747 if ct not in content_types: 748 content_types.append(ct) 749 if 'charset' in params: 750 if params['charset'] not in charsets: 751 charsets.append(params['charset']) 752 753 if content_types: 754 content_type = content_types[0] 755 if len(content_types) > 1: 756 module.warn( 757 'Received multiple conflicting Content-Type values (%s), using %s' % (', '.join(content_types), content_type) 758 ) 759 if charsets: 760 content_encoding = charsets[0] 761 if len(charsets) > 1: 762 module.warn( 763 'Received multiple conflicting charset values (%s), using %s' % (', '.join(charsets), content_encoding) 764 ) 765 766 u_content = to_text(content, encoding=content_encoding) 767 if any(candidate in content_type for candidate in JSON_CANDIDATES): 768 try: 769 js = json.loads(u_content) 770 uresp['json'] = js 771 except Exception: 772 if PY2: 773 sys.exc_clear() # Avoid false positive traceback in fail_json() on Python 2 774 else: 775 u_content = to_text(content, encoding=content_encoding) 776 777 if module.no_log_values: 778 uresp = sanitize_keys(uresp, module.no_log_values, NO_MODIFY_KEYS) 779 780 if resp['status'] not in status_code: 781 uresp['msg'] = 'Status code was %s and not %s: %s' % (resp['status'], status_code, uresp.get('msg', '')) 782 if return_content: 783 module.fail_json(content=u_content, **uresp) 784 else: 785 module.fail_json(**uresp) 786 elif return_content: 787 module.exit_json(content=u_content, **uresp) 788 else: 789 module.exit_json(**uresp) 790 791 792if __name__ == '__main__': 793 main() 794