package net.ttddyy.evernote.rest; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.metrics.CounterService; import org.springframework.boot.actuate.metrics.GaugeService; import org.springframework.boot.autoconfigure.web.ErrorAttributes; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.http.HttpStatus; import org.springframework.social.evernote.api.Evernote; import org.springframework.social.evernote.api.EvernoteException; import org.springframework.social.evernote.api.StoreClientHolder; import org.springframework.social.evernote.api.StoreOperations; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.StopWatch; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.view.InternalResourceView; import org.springframework.web.util.WebUtils; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Method; /** * @author Tadaya Tsuyukubo */ @RestController @RequestMapping("/{storeName:noteStore|userStore}") public class StoreOperationController { @Autowired private Evernote evernote; @Autowired private ObjectMapper objectMapper; @Autowired private ParameterNameDiscoverer parameterNameDiscoverer; @Autowired private ParameterJavaTypeDiscoverer parameterJavaTypeDiscoverer; @Autowired private CounterService counterService; @Autowired private GaugeService gaugeService; @Autowired private ErrorAttributes errorAttributes; @RequestMapping(value = "/{methodName}", method = RequestMethod.POST) public Object invoke(@PathVariable String storeName, @PathVariable String methodName, @RequestBody(required = false) JsonNode jsonNode, HttpServletRequest request, HttpServletResponse response) { final StoreOperations storeOperations = getStoreOperations(storeName); final Class<?> storeOperationsClass = storeOperations.getClass(); final Class<?> actualStoreClientClass = resolveStoreClientClass(storeOperations); // underlying ~StoreClient class. // In ~StoreClient class, method names are currently unique. passing null to paramTypes arg means find method by name. final Method method = ReflectionUtils.findMethod(storeOperationsClass, methodName, null); final Method actualMethod = ReflectionUtils.findMethod(actualStoreClientClass, methodName, null); if (method == null || actualMethod == null) { final String message = String.format("Cannot find methodName=[%s] on [%s].", methodName, actualStoreClientClass); throw new EvernoteRestException(message); } Object[] params = null; if (jsonNode != null) { // Cannot retrieve parameter names and generic method parameter type from interface, even though classes // are compiled with debugging information. // ~StoreClient class, which is an underlying implementation class of ~StoreOperations, uses same parameter // names and types. Thus, for now, use underlying actual ~StoreClient class to resolve names and types. // Java8 with StandardReflectionParameterNameDiscoverer class, it may be possible to retrieve param names from // interface. (haven't checked) params = resolveParameters(actualMethod, jsonNode); } // metric format: // evernote.api.[userStore|noteStore].<method>.[succeeded|failed] // evernote.api.[userStore|noteStore].<method>.response final String metricNamePrefix = "evernote.api." + storeName + "." + methodName; // evernote.api.[userStore|noteStore].<method> final StopWatch stopWatch = new StopWatch(); try { stopWatch.start(); Object result = ReflectionUtils.invokeMethod(method, storeOperations, params); stopWatch.stop(); counterService.increment(metricNamePrefix + ".succeeded"); gaugeService.submit(metricNamePrefix + ".response", stopWatch.getTotalTimeMillis()); return result; } catch (Exception e) { if (stopWatch.isRunning()) { stopWatch.stop(); } counterService.increment(metricNamePrefix + ".failed"); final String message = String.format( "Failed to invoke method. method=[%s], storeClient=[%s], params=[%s], caused-by=[%s] exception-message=[%s]", method.getName(), actualStoreClientClass, ObjectUtils.nullSafeToString(params), e.getClass().getName(), e.getMessage() ); if (e instanceof EvernoteException && ((EvernoteException) e).isEDAMException()) { // For EDAM*Exception, return status=BAD_REQUEST(400), and do not throw exception, so that server-side // will not write out the exception since this is an client error. // Using spring-boot's BasicErrorController to generate response to client // expose exception where BasicErrorController can pick-up. maybe too detail... ((HandlerExceptionResolver) errorAttributes).resolveException(request, response, null, e); // delegate to spring-boot infrastructure... request.setAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, HttpStatus.BAD_REQUEST.value()); // response status code return new InternalResourceView("/error"); } else { throw new EvernoteRestException(message, e); } } } private StoreOperations getStoreOperations(String storeName) { if ("noteStore".equals(storeName)) { return evernote.noteStoreOperations(); } else { return evernote.userStoreOperations(); } } /** * Based on received json, deserialize parameters. */ private Object[] resolveParameters(Method actualMethod, JsonNode jsonNode) { final String[] parameterNames = parameterNameDiscoverer.getParameterNames(actualMethod); if (parameterNames == null) { final String message = String.format("Cannot find parameter names for method=[%s].", actualMethod.getName()); throw new EvernoteRestException(message); } // to allow jackson to map generic type in collection appropriately, such as List<Long> or List<Short>, // object mapper requires JavaType to be provided. Otherwise, generics for number gets default to List<Integer>. final JavaType[] parameterJavaTypes = parameterJavaTypeDiscoverer.getParameterJavaTypes(actualMethod); return resolveParameterValues(parameterNames, parameterJavaTypes, jsonNode); } private Class<?> resolveStoreClientClass(StoreOperations storeOperations) { return ((StoreClientHolder) storeOperations).getStoreClient().getClass(); } private Object[] resolveParameterValues(String[] parameterNames, JavaType[] javaTypes, JsonNode jsonNode) { // populate params final int parameterSize = parameterNames.length; final Object[] params = new Object[parameterSize]; for (int i = 0; i < parameterSize; i++) { final JavaType javaType = javaTypes[i]; final String parameterName = parameterNames[i]; if (jsonNode.has(parameterName)) { final String subJson = jsonNode.get(parameterName).toString(); try { final Object param = this.objectMapper.readValue(subJson, javaType); params[i] = param; } catch (IOException e) { final String message = e.getMessage() + ". parameter=[" + parameterName + "] json=[" + subJson + "]"; throw new EvernoteRestException(message, e); } } else { params[i] = null; // if not included in json, then set as null TODO: resolve default value?? } } return params; } }