All Downloads are FREE. Search and download functionalities are using the official Maven repository.

tech.greenfield.vertx.irked.Request Maven / Gradle / Ivy

There is a newer version: 4.5.10
Show newest version
package tech.greenfield.vertx.irked;

import java.util.*;
import java.util.Map.Entry;
import java.util.stream.Stream;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.vertx.core.Future;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.*;
import io.vertx.ext.web.RequestBody;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.impl.RoutingContextDecorator;
import io.vertx.ext.web.impl.RoutingContextInternal;
import tech.greenfield.vertx.irked.Controller.WebHandler;
import tech.greenfield.vertx.irked.auth.AuthorizationToken;
import tech.greenfield.vertx.irked.exceptions.MissingBodyException;
import tech.greenfield.vertx.irked.status.BadRequest;
import tech.greenfield.vertx.irked.status.OK;

/**
 * Request handling wrapper which adds some useful routines for
 * API writers.
 * 
 * Can serve as a basis for local context parsers that API writers
 * can use to expose path arguments from parent prefixes
 * 
 * @author odeda
 */
public class Request extends RoutingContextDecorator {

	private static RoutingContextInternal downCastOrFailWithExplanation(RoutingContext outerContext) {
		if (outerContext instanceof RoutingContextInternal)
			return (RoutingContextInternal) outerContext;
		/*
		 * This is an issue because of https://github.com/vert-x3/vertx-web/commit/65972a2e43a853ae6a226a25cc24351d685e0a44
		 * Under the guise of "Feature." [sic], required functionality was added to the (supposedly internal) RoutingContextInternal
		 * extension interface and then requiring it for the RoutingContextDecorator c'tor, but without modifying all the other APIs
		 * that still use RoutingContext. If you think this code here is bad, than check out RouterImpl that does an *unchecked* cast down:
		 * https://github.com/vert-x3/vertx-web/blob/e9430acf6edd029ddc80bcb00f87a56e10171312/vertx-web/src/main/java/io/vertx/ext/web/impl/RouterImpl.java#L248
		 * 
		 * At the time of this writing all vertx-web implementations of RoutingContext actually implement RoutingContextInternal, and all are
		 * created by RouterImpl - which we use explicitly through io.vertx.ext.web.Router.router() (in tech.greenfield.vertx.irked.Router), but
		 * this here now relies on vertx-web developers always paying attention instead on compiler type checking...
		 */
		throw new RuntimeException("Unexpected parent context that does not implement RoutingContextInternal! This is a bug in vertx-web 4.2.2");
	}
	
	private RoutingContext outerContext;

	/**
	 * Create a new request wrapper as a {@link RoutingContextDecorator} around the specified parent routing context
	 * (what vertx-web calls "inner context").
	 * 
	 * This is an internal constructor to be used by {@link Router} - use at your own risk.
	 * @param outerContext parent routing context to wrap
	 */
	public Request(RoutingContext outerContext) {
		super(outerContext.currentRoute(), downCastOrFailWithExplanation(outerContext));
		this.outerContext = outerContext;
	}
	
	@Override
	public void next() {
		this.outerContext.next();
	}

	@Override
	public void fail(int statusCode) { 
		// we're overriding the fail handlers, which for some reason the decorator 
		// feels should be moved to another thread. Instead, use the outer implementation
		// and let it do what's right
		this.outerContext.fail(statusCode);
	}
	
	/**
	 * Fail helper that wraps Irked HTTP errors in Vert.x-web (final?!) HttpException class
	 * that is better handled by the RoutingContextImpl
	 * @param httpError error to fail with
	 */
	public void fail(HttpError httpError) {
		fail(httpError.getStatusCode(), httpError);
	}
	
	@Override
	public void fail(Throwable throwable) {
		// we're overriding the fail handlers, which for some reason the decorator 
		// feels should be moved to another thread. Instead, use the outer implementation
		// and let it do what's right
		this.outerContext.fail(throwable);
	}
	
	/**
	 * Helper failure handler for CompletableFuture users.
	 * Use at the end of an async chain to succinctly propagate exceptions, as
	 * thus: .exceptionally(req::handleFailure).
	 * This method will call {@link #fail(Throwable)} after unwrapping
	 * {@link RuntimeException}s as needed.
	 * @param throwable A {@link Throwable} error to fail on
	 * @return null
	 */
	public Void handleFailure(Throwable throwable) {
		var failure = HttpError.unwrap(throwable);
		if (failure instanceof HttpError)
			fail((HttpError)failure);
		else
			fail(failure);
		return null;
	}
	
	/**
	 * Helper failure handler for CompletableFuture users.
	 * Use in the middle an async chain to succinctly propagate exceptions, or
	 * success values as thus: .whenComplete(req::handlePossibleFailure).
	 * This method will call {@link Request#fail(Throwable)} if a failure occurred,
	 * after unwrapping {@link RuntimeException}s as needed. It will also pass on
	 * the success value (or null if there was a failure) for the next async
	 * element. Subsequent code can check whether a failure was propagated
	 * by calling {@link #failed()}
	 * @param  the type of previous completion value that will be returned as the completion value for completion stages running this method 
	 * @param successValue successful completion value to return in case no failure occurred
	 * @param throwable A {@link Throwable} error to fail on
	 * @return null
	 */
	public  V handlePossibleFailure(V successValue, Throwable throwable) {
		if (Objects.nonNull(throwable))
			fail(HttpError.unwrap(throwable));
		return successValue;
	}
	
	/**
	 * Helper to easily configure standard failure handlers
	 * @return a WebHandler that sends Irked status exceptions as HTTP responses
	 */
	public static WebHandler failureHandler() {
		return r -> {
			r.sendError(HttpError.toHttpError(r));
		};
	}
	
	/**
	 * Convert request body to an instance of the specified POJO
	 * 
	 * Currently the followed request body content types are supported:
	 *  * application/json - the body is read using {@link RoutingContext#getBodyAsJson()} then
	 *    mapped to the bean type using {@link JsonObject#mapTo(Class)}
	 *  * application/x-www-form-urlencoded - the body is read using {@link HttpServerRequest#formAttributes()}
	 *   into a {@link JsonObject} as keys with string values, then mapped to the bean type using {@link JsonObject#mapTo(Class)}.
	 *   If the same key is present multiple times, the values will be stored into the JsonObject as a {@link JsonArray} with string values.
	 * 
	 * If no content-type header is specified in the request, application/json is assumed.
	 * 
	 * If no body is present, this method will throw an unchecked {@link MissingBodyException} - i.e. a "Bad Request"
	 * HTTP error with the text "Required request body is missing".
	 * 
	 * @apiNote this API is very similar to the Vert.x 4.3 API {@link RequestBody#asPojo(Class)}. The notable difference
	 *   is that this implementation checks the request Content-Type and if its a form POST, it will read the form post fields
	 *   to mimic a JSON object. This may or may not be a desired behavior.
	 * 
	 * @param 
	 * @param type
	 * @return
	 */
	public  T getBodyAs(Class type) {
		String contentType = this.request().getHeader("Content-Type");
		if (Objects.isNull(contentType)) contentType = "application/json"; // we love JSON
		String[] ctParts = contentType.split(";\\s*");
		switch (ctParts[0]) {
		case "application/x-www-form-urlencoded":
			JsonObject out = new JsonObject();
			request().formAttributes().forEach(e -> {
				Object old = out.getValue(e.getKey());
				if (Objects.isNull(old)) {
					out.put(e.getKey(), e.getValue());
					return;
				}
				if (old instanceof JsonArray) {
					((JsonArray)old).add(e.getValue());
					return;
				}
				out.put(e.getKey(), new JsonArray().add(old).add(e.getValue()));
			});
			return out.mapTo(type);
		case "application/json":
		default:
			try {
				JsonObject body = body().asJsonObject();
				if (body == null)
					throw new MissingBodyException().unchecked();
				return body.mapTo(type);
			} catch (DecodeException e) {
				throw new BadRequest("Unrecognized content-type " + ctParts[0] + 
						" and content does not decode as JSON: " + e.getMessage()).unchecked();
			}
		}
	}
	
	/**
	 * Helper method to terminate request processing with a success (200 OK) response 
	 * containing a JSON body.
	 * @param json {@link JsonObject} containing the output to encode
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendJSON(JsonObject json) {
		return sendJSON(json, new OK());
	}
	
	/**
	 * Helper method to terminate a request processing with a success (200 OK) response
	 * containing a JSON object mapped from the specified POJO
	 * @param data POJO containing the data to map to a JSON encoded object
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendObject(Object data) {
		return sendJSON(JsonObject.mapFrom(data));
	}
	
	/**
	 * Helper method to terminate request processing with a success (200 OK) response 
	 * containing a JSON body.
	 * @param json {@link JsonArray} containing the output to encode
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendJSON(JsonArray json) {
		return sendJSON(json, new OK());
	}
	
	/**
	 * Helper method to terminate request processing with a custom response 
	 * containing a JSON body and the specified status line.
	 * @param json {@link JsonObject} containing the output to encode
	 * @param status An HttpError object representing the HTTP status to be sent
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendJSON(JsonObject json, HttpError status) {
		return sendContent(json.encode(), status, "application/json");
	}
	
	/**
	 * Helper method to terminate a request processing with a custom response
	 * containing a JSON object mapped from the specified POJO and the specified status line.
	 * @param data POJO containing the data to map to a JSON encoded object
	 * @param status An HttpError object representing the HTTP status to be sent
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendObject(Object data, HttpError status) {
		return sendJSON(JsonObject.mapFrom(data), status);
	}
	
	/**
	 * Helper method to terminate request processing with a custom response 
	 * containing a JSON body and the specified status line.
	 * @param json {@link JsonArray} containing the output to encode
	 * @param status HTTP status to send
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendJSON(JsonArray json, HttpError status) {
		return sendContent(json.encode(), status, "application/json");
	}
	
	/**
	 * Helper method to terminate request processing with a custom response
	 * containing some text and the specified status line.
	 * @param content Text content to send in the response
	 * @param status An HttpError object representing the HTTP status to be sent
	 * @param contentType The MIME Content-Type to be set for the response
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendContent(String content, HttpError status, String contentType) {
		return sendContent(Buffer.buffer(content), status, contentType);
	}
	
	/**
	 * Helper method to terminate request processing with a custom response
	 * containing some data and the specified status line.
	 * @param content Binary content to send in the response
	 * @param status An HttpError object representing the HTTP status to be sent
	 * @param contentType The MIME Content-Type to be set for the response
	 * @return a promise that will resolve when the body was sent successfully
	 */
	public Future sendContent(Buffer content, HttpError status, String contentType) {
		var res = response(status)
				.putHeader("Content-Type", contentType)
				.putHeader("Content-Length", String.valueOf(content.length()));
		if (isHead())
			return res.end();
		return res.end(content);
	}
	
	/**
	 * Helper method to terminate request processing with a custom response
	 * containing some text and the specifeid status line.
	 * @param content Text content to send in the response
	 * @param contentType The MIME Content-Type to be set for the response
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendContent(String content, String contentType) {
		return sendContent(content, new OK(), contentType);
	}
	
	/**
	 * Helper method to terminate request processing with a custom response
	 * containing some text and the specifeid status line.
	 * @param content Text content to send in the response
	 * @param status An HttpError object representing the HTTP status to be sent
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendContent(String content, HttpError status) {
		return sendContent(content, status, "text/plain");
	}
	
	/**
	 * Helper method to terminate request processing with a custom response
	 * containing some text and the specifeid status line.
	 * @param content Text content to send in the response
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendContent(String content) {
		return sendContent(content, new OK(), "text/plain");
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP error (non-200 OK) response.
	 * The resulting HTTP response will have the correct status line and an application/json content
	 * with a JSON encoded object containing the fields "status" set to "false" and "message" set
	 * to the {@link HttpError}'s message.
	 * @param status An HttpError object representing the HTTP status to be sent
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future sendError(HttpError status) {
		return sendJSON(new JsonObject().put("status", status.getStatusCode() / 100 == 2).put("message", status.getMessage()), status);
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP OK and a JSON response
	 * @param object {@link JsonObject} of data to send
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future send(JsonObject object) {
		return sendJSON(object);
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP OK and a JSON response
	 * @param list {@link JsonArray} of a list of data to send
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future send(JsonArray list) {
		return sendJSON(list);
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP OK and a text/plain response
	 * @param content text to send
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future send(String content) {
		return sendContent(content);
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP OK and a application/octet-stream response
	 * @param buffer binary data to send
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future send(Buffer buffer) {
		return sendContent(buffer, new OK(), "application/octet-stream");
	}
	
	/**
	 * Helper method to terminate request processing with a non-OK HTTP response with default text
	 * @param status {@link HttpError} to send
	 * @return a promise that will complete when the body was sent successfully
	 */
	public Future send(HttpError status) {
		return sendError(status);
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP OK and an application/json
	 * response containing a list of {@link io.vertx.core.json.Json}-encoded objects
	 * @param  type of objects in the list
	 * @param list List to convert to a JSON array for sending
	 * @return a promise that will complete when the body was sent successfully
	 */
	public  Future sendList(List list) {
		return sendStream(list.stream());
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP OK and an application/json
	 * response containing a stream of {@link io.vertx.core.json.Json}-encoded objects.
	 * Please note that the response will be buffered in memory using a {@link io.vertx.core.json.JsonArray}
	 * based collector.
	 * @param  type of objects in the stream
	 * @param stream Stream to convert to a JSON array for sending
	 * @return a promise that will complete when the body was sent successfully
	 */
	public  Future sendStream(Stream stream) {
		return sendJSON(stream.map(this::encodeToJsonType).collect(JsonArray::new, JsonArray::add, JsonArray::addAll));
	}
	
	/**
	 * Helper method to terminate request processing with an HTTP OK and a JSON response
	 * @param object custom object to process through Jackson's {@link ObjectMapper} to generate JSON content
	 * @return a promise that will complete when the body was sent successfully
	 */
	@SuppressWarnings("unchecked")
	public Future send(Object object) {
		if (object instanceof List)
			return sendList((List)object);
		else if (object instanceof Stream)
			return sendStream((Stream)object);
		else
			return sendObject(object);
	}

	/**
	 * Helper method to generate response with the specified HTTP status
	 * @param status HTTP status code and text to set on the response
	 * @return HTTP response created using {@link RoutingContext#response()}
	 */
	public HttpServerResponse response(HttpError status) {
		HttpServerResponse res = response();
		for (Entry h : status.getHeaders())
			res.putHeader(h.getKey(), h.getValue());
		return res.setStatusCode(status.getStatusCode()).setStatusMessage(status.getStatusText());
	}
	
	/**
	 * Check if the client requested a connection upgrade, regardless which type
	 * of upgrade is required.
	 * @return {@literal true} if the request includes a 'Connection: upgrade' header.
	 */
	public boolean needUpgrade() {
		return needUpgrade(null);
	}
	
	/**
	 * check if the client requested a specific connection upgrade.
	 * @param type What upgrade type to test against, case insensitive
	 * @return {@literal true} if the request includes a 'Connection: upgrade' header and an 'Upgrade' header with the specified type.
	 */
	public boolean needUpgrade(String type) {
		HttpServerRequest req = request();
		return req.getHeader("Connection").equalsIgnoreCase("upgrade") && (Objects.isNull(type) || req.getHeader("Upgrade").equalsIgnoreCase(type));
	}
	
	/**
	 * Helper for authorization header parsing
	 * @return A parsed {@link AuthorizationToken}
	 */
	public AuthorizationToken getAuthorization() {
		return AuthorizationToken.parse(request().getHeader("Authorization"));
	}

	/**
	 * Helper method to encode arbitrary types to a type that Vert.x 
	 * @{link io.vertx.json.JsonObject} and @{link io.vertx.json.JsonArray}
	 * will accept.
	 * 
	 * This implementation recognizes some types as Vert.x JSON "safe" (i.e. can
	 * be used with JsonArray::add but not all the types that would be
	 * accepted. Ideally we would use Json.checkAndCopy() but it is not visible.
	 * @param value object to recode to a valid JSON type
	 * @return a type that will be accepted by JsonArray.add();
	 */
	private Object encodeToJsonType(Object value) {
		if (value instanceof Boolean ||
				value instanceof Number ||
				value instanceof String ||
				value instanceof JsonArray ||
				value instanceof List ||
				value instanceof JsonObject ||
				value instanceof Map)
			return value;
		return JsonObject.mapFrom(value);
	}

	/**
	 * Check if this request is a HEAD request, in which case {@link #sendContent(Buffer, HttpError, String)}
	 * will not send any content (but will send all headers including content-type and content-length).
	 * @return whether the request is a HEAD request
	 */
	public boolean isHead() {
		return request().method() == HttpMethod.HEAD;
	}

}