mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
uri: Add form-urlencoded support to body_format (#37188)
* uri: Add form-urlencoded support to body_format This PR adds form-urlencoded support so the user does not need to take care of correctly encode input and have the same convenience as using JSON. This fixes #37182 * Various fixes * Undo documentation improvements No longer my problem * Fix the remaining review comments
This commit is contained in:
parent
0f16b26080
commit
0fba72ce3c
2 changed files with 141 additions and 34 deletions
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/python
|
#!/usr/bin/python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# (c) 2013, Romeo Theriault <romeot () hawaii.edu>
|
# Copyright: (c) 2013, Romeo Theriault <romeot () hawaii.edu>
|
||||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
from __future__ import absolute_import, division, print_function
|
from __future__ import absolute_import, division, print_function
|
||||||
|
@ -40,14 +40,15 @@ options:
|
||||||
description:
|
description:
|
||||||
- The body of the http request/response to the web service. If C(body_format) is set
|
- The body of the http request/response to the web service. If C(body_format) is set
|
||||||
to 'json' it will take an already formatted JSON string or convert a data structure
|
to 'json' it will take an already formatted JSON string or convert a data structure
|
||||||
into JSON.
|
into JSON. If C(body_format) is set to 'form-urlencoded' it will convert a dictionary
|
||||||
|
or list of tuples into an 'application/x-www-form-urlencoded' string. (Added in v2.7)
|
||||||
body_format:
|
body_format:
|
||||||
description:
|
description:
|
||||||
- The serialization format of the body. When set to json, encodes the
|
- The serialization format of the body. When set to C(json) or C(form-urlencoded), encodes the
|
||||||
body argument, if needed, and automatically sets the Content-Type header accordingly.
|
body argument, if needed, and automatically sets the Content-Type header accordingly.
|
||||||
As of C(2.3) it is possible to override the `Content-Type` header, when
|
As of C(2.3) it is possible to override the `Content-Type` header, when
|
||||||
set to json via the I(headers) option.
|
set to C(json) or C(form-urlencoded) via the I(headers) option.
|
||||||
choices: [ "raw", "json" ]
|
choices: [ form-urlencoded, json, raw ]
|
||||||
default: raw
|
default: raw
|
||||||
version_added: "2.0"
|
version_added: "2.0"
|
||||||
method:
|
method:
|
||||||
|
@ -79,8 +80,8 @@ options:
|
||||||
any redirects. Note that C(yes) and C(no) choices are accepted for backwards compatibility,
|
any redirects. Note that C(yes) and C(no) choices are accepted for backwards compatibility,
|
||||||
where C(yes) is the equivalent of C(all) and C(no) is the equivalent of C(safe). C(yes) and C(no)
|
where C(yes) is the equivalent of C(all) and C(no) is the equivalent of C(safe). C(yes) and C(no)
|
||||||
are deprecated and will be removed in some future version of Ansible.
|
are deprecated and will be removed in some future version of Ansible.
|
||||||
choices: [ all, none, safe ]
|
choices: [ all, 'none', safe ]
|
||||||
default: "safe"
|
default: safe
|
||||||
creates:
|
creates:
|
||||||
description:
|
description:
|
||||||
- A filename, when it already exists, this step will not be run.
|
- A filename, when it already exists, this step will not be run.
|
||||||
|
@ -89,8 +90,8 @@ options:
|
||||||
- A filename, when it does not exist, this step will not be run.
|
- A filename, when it does not exist, this step will not be run.
|
||||||
status_code:
|
status_code:
|
||||||
description:
|
description:
|
||||||
- A valid, numeric, HTTP status code that signifies success of the
|
- A list of valid, numeric, HTTP status codes that signifies success of the
|
||||||
request. Can also be comma separated list of status codes.
|
request.
|
||||||
default: 200
|
default: 200
|
||||||
timeout:
|
timeout:
|
||||||
description:
|
description:
|
||||||
|
@ -107,7 +108,7 @@ options:
|
||||||
description:
|
description:
|
||||||
- Add custom HTTP headers to a request in the format of a YAML hash. As
|
- Add custom HTTP headers to a request in the format of a YAML hash. As
|
||||||
of C(2.3) supplying C(Content-Type) here will override the header
|
of C(2.3) supplying C(Content-Type) here will override the header
|
||||||
generated by supplying C(json) for I(body_format).
|
generated by supplying C(json) or C(form-urlencoded) for I(body_format).
|
||||||
version_added: '2.1'
|
version_added: '2.1'
|
||||||
others:
|
others:
|
||||||
description:
|
description:
|
||||||
|
@ -150,12 +151,8 @@ EXAMPLES = r'''
|
||||||
- uri:
|
- uri:
|
||||||
url: http://www.example.com
|
url: http://www.example.com
|
||||||
return_content: yes
|
return_content: yes
|
||||||
register: webpage
|
register: this
|
||||||
|
failed_when: "'AWESOME' not in this.content"
|
||||||
- name: Fail if AWESOME is not in the page content
|
|
||||||
fail:
|
|
||||||
when: "'AWESOME' not in webpage.content"
|
|
||||||
|
|
||||||
|
|
||||||
- name: Create a JIRA issue
|
- name: Create a JIRA issue
|
||||||
uri:
|
uri:
|
||||||
|
@ -174,10 +171,24 @@ EXAMPLES = r'''
|
||||||
- uri:
|
- uri:
|
||||||
url: https://your.form.based.auth.example.com/index.php
|
url: https://your.form.based.auth.example.com/index.php
|
||||||
method: POST
|
method: POST
|
||||||
body: "name=your_username&password=your_password&enter=Sign%20in"
|
body_format: form-urlencoded
|
||||||
|
body:
|
||||||
|
name: your_username
|
||||||
|
password: your_password
|
||||||
|
enter: Sign in
|
||||||
|
status_code: 302
|
||||||
|
register: login
|
||||||
|
|
||||||
|
# Same, but now using a list of tuples
|
||||||
|
- uri:
|
||||||
|
url: https://your.form.based.auth.example.com/index.php
|
||||||
|
method: POST
|
||||||
|
body_format: form-urlencoded
|
||||||
|
body:
|
||||||
|
- [ name, your_username ]
|
||||||
|
- [ password, your_password ]
|
||||||
|
- [ enter, Sign in ]
|
||||||
status_code: 302
|
status_code: 302
|
||||||
headers:
|
|
||||||
Content-Type: "application/x-www-form-urlencoded"
|
|
||||||
register: login
|
register: login
|
||||||
|
|
||||||
- uri:
|
- uri:
|
||||||
|
@ -185,17 +196,16 @@ EXAMPLES = r'''
|
||||||
method: GET
|
method: GET
|
||||||
return_content: yes
|
return_content: yes
|
||||||
headers:
|
headers:
|
||||||
Cookie: "{{login.set_cookie}}"
|
Cookie: "{{ login.set_cookie }}"
|
||||||
|
|
||||||
- name: Queue build of a project in Jenkins
|
- name: Queue build of a project in Jenkins
|
||||||
uri:
|
uri:
|
||||||
url: "http://{{ jenkins.host }}/job/{{ jenkins.job }}/build?token={{ jenkins.token }}"
|
url: http://{{ jenkins.host }}/job/{{ jenkins.job }}/build?token={{ jenkins.token }}
|
||||||
method: GET
|
method: GET
|
||||||
user: "{{ jenkins.user }}"
|
user: "{{ jenkins.user }}"
|
||||||
password: "{{ jenkins.password }}"
|
password: "{{ jenkins.password }}"
|
||||||
force_basic_auth: yes
|
force_basic_auth: yes
|
||||||
status_code: 201
|
status_code: 201
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
RETURN = r'''
|
RETURN = r'''
|
||||||
|
@ -230,9 +240,10 @@ import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from collections import Mapping, Sequence
|
||||||
from ansible.module_utils.basic import AnsibleModule
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
import ansible.module_utils.six as six
|
from ansible.module_utils.six import iteritems, string_types
|
||||||
|
from ansible.module_utils.six.moves.urllib.parse import urlencode, urlsplit
|
||||||
from ansible.module_utils._text import to_native, to_text
|
from ansible.module_utils._text import to_native, to_text
|
||||||
from ansible.module_utils.urls import fetch_url, url_argument_spec
|
from ansible.module_utils.urls import fetch_url, url_argument_spec
|
||||||
|
|
||||||
|
@ -290,7 +301,7 @@ def write_file(module, url, dest, content):
|
||||||
|
|
||||||
|
|
||||||
def url_filename(url):
|
def url_filename(url):
|
||||||
fn = os.path.basename(six.moves.urllib.parse.urlsplit(url)[2])
|
fn = os.path.basename(urlsplit(url)[2])
|
||||||
if fn == '':
|
if fn == '':
|
||||||
return 'index.html'
|
return 'index.html'
|
||||||
return fn
|
return fn
|
||||||
|
@ -305,7 +316,7 @@ def absolute_location(url, location):
|
||||||
return location
|
return location
|
||||||
|
|
||||||
elif location.startswith('/'):
|
elif location.startswith('/'):
|
||||||
parts = six.moves.urllib.parse.urlsplit(url)
|
parts = urlsplit(url)
|
||||||
base = url.replace(parts[2], '')
|
base = url.replace(parts[2], '')
|
||||||
return '%s%s' % (base, location)
|
return '%s%s' % (base, location)
|
||||||
|
|
||||||
|
@ -317,6 +328,39 @@ def absolute_location(url, location):
|
||||||
return location
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
def kv_list(data):
|
||||||
|
''' Convert data into a list of key-value tuples '''
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(data, Sequence):
|
||||||
|
return list(data)
|
||||||
|
|
||||||
|
if isinstance(data, Mapping):
|
||||||
|
return list(data.items())
|
||||||
|
|
||||||
|
raise TypeError('cannot form-urlencode body, expect list or dict')
|
||||||
|
|
||||||
|
|
||||||
|
def form_urlencoded(body):
|
||||||
|
''' Convert data into a form-urlencoded string '''
|
||||||
|
if isinstance(body, string_types):
|
||||||
|
return body
|
||||||
|
|
||||||
|
if isinstance(body, (Mapping, Sequence)):
|
||||||
|
result = []
|
||||||
|
# Turn a list of lists into a list of tupples that urlencode accepts
|
||||||
|
for key, values in kv_list(body):
|
||||||
|
if isinstance(values, string_types) or not isinstance(values, (Mapping, Sequence)):
|
||||||
|
values = [values]
|
||||||
|
for value in values:
|
||||||
|
if value is not None:
|
||||||
|
result.append((to_text(key), to_text(value)))
|
||||||
|
return urlencode(result, doseq=True)
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
def uri(module, url, dest, body, body_format, method, headers, socket_timeout):
|
def uri(module, url, dest, body, body_format, method, headers, socket_timeout):
|
||||||
# is dest is set and is a directory, let's check if we get redirected and
|
# is dest is set and is a directory, let's check if we get redirected and
|
||||||
# set the filename from that url
|
# set the filename from that url
|
||||||
|
@ -373,9 +417,9 @@ def main():
|
||||||
url_username=dict(type='str', aliases=['user']),
|
url_username=dict(type='str', aliases=['user']),
|
||||||
url_password=dict(type='str', aliases=['password'], no_log=True),
|
url_password=dict(type='str', aliases=['password'], no_log=True),
|
||||||
body=dict(type='raw'),
|
body=dict(type='raw'),
|
||||||
body_format=dict(type='str', default='raw', choices=['raw', 'json']),
|
body_format=dict(type='str', default='raw', choices=['form-urlencoded', 'json', 'raw']),
|
||||||
method=dict(type='str', default='GET', choices=['GET', 'POST', 'PUT', 'HEAD', 'DELETE', 'OPTIONS', 'PATCH', 'TRACE', 'CONNECT', 'REFRESH']),
|
method=dict(type='str', default='GET', choices=['GET', 'POST', 'PUT', 'HEAD', 'DELETE', 'OPTIONS', 'PATCH', 'TRACE', 'CONNECT', 'REFRESH']),
|
||||||
return_content=dict(type='bool', default='no'),
|
return_content=dict(type='bool', default=False),
|
||||||
follow_redirects=dict(type='str', default='safe', choices=['all', 'no', 'none', 'safe', 'urllib2', 'yes']),
|
follow_redirects=dict(type='str', default='safe', choices=['all', 'no', 'none', 'safe', 'urllib2', 'yes']),
|
||||||
creates=dict(type='path'),
|
creates=dict(type='path'),
|
||||||
removes=dict(type='path'),
|
removes=dict(type='path'),
|
||||||
|
@ -406,16 +450,23 @@ def main():
|
||||||
|
|
||||||
if body_format == 'json':
|
if body_format == 'json':
|
||||||
# Encode the body unless its a string, then assume it is pre-formatted JSON
|
# Encode the body unless its a string, then assume it is pre-formatted JSON
|
||||||
if not isinstance(body, six.string_types):
|
if not isinstance(body, string_types):
|
||||||
body = json.dumps(body)
|
body = json.dumps(body)
|
||||||
lower_header_keys = [key.lower() for key in dict_headers]
|
if 'content-type' not in [header.lower() for header in dict_headers]:
|
||||||
if 'content-type' not in lower_header_keys:
|
|
||||||
dict_headers['Content-Type'] = 'application/json'
|
dict_headers['Content-Type'] = 'application/json'
|
||||||
|
elif body_format == 'form-urlencoded':
|
||||||
|
if not isinstance(body, string_types):
|
||||||
|
try:
|
||||||
|
body = form_urlencoded(body)
|
||||||
|
except ValueError as e:
|
||||||
|
module.fail_json(msg='failed to parse body as form_urlencoded: %s' % to_native(e))
|
||||||
|
if 'content-type' not in [header.lower() for header in dict_headers]:
|
||||||
|
dict_headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
|
||||||
# TODO: Deprecated section. Remove in Ansible 2.9
|
# TODO: Deprecated section. Remove in Ansible 2.9
|
||||||
# Grab all the http headers. Need this hack since passing multi-values is
|
# Grab all the http headers. Need this hack since passing multi-values is
|
||||||
# currently a bit ugly. (e.g. headers='{"Content-Type":"application/json"}')
|
# currently a bit ugly. (e.g. headers='{"Content-Type":"application/json"}')
|
||||||
for key, value in six.iteritems(module.params):
|
for key, value in iteritems(module.params):
|
||||||
if key.startswith("HEADER_"):
|
if key.startswith("HEADER_"):
|
||||||
module.deprecate('Supplying headers via HEADER_* is deprecated. Please use `headers` to'
|
module.deprecate('Supplying headers via HEADER_* is deprecated. Please use `headers` to'
|
||||||
' supply headers for the request', version='2.9')
|
' supply headers for the request', version='2.9')
|
||||||
|
@ -432,7 +483,7 @@ def main():
|
||||||
|
|
||||||
if removes is not None:
|
if removes is not None:
|
||||||
# do not run the command if the line contains removes=filename
|
# do not run the command if the line contains removes=filename
|
||||||
# and the filename do not exists. This allows idempotence
|
# and the filename does not exist. This allows idempotence
|
||||||
# of uri executions.
|
# of uri executions.
|
||||||
if not os.path.exists(removes):
|
if not os.path.exists(removes):
|
||||||
module.exit_json(stdout="skipped, since '%s' does not exist" % removes, changed=False, rc=0)
|
module.exit_json(stdout="skipped, since '%s' does not exist" % removes, changed=False, rc=0)
|
||||||
|
@ -463,7 +514,7 @@ def main():
|
||||||
# In python3, the headers are title cased. Lowercase them to be
|
# In python3, the headers are title cased. Lowercase them to be
|
||||||
# compatible with the python2 behaviour.
|
# compatible with the python2 behaviour.
|
||||||
uresp = {}
|
uresp = {}
|
||||||
for key, value in six.iteritems(resp):
|
for key, value in iteritems(resp):
|
||||||
ukey = key.replace("-", "_").lower()
|
ukey = key.replace("-", "_").lower()
|
||||||
uresp[ukey] = value
|
uresp[ukey] = value
|
||||||
|
|
||||||
|
|
|
@ -334,6 +334,62 @@
|
||||||
register: result
|
register: result
|
||||||
failed_when: result.json.headers['Content-Type'] != 'text/json'
|
failed_when: result.json.headers['Content-Type'] != 'text/json'
|
||||||
|
|
||||||
|
- name: Validate body_format form-urlencoded using dicts works
|
||||||
|
uri:
|
||||||
|
url: https://{{ httpbin_host }}/post
|
||||||
|
method: POST
|
||||||
|
body:
|
||||||
|
user: foo
|
||||||
|
password: bar!#@ |&82$M
|
||||||
|
submit: Sign in
|
||||||
|
body_format: form-urlencoded
|
||||||
|
return_content: yes
|
||||||
|
register: result
|
||||||
|
|
||||||
|
- name: Assert form-urlencoded dict input
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- result is successful
|
||||||
|
- result.json.headers['Content-Type'] == 'application/x-www-form-urlencoded'
|
||||||
|
- result.json.form.password == 'bar!#@ |&82$M'
|
||||||
|
|
||||||
|
- name: Validate body_format form-urlencoded using lists works
|
||||||
|
uri:
|
||||||
|
url: https://{{ httpbin_host }}/post
|
||||||
|
method: POST
|
||||||
|
body:
|
||||||
|
- [ user, foo ]
|
||||||
|
- [ password, bar!#@ |&82$M ]
|
||||||
|
- [ submit, Sign in ]
|
||||||
|
body_format: form-urlencoded
|
||||||
|
return_content: yes
|
||||||
|
register: result
|
||||||
|
|
||||||
|
- name: Assert form-urlencoded list input
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- result is successful
|
||||||
|
- result.json.headers['Content-Type'] == 'application/x-www-form-urlencoded'
|
||||||
|
- result.json.form.password == 'bar!#@ |&82$M'
|
||||||
|
|
||||||
|
- name: Validate body_format form-urlencoded of invalid input fails
|
||||||
|
uri:
|
||||||
|
url: https://{{ httpbin_host }}/post
|
||||||
|
method: POST
|
||||||
|
body:
|
||||||
|
- foo
|
||||||
|
- bar: baz
|
||||||
|
body_format: form-urlencoded
|
||||||
|
return_content: yes
|
||||||
|
register: result
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Assert invalid input fails
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- result is failure
|
||||||
|
- "'failed to parse body as form_urlencoded: too many values to unpack' in result.msg"
|
||||||
|
|
||||||
- name: Test client cert auth, no certs
|
- name: Test client cert auth, no certs
|
||||||
uri:
|
uri:
|
||||||
url: "https://ansible.http.tests/ssl_client_verify"
|
url: "https://ansible.http.tests/ssl_client_verify"
|
||||||
|
|
Loading…
Reference in a new issue