/*
* Copyright (c) 2012-2016, Inversoft Inc., All Rights Reserved
*
* Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0
*
* 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 org.primeframework.mvc.action.config;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.primeframework.jwt.domain.JWT;
import org.primeframework.mvc.PrimeException;
import org.primeframework.mvc.action.ExecuteMethodConfiguration;
import org.primeframework.mvc.action.JWTMethodConfiguration;
import org.primeframework.mvc.action.ValidationMethodConfiguration;
import org.primeframework.mvc.action.annotation.Action;
import org.primeframework.mvc.action.result.annotation.ResultAnnotation;
import org.primeframework.mvc.action.result.annotation.ResultContainerAnnotation;
import org.primeframework.mvc.control.form.annotation.FormPrepareMethod;
import org.primeframework.mvc.parameter.annotation.PostParameterMethod;
import org.primeframework.mvc.parameter.annotation.PreParameter;
import org.primeframework.mvc.parameter.annotation.PreParameterMethod;
import org.primeframework.mvc.parameter.fileupload.annotation.FileUpload;
import org.primeframework.mvc.scope.ScopeField;
import org.primeframework.mvc.scope.annotation.ScopeAnnotation;
import org.primeframework.mvc.security.annotation.AnonymousAccess;
import org.primeframework.mvc.security.annotation.JWTAuthorizeMethod;
import org.primeframework.mvc.servlet.HTTPMethod;
import org.primeframework.mvc.util.ReflectionUtils;
import org.primeframework.mvc.util.URIBuilder;
import org.primeframework.mvc.validation.Validation;
import org.primeframework.mvc.validation.ValidationMethod;
import org.primeframework.mvc.validation.annotation.PostValidationMethod;
import org.primeframework.mvc.validation.annotation.PreValidationMethod;
import com.google.inject.Inject;
/**
* Default action configuration builder.
*
* @author Brian Pontarelli
*/
public class DefaultActionConfigurationBuilder implements ActionConfigurationBuilder {
private final Set<ActionConfigurator> configurators;
private final URIBuilder uriBuilder;
@Inject
public DefaultActionConfigurationBuilder(URIBuilder uriBuilder, Set<ActionConfigurator> configurators) {
this.uriBuilder = uriBuilder;
this.configurators = configurators;
}
/**
* Builds the action configuration using the class.
*
* @param actionClass The action class.
* @return The action configuration.
*/
@Override
public ActionConfiguration build(Class<?> actionClass) {
if ((actionClass.getModifiers() & Modifier.ABSTRACT) != 0) {
throw new PrimeException("The action class [" + actionClass + "] is annotated with the @Action annotation but is " +
"abstract. You can only annotate concrete action classes");
}
String uri = uriBuilder.build(actionClass);
Map<HTTPMethod, ExecuteMethodConfiguration> executeMethods = findExecuteMethods(actionClass);
List<Method> formPrepareMethods = ReflectionUtils.findAllMethodsWithAnnotation(actionClass, FormPrepareMethod.class);
List<Method> preParameterMethods = ReflectionUtils.findAllMethodsWithAnnotation(actionClass, PreParameterMethod.class);
List<Method> postParameterMethods = ReflectionUtils.findAllMethodsWithAnnotation(actionClass, PostParameterMethod.class);
List<Method> preValidationMethods = ReflectionUtils.findAllMethodsWithAnnotation(actionClass, PreValidationMethod.class);
List<Method> postValidationMethods = ReflectionUtils.findAllMethodsWithAnnotation(actionClass, PostValidationMethod.class);
Map<String, Annotation> resultAnnotations = findResultConfigurations(actionClass);
Map<String, PreParameter> preParameterMembers = ReflectionUtils.findAllMembersWithAnnotation(actionClass, PreParameter.class);
Map<String, FileUpload> fileUploadMembers = ReflectionUtils.findAllMembersWithAnnotation(actionClass, FileUpload.class);
Set<String> memberNames = ReflectionUtils.findAllMembers(actionClass);
Map<HTTPMethod, List<ValidationMethodConfiguration>> validationMethods = findValidationMethods(actionClass);
Map<HTTPMethod, List<JWTMethodConfiguration>> jwtAuthorizationMethods = findJwtAuthorizationMethods(actionClass, executeMethods);
List<ScopeField> scopeFields = findScopeFields(actionClass);
Map<Class<?>, Object> additionalConfiguration = getAdditionalConfiguration(actionClass);
return new ActionConfiguration(actionClass, executeMethods, validationMethods, formPrepareMethods, jwtAuthorizationMethods,
postValidationMethods, preParameterMethods, postParameterMethods, resultAnnotations, preParameterMembers, fileUploadMembers,
memberNames, scopeFields, additionalConfiguration, uri, preValidationMethods);
}
/**
* Adds the result annotation to the map and handles throwing an exception if there are duplicates.
*
* @param actionClass The action class.
* @param resultConfigurations The result configurations Map.
* @param annotation The annotation to check and add.
*/
protected void addResultConfiguration(Class<?> actionClass, Map<String, Annotation> resultConfigurations,
Annotation annotation, Class<? extends Annotation> annotationType) {
try {
String code = (String) annotation.getClass().getMethod("code").invoke(annotation);
if (resultConfigurations.containsKey(code)) {
throw new PrimeException("The action class [" + actionClass + "] contains two or more result annotations for " +
"the code [" + code + "]");
}
resultConfigurations.put(code, annotation);
} catch (NoSuchMethodException e) {
throw new PrimeException("The result annotation [" + annotationType + "] is missing a method named [code] that " +
"returns a String. For example:\n\n" +
"public @interface MyResult {\n" +
" String code() default \"success\";\n" +
"}", e);
} catch (InvocationTargetException | IllegalAccessException e) {
throw new PrimeException("Unable to invoke the code() method on the result annotation container [" +
annotationType + "]", e);
}
}
/**
* Adds all the result annotations for the given class.
*
* @param actionClass The action class.
*/
protected Map<String, Annotation> addResultsForClass(Class<?> actionClass) {
Map<String, Annotation> resultConfigurations = new HashMap<>();
Annotation[] annotations = actionClass.getAnnotations();
for (Annotation annotation : annotations) {
Class<? extends Annotation> annotationType = annotation.annotationType();
ResultAnnotation resultAnnotation = annotationType.getAnnotation(ResultAnnotation.class);
if (resultAnnotation != null) {
addResultConfiguration(actionClass, resultConfigurations, annotation, annotationType);
} else if (annotationType.isAnnotationPresent(ResultContainerAnnotation.class)) {
// There are multiple annotations inside the value
try {
Annotation[] results = (Annotation[]) annotation.getClass().getMethod("value").invoke(annotation);
for (Annotation result : results) {
annotationType = result.annotationType();
addResultConfiguration(actionClass, resultConfigurations, result, annotationType);
}
} catch (NoSuchMethodException e) {
throw new PrimeException("The result annotation container [" + annotationType + "] must have a method named " +
"[value] that is an array of result annotations. For example:\n\n" +
"public @interface MyContainer {\n" +
" MyResult[] value();\n" +
"}", e);
} catch (InvocationTargetException | IllegalAccessException e) {
throw new PrimeException("Unable to invoke the value() method on the result annotation container [" +
annotationType + "]", e);
}
}
}
return resultConfigurations;
}
/**
* Locates all the valid execute methods on the action.
*
* @param actionClass The action class.
* @return The execute methods Map.
*/
protected Map<HTTPMethod, ExecuteMethodConfiguration> findExecuteMethods(Class<?> actionClass) {
Method defaultMethod = null;
try {
defaultMethod = actionClass.getMethod("execute");
} catch (NoSuchMethodException e) {
// Ignore
}
Map<HTTPMethod, ExecuteMethodConfiguration> executeMethods = new HashMap<>();
for (HTTPMethod httpMethod : HTTPMethod.values()) {
Method method = null;
try {
method = actionClass.getMethod(httpMethod.name().toLowerCase());
} catch (NoSuchMethodException e) {
// Ignore
}
// Handle HEAD requests using a GET
if (method == null && httpMethod == HTTPMethod.HEAD) {
try {
method = actionClass.getMethod("get");
} catch (NoSuchMethodException e) {
// Ignore
}
}
if (method == null) {
method = defaultMethod;
}
if (method != null) {
verify(method);
executeMethods.put(httpMethod, new ExecuteMethodConfiguration(httpMethod, method, method.getAnnotation(Validation.class)));
}
}
if (executeMethods.isEmpty()) {
throw new PrimeException("The action class [" + actionClass + "] is missing at least one valid execute method. " +
"The class can define execute methods with the same names as the HTTP methods (lowercased) or a default execute " +
"method named [execute]. For example:\n\n" +
"public String execute() {\n" +
" return \"success\"\n" +
"}\n\n" +
"or\n\n" +
"public String post() {\n" +
" return \"success\"\n" +
"}");
}
return executeMethods;
}
protected Map<HTTPMethod, List<JWTMethodConfiguration>> findJwtAuthorizationMethods(Class<?> actionClass, Map<HTTPMethod, ExecuteMethodConfiguration> executeMethods) {
// When JWT is not enabled, we will not call any of the JWT Authorization Methods.
if (!actionClass.getAnnotation(Action.class).jwtEnabled()) {
return Collections.emptyMap();
}
List<Method> methods = ReflectionUtils.findAllMethodsWithAnnotation(actionClass, JWTAuthorizeMethod.class);
if (methods.isEmpty()) {
throw new PrimeException("The action class [" + actionClass + "] is missing at a JWT Authorization method. " +
"The class must define a one or more methods annotated " + JWTAuthorizeMethod.class.getSimpleName() + " when [jwtEnabled] is set to [true].");
}
// Return type must be Boolean or boolean
if (methods.stream().anyMatch(m -> m.getReturnType() != Boolean.TYPE && m.getReturnType() != Boolean.class)) {
throw new PrimeException("The action class [" + actionClass + "] has at least one JWT Authorization method that has declared a return "
+ "type of something other than boolean. Your method annotated with " + JWTAuthorizeMethod.class.getSimpleName() + " must declare a return type"
+ "of boolean or Boolean.");
}
// Must take a single parameter for a JWT
if (methods.stream().anyMatch(m -> m.getParameterCount() != 1 || m.getParameterTypes()[0] != JWT.class)) {
throw new PrimeException("The action class [" + actionClass + "] has at least one JWT Authorization method that has not declared the correct method "
+ "signature. Your method annotated with " + JWTAuthorizeMethod.class.getSimpleName() + " must declare a single method parameter of type JWT.");
}
// Map HTTP method to a list of JWT Authorize Methods.
Map<HTTPMethod, List<JWTMethodConfiguration>> jwtMethods = new HashMap<>();
methods.stream()
.map(m -> new JWTMethodConfiguration(m, m.getAnnotation(JWTAuthorizeMethod.class)))
.forEach(c -> Arrays.asList(c.annotation.httpMethods())
.forEach(m -> jwtMethods.computeIfAbsent(m, k -> new ArrayList<>()).add(c)));
// Ensure we're calling the JWT Authorize GET method for a HEAD request
if (jwtMethods.containsKey(HTTPMethod.GET) && !jwtMethods.containsKey(HTTPMethod.HEAD)) {
jwtMethods.put(HTTPMethod.HEAD, jwtMethods.get(HTTPMethod.GET));
}
// All Execute Methods that require authentication need to be accounted for in JWT Methods. It is ok if the JWT Methods define a superset of the execute methods.
Set<HTTPMethod> authenticatedMethods = executeMethods.keySet().stream().filter(k -> {
ExecuteMethodConfiguration methodConfiguration = executeMethods.get(k);
return methodConfiguration.annotations.containsKey(AnonymousAccess.class);
}).collect(Collectors.toSet());
if (!jwtMethods.keySet().containsAll(authenticatedMethods)) {
throw new PrimeException("The action class [" + actionClass + "] is missing at a JWT Authorization method. " +
"The class must define one or more methods annotated " + JWTAuthorizeMethod.class.getSimpleName() + " when [jwtEnabled] is set to [true]. "
+ "Ensure that for each execute method in your action such as post, put, get and delete that a method is configured to authorize the JWT.");
}
return jwtMethods;
}
/**
* Finds all of the result configurations for the action class.
*
* @param actionClass The action class.
* @return The map of all the result configurations.
*/
protected Map<String, Annotation> findResultConfigurations(Class<?> actionClass) {
Map<String, Annotation> resultConfigurations = new HashMap<>();
while (actionClass != Object.class) {
Map<String, Annotation> resultsForClass = addResultsForClass(actionClass);
resultsForClass.forEach(resultConfigurations::putIfAbsent);
actionClass = actionClass.getSuperclass();
}
return resultConfigurations;
}
/**
* Locates all the fields in the action class that have a scope annotation on them.
*
* @param actionClass The action class.
* @return The scope fields.
*/
protected List<ScopeField> findScopeFields(Class<?> actionClass) {
List<ScopeField> scopeFields = new ArrayList<>();
while (actionClass != Object.class) {
Field[] fields = actionClass.getDeclaredFields();
for (Field field : fields) {
Annotation[] annotations = field.getAnnotations();
for (Annotation annotation : annotations) {
Class<? extends Annotation> type = annotation.annotationType();
if (type.isAnnotationPresent(ScopeAnnotation.class)) {
scopeFields.add(new ScopeField(field, annotation));
}
}
}
actionClass = actionClass.getSuperclass();
}
return scopeFields;
}
/**
* Locates all of the validation methods and return a map keyed by HTTP Method.
*
* @param actionClass The action class.
* @return The validation method configurations.
*/
protected Map<HTTPMethod, List<ValidationMethodConfiguration>> findValidationMethods(Class<?> actionClass) {
List<Method> methods = ReflectionUtils.findAllMethodsWithAnnotation(actionClass, ValidationMethod.class);
// Map HTTP method to a list of Validation Methods.
Map<HTTPMethod, List<ValidationMethodConfiguration>> validationMethods = new HashMap<>();
methods.stream()
.map(m -> new ValidationMethodConfiguration(m, m.getAnnotation(ValidationMethod.class)))
.forEach(c -> Arrays.asList(c.annotation.httpMethods())
.forEach(m -> validationMethods.computeIfAbsent(m, k -> new ArrayList<>()).add(c)));
// Ensure we're calling the GET Validation Method for a HEAD request
if (validationMethods.containsKey(HTTPMethod.GET) && !validationMethods.containsKey(HTTPMethod.HEAD)) {
validationMethods.put(HTTPMethod.HEAD, validationMethods.get(HTTPMethod.GET));
}
return validationMethods;
}
/**
* Ensures that the method is a correct execute method.
*
* @param method The method.
* @throws PrimeException If the method is invalid.
*/
protected void verify(Method method) {
if (method.getReturnType() != String.class || method.getParameterTypes().length != 0) {
throw new PrimeException("The action class [" + method.getDeclaringClass().getClass() + "] has defined an " +
"execute method named [" + method.getName() + "] that is invalid. Execute methods must have zero parameters " +
"and return a String like this:\n\n" +
"public String execute() {\n" +
" return \"success\"\n" +
"}");
}
}
private Map<Class<?>, Object> getAdditionalConfiguration(Class<?> actionClass) {
Map<Class<?>, Object> additionalConfiguration = new HashMap<>();
for (ActionConfigurator configurator : configurators) {
Object config = configurator.configure(actionClass);
if (config != null) {
additionalConfiguration.put(config.getClass(), config);
}
}
return additionalConfiguration;
}
}