Query Response format to be based on http 'accept' header & Query Payload content type to be based on 'content-type' header (#4033)

* o- Query Response format to be based on http 'accept' header & Query Payload contenty type to be based on 'content-type' header

* o- Query Response format to be based on http 'accept' header & Query Payload contenty type to be based on 'content-type' header
o- if Accept header is absent, it defaults to Content-Type header

* Feature: Query Response format to be based on http 'accept' header & Query Payload content type to be based on 'content-type'  PR #4033
Minor change to a comment - restoring to previous wording

* Feature: Query Response format to be based on http 'accept' header & Query Payload content type to be based on 'content-type'  PR #4033
o- minor change to check for empty string
This commit is contained in:
vishnu rao 2018-10-13 05:29:14 +08:00 committed by Charles Allen
parent 3be4a97150
commit 6567fff9e7
3 changed files with 145 additions and 9 deletions

View File

@ -11,12 +11,20 @@ REST query interface. For normal Druid operations, queries should be issued to t
to the queryable nodes like this - to the queryable nodes like this -
```bash ```bash
curl -X POST '<queryable_host>:<port>/druid/v2/?pretty' -H 'Content-Type:application/json' -d @<query_json_file> curl -X POST '<queryable_host>:<port>/druid/v2/?pretty' -H 'Content-Type:application/json' -H 'Accept:application/json' -d @<query_json_file>
``` ```
Druid's native query language is JSON over HTTP, although many members of the community have contributed different Druid's native query language is JSON over HTTP, although many members of the community have contributed different
[client libraries](../development/libraries.html) in other languages to query Druid. [client libraries](../development/libraries.html) in other languages to query Druid.
The Content-Type/Accept Headers can also take 'application/x-jackson-smile'.
```bash
curl -X POST '<queryable_host>:<port>/druid/v2/?pretty' -H 'Content-Type:application/json' -H 'Accept:x-jackson-smile' -d @<query_json_file>
```
Note: If Accept header is not provided, it defaults to value of 'Content-Type' header.
Druid's native query is relatively low level, mapping closely to how computations are performed internally. Druid queries Druid's native query is relatively low level, mapping closely to how computations are performed internally. Druid queries
are designed to be lightweight and complete very quickly. This means that for more complex analysis, or to build are designed to be lightweight and complete very quickly. This means that for more complex analysis, or to build
more complex visualizations, multiple Druid queries may be required. more complex visualizations, multiple Druid queries may be required.

View File

@ -24,6 +24,8 @@ import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.joda.ser.DateTimeSerializer; import com.fasterxml.jackson.datatype.joda.ser.DateTimeSerializer;
import com.fasterxml.jackson.jaxrs.smile.SmileMediaTypes; import com.fasterxml.jackson.jaxrs.smile.SmileMediaTypes;
import com.google.common.base.Strings;
import com.google.common.base.Throwables; import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
@ -156,13 +158,19 @@ public class QueryResource implements QueryCountStatsProvider
public Response doPost( public Response doPost(
final InputStream in, final InputStream in,
@QueryParam("pretty") final String pretty, @QueryParam("pretty") final String pretty,
@Context final HttpServletRequest req // used to get request content-type, remote address and auth-related headers @Context final HttpServletRequest req // used to get request content-type,Accept header, remote address and auth-related headers
) throws IOException ) throws IOException
{ {
final QueryLifecycle queryLifecycle = queryLifecycleFactory.factorize(); final QueryLifecycle queryLifecycle = queryLifecycleFactory.factorize();
Query<?> query = null; Query<?> query = null;
final ResponseContext context = createContext(req.getContentType(), pretty != null); String acceptHeader = req.getHeader("Accept");
if (Strings.isNullOrEmpty(acceptHeader)) {
//default to content-type
acceptHeader = req.getContentType();
}
final ResponseContext context = createContext(acceptHeader, pretty != null);
final String currThreadName = Thread.currentThread().getName(); final String currThreadName = Thread.currentThread().getName();
try { try {
@ -294,13 +302,13 @@ public class QueryResource implements QueryCountStatsProvider
} }
} }
private static Query<?> readQuery( private Query<?> readQuery(
final HttpServletRequest req, final HttpServletRequest req,
final InputStream in, final InputStream in,
final ResponseContext context final ResponseContext context
) throws IOException ) throws IOException
{ {
Query baseQuery = context.getObjectMapper().readValue(in, Query.class); Query baseQuery = getMapperForRequest(req.getContentType()).readValue(in, Query.class);
String prevEtag = getPreviousEtag(req); String prevEtag = getPreviousEtag(req);
if (prevEtag != null) { if (prevEtag != null) {
@ -317,6 +325,13 @@ public class QueryResource implements QueryCountStatsProvider
return req.getHeader(HEADER_IF_NONE_MATCH); return req.getHeader(HEADER_IF_NONE_MATCH);
} }
protected ObjectMapper getMapperForRequest(String requestContentType)
{
boolean isSmile = SmileMediaTypes.APPLICATION_JACKSON_SMILE.equals(requestContentType) ||
APPLICATION_SMILE.equals(requestContentType);
return isSmile ? smileMapper : jsonMapper;
}
protected ObjectMapper serializeDataTimeAsLong(ObjectMapper mapper) protected ObjectMapper serializeDataTimeAsLong(ObjectMapper mapper)
{ {
return mapper.copy().registerModule(new SimpleModule().addSerializer(DateTime.class, new DateTimeSerializer())); return mapper.copy().registerModule(new SimpleModule().addSerializer(DateTime.class, new DateTimeSerializer()));

View File

@ -19,6 +19,7 @@
package org.apache.druid.server; package org.apache.druid.server;
import com.fasterxml.jackson.jaxrs.smile.SmileMediaTypes;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Throwables; import com.google.common.base.Throwables;
@ -52,6 +53,7 @@ import org.apache.druid.server.security.Authorizer;
import org.apache.druid.server.security.AuthorizerMapper; import org.apache.druid.server.security.AuthorizerMapper;
import org.apache.druid.server.security.ForbiddenException; import org.apache.druid.server.security.ForbiddenException;
import org.apache.druid.server.security.Resource; import org.apache.druid.server.security.Resource;
import org.apache.http.HttpStatus;
import org.easymock.EasyMock; import org.easymock.EasyMock;
import org.joda.time.Interval; import org.joda.time.Interval;
import org.junit.After; import org.junit.After;
@ -125,6 +127,7 @@ public class QueryResourceTest
public void setup() public void setup()
{ {
EasyMock.expect(testServletRequest.getContentType()).andReturn(MediaType.APPLICATION_JSON).anyTimes(); EasyMock.expect(testServletRequest.getContentType()).andReturn(MediaType.APPLICATION_JSON).anyTimes();
EasyMock.expect(testServletRequest.getHeader("Accept")).andReturn(MediaType.APPLICATION_JSON).anyTimes();
EasyMock.expect(testServletRequest.getHeader(QueryResource.HEADER_IF_NONE_MATCH)).andReturn(null).anyTimes(); EasyMock.expect(testServletRequest.getHeader(QueryResource.HEADER_IF_NONE_MATCH)).andReturn(null).anyTimes();
EasyMock.expect(testServletRequest.getRemoteAddr()).andReturn("localhost").anyTimes(); EasyMock.expect(testServletRequest.getRemoteAddr()).andReturn("localhost").anyTimes();
queryManager = new QueryManager(); queryManager = new QueryManager();
@ -187,6 +190,116 @@ public class QueryResourceTest
Assert.assertNotNull(response); Assert.assertNotNull(response);
} }
@Test
public void testGoodQueryWithNullAcceptHeader() throws IOException
{
final String acceptHeader = null;
final String contentTypeHeader = MediaType.APPLICATION_JSON;
EasyMock.reset(testServletRequest);
EasyMock.expect(testServletRequest.getAttribute(AuthConfig.DRUID_AUTHORIZATION_CHECKED))
.andReturn(null)
.anyTimes();
EasyMock.expect(testServletRequest.getAttribute(AuthConfig.DRUID_ALLOW_UNSECURED_PATH)).andReturn(null).anyTimes();
EasyMock.expect(testServletRequest.getAttribute(AuthConfig.DRUID_AUTHENTICATION_RESULT))
.andReturn(authenticationResult)
.anyTimes();
testServletRequest.setAttribute(AuthConfig.DRUID_AUTHORIZATION_CHECKED, true);
EasyMock.expect(testServletRequest.getHeader("Accept")).andReturn(acceptHeader).anyTimes();
EasyMock.expect(testServletRequest.getContentType()).andReturn(contentTypeHeader).anyTimes();
EasyMock.expect(testServletRequest.getHeader(QueryResource.HEADER_IF_NONE_MATCH)).andReturn(null).anyTimes();
EasyMock.expect(testServletRequest.getRemoteAddr()).andReturn("localhost").anyTimes();
EasyMock.replay(testServletRequest);
Response response = queryResource.doPost(
new ByteArrayInputStream(simpleTimeSeriesQuery.getBytes("UTF-8")),
null /*pretty*/,
testServletRequest
);
Assert.assertEquals(HttpStatus.SC_OK, response.getStatus());
//since accept header is null, the response content type should be same as the value of 'Content-Type' header
Assert.assertEquals(contentTypeHeader, (response.getMetadata().get("Content-Type").get(0)).toString());
Assert.assertNotNull(response);
}
@Test
public void testGoodQueryWithEmptyAcceptHeader() throws IOException
{
final String acceptHeader = "";
final String contentTypeHeader = MediaType.APPLICATION_JSON;
EasyMock.reset(testServletRequest);
EasyMock.expect(testServletRequest.getAttribute(AuthConfig.DRUID_AUTHORIZATION_CHECKED))
.andReturn(null)
.anyTimes();
EasyMock.expect(testServletRequest.getAttribute(AuthConfig.DRUID_ALLOW_UNSECURED_PATH)).andReturn(null).anyTimes();
EasyMock.expect(testServletRequest.getAttribute(AuthConfig.DRUID_AUTHENTICATION_RESULT))
.andReturn(authenticationResult)
.anyTimes();
testServletRequest.setAttribute(AuthConfig.DRUID_AUTHORIZATION_CHECKED, true);
EasyMock.expect(testServletRequest.getHeader("Accept")).andReturn(acceptHeader).anyTimes();
EasyMock.expect(testServletRequest.getContentType()).andReturn(contentTypeHeader).anyTimes();
EasyMock.expect(testServletRequest.getHeader(QueryResource.HEADER_IF_NONE_MATCH)).andReturn(null).anyTimes();
EasyMock.expect(testServletRequest.getRemoteAddr()).andReturn("localhost").anyTimes();
EasyMock.replay(testServletRequest);
Response response = queryResource.doPost(
new ByteArrayInputStream(simpleTimeSeriesQuery.getBytes("UTF-8")),
null /*pretty*/,
testServletRequest
);
Assert.assertEquals(HttpStatus.SC_OK, response.getStatus());
//since accept header is empty, the response content type should be same as the value of 'Content-Type' header
Assert.assertEquals(contentTypeHeader, (response.getMetadata().get("Content-Type").get(0)).toString());
Assert.assertNotNull(response);
}
@Test
public void testGoodQueryWithSmileAcceptHeader() throws IOException
{
//Doing a replay of testServletRequest for teardown to succeed.
//We dont use testServletRequest in this testcase
EasyMock.replay(testServletRequest);
//Creating our own Smile Servlet request, as to not disturb the remaining tests.
// else refactoring required for this class. i know this kinda makes the class somewhat Dirty.
final HttpServletRequest smileRequest = EasyMock.createMock(HttpServletRequest.class);
EasyMock.expect(smileRequest.getContentType()).andReturn(MediaType.APPLICATION_JSON).anyTimes();
EasyMock.expect(smileRequest.getAttribute(AuthConfig.DRUID_AUTHORIZATION_CHECKED))
.andReturn(null)
.anyTimes();
EasyMock.expect(smileRequest.getAttribute(AuthConfig.DRUID_ALLOW_UNSECURED_PATH)).andReturn(null).anyTimes();
EasyMock.expect(smileRequest.getAttribute(AuthConfig.DRUID_AUTHENTICATION_RESULT))
.andReturn(authenticationResult)
.anyTimes();
smileRequest.setAttribute(AuthConfig.DRUID_AUTHORIZATION_CHECKED, true);
EasyMock.expect(smileRequest.getHeader("Accept")).andReturn(SmileMediaTypes.APPLICATION_JACKSON_SMILE).anyTimes();
EasyMock.expect(smileRequest.getHeader(QueryResource.HEADER_IF_NONE_MATCH)).andReturn(null).anyTimes();
EasyMock.expect(smileRequest.getRemoteAddr()).andReturn("localhost").anyTimes();
EasyMock.replay(smileRequest);
Response response = queryResource.doPost(
new ByteArrayInputStream(simpleTimeSeriesQuery.getBytes("UTF-8")),
null /*pretty*/,
smileRequest
);
Assert.assertEquals(HttpStatus.SC_OK, response.getStatus());
Assert.assertEquals(SmileMediaTypes.APPLICATION_JACKSON_SMILE, (response.getMetadata().get("Content-Type").get(0)).toString());
Assert.assertNotNull(response);
EasyMock.verify(smileRequest);
}
@Test @Test
public void testBadQuery() throws IOException public void testBadQuery() throws IOException
{ {
@ -287,7 +400,8 @@ public class QueryResourceTest
Assert.assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); Assert.assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
Assert.assertEquals(0, responses.size()); Assert.assertEquals(0, responses.size());
Assert.assertEquals(1, testRequestLogger.getLogs().size()); Assert.assertEquals(1, testRequestLogger.getLogs().size());
Assert.assertEquals(true, testRequestLogger.getLogs().get(0).getQueryStats().getStats().get("success")); Assert.assertEquals(true,
testRequestLogger.getLogs().get(0).getQueryStats().getStats().get("success"));
Assert.assertEquals("druid", testRequestLogger.getLogs().get(0).getQueryStats().getStats().get("identity")); Assert.assertEquals("druid", testRequestLogger.getLogs().get(0).getQueryStats().getStats().get("identity"));
} }
@ -401,8 +515,7 @@ public class QueryResourceTest
startAwaitLatch.await(); startAwaitLatch.await();
Executors.newSingleThreadExecutor().submit( Executors.newSingleThreadExecutor().submit(
new Runnable() new Runnable() {
{
@Override @Override
public void run() public void run()
{ {