/* * Copyright (C) 2013 Square, Inc. * * 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 retrofit; 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.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import retrofit.http.Body; import retrofit.http.EncodedPath; import retrofit.http.EncodedQuery; import retrofit.http.Field; import retrofit.http.FormUrlEncoded; import retrofit.http.Header; import retrofit.http.Headers; import retrofit.http.Multipart; import retrofit.http.Part; import retrofit.http.Path; import retrofit.http.Query; import retrofit.http.RestMethod; /** * Request metadata about a service interface declaration. */ final class RestMethodInfo { // Upper and lower characters, digits, underscores, and hyphens, starting with a character. 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 + ")\\}"); final Method method; // Method-level details final boolean isSynchronous; boolean loaded = false; Type responseObjectType; RequestType requestType = RequestType.SIMPLE; String requestMethod; boolean requestHasBody; String requestUrl; Set<String> requestUrlParamNames; String requestQuery; List<retrofit.client.Header> headers; // Parameter-level details String[] requestParamNames; ParamUsage[] requestParamUsage; RestMethodInfo(Method method) { this.method = method; isSynchronous = parseResponseType(); } /** * 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; } synchronized void init() { if (loaded) return; parseMethodAnnotations(); parseParameters(); loaded = true; } /** * Loads {@link #requestMethod} and {@link #requestType}. */ private void parseMethodAnnotations() { for (Annotation methodAnnotation : method.getAnnotations()) { Class<? extends Annotation> annotationType = methodAnnotation.annotationType(); RestMethod methodInfo = null; // Look for a @RestMethod annotation on the parameter annotation indicating request method. for (Annotation innerAnnotation : annotationType.getAnnotations()) { if (RestMethod.class == innerAnnotation.annotationType()) { methodInfo = (RestMethod) innerAnnotation; break; } } if (methodInfo != null) { if (requestMethod != null) { throw new IllegalArgumentException("Method " + method.getName() + " contains multiple HTTP methods. Found: " + requestMethod + " and " + methodInfo.value()); } String path; try { path = (String) annotationType.getMethod("value").invoke(methodAnnotation); } catch (Exception e) { throw new RuntimeException("Failed to extract path from " + annotationType.getSimpleName() + " annotation on " + method.getName() + ".", e); } parsePath(path); requestMethod = methodInfo.value(); requestHasBody = methodInfo.hasBody(); } else if (annotationType == Headers.class) { String[] headersToParse = ((Headers) methodAnnotation).value(); if (headersToParse.length == 0) { throw new IllegalStateException("Headers annotation was empty."); } headers = parseHeaders(headersToParse); } else if (annotationType == Multipart.class) { if (requestType != RequestType.SIMPLE) { throw new IllegalStateException("Only one encoding annotation per method is allowed: " + method.getName()); } requestType = RequestType.MULTIPART; } else if (annotationType == FormUrlEncoded.class) { if (requestType != RequestType.SIMPLE) { throw new IllegalStateException("Only one encoding annotation per method is allowed: " + method.getName()); } requestType = RequestType.FORM_URL_ENCODED; } } if (requestMethod == null) { throw new IllegalStateException("Method " + method.getName() + " not annotated with request type (e.g., GET, POST)."); } if (!requestHasBody) { if (requestType == RequestType.MULTIPART) { throw new IllegalStateException("Multipart can only be specific on HTTP methods with request body (e.g., POST). (" + method.getName() + ")"); } if (requestType == RequestType.FORM_URL_ENCODED) { throw new IllegalStateException("Multipart can only be specific on HTTP methods with request body (e.g., POST). (" + method.getName() + ")"); } } } /** * Loads {@link #requestUrl}, {@link #requestUrlParamNames}, and {@link #requestQuery}. */ private void parsePath(String path) { if (path == null || path.length() == 0 || path.charAt(0) != '/') { throw new IllegalArgumentException("URL path \"" + path + "\" on method " + method.getName() + " must start with '/'. (" + method.getName() + ")"); } // Get the relative URL path and existing query string, if present. String url = path; String query = null; int question = path.indexOf('?'); if (question != -1 && question < path.length() - 1) { url = path.substring(0, question); query = path.substring(question + 1); // Ensure the query string does not have any named parameters. Matcher queryParamMatcher = PARAM_URL_REGEX.matcher(query); if (queryParamMatcher.find()) { throw new IllegalStateException("URL query string \"" + query + "\" on method " + method.getName() + " may not have replace block."); } } Set<String> urlParams = parsePathParameters(path); requestUrl = url; requestUrlParamNames = urlParams; requestQuery = query; } private List<retrofit.client.Header> parseHeaders(String[] headers) { List<retrofit.client.Header> headerList = new ArrayList<retrofit.client.Header>(); for (String header : headers) { int colon = header.indexOf(':'); if (colon == -1 || colon == 0 || colon == headers.length - 1) { throw new IllegalStateException("Header must be in the form 'Name: Value': " + header); } headerList.add(new retrofit.client.Header(header.substring(0, colon), header.substring(colon + 1).trim())); } return headerList; } /** * Loads {@link #responseObjectType}. Returns {@code true} if method is synchronous. */ private boolean parseResponseType() { // Synchronous methods have a non-void return type. Type returnType = method.getGenericReturnType(); // Asynchronous methods should have a Callback type as the last argument. Type lastArgType = null; Class<?> lastArgClass = null; Type[] parameterTypes = method.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 new IllegalArgumentException("Method " + method.getName() + " may only have return type or Callback as last argument, not both."); } if (!hasReturnType && !hasCallback) { throw new IllegalArgumentException("Method " + method.getName() + " must have either a return type or Callback as last argument."); } if (hasReturnType) { responseObjectType = returnType; return true; } lastArgType = Types.getSupertype(lastArgType, Types.getRawType(lastArgType), Callback.class); if (lastArgType instanceof ParameterizedType) { Type[] types = ((ParameterizedType) lastArgType).getActualTypeArguments(); for (int i = 0; i < types.length; i++) { Type type = types[i]; if (type instanceof WildcardType) { types[i] = ((WildcardType) type).getUpperBounds()[0]; } } responseObjectType = types[0]; return false; } throw new IllegalArgumentException("Last parameter of " + method.getName() + " must be of type Callback<X> or Callback<? super X>. Found: " + lastArgType); } /** * Loads {@link #requestParamNames} and {@link #requestParamUsage}. Must be called after * {@link #parseMethodAnnotations()}. */ private void parseParameters() { Class<?>[] parameterTypes = method.getParameterTypes(); Annotation[][] parameterAnnotationArrays = method.getParameterAnnotations(); int count = parameterAnnotationArrays.length; if (!isSynchronous) { count -= 1; // Callback is last argument when not a synchronous method. } String[] paramNames = new String[count]; requestParamNames = paramNames; ParamUsage[] paramUsage = new ParamUsage[count]; requestParamUsage = paramUsage; boolean gotField = false; boolean gotPart = false; boolean gotBody = false; for (int i = 0; i < count; i++) { Class<?> parameterType = parameterTypes[i]; Annotation[] parameterAnnotations = parameterAnnotationArrays[i]; if (parameterAnnotations != null) { for (Annotation parameterAnnotation : parameterAnnotations) { Class<? extends Annotation> annotationType = parameterAnnotation.annotationType(); if (annotationType == Path.class) { String name = ((Path) parameterAnnotation).value(); validatePathName(name); paramNames[i] = name; paramUsage[i] = ParamUsage.PATH; } else if (annotationType == EncodedPath.class) { String name = ((EncodedPath) parameterAnnotation).value(); validatePathName(name); paramNames[i] = name; paramUsage[i] = ParamUsage.ENCODED_PATH; } else if (annotationType == Query.class) { String name = ((Query) parameterAnnotation).value(); paramNames[i] = name; paramUsage[i] = ParamUsage.QUERY; } else if (annotationType == EncodedQuery.class) { String name = ((EncodedQuery) parameterAnnotation).value(); paramNames[i] = name; paramUsage[i] = ParamUsage.ENCODED_QUERY; } else if (annotationType == Header.class) { String name = ((Header) parameterAnnotation).value(); if (parameterType != String.class) { throw new IllegalStateException("@Header parameter type must be String: " + name); } paramNames[i] = name; paramUsage[i] = ParamUsage.HEADER; } else if (annotationType == Field.class) { if (requestType != RequestType.FORM_URL_ENCODED) { throw new IllegalStateException("@Field parameters can only be used with form encoding."); } String name = ((Field) parameterAnnotation).value(); gotField = true; paramNames[i] = name; paramUsage[i] = ParamUsage.FIELD; } else if (annotationType == Part.class) { if (requestType != RequestType.MULTIPART) { throw new IllegalStateException("@Part parameters can only be used with multipart encoding."); } String name = ((Part) parameterAnnotation).value(); gotPart = true; paramNames[i] = name; paramUsage[i] = ParamUsage.PART; } else if (annotationType == Body.class) { if (requestType != RequestType.SIMPLE) { throw new IllegalStateException("@Body parameters cannot be used with form or multi-part encoding."); } if (gotBody) { throw new IllegalStateException("Method annotated with multiple Body method annotations: " + method); } gotBody = true; paramUsage[i] = ParamUsage.BODY; } } } if (paramUsage[i] == null) { throw new IllegalStateException("No Retrofit annotation found on parameter " + (i + 1) + " of " + method.getName()); } } if (requestType == RequestType.SIMPLE && !requestHasBody && gotBody) { throw new IllegalStateException("Non-body HTTP method cannot contain @Body or @TypedOutput."); } if (requestType == RequestType.FORM_URL_ENCODED && !gotField) { throw new IllegalStateException("Form-encoded method must contain at least one @Field."); } if (requestType == RequestType.MULTIPART && !gotPart) { throw new IllegalStateException("Multipart method must contain at least one @Part."); } } private void validatePathName(String name) { if (!PARAM_NAME_REGEX.matcher(name).matches()) { throw new IllegalStateException("Path parameter name is not valid: " + name + ". Must match " + PARAM_URL_REGEX.pattern()); } // Verify URL replacement name is actually present in the URL path. if (!requestUrlParamNames.contains(name)) { throw new IllegalStateException("Method URL \"" + requestUrl + "\" does not contain {" + name + "}."); } } enum ParamUsage { PATH, ENCODED_PATH, QUERY, ENCODED_QUERY, FIELD, PART, BODY, HEADER } enum RequestType { /** * No content-specific logic required. */ SIMPLE, /** * Multi-part request body. */ MULTIPART, /** * Form URL-encoded request body. */ FORM_URL_ENCODED } }