ProvidenceHttpServlet.java

/*
 * Copyright 2016-2017 Providence Authors
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package net.morimekta.providence.server;

import net.morimekta.providence.PApplicationException;
import net.morimekta.providence.PMessage;
import net.morimekta.providence.descriptor.PMessageDescriptor;
import net.morimekta.providence.serializer.DefaultSerializerProvider;
import net.morimekta.providence.serializer.Serializer;
import net.morimekta.providence.serializer.SerializerException;
import net.morimekta.providence.serializer.SerializerProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.activation.MimeType;
import javax.activation.MimeTypeParseException;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

import static net.morimekta.providence.PApplicationExceptionType.INVALID_PROTOCOL;

/**
 * A simple HTTP POST servlet that simply deserializes the POST body
 * as a providence message, and serializes the response message using
 * the requested content type or accept type.
 * <p>
 * Note that the {@link ProvidenceHttpServlet} is <b>NOT</b> usable for
 * thrift services, but is meant to be used to make simple POST based
 * HTTP servlets.
 *
 * <pre>{@code
 * public class MyServlet extends ProvidenceHttpServlet<
 *         MyRequest, MyRequest._Field,
 *         MyResponse, MyResponse._Field> {
 *     {@literal@}Override
 *     protected MyResponse handle(HttpServletRequest httpRequest,
 *                                 MyRequest request)
 *              throws MyException, InternalFailureException {
 *         // ... do stuff with request, or throw MyException or IFE.
 *         return MyResponse.builder()
 *                          .setFieldOut("yes")
 *                          .build();
 *     }
 *
 *     {@literal@}Override
 *     protected int statusCodeForException({@literal@}Nonnull Throwable exception) {
 *         if (exception instanceof MyException) {
 *             return HttpStatus.BAD_REQUEST_400;
 *         }
 *         return super.statusCodeForException(ex);
 *     }
 * }
 * }</pre>
 *
 * This will result in a simple HTTP servlet that can be queries with CURL
 * e.g. like this:
 *
 * <pre>{@code
 * # Simple success
 * $ curl -sS -X POST -d "{\"field_in\": \"value\"}" \
 * >     -H "Content-Type: application/json" \
 * >     localhost:8080/my/servlet
 * {\"field_out\":\"yes\"}
 *
 * # Simple Error
 * $ curl -sSv -X POST -d "{\"field_in\": \"not valid\"}" \
 * >     -H "Content-Type: application/json" \
 * >     localhost:8080/my/servlet
 * ...
 * > Content-Type: application/json
 * > Accept: *{@literal/}*
 * ...
 * < HTTP/1.1 400 Bad Request
 * < Content-Type: application/json
 * ...
 * {\"text\":\"not valid value\"}
 * }</pre>
 *
 * Alternatively you can hijack the whole exception / error response handling, which
 * might be needed where custom headers etc are needed, e.g. with <code>Unauthorized (401)</code>:
 *
 * <pre>{@code
 * public class MyServlet extends ProvidenceHttpServlet&lt;
 *         MyRequest, MyRequest._Field,
 *         MyResponse, MyResponse._Field&gt; {
 *     public MyServlet() {
 *         super(MyRequest.kDescriptor, new MyExceptionHandler(), DefaultSerializerProvider.INSTANCE);
 *     }
 *
 *     {@literal@}Override
 *     protected MyResponse handle(HttpServletRequest httpRequest,
 *                                 MyRequest request)
 *              throws MyException, InternalFailureException {
 *         // ... do stuff with request, or throw MyException or IFE.
 *         return MyResponse.builder()
 *                          .setFieldOut("yes")
 *                          .build();
 *     }
 * }
 * }</pre>
 *
 * <pre>{@code
 * public class MyExceptionHandler extends ExceptionHandler {
 *     {@literal@}Override
 *     protected void writeErrorResponse(
 *             {@literal@}Nonnull Throwable exception,
 *             {@literal@}Nonnull Serializer responseSerializer,
 *             {@literal@}Nonnull HttpServletRequest httpRequest,
 *             {@literal@}Nonnull HttpServletResponse httpResponse)
 *             throws IOException {
 *         if (exception instanceof MyException) {
 *             httpResponse.setStatus(HttpStatus.UNAUTHORIZED_401);
 *             httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, "www.my-domain.com");
 *             responseSerializer.serialize(httpResponse.getOutputStream(), (MyException) exception);
 *             return;
 *         }
 *         super.writeErrorResponse(exception);
 *     }
 * }
 * }</pre>
 *
 * <h2>Overridable Methods</h2>
 *
 * <ul>
 *     <li>
 *         <code>{@link #handle(HttpServletRequest, PMessage)}</code>: The main handle method. This <b>must</b> be
 *         implemented.
 *     </li>
 * </ul>
 *
 * @param <RequestType> The request type.
 * @param <ResponseType> The response type.
 * @since 1.6.0
 */
public abstract class ProvidenceHttpServlet<
        RequestType extends PMessage<RequestType>,
        ResponseType extends PMessage<ResponseType>> extends HttpServlet {
    private static final Logger LOGGER = LoggerFactory.getLogger(ProvidenceHttpServlet.class);

    private final PMessageDescriptor<RequestType> requestDescriptor;
    private final ExceptionHandler                exceptionHandler;
    private final SerializerProvider              serializerProvider;

    public ProvidenceHttpServlet(@Nonnull PMessageDescriptor<RequestType> requestDescriptor,
                                 @Nullable ExceptionHandler exceptionHandler,
                                 @Nullable SerializerProvider serializerProvider) {
        this.requestDescriptor = requestDescriptor;
        this.exceptionHandler = exceptionHandler == null ? ExceptionHandler.INSTANCE : exceptionHandler;
        this.serializerProvider = serializerProvider == null ? DefaultSerializerProvider.INSTANCE : serializerProvider;
    }

    /**
     * Handle the request itself as a simple called method.
     *
     * @param httpRequest The HTTP request.
     * @param request The parsed providence request.
     * @return The response object.
     * @throws Exception On any internal exception.
     */
    @Nonnull
    protected abstract
    ResponseType handle(@Nonnull HttpServletRequest httpRequest,
                        @Nonnull RequestType request) throws Exception;

    @Override
    protected final void doPost(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException {
        ResponseType response;
        Serializer   requestSerializer = serializerProvider.getDefault();
        if (httpRequest.getContentType() != null) {
            try {
                requestSerializer = serializerProvider.getSerializer(httpRequest.getContentType());
            } catch (IllegalArgumentException e) {
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "Unknown content-type: " + httpRequest.getContentType());
                LOGGER.warn("Unknown content type in request", e);
                return;
            }
        } else {
            LOGGER.debug("Request is missing content type.");
        }

        Serializer responseSerializer = requestSerializer;
        String acceptHeader = httpRequest.getHeader("Accept");
        if (acceptHeader != null) {
            String[] entries = acceptHeader.split(",");
            for (String entry : entries) {
                entry = entry.trim();
                if (entry.isEmpty()) {
                    continue;
                }
                if ("*/*".equals(entry)) {
                    // Then responding same as request is good.
                    break;
                }

                try {
                    MimeType mediaType = new MimeType(entry);
                    responseSerializer = serializerProvider.getSerializer(mediaType.getBaseType());
                    break;
                } catch (MimeTypeParseException ignore) {
                    // Ignore. Bad header input is pretty common.
                }
            }
        }

        RequestType request;
        try {
            request = requestSerializer.deserialize(httpRequest.getInputStream(), requestDescriptor);
            requestSerializer.verifyEndOfContent(httpRequest.getInputStream());
        } catch (SerializerException e) {
            LOGGER.info("Failed to deserialize request to {}: {}", httpRequest.getServletPath(), e.displayString(), e);

            PApplicationException ex = new PApplicationException(e.getMessage(), INVALID_PROTOCOL).initCause(e);
            exceptionHandler.handleException(ex, responseSerializer, httpRequest, httpResponse);
            return;
        }

        try {
            response = handle(httpRequest, request);
        } catch (Exception e) {
            try {
                exceptionHandler.handleException(e, responseSerializer, httpRequest, httpResponse);
                if (!httpResponse.isCommitted()) {
                    httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
                }
            } catch (Exception e1) {
                LOGGER.error("Exception sending error", e1);
                if (!httpResponse.isCommitted()) {
                    httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e1.getMessage());
                }
            }
            return;
        }

        httpResponse.setStatus(HttpServletResponse.SC_OK);
        httpResponse.setContentType(responseSerializer.mediaType());
        responseSerializer.serialize(httpResponse.getOutputStream(), response);
        httpResponse.flushBuffer();
    }
}