JavaXT
|
|
WebService Classpackage javaxt.express; import javaxt.express.ServiceRequest.Sort; import javaxt.express.ServiceRequest.Field; import javaxt.express.ServiceRequest.Filter; import javaxt.express.utils.*; import javaxt.sql.*; import javaxt.json.*; import javaxt.utils.Console; import javaxt.http.servlet.ServletException; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; //****************************************************************************** //** WebService //****************************************************************************** /** * Abstract class used to map HTTP requests to either virtual or concrete * methods found in the extending class. * ******************************************************************************/ public abstract class WebService { private ConcurrentHashMap<String, DomainClass> classes = new ConcurrentHashMap<>(); public static Console console = new Console(); //do not replace with static import! private class DomainClass { private Class c; private boolean readOnly; public DomainClass(Class c, boolean readOnly){ this.c = c; this.readOnly = readOnly; } public boolean isReadOnly(){ return readOnly; } public String toString(){ return c.toString() + (readOnly ? " (readonly)" : ""); } } //************************************************************************** //** addModel //************************************************************************** /** Register model that this service will support * @param c A Java class that extends the javaxt.sql.Model abstract class. */ public void addModel(Class c){ addModel(c, false); } //************************************************************************** //** addModel //************************************************************************** /** Register model that this service will support * @param c A Java class that extends the javaxt.sql.Model abstract class. * @param readOnly If true, the CRUD operations will be disabled. PUT, * POST, and DELETE requests will be handled the same as GET requests. */ public void addModel(Class c, boolean readOnly){ if (!Model.class.isAssignableFrom(c)){ throw new IllegalArgumentException(); } String name = c.getSimpleName(); String pkg = c.getPackage().getName(); if (name.startsWith(pkg)) name = name.substring(pkg.length()+1); name = name.toLowerCase(); int idx = name.lastIndexOf("."); if (idx>0) name = name.substring(idx+1); synchronized(classes){ classes.put(name, new DomainClass(c, readOnly)); classes.notify(); } } //************************************************************************** //** addClass //************************************************************************** /** @deprecated Use addModel instead */ public void addClass(Class c){ addModel(c); } //************************************************************************** //** addClass //************************************************************************** /** @deprecated Use addModel instead */ public void addClass(Class c, boolean readOnly){ addModel(c, readOnly); } //************************************************************************** //** getServiceResponse //************************************************************************** /** Returns a ServiceResponse for a given request. */ public ServiceResponse getServiceResponse(ServiceRequest request) throws ServletException { return getServiceResponse(request, null); } //************************************************************************** //** getServiceResponse //************************************************************************** /** Returns a ServiceResponse for a given request and database. */ public ServiceResponse getServiceResponse(ServiceRequest request, Database database) throws ServletException { //Get requested method. Note that the ServiceRequest typically prepends a //keyword to the request path (e.g. get, save, delete) depending on the //HTTP request method (e.g. GET, POST, PUT, DELETE) String methodName = request.getMethod(); //Find a concrete implementation of the requested method in the subclass { boolean strictLookup = false; if (!strictLookup) methodName = methodName.toLowerCase(); //Generate a list of all the service methods in the subclass. Service //methods are public methods that accept a ServiceRequest parameter //and return a ServiceResponse object. Implementation note: the //getDeclaredMethod() method will only find methods declared in the //current Class, not inherited from supertypes. So we may need to //traverse up the concrete class hierarchy if this becomes a requirement. LinkedHashMap<String, ArrayList<Method>> serviceMethods = new LinkedHashMap<>(); for (Method m : this.getClass().getDeclaredMethods()){ if (Modifier.isPrivate(m.getModifiers())) continue; if (m.getReturnType().equals(ServiceResponse.class)){ Class<?>[] params = m.getParameterTypes(); if (params.length>0){ if (ServiceRequest.class.isAssignableFrom(params[0])){ String key = m.getName(); if (!strictLookup) key = key.toLowerCase(); ArrayList<Method> methods = serviceMethods.get(key); if (methods==null){ methods = new ArrayList<>(); serviceMethods.put(key, methods); } methods.add(m); } } } } //Find service methods that implement the requested method ArrayList<Method> methods = null; if (serviceMethods.containsKey(methodName)){ methods = serviceMethods.get(methodName); } else{ int i = 0; if (methodName.startsWith("get")) i = 4; if (methodName.startsWith("save")) i = 5; if (methodName.startsWith("delete")) i = 6; if (i>0){ methodName = methodName.substring(i-1, i).toLowerCase() + methodName.substring(i); methods = serviceMethods.get(methodName); } } //Return ServiceResponse if (methods!=null){ for (Method m : methods){ Class<?>[] params = m.getParameterTypes(); //Check whether the method accepts a ServiceRequest //or ServiceRequest + Database as inputs Object[] inputs = null; if (params.length==1){ inputs = new Object[]{request}; } else if (params.length==2){ if (Database.class.isAssignableFrom(params[1])){ inputs = new Object[]{request, database}; } } if (inputs!=null){ //Ensure that we don't want to invoke this function! //For example, the caller might want to call //super.getServiceResponse(request, database); //If so, we would end up in a recursion causing a //stack overflow. Instead of calling getServiceResponse() //let's just flow down to the CRUD handlers below. StackTraceElement[] stackTrace = new Exception().getStackTrace(); StackTraceElement el = stackTrace[1]; if (m.getName().equals(el.getMethodName())){ break; } //If we're still here, call the requested method //and return the response try{ m.setAccessible(true); return (ServiceResponse) m.invoke(this, inputs); } catch(Exception e){ return getServiceResponse(e); } } } } } //If we're still here, see if the requested method corresponds to a //standard CRUD operation. String method = request.getMethod().toLowerCase(); //don't use methodName! if (method.startsWith("get")){ //Find and return model String className = method.substring(3); DomainClass c = getClass(className); if (c!=null) return get(c.c, request, database); //Special case for plural-form of a model. Return list of models. if (className.endsWith("ies")){ //Categories == Category c = getClass(className.substring(0, className.length()-3) + "y"); } else if (className.endsWith("ses")){ //Classes == Class c = getClass(className.substring(0, className.length()-2)); } else if (className.endsWith("s")){ //Sources == Source c = getClass(className.substring(0, className.length()-1)); } if (c!=null) return list(c.c, request, database); } else if (method.startsWith("save")){ //Find model and save String className = method.substring(4); DomainClass c = getClass(className); if (c!=null){ if (c.isReadOnly()){ return get(c.c, request, database); } else{ return save(c.c, request, database); } } //Special case for plural-form of a model if (className.endsWith("ies")){ //Categories == Category c = getClass(className.substring(0, className.length()-3) + "y"); } else if (className.endsWith("ses")){ //Classes == Class c = getClass(className.substring(0, className.length()-2)); } else if (className.endsWith("s")){ //Sources == Source c = getClass(className.substring(0, className.length()-1)); } if (c!=null){ if (c.isReadOnly()){ return list(c.c, request, database); } else{ return new ServiceResponse(501, "Not Implemented."); } } } else if (method.startsWith("delete")){ String className = method.substring(6); DomainClass c = getClass(className); if (c!=null){ if (c.isReadOnly()){ return new ServiceResponse(403, "Delete access forbidden."); } else{ return delete(c.c, request, database); } } } return new ServiceResponse(501, "Not Implemented."); } //************************************************************************** //** getRecordset //************************************************************************** /** Returns a Recordset that is used fetch records from the database and * support CRUD operations. This is a protected method that extending * classes can override to apply custom filters or add constraints when * retrieving objects from the database. This method is called whenever an * HTTP GET, POST, or DELETE request is made for a Model. It is perfectly * acceptable to throw exceptions when overriding this method. When * throwing exceptions, an IllegalArgumentException will return a HTTP 400 * error to the client and a SecurityException will return a 403 error. All * other exceptions will return a 500 error. * @param op Operation that is requesting the Recordset. Options include * "list, "get", "save", and "delete". * @param c The Model (Java class) associated with the request. * @param sql The default SQL statement generated for the request. * @param conn A database connection used to open the Recordset. */ protected Recordset getRecordset(ServiceRequest request, String op, Class c, String sql, Connection conn) throws Exception { Recordset rs = new Recordset(); if (op.equals("list")) rs.setFetchSize(1000); rs.open(sql, conn); return rs; } //************************************************************************** //** get //************************************************************************** /** Used to retrieve an object from the database. Returns a JSON object. */ private ServiceResponse get(Class c, ServiceRequest request, Database database) { try{ //Compile sql statement HashMap<String, Object> tablesAndFields = getTableAndFields(c); String tableName = (String) tablesAndFields.get("tableName"); String sql = "select " + tableName + ".id from " + tableName + " where "; Long id = request.getID(); if (id==null){ String where = getWhere(request, tablesAndFields); if (where==null) return new ServiceResponse(404); else sql += where; } else{ sql += tableName + ".id=" + id; } //Apply filter try (Connection conn = database.getConnection()){ try (Recordset rs = getRecordset(request, "get", c, sql, conn)){ if (rs.EOF) id = null; else id = rs.getValue("id").toLong(); } } if (id==null) return new ServiceResponse(404); Object obj = newInstance(c, id); Method toJson = getMethod("toJson", c); return new ServiceResponse((JSONObject) toJson.invoke(obj)); } catch(Exception e){ return getServiceResponse(e); } } //************************************************************************** //** list //************************************************************************** /** Used to retrieve a shallow list of objects from the database. */ private ServiceResponse list(Class c, ServiceRequest request, Database database){ //Get tableName and fields associated with the Model HashMap<String, Object> tablesAndFields; HashSet<String> spatialFields; String tableName; try{ tablesAndFields = getTableAndFields(c); tableName = (String) tablesAndFields.get("tableName"); spatialFields = (HashSet<String>) tablesAndFields.get("spatialFields"); } catch(Exception e){ return getServiceResponse(e); } //Compile SQL statement StringBuilder sql = new StringBuilder(); sql.append(request.getSelectStatement(tableName)); sql.append(" from "); sql.append(tableName); String where = getWhere(request, tablesAndFields); if (where!=null){ sql.append(" where "); sql.append(where); } sql.append(request.getOrderByStatement()); sql.append(request.getOffsetLimitStatement(database.getDriver())); //console.log(sql); //Get output format String format = request.getParameter("format").toString(); if (format==null) format = ""; else format = format.toLowerCase(); //Excute query and generate response try (Connection conn = database.getConnection()){ try (Recordset rs = getRecordset(request, "list", c, sql.toString(), conn)){ ServiceResponse response; if (format.equals("csv")){ StringBuilder csv = new StringBuilder(); long x = 0; while (rs.next()){ if (x>0) csv.append("\r\n"); if (x==0){ int i = 0; for (javaxt.sql.Field field : rs.getFields()){ if (i>0) csv.append(","); csv.append(field.getName()); i++; } csv.append("\r\n"); } int i = 0; for (javaxt.sql.Field field : rs.getFields()){ if (i>0) csv.append(","); javaxt.sql.Value value = field.getValue(); if (!value.isNull()){ String val = value.toString(); if (val.contains("\"")) val = "\"" + val + "\""; csv.append(val); } i++; } x++; } response = new ServiceResponse(csv); response.setContentType("text/csv"); } else if (format.equals("json")){ StringBuilder json = new StringBuilder("["); long x = 0; while (rs.next()){ if (x>0) json.append(","); json.append(DbUtils.getJson(rs)); x++; } json.append("]"); response = new ServiceResponse(json.toString()); response.setContentType("application/json"); } else { long x = 0; JSONArray cols = new JSONArray(); StringBuilder json = new StringBuilder("{\"rows\":["); while (rs.next()){ JSONArray row = new JSONArray(); JSONObject record = DbUtils.getJson(rs); for (javaxt.sql.Field field : rs.getFields()){ String fieldName = field.getName().toLowerCase(); fieldName = StringUtils.underscoreToCamelCase(fieldName); if (x==0) cols.add(fieldName); JSONValue val = record.get(fieldName); if (!val.isNull()){ if (spatialFields.contains(fieldName)){ if (database.getDriver().equals("PostgreSQL")){ val = new JSONValue(createGeom(val.toString())); } } } row.add(val); } if (x>0) json.append(","); json.append(row.toString()); x++; } json.append("]"); json.append(",\"cols\":"); json.append(cols.toString()); json.append("}"); response = new ServiceResponse(json.toString()); response.setContentType("application/json"); } return response; } } catch(Exception e){ return getServiceResponse(e); } } //************************************************************************** //** save //************************************************************************** /** Used to create or update an object in the database. Returns the object * ID. */ private ServiceResponse save(Class c, ServiceRequest request, Database database) { try{ //Parse json JSONObject json = request.getJson(); if (json==null || json.isEmpty()) throw new Exception("JSON is empty."); Long id = json.get("id").toLong(); boolean isNew = id==null; //Apply filter HashMap<String, Object> tablesAndFields = getTableAndFields(c); String tableName = (String) tablesAndFields.get("tableName"); String sql = "select " + tableName + ".id from " + tableName + " where " + tableName + ".id=" + (id==null ? -1 : id); try (Connection conn = database.getConnection()){ try (Recordset rs = getRecordset(request, "save", c, sql, conn)){ if (rs.EOF) id = null; else id = rs.getValue("id").toLong(); } } if (id==null && !isNew) return new ServiceResponse(404); //Reparse json (json may have changed in getRecordset) json = request.getJson(); id = json.get("id").toLong(); isNew = id==null; //Create new instance of the class Object obj; if (id!=null){ obj = newInstance(c, id); Method update = c.getDeclaredMethod("update", JSONObject.class); update.invoke(obj, new Object[]{json}); } else{ obj = newInstance(c, json); isNew = true; } //Call the save method Method save = getMethod("save", c); save.invoke(obj); //Get id Method getID = getMethod("getID", c); id = (Long) getID.invoke(obj); if (id==null) return new ServiceResponse(500, "Failed to retrieve ID on save"); //Fire event if (isNew) onCreate(obj, request); else onUpdate(obj, request); //Return response return new ServiceResponse(id+""); } catch(Exception e){ return getServiceResponse(e); } } //************************************************************************** //** delete //************************************************************************** /** Used to delete an object in the database. Returns a 200 status code if * the object was successfully deleted. */ private ServiceResponse delete(Class c, ServiceRequest request, Database database) { try (Connection conn = database.getConnection()){ //Apply filter Long id = request.getID(); try (Recordset rs = getRecordset(request, "delete", c, "select id from " + getTableName(c.newInstance()) + " where id=" + id, conn)){ if (rs.EOF) id = null; else id = rs.getValue("id").toLong(); } if (id==null) return new ServiceResponse(404); //Reparse request to get ID (id may have changed in getRecordset) Long newID = request.getParameter("id").toLong(); if (newID!=null) id = newID; //Create new instance of the class Object obj = newInstance(c, id); //Delete object Method delete = getMethod("delete", c); delete.invoke(obj); //Fire event onDelete(obj, request); //Return response return new ServiceResponse(200); } catch(Exception e){ return getServiceResponse(e); } } public void onCreate(Object obj, ServiceRequest request){}; public void onUpdate(Object obj, ServiceRequest request){}; public void onDelete(Object obj, ServiceRequest request){}; //************************************************************************** //** getClass //************************************************************************** /** Returns a class from the list of known/supported classes for a given * class name. */ private DomainClass getClass(String className){ synchronized(classes){ return classes.get(className); } } //************************************************************************** //** getMethod //************************************************************************** /** Returns a declared (public) method defined in a given class. */ private Method getMethod(String name, Class clazz){ while (clazz != null) { Method[] methods = clazz.getDeclaredMethods(); for (Method method : methods) { if (method.getName().equals(name)) { return method; } } clazz = clazz.getSuperclass(); } return null; } //************************************************************************** //** newInstance //************************************************************************** /** Returns a new instance for a given class using an ID and a connection to * the database. */ private Object newInstance(Class c, long id) throws Exception { Constructor constructor = c.getDeclaredConstructor(new Class[]{Long.TYPE}); return constructor.newInstance(new Object[]{id}); } //************************************************************************** //** newInstance //************************************************************************** /** Returns a new instance for a given class using a JSON object. */ private Object newInstance(Class c, JSONObject json) throws Exception { Constructor constructor = c.getDeclaredConstructor(new Class[]{JSONObject.class}); return constructor.newInstance(new Object[]{json}); } //************************************************************************** //** getTableName //************************************************************************** /** Returns the "tableName" private variable associated with a model */ private String getTableName(Object obj) throws Exception { java.lang.reflect.Field field = obj.getClass().getSuperclass().getDeclaredField("tableName"); field.setAccessible(true); String tableName = (String) field.get(obj); return tableName; } //************************************************************************** //** getTableAndFields //************************************************************************** /** Returns the table name and fields associated with a model */ private HashMap<String, Object> getTableAndFields(Class c) throws Exception { String tableName; HashMap<String, String> fieldMap = new HashMap<>(); HashSet<String> stringFields = new HashSet<>(); HashSet<String> spatialFields = new HashSet<>(); Object obj = c.newInstance(); //maybe clone instead? //Get tableName java.lang.reflect.Field field = obj.getClass().getSuperclass().getDeclaredField("tableName"); field.setAccessible(true); tableName = (String) field.get(obj); //Get fieldMap field = obj.getClass().getSuperclass().getDeclaredField("fieldMap"); field.setAccessible(true); HashMap<String, String> map = (HashMap<String, String>) field.get(obj); Iterator<String> it = map.keySet().iterator(); while (it.hasNext()){ String fieldName = it.next(); String columnName = map.get(fieldName); fieldMap.put(fieldName, columnName); } fieldMap.put("id", "id"); //Get spatial fields for (java.lang.reflect.Field f : obj.getClass().getDeclaredFields()){ Class fieldType = f.getType(); String packageName = fieldType.getPackage()==null ? "" : fieldType.getPackage().getName(); if (packageName.startsWith("javaxt.geospatial.geometry") || packageName.startsWith("com.vividsolutions.jts.geom") || packageName.startsWith("org.locationtech.jts.geom")){ spatialFields.add(f.getName()); } if (fieldType.equals(String.class)){ stringFields.add(f.getName()); } } HashMap<String, Object> p = new HashMap<>(); p.put("tableName", tableName); p.put("fieldMap", fieldMap); p.put("stringFields", stringFields); p.put("spatialFields", spatialFields); return p; } //************************************************************************** //** getWhere //************************************************************************** /** Used to compile a where statement */ private String getWhere(ServiceRequest request, HashMap<String, Object> tablesAndFields){ String tableName = (String) tablesAndFields.get("tableName"); HashMap<String, String> fieldMap = (HashMap<String, String>) tablesAndFields.get("fieldMap"); HashSet<String> stringFields = (HashSet<String>) tablesAndFields.get("stringFields"); String where = null; Filter filter = request.getFilter(); if (!filter.isEmpty()){ ArrayList<String> arr = new ArrayList<>(); for (Filter.Item item : filter.getItems()){ String name = item.getField(); String op = item.getOperation(); String v = item.getValue().toString(); //Check if the column name is a function Field[] fields = request.getFields(name); Field field = null; if (fields!=null){ field = fields[0]; if (field.isFunction()){ arr.add("(" + item.toString() + ")"); continue; } } //Append table name to the column Iterator<String> it = fieldMap.keySet().iterator(); boolean foundField = false; while (it.hasNext()){ String fieldName = it.next(); String columnName = fieldMap.get(fieldName); if (name.equalsIgnoreCase(fieldName) || name.equalsIgnoreCase(columnName)){ foundField = true; if (v!=null && stringFields.contains(fieldName)){ if (!(v.startsWith("'") && v.endsWith("'"))){ v = "'" + v.replace("'","''") + "'"; } } arr.add("(" + tableName + "." + columnName + " " + op + " " + v + ")"); break; } } //If we're still here, append the filter "as is" if (!foundField){ //Set column name String col; if (field!=null) col = field.getColumn(); else col = StringUtils.camelCaseToUnderScore(name); //Update value if (v!=null && v.contains(" ")){ if (!(v.startsWith("'") && v.endsWith("'"))){ v = "'" + v.replace("'","''") + "'"; } } arr.add("(" + col + " " + op + " " + v + ")"); } } if (!arr.isEmpty()){ where = String.join(" and ", arr); } } else{ where = request.getWhere(); } return where; } //************************************************************************** //** getServiceResponse //************************************************************************** /** Returns a ServiceResponse for a given Exception. */ private ServiceResponse getServiceResponse(Exception e){ if (e instanceof java.lang.reflect.InvocationTargetException){ return new ServiceResponse(e.getCause()); } else if (e instanceof SecurityException){ return new ServiceResponse(403, "Not Authorized"); } else if (e instanceof IllegalArgumentException){ return new ServiceResponse(400, e.getMessage()); } else{ return new ServiceResponse(e); } } //************************************************************************** //** createGeom //************************************************************************** /** Used to create a geometry from a EWKT formatted string returned from * PostgreSQL/PostGIS */ public Object createGeom(String hex) throws Exception { //byte[] b = WKBReader.hexToBytes(hex); //return new WKBReader().read(b); Class c; try{ c = Class.forName("com.vividsolutions.jts.io.WKBReader"); } catch(ClassNotFoundException e){ try{ c = Class.forName("org.locationtech.jts.io.WKBReader"); } catch(ClassNotFoundException ex){ throw new Exception("JTS not found!"); } } Method hexToBytes, read; hexToBytes = read = null; for (Method method : c.getDeclaredMethods()) { String methodName = method.getName(); if (methodName.equals("hexToBytes")) { hexToBytes = method; } else if (methodName.equals("read")) { Parameter[] parameters = method.getParameters(); if (parameters.length==1 && parameters[0].getType().equals(byte[].class)){ read = method; } } } byte[] b = (byte[]) hexToBytes.invoke(null, new Object[]{hex}); return read.invoke(c.newInstance(), new Object[]{b}); } } |