package org.webpieces.router.impl.params;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.webpieces.ctx.api.HttpMethod;
import org.webpieces.ctx.api.RequestContext;
import org.webpieces.ctx.api.RouterRequest;
import org.webpieces.ctx.api.Validation;
import org.webpieces.router.api.BodyContentBinder;
import org.webpieces.router.api.EntityLookup;
import org.webpieces.router.api.ObjectStringConverter;
import org.webpieces.router.api.exceptions.ClientDataError;
import org.webpieces.router.api.exceptions.DataMismatchException;
import org.webpieces.router.api.exceptions.NotFoundException;
@Singleton
public class ParamToObjectTranslatorImpl {
//private static final Logger log = LoggerFactory.getLogger(ArgumentTranslator.class);
private ParamValueTreeCreator treeCreator;
private ObjectTranslator objectTranslator;
private Set<EntityLookup> lookupHooks;
@Inject
public ParamToObjectTranslatorImpl(ParamValueTreeCreator treeCreator, ObjectTranslator primitiveConverter) {
this.treeCreator = treeCreator;
this.objectTranslator = primitiveConverter;
}
//ok, here are a few different scenarios to consider
// 1. /user/{var1}/{var2}/{var3} Controller.method() and controller accesses RequestLocal.getRequest().getParams().get("var1");
// 2. /user/{var1}/{var2}/{var3} Controller.method(var1, var2, var3)
// 3. /user/{var1}?var2=xx&var3=yyy&cat=dog Controller.method(var1) and controller accesses RequestLocal.getRequest().getParams().get("var2");
// 4. /user/{var1}?var2=xx Controller.method(var2) and controller accesses RequestLocal.getRequest().getParams().get("var1");
// 5. /user?var1=xxx&var1=yyy Controller.method({xxx, yyy}) as an array
// 6. /user/{var1}/{var1}/{var1} We don't allow this and last one wins if they are different since outgoing they all have to be the same
//
//ON TOP of this, do you maintain a separate structure for params IN THE PATH /user/{var1} vs in the query params /user/{var1}?var1=xxx
//
//AND ON TOP of that, we have multi-part fields as well with keys and values
public List<Object> createArgs(Method m, RequestContext ctx, BodyContentBinder binder) {
RouterRequest req = ctx.getRequest();
try {
return createArgsImpl(m, ctx, binder);
} catch(DataMismatchException e) {
if(req.method == HttpMethod.GET) {
//For GET with query params or path urls, if we can't convert, it should be a 404...
//This is because a human user typed in the wrong url so they should get back not found
throw new NotFoundException(e);
} else {
//For POST with multipart, this should be a 500 because a human user does NOT type in post
//urls and instead the developer typed in the wrong url and an issue needs to be fixed(or
//some hacker is doing something so internal error there is fine as well)
if(req.multiPartFields.size() > 0)
throw new IllegalArgumentException(e);
else //for apis that POST, this is a client error(or developer error when testing)
throw new ClientDataError(e);
}
}
}
protected List<Object> createArgsImpl(Method method, RequestContext ctx, BodyContentBinder binder) {
RouterRequest req = ctx.getRequest();
Parameter[] paramMetas = method.getParameters();
Annotation[][] paramAnnotations = method.getParameterAnnotations();
ParamTreeNode paramTree = new ParamTreeNode();
//For multipart AND for query params such as ?var=xxx&var=yyy&var2=xxx AND for url path params /mypath/{var1}/account/{id}
//query params first
Map<String, String> queryParams = translate(req.queryParams);
treeCreator.createTree(paramTree, queryParams, FromEnum.QUERY_PARAM);
//next multi-part params
Map<String, String> multiPartParams = translate(req.multiPartFields);
treeCreator.createTree(paramTree, multiPartParams, FromEnum.FORM_MULTIPART);
//lastly path params
treeCreator.createTree(paramTree, ctx.getPathParams(), FromEnum.URL_PATH);
List<Object> result = new ArrayList<>();
for(int i = 0; i < paramMetas.length; i++) {
Parameter paramMeta = paramMetas[i];
Annotation[] annotations = paramAnnotations[i];
ParamMeta fieldMeta = new ParamMeta(method, paramMeta, annotations);
String name = fieldMeta.getName();
ParamNode paramNode = paramTree.get(name);
if(binder != null && isManagedBy(binder, fieldMeta)) {
Object bean = binder.unmarshal(fieldMeta.getFieldClass(), req.body.createByteArray());
result.add(bean);
} else {
Object arg = translate(req, method, paramNode, fieldMeta, ctx.getValidation());
result.add(arg);
}
}
return result;
}
private boolean isManagedBy(BodyContentBinder binder, ParamMeta fieldMeta) {
Class<?> fieldClass = fieldMeta.getFieldClass();
Annotation[] annotations = fieldMeta.getAnnotations();
for(Annotation anno : annotations) {
if(binder.isManaged(fieldClass, anno.annotationType()))
return true;
}
return false;
}
private Map<String, String> translate(Map<String, List<String>> queryParams) {
Map<String, String> newForm = new HashMap<>();
for(Map.Entry<String, List<String>> entry : queryParams.entrySet()) {
String key = entry.getKey();
List<String> value = entry.getValue();
if(value.size() == 1) {
newForm.put(key, value.get(0));
} else {
for(int i = 0; i < value.size(); i++) {
//put in proper form such that invoking PropertyUtils works...
String newKey = key+"["+i+"]";
newForm.put(newKey, value.get(i));
}
}
}
return newForm;
}
private Object translate(RouterRequest req, Method method, ParamNode valuesToUse, Meta fieldMeta, Validation validator) {
Class<?> fieldClass = fieldMeta.getFieldClass();
ObjectStringConverter<?> converter = objectTranslator.getConverter(fieldClass);
if(converter != null) {
return convert(req, method, valuesToUse, fieldMeta, converter, validator);
} else if(fieldClass.isArray()) {
throw new UnsupportedOperationException("not done yet...let me know and I will do it="+fieldMeta);
} else if(fieldClass.isEnum()) {
throw new UnsupportedOperationException("You need to install a "+ObjectStringConverter.class.getSimpleName()+" for this enum "+fieldMeta);
} else if(List.class.isAssignableFrom(fieldClass)) {
if(valuesToUse == null)
return null;
else if(valuesToUse instanceof ArrayNode) {
List<ParamNode> paramNodes = ((ArrayNode) valuesToUse).getList();
return createList(req, method, fieldMeta, validator, paramNodes);
} else if(valuesToUse instanceof ValueNode) {
List<ParamNode> paramNodes = new ArrayList<>();
paramNodes.add(valuesToUse);
return createList(req, method, fieldMeta, validator, paramNodes);
}
throw new IllegalArgumentException("Found List on field or param="+fieldMeta+" but did not find ArrayNode type");
} else if(valuesToUse instanceof ArrayNode) {
throw new IllegalArgumentException("Incoming array need a type List but instead found type="+fieldClass+" on field="+fieldMeta);
} else if(valuesToUse instanceof ValueNode) {
ValueNode v = (ValueNode) valuesToUse;
String fullName = v.getFullName();
throw new IllegalArgumentException("Could not convert incoming value="+v.getValue()+" of key name="+fullName+" field="+fieldMeta);
} else if(valuesToUse == null) {
fieldMeta.validateNullValue(); //validate if null is ok or not
return null;
} else if(!(valuesToUse instanceof ParamTreeNode)) {
throw new IllegalStateException("Bug, must be missing a case. v="+valuesToUse+" type to field="+fieldMeta);
}
ParamTreeNode tree = (ParamTreeNode) valuesToUse;
EntityLookup pluginLookup = fetchPluginLoader(fieldClass);
Object bean = null;
if(pluginLookup != null) {
bean = pluginLookup.find(fieldMeta, tree, c -> createBean(c));
if(bean == null)
throw new IllegalStateException("plugin="+pluginLookup.getClass()+" failed to create bean. This is a plugin bug");
} else
bean = createBean(fieldClass);
for(Map.Entry<String, ParamNode> entry: tree.entrySet()) {
String key = entry.getKey();
ParamNode value = entry.getValue();
Field field = findBeanFieldType(bean.getClass(), key, new ArrayList<>());
FieldMeta nextFieldMeta = new FieldMeta(field);
Object translatedValue = translate(req, method, value, nextFieldMeta, validator);
nextFieldMeta.setValueOnBean(bean, translatedValue);
}
return bean;
}
@SuppressWarnings("unchecked")
private Object createList(RouterRequest req, Method method, Meta fieldMeta, Validation validator, List<ParamNode> paramNodes) {
List<Object> list = new ArrayList<>();
ParameterizedType type = (ParameterizedType) fieldMeta.getParameterizedType();
Type[] actualTypeArguments = type.getActualTypeArguments();
Type type2 = actualTypeArguments[0];
@SuppressWarnings("rawtypes")
GenericMeta genMeta = new GenericMeta((Class) type2);
for(ParamNode node : paramNodes) {
Object bean = null;
if(node != null)
bean = translate(req, method, node, genMeta, validator);
list.add(bean);
}
return list;
}
private Field findBeanFieldType(Class<?> beanType, String key, List<String> classList) {
classList.add(beanType.getName());
Field[] fields = beanType.getDeclaredFields();
for(Field f : fields) {
if(key.equals(f.getName())) {
return f;
}
}
Class<?> superclass = beanType.getSuperclass();
if(superclass == null)
throw new IllegalArgumentException("Field with name="+key+" not found in any of the classes="+classList);
return findBeanFieldType(superclass, key, classList);
}
private <T> T createBean(Class<T> paramTypeToCreate) {
try {
return paramTypeToCreate.newInstance();
} catch (InstantiationException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
@SuppressWarnings("rawtypes")
private Object convert(RouterRequest req, Method method, ParamNode valuesToUse, Meta fieldMeta, ObjectStringConverter converter, Validation validator) {
Class<?> paramTypeToCreate = fieldMeta.getFieldClass();
if(fieldMeta instanceof ParamMeta) {
//for params only not fields as with fields, we just don't set the field and skip it...before we call a method,
//we MUST have a value to set
checkForBadNullToPrimitiveConversion(req, valuesToUse, fieldMeta, method);
}
if(valuesToUse == null)
return null;
if(!(valuesToUse instanceof ValueNode))
throw new IllegalArgumentException("method takes param type="+paramTypeToCreate+" but complex structure found");
ValueNode node = (ValueNode) valuesToUse;
String value = node.getValue();
try {
return converter.stringToObject(value);
} catch(Exception e) {
if(node.getFrom() == FromEnum.FORM_MULTIPART) {
validator.addError(node.getFullKeyName(), "Could not convert value");
return null;
} else
//This should be a 404 in production if the url is bad...
throw new NotFoundException("The method='"+method+"' requires that the parameter or field '"+fieldMeta+"' be of type="
+paramTypeToCreate+" but the request contained a value that could not be converted="+value);
}
}
private void checkForBadNullToPrimitiveConversion(RouterRequest req, ParamNode valuesToUse, Meta fieldMeta,
Method method) {
Class<?> paramTypeToCreate2 = fieldMeta.getFieldClass();
if(paramTypeToCreate2.isPrimitive()) {
if(valuesToUse == null) {
String s = "The method='"+method+"' requires that "+fieldMeta+" be of type="
+paramTypeToCreate2+" but the request did not contain any value in query params, path "
+ "params nor multi-part form fields with a value and we can't convert null to a primitive";
throw new DataMismatchException(s);
}
}
}
private EntityLookup fetchPluginLoader(Class<?> paramTypeToCreate) {
for(EntityLookup lookup : lookupHooks) {
if(lookup.isManaged(paramTypeToCreate))
return lookup;
}
return null;
}
public void install(Set<EntityLookup> lookupHooks) {
this.lookupHooks = lookupHooks;
}
}