/*
* Copyright (c) 2013 the original author or authors
*
* 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 io.werval.runtime.routes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.werval.api.exceptions.IllegalRouteException;
import io.werval.api.exceptions.ParameterBinderException;
import io.werval.api.http.Method;
import io.werval.api.http.QueryString;
import io.werval.api.http.RequestHeader;
import io.werval.api.outcomes.Outcome;
import io.werval.api.routes.ControllerParams;
import io.werval.api.routes.ControllerParams.ParamValue;
import io.werval.api.routes.ParameterBinders;
import io.werval.api.routes.Route;
import io.werval.runtime.util.TypeResolver;
import static io.werval.util.IllegalArguments.ensureNotEmpty;
import static io.werval.util.IllegalArguments.ensureNotNull;
import static io.werval.util.Iterables.addAll;
import static io.werval.util.Iterables.toList;
import static io.werval.util.Strings.SPACE;
import static io.werval.util.Strings.rightPad;
/**
* Instance of a Route.
*/
/* package */ final class RouteInstance
implements Route
{
private final Method httpMethod;
private final String path;
private final Class<?> controllerType;
private java.lang.reflect.Method controllerMethod;
private final String controllerMethodName;
private final ControllerParams controllerParams;
private final Set<String> modifiers;
private final Pattern pathRegex;
/* package */ RouteInstance(
Method httpMethod, String path,
Class<?> controllerType,
String controllerMethodName,
ControllerParams controllerParams,
Set<String> modifiers
)
{
ensureNotNull( "HTTP Method", httpMethod );
ensureNotEmpty( "Path", path );
ensureNotNull( "Controller Type", controllerType );
ensureNotEmpty( "Controller Method Name", controllerMethodName );
this.httpMethod = httpMethod;
this.path = path;
this.controllerType = controllerType;
this.controllerMethodName = controllerMethodName;
this.controllerParams = controllerParams;
this.modifiers = new LinkedHashSet<>( modifiers );
validateRoute();
this.pathRegex = Pattern.compile( generatePathRegex() );
}
private void validateRoute()
{
if( !path.startsWith( "/" ) )
{
throw new IllegalRouteException( toString(), "Invalid path: " + path );
}
Iterable<String> controllerParamsNames = controllerParams.names();
List<String> pathParamsNames = new ArrayList<>();
for( String pathSegment : path.substring( 1 ).split( "/" ) )
{
if( pathSegment.startsWith( ":" ) || pathSegment.startsWith( "*" ) )
{
pathParamsNames.add( pathSegment.substring( 1 ) );
}
}
// Disallow multiple occurences of a single parameter name in the path
for( String paramName : pathParamsNames )
{
if( Collections.frequency( pathParamsNames, paramName ) > 1 )
{
throw new IllegalRouteException(
toString(),
"Parameter '" + paramName + "' is present several times in path."
);
}
}
// Ensure all parameters in path are bound to controller parameters and "vice et versa".
// Allow params absent from path that should come form QueryString
Set<String> allParamsNames = new LinkedHashSet<>();
addAll( allParamsNames, controllerParamsNames );
addAll( allParamsNames, pathParamsNames );
for( String paramName : allParamsNames )
{
boolean found = false;
for( String controllerParamName : controllerParamsNames )
{
if( paramName.equals( controllerParamName ) )
{
found = true;
break;
}
}
if( !found )
{
throw new IllegalRouteException(
toString(),
"Parameter '" + paramName + "' is present in path but not bound."
);
}
}
// Ensure controller method exists and return an Outcome or a CompletableFuture<Outcome>
Class<?>[] controllerParamsTypes = controllerParams.types();
try
{
controllerMethod = controllerType.getMethod( controllerMethodName, controllerParamsTypes );
boolean correctReturnType = false;
Throwable incorrectReturnTypeCause = null;
if( Outcome.class.isAssignableFrom( controllerMethod.getReturnType() ) )
{
correctReturnType = true;
}
else if( CompletableFuture.class.isAssignableFrom( controllerMethod.getReturnType() ) )
{
try
{
Class<?> futureOutcomeType = TypeResolver.resolveArgument(
controllerMethod.getGenericReturnType(),
CompletableFuture.class
);
if( futureOutcomeType.isAssignableFrom( Outcome.class ) )
{
correctReturnType = true;
}
}
catch( IllegalArgumentException ex )
{
incorrectReturnTypeCause = ex;
}
}
if( !correctReturnType )
{
String message = "Controller Method '" + controllerType.getSimpleName()
+ "#" + controllerMethodName
+ "( " + Arrays.toString( controllerParamsTypes ) + " )' "
+ "do not return an Outcome nor a CompletableFuture<Outcome>.";
IllegalRouteException ex = new IllegalRouteException( toString(), message );
if( incorrectReturnTypeCause != null )
{
ex.initCause( incorrectReturnTypeCause );
}
throw ex;
}
}
catch( NoSuchMethodException ex )
{
throw new IllegalRouteException(
toString(),
"Controller Method '" + controllerType.getSimpleName()
+ "#" + controllerMethodName
+ "( " + Arrays.toString( controllerParamsTypes ) + " )' "
+ "not found.",
ex
);
}
}
/**
* @return Regex for parameter extraction from path with one named group per path parameter
*/
private String generatePathRegex()
{
StringBuilder regex = new StringBuilder( "/" );
String[] pathElements = path.substring( 1 ).split( "/" );
for( int idx = 0; idx < pathElements.length; idx++ )
{
String pathElement = pathElements[idx];
if( pathElement.length() > 1 )
{
switch( pathElement.substring( 0, 1 ) )
{
case ":":
regex.append( "(?<" ).append( pathElement.substring( 1 ) ).append( ">[^/]+)" );
break;
case "*":
regex.append( "(?<" ).append( pathElement.substring( 1 ) ).append( ">.+)" );
break;
default:
regex.append( pathElement );
break;
}
}
else
{
regex.append( pathElement );
}
if( idx + 1 < pathElements.length )
{
regex.append( "/" );
}
}
return regex.toString();
}
@Override
public boolean satisfiedBy( RequestHeader requestHeader )
{
if( !httpMethod.equals( requestHeader.method() ) )
{
return false;
}
return pathRegex.matcher( requestHeader.path() ).matches();
}
@Override
public Method httpMethod()
{
return httpMethod;
}
@Override
public String path()
{
return path;
}
@Override
public Class<?> controllerType()
{
return controllerType;
}
@Override
public java.lang.reflect.Method controllerMethod()
{
return controllerMethod;
}
@Override
public String controllerMethodName()
{
return controllerMethodName;
}
@Override
public Set<String> modifiers()
{
return Collections.unmodifiableSet( modifiers );
}
@Override
public Map<String, Object> bindParameters( ParameterBinders parameterBinders, String path, QueryString queryString )
{
Matcher matcher = pathRegex.matcher( path );
if( !matcher.matches() )
{
throw new IllegalArgumentException( "Unable to bind, Route is not satisfied by path: " + path );
}
Map<String, Object> boundParams = new LinkedHashMap<>();
for( ControllerParams.Param param : controllerParams )
{
if( ParamValue.FORCED == param.valueKind() )
{
boundParams.put( param.name(), param.forcedValue() );
}
else
{
String unboundValue = null;
try
{
unboundValue = matcher.group( param.name() );
}
catch( IllegalArgumentException noMatchingGroupInPath )
{
if( queryString.keys().contains( param.name() ) )
{
// QUID We currently requires a single value to bind QueryString parameter.
// QUID Should we provide a config property to use first/last value?
// QUID Or binding to collectionish types from all values?
unboundValue = queryString.singleValue( param.name() );
}
}
if( unboundValue == null )
{
if( ParamValue.DEFAULTED == param.valueKind() )
{
boundParams.put( param.name(), param.defaultedValue() );
}
else
{
throw new ParameterBinderException(
"Parameter named '" + param.name() + "' not found in path nor in query string."
);
}
}
else
{
boundParams.put( param.name(), parameterBinders.bind( param.type(), param.name(), unboundValue ) );
}
}
}
return boundParams;
}
@Override
@SuppressWarnings( "unchecked" )
public String unbindParameters( ParameterBinders parameterBinders, Map<String, Object> parameters )
{
List<String> paramsToUnbind = toList( controllerParams.names() );
StringBuilder unboundPath = new StringBuilder( "/" );
// Unbinding path
String[] pathElements = path.substring( 1 ).split( "/" );
for( int idx = 0; idx < pathElements.length; idx++ )
{
String pathElement = pathElements[idx];
if( pathElement.length() > 1 )
{
switch( pathElement.substring( 0, 1 ) )
{
case ":":
case "*":
ControllerParams.Param controllerParam = controllerParams.get( pathElement.substring( 1 ) );
Object value;
if( ParamValue.FORCED == controllerParam.valueKind() )
{
value = controllerParam.forcedValue();
}
else if( ParamValue.DEFAULTED == controllerParam.valueKind()
&& !parameters.containsKey( controllerParam.name() ) )
{
value = controllerParam.defaultedValue();
}
else
{
value = parameters.get( controllerParam.name() );
}
unboundPath.append(
parameterBinders.unbind(
(Class<Object>) controllerParam.type(),
controllerParam.name(),
value
)
);
paramsToUnbind.remove( controllerParam.name() );
break;
default:
unboundPath.append( pathElement );
break;
}
}
else
{
unboundPath.append( pathElement );
}
if( idx + 1 < pathElements.length )
{
unboundPath.append( "/" );
}
}
// Unbinding query string
// WARN Only controller parameters are unbound, not all given parameters
if( !paramsToUnbind.isEmpty() )
{
unboundPath.append( "?" );
for( Iterator<String> qsit = paramsToUnbind.iterator(); qsit.hasNext(); )
{
String qsParam = qsit.next();
ControllerParams.Param controllerParam = controllerParams.get( qsParam );
Object value;
if( ParamValue.FORCED == controllerParam.valueKind() )
{
value = controllerParam.forcedValue();
}
else if( ParamValue.DEFAULTED == controllerParam.valueKind()
&& !parameters.containsKey( controllerParam.name() ) )
{
value = controllerParam.defaultedValue();
}
else
{
value = parameters.get( controllerParam.name() );
}
unboundPath.append( qsParam ).append( "=" );
unboundPath.append(
parameterBinders.unbind(
(Class<Object>) controllerParam.type(),
controllerParam.name(),
value
)
);
if( qsit.hasNext() )
{
unboundPath.append( "&" );
}
}
}
return unboundPath.toString();
}
public ControllerParams controllerParams()
{
return controllerParams;
}
@Override
public String toString()
{
return toString( null, null, null );
}
public String toString( Integer methodPadLen, Integer pathPadLen, Integer actionPadLen )
{
StringBuilder builder = new StringBuilder();
// HTTP Method
builder.append( methodPadLen == null ? httpMethod : rightPad( methodPadLen, httpMethod.name() ) );
builder.append( SPACE );
// Path
builder.append( pathPadLen == null ? path : rightPad( pathPadLen, path ) );
builder.append( SPACE );
// Action
StringBuilder actionBuilder = new StringBuilder();
actionBuilder.append( controllerType.getName() ).append( '.' ).append( controllerMethodName ).append( '(' );
Iterator<ControllerParams.Param> it = controllerParams.iterator();
if( it.hasNext() )
{
actionBuilder.append( SPACE );
while( it.hasNext() )
{
ControllerParams.Param param = it.next();
actionBuilder.append( param.type().getSimpleName() ).append( SPACE ).append( param.name() );
switch( param.valueKind() )
{
case FORCED:
actionBuilder.append( " = '" ).append( param.forcedValue() ).append( "'" );
break;
case DEFAULTED:
actionBuilder.append( " ?= '" ).append( param.defaultedValue() ).append( "'" );
break;
default:
}
if( it.hasNext() )
{
actionBuilder.append( ", " );
}
}
actionBuilder.append( SPACE );
}
actionBuilder.append( ')' );
String action = actionBuilder.toString();
builder.append( actionPadLen == null ? action : rightPad( actionPadLen, action ) );
// Modifiers
Iterator<String> modifiersIt = modifiers.iterator();
if( modifiersIt.hasNext() )
{
builder.append( SPACE );
while( modifiersIt.hasNext() )
{
builder.append( modifiersIt.next() );
if( modifiersIt.hasNext() )
{
builder.append( SPACE );
}
}
}
return builder.toString();
}
@Override
public int hashCode()
{
// CHECKSTYLE:OFF
int hash = 7;
hash = 59 * hash + ( this.httpMethod != null ? this.httpMethod.hashCode() : 0 );
hash = 59 * hash + ( this.path != null ? this.path.hashCode() : 0 );
hash = 59 * hash + ( this.controllerType != null ? this.controllerType.hashCode() : 0 );
hash = 59 * hash + ( this.controllerMethodName != null ? this.controllerMethodName.hashCode() : 0 );
hash = 59 * hash + ( this.controllerParams != null ? this.controllerParams.hashCode() : 0 );
hash = 59 * hash + ( this.modifiers != null ? this.modifiers.hashCode() : 0 );
return hash;
// CHECKSTYLE:ON
}
@Override
@SuppressWarnings( "AccessingNonPublicFieldOfAnotherObject" )
public boolean equals( Object obj )
{
// CHECKSTYLE:OFF
if( this == obj )
{
return true;
}
if( obj == null )
{
return false;
}
if( getClass() != obj.getClass() )
{
return false;
}
final RouteInstance other = (RouteInstance) obj;
if( this.httpMethod != other.httpMethod && ( this.httpMethod == null || !this.httpMethod.equals( other.httpMethod ) ) )
{
return false;
}
if( ( this.path == null ) ? ( other.path != null ) : !this.path.equals( other.path ) )
{
return false;
}
if( this.controllerType != other.controllerType && ( this.controllerType == null || !this.controllerType.equals( other.controllerType ) ) )
{
return false;
}
if( ( this.controllerMethodName == null ) ? ( other.controllerMethodName != null ) : !this.controllerMethodName.equals( other.controllerMethodName ) )
{
return false;
}
if( this.controllerParams != other.controllerParams && ( this.controllerParams == null || !this.controllerParams.equals( other.controllerParams ) ) )
{
return false;
}
return this.modifiers == other.modifiers || ( this.modifiers != null && this.modifiers.equals( other.modifiers ) );
// CHECKSTYLE:ON
}
}