/* * Copyright: Almende B.V. (2014), Rotterdam, The Netherlands * License: The Apache Software License, Version 2.0 */ package com.almende.eve.protocol.jsonrpc; import java.io.IOException; import java.lang.invoke.MethodHandle; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Type; import java.net.URI; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import com.almende.eve.protocol.auth.Authorizor; import com.almende.eve.protocol.jsonrpc.NamespaceUtil.CallTuple; import com.almende.eve.protocol.jsonrpc.annotation.Access; import com.almende.eve.protocol.jsonrpc.annotation.AccessType; import com.almende.eve.protocol.jsonrpc.annotation.Name; import com.almende.eve.protocol.jsonrpc.annotation.Optional; import com.almende.eve.protocol.jsonrpc.annotation.RequestId; import com.almende.eve.protocol.jsonrpc.annotation.Sender; import com.almende.eve.protocol.jsonrpc.formats.JSONRPCException; import com.almende.eve.protocol.jsonrpc.formats.JSONRequest; import com.almende.eve.protocol.jsonrpc.formats.JSONResponse; import com.almende.util.AnnotationUtil; import com.almende.util.AnnotationUtil.AnnotatedMethod; import com.almende.util.AnnotationUtil.AnnotatedParam; import com.almende.util.AnnotationUtil.CachedAnnotation; import com.almende.util.Defines; import com.almende.util.TypeUtil; import com.almende.util.URIUtil; import com.almende.util.jackson.JOM; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; /** * The Class JSONRPC. */ public final class JSONRpc { private static final Logger LOG = Logger.getLogger(JSONRpc.class.getName()); static { if (Defines.HASMETHODHANDLES) { LOG.log(Level.FINE, "Using MethodHandle i.s.o. plain reflection!"); } else { LOG.log(Level.FINE, "Using plain reflection i.s.o. MethodHandle!"); } } /** * Instantiates a new jsonrpc. */ private JSONRpc() {} // TODO: implement JSONRPC 2.0 Batch /** * Invoke a method on an object. * * @param destination * the destination * @param request * A request in JSON-RPC format * @param auth * the auth * @return the string * @throws IOException * Signals that an I/O exception has occurred. */ public static String invoke(final Object destination, final String request, final Authorizor auth) throws IOException { return invoke(destination, request, null, auth); } /** * Invoke a method on an object. * * @param destination * the destination * @param request * A request in JSON-RPC format * @param senderUrl * the sender url * @param auth * the auth * @return the string * @throws IOException * Signals that an I/O exception has occurred. */ public static String invoke(final Object destination, final String request, final URI senderUrl, final Authorizor auth) throws IOException { JSONRequest jsonRequest = null; JSONResponse jsonResponse = null; try { jsonRequest = new JSONRequest(request); jsonResponse = invoke(destination, jsonRequest, senderUrl, auth); } catch (final JSONRPCException err) { jsonResponse = new JSONResponse(err); } return jsonResponse.toString(); } /** * Invoke a method on an object. * * @param destination * destination url * @param request * the request * @param auth * the auth * @return the jSON response */ public static JSONResponse invoke(final Object destination, final JSONRequest request, final Authorizor auth) { return invoke(destination, request, null, auth); } /** * Invoke a method on an object. * * @param destination * the destination * @param request * A request in JSON-RPC format * @param senderUrl * the sender url * @param auth * the auth * @return the jSON response */ public static JSONResponse invoke(final Object destination, final JSONRequest request, final URI senderUrl, final Authorizor auth) { JSONResponse resp = null; final JsonNode id = request.getId(); if (id != null && !id.isNull()) { resp = new JSONResponse(id, null); } try { final CallTuple tuple = NamespaceUtil.get(destination, request.getMethod()); final Object realDest = tuple.getDestination(); final AnnotatedMethod annotatedMethod = tuple.getMethod(); if (!isAvailable(annotatedMethod, realDest, senderUrl, auth)) { throw new JSONRPCException( JSONRPCException.CODE.METHOD_NOT_FOUND, "Method '" + request.getMethod() + "' not found. The method does not exist or you are not authorized."); } final MethodHandle methodHandle = annotatedMethod.getMethodHandle(); final Method method = annotatedMethod.getActualMethod(); Object result = null; if (Defines.HASMETHODHANDLES) { final Object[] params = castParams(realDest, request.getParams(), annotatedMethod.getParams(), senderUrl, request.getId()); if (annotatedMethod.isVoid()) { methodHandle.invokeExact(params); } else { result = methodHandle.invokeExact(params); } } else { final Object[] params = castParams(request.getParams(), annotatedMethod.getParams(), senderUrl, request.getId()); result = method.invoke(realDest, params); } if (resp != null) { if (result == null) { result = JOM.createNullNode(); } resp.setResult(result); } } catch (final JSONRPCException err) { if (resp != null) { resp.setError(err); } } catch (final Throwable err) { final Throwable cause = err.getCause(); if (cause instanceof JSONRPCException) { if (resp != null) { resp.setError((JSONRPCException) cause); } } else { if (err instanceof InvocationTargetException && cause != null) { LOG.log(Level.WARNING, "Exception raised, returning its cause as JSONRPCException. Request:" + request, cause); final JSONRPCException jsonError = new JSONRPCException( JSONRPCException.CODE.INTERNAL_ERROR, getMessage(cause), cause); jsonError.setData(cause); if (resp != null) { resp.setError(jsonError); } } else { LOG.log(Level.WARNING, "Exception raised, returning it as JSONRPCException. Request:" + request, err); final JSONRPCException jsonError = new JSONRPCException( JSONRPCException.CODE.INTERNAL_ERROR, getMessage(err), err); jsonError.setData(err); if (resp != null) { resp.setError(jsonError); } } } } return resp; } /** * Describe all JSON-RPC methods of given class. * Format: * http://www.simple-is-better.org/json-rpc/jsonrpc20-schema-service- * descriptor.html * * @param c * The class to be described * @param auth * the authorizor * @return the Map */ public static ObjectNode describe(final Object c, final Authorizor auth) { final ObjectNode methods = JOM.createObjectNode(); if (c == null) { return methods; } for (final String path : NamespaceUtil.getAllMethodPaths(c)) { try { final CallTuple method = NamespaceUtil.get(c, path); if (isAvailable(method.getMethod(), method.getDestination(), URIUtil.create("local:null"), auth)) { final ObjectNode result = JOM.createObjectNode(); result.put("type", "method"); result.put("description", typeToString(method.getMethod() .getGenericReturnType())); result.set("returns", typeToJsonSchema(method.getMethod() .getGenericReturnType())); final ArrayNode params = JOM.createArrayNode(); for (final AnnotatedParam param : method.getMethod() .getParams()) { if (param.getAnnotation(Sender.class) == null && param.getAnnotation(RequestId.class) == null) { final ObjectNode paramData = JOM.createObjectNode(); paramData.put("name", getName(param)); paramData.put("description", typeToString(param.getGenericType())); paramData.set("type", typeToJsonSchema(param.getGenericType())); paramData.put("required", isRequired(param)); params.add(paramData); } } result.set("params", params); methods.set(path, result); } } catch (Exception e) { LOG.log(Level.WARNING, "Failed to describe: " + path + " in class:" + c.getClass().getName()); } } return methods; } /** * Get type description from a class. Returns for example "String" or * "List<String>". * * @param c * the c * @return the string */ private static String typeToString(final Type c) { String s = c.toString(); // replace full namespaces to short names int point = s.lastIndexOf('.'); while (point >= 0) { final int angle = s.lastIndexOf('<', point); final int space = s.lastIndexOf(' ', point); final int start = Math.max(angle, space); s = s.substring(0, start + 1) + s.substring(point + 1); point = s.lastIndexOf('.'); } // remove modifiers like "class blabla" or "interface blabla" final int space = s.indexOf(' '); final int angle = s.indexOf('<', point); if (space >= 0 && (angle < 0 || angle > space)) { s = s.substring(space + 1); } return s; } private static ObjectNode typeToJsonSchema(final Type c) throws JsonMappingException { return JOM.getTypeSchema(c); } /** * Retrieve a description of an error. * * @param error * the error * @return message String with the error description of the cause */ private static String getMessage(final Throwable error) { Throwable cause = error; while (cause.getCause() != null) { cause = cause.getCause(); } return cause.toString(); } /** * @param params * @param annotatedParams * @param requestParams * @return the Object[] */ private static Object[] castParams(final ObjectNode params, final List<AnnotatedParam> annotatedParams, final URI senderUrl, final JsonNode requestId) { return castParams(null, params, annotatedParams, senderUrl, requestId); } /** * Cast a JSONArray or JSONObject params to the desired paramTypes. * * @param params * the params * @param annotatedParams * the annotated params * @param requestParams * the request params * @return the object[] */ private static Object[] castParams(final Object realDest, final ObjectNode params, final List<AnnotatedParam> annotatedParams, final URI senderUrl, final JsonNode requestId) { switch (annotatedParams.size()) { case 0: if (realDest != null) { return new Object[] { realDest }; } else { return new Object[0]; } /* -- Unreachable, explicit no break -- */ case 1: final AnnotatedParam parm = annotatedParams.get(0); if (parm.getType().equals(ObjectNode.class) && parm.getAnnotations().isEmpty()) { // the method expects one parameter of type JSONObject // feed the params object itself to it. if (realDest != null) { return new Object[] { realDest, params }; } else { return new Object[] { params }; } } /* -- Explicit no break -- */ default: final ObjectNode paramsObject = (ObjectNode) params; int offset = 0; if (realDest != null) { offset = 1; } final Object[] objects = new Object[annotatedParams.size() + offset]; if (realDest != null) { objects[0] = realDest; } for (int i = 0; i < annotatedParams.size(); i++) { final AnnotatedParam p = annotatedParams.get(i); final String name = getName(p); if (name == null) { CachedAnnotation a = p.getAnnotation(Sender.class); if (a != null) { // this is a systems parameter if (p.getType().equals(String.class)) { LOG.warning("Deprecated parameter usage: @Sender should now by an URI i.s.o. String"); objects[i + offset] = senderUrl.toString(); } else { objects[i + offset] = senderUrl; } } else { a = p.getAnnotation(RequestId.class); if (a != null) { objects[i + offset] = requestId; } else { // this is a problem throw new ClassCastException( "Name of parameter " + i + " not defined"); } } } else { // this is a named parameter if (paramsObject.has(name)) { objects[i + offset] = TypeUtil.inject( paramsObject.get(name), p.getGenericType()); } else { if (isRequired(p)) { throw new ClassCastException( "Required parameter '" + name + "' missing."); } else if (p.getType().isPrimitive()) { // TODO: should this test be moved to // isAvailable()? throw new ClassCastException("Parameter '" + name + "' cannot be both optional and " + "a primitive type (" + p.getType().getSimpleName() + ")"); } else { objects[i + offset] = null; } } } } return objects; } } /** * Check whether a method is available for JSON-RPC calls. This is the case * when it is public, has named parameters, and has a public or private @Access * annotation * * @param method * the method * @param destination * the destination * @param requestParams * the request params * @param auth * the auth * @return available */ private static boolean isAvailable(final AnnotatedMethod method, final Object destination, final URI senderUrl, final Authorizor auth) { if (method == null) { return false; } if (destination != null && !method.getActualMethod().getDeclaringClass() .isAssignableFrom(destination.getClass())) { return false; } final int mod = method.getActualMethod().getModifiers(); if (!(Modifier.isPublic(mod) && hasNamedParams(method))) { return false; } CachedAnnotation methodAccess = method.getAnnotation(Access.class); if (methodAccess == null) { methodAccess = AnnotationUtil.get( destination != null ? destination.getClass() : method .getActualMethod().getDeclaringClass()) .getAnnotation(Access.class); } if (methodAccess == null) { // Default: UNAVAILABLE! return false; } final AccessType value = (AccessType) methodAccess.value(); switch (value) { case PUBLIC: return true; case UNAVAILABLE: return false; case PRIVATE: return auth != null ? auth.onAccess(senderUrl, ((Access) methodAccess.getAnnotation()).tag()) : false; case SELF: return auth != null ? auth.isSelf(senderUrl) : false; default: return false; } } /** * Test whether a method has named parameters. * * @param method * the method * @param requestParams * the request params * @return hasNamedParams */ private static boolean hasNamedParams(final AnnotatedMethod method) { for (final AnnotatedParam param : method.getParams()) { CachedAnnotation a = param.getAnnotation(Name.class); if (a == null) { a = param.getAnnotation(Sender.class); if (a == null) { a = param.getAnnotation(RequestId.class); if (a == null) { return false; } } } } return true; } /** * Test if a parameter is required Reads the parameter annotation @Required. * Returns True if the annotation is not provided. * * @param param * the param * @return required */ @SuppressWarnings("deprecation") static boolean isRequired(final AnnotatedParam param) { boolean required = true; final CachedAnnotation requiredAnnotation = param .getAnnotation(com.almende.eve.protocol.jsonrpc.annotation.Required.class); if (requiredAnnotation != null) { required = (boolean) requiredAnnotation.value(); } if (param.getAnnotation(Optional.class) != null) { required = false; } return required; } /** * Get the name of a parameter Reads the parameter annotation @Name. Returns * null if the annotation is not provided. * * @param param * the param * @return name */ static String getName(final AnnotatedParam param) { String name = null; final CachedAnnotation nameAnnotation = param.getAnnotation(Name.class); if (nameAnnotation != null) { name = (String) nameAnnotation.value(); } return name; } }