HttpErrorGenerator.java

/******************************************************************************
   Copyright 2014 Emily Estes

   Licensed 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.metanotion.web.concrete;


import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;

import java.sql.SQLException;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import net.metanotion.json.JsonArray;
import net.metanotion.json.JsonMagic;
import net.metanotion.json.JsonObject;
import net.metanotion.util.Dispatcher;
import net.metanotion.util.Message;
import net.metanotion.web.HttpValues;

/** This class provides a dispatcher implementation that pattern matches exceptions thrown by messages dispatched
against the delegated dispatcher. When a pattern match is found, this class generates an
{@link net.metanotion.web.concrete.HttpException} with a JSON encoded error response. This class also provides
utility methods to load exception patterns and error details from a JSON formatted resource file.
*/
public final class HttpErrorGenerator {
	private static final Logger logger = LoggerFactory.getLogger(HttpErrorGenerator.class);

	private static final class ErrorInfo {
		public String selector;
		public String message;
		public int httpStatus;
		public String type;
		public List<String> exceptionDetails;
	}

	private static final JsonMagic<ErrorInfo> decoder = new JsonMagic<>(ErrorInfo.class);

	/** Load a JSON file as a resource containing exception patterns.
		@param klazz The class name the exception patterns will match against.
		@throws IOException If there is an issue loading the JSON file.
	*/
	public void load(final Class klazz) throws IOException {
		try (final InputStream in = klazz.getClassLoader()
				.getResourceAsStream(klazz.getName().replace('.', '/') + ".errors.json")) {
			load(klazz.getName(), JsonObject.read(new InputStreamReader(in, "UTF-8")));
		}
	}


	/** Load a JSON file as a resource containing exception patterns.
		@param klazz The class name the exception patterns will match against.
		@param obj A JSON object instance containing the patterns.
	*/
	public void load(final String klazz, final JsonObject obj) {
		final JsonObject defaultProperty = (JsonObject) obj.get("default");
		if(defaultProperty != null) {
			final ErrorInfo info = decoder.toStruct(defaultProperty);
			addClassDefaultError(klazz, info.selector, info.message, info.httpStatus, info.type, info.exceptionDetails);
		}
		loadMethods(klazz, (JsonObject) obj.get("errors"));
	}

	private void loadMethods(final String klazz, final JsonObject methods) {
		for(final String method: methods.keySet()) {
			final JsonArray array = (JsonArray) methods.get(method);
			for(int i=0; i < array.size(); i++) {
				final JsonObject json = (JsonObject) array.get(i);
				final ErrorInfo info = decoder.toStruct(json);
				final String sqlState = (String) json.get("sqlState");
				if(sqlState == null) {
					addError(klazz, method, info.selector, info.message, info.httpStatus, info.type, info.exceptionDetails);
				} else {
					addSqlError(klazz, method, info.selector, info.message, info.httpStatus, sqlState, info.exceptionDetails);
				}
			}
		}
	}

	private interface Generator {
		public HttpValues result(Throwable error);
		public void addError(String type, List<String> exceptionMessage, HttpValues errValue);
	}

	private static final class BasicGenerator implements Generator {
		private static final class Err {
			public final String type;
			public final List<String> pieces;
			public final HttpValues result;
			public Err(final String type, final List<String> pieces, final HttpValues result) {
				this.type = type;
				this.pieces = pieces;
				this.result = result;
			}
		}

		private final List<Err> errors = new ArrayList<>();
		@Override public HttpValues result(final Throwable error) {
			final String message = error.getMessage();
			if(message == null) { return null; }
			outer: for(final Err item: errors) {
				if((item.type != null) && (!item.type.equals(error.getClass().getName()))) {
					continue outer;
				}
				for(final String s: item.pieces) {
					if(!message.contains(s)) { continue outer; }
				}
				return item.result;
			}
			return null;
		}

		@Override public void addError(final String type, final List<String> exceptionMessage, final HttpValues errValue) {
			errors.add(new Err(type, exceptionMessage, errValue));
		}
	}

	private static final class SQLGenerator implements Generator {
		// http://www.postgresql.org/docs/9.3/static/errcodes-appendix.html
		public final Map<String,Generator> sqlStates = new HashMap<>();
		public final Generator next;

		public SQLGenerator() { next = new BasicGenerator(); }
		public SQLGenerator(final Generator next) { this.next = next; }

		@Override public HttpValues result(final Throwable error) {
			if(error instanceof SQLException) {
				final String state = ((SQLException) error).getSQLState();
				logger.debug("!!!SQLState: {}", state);
				final Generator g = sqlStates.get(state);
				if(g == null) { return next.result(error); }
				return g.result(error);
			} else {
				return next.result(error);
			}
		}

		@Override public void addError(final String type, final List<String> exceptionMessage, final HttpValues errValue) {
			next.addError(type, exceptionMessage, errValue);
		}

		public void addSqlError(final String sqlState, final List<String> exceptionMessage, final HttpValues errValue) {
			Generator g = sqlStates.get(sqlState);
			if(g == null) {
				g = new BasicGenerator();
				sqlStates.put(sqlState, g);
			}
			g.addError(null, exceptionMessage, errValue);
		}
	}

	private static final class MethodMap {
		public final Map<String,Generator> methods = new HashMap<>();
		public final Generator def = new BasicGenerator();
	}
	private final Map<String,MethodMap> klazzes = new HashMap<>();
	private final Generator def = new BasicGenerator();

	/** Set the error message when the exception type is specifically a SQLException instance.
		@param klazz The class this matches against.
		@param method The method the exception occurred in.
		@param selector The selector value of the JSON response.
		@param message The message value of the JSON response.
		@param httpStatus The HTTP Status code of the response.
		@param sqlState the SQL State error code for the exception. (SQL State error codes are a part of the SQL standard.)
		@param exceptionMessage String fragements that must be contained in the exception.
	*/
	public void addSqlError(final String klazz,
			final String method,
			final String selector,
			final String message,
			final int httpStatus,
			final String sqlState,
			final List<String> exceptionMessage) {
		final HttpValues errValue = new JsonError().add(selector, message).toHttpResponse(httpStatus);
		MethodMap m = klazzes.get(klazz);
		if(m == null) {
			m = new MethodMap();
			klazzes.put(klazz, m);
		}
		Generator g = m.methods.get(method);
		if(g == null) {
			g = new SQLGenerator();
			m.methods.put(method, g);
		} else if(g instanceof BasicGenerator) {
			g = new SQLGenerator(g);
			m.methods.put(method, g);
		}
		((SQLGenerator) g).addSqlError(sqlState, exceptionMessage, errValue);
	}

	/** Set the default error message for a specific class if no other exception can be found (as long as it contains
		certain substrings).
		@param klazz The class this matches against.
		@param selector The selector value of the JSON response.
		@param message The message value of the JSON response.
		@param httpStatus The HTTP Status code of the response.
		@param type The exception type this matches against.
		@param exceptionMessage String fragements that must be contained in the exception.
	*/
	public void addClassDefaultError(final String klazz,
			final String selector,
			final String message,
			final int httpStatus,
			final String type,
			final List<String> exceptionMessage) {
		final HttpValues errValue = new JsonError().add(selector, message).toHttpResponse(httpStatus);
		MethodMap m = klazzes.get(klazz);
		if(m == null) {
			m = new MethodMap();
			klazzes.put(klazz, m);
		}
		m.def.addError(type, exceptionMessage, errValue);
	}

	/** Set the error message for a given exception pattern.
		@param klazz The class this matches against.
		@param method The method the exception occurred in.
		@param selector The selector value of the JSON response.
		@param message The message value of the JSON response.
		@param httpStatus The HTTP Status code of the response.
		@param type The exception type this matches against.
		@param exceptionMessage String fragements that must be contained in the exception.
	*/
	public void addError(final String klazz,
			final String method,
			final String selector,
			final String message,
			final int httpStatus,
			final String type,
			final List<String> exceptionMessage) {
		final HttpValues errValue = new JsonError().add(selector, message).toHttpResponse(httpStatus);
		MethodMap m = klazzes.get(klazz);
		if(m == null) {
			m = new MethodMap();
			klazzes.put(klazz, m);
		}
		Generator g = m.methods.get(method);
		if(g == null) {
			g = new BasicGenerator();
			m.methods.put(method, g);
		}
		g.addError(type, exceptionMessage, errValue);
	}

	/** Set the default error message if no other exception can be found.
		@param selector The selector value of the JSON response.
		@param message The message value of the JSON response.
		@param httpStatus The HTTP Status code of the response.
	*/
	public void setDefaultError(final String selector, final String message, final int httpStatus) {
		this.def.addError(null, new ArrayList<String>(), new JsonError().add(selector, message).toHttpResponse(httpStatus));
	}

	/** Set the default error message if no other exception can be found (as long as it contains certain substrings).
		@param selector The selector value of the JSON response.
		@param message The message value of the JSON response.
		@param httpStatus The HTTP Status code of the response.
		@param type The exception type this matches against.
		@param exceptionMessage String fragements that must be contained in the exception.
	*/
	public void setDefaultError(final String selector,
			final String message,
			final int httpStatus,
			final String type,
			final List<String> exceptionMessage) {
		this.def.addError(type, exceptionMessage, new JsonError().add(selector, message).toHttpResponse(httpStatus));
	}

	/** Compare an exception against the patterns stored in this class.
		@param error The exception to match against.
		@return an HTTP response suitable for the exception or null if no match could be found.
	*/
	public HttpValues lookup(final Throwable error) {
		for(final StackTraceElement ste: error.getStackTrace()) {
			final MethodMap m = klazzes.get(ste.getClassName());
			if(m != null) {
				final Generator g = m.methods.get(ste.getMethodName());
				if(g != null) {
					final HttpValues result = g.result(error);
					if(result != null) { return result; }
				} else {
					final HttpValues result = m.def.result(error);
					if(result != null) {
						logger.info("Default error, {}", error);
						return result;
					}
				}
			}
		}
		logger.warn("Failure in Request, {}", error);
		return def.result(error);
	}

	/** Wrap a dispatcher instance with a error matcher based on the exception patterns stored in this instance
		of HttpErrorGenerator.
		@param delegate The dispatcher to delegate to
		@param <I> The interface the delegated dispatcher evaluates messages against.
		@param <D> The type of data the delegated dispatcher reads from.
		@return A wrapped instance of the delegated dispatcher.
	*/
	public <I,D> Dispatcher<I,D> dispatcher(final Dispatcher<I,D> delegate) {
		return new HttpErrorDispatcher<I,D>(this, delegate);
	}

	private static final class HttpErrorDispatcher<I,D> implements Dispatcher<I,D> {
		private final Dispatcher<I,D> delegate;
		private final HttpErrorGenerator errGen;
		public HttpErrorDispatcher(final HttpErrorGenerator errGen, final Dispatcher<I,D> delegate) {
			this.errGen = errGen;
			this.delegate = delegate;
		}

		@Override public Message<I> dispatch(final D d) {
			final Message<I> m = delegate.dispatch(d);
			return new Message<I>() {
				@Override public Class<I> receiverType() { return m.receiverType(); }
				@Override public Object call(final I o) {
					try {
						return m.call(o);
					} catch (final Exception e) {
						Throwable t = e;
						while(t != null) {
							final Throwable t2 = t.getCause();
							if(t2 == null) { break; }
							t = t2;
						}
						final HttpValues result = errGen.lookup(t);
						if(result != null) {
							throw new HttpException(result);
						} else {
							throw e;
						}
					}
				}
			};
		}
	}
}