AbstractServlet.java

/*******************************************************************************
 * Copyright (c) 2015 Eclipse RDF4J contributors, Aduna, and others.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *******************************************************************************/
package org.eclipse.rdf4j.workbench.base;

import java.io.IOException;
import java.io.OutputStream;
import java.util.Optional;
import java.util.regex.Pattern;

import javax.servlet.Servlet;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.eclipse.rdf4j.common.app.AppConfiguration;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.query.resultio.BasicQueryWriterSettings;
import org.eclipse.rdf4j.query.resultio.QueryResultFormat;
import org.eclipse.rdf4j.query.resultio.QueryResultIO;
import org.eclipse.rdf4j.query.resultio.QueryResultWriter;
import org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat;
import org.eclipse.rdf4j.query.resultio.UnsupportedQueryResultFormatException;
import org.eclipse.rdf4j.rio.helpers.BasicWriterSettings;
import org.eclipse.rdf4j.workbench.util.TupleResultBuilder;
import org.eclipse.rdf4j.workbench.util.WorkbenchRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public abstract class AbstractServlet implements Servlet {

	protected final Logger log = LoggerFactory.getLogger(this.getClass());

	@Deprecated
	protected static final String SERVER_USER = "server-user";

	@Deprecated
	protected static final String SERVER_PASSWORD = "server-password";

	protected static final String SERVER_USER_PASSWORD = "server-user-password";

	protected static final String ACCEPT = "Accept";

	/**
	 * This response content type is always used for JSONP results.
	 */
	protected static final String APPLICATION_JAVASCRIPT = "application/javascript";

	/**
	 * This response content type is used in cases where application/xml is explicitly requested, or in cases where the
	 * user agent is known to be a commonly available browser.
	 */
	protected static final String APPLICATION_XML = "application/xml";

	/**
	 * This response content type is used for SPARQL Results XML results in non-browser user agents or other cases where
	 * application/xml is not specifically requested.
	 */
	protected static final String APPLICATION_SPARQL_RESULTS_XML = "application/sparql-results+xml";

	protected static final String TEXT_HTML = "text/html";
	protected static final String TEXT_PLAIN = "text/plain";

	protected static final String USER_AGENT = "User-Agent";

	protected static final String MSIE = "MSIE";

	protected static final String MOZILLA = "Mozilla";

	/**
	 * JSONP property for enabling/disabling jsonp functionality.
	 */
	protected static final String JSONP_ENABLED = "org.eclipse.rdf4j.workbench.jsonp.enabled";

	/**
	 * This query parameter is only used in cases where the configuration property is not setup explicitly.
	 */
	protected static final String DEFAULT_JSONP_CALLBACK_PARAMETER = "callback";

	protected static final Pattern JSONP_VALIDATOR = Pattern.compile("^[A-Za-z]\\w+$");

	protected static final String JSONP_CALLBACK_PARAMETER = "org.eclipse.rdf4j.workbench.jsonp.callbackparameter";

	protected ServletConfig config;

	protected AppConfiguration appConfig;

	@Override
	public ServletConfig getServletConfig() {
		return config;
	}

	@Override
	public String getServletInfo() {
		return getClass().getSimpleName();
	}

	@Override
	public void init(final ServletConfig config) throws ServletException {
		this.config = config;
		this.appConfig = new AppConfiguration("Workbench", "RDF4J Workbench");
		try {
			// Suppress loading of log configuration.
			this.appConfig.init(false);
		} catch (IOException e) {
			throw new ServletException(e);
		}
	}

	@Override
	public void destroy() {
	}

	@Override
	public final void service(final ServletRequest req, final ServletResponse resp)
			throws ServletException, IOException {
		final HttpServletRequest hreq = (HttpServletRequest) req;
		final HttpServletResponse hresp = (HttpServletResponse) resp;
		service(hreq, hresp);
	}

	public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		// default empty implementation
	}

	protected QueryResultFormat getTupleResultFormat(final HttpServletRequest req, final ServletResponse resp) {
		String header = null;
		if (req instanceof WorkbenchRequest) {
			header = ((WorkbenchRequest) req).getParameter(ACCEPT);
		}
		if (null == header) {
			header = req.getHeader(ACCEPT);
		}
		if (header != null) {
			Optional<QueryResultFormat> tupleFormat = QueryResultIO.getParserFormatForMIMEType(header);
			if (tupleFormat.isPresent()) {
				return tupleFormat.get();
			}
		}

		return null;
	}

	protected QueryResultFormat getBooleanResultFormat(final HttpServletRequest req, final ServletResponse resp) {
		String header = req.getHeader(ACCEPT);
		if (header != null) {
			// Then try boolean format
			Optional<QueryResultFormat> booleanFormat = QueryResultIO.getBooleanParserFormatForMIMEType(header);
			if (booleanFormat.isPresent()) {
				return booleanFormat.get();
			}
		}

		return null;
	}

	protected QueryResultFormat getJSONPResultFormat(final HttpServletRequest req, final ServletResponse resp) {
		String header = req.getHeader(ACCEPT);
		if (header != null) {
			if (header.equals(APPLICATION_JAVASCRIPT)) {
				return TupleQueryResultFormat.JSON;
			}
		}

		return null;
	}

	protected QueryResultWriter getResultWriter(final HttpServletRequest req, final ServletResponse resp,
			final OutputStream outputStream) throws UnsupportedQueryResultFormatException, IOException {
		QueryResultFormat resultFormat = getTupleResultFormat(req, resp);

		if (resultFormat == null) {
			resultFormat = getBooleanResultFormat(req, resp);
		}

		if (resultFormat == null) {
			resultFormat = getJSONPResultFormat(req, resp);
		}

		if (resultFormat == null) {
			// This is safe with the current SPARQL Results XML implementation that
			// is able to write out boolean results from the "Tuple" writer.
			resultFormat = TupleQueryResultFormat.SPARQL;
		}

		return QueryResultIO.createWriter(resultFormat, outputStream);
	}

	/**
	 * Gets a {@link TupleResultBuilder} based on the Accept header, and sets the result content type to the best
	 * available match for that, returning a builder that can be used to write out the results.
	 *
	 * @param req          the current HTTP request
	 * @param resp         the current HTTP response
	 * @param outputStream TODO
	 * @return a builder that can be used to write out the results
	 * @throws IOException
	 * @throws UnsupportedQueryResultFormatException
	 */
	protected TupleResultBuilder getTupleResultBuilder(HttpServletRequest req, HttpServletResponse resp,
			OutputStream outputStream) throws UnsupportedQueryResultFormatException, IOException {
		String contentType;
		QueryResultWriter resultWriter = checkJSONP(req, outputStream);

		if (resultWriter != null) {
			// explicitly set the content type to "application/javascript"
			// to fit JSONP best practices
			contentType = APPLICATION_JAVASCRIPT;
		} else {
			// If the JSON-P check above failed, use the normal methods to
			// determine output format
			resultWriter = getResultWriter(req, resp, resp.getOutputStream());
			contentType = resultWriter.getQueryResultFormat().getDefaultMIMEType();
		}

		// HACK: In order to make XSLT stylesheet driven user interface work,
		// browser user agents must receive application/xml if they are going to
		// actually get application/sparql-results+xml
		// NOTE: This will test against both BooleanQueryResultsFormat and
		// TupleQueryResultsFormat
		if (contentType.equals(APPLICATION_SPARQL_RESULTS_XML)) {
			String uaHeader = req.getHeader(USER_AGENT);
			String acceptHeader = req.getHeader(ACCEPT);

			if (acceptHeader != null && acceptHeader.contains(APPLICATION_SPARQL_RESULTS_XML)) {
				// Do nothing, leave the contentType as
				// application/sparql-results+xml
			}
			// Switch back to application/xml for user agents who claim to be
			// Mozilla compatible
			else if (uaHeader != null && uaHeader.contains(MOZILLA)) {
				contentType = APPLICATION_XML;
			}
			// Switch back to application/xml for user agents who accept either
			// application/xml or text/html
			else if (acceptHeader != null
					&& (acceptHeader.contains(APPLICATION_XML) || acceptHeader.contains(TEXT_HTML))) {
				contentType = APPLICATION_XML;
			}
		}

		// Setup qname support for result writers who declare that they support it
		if (resultWriter.getSupportedSettings().contains(BasicQueryWriterSettings.ADD_SESAME_QNAME)) {
			resultWriter.getWriterConfig().set(BasicQueryWriterSettings.ADD_SESAME_QNAME, true);
		}

		resp.setContentType(contentType);

		// TODO: Make the following two settings configurable

		// Convert xsd:string back to plain literals where this behaviour is
		// supported
		if (resultWriter.getSupportedSettings().contains(BasicWriterSettings.XSD_STRING_TO_PLAIN_LITERAL)) {
			resultWriter.getWriterConfig().set(BasicWriterSettings.XSD_STRING_TO_PLAIN_LITERAL, true);
		}

		// Convert rdf:langString back to language literals where this behaviour
		// is supported
		if (resultWriter.getSupportedSettings().contains(BasicWriterSettings.RDF_LANGSTRING_TO_LANG_LITERAL)) {
			resultWriter.getWriterConfig().set(BasicWriterSettings.RDF_LANGSTRING_TO_LANG_LITERAL, true);
		}

		return new TupleResultBuilder(resultWriter, SimpleValueFactory.getInstance());
	}

	protected QueryResultWriter checkJSONP(HttpServletRequest req, OutputStream outputStream) throws IOException {
		QueryResultWriter resultWriter = null;
		// HACK : SES-2043 : Need to support Chrome who decide to send Accept: */*
		// instead of application/javascript for JSONP queries, so need to check
		// it first as other algorithm fails in this case.
		// JSONP is enabled in the default properties, but if users setup their
		// own application.properties file then it must be inserted explicitly
		// to be enabled
		if (appConfig != null && appConfig.getProperties().containsKey(JSONP_ENABLED)) {

			String jsonpEnabledProperty = appConfig.getProperties().getProperty(JSONP_ENABLED);

			// check if jsonp is a property and it is set to true
			if (jsonpEnabledProperty != null && Boolean.parseBoolean(jsonpEnabledProperty)) {
				String parameterName = null;

				// check whether they customised the parameter to use to identify
				// the jsonp callback
				if (appConfig.getProperties().containsKey(JSONP_CALLBACK_PARAMETER)) {
					parameterName = appConfig.getProperties().getProperty(JSONP_CALLBACK_PARAMETER);
				}

				// Use default parameter name if it was missing in the
				// configuration after jsonp was enabled
				if (parameterName == null || parameterName.trim().isEmpty()) {
					parameterName = DEFAULT_JSONP_CALLBACK_PARAMETER;
				}

				String parameter = req.getParameter(parameterName);

				if (parameter != null) {
					parameter = parameter.trim();

					if (parameter.isEmpty()) {
						parameter = BasicQueryWriterSettings.JSONP_CALLBACK.getDefaultValue();
					}

					// check callback function name is a valid javascript function
					// name
					if (!JSONP_VALIDATOR.matcher(parameter).matches()) {
						throw new IOException("Callback function name was invalid");
					}

					resultWriter = QueryResultIO.createWriter(TupleQueryResultFormat.JSON, outputStream);

					resultWriter.getWriterConfig().set(BasicQueryWriterSettings.JSONP_CALLBACK, parameter);
				}
			}
		}
		return resultWriter;
	}
}