/**
*
* Copyright
* 2009-2015 Jayway Products AB
* 2016-2017 Föreningen Sambruk
*
* Licensed under AGPL, Version 3.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.gnu.org/licenses/agpl.txt
*
* 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 se.streamsource.dci.restlet.server;
import org.qi4j.api.common.Optional;
import org.qi4j.api.composite.TransientBuilder;
import org.qi4j.api.composite.TransientComposite;
import org.qi4j.api.constraint.Name;
import org.qi4j.api.entity.EntityComposite;
import org.qi4j.api.entity.association.ManyAssociation;
import org.qi4j.api.injection.scope.Service;
import org.qi4j.api.injection.scope.Structure;
import org.qi4j.api.injection.scope.Uses;
import org.qi4j.api.object.ObjectBuilder;
import org.qi4j.api.specification.Specification;
import org.qi4j.api.unitofwork.EntityTypeNotFoundException;
import org.qi4j.api.unitofwork.NoSuchEntityException;
import org.qi4j.api.value.ValueBuilder;
import org.qi4j.api.value.ValueComposite;
import org.qi4j.spi.Qi4jSPI;
import org.qi4j.spi.property.PropertyDescriptor;
import org.qi4j.spi.structure.ModuleSPI;
import org.qi4j.spi.value.ValueDescriptor;
import org.restlet.Request;
import org.restlet.Response;
import org.restlet.Uniform;
import org.restlet.data.Form;
import org.restlet.data.Language;
import org.restlet.data.Preference;
import org.restlet.data.Reference;
import org.restlet.data.Status;
import org.restlet.representation.EmptyRepresentation;
import org.restlet.representation.Representation;
import org.restlet.resource.ResourceException;
import se.streamsource.dci.api.InteractionConstraints;
import se.streamsource.dci.api.RoleMap;
import se.streamsource.dci.api.SkipResourceValidityCheck;
import se.streamsource.dci.restlet.server.api.ResourceValidity;
import se.streamsource.dci.restlet.server.api.SubResource;
import se.streamsource.dci.restlet.server.api.SubResources;
import se.streamsource.dci.value.ResourceValue;
import se.streamsource.dci.value.link.LinkValue;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static org.qi4j.api.util.Annotations.*;
import static org.qi4j.api.util.Iterables.*;
/**
* JAVADOC
*/
public class CommandQueryResource
implements Uniform
{
private static final String ARGUMENTS = "arguments";
private Map<String, Class> interactionClasses = new ConcurrentHashMap<String, Class>();
private Map<String, Method> resourceMethods = new ConcurrentHashMap<String, Method>();
private Map<String, Method> contextMethods = new ConcurrentHashMap<String, Method>();
private
@Structure
Qi4jSPI spi;
protected
@Structure
ModuleSPI module;
private
@Service
ResponseWriterDelegator resultWriter;
@Service
RequestReaderDelegator requestReader;
private
@Service
InteractionConstraints constraints;
private
@Optional
@Service
ResultConverter converter;
private
@Uses
CommandQueryRestlet restlet;
private Class[] contextClasses;
public CommandQueryResource(@Uses Class... contextClasses)
{
this.contextClasses = contextClasses;
// Resource method mappings
for (Method method : getClass().getMethods())
{
if (CommandQueryResource.class.isAssignableFrom(method.getDeclaringClass()) && !CommandQueryResource.class.equals(method.getDeclaringClass()))
{
Method oldMethod = resourceMethods.put(method.getName().toLowerCase(), method);
if (oldMethod != null)
{
throw new IllegalStateException("Two methods in resource "+getClass().getName()+" with same name "+oldMethod.getName()+", which is not allowed");
}
}
}
// Context method mappings
for (Class contextClass : contextClasses)
{
for (Method method : contextClass.getMethods())
{
if (!method.isSynthetic())
{
if (!interactionClasses.containsKey(method.getName().toLowerCase()))
{
interactionClasses.put(method.getName().toLowerCase(), contextClass);
contextMethods.put(method.getName().toLowerCase(), method);
}
}
}
}
}
// Uniform implementation
public final void handle(Request request, Response response)
{
RoleMap roleMap = RoleMap.current();
// Check constraints for this resource
if (!constraints.isValid(getClass(), roleMap, module))
{
throw new ResourceException(Status.CLIENT_ERROR_FORBIDDEN);
}
RoleMap.setCurrentRoleMap(new RoleMap(roleMap));
// Find remaining segments
List<String> segments = getSegments();
if (segments.size() > 0)
{
String segment = segments.remove(0);
if (segments.size() > 0)
{
handleSubResource(segment);
} else
{
handleResource(segment);
}
}
}
// API methods
protected void setResourceValidity(EntityComposite entity)
{
ResourceValidity validity = new ResourceValidity(entity, spi);
RoleMap.current().set(validity);
}
protected <T> T context(Class<T> contextClass)
{
if (TransientComposite.class.isAssignableFrom(contextClass))
{
TransientBuilder<T> builder = module.transientBuilderFactory().newTransientBuilder(contextClass);
for (Object rolePlayer : RoleMap.current().getAll(Object.class))
{
builder.use(rolePlayer);
}
return builder.newInstance();
} else
{
ObjectBuilder<T> builder = module.objectBuilderFactory().newObjectBuilder(contextClass);
for (Object rolePlayer : RoleMap.current().getAll(Object.class))
{
builder.use(rolePlayer);
}
return builder.newInstance();
}
}
protected void subResource(Class<? extends CommandQueryResource> subResourceClass)
{
restlet.subResource(subResourceClass);
}
protected void subResourceContexts(Class<?>... contextClasses)
{
restlet.subResourceContexts(contextClasses);
}
protected <T> T setRole(Class<T> entityClass, String id, Class... roleClasses)
throws ResourceException
{
try
{
T composite = module.unitOfWorkFactory().currentUnitOfWork().get(entityClass, id);
RoleMap.current().set(composite, roleClasses);
return composite;
} catch (EntityTypeNotFoundException e)
{
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
} catch (NoSuchEntityException e)
{
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
}
}
protected <T> T findManyAssociation(ManyAssociation<T> manyAssociation, String id)
throws ResourceException
{
for (T entity : manyAssociation)
{
if (entity.toString().equals(id))
{
RoleMap.current().set(entity);
return entity;
}
}
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
}
protected void findList(List<?> list, String indexString)
{
Integer index = Integer.decode(indexString);
if (index < 0 || index >= list.size())
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
RoleMap.current().set(index, Integer.class);
Object value = list.get(index);
RoleMap.current().set(value);
}
protected Locale getLocale()
{
Request request = Request.getCurrent();
List<Preference<Language>> preferenceList = request.getClientInfo().getAcceptedLanguages();
if (preferenceList.isEmpty())
return Locale.getDefault();
Language language = preferenceList
.get( 0 ).getMetadata();
String[] localeStr = language.getName().split( "-" );
Locale locale;
switch (localeStr.length)
{
case 1:
locale = new Locale( localeStr[0] );
break;
case 2:
locale = new Locale( localeStr[0], localeStr[1] );
break;
case 3:
locale = new Locale( localeStr[0], localeStr[1], localeStr[2] );
break;
default:
locale = Locale.getDefault();
}
return locale;
}
// Private implementation
private void handleSubResource(String segment)
{
if (this instanceof SubResources)
{
SubResources subResources = (SubResources) this;
try
{
StringBuilder template = (StringBuilder) Request.getCurrent().getAttributes().get("template");
template.append("resource/");
subResources.resource(URLDecoder.decode(segment, "UTF-8"));
} catch (UnsupportedEncodingException e)
{
subResources.resource(segment);
}
} else
{
// Find @SubResource annotated method
try
{
Method method = getSubResourceMethod(segment);
StringBuilder template = (StringBuilder) Request.getCurrent().getAttributes().get("template");
template.append(segment).append("/");
method.invoke(this);
} catch (Throwable e)
{
ResourceExceptionHelper.handleException( getClass(), Response.getCurrent(), e);
}
}
}
private Method getSubResourceMethod(String resourceName)
{
for (Method method : getClass().getMethods())
{
if (method.getName().equalsIgnoreCase(resourceName) && method.getAnnotation(SubResource.class) != null)
return method;
}
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
}
private void resource()
{
if (Request.getCurrent().getMethod().equals(org.restlet.data.Method.GET))
{
RoleMap roleMap = RoleMap.current();
final List<Method> queries = new ArrayList<Method>();
final List<Method> commands = new ArrayList<Method>();
final List<Method> subResources = new ArrayList<Method>();
// Add commands+queries from the context classes
for (Class contextClass : contextClasses)
{
// Check context class constraints
if (!constraints.isValid(contextClass, roleMap, module))
continue; // Skip this class entirely
// Filter out methods first.
// TODO Cache this
Iterable<Method> methods = filter(new Specification<Method>()
{
public boolean satisfiedBy(Method method)
{
return !method.isSynthetic() &&
!(method.getDeclaringClass().isAssignableFrom(TransientComposite.class)) &&
!(method.getName().equals("isValid")) && !(method.getName().equals("bind"));
}
}, iterable(contextClass.getMethods()));
for (Method method : methods)
{
if (constraints.isValid(method, roleMap, module))
if (isCommand(method))
{
commands.add(method);
} else
{
queries.add(method);
}
}
}
// Add subresources available from this resource
if (!SubResources.class.isAssignableFrom(getClass()))
{
Iterable<Method> methods = Arrays.asList(getClass().getMethods());
for (Method method : methods)
{
if (method.getAnnotation(SubResource.class) != null && constraints.isValid(method, roleMap, module))
subResources.add(method);
}
}
ValueBuilder<ResourceValue> builder = module.valueBuilderFactory().newValueBuilder(ResourceValue.class);
ValueBuilder<LinkValue> linkBuilder = module.valueBuilderFactory().newValueBuilder(LinkValue.class);
LinkValue prototype = linkBuilder.prototype();
if (queries.size() > 0)
{
List<LinkValue> queriesProperty = builder.prototype().queries().get();
prototype.classes().set("query");
for (Method query : queries)
{
prototype.text().set(humanReadable(query.getName()));
prototype.href().set(query.getName().toLowerCase());
prototype.rel().set(query.getName().toLowerCase());
prototype.id().set(query.getName().toLowerCase());
queriesProperty.add(linkBuilder.newInstance());
}
}
if (commands.size() > 0)
{
List<LinkValue> commandsProperty = builder.prototype().commands().get();
prototype.classes().set("command");
for (Method command : commands)
{
prototype.text().set(humanReadable(command.getName()));
prototype.href().set(command.getName().toLowerCase());
prototype.rel().set(command.getName().toLowerCase());
prototype.id().set(command.getName().toLowerCase());
commandsProperty.add(linkBuilder.newInstance());
}
}
if (subResources.size() > 0)
{
List<LinkValue> resourcesProperty = builder.prototype().resources().get();
prototype.classes().set("resource");
for (Method subResource : subResources)
{
prototype.text().set(humanReadable(subResource.getName()));
prototype.href().set(subResource.getName().toLowerCase() + "/");
prototype.rel().set(subResource.getName().toLowerCase());
prototype.id().set(subResource.getName().toLowerCase());
resourcesProperty.add(linkBuilder.newInstance());
}
}
try
{
Object index = convert(invokeResource(getInteractionMethod("index")));
if (index != null && index instanceof ValueComposite)
{
builder.prototype().index().set((ValueComposite) index);
}
} catch (Throwable e)
{
// Ignore
}
try
{
resultWriter.write(builder.newInstance(), Response.getCurrent());
} catch (Throwable e)
{
ResourceExceptionHelper.handleException( getClass(), Response.getCurrent(), e);
}
} else
{
Response.getCurrent().setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED);
}
}
private boolean isCommand(Method method)
{
return method.getReturnType().equals(Void.TYPE) || method.getName().equals("create");
}
/**
* Transform a Java name to a human readable string by replacing uppercase characters
* with space+toLowerCase(char)
* Example:
* changeDescription -> Change description
* doStuffNow -> Do stuff now
*
* @param name
* @return
*/
private String humanReadable(String name)
{
StringBuilder humanReadableString = new StringBuilder();
for (int i = 0; i < name.length(); i++)
{
char character = name.charAt(i);
if (i == 0)
{
// Capitalize first character
humanReadableString.append(Character.toUpperCase(character));
} else if (Character.isLowerCase(character))
{
humanReadableString.append(character);
} else
{
humanReadableString.append(' ').append(Character.toLowerCase(character));
}
}
return humanReadableString.toString();
}
private Object createContext(final String interactionName)
{
Class contextClass = interactionClasses.get(interactionName);
if (contextClass == null)
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
return context(contextClass);
}
private void result(Object resultValue) throws Exception
{
if (resultValue != null)
{
if (!resultWriter.write(resultValue, Response.getCurrent()))
{
throw new ResourceException(Status.SERVER_ERROR_INTERNAL, "No result writer for type " + resultValue.getClass().getName());
}
}
}
private Object invoke(Object target, Method method, Method contextMethod) throws Throwable
{
Object[] arguments;
// Check context class constraints
if (!constraints.isValid(contextMethod.getDeclaringClass(), RoleMap.current(), module))
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
// Check method constraints
if (!constraints.isValid(contextMethod, RoleMap.current(), module))
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
if (isCommand(contextMethod))
{
// Command
// Create argument
arguments = requestReader.readRequest(Request.getCurrent(), method);
Request.getCurrent().getAttributes().put(ARGUMENTS, arguments);
// Invoke method
try
{
method.invoke(target, arguments);
return null; // TODO Get events here
} catch (IllegalAccessException e)
{
throw e;
} catch (IllegalArgumentException e)
{
throw e;
} catch (InvocationTargetException e)
{
throw e.getCause();
}
} else
{
// Query
// Create argument
if (method.getParameterTypes().length > 0)
{
try
{
arguments = requestReader.readRequest(Request.getCurrent(), method);
if (arguments == null)
{
// Show form
return formForMethod(method);
}
} catch (IllegalArgumentException e)
{
// Still missing some values - show form
return formForMethod(method);
}
} else
{
// No arguments to this query
arguments = new Object[0];
}
// Invoke method
try
{
Request.getCurrent().getAttributes().put(ARGUMENTS, arguments);
return method.invoke(target, arguments);
} catch (IllegalAccessException e)
{
throw e;
} catch (IllegalArgumentException e)
{
throw e;
} catch (InvocationTargetException e)
{
throw e.getCause();
}
}
}
private Method getInteractionMethod(String methodName) throws ResourceException
{
Method method = contextMethods.get(methodName);
if (method == null)
throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND);
return method;
}
private List<String> getSegments()
{
return (List<String>) Request.getCurrent().getAttributes().get("segments");
}
private void handleResource(String segment)
{
if (segment.equals("") || segment.equals("."))
{
StringBuilder template = (StringBuilder) Request.getCurrent().getAttributes().get("template");
template.append("resource");
// Index for this resource
resource();
} else
{
StringBuilder template = (StringBuilder) Request.getCurrent().getAttributes().get("template");
template.append(segment);
Method contextMethod = null;
try
{
contextMethod = getInteractionMethod(segment);
} catch (ResourceException e)
{
// Not found as interaction, try SubResource
Method resourceMethod = resourceMethods.get(segment);
if (resourceMethod != null && resourceMethod.getAnnotation(SubResource.class) != null)
{
Response.getCurrent().setStatus(Status.REDIRECTION_FOUND);
Response.getCurrent().setLocationRef(new Reference(Request.getCurrent().getResourceRef().toString()+"/").toString());
return;
} else
throw e;
}
if (isCommand(contextMethod))
{
handleCommand(contextMethod);
} else
{
handleQuery(contextMethod);
}
}
}
private void handleCommand(Method contextMethod)
{
// Check if this is a request to show the form for this command
if (shouldShowCommandForm(contextMethod))
{
// Show form
Request.getCurrent().setMethod(org.restlet.data.Method.POST);
try
{
result(formForMethod(contextMethod));
} catch (Exception e)
{
ResourceExceptionHelper.handleException( getClass(), Response.getCurrent(), e);
}
} else
{
try
{
// Check timestamps
ResourceValidity validity = RoleMap.role(ResourceValidity.class);
validity.checkRequest(Request.getCurrent());
} catch (IllegalArgumentException e)
{
// Ignore
}
// We have input data - do command
try
{
Object result = invokeResource(contextMethod);
if (result != null)
{
if (result instanceof Representation)
{
Response.getCurrent().setEntity((Representation) result);
} else
result(convert(result));
}
} catch (Throwable e)
{
ResourceExceptionHelper.handleException( getClass(), Response.getCurrent(), e);
}
}
}
private Object invokeResource(Method contextMethod) throws Throwable
{
Method method = resourceMethods.get(contextMethod.getName().toLowerCase());
if (method != null)
return invoke(this, method, contextMethod); // Invoke on resource
else
{
Object context = createContext(contextMethod.getName().toLowerCase());
return invoke(context, contextMethod, contextMethod); // Invoke directly on context
}
}
private boolean shouldShowCommandForm(Method contextMethod)
{
// Show form on GET/HEAD
if (Request.getCurrent().getMethod().isSafe())
return true;
if (contextMethod.getParameterTypes().length > 0)
{
return !(contextMethod.getParameterTypes()[0].equals(Response.class) || Request.getCurrent().getEntity().isAvailable() || Request.getCurrent().getEntityAsText() != null || Request.getCurrent().getResourceRef().getQuery() != null);
}
return false;
}
private void handleQuery(Method contextMethod)
{
// Query
// Check if this is a Request.getCurrent() to show the form for this interaction
if ((Request.getCurrent().getMethod().isSafe() && contextMethod.getParameterTypes().length != 0 && Request.getCurrent().getResourceRef().getQuery() == null) ||
(!Request.getCurrent().getMethod().isSafe() && contextMethod.getParameterTypes().length != 0 && !(Request.getCurrent().getEntity().isAvailable() || Request.getCurrent().getResourceRef().getQuery() != null || contextMethod.getParameterTypes()[0].equals(Response.class))))
{
// Show form
try
{
result(formForMethod(contextMethod));
} catch (Exception e)
{
ResourceExceptionHelper.handleException( getClass(), Response.getCurrent(), e);
}
} else
{
if (contextMethod.getAnnotation( SkipResourceValidityCheck.class ) == null)
{
try
{
// Check timestamps
ResourceValidity validity = RoleMap.role( ResourceValidity.class );
validity.checkRequest( Request.getCurrent() );
} catch (IllegalArgumentException e)
{
// Ignore
}
}
// We have input data - do query
try
{
Object result = invokeResource(contextMethod);
if (result != null)
{
if (result instanceof Representation)
{
Response.getCurrent().setEntity((Representation) result);
} else
result(convert(result));
}
} catch (IllegalAccessException e)
{
Response.getCurrent().setStatus(Status.CLIENT_ERROR_NOT_FOUND);
} catch (Throwable e)
{
ResourceExceptionHelper.handleException( getClass(), Response.getCurrent(), e);
}
}
}
private Object convert( Object result )
{
if (converter != null)
result = converter.convert(result, Request.getCurrent(), (Object[]) Request.getCurrent().getAttributes().get(ARGUMENTS));
return result;
}
private Form formForMethod(Method contextMethod)
{
Form form = new Form();
Form queryAsForm = Request.getCurrent().getResourceRef().getQueryAsForm();
Form entityAsForm = null;
Representation representation = Request.getCurrent().getEntity();
if (representation != null && !EmptyRepresentation.class.isInstance(representation))
{
entityAsForm = new Form(representation);
} else
entityAsForm = new Form();
Class valueType = contextMethod.getParameterTypes()[0];
if (ValueComposite.class.isAssignableFrom(valueType))
{
ValueDescriptor valueDescriptor = module.valueDescriptor(valueType.getName());
for (PropertyDescriptor propertyDescriptor : valueDescriptor.state().properties())
{
String value = getValue(propertyDescriptor.qualifiedName().name(), queryAsForm, entityAsForm);
if (value == null && propertyDescriptor.initialValue() != null)
value = propertyDescriptor.initialValue().toString();
form.add(propertyDescriptor.qualifiedName().name(), value);
}
} else if (valueType.isInterface() && contextMethod.getParameterTypes().length == 1)
{
// Single entity as input
form.add("entity", getValue("entity", queryAsForm, entityAsForm));
} else
{
// Construct form out of individual parameters instead
int idx = 0;
for (Annotation[] annotations : contextMethod.getParameterAnnotations())
{
Name name = (Name) first(filter(isType(Name.class), iterable(annotations)));
String value = getValue(name.value(), queryAsForm, entityAsForm);
String paramName;
if (name != null)
{
paramName = name.value();
} else
{
paramName = "param" + idx;
}
form.add(paramName, value);
idx++;
}
}
return form;
}
private String getValue(String name, Form queryAsForm, Form entityAsForm)
{
String value = queryAsForm.getFirstValue(name);
if (value == null)
value = entityAsForm.getFirstValue(name);
return value;
}
}