REST APIs that speak XML
Asking for XML or JSON with Accept, 406 and 415, and what changes between the two formats.
XML isn't only for SOAP. Plenty of REST APIs send and receive it, either only XML or as an alternative to JSON chosen per request. Testing them uses everything from the earlier lessons, plus two HTTP headers that decide which format you get, and a set of problems that come from translating between JSON and XML. This lesson covers both.
Choosing a format: content negotiation
When one URL can return several formats, the client says which it wants in the Accept request header, and the server says which it sent in the Content-Type response header. This is called content negotiation, and it's defined in RFC 9110, the specification for HTTP's meaning. This API's negotiation endpoint returns the same user in whatever format you ask for:
$ curl -i -H "Accept: application/xml" https://api.testingapis.com/api/formats/negotiate
HTTP/1.1 200 OK
content-type: application/xml; charset=utf-8
<?xml version="1.0" encoding="UTF-8"?><user xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><id>1</id><name>Alice Johnson</name><email>alice@example.com</email><roles><role>admin</role><role>editor</role></roles><active>true</active><score>98.5</score><manager xsi:nil="true"/></user>
$ curl -H "Accept: application/json" https://api.testingapis.com/api/formats/negotiate
{"id":1,"name":"Alice Johnson","email":"alice@example.com","roles":["admin","editor"],"active":true,"score":98.5,"manager":null}Same URL, two formats. If you ask for a format the server can't produce, the correct answer is 406 Not Acceptable, which here lists what it can produce:
$ curl -i -H "Accept: image/png" https://api.testingapis.com/api/formats/negotiate
HTTP/1.1 406 Not Acceptable
content-type: application/json; charset=utf-8
{"status":406,"error":"Not Acceptable","message":"None of the requested media types are supported: 'image/png'.","supported":["application/json","application/xml","text/xml","text/html","text/plain","text/csv"]}| Header | Sent by | Says | Wrong value gets |
|---|---|---|---|
Accept | Client | Which formats I can read | 406 Not Acceptable |
Content-Type | Client, with a body | The format of the body I'm sending | 415 Unsupported Media Type |
Content-Type | Server | The format of the body I'm sending back | Not an error, but a bug if it's wrong |
The tests check all three outcomes:
@Test
@DisplayName("Accept: application/xml gets XML")
void askForXml() {
Api.Response response = getWithAccept("/api/formats/negotiate", "application/xml");
assertEquals(200, response.status());
assertTrue(response.contentType().startsWith("application/xml"));
assertEquals("Alice Johnson", q.string(response.xml(), "/user/name"));
}@Test
@DisplayName("a type the server can't produce gets 406 Not Acceptable")
void notAcceptable() {
Api.Response response = getWithAccept("/api/formats/negotiate", "image/png");
assertEquals(406, response.status());
}Same resource, same data
Look again at the two responses above. Both have the same seven fields, but three of them needed a decision, because JSON and XML don't map onto each other directly (lesson 1). When this lesson was first written, this API's XML left those three out, and a client that asked for XML got less. It's a common bug, for these reasons:
- Lists: JSON has arrays, XML has repeated elements, and serializers often need extra configuration to produce a wrapper element. Here the
rolesarray became aroleselement holding oneroleper item. - null: XML has no null.
manager: nullcan become an empty element, anxsi:nilattribute, or nothing at all. This API usesxsi:nil="true", a convention from XML Schema, which needs thexsinamespace declared on the root. - Numbers:
98.5becomes text, and the formatting can change between serializers: decimal places, exponents, the decimal separator.
When an API offers both formats, test that both carry the same data, including the awkward fields:
@Test
@DisplayName("the XML form carries the same fields as the JSON, lists and null included")
void sameFieldsAsJson() {
Document doc = getWithAccept("/api/formats/negotiate", "application/xml").xml();
assertEquals(List.of("admin", "editor"), q.strings(doc, "/user/roles/role")); // a JSON array
assertEquals("98.5", q.string(doc, "/user/score"));
XmlQuery xsi = new XmlQuery(Map.of("xsi", "http://www.w3.org/2001/XMLSchema-instance"));
assertEquals("true", xsi.string(doc, "/user/manager/@xsi:nil")); // JSON's null
}An XML-only API
/api/xml/UserXMLsign in to see is a small REST API that only speaks XML: four users, with IDs 101 to 104, and endpoints to read, create, update and delete them.
$ curl -i https://api.testingapis.com/api/xml/UserXML/101
HTTP/1.1 200 OK
content-type: application/xml; charset=utf-8
<User xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><Id>101</Id><Name>Hiroshi Tanaka</Name><Job>Engineer</Job><City>Tokyo</City></User>
$ curl -s https://api.testingapis.com/api/xml/UserXML/all
<Users xmlns:xsi="..." xmlns:xsd="..."><User><Id>101</Id><Name>Hiroshi Tanaka</Name><Job>Engineer</Job><City>Tokyo</City></User><User><Id>102</Id>...</User>...</Users>@Test
@DisplayName("an XML-only resource: a user by ID")
void xmlUser() {
Api.Response response = Api.get("/api/xml/UserXML/101");
assertEquals(200, response.status());
Document doc = response.xml();
assertEquals("User", doc.getDocumentElement().getLocalName());
assertEquals("Hiroshi Tanaka", q.string(doc, "/User/Name"));
assertEquals("Tokyo", q.string(doc, "/User/City"));
}Compare the single user with the list: a user is a User element in both, and the list wraps them in Users. That sounds obvious, but serializers don't do it by default. When this lesson was first written, the list used the server's internal class names, UserXmlResponse inside ArrayOfUserXmlResponse, so a client couldn't reuse its parsing from one endpoint to the other. The test checks the names match:
@Test
@DisplayName("the list uses the same User element as the single-user endpoint")
void listElementNames() {
Document doc = Api.get("/api/xml/UserXML/all").xml();
assertEquals("Users", doc.getDocumentElement().getLocalName());
assertTrue(q.number(doc, "count(/Users/User)") >= 4); // the seeds, plus any users people have created
assertEquals("Hiroshi Tanaka", q.string(doc, "/Users/User[Id='101']/Name"));
}Errors
$ curl -i https://api.testingapis.com/api/xml/UserXML/999
HTTP/1.1 404 Not Found
content-type: application/xml; charset=utf-8
<Error xmlns:xsi="..." xmlns:xsd="..."><Status>404</Status><Message>User with ID 999 not found</Message></Error>
$ curl -i -X POST https://api.testingapis.com/api/xml/UserXML/create -H "Content-Type: application/json" -d '{"name":"Ana"}'
HTTP/1.1 415 Unsupported Media Type
content-length: 0@Test
@DisplayName("a missing user is a 404 with an XML error")
void notFound() {
Api.Response response = Api.get("/api/xml/UserXML/999");
assertEquals(404, response.status());
assertTrue(response.contentType().startsWith("application/xml"));
assertEquals("404", q.string(response.xml(), "/Error/Status"));
assertEquals("User with ID 999 not found", q.string(response.xml(), "/Error/Message"));
}Errors come back as an Errorelement with a status and a message, in XML like everything else. That includes a body that isn't well-formed XML:
$ curl -i -X POST https://api.testingapis.com/api/xml/UserXML/create -H "Content-Type: application/xml" -d "<UserRequest><Name>Ana</Name>"
HTTP/1.1 400 Bad Request
content-type: application/xml; charset=utf-8
<Error xmlns:xsi="..." xmlns:xsd="..."><Status>400</Status><Message>The body must be a well-formed <UserRequest> element with Name, Job and City.</Message></Error>@Test
@DisplayName("a body that isn't well-formed XML gets a 400 in XML too")
void badBody() {
Api.Response response = Api.post("/api/xml/UserXML/create", Map.of("Content-Type", "application/xml"), "<UserRequest><Name>Ana</Name>");
assertEquals(400, response.status());
assertTrue(response.contentType().startsWith("application/xml"));
assertEquals("400", q.string(response.xml(), "/Error/Status"));
}This one is easy to get wrong. Frameworks often answer an unreadable body before your code runs, in their own default format. When this lesson was first written, this endpoint did exactly that: a bare <string> for a 404, and a JSON problem report for a malformed body. An XML API that answers some errors in JSON forces every client to handle both formats on the error path, where clients are least tested. Check the Content-Type of every error response, not just the successes.
What to check in a REST API that speaks XML
| Check | Why |
|---|---|
| Accept: application/xml gets XML, with the right Content-Type | Clients depend on the header to choose a parser |
| An unsupported Accept gets 406 | Rather than a format the client can't read |
| A body in the wrong format gets 415 | Rather than a confusing parse error |
| XML and JSON carry the same fields and values | Serializers drop lists and nulls |
| Element names are the same wherever a resource appears | So one parser works for every endpoint |
| Errors come back in XML too | Clients shouldn't need a JSON parser for failures |
| Responses are well-formed and, if there's a schema, valid | The same contract checks as lesson 7 |
| Success statuses match the action, such as 201 for a create | With a Location header saying where the new resource is |
Check your understanding
1.Which header asks the server for XML, and which says what format came back?Show answer
Accept on the request asks; Content-Type on the response says.
2.What status should a server return when it can't produce any format the client accepts?Show answer
406 Not Acceptable.
3.You POST a JSON body to an XML-only endpoint. What status is correct?Show answer
415 Unsupported Media Type, because the server doesn't accept that Content-Type.
4.Why do XML versions of JSON resources often lose fields?Show answer
JSON arrays and null have no direct XML equivalent, so serializers need extra configuration for them, and without it they drop or reshape those fields.
5.Why check the Content-Type of error responses?Show answer
Some APIs return errors in a different format from their successes, which breaks clients on the error path.
Exercise
Write a test that fetches /api/formats/negotiatesign in to see twice, with Accept: application/xml and Accept: application/json, and compares the id, name, email and active fields. Make the test fail if the JSON has a field the XML lacks. Then try Accept: text/csv, and see how the CSV writes the list and the null, and Accept: application/xml;q=0.5, application/json, and work out from RFC 9110 which format the second one should get.