/* Copyright 2012-2013 Fabian Steeg, hbz. Licensed under the Eclipse Public License 1.0 */
package controllers;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
import org.elasticsearch.indices.IndexMissingException;
import org.lobid.lodmill.JsonLdConverter;
import org.lobid.lodmill.JsonLdConverter.Format;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import models.Document;
import models.Index;
import models.Parameter;
import models.Search;
import play.Logger;
import play.api.http.MediaRange;
import play.libs.F;
import play.libs.F.Promise;
import play.libs.Json;
import play.libs.ws.WS;
import play.libs.ws.WSResponse;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Http.Request;
import play.mvc.Result;
import play.twirl.api.Html;
import scala.concurrent.ExecutionContext;
/**
* Main application controller.
*
* @author Fabian Steeg (fsteeg)
*/
public final class Application extends Controller {
/**
* Error message when http status codes 406 aka "not acceptable"
*/
public final static String HTTP_CODE_406_MESSAGE =
"Not acceptable: unsupported content type requested\n";
private Application() { // NOPMD
/* No instantiation */
@SuppressWarnings("unused")
ExecutionContext ec; // to retain import
}
/**
* @return The main page.
*/
public static Promise<Result> index() {
return okPromise(views.html.index.render());
}
/**
* @return The main page.
*/
public static Promise<Result> v1() {
return okPromise(views.html.v1.render());
}
/**
* @return The API page.
*/
public static Promise<Result> api() {
return okPromise(views.html.api.render());
}
/**
* @return The main page.
*/
public static Promise<Result> contact() {
return okPromise(views.html.contact.render());
}
/**
* @return The main page.
*/
public static Promise<Result> about() {
return okPromise(views.html.about.render());
}
/**
* Search enpoint for actual queries.
*
* @param indexParameter The index to search (see {@link Index}).
* @param parameter The search parameter type (see {@link Parameter}).
* @param queryParameter The search query
* @param formatParameter The result format
* @param from The start index of the result set
* @param size The size of the result set
* @param format The result format requested
* @param owner The ID of an owner holding items of the requested resources
* @param set The ID of a set the requested resources should be part of
* @param type The type of the requestes resources
* @param sort The sort order
* @return The results, in the format specified
* @throws InterruptedException
*/
static Promise<Result> search(final Index index,
final java.util.Map<Parameter, String> parameters,
final String formatParameter, final int from, final int size,
final String owner, final String set, final String type,
final String sort, final boolean addQueryInfo, final String scroll) {
final ResultFormat resultFormat;
try {
resultFormat = ResultFormat
.valueOf(getFieldAndFormat(formatParameter).getRight().toUpperCase());
} catch (IllegalArgumentException e) {
return badRequestPromise("Invalid 'format' parameter, use one of: "
+ Joiner.on(", ").join(ResultFormat.values()).toLowerCase());
}
Search search;
try {
search = new Search(parameters, index).page(from, size)
.field(getFieldAndFormat(formatParameter).getLeft()).owner(owner)
.set(set).type(type).sort(sort).scroll(scroll);
} catch (IllegalArgumentException e) {
Logger.error(e.getMessage(), e);
return badRequestPromise(e.getMessage());
}
if (!scroll.isEmpty()) {
if (search.doingScrollScanNow()) {
return Promise.pure(status(Http.Status.CONFLICT,
"Already doing a scroll scan. Only one permitted. Please try again later."));
}
if (search.getTotalHits() > Search.MAX_SCROLL_HITS) {
return Promise.pure(status(Http.Status.REQUEST_ENTITY_TOO_LARGE,
"The requested entity is too large. Not more than "
+ Search.MAX_SCROLL_HITS + " hits are allowed."));
}
Serialization serialization = getSerialization(request());
if (serialization == null)
return Promise
.pure(status(Http.Status.NOT_ACCEPTABLE, HTTP_CODE_406_MESSAGE));
response().setHeader("Transfer-Encoding", "Chunked");
try {
return Promise
.pure(ok(search.executeScrollScan(request(), serialization)));
} catch (IllegalArgumentException e) {
Logger.error(e.getMessage(), e);
return badRequestPromise(e.getMessage());
}
}
try {
List<Document> docs = search.documents();
long allHits = search.totalHits();
final Promise<ImmutableMap<ResultFormat, Result>> resultPromise =
resultsPromise(docs, index,
getFieldAndFormat(formatParameter).getLeft(), allHits,
addQueryInfo);
return resultPromise.map(results -> {
return results.get(resultFormat);
});
} catch (IllegalArgumentException e) {
Logger.error(e.getMessage(), e);
return badRequestPromise(e.getMessage());
}
}
private static Promise<ImmutableMap<ResultFormat, Result>> resultsPromise(
final List<Document> docs, final Index index, final String field,
final long allHits, final boolean addQueryInfo) {
return Promise.promise(() -> {
return results(docs, index, field, allHits, addQueryInfo);
});
}
static Promise<Result> badRequestPromise(final String message) {
return Promise.promise(() -> {
return badRequest(message);
});
}
static Promise<Result> okPromise(final Html html) {
return Promise.promise(() -> {
return ok(html);
});
}
private static Pair<String, String> getFieldAndFormat(final String format) {
if (format.contains(".")) {
final String[] strings = format.split("\\.");
if (strings.length != 2 || !strings[0].equals("short"))
throw new IllegalArgumentException(
"Parameter modifier only supported on `short` format, "
+ "e.g. `format=short.fulltextOnline`.");
return new ImmutablePair<>(strings[1], "full");
}
return new ImmutablePair<>("", format);
}
private static Function<Document, JsonNode> jsonLabelValue = doc -> {
final ObjectNode object = Json.newObject();
object.put("label", doc.getMatchedField());
object.put("value", doc.getId());
return object;
};
private static ImmutableMap<ResultFormat, Result> results(
final List<Document> documents, final Index selectedIndex,
final String field, long allHits, boolean addQueryInfo) {
/* JSONP callback support for remote server calls with JavaScript: */
final String[] callback =
request() == null || request().queryString() == null ? null
: request().queryString().get("callback");
Serialization ser = getSerialization(request());
Result negotiateRes;
if (ser == null) {
negotiateRes = status(Http.Status.NOT_ACCEPTABLE, HTTP_CODE_406_MESSAGE);
} else
negotiateRes = ok(getSerializedResult(documents, selectedIndex, field,
allHits, addQueryInfo, request(), ser)).as(ser.getTypes().get(0));
final ImmutableMap<ResultFormat, Result> results =
new ImmutableMap.Builder<ResultFormat, Result>()
.put(ResultFormat.NEGOTIATE, negotiateRes)
.put(ResultFormat.FULL,
withCallback(callback,
fullJsonResponse(documents, field, allHits, addQueryInfo,
request())))
.put(ResultFormat.SHORT, withCallback(callback, Json
.toJson(new LinkedHashSet<>(Lists.transform(documents, doc -> {
return doc.getMatchedField();
})))))
.put(ResultFormat.INTERNAL, internalJsonResponse(documents))
.put(ResultFormat.IDS,
withCallback(callback,
Json.toJson(Lists.transform(documents, jsonLabelValue))))
.put(ResultFormat.SOURCE, mabXml(documents)).build();
return results;
}
private static Result mabXml(final List<Document> documents) {
try {
final StringBuilder builder = new StringBuilder();
final String errorMessage = "No source data found for ";
for (Document document : documents)
if (document.getId().contains("lobid.org/resource"))
appendMabXml(builder, errorMessage, document);
final String result = builder.toString().trim();
return result.isEmpty() ? notFound(errorMessage + "request") : //
documents.size() > 1 ? ok(result)
: ok(result).as("text/xml; charset: utf-8");
} catch (IndexMissingException e) {
return notFound(e.getMessage());
}
}
private static void appendMabXml(final StringBuilder builder,
final String errorMessage, Document document) {
JsonNode value = Json.parse(document.getSource()).findValue("hbzId");
String mabXml = null;
if (value != null) {
final String id = value.textValue();
String url = Index.CONFIG.getString("hbz01.api") + "/" + id;
Promise<String> promise = WS.url(url).get().map((WSResponse response) -> {
if (response.getStatus() == Http.Status.OK) {
return new String(response.asByteArray(), StandardCharsets.UTF_8);
}
Logger.warn("Response for {} not OK: {}", url,
response.getStatusText());
return null;
});
promise = promise.recover(new F.Function<Throwable, String>() {
@Override
public String apply(Throwable t) throws Throwable {
Logger.error("Could not get response for {}: {}", url, t);
return null;
}
});
mabXml = promise.get(10000);
}
if (mabXml == null)
Logger.warn(errorMessage + document.getId());
else
builder.append(mabXml).append("\n");
}
private static Status withCallback(final String[] callback,
final JsonNode shortJson) {
return callback != null
? ok(String.format("/**/%s(%s)", callback[0], shortJson))
: ok(shortJson);
}
private static JsonNode fullJsonResponse(final List<Document> documents,
final String field, long allHits, boolean addQueryInfo, Request request) {
Iterable<JsonNode> nonEmptyNodes =
Iterables.filter(Lists.transform(documents, doc -> {
return Json.parse(doc.getSource());
}), node -> {
return node.size() > 0;
});
if (!field.isEmpty()) {
nonEmptyNodes = ImmutableSortedSet.copyOf((o1, o2) -> {
return o1.asText().compareTo(o2.asText());
}, FluentIterable.from(nonEmptyNodes).transformAndConcat(input -> {
return input.isArray() ? /**/
Lists.newArrayList(input.elements()) : Lists.newArrayList(input);
}));
}
List<JsonNode> data = new ArrayList<>();
if (addQueryInfo)
data.add(queryInfo(allHits, request));
data.addAll(ImmutableSet.copyOf(nonEmptyNodes));
return Json.toJson(data);
}
private static Result internalJsonResponse(final List<Document> documents) {
try {
final StringBuilder builder = new StringBuilder();
for (Document document : documents)
builder.append(document.getEsSource()).append("\n");
final String result = builder.toString().trim();
return documents.size() > 1 ? ok(result) : ok(result).as("text/xml");
} catch (IndexMissingException e) {
return notFound(e.getMessage());
}
}
private static JsonNode queryInfo(long allHits, Request request) {
return Json.toJson(ImmutableMap.of(//
"@id", "http://lobid.org" + request.uri(), //
"http://sindice.com/vocab/search#totalResults", allHits));
}
/**
* @param request The http request.
* @return the serialization
*/
public static Serialization getSerialization(Request request) {
if (invalidAcceptHeader(request))
return null;
for (MediaRange mediaRange : request.acceptedTypes())
for (Serialization serialization : Serialization.values())
for (String mimeType : serialization.getTypes())
if (mediaRange.accepts(mimeType))
return serialization;
return null;
}
/**
* @param documents A list of documents. These will be serialized according to
* the requested content type.
* @param selectedIndex The elasticsearch index
* @param field A field to be sorted. Only used in conjunction of "format".
* @param allHits Number of hits.
* @param addQueryInfo Boolean if query info should be added.
* @param request The request of the client, with headers etc.
* @param serialization The wanted serialization of the result.
* @return the result of the serialization of the hits as String.
*/
public static String getSerializedResult(List<Document> documents,
Index selectedIndex, String field, long allHits, boolean addQueryInfo,
Request request, Serialization serialization) {
return serialization(documents, selectedIndex, serialization, field,
allHits, addQueryInfo, request);
}
private static String serialization(List<Document> documents,
Index selectedIndex, Serialization serialization, String field,
long allHits, boolean addQueryInfo, Request request) {
switch (serialization) {
case JSON_LD:
return fullJsonResponse(documents, field, allHits, addQueryInfo, request)
.toString();
case RDF_A:
return views.html.docs.render(documents, selectedIndex).toString();
case RDF_XML:
return "<docs>" + Joiner.on("\n").join(
transform(documents, serialization, allHits, addQueryInfo, request))
+ "</docs>"; // serve well-formed XML, retain document structure
default:
return Joiner.on("\n").join(
transform(documents, serialization, allHits, addQueryInfo, request));
}
}
private static boolean invalidAcceptHeader(Request request) {
if (request == null)
return true;
final String acceptHeader = request.getHeader("Accept");
return (acceptHeader == null || acceptHeader.trim().isEmpty());
}
private static List<String> transform(List<Document> documents,
final Serialization serialization, long allHits, boolean addQueryInfo,
Request request) {
List<String> transformed = new ArrayList<>();
if (addQueryInfo)
transformed.add(transformed(queryInfo(allHits, request).toString(),
serialization.format));
transformed.addAll(Lists.transform(documents, doc -> {
return doc.as(serialization.format);
}));
return transformed;
}
private static String transformed(String jsonLdInfo, Format format) {
final JsonLdConverter converter = new JsonLdConverter(format);
return converter.toRdf(jsonLdInfo).trim();
}
}