blob: 771423effce86c18ee987b8ec437d50abf1d1642 [file] [log] [blame]
// Copyright (C) 2012 The Android Open Source Project
//
// 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.google.gerrit.client.rpc;
import static com.google.gwt.http.client.RequestBuilder.DELETE;
import static com.google.gwt.http.client.RequestBuilder.GET;
import static com.google.gwt.http.client.RequestBuilder.POST;
import static com.google.gwt.http.client.RequestBuilder.PUT;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.RpcStatus;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.http.client.Request;
import com.google.gwt.http.client.RequestBuilder;
import com.google.gwt.http.client.RequestBuilder.Method;
import com.google.gwt.http.client.RequestCallback;
import com.google.gwt.http.client.RequestException;
import com.google.gwt.http.client.Response;
import com.google.gwt.http.client.URL;
import com.google.gwt.json.client.JSONException;
import com.google.gwt.json.client.JSONParser;
import com.google.gwt.json.client.JSONValue;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.rpc.StatusCodeException;
/** Makes a REST API call to the server. */
public class RestApi {
private static final int SC_UNAVAILABLE = 2;
private static final int SC_BAD_TRANSPORT = 3;
private static final int SC_BAD_RESPONSE = 4;
private static final String JSON_TYPE = "application/json";
private static final String JSON_UTF8 = JSON_TYPE + "; charset=utf-8";
private static final String TEXT_TYPE = "text/plain";
private static final String TEXT_UTF8 = TEXT_TYPE + "; charset=utf-8";
/**
* Expected JSON content body prefix that prevents XSSI.
* <p>
* The server always includes this line as the first line of the response
* content body when the response body is formatted as JSON. It gets inserted
* by the server to prevent the resource from being imported into another
* domain's page using a &lt;script&gt; tag. This line must be removed before
* the JSON can be parsed.
*/
private static final String JSON_MAGIC = ")]}'\n";
/** True if err is a StatusCodeException reporting Not Found. */
public static boolean isNotFound(Throwable err) {
return isStatus(err, Response.SC_NOT_FOUND);
}
/** True if err is describing a user that is currently anonymous. */
public static boolean isNotSignedIn(Throwable err) {
if (err instanceof StatusCodeException) {
StatusCodeException sce = (StatusCodeException) err;
if (sce.getStatusCode() == Response.SC_UNAUTHORIZED) {
return true;
}
return sce.getStatusCode() == Response.SC_FORBIDDEN
&& (sce.getEncodedResponse().equals("Authentication required")
|| sce.getEncodedResponse().startsWith("Must be signed-in")
|| sce.getEncodedResponse().startsWith("Invalid authentication"));
}
return false;
}
/** True if err is a StatusCodeException with a specific HTTP code. */
public static boolean isStatus(Throwable err, int status) {
return err instanceof StatusCodeException
&& ((StatusCodeException) err).getStatusCode() == status;
}
/** Is the Gerrit Code Review server likely to return this status? */
public static boolean isExpected(int statusCode) {
switch (statusCode) {
case SC_UNAVAILABLE:
case 400: // Bad Request
case 401: // Unauthorized
case 403: // Forbidden
case 404: // Not Found
case 405: // Method Not Allowed
case 409: // Conflict
case 412: // Precondition Failed
case 422: // Unprocessable Entity
case 429: // Too Many Requests (RFC 6585)
return true;
default:
// Assume any other code is not expected. These may be
// local proxy server errors outside of our control.
return false;
}
}
private static class HttpImpl<T extends JavaScriptObject>
implements RequestCallback {
private final boolean background;
private final HttpCallback<T> cb;
HttpImpl(boolean bg, HttpCallback<T> cb) {
this.background = bg;
this.cb = cb;
}
@Override
public void onResponseReceived(Request req, final Response res) {
int status = res.getStatusCode();
if (status == Response.SC_NO_CONTENT) {
cb.onSuccess(new HttpResponse<T>(res, null, null));
if (!background) {
RpcStatus.INSTANCE.onRpcComplete();
}
} else if (200 <= status && status < 300) {
long start = System.currentTimeMillis();
final T data;
final String type;
if (isJsonBody(res)) {
try {
JSONValue val = parseJson(res);
if (isJsonEncoded(res) && val.isString() != null) {
data = NativeString.wrap(val.isString().stringValue()).cast();
type = simpleType(res.getHeader("X-FYI-Content-Type"));
} else {
data = RestApi.<T> cast(val);
type = JSON_TYPE;
}
} catch (JSONException e) {
if (!background) {
RpcStatus.INSTANCE.onRpcComplete();
}
cb.onFailure(new StatusCodeException(SC_BAD_RESPONSE,
"Invalid JSON: " + e.getMessage()));
return;
}
} else if (isTextBody(res)) {
data = NativeString.wrap(res.getText()).cast();
type = TEXT_TYPE;
} else {
if (!background) {
RpcStatus.INSTANCE.onRpcComplete();
}
cb.onFailure(new StatusCodeException(SC_BAD_RESPONSE, "Expected "
+ JSON_TYPE + " or " + TEXT_TYPE + "; received Content-Type: "
+ res.getHeader("Content-Type")));
return;
}
Scheduler.ScheduledCommand cmd = new Scheduler.ScheduledCommand() {
@Override
public void execute() {
try {
cb.onSuccess(new HttpResponse<>(res, type, data));
} finally {
if (!background) {
RpcStatus.INSTANCE.onRpcComplete();
}
}
}
};
// Defer handling the response if the parse took a while.
if ((System.currentTimeMillis() - start) > 75) {
Scheduler.get().scheduleDeferred(cmd);
} else {
cmd.execute();
}
} else {
String msg;
if (isTextBody(res)) {
msg = res.getText().trim();
} else if (isJsonBody(res)) {
JSONValue v;
try {
v = parseJson(res);
} catch (JSONException e) {
v = null;
}
if (v != null && v.isString() != null) {
msg = v.isString().stringValue();
} else {
msg = trimJsonMagic(res.getText()).trim();
}
} else {
msg = res.getStatusText();
}
if (!background) {
RpcStatus.INSTANCE.onRpcComplete();
}
cb.onFailure(new StatusCodeException(status, msg));
}
}
@Override
public void onError(Request req, Throwable err) {
if (!background) {
RpcStatus.INSTANCE.onRpcComplete();
}
if (err.getMessage().contains("XmlHttpRequest.status")) {
cb.onFailure(new StatusCodeException(
SC_UNAVAILABLE,
RpcConstants.C.errorServerUnavailable()));
} else {
cb.onFailure(new StatusCodeException(SC_BAD_TRANSPORT, err.getMessage()));
}
}
}
private StringBuilder url;
private boolean hasQueryParams;
private boolean background;
private String ifNoneMatch;
/**
* Initialize a new API call.
* <p>
* By default the JSON format will be selected by including an HTTP Accept
* header in the request.
*
* @param name URL of the REST resource to access, e.g. {@code "/projects/"}
* to list accessible projects from the server.
*/
public RestApi(String name) {
if (name.startsWith("/")) {
name = name.substring(1);
}
url = new StringBuilder();
url.append(GWT.getHostPageBaseURL());
url.append(name);
}
public RestApi view(String name) {
return idRaw(name);
}
public RestApi id(String id) {
return idRaw(URL.encodeQueryString(id));
}
public RestApi id(int id) {
return idRaw(Integer.toString(id));
}
public RestApi idRaw(String name) {
if (hasQueryParams) {
throw new IllegalStateException();
}
if (url.charAt(url.length() - 1) != '/') {
url.append('/');
}
url.append(name);
return this;
}
public RestApi addParameter(String name, String value) {
return addParameterRaw(name, URL.encodeQueryString(value));
}
public RestApi addParameter(String name, String... value) {
for (String val : value) {
addParameter(name, val);
}
return this;
}
public RestApi addParameterTrue(String name) {
return addParameterRaw(name, null);
}
public RestApi addParameter(String name, boolean value) {
return addParameterRaw(name, value ? "t" : "f");
}
public RestApi addParameter(String name, int value) {
return addParameterRaw(name, String.valueOf(value));
}
public RestApi addParameter(String name, Enum<?> value) {
return addParameterRaw(name, value.name());
}
public RestApi addParameterRaw(String name, String value) {
if (hasQueryParams) {
url.append("&");
} else {
url.append("?");
hasQueryParams = true;
}
url.append(name);
if (value != null) {
url.append("=").append(value);
}
return this;
}
public RestApi ifNoneMatch() {
return ifNoneMatch("*");
}
public RestApi ifNoneMatch(String etag) {
ifNoneMatch = etag;
return this;
}
public RestApi background() {
background = true;
return this;
}
public String url() {
return url.toString();
}
public <T extends JavaScriptObject> void get(AsyncCallback<T> cb) {
get(wrap(cb));
}
public <T extends JavaScriptObject> void get(HttpCallback<T> cb) {
send(GET, cb);
}
public <T extends JavaScriptObject> void delete(AsyncCallback<T> cb) {
delete(wrap(cb));
}
public <T extends JavaScriptObject> void delete(HttpCallback<T> cb) {
send(DELETE, cb);
}
private <T extends JavaScriptObject> void send(Method method,
HttpCallback<T> cb) {
HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
try {
if (!background) {
RpcStatus.INSTANCE.onRpcStart();
}
request(method).sendRequest(null, httpCallback);
} catch (RequestException e) {
httpCallback.onError(null, e);
}
}
public <T extends JavaScriptObject> void post(
JavaScriptObject content,
AsyncCallback<T> cb) {
post(content, wrap(cb));
}
public <T extends JavaScriptObject> void post(
JavaScriptObject content,
HttpCallback<T> cb) {
sendJSON(POST, content, cb);
}
public <T extends JavaScriptObject> void post(String content,
AsyncCallback<T> cb) {
post(content, wrap(cb));
}
public <T extends JavaScriptObject> void post(String content,
HttpCallback<T> cb) {
sendText(POST, content, cb);
}
public <T extends JavaScriptObject> void put(AsyncCallback<T> cb) {
put(wrap(cb));
}
public <T extends JavaScriptObject> void put(HttpCallback<T> cb) {
send(PUT, cb);
}
public <T extends JavaScriptObject> void put(String content,
AsyncCallback<T> cb) {
put(content, wrap(cb));
}
public <T extends JavaScriptObject> void put(String content,
HttpCallback<T> cb) {
sendText(PUT, content, cb);
}
public <T extends JavaScriptObject> void put(
JavaScriptObject content,
AsyncCallback<T> cb) {
put(content, wrap(cb));
}
public <T extends JavaScriptObject> void put(
JavaScriptObject content,
HttpCallback<T> cb) {
sendJSON(PUT, content, cb);
}
private <T extends JavaScriptObject> void sendJSON(
Method method, JavaScriptObject content,
HttpCallback<T> cb) {
HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
try {
if (!background) {
RpcStatus.INSTANCE.onRpcStart();
}
RequestBuilder req = request(method);
req.setHeader("Content-Type", JSON_UTF8);
req.sendRequest(str(content), httpCallback);
} catch (RequestException e) {
httpCallback.onError(null, e);
}
}
private static native String str(JavaScriptObject jso)
/*-{ return JSON.stringify(jso) }-*/;
private <T extends JavaScriptObject> void sendText(Method method, String body,
HttpCallback<T> cb) {
HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
try {
if (!background) {
RpcStatus.INSTANCE.onRpcStart();
}
RequestBuilder req = request(method);
req.setHeader("Content-Type", TEXT_UTF8);
req.sendRequest(body, httpCallback);
} catch (RequestException e) {
httpCallback.onError(null, e);
}
}
private RequestBuilder request(Method method) {
RequestBuilder req = new RequestBuilder(method, url());
if (ifNoneMatch != null) {
req.setHeader("If-None-Match", ifNoneMatch);
}
req.setHeader("Accept", JSON_TYPE);
if (Gerrit.getXGerritAuth() != null) {
req.setHeader("X-Gerrit-Auth", Gerrit.getXGerritAuth());
}
return req;
}
private static boolean isJsonBody(Response res) {
return isContentType(res, JSON_TYPE);
}
private static boolean isTextBody(Response res) {
return isContentType(res, TEXT_TYPE);
}
private static boolean isJsonEncoded(Response res) {
return "json".equals(res.getHeader("X-FYI-Content-Encoding"));
}
private static boolean isContentType(Response res, String want) {
String type = res.getHeader("Content-Type");
return type != null && want.equals(simpleType(type));
}
private static String simpleType(String type) {
int semi = type.indexOf(';');
if (semi >= 0) {
return type.substring(0, semi).trim();
}
return type;
}
private static JSONValue parseJson(Response res)
throws JSONException {
String json = trimJsonMagic(res.getText());
if (json.isEmpty()) {
throw new JSONException("response was empty");
}
return JSONParser.parseStrict(json);
}
private static String trimJsonMagic(String json) {
if (json.startsWith(JSON_MAGIC)) {
json = json.substring(JSON_MAGIC.length());
}
return json;
}
@SuppressWarnings("unchecked")
private static <T extends JavaScriptObject> T cast(JSONValue val) {
if (val.isObject() != null) {
return (T) val.isObject().getJavaScriptObject();
} else if (val.isArray() != null) {
return (T) val.isArray().getJavaScriptObject();
} else if (val.isString() != null) {
return (T) NativeString.wrap(val.isString().stringValue());
} else if (val.isNull() != null) {
return null;
} else {
throw new JSONException("unsupported JSON type");
}
}
private static <T extends JavaScriptObject> HttpCallback<T> wrap(
final AsyncCallback<T> cb) {
return new HttpCallback<T>() {
@Override
public void onSuccess(HttpResponse<T> r) {
cb.onSuccess(r.getResult());
}
@Override
public void onFailure(Throwable e) {
cb.onFailure(e);
}
};
}
}