/**
*
*/
package com.github.lpezet.antiope2.retrofitted;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import rx.Observable;
import com.github.lpezet.antiope2.retrofitted.annotation.Converter;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Body;
import com.github.lpezet.antiope2.retrofitted.annotation.http.DELETE;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Field;
import com.github.lpezet.antiope2.retrofitted.annotation.http.FieldMap;
import com.github.lpezet.antiope2.retrofitted.annotation.http.FormUrlEncoded;
import com.github.lpezet.antiope2.retrofitted.annotation.http.GET;
import com.github.lpezet.antiope2.retrofitted.annotation.http.HEAD;
import com.github.lpezet.antiope2.retrofitted.annotation.http.HTTP;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Header;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Headers;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Multipart;
import com.github.lpezet.antiope2.retrofitted.annotation.http.PATCH;
import com.github.lpezet.antiope2.retrofitted.annotation.http.POST;
import com.github.lpezet.antiope2.retrofitted.annotation.http.PUT;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Part;
import com.github.lpezet.antiope2.retrofitted.annotation.http.PartMap;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Path;
import com.github.lpezet.antiope2.retrofitted.annotation.http.Query;
import com.github.lpezet.antiope2.retrofitted.annotation.http.QueryMap;
/**
* @author Luc Pezet
*/
public class MethodInfo {
private static final String PARAM = "[a-zA-Z][a-zA-Z0-9_-]*";
private static final Pattern PARAM_NAME_REGEX = Pattern.compile(PARAM);
private static final Pattern PARAM_URL_REGEX = Pattern.compile("\\{(" + PARAM + ")\\}");
enum ExecutionType {
ASYNC,
RX,
SYNC
}
enum RequestType {
/** No content-specific logic required. */
SIMPLE,
/** Multi-part request body. */
MULTIPART,
/** Form URL-encoded request body. */
FORM_URL_ENCODED
}
private Method mMethod;
private final ExecutionType mExecutionType;
private Type mResponseObjectType;
private RequestType mRequestType = RequestType.SIMPLE;
// Not yet supported but added for now (will always return false)
private final boolean mStreaming = false;
private Type mRequestObjectType;
private boolean mRequestHasBody;
private String mRequestMethod;
private com.github.lpezet.antiope2.dao.http.Headers mHeaders = new com.github.lpezet.antiope2.dao.http.Headers();
private String mResourcePath;
private String mResourceQuery;
private Set<String> mResourcePathParams;
private com.github.lpezet.antiope2.retrofitted.converter.Converter mConverter;
private Annotation[] mParamAnnotations;
public MethodInfo(Method pMethod) {
mMethod = pMethod;
mExecutionType = parseResponseType();
parseMethodAnnotations();
parseParameters();
}
public Type getResponseObjectType() {
return mResponseObjectType;
}
public Type getRequestObjectType() {
return mRequestObjectType;
}
public ExecutionType getExecutionType() {
return mExecutionType;
}
public boolean isAsync() {
return mExecutionType == ExecutionType.ASYNC;
}
public Annotation[] getParamAnnotations() {
return mParamAnnotations;
}
public String getResourcePath() {
return mResourcePath;
}
public String getRequestMethod() {
return mRequestMethod;
}
public String getResourceQuery() {
return mResourceQuery;
}
public List<com.github.lpezet.antiope2.dao.http.Header> getHeaders() {
return mHeaders.getAllHeaders();
}
public boolean isStreaming() {
return mStreaming;
}
public com.github.lpezet.antiope2.retrofitted.converter.Converter getConverter() {
return mConverter;
}
// ##############################################################
// Parse Response Type
// ##############################################################
private ExecutionType parseResponseType() {
// Synchronous methods have a non-void return type.
// Observable methods have a return type of Observable.
Type returnType = mMethod.getGenericReturnType();
// Asynchronous methods should have a Callback type as the last argument.
Type lastArgType = null;
Class<?> lastArgClass = null;
Type[] parameterTypes = mMethod.getGenericParameterTypes();
if (parameterTypes.length > 0) {
Type typeToCheck = parameterTypes[parameterTypes.length - 1];
lastArgType = typeToCheck;
if (typeToCheck instanceof ParameterizedType) {
typeToCheck = ((ParameterizedType) typeToCheck).getRawType();
}
if (typeToCheck instanceof Class) {
lastArgClass = (Class<?>) typeToCheck;
}
}
boolean hasReturnType = returnType != void.class;
boolean hasCallback = lastArgClass != null && Callback.class.isAssignableFrom(lastArgClass);
// Check for invalid configurations.
if (hasReturnType && hasCallback) {
throw methodError("Must have return type or Callback as last argument, not both.");
}
if (!hasReturnType && !hasCallback) {
throw methodError("Must have either a return type or Callback as last argument.");
}
if (hasReturnType) {
if (Platform.HAS_RX_JAVA) {
Class rawReturnType = Types.getRawType(returnType);
if (RxSupport.isObservable(rawReturnType)) {
returnType = RxSupport.getObservableType(returnType, rawReturnType);
mResponseObjectType = getParameterUpperBound((ParameterizedType) returnType);
return ExecutionType.RX;
}
}
mResponseObjectType = returnType;
return ExecutionType.SYNC;
}
lastArgType = Types.getSupertype(lastArgType, Types.getRawType(lastArgType), Callback.class);
if (lastArgType instanceof ParameterizedType) {
mResponseObjectType = getParameterUpperBound((ParameterizedType) lastArgType);
return ExecutionType.ASYNC;
}
throw methodError("Last parameter must be of type Callback<X> or Callback<? super X>.");
}
private RuntimeException methodError(String message, Object... args) {
if (args.length > 0) {
message = String.format(message, args);
}
return new IllegalArgumentException(
mMethod.getDeclaringClass().getSimpleName() + "." + mMethod.getName() + ": " + message);
}
/** Indirection to avoid log complaints if RxJava isn't present. */
private static final class RxSupport {
public static boolean isObservable(Class rawType) {
return rawType == Observable.class;
}
public static Type getObservableType(Type contextType, Class contextRawType) {
return Types.getSupertype(contextType, contextRawType, Observable.class);
}
}
private static Type getParameterUpperBound(ParameterizedType type) {
Type[] types = type.getActualTypeArguments();
for (int i = 0; i < types.length; i++) {
Type paramType = types[i];
if (paramType instanceof WildcardType) {
types[i] = ((WildcardType) paramType).getUpperBounds()[0];
}
}
return types[0];
}
// ######################################################
// Parse Method Annotations
// ######################################################
/** Loads {@link #mRequestMethod} and {@link #requestType}. */
private void parseMethodAnnotations() {
for (Annotation methodAnnotation : mMethod.getAnnotations()) {
Class<? extends Annotation> annotationType = methodAnnotation.annotationType();
if (annotationType == com.github.lpezet.antiope2.retrofitted.annotation.Converter.class) {
mConverter = createConverter((com.github.lpezet.antiope2.retrofitted.annotation.Converter) methodAnnotation);
} else if (annotationType == DELETE.class) {
parseHttpMethodAndPath("DELETE", ((DELETE) methodAnnotation).value(), false);
} else if (annotationType == GET.class) {
parseHttpMethodAndPath("GET", ((GET) methodAnnotation).value(), false);
} else if (annotationType == HEAD.class) {
parseHttpMethodAndPath("HEAD", ((HEAD) methodAnnotation).value(), false);
} else if (annotationType == PATCH.class) {
parseHttpMethodAndPath("PATCH", ((PATCH) methodAnnotation).value(), true);
} else if (annotationType == POST.class) {
parseHttpMethodAndPath("POST", ((POST) methodAnnotation).value(), true);
} else if (annotationType == PUT.class) {
parseHttpMethodAndPath("PUT", ((PUT) methodAnnotation).value(), true);
} else if (annotationType == HTTP.class) {
HTTP http = (HTTP) methodAnnotation;
parseHttpMethodAndPath(http.method(), http.path(), http.hasBody());
} else if (annotationType == Headers.class) {
String[] headersToParse = ((Headers) methodAnnotation).value();
if (headersToParse.length == 0) {
throw methodError("@Headers annotation is empty.");
}
mHeaders = parseHeaders(headersToParse);
} else if (annotationType == Multipart.class) {
if (mRequestType != RequestType.SIMPLE) {
throw methodError("Only one encoding annotation is allowed.");
}
mRequestType = RequestType.MULTIPART;
} else if (annotationType == FormUrlEncoded.class) {
if (mRequestType != RequestType.SIMPLE) {
throw methodError("Only one encoding annotation is allowed.");
}
mRequestType = RequestType.FORM_URL_ENCODED;
}
/*
* TODO: Implement
* else if (annotationType == Streaming.class) {
* if (responseObjectType != Response.class) {
* throw methodError(
* "Only methods having %s as data type are allowed to have @%s annotation.",
* Response.class.getSimpleName(), Streaming.class.getSimpleName());
* }
* isStreaming = true;
* }
*/
}
if (mRequestMethod == null) {
throw methodError("HTTP method annotation is required (e.g., @GET, @POST, etc.).");
}
if (!mRequestHasBody) {
if (mRequestType == RequestType.MULTIPART) {
throw methodError("Multipart can only be specified on HTTP methods with request body (e.g., @POST).");
}
if (mRequestType == RequestType.FORM_URL_ENCODED) {
throw methodError("FormUrlEncoded can only be specified on HTTP methods with request body "
+ "(e.g., @POST).");
}
}
}
private com.github.lpezet.antiope2.retrofitted.converter.Converter createConverter(Converter pMethodAnnotation) {
try {
return pMethodAnnotation.value().newInstance();
} catch (InstantiationException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
/** Loads {@link #requestUrl}, {@link #requestUrlParamNames}, and {@link #requestQuery}. */
private void parseHttpMethodAndPath(String pMethod, String pPath, boolean pHasBody) {
if (mRequestMethod != null) {
throw methodError("Only one HTTP method is allowed. Found: %s and %s.", mRequestMethod,
pMethod);
}
if (pPath == null || pPath.length() == 0 || pPath.charAt(0) != '/') {
throw methodError("URL path \"%s\" must start with '/'.", pPath);
}
// Get the relative URL path and existing query string, if present.
String url = pPath;
String query = null;
int question = pPath.indexOf('?');
if (question != -1 && question < pPath.length() - 1) {
url = pPath.substring(0, question);
query = pPath.substring(question + 1);
// Ensure the query string does not have any named parameters.
Matcher queryParamMatcher = PARAM_URL_REGEX.matcher(query);
if (queryParamMatcher.find()) {
throw methodError("URL query string \"%s\" must not have replace block. For dynamic query"
+ " parameters use @Query.", query);
}
}
mRequestMethod = pMethod;
mResourcePath = url;
mRequestHasBody = pHasBody;
mResourceQuery = query;
mResourcePathParams = parsePathParameters(pPath);
}
/**
* Gets the set of unique path parameters used in the given URI. If a parameter is used twice
* in the URI, it will only show up once in the set.
*/
static Set<String> parsePathParameters(String path) {
Matcher m = PARAM_URL_REGEX.matcher(path);
Set<String> patterns = new LinkedHashSet<String>();
while (m.find()) {
patterns.add(m.group(1));
}
return patterns;
}
private com.github.lpezet.antiope2.dao.http.Headers parseHeaders(String[] pHeaders) {
com.github.lpezet.antiope2.dao.http.Headers oHeaders = new com.github.lpezet.antiope2.dao.http.Headers();
for (String header : pHeaders) {
int colon = header.indexOf(':');
if (colon == -1 || colon == 0 || colon == header.length() - 1) {
throw methodError("@Headers value must be in the form \"Name: Value\". Found: \"%s\"",
header);
}
String oName = header.substring(0, colon);
String oValue = header.substring(colon + 1).trim();
oHeaders.add(oName, oValue);
}
return oHeaders;
}
// #########################################
// Parse Parameters
// #########################################
/**
* Loads {@link #requestParamAnnotations}. Must be called after
* {@link #parseMethodAnnotations()}.
*/
private void parseParameters() {
Type[] methodParameterTypes = mMethod.getGenericParameterTypes();
Annotation[][] methodParameterAnnotationArrays = mMethod.getParameterAnnotations();
int count = methodParameterAnnotationArrays.length;
if (mExecutionType == ExecutionType.ASYNC) {
count -= 1; // Callback is last argument when not a synchronous method.
}
Annotation[] requestParamAnnotations = new Annotation[count];
boolean gotField = false;
boolean gotPart = false;
boolean gotBody = false;
for (int i = 0; i < count; i++) {
Type methodParameterType = methodParameterTypes[i];
Annotation[] methodParameterAnnotations = methodParameterAnnotationArrays[i];
if (methodParameterAnnotations != null) {
for (Annotation methodParameterAnnotation : methodParameterAnnotations) {
Class<? extends Annotation> methodAnnotationType =
methodParameterAnnotation.annotationType();
if (methodAnnotationType == Path.class) {
String name = ((Path) methodParameterAnnotation).value();
validatePathName(i, name);
} else if (methodAnnotationType == Query.class) {
// Nothing to do.
} else if (methodAnnotationType == QueryMap.class) {
if (!Map.class.isAssignableFrom(Types.getRawType(methodParameterType))) {
throw parameterError(i, "@QueryMap parameter type must be Map.");
}
} else if (methodAnnotationType == Header.class) {
// Nothing to do.
} else if (methodAnnotationType == Field.class) {
if (mRequestType != RequestType.FORM_URL_ENCODED) {
throw parameterError(i, "@Field parameters can only be used with form encoding.");
}
gotField = true;
} else if (methodAnnotationType == FieldMap.class) {
if (mRequestType != RequestType.FORM_URL_ENCODED) {
throw parameterError(i, "@FieldMap parameters can only be used with form encoding.");
}
if (!Map.class.isAssignableFrom(Types.getRawType(methodParameterType))) {
throw parameterError(i, "@FieldMap parameter type must be Map.");
}
gotField = true;
} else if (methodAnnotationType == Part.class) {
if (mRequestType != RequestType.MULTIPART) {
throw parameterError(i, "@Part parameters can only be used with multipart encoding.");
}
gotPart = true;
} else if (methodAnnotationType == PartMap.class) {
if (mRequestType != RequestType.MULTIPART) {
throw parameterError(i,
"@PartMap parameters can only be used with multipart encoding.");
}
if (!Map.class.isAssignableFrom(Types.getRawType(methodParameterType))) {
throw parameterError(i, "@PartMap parameter type must be Map.");
}
gotPart = true;
} else if (methodAnnotationType == Body.class) {
if (mRequestType != RequestType.SIMPLE) {
throw parameterError(i,
"@Body parameters cannot be used with form or multi-part encoding.");
}
if (gotBody) {
throw methodError("Multiple @Body method annotations found.");
}
mRequestObjectType = methodParameterType;
gotBody = true;
} else {
// This is a non-Retrofit annotation. Skip to the next one.
continue;
}
if (requestParamAnnotations[i] != null) {
throw parameterError(i,
"Multiple Retrofit annotations found, only one allowed: @%s, @%s.",
requestParamAnnotations[i].annotationType().getSimpleName(),
methodAnnotationType.getSimpleName());
}
requestParamAnnotations[i] = methodParameterAnnotation;
}
}
if (requestParamAnnotations[i] == null) {
throw parameterError(i, "No Retrofit annotation found.");
}
}
if (mRequestType == RequestType.SIMPLE && !mRequestHasBody && gotBody) {
throw methodError("Non-body HTTP method cannot contain @Body or @TypedOutput.");
}
if (mRequestType == RequestType.FORM_URL_ENCODED && !gotField) {
throw methodError("Form-encoded method must contain at least one @Field.");
}
if (mRequestType == RequestType.MULTIPART && !gotPart) {
throw methodError("Multipart method must contain at least one @Part.");
}
mParamAnnotations = requestParamAnnotations;
}
private void validatePathName(int index, String name) {
if (!PARAM_NAME_REGEX.matcher(name).matches()) {
throw parameterError(index, "@Path parameter name must match %s. Found: %s",
PARAM_URL_REGEX.pattern(), name);
}
// Verify URL replacement name is actually present in the URL path.
if (!mResourcePathParams.contains(name)) {
throw parameterError(index, "URL \"%s\" does not contain \"{%s}\".", mResourcePath, name);
}
}
private RuntimeException parameterError(int index, String message, Object... args) {
return methodError(message + " (parameter #" + (index + 1) + ")", args);
}
}