wscalls: unflatten payload when calling webservice (#66916) #1204

Merged
smihai merged 2 commits from wip/66916-wscall-apply-unflatten-payload into main 2024-04-15 16:54:33 +02:00
11 changed files with 422 additions and 8 deletions

View File

@ -305,3 +305,53 @@ def test_afterjobs_base_directory(pub):
get_app(pub).get('/api/jobs/', status=403)
# base directory is 404
get_app(pub).get(sign_url('/api/jobs/?orig=coucou', '1234'), status=404)
def test_preview_payload_structure(pub, admin_user):
get_app(pub).get('/api/preview-payload-structure', status=403)
app = login(get_app(pub))
resp = app.get('/api/preview-payload-structure')
assert resp.pyquery('div.payload-preview').length == 1
assert '<h2>Payload structure preview</h2>' in resp.text
assert resp.pyquery('div.payload-preview').text() == '{}'
params = {
'request$post_data$added_elements': 1,
'request$post_data$element1key': 'user/first_name',
'request$post_data$element1value$value_template': 'Foo',
'request$post_data$element1value$value_python': '',
'request$post_data$element2key': 'user/last_name',
'request$post_data$element2value$value_template': 'Bar',
'request$post_data$element2value$value_python': '',
'request$post_data$element3key': 'user/0',
}
resp = app.get('/api/preview-payload-structure', params=params)
assert resp.pyquery('div.payload-preview div.errornotice').length == 0
assert resp.pyquery('div.payload-preview').text() == '{"user": {"first_name": "Foo","last_name": "Bar"}}'
params.update(
{
'request$post_data$element3value$value_template': 'value',
'request$post_data$element3value$value_python': '',
}
)
resp = app.get('/api/preview-payload-structure', params=params)
assert resp.pyquery('div.payload-preview div.errornotice').length == 1
assert 'Unable to preview payload' in resp.pyquery('div.payload-preview div.errornotice').text()
assert (
'Following error occured: there is a mix between lists and dicts'
in resp.pyquery('div.payload-preview div.errornotice').text()
)
params = {
'post_data$element1key': '0/0',
'post_data$element1value$value_template': 'Foo',
'post_data$element1value$value_python': '',
'post_data$element2key': '0/1',
'post_data$element2value$value_template': '{{ form_name }}',
'post_data$element2value$value_python': '',
'post_data$element3key': '1/0',
'post_data$element3value$value_template': '',
}
resp = app.get('/api/preview-payload-structure', params=params)
assert resp.pyquery('div.payload-preview').text() == '[["Foo",{{ form_name }}],[""]]'

View File

@ -289,6 +289,47 @@ def test_webservice_empty_param_values(http_requests, pub):
assert http_requests.get_last('body') == '{"toto": ""}'
def test_webservice_with_unflattened_payload_keys(http_requests, pub):
NamedWsCall.wipe()
wscall = NamedWsCall()
wscall.name = 'Hello world'
wscall.request = {
'method': 'POST',
'url': 'http://remote.example.net/json',
'post_data': {'foo/0': 'first', 'foo/1': 'second', 'bar': 'example', 'foo/2': ''},
}
wscall.store()
wscall.call()
assert http_requests.get_last('url') == 'http://remote.example.net/json'
assert http_requests.get_last('body') == '{"bar": "example", "foo": ["first", "second", ""]}'
assert http_requests.count() == 1
wscall.request = {
'method': 'POST',
'url': 'http://remote.example.net/json',
'post_data': {'foo/0': 'first', 'foo/1': 'second', 'foo/bar': 'example'},
}
wscall.store()
pub.loggederror_class.wipe()
http_requests.empty()
smihai marked this conversation as resolved Outdated
Outdated
Review

Plutôt utiliser un with pytest.raises le plus restrictif possible

Plutôt utiliser un with pytest.raises le plus restrictif possible

Au final je ne léve pas d'exception mais plutôt enregistre l'erreur les logs.

Au final je ne léve pas d'exception mais plutôt enregistre l'erreur les logs.
wscall.call()
assert http_requests.count() == 0
smihai marked this conversation as resolved Outdated
Outdated
Review

faire plutôt un http_requests.empty() avant de lancer le wscall, afin de faire un == 0 ici

faire plutôt un http_requests.empty() avant de lancer le wscall, afin de faire un == 0 ici

Ok, merci.

Ok, merci.
assert pub.loggederror_class.count() == 0
wscall.record_on_errors = True
wscall.store()
pub.loggederror_class.wipe()
wscall.call()
assert pub.loggederror_class.count() == 1
assert (
pub.loggederror_class.select()[0].summary
== '[WSCALL] Webservice call failure because unable to unflatten payload keys (there is a mix between lists and dicts)'
)
def test_webservice_timeout(http_requests, pub):
NamedWsCall.wipe()

View File

@ -574,6 +574,82 @@ def test_webservice_call(http_requests, pub):
assert payload == {'one': 1, 'str': 'abcd', 'evalme': formdata.get_display_id()}
def test_webservice_with_unflattened_payload_keys(http_requests, pub):
wf = Workflow(name='wf1')
wf.add_status('Status1', 'st1')
wf.store()
FormDef.wipe()
formdef = FormDef()
formdef.name = 'baz'
formdef.fields = []
formdef.workflow_id = wf.id
formdef.store()
formdata = formdef.data_class()()
formdata.just_created()
formdata.store()
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net'
item.post_data = {
'foo/0': 'first',
'foo/1': 'second',
'foo/2': '{{ form_name }}',
'bar': 'example',
'form//name': '{{ form_name }}',
}
pub.substitutions.feed(formdata)
smihai marked this conversation as resolved Outdated
Outdated
Review

même remarque sur le with pytest.raise et le http_requests.empty()

même remarque sur le with pytest.raise et le http_requests.empty()
item.perform(formdata)
assert http_requests.count() == 1
assert http_requests.get_last('url') == 'http://remote.example.net/'
assert http_requests.get_last('method') == 'POST'
payload = json.loads(http_requests.get_last('body'))
assert payload == {'foo': ['first', 'second', 'baz'], 'bar': 'example', 'form/name': 'baz'}
http_requests.empty()
pub.loggederror_class.wipe()
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net'
item.record_on_errors = True
item.post_data = {'foo/1': 'first', 'foo/2': 'second'}
item.perform(formdata)
assert http_requests.count() == 0
assert pub.loggederror_class.count() == 1
assert (
pub.loggederror_class.select()[0].summary
== '[WSCALL] Webservice call failure because unable to unflatten payload keys (incomplete array before key "foo/1")'
)
http_requests.empty()
pub.loggederror_class.wipe()
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net'
item.record_on_errors = True
item.post_data = {'0/foo': 'value', '1/bar': 'value', 'name': '{{ form_name }}'}
item.perform(formdata)
assert (
pub.loggederror_class.select()[0].summary
== '[WSCALL] Webservice call failure because unable to unflatten payload keys (there is a mix between lists and dicts)'
)
http_requests.empty()
pub.loggederror_class.wipe()
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net'
item.record_on_errors = True
item.post_data = {'0/foo': 'value', '1/bar': 'value'}
pub.substitutions.feed(formdata)
item.perform(formdata)
assert http_requests.count() == 1
payload = json.loads(http_requests.get_last('body'))
assert payload == [{'foo': 'value'}, {'bar': 'value'}]
def test_webservice_waitpoint(pub):
item = WebserviceCallStatusItem()
assert item.waitpoint

View File

@ -500,6 +500,7 @@ class WorkflowItemPage(Directory, DocumentableMixin):
return redirect('..')
get_response().set_title('%s - %s' % (_('Workflow'), self.workflow.name))
get_response().add_javascript(['jquery-ui.js'])
context = {
'view': self,
'html_form': form,

View File

@ -203,6 +203,7 @@ class NamedWsCallPage(Directory, DocumentableMixin):
return redirect('../%s/' % self.wscall.id)
get_response().breadcrumb.append(('edit', _('Edit')))
get_response().add_javascript(['jquery-ui.js'])
get_response().set_title(_('Edit webservice call'))
r = TemplateIO(html=True)
r += htmltext('<h2>%s</h2>') % _('Edit webservice call')

View File

@ -27,6 +27,7 @@ from django.utils.timezone import localtime, make_naive
from quixote import get_publisher, get_request, get_response, get_session, redirect
from quixote.directory import Directory
from quixote.errors import MethodNotAllowedError, RequestError
from quixote.html import TemplateIO, htmltext
import wcs.qommon.storage as st
from wcs.admin.settings import UserFieldsFormDef
@ -57,6 +58,7 @@ from wcs.sql_criterias import (
StrictNotEqual,
)
from wcs.workflows import ContentSnapshotPart
from wcs.wscalls import UnflattenKeysException, unflatten_keys
from .backoffice.data_management import CardPage as BackofficeCardPage
from .backoffice.management import FormPage as BackofficeFormPage
@ -1407,6 +1409,7 @@ class ApiDirectory(Directory):
'geojson',
'jobs',
('card-file-by-token', 'card_file_by_token'),
('preview-payload-structure', 'preview_payload_structure'),
('sign-url-token', 'sign_url_token'),
]
@ -1434,6 +1437,81 @@ class ApiDirectory(Directory):
get_response().set_content_type('application/json')
return json.dumps({'err': 0, 'data': list_roles})
def preview_payload_structure(self):
if not (get_request().user and get_request().user.can_go_in_admin()):
raise AccessForbiddenError('user has no access to backoffice')
def parse_payload():
payload = {}
for param, value in get_request().form.items():
# skip elements which are not part of payload
if 'post_data$element' not in param or param.endswith('value_python'):
continue
prefix, order, field = re.split(r'(\d)(?!\d)', param) # noqa pylint: disable=unused-variable
# skip elements that aren't ordered
if not order:
continue
if order not in payload:
payload[order] = []
if field == 'key':
# skip empty keys
if not value:
continue
# insert key on first position
payload[order].insert(0, value)
else:
payload[order].append(value)
return dict([v for v in payload.values() if len(v) > 1])
def format_payload(o, html=htmltext(''), last_element=True):
if isinstance(o, (list, tuple)):
html += htmltext('[<span class="payload-preview--obj">')
while True:
try:
head, tail = o[0], o[1:]
except IndexError:
break
html = format_payload(head, html=html, last_element=len(tail) < 1)
o = tail
html += htmltext('</span>]')
elif isinstance(o, dict):
html += htmltext('{<span class="payload-preview--obj">')
for i, (k, v) in enumerate(o.items()):
html += htmltext('<span class="payload-preview--key">"%s"</span>: ' % k)
html = format_payload(v, html=html, last_element=i == len(o) - 1)
html += htmltext('</span>}')
else:
# check if it's empty string, a template with text around or just text
if not o or re.sub('^({[{|%]).+([%|}]})$', '', o):
# and add double quotes
html += htmltext('<span class="payload-preview--value">"%s"</span>' % o)
else:
html += htmltext('<span class="payload-preview--template-value">%s</span>' % o)
# last element doesn't need separator
if not last_element:
html += htmltext('<span class="payload-preview--item-separator">,</span>')
return html
payload = parse_payload()
r = TemplateIO(html=True)
r += htmltext('<h2>%s</h2>') % _('Payload structure preview')
r += htmltext('<div class="payload-preview">')
try:
unflattened_payload = unflatten_keys(payload)
r += htmltext('<div class="payload-preview--structure">')
r += format_payload(unflattened_payload)
r += htmltext('</div>')
except UnflattenKeysException as e:
r += htmltext('<div class="errornotice"><p>%s</p><p>%s %s</p></div>') % (
_('Unable to preview payload.'),
_('Following error occured: '),
e,
)
r += htmltext('</div>')
return r.getvalue()
def _q_traverse(self, path):
get_request().is_json_marker = True
return super()._q_traverse(path)

View File

@ -3332,3 +3332,21 @@ aside .bo-block.documentation {
// always keep a bit of space, for documentation button
max-width: calc(100% - 80px);
}
.payload-preview {
&--structure {
font-family: monospace;
line-height: 150%;
}
&--template-value {
background: #ffc;
}
&--obj {
margin-left: 1em;
display: block;
}
&--item-separator::after {
content: '\a'; // force line-break
white-space: pre;
}
}

View File

@ -304,6 +304,28 @@ $(function() {
$window.trigger('scroll');
}
$('div.WidgetDict[data-widget-name*="post_data"] input[type="text"]').on('change', function() {
var $widget = $(this).parents('div.WidgetDict');
var url = '/api/preview-payload-structure?' + $('input', $widget).serialize();
var preview_button_id = 'payload-preview-button';
var preview_button_selector = 'a#' + preview_button_id;
if ($widget.find(preview_button_selector).length) {
$widget.find(preview_button_selector).attr('href', url);
} else {
if ($(this).parents('div.dict-key').length < 0) return;
if (!$(this).val().includes('/')) return;
var eval_link = document.createElement('a');
eval_link.setAttribute('rel', 'popup');
eval_link.setAttribute('href', url);
eval_link.setAttribute('id', preview_button_id);
eval_link.setAttribute('class', 'pk-button');
eval_link.setAttribute('data-selector', 'div.payload-preview');
eval_link.setAttribute('data-title-selector', 'h2');
eval_link.innerHTML = WCS_I18N.preview_payload_structure;
$widget.append(eval_link);
}
}).trigger('change');
$('#inspect-test-tools form').on('submit', function() {
var data = $(this).serialize();
$.ajax({url: 'inspect-tool',

View File

@ -90,6 +90,7 @@ def i18n_js(request):
'close': _('Close'),
'email_domain_suggest': _('Did you want to write'),
smihai marked this conversation as resolved Outdated

Preview payload structure ?

Preview payload structure ?

En effet, c'est mieux.

En effet, c'est mieux.
'email_domain_fix': _('Apply fix'),
'preview_payload_structure': _('Preview payload structure'),
}
return HttpResponse(
'WCS_I18N = %s;\n' % json.dumps(strings, cls=misc.JSONEncoder), content_type='application/javascript'

View File

@ -35,7 +35,13 @@ from wcs.workflows import (
WorkflowStatusItem,
register_item_class,
)
from wcs.wscalls import PayloadError, call_webservice, get_app_error_code, record_wscall_error
from wcs.wscalls import (
PayloadError,
UnflattenKeysException,
call_webservice,
get_app_error_code,
record_wscall_error,
)
from ..qommon import _, force_str, pgettext
from ..qommon.errors import ConnectionError
@ -298,6 +304,15 @@ class WebserviceCallStatusItem(WorkflowStatusItem):
]
),
},
hint=_(
'The / in parameter name allows to generate complex objects. '
'Thus a parameter named "element/child" containing "value" will generate the payload '
'"element": {"child": "value"}. If the subkey, i.e. "child", is an integer it will '
'become a list index and two elements "element/0", "element/1" (indexes should '
' start from zero) containing "value1" and "value2" will generate the payload '
'"element": ["value1", "value2"]. It is possible to combine the two types, for '
smihai marked this conversation as resolved Outdated
Outdated
Review

Comme j'ai eu du support à faire ce matin sur le sujet, on pourrait ajouter un petit bout qui dit qu'en cas de element/0, element/1, le 0 est obligatoire. Genre ajouter un "index 0 is mandatory" :

If the subkey, i.e. "child", is an integer it will become a list index and two elements "element/0", "element/1" containing "value1" and "value2" will generate the payload "element": ["value1", "value2"] (note: index 0 is mandatory).

Bon, si c'est jugé trop lourdingue tu oublies, on essayera de faire préciser ça dans une doc, qui contiendra des exemples.

Comme j'ai eu du support à faire ce matin sur le sujet, on pourrait ajouter un petit bout qui dit qu'en cas de element/0, element/1, le 0 est obligatoire. Genre ajouter un "index 0 is mandatory" : _If the subkey, i.e. "child", is an integer it will become a list index and two elements "element/0", "element/1" containing "value1" and "value2" will generate the payload "element": ["value1", "value2"] (note: index 0 is mandatory)._ Bon, si c'est jugé trop lourdingue tu oublies, on essayera de faire préciser ça dans une doc, qui contiendra des exemples.

On n'est pas à quelques mots près. J'ai rajouté la remarque sur les indexes.

On n'est pas à quelques mots près. J'ai rajouté la remarque sur les indexes.
'example "element/0/key1" to generate a list of objects.'
),
)
response_types = collections.OrderedDict([('json', _('JSON')), ('attachment', _('Attachment'))])
@ -481,6 +496,17 @@ class WebserviceCallStatusItem(WorkflowStatusItem):
exception=e,
)
return
except UnflattenKeysException as e:
get_publisher().record_error(
error_summary=e.get_summary(),
exception=e,
context='[WSCALL]',
formdata=formdata,
status_item=self,
notify=self.notify_on_errors,
record=self.record_on_errors,
)
return
app_error_code = get_app_error_code(response, data, self.response_type)
app_error_code_header = response.headers.get('x-error-code')

View File

@ -17,6 +17,7 @@
import collections
import hashlib
import json
import re
import urllib.parse
import xml.etree.ElementTree as ET
@ -52,6 +53,86 @@ class PayloadError(Exception):
pass
class UnflattenKeysException(Exception):
def get_summary(self):
return _('Webservice call failure because unable to unflatten payload keys (%s)') % self
def unflatten_keys(d):
"""Transform:
{"a/b/0/x": "1234"}
smihai marked this conversation as resolved Outdated
Outdated
Review

Et tout à coup je me demande si on ne veut pas, dès maintenant, prévoir la possibilité d'échappement ? Genre « // » ?

Et tout à coup je me demande si on ne veut pas, dès maintenant, prévoir la possibilité d'échappement ? Genre « // » ?

C'est déjà géré, par cette partie :

            elif isinstance(x, str):
                return x.replace('//', '/') 

il faudrait donc y ajouter un commentaire pour expliciter.

C'est déjà géré, par cette partie : ``` elif isinstance(x, str): return x.replace('//', '/') ``` il faudrait donc y ajouter un commentaire pour expliciter.

Ok, j'ajoute un commentaire et un test.

Ok, j'ajoute un commentaire et un test.
into:
{"a": {"b": [{"x": "1234"}]}}
"""
smihai marked this conversation as resolved Outdated

Ce commentaire n'apporte pas grand chose.

Ce commentaire n'apporte pas grand chose.
if not isinstance(d, dict) or not d: # unflattening an empty dict has no sense
return d
smihai marked this conversation as resolved Outdated

Dans w.c.s. on a misc.is_ascii_digit() pour faire le is_number() de passerelle, c'est nécessaire parce que :

>>> '²'.isnumeric()
True
>>> '൨'.isnumeric()
True
>>> 'Ⅱ'.isnumeric()
True
etc.
Dans w.c.s. on a misc.is_ascii_digit() pour faire le is_number() de passerelle, c'est nécessaire parce que : ``` >>> '²'.isnumeric() True >>> '൨'.isnumeric() True >>> 'Ⅱ'.isnumeric() True etc. ```
def split_key(key):
def map_key(x):
if misc.is_ascii_digit(x):
return int(x)
elif isinstance(x, str):
# allow / char escaping
smihai marked this conversation as resolved Outdated

On ne va pas rendre le séparateur paramétrable, la fonction sera plus lisible sans ça.

(ça vaut quand même le coup d'expliciter l'objectif de la regex).

On ne va pas rendre le séparateur paramétrable, la fonction sera plus lisible sans ça. (ça vaut quand même le coup d'expliciter l'objectif de la regex).

J'ai viré la regex car il n'y a pas besoin de l'utiliser pour découper la chaîne.

J'ai viré la regex car il n'y a pas besoin de l'utiliser pour découper la chaîne.
return x.replace('//', '/')
return x
smihai marked this conversation as resolved Outdated

Tout le monde ne lit pas les regex facilement, un commentaire pour l'expliciter serait utile.

Tout le monde ne lit pas les regex facilement, un commentaire pour l'expliciter serait utile.
# split key by single / only
return [map_key(x) for x in re.split(r'(?<!/)/(?!/)', key)]
keys = [(split_key(key), key) for key in d]
try:
smihai marked this conversation as resolved Outdated

a mix

a mix
keys.sort()
smihai marked this conversation as resolved Outdated

Le commentaire dit qu'e c'est parce qu'il y a un mélange mais l'exception dit que c'est parce qu'il y a un problème d'ordre ?

Le commentaire dit qu'e c'est parce qu'il y a un mélange mais l'exception dit que c'est parce qu'il y a un problème d'ordre ?

Corrigé le message de l'exception.

Corrigé le message de l'exception.
except TypeError:
# sorting fail means that there is a mix between lists and dicts
raise UnflattenKeysException(_('there is a mix between lists and dicts'))
def set_path(path, orig_key, d, value, i=0):
assert path
smihai marked this conversation as resolved Outdated

set the value

set the value
key, tail = path[i], path[i + 1 :]
if not tail: # end of path, set the value
if isinstance(key, int):
assert isinstance(d, list)
if len(d) != key:
raise UnflattenKeysException(_('incomplete array before key "%s"') % orig_key)
d.append(value)
smihai marked this conversation as resolved Outdated

Je serais pour ici avoir un "return # end recursion", et enchainer la suite un nouveau d'indentation plus bas (retirer le else de la ligne suivante).

Cela parce qu'à la lecture de la fonction je me suis perdu dans l'indentation et avec l'appel set_path() qui la termine je ne voyais pas comment la récursion s'arrêtait.

Je serais pour ici avoir un "return # end recursion", et enchainer la suite un nouveau d'indentation plus bas (retirer le else de la ligne suivante). Cela parce qu'à la lecture de la fonction je me suis perdu dans l'indentation et avec l'appel set_path() qui la termine je ne voyais pas comment la récursion s'arrêtait.
else:
assert isinstance(d, dict)
d[key] = value
return # end of recursion
new = [] if isinstance(tail[0], int) else {}
if isinstance(key, int):
smihai marked this conversation as resolved Outdated

C'est possible de réécrire le map sous forme de list comprehension ? (str(x) for x in in path[...])

C'est possible de réécrire le map sous forme de list comprehension ? (str(x) for x in in path[...])
assert isinstance(d, list)
if len(d) < key:
raise UnflattenKeysException(
_('incomplete array before %s in %s')
% (('/'.join([str(x) for x in path[: i + 1]])), orig_key)
)
if len(d) == key:
d.append(new)
else:
new = d[key]
else:
smihai marked this conversation as resolved Outdated

Possibilité d'étendre le commentaire avec un rappel de ce que contiendrait keys ici ?

Possibilité d'étendre le commentaire avec un rappel de ce que contiendrait keys ici ?

Bonne remarque.
J'ai complété le test pour couvrir les 2 cas.

Bonne remarque. J'ai complété le test pour couvrir les 2 cas.
new = d.setdefault(key, new)
set_path(path, orig_key, new, value, i + 1)
# Is the first level an array (ie key is like "0/param") or a dict (key is like "param/0") ?
if isinstance(keys[0][0][0], int):
new = []
else:
new = {}
for path, key in keys:
value = d[key]
set_path(path, key, new, value)
return new
def get_app_error_code(response, data, response_type):
app_error_code = 0
app_error_code_header = response.headers.get('x-error-code')
@ -167,6 +248,7 @@ def call_webservice(
else:
if payload[key]:
payload[key] = get_publisher().get_cached_complex_data(payload[key])
payload = unflatten_keys(payload)
# if formdata has to be sent, it's the payload. If post_data exists,
# it's added in formdata['extra']
@ -366,13 +448,22 @@ class NamedWsCall(XmlStorableObject):
if getattr(get_request(), 'disable_error_notifications', None) is True:
notify_on_errors = False
record_on_errors = False
data = call_webservice(
cache=True,
notify_on_errors=notify_on_errors,
record_on_errors=record_on_errors,
**(self.request or {}),
)[2]
return json.loads(force_str(data))
try:
data = call_webservice(
cache=True,
notify_on_errors=notify_on_errors,
record_on_errors=record_on_errors,
**(self.request or {}),
)[2]
return json.loads(force_str(data))
except UnflattenKeysException as e:
get_publisher().record_error(
error_summary=e.get_summary(),
exception=e,
context='[WSCALL]',
notify=notify_on_errors,
record=record_on_errors,
)
class WsCallsSubstitutionProxy:
@ -444,6 +535,15 @@ class WsCallRequestWidget(CompositeWidget):
'data-dynamic-display-child-of': method_widget.get_name(),
'data-dynamic-display-value': methods.get('POST'),
},
hint=_(
'The / in parameter name allows to generate complex objects. '
'Thus a parameter named "element/child" containing "value" will generate the payload '
'"element": {"child": "value"}. If the subkey, i.e. "child", is an integer it will '
'become a list index and two elements "element/0", "element/1" (indexes should '
' start from zero) containing "value1" and "value2" will generate the payload '
'"element": ["value1", "value2"]. It is possible to combine the two types, for '
'example "element/0/key1" to generate a list of objects.'
),
)
self.add(