3

I'm evaluating Axios and one thing I can't seem to figure out how to enforce that a response is JSON. From what I've gathered, Axios will automatically parse the JSON for us based on the content type (https://stackoverflow.com/a/65976510/1103734). However, I was hoping to actually enforce that the response is JSON (e.g. if my nginx proxy returns HTML due to a downstream error, I would want to handle that).

I noticed that the Axios request config has a responseType property, but as near as I can tell, this is not used to actually enforce an expected type is returned. Here's an example snippet that demonstrates what I'm talking about

axios.get('http://cataas.com/cat?html=true', {responseType: "json"})
  .then(res => console.log(`Response:\n ${res.data}`))
  .catch((err) => console.log(`Error: ${err}`))

Output:

Response:
 
                <!DOCTYPE html>
                <html lang="en">
                    <header>
                        <meta charset="utf-8">
                    </header>
                    <body>
                        <img alt="GRxZb4kUHleQ3LhC" src="/cat/GRxZb4kUHleQ3LhC">
                    </body>
                </html>

The best thing I can find is to put JSON.parse in the transformResponse property, but this means that if there's an error in parsing a response with a bad status code, I will lose that status code information in my catch.

axios.get('http://cataas.com/cat?html=true', {responseType: "json", transformResponse: JSON.parse})
  .then(res => console.log(`Response\n ${res.data}`))
  .catch((err) => console.log(`Error: ${err}`))

Output (obviously, SyntaxError does not contain any information about the response):

Error: SyntaxError: Unexpected token < in JSON at position 17

Is there a nice way to achieve what I want?

ollien
  • 4,418
  • 9
  • 35
  • 58
  • 2
    No, there is no way to enforce a JSON response. The server can always decide to respond what it want. The client can ask for specific response type, but the server doesn't have to follow. – jabaa Mar 19 '23 at 20:46
  • Of course; I can't control the server response, but I'm looking for a client-side assertion to ensure that the data in `response.data` is actually JSON, and not just a string (in the example I gave above, axios does not throw an error in this case; it simply carries on). Ideally, the response would have a proper `Content-Type` header, and Axios could see it wasn't JSON, and throw an error. – ollien Mar 19 '23 at 20:48
  • 1
    If you want to make sure the response is JSON just try/catch `JSON.parse(response)` and you'll know? – customcommander Mar 19 '23 at 20:52
  • Where would I do that? If I do it after the response is returned, I will be attempting to parse already-parsed JSON (which is a syntax error). I could always check if the response type is not a string (though that technically doesn't cover an edge-case where a server sends me a JSON-encoded string), but I would have expected a library like Axios to handle this for me. As noted in the question, doing it in `transformResponse` throws away valuable error handling information. – ollien Mar 19 '23 at 20:54
  • I think you're confused about what JSON is. A JSON document *is* a string, that you then *parse* into an object, a number or a string, boolean, array. i.e. `'42'` is a valid JSON document which is 42 as a number. `'"42"'` is another JSON document resulting in 42 as a string when parsed. Browser tooling would often have parsed the response for you which may lead you to think JSON is some form of an object but that is not the case. JSON is a text format. – customcommander Mar 19 '23 at 20:58
  • I understand what JSON is :). The edge-case you're pointing out was mentioned in my comment (though perhaps not clearly "(though that technically doesn't cover an edge-case where a server sends me a JSON-encoded string"). However, it should be possible for Axios to detect when the response given is not JSON (as indicated by the `Content-Type` header, or by the non-parsabilty of something _with_ that header), and let me deal with the error. Perhaps what I want to do is not possible with Axios, but I figured I'd ask. – ollien Mar 19 '23 at 21:00
  • 1
    TBH, I still don't understand what you want. The response is always a string. It can be a JSON string or something. How do you want to handle a JSON string and how do you want something else? – jabaa Mar 19 '23 at 21:06
  • I want Axios to throw an error when it receives a non-JSON response (whether the content type indicates otherwise or the response is just simply not parsable as json). In other words, I want to guarantee that by the time my application code reads `response.data`, it is actually parsed JSON. Apologies for the confusion – ollien Mar 19 '23 at 21:07
  • Same here. What's the edge case you're talking about? A JSON response is always a string. Even if the headers tell you're sending or receiving JSON, both ends would have to make sure it actually is. JSON.parse gives you that option. If you catch an error when parsing then you know it wasn't JSON. – customcommander Mar 19 '23 at 21:08
  • "What's the edge case you're talking about? " -> the case of receiving a JSON encoded string of `"blah"` like you described before. "If you catch an error when parsing then you know it wasn't JSON." Understood. As stated in the question, if I do this in `transformResponse`, error information gets thrown away. I'm trying to find a way to do this that will both allow me to inspect the original response (e.g. for a bad status code), If I attempt to do it after the response is returned, I have no idea if Axios already parsed a string such as `"blah"` as JSON or if what I got was just not JSON. – ollien Mar 19 '23 at 21:11
  • I really really don't mean to be pedantic here but `"blah"` isn't a JSON encoded string at all. It won't even parse. However `'"blah"'` is. If `transformResponse` isn't what you want why don't you simply parse the response yourself? If you catch an error when parsing you have both your answer (it's not JSON) and the original context to inspect. I'm sure Axios has other callbacks you can hook into to inspect the response at various stages. – customcommander Mar 19 '23 at 21:18
  • Sorry, I think this is a difference of "notation". I know the literal string "blah" (without quotes) is not valid JSON, but the _string_ `"blah"` encoded _with quotes_ is one (I've written full-blown JSON parsers before, I'm aware of what the format can do). As for "If transformResponse isn't what you want why don't you simply,,," again, I'm not clear _where_ one would do this. Axios seems to do this JSON parsing when it _thinks_ the response is JSON, but if I get a string, I have no way of knowing whether that was a quoted string parsed as JSON, or it was never JSON. – ollien Mar 19 '23 at 21:22

2 Answers2

3

I think there is some confusion about the term "JSON"

I think what you mean is that you want the result from Axios to be a Javascript object, not a JSON string. The confusion is common because we often call Javascript objects "JSON objects" as a slang term.

If you type the following into the console, the resulting value of a will be a Javascript object:

const a = { x: 10}

Some people would call a a JSON object, but strictly speaking it is not. The JSON representation of a is the following string:

{ "x": 10 }

What Axios returns to you_ not a JSON string, but a Javascript object

This contains various pieces of information, in different properties of the object. Important to us here are:

  • The "data" property, which may be a string containing HTML, or a Javascript object, or something else.

  • Within the "headers" property, the "content-type" subproperty. This will begin with "application/json" if data is a Javascript object, and "text/html" if data is an HTML response.

Here is your code showing the content-type of the server response explicitly.

axios.get('http://cataas.com/cat?html=true')
  .then(response => {
    console.log("Example of an API returning an HTML response")
    const contentType = response.headers["content-type"];
    const data = response.data;

    console.log("Type of response data is:", contentType)
    console.log("Because it is a long string, I am just going to show a few characters of it:", data.slice(0, 40))

  })
  .catch((err) => console.log(`Error: ${err}`))


axios.get('https://dummyjson.com/products/1')
  .then(response => {
    console.log("Example of an API returning an JSON response")
    const contentType = response.headers["content-type"];
    const data = response.data;

    console.log("Type of response data is:", contentType)
    console.log("Because it is a small object, I am going to show it all:", data)

  })
  .catch((err) => console.log(`Error: ${err}`))
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/1.3.4/axios.min.js" integrity="sha512-LUKzDoJKOLqnxGWWIBM4lzRBlxcva2ZTztO8bTcWPmDSpkErWx0bSP4pdsjNH8kiHAUPaT06UXcb+vOEZH+HpQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

The http://cataas.com/cat?html=true API returns an HTML string

Axios faithfully gives you that string in the data property.

 <!DOCTYPE html>
                <html lang="en">
                    <header>
                        <meta charset="utf-8">
                    </header>
                    <body>
                        <img alt="ltBmKwnyGcagdHo3" src="/cat/ltBmKwnyGcagdHo3">
                    </body>
                </html>

The https://dummyjson.com/products/1 API returns a JSON string to Axios

Axios automatically converts that JSON string into a Javascript object for you.

{"id":1,"title":"iPhone 9","description":"An apple mobile which is nothing like apple","price":549,"discountPercentage":12.96,"rating":4.69,"stock":94,"brand":"Apple","category":"smartphones","thumbnail":"https://i.dummyjson.com/data/products/1/thumbnail.jpg","images":["https://i.dummyjson.com/data/products/1/1.jpg","https://i.dummyjson.com/data/products/1/2.jpg","https://i.dummyjson.com/data/products/1/3.jpg","https://i.dummyjson.com/data/products/1/4.jpg","https://i.dummyjson.com/data/products/1/thumbnail.jpg"]}

One way to achieve what you want:

  • Read response.headers["content-type"]

  • If it begins with application/json, then you are in luck: just treat response.data as a Javascript object

  • If it begins with text/html, despite you having requested a JSON, then something has gone wrong. You could read response.data as HTML, and look for whether the server said anything helpful.

I don't like the idea of wrapping everything in a try/catch, and picking up a failed JSON.parse. We are already being given information on whether response.data is an object or not, so let's use that.

You could even write a wrapper for Axios

That could do the above, so you only have to write the code once.

ProfDFrancis
  • 8,816
  • 1
  • 17
  • 26
  • Yeah, sorry for the confusion there. This is closest to what I want, but not quite; I was hoping that Axios could handle this in such a way that each individual callsite to `.get` wouldn't have to handle the content-type header. I may have found some trickery involving interceptors that I will post as a separate answer. N.B: Thanks for `dummyjson.com`, didn't know about that. – ollien Mar 19 '23 at 21:50
0

I think I've found a way to do what I want

import axios, { AxiosResponse } from "axios";

class BadResponseFormatError extends Error {
    constructor (public response: AxiosResponse) {
        super("Malformed response");
    }
}

axios.interceptors.response.use(
    (response: AxiosResponse) => {
        if (response.headers["content-type"] !== "application/json") {
            throw new BadResponseFormatError(response);
        }

        try {
            response.data = JSON.parse(response.data);
            return response;
        } catch {
            throw new BadResponseFormatError(response);
        }
    }
)

axios.get('http://cataas.com/cat?html=true', {responseType: "json", transformResponse: (body) => body})
  .then((res) => console.log(`Got response with data ${JSON.stringify(res.data)}`))
  .catch((err) =>  {
        // This could also be moved to a response interceptor,
        // I just did it here for the sake of demonstration
        if (err instanceof BadResponseFormatError) {
            console.error(`Got a bad format response with status code ${err.response.status}: ${err.response.data}`)
        } else {
            console.error(`Got some other error: ${err}`)
        }
    }
  )

A brief summary of what's going on

  1. I'm using transformResponse doing (body) => body, as presented in this answer. This allows the response interceptor to actually get at the textual response data. This was the key to make this work.
  2. I then delay the actual parse to the response interceptor, which allows me to error handle the parse manually.
  3. From there, I can create a custom exception that contains the original response, which I then use in my error handling.
ollien
  • 4,418
  • 9
  • 35
  • 58