// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.search.handler;
import com.yahoo.json.Jackson;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yahoo.container.Container;
import com.yahoo.container.core.config.testutil.HandlersConfigurerTestWrapper;
import com.yahoo.container.jdisc.HttpRequest;
import com.yahoo.container.jdisc.RequestHandlerTestDriver;
import com.yahoo.container.protect.Error;
import com.yahoo.io.IOUtils;
import com.yahoo.net.HostName;
import com.yahoo.search.searchchain.config.test.SearchChainConfigurerTestCase;
import com.yahoo.slime.Inspector;
import com.yahoo.slime.SlimeUtils;
import com.yahoo.test.json.JsonTestHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Random;
import static com.yahoo.jdisc.http.HttpRequest.Method.GET;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests submitting the query as JSON.
*
* @author henrhoi
*/
public class JSONSearchHandlerTestCase {
private static final ObjectMapper jsonMapper = Jackson.mapper();
private static final String testDir = "src/test/java/com/yahoo/search/handler/test/config";
private static final String myHostnameHeader = "my-hostname-header";
private static final String selfHostname = HostName.getLocalhost();
private static String tempDir = "";
private static final String uri = "http://localhost?";
private static final String JSON_CONTENT_TYPE = "application/json";
@TempDir
public File tempfolder;
private RequestHandlerTestDriver driver = null;
private HandlersConfigurerTestWrapper configurer = null;
private SearchHandler searchHandler;
@BeforeEach
public void startUp() throws IOException {
File cfgDir = newFolder(tempfolder, "SearchHandlerTestCase");
tempDir = cfgDir.getAbsolutePath();
String configId = "dir:" + tempDir;
IOUtils.copyDirectory(new File(testDir), cfgDir, 1); // make configs active
generateComponentsConfigForActive();
configurer = new HandlersConfigurerTestWrapper(new Container(), configId);
searchHandler = (SearchHandler)configurer.getRequestHandlerRegistry().getComponent(SearchHandler.class.getName());
driver = new RequestHandlerTestDriver(searchHandler);
}
@AfterEach
public void shutDown() {
if (configurer != null) configurer.shutdown();
if (driver != null) driver.close();
}
private void generateComponentsConfigForActive() throws IOException {
File activeConfig = new File(tempDir);
SearchChainConfigurerTestCase.
createComponentsConfig(new File(activeConfig, "chains.cfg").getPath(),
new File(activeConfig, "handlers.cfg").getPath(),
new File(activeConfig, "components.cfg").getPath());
}
private SearchHandler fetchSearchHandler(HandlersConfigurerTestWrapper configurer) {
return (SearchHandler) configurer.getRequestHandlerRegistry().getComponent(SearchHandler.class.getName());
}
@Test
void testBadJSON() {
String json = "Not a valid JSON-string";
RequestHandlerTestDriver.MockResponseHandler responseHandler = driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json, JSON_CONTENT_TYPE);
String response = responseHandler.readAll();
assertEquals(400, responseHandler.getStatus());
assertTrue(response.contains("errors"));
assertTrue(response.contains("\"code\":" + Error.ILLEGAL_QUERY.code));
}
@Test
void testFailing() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "test");
json.put("searchChain", "classLoadingError");
assertTrue(driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE).readAll().contains("NoClassDefFoundError"));
}
@Test
synchronized void testPluginError() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "test");
json.put("searchChain", "exceptionInPlugin");
assertTrue(driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE).readAll().contains("NullPointerException"));
}
@Test
synchronized void testWorkingReconfiguration() throws IOException {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "abc");
assertJsonResult(json, driver);
// reconfiguration
IOUtils.copyDirectory(new File(testDir, "handlers2"), new File(tempDir), 1);
generateComponentsConfigForActive();
configurer.reloadConfig();
// ...and check the resulting config
SearchHandler newSearchHandler = fetchSearchHandler(configurer);
assertNotSame(searchHandler, newSearchHandler, "Have a new instance of the search handler");
assertNotNull(fetchSearchHandler(configurer).getSearchChainRegistry().getChain("hello"), "Have the new search chain");
assertNull(fetchSearchHandler(configurer).getSearchChainRegistry().getChain("classLoadingError"), "Don't have the new search chain");
try (RequestHandlerTestDriver newDriver = new RequestHandlerTestDriver(newSearchHandler)) {
assertJsonResult(json, newDriver);
}
}
@Test
void testInvalidYqlQuery() throws IOException {
IOUtils.copyDirectory(new File(testDir, "config_yql"), new File(tempDir), 1);
generateComponentsConfigForActive();
configurer.reloadConfig();
SearchHandler newSearchHandler = fetchSearchHandler(configurer);
assertNotSame(searchHandler, newSearchHandler, "Do I have a new instance of the search handler?");
try (RequestHandlerTestDriver newDriver = new RequestHandlerTestDriver(newSearchHandler)) {
ObjectNode json = jsonMapper.createObjectNode();
json.put("yql", "selectz * from foo where bar > 1453501295");
RequestHandlerTestDriver.MockResponseHandler responseHandler = newDriver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE);
responseHandler.readAll();
assertEquals(400, responseHandler.getStatus());
}
}
// Query handling takes a different code path when a query profile is active, so we test both paths.
@Test
void testInvalidQueryParamWithQueryProfile() throws IOException {
try (RequestHandlerTestDriver newDriver = driverWithConfig("config_invalid_param")) {
testInvalidQueryParam(newDriver);
}
}
private void testInvalidQueryParam(final RequestHandlerTestDriver testDriver) {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "status_code:0");
json.put("hits", 20);
json.put("offset", -20);
RequestHandlerTestDriver.MockResponseHandler responseHandler =
testDriver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE);
String response = responseHandler.readAll();
assertEquals(400, responseHandler.getStatus());
assertTrue(response.contains("offset"));
assertTrue(response.contains("\"code\":" + com.yahoo.container.protect.Error.ILLEGAL_QUERY.code));
}
@Test
void testNormalResultJsonAliasRendering() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("format", "json");
json.put("query", "abc");
assertJsonResult(json, driver);
}
@Test
void testNullQuery() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("format", "xml");
assertEquals("""
1.0
testHit
""", driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE).readAll());
}
@Test
void testWebServiceStatus() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "web_service_status_code");
RequestHandlerTestDriver.MockResponseHandler responseHandler =
driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE);
String response = responseHandler.readAll();
assertEquals(406, responseHandler.getStatus());
assertTrue(response.contains("\"code\":" + 406));
}
@Test
void testNormalResultImplicitDefaultRendering() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "abc");
assertJsonResult(json, driver);
}
@Test
void testNormalResultExplicitDefaultRendering() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "abc");
json.put("format", "default");
assertJsonResult(json, driver);
}
@Test
void testNormalResultXmlAliasRendering() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "abc");
json.put("format", "xml");
assertXmlResult(json, driver);
}
@Test
void testNormalResultExplicitDefaultRenderingFullRendererName1() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "abc");
json.put("format", "XmlRenderer");
assertXmlResult(json, driver);
}
@Test
void testNormalResultExplicitDefaultRenderingFullRendererName2() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "abc");
json.put("format", "JsonRenderer");
assertJsonResult(json, driver);
}
private static final String xmlResult =
"""
1.0
testHit
""";
private void assertXmlResult(JsonNode json, RequestHandlerTestDriver driver) {
assertOkResult(driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE), xmlResult);
}
private static final String jsonResult = "{\"root\":{"
+ "\"id\":\"toplevel\",\"relevance\":1.0,\"fields\":{\"totalCount\":0},"
+ "\"children\":["
+ "{\"id\":\"testHit\",\"relevance\":1.0,\"fields\":{\"uri\":\"testHit\"}}"
+ "]}}";
private void assertJsonResult(JsonNode json, RequestHandlerTestDriver driver) {
assertOkResult(driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), JSON_CONTENT_TYPE), jsonResult);
}
private void assertOkResult(RequestHandlerTestDriver.MockResponseHandler response, String expected) {
assertEquals(expected, response.readAll());
assertEquals(200, response.getStatus());
assertEquals(selfHostname, response.getResponse().headers().get(myHostnameHeader).get(0));
}
private RequestHandlerTestDriver driverWithConfig(String configDirectory) throws IOException {
IOUtils.copyDirectory(new File(testDir, configDirectory), new File(tempDir), 1);
generateComponentsConfigForActive();
configurer.reloadConfig();
SearchHandler newSearchHandler = fetchSearchHandler(configurer);
assertNotSame(searchHandler, newSearchHandler, "Do I have a new instance of the search handler?");
return new RequestHandlerTestDriver(newSearchHandler);
}
@Test
void testInputParameters() throws IOException {
String json = """
{
"input": {
"query(q_category)": { "Tablet Keyboard Cases": 42.5 },
"query(q_vector)": [ 1, 2.5, 3 ]
}
}
""";
Map map = new Json2SingleLevelMap(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))).parse();
assertEquals("{ \"Tablet Keyboard Cases\": 42.5 }", map.get("input.query(q_category)"));
assertEquals("[ 1, 2.5, 3 ]", map.get("input.query(q_vector)"));
}
@Test
void testSelectParameters() throws IOException {
ObjectNode json = jsonMapper.createObjectNode();
ObjectNode select = jsonMapper.createObjectNode();
ObjectNode where = jsonMapper.createObjectNode();
where.put("where", "where");
ObjectNode grouping = jsonMapper.createObjectNode();
grouping.put("grouping", "grouping");
select.set("where", where);
select.set("grouping", grouping);
json.set("select", select);
Inspector inspector = SlimeUtils.jsonToSlime(json.toString().getBytes(StandardCharsets.UTF_8)).get();
Map map = new Json2SingleLevelMap(new ByteArrayInputStream(inspector.toString().getBytes(StandardCharsets.UTF_8))).parse();
JsonNode processedWhere = jsonMapper.readTree(map.get("select.where"));
JsonTestHelper.assertJsonEquals(where.toString(), processedWhere.toString());
JsonNode processedGrouping = jsonMapper.readTree(map.get("select.grouping"));
JsonTestHelper.assertJsonEquals(grouping.toString(), processedGrouping.toString());
}
@Test
void testJsonQueryWithSelectWhere() {
ObjectNode root = jsonMapper.createObjectNode();
ObjectNode select = jsonMapper.createObjectNode();
ObjectNode where = jsonMapper.createObjectNode();
ArrayNode term = jsonMapper.createArrayNode();
term.add("default");
term.add("bad");
where.set("contains", term);
select.set("where", where);
root.set("select", select);
// Run query
String result = driver.sendRequest(uri + "searchChain=echoingQuery", com.yahoo.jdisc.http.HttpRequest.Method.POST, root.toString(), JSON_CONTENT_TYPE).readAll();
assertEquals("{\"root\":{\"id\":\"toplevel\",\"relevance\":1.0,\"fields\":{\"totalCount\":0},\"children\":[{\"id\":\"Query\",\"relevance\":1.0,\"fields\":{\"query\":\"select * from sources * where default contains \\\"bad\\\"\"}}]}}",
result);
}
@Test
void testJsonWithWhereAndGroupingUnderSelect() {
String query = """
{
"select": {
"where": {
"contains": [
"field",
"term"
]
},
"grouping":[
{
"all": {
"output": "count()"
}
}
]
}
}
""";
String result = driver.sendRequest(uri + "searchChain=echoingQuery", com.yahoo.jdisc.http.HttpRequest.Method.POST, query, JSON_CONTENT_TYPE).readAll();
String expected = "{\"root\":{\"id\":\"toplevel\",\"relevance\":1.0,\"fields\":{\"totalCount\":0},\"children\":[{\"id\":\"Query\",\"relevance\":1.0,\"fields\":{\"query\":\"select * from sources * where field contains \\\"term\\\" | all(output(count()))\"}}]}}";
assertEquals(expected, result);
}
@Test
void testJsonWithWhereAndGroupingSeparate() {
String query = """
{
"select.where": {
"contains": [
"field",
"term"
]
},
"select.grouping":[
{
"all": {
"output": "count()"
}
}
]
}
""";
String result = driver.sendRequest(uri + "searchChain=echoingQuery", com.yahoo.jdisc.http.HttpRequest.Method.POST, query, JSON_CONTENT_TYPE).readAll();
String expected = "{\"root\":{\"id\":\"toplevel\",\"relevance\":1.0,\"fields\":{\"totalCount\":0},\"children\":[{\"id\":\"Query\",\"relevance\":1.0,\"fields\":{\"query\":\"select * from sources * where field contains \\\"term\\\" | all(output(count()))\"}}]}}";
assertEquals(expected, result);
}
@Test
void testJsonQueryWithYQL() {
ObjectNode root = jsonMapper.createObjectNode();
root.put("yql", "select * from sources * where default contains 'bad';");
// Run query
String result = driver.sendRequest(uri + "searchChain=echoingQuery", com.yahoo.jdisc.http.HttpRequest.Method.POST, root.toString(), JSON_CONTENT_TYPE).readAll();
assertEquals("{\"root\":{\"id\":\"toplevel\",\"relevance\":1.0,\"fields\":{\"totalCount\":0},\"children\":[{\"id\":\"Query\",\"relevance\":1.0,\"fields\":{\"query\":\"select * from sources * where default contains \\\"bad\\\"\"}}]}}",
result);
}
@Test
void testRequestMapping() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("yql", "select * from sources * where sddocname contains \"blog_post\" limit 0 | all(group(date) max(3) order(-count())each(output(count())))");
json.put("hits", 10);
json.put("offset", 5);
json.put("queryProfile", "foo");
json.put("nocache", false);
json.put("groupingSessionCache", false);
json.put("searchChain", "exceptionInPlugin");
json.put("timeout", 0);
json.put("select", "_all");
ObjectNode model = jsonMapper.createObjectNode();
model.put("defaultIndex", 1);
model.put("encoding", "json");
model.put("filter", "default");
model.put("language", "en");
model.put("queryString", "abc");
model.put("restrict", "_doc,json,xml");
model.put("searchPath", "node1");
model.put("sources", "source1,source2");
model.put("type", "yql");
json.set("model", model);
ObjectNode ranking = jsonMapper.createObjectNode();
ranking.put("location", "123789.89123N;128123W");
ranking.put("features", "none");
ranking.put("listFeatures", false);
ranking.put("profile", "1");
ranking.put("properties", "default");
ranking.put("sorting", "desc");
ranking.put("freshness", "0.05");
ranking.put("queryCache", false);
ObjectNode matchPhase = jsonMapper.createObjectNode();
matchPhase.put("maxHits", "100");
matchPhase.put("attribute", "title");
matchPhase.put("ascending", true);
ObjectNode diversity = jsonMapper.createObjectNode();
diversity.put("attribute", "title");
diversity.put("minGroups", 1);
matchPhase.set("diversity", diversity);
ranking.set("matchPhase", matchPhase);
json.set("ranking", ranking);
ObjectNode presentation = jsonMapper.createObjectNode();
presentation.put("bolding", true);
presentation.put("format", "json");
presentation.put("summary", "none");
presentation.put("template", "json");
presentation.put("timing", false);
json.set("presentation", presentation);
ObjectNode collapse = jsonMapper.createObjectNode();
collapse.put("field", "none");
collapse.put("size", 2);
collapse.put("summary", "default");
json.set("collapse", collapse);
ObjectNode trace = jsonMapper.createObjectNode();
trace.put("level", 1);
trace.put("timestamps", false);
trace.put("rules", "none");
json.set("trace", trace);
ObjectNode pos = jsonMapper.createObjectNode();
pos.put("ll", "1263123N;1231.9W");
pos.put("radius", "71234m");
pos.put("bb", "1237123W;123218N");
pos.put("attribute", "default");
json.set("pos", pos);
ObjectNode streaming = jsonMapper.createObjectNode();
streaming.put("userid", 123);
streaming.put("groupname", "abc");
streaming.put("selection", "none");
streaming.put("priority", 10);
streaming.put("maxbucketspervisitor", 5);
json.set("streaming", streaming);
ObjectNode rules = jsonMapper.createObjectNode();
rules.put("off", false);
rules.put("rulebase", "default");
json.set("rules", rules);
ObjectNode metrics = jsonMapper.createObjectNode();
metrics.put("ignore", "_all");
json.set("metrics", metrics);
json.put("recall", "none");
json.put("user", 123);
json.put("nocachewrite", false);
json.put("hitcountestimate", true);
// Create mapping
Inspector inspector = SlimeUtils.jsonToSlime(json.toString().getBytes(StandardCharsets.UTF_8)).get();
Map map = new Json2SingleLevelMap(new ByteArrayInputStream(inspector.toString().getBytes(StandardCharsets.UTF_8))).parse();
// Create GET-request with same query
String url = uri + "&model.sources=source1%2Csource2&select=_all&model.language=en&presentation.timing=false&pos.attribute=default&pos.radius=71234m&model.searchPath=node1&nocachewrite=false&ranking.matchPhase.maxHits=100&presentation.summary=none" +
"&nocache=false&model.type=yql&collapse.summary=default&ranking.matchPhase.diversity.minGroups=1&ranking.location=123789.89123N%3B128123W&ranking.queryCache=false&offset=5&streaming.groupname=abc&groupingSessionCache=false" +
"&presentation.template=json&trace.rules=none&rules.off=false&ranking.properties=default&searchChain=exceptionInPlugin&pos.ll=1263123N%3B1231.9W&ranking.sorting=desc&ranking.matchPhase.ascending=true&ranking.features=none&hitcountestimate=true" +
"&model.filter=default&metrics.ignore=_all&collapse.field=none&ranking.profile=1&rules.rulebase=default&model.defaultIndex=1&trace.level=1&ranking.listFeatures=false&timeout=0&presentation.format=json" +
"&yql=select+%2A+from+sources+%2A+where+sddocname+contains+%22blog_post%22+limit+0+%7C+all%28group%28date%29+max%283%29+order%28-count%28%29%29each%28output%28count%28%29%29%29%29&recall=none&streaming.maxbucketspervisitor=5" +
"&queryProfile=foo&presentation.bolding=true&model.encoding=json&model.queryString=abc&streaming.selection=none&trace.timestamps=false&collapse.size=2&streaming.priority=10&ranking.matchPhase.diversity.attribute=title" +
"&ranking.matchPhase.attribute=title&hits=10&streaming.userid=123&pos.bb=1237123W%3B123218N&model.restrict=_doc%2Cjson%2Cxml&ranking.freshness=0.05&user=123";
HttpRequest request = HttpRequest.createTestRequest(url, GET);
// Get mapping
Map propertyMap = request.propertyMap();
assertEquals(propertyMap, map);
}
@Test
void testContentTypeParsing() {
ObjectNode json = jsonMapper.createObjectNode();
json.put("query", "abc");
assertOkResult(driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, json.toString(), "Application/JSON; charset=utf-8"), jsonResult);
}
private static String createBenchmarkRequest(int num) {
Random rand = new Random();
StringBuilder sb = new StringBuilder("{\"yql\": \"select id from vectors where {targetHits:10, approximate:true}nearestNeighbor(vector,q);\", \"input.query(q)\":[");
sb.append(rand.nextDouble());
for (int i=1; i < num; i++) {
sb.append(',');
sb.append(rand.nextDouble());
}
sb.append("]}");
return sb.toString();
}
@Disabled
public void benchmarkJsonParsing() {
String request = createBenchmarkRequest(768);
for (int i=0; i < 10000; i++) {
RequestHandlerTestDriver.MockResponseHandler responseHandler =
driver.sendRequest(uri, com.yahoo.jdisc.http.HttpRequest.Method.POST, request, JSON_CONTENT_TYPE);
String response = responseHandler.readAll();
assertEquals(200, responseHandler.getStatus());
assertFalse(response.isEmpty());
}
}
private static File newFolder(File root, String... subDirs) throws IOException {
String subFolder = String.join("/", subDirs);
File result = new File(root, subFolder);
if (!result.mkdirs()) {
throw new IOException("Couldn't create folders " + root);
}
return result;
}
}