pythonapipython-requests

Understanding the difference between these two python requests POST calls (data vs. json args)


This is a toy, non-reproducible example as I can't share the original. I think it's answerable, and might help others. From other SO posts like this and this, my understanding is that given some dictionary d of params, these are equivalent:

requests.post(url, data=json.dumps(d))
requests.post(url, json=d)

The parameters for a token endpoint were defined in documentation like so:

I started with this, with variables loaded from a .env file:

resp = requests.post(f'{base_url}/token',
                     json={'grant_type': 'password', 'username': uname, 'password': pwd,
                           'scope': {'account': account, 'tenant': tenant}})
resp.text
# '{"error":"unsupported_grant_type"}'

I tried changing to the data argument, and got a more sane error:

resp = requests.post(f'{base_url}/token',
                     data={'grant_type': 'password', 'username': uname, 'password': pwd,
                           'scope': {'account': account, 'tenant': tenant}})
resp.text
# '{"error":"invalid_grant","error_description":"{\\"ErrorMessage\\":\\"Error trying to Login  - User [username] Account [] Unexpected character encountered while parsing value: a.

I tried a few other things like forcing quotes around args (e.g. {'account': f"{account}"}) without success, and ultimately succeeded with this "hybrid" method:

resp = requests.post(f'{base_url}/token',
                 data={'grant_type': 'password', 'username': uname, 'password': pwd,
                       'scope': json.dumps({'account': account, 'tenant': tenant})})

My questions:


Solution

  • There is a big difference in providing json= or data= argument.

    Providing json= will send Content-Type: application/json and will format the data you sent as json-string. E.g. the following request:

    resp = requests.post(url, json={"a": 42, "b": 55})
    

    Will result in the server receiving this:

    POST / HTTP/1.1
    Host: localhost:12001
    User-Agent: python-requests/2.28.1
    Accept-Encoding: gzip, deflate, br
    Accept: */*
    Connection: keep-alive
    Content-Length: 18
    Content-Type: application/json
    
    
    {"a": 42, "b": 55}
    

    whereas sending the same as data will send it as form data with content-type application/x-www-form-urlencoded:

    resp = requests.post(url, data={"a": 42, "b": 55})
    

    Will result in the server receiving this:

    POST / HTTP/1.1
    Host: localhost:12001
    User-Agent: python-requests/2.28.1
    Accept-Encoding: gzip, deflate, br
    Accept: */*
    Connection: keep-alive
    Content-Length: 9
    Content-Type: application/x-www-form-urlencoded
    
    
    a=42&b=55
    

    If you are using the data=json.dumps(...) variant, you essentially pass a single string as data. The string is json-format but requests does not know this (it could be anything). In this case, it seems, requests does not send any Content-Type, you would have to explicitly set it in the code.

    Now, which variant is correct, depends on the API you are using, but as the data seems to be structured, it cannot be encoded well into form-data, so probably json is what you want to send.

    With respect to the mixed solution, I would argue that it shows that the API is very ill-designed, but here is what the server receives:

    POST / HTTP/1.1
    Host: localhost:12001
    User-Agent: python-requests/2.28.1
    Accept-Encoding: gzip, deflate, br
    Accept: */*
    Connection: keep-alive
    Content-Length: 145
    Content-Type: application/x-www-form-urlencoded
    
    
    grant_type=password&username=treuss&password=s3cret&scope=%7B%22account%22%3A+%22treuss%40stackoverflow.com%22%2C+%22tenant%22%3A+%22tenant%22%7D
    

    Everything after scope= is the encoded json-string generated from the json.dump in your example, i.e. an url-encoded version of the string {"account": "treuss@stackoverflow.com", "tenant": "tenant"}