/*
* Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
*
* 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 com.scvngr.levelup.core.net;
import android.content.Context;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import com.scvngr.levelup.core.annotation.LevelUpApi;
import com.scvngr.levelup.core.annotation.LevelUpApi.Contract;
import com.scvngr.levelup.core.util.CoreLibConstants;
import com.scvngr.levelup.core.util.LogManager;
import com.scvngr.levelup.core.util.NullUtils;
import com.scvngr.levelup.core.util.PreconditionUtil;
import net.jcip.annotations.Immutable;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeSet;
/**
* Object which represents an HTTP request.
*/
@Immutable
@LevelUpApi(contract = Contract.DRAFT)
public abstract class AbstractRequest implements Parcelable {
/**
* The method of the HTTP request.
*/
@NonNull
private final HttpMethod mMethod;
/**
* The URL string where the request will be send. This URL does not yet encode
* {@link #mQueryParams}.
*/
@NonNull
private final String mUrlString;
/**
* Key-value pairs representing HTTP headers. The elements stored in this map are not yet URL
* encoded.
* <p>
* This map cannot contain null keys or null values.
* <p>
* This field has been wrapped in a call to {@link Collections#unmodifiableMap(Map)}.
*/
@NonNull
private final Map<String, String> mRequestHeaders;
/**
* Key-value pairs representing query string parameters. The elements stored in this map are not
* yet URL encoded.
* <p>
* This map cannot contain null keys or null values.
* <p>
* This field has been wrapped in a call to {@link Collections#unmodifiableMap(Map)}.
*/
@NonNull
private final Map<String, String> mQueryParams;
/**
* Create a new {@link AbstractRequest}.
* <p>
* Note that there are some representation invariants for the {@link AbstractRequest} object.
* For example, a {@link HttpMethod#GET} cannot contain a {@code body}.
*
* @param method the {@code HttpMethod} of the request type.
* @param url the URL to request (and to append query string parameters to).
* @param requestHeaders the headers to add to the request. This cannot contain null keys or
* null values.
* @param queryParams the query string parameters. This cannot contain null keys or null values.
*/
public AbstractRequest(@NonNull final HttpMethod method, @NonNull final String url,
@Nullable final Map<String, String> requestHeaders,
@Nullable final Map<String, String> queryParams) {
PreconditionUtil.assertNotNull(method, "method");
PreconditionUtil.assertNotNull(url, "url");
if (null != requestHeaders) {
mRequestHeaders =
NullUtils.nonNullContract(Collections
.unmodifiableMap(new HashMap<String, String>(requestHeaders)));
} else {
mRequestHeaders =
NullUtils.nonNullContract(Collections
.unmodifiableMap(new HashMap<String, String>()));
}
if (null != queryParams) {
mQueryParams =
NullUtils.nonNullContract(Collections
.unmodifiableMap(new HashMap<String, String>(queryParams)));
} else {
mQueryParams =
NullUtils.nonNullContract(Collections
.unmodifiableMap(new HashMap<String, String>()));
}
mUrlString = url;
mMethod = method;
checkRep();
}
/**
* Create a new {@link AbstractRequest} from an absolute URL.
*
* @param method the {@code HttpMethod} of the request type.
* @param url The URL to request. This can include query parameters.
* @param requestHeaders the headers to add to the request. This cannot contain null keys or
* null values.
* @throws IllegalArgumentException if the URI passed in isn't an absolute URL.
*/
public AbstractRequest(@NonNull final HttpMethod method, @NonNull final Uri url,
@Nullable final Map<String, String> requestHeaders) throws IllegalArgumentException {
this(method, stripQueryParameters(url), requestHeaders, extractQueryParameters(url));
}
/**
* @param url the URL whose query parameters will be extracted.
* @return a map of the query parameters or null if there is an error parsing the URL.
*/
@Nullable
private static Map<String, String> extractQueryParameters(final Uri url) {
Map<String, String> params = null;
try {
final List<NameValuePair> paramsList =
URLEncodedUtils.parse(new URI(url.toString()), "utf-8");
params = new HashMap<String, String>(paramsList.size());
for (final NameValuePair nvp : paramsList) {
params.put(nvp.getName(), nvp.getValue());
}
} catch (final URISyntaxException e) {
// failsafe
LogManager.e(NullUtils.format("could not parse uri: '%s'. "
+ "dropping query parameters.", url), e);
}
return params;
}
/**
* Returns the portion of an absolute URL before the query string. E.g.
* {@code http://example.com/search?q=kittens} would return {@code "http://example.com/search"}.
*
* @param url the URL to strip.
* @return the URL stripped of any query parameters, including the '?'.
* @throws IllegalArgumentException if the URI passed in isn't an absolute URL.
*/
@NonNull
private static String stripQueryParameters(final Uri url) throws IllegalArgumentException {
if (!url.isAbsolute() || !url.isHierarchical()) {
throw new IllegalArgumentException("Request URI must be an absolute URL");
}
return NullUtils.nonNullContract(url.buildUpon().query(null).build().toString());
}
/**
* Constructor for parceling.
*
* @param in the parcel to read from
*/
public AbstractRequest(@NonNull final Parcel in) {
mMethod = NullUtils.nonNullContract(HttpMethod.valueOf(in.readString()));
mUrlString = NullUtils.nonNullContract(in.readString());
final Map<String, String> headers = new HashMap<String, String>();
in.readMap(headers, HashMap.class.getClassLoader());
mRequestHeaders = NullUtils.nonNullContract(Collections.unmodifiableMap(headers));
final Map<String, String> query = new HashMap<String, String>();
in.readMap(query, HashMap.class.getClassLoader());
mQueryParams = NullUtils.nonNullContract(Collections.unmodifiableMap(query));
checkRep();
}
/**
* Asserts representation invariants of this class.
*/
@SuppressWarnings({ "unused", "null" })
private void checkRep() {
if (CoreLibConstants.IS_CHECKREP_ENABLED) {
if (null == mMethod) {
throw new NullPointerException("mMethod cannot be null");
}
if (null == mUrlString) {
throw new NullPointerException("mUrlString cannot be null");
}
if (null == mRequestHeaders) {
throw new NullPointerException("mUrlString cannot be null");
}
for (final Entry<String, String> entry : mRequestHeaders.entrySet()) {
if (null == entry.getKey()) {
throw new NullPointerException("mRequestHeaders cannot contain null keys");
}
if (null == entry.getValue()) {
throw new NullPointerException("mRequestHeaders cannot contain null values");
}
}
if (null == mQueryParams) {
throw new NullPointerException("mQueryParams");
}
for (final Entry<String, String> entry : mQueryParams.entrySet()) {
if (null == entry.getKey()) {
throw new NullPointerException("mQueryParams cannot contain null keys");
}
if (null == entry.getValue()) {
throw new NullPointerException("mQueryParams cannot contain null values");
}
}
}
}
/**
* @param context the context to use to get context dependent headers.
* @return the HTTP headers for this request. This has been wrapped in a call to
* {@link Collections#unmodifiableMap(Map)}.
*/
@NonNull
public Map<String, String> getRequestHeaders(@NonNull final Context context) {
return mRequestHeaders;
}
/**
* @param context the context to use to get context dependent parameters.
* @return the parameters encoded in the query string. This has been wrapped in a call to
* {@link Collections#unmodifiableMap(Map)}.
*/
@NonNull
public Map<String, String> getQueryParams(@NonNull final Context context) {
return mQueryParams;
}
/**
* @param context Application context.
* @return the base URL String.
* @throws BadRequestException if the request is invalid.
*/
@NonNull
public String getUrlString(@NonNull final Context context) throws BadRequestException {
return mUrlString;
}
/**
* @param context the context to use to get context dependent parameters
* @return the final {@link URL} to request, including query string parameters.
* @throws BadRequestException if the request is invalid.
*/
@NonNull
public final URL getUrl(@NonNull final Context context) throws BadRequestException {
URL url = null;
final Map<String, String> queryParams = getQueryParams(context);
final Uri.Builder builder = Uri.parse(getUrlString(context)).buildUpon();
/*
* Sort the query params by their keys, this is not part of the public interface and is
* subject to change. We do this for testing purposes.
*/
final Set<String> keys = new TreeSet<String>(queryParams.keySet());
for (final String key : keys) {
builder.appendQueryParameter(key, queryParams.get(key));
}
try {
url = new URL(builder.build().toString());
} catch (final MalformedURLException e) {
LogManager.e("MalformedUrlException when getting request url", e);
final BadRequestException e2 =
new BadRequestException("MalformedUrlException when getting request url");
e2.initCause(e);
throw e2;
}
return url;
}
/**
* @return the {@link HttpMethod} for the request
*/
public final HttpMethod getMethod() {
return mMethod;
}
/**
* Subclasses must implement this write the POST body (if it has one) to the
* {@link OutputStream} passed.
*
* @param context the Application context.
* @param stream the {@link OutputStream} to write the POST body to.
* @throws IOException if writing to the {@link OutputStream} fails
*/
public abstract void writeBodyToStream(@NonNull final Context context,
@NonNull final OutputStream stream) throws IOException;
/**
* @param context the Application context.
* @return the length of the request body if there is one.
*/
public abstract int getBodyLength(@NonNull final Context context);
//@formatter:off
@SuppressWarnings("null")
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((null == mMethod) ? 0 : mMethod.hashCode());
result = prime * result + ((null == mQueryParams) ? 0 : mQueryParams.hashCode());
result = prime * result + ((null == mRequestHeaders) ? 0 : mRequestHeaders.hashCode());
result = prime * result + ((null == mUrlString) ? 0 : mUrlString.hashCode());
return result;
}
@SuppressWarnings({ "unused", "null" })
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (null == obj) {
return false;
}
if (!(obj instanceof AbstractRequest)) {
return false;
}
final AbstractRequest other = (AbstractRequest) obj;
if (mMethod != other.mMethod) {
return false;
}
if (null == mQueryParams) {
if (null != other.mQueryParams) {
return false;
}
} else if (!mQueryParams.equals(other.mQueryParams)) {
return false;
}
if (null == mRequestHeaders) {
if (null != other.mRequestHeaders) {
return false;
}
} else if (!mRequestHeaders.equals(other.mRequestHeaders)) {
return false;
}
if (null == mUrlString) {
if (null != other.mUrlString) {
return false;
}
} else if (!mUrlString.equals(other.mUrlString)) {
return false;
}
return true;
}
//@formatter:on
@Override
public String toString() {
return String.format(Locale.US,
"AbstractRequest [mMethod=%s, mUrlString=%s, mRequestHeaders=%s, mQueryParams=%s]",
mMethod, mUrlString, mRequestHeaders, mQueryParams);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeString(mMethod.name());
dest.writeString(mUrlString);
dest.writeMap(mRequestHeaders);
dest.writeMap(mQueryParams);
}
/**
* Exception that is thrown if a request is invalid at the time it it sent.
*/
public static final class BadRequestException extends Exception {
/**
* Implements {@link java.io.Serializable}.
*/
private static final long serialVersionUID = 8423248708984803306L;
/**
* Constructor.
*
* @param detailMessage the message to display in the backtrace.
*/
public BadRequestException(final String detailMessage) {
super(detailMessage);
}
}
}