package org.zstack.sdk;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import okhttp3.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Created by xing5 on 2016/12/9.
*/
public class ZSClient {
private static OkHttpClient http = new OkHttpClient();
static final Gson gson;
static final Gson prettyGson;
private static ConcurrentHashMap<String, Api> waittingApis = new ConcurrentHashMap<>();
static {
gson = new GsonBuilder().create();
prettyGson = new GsonBuilder().setPrettyPrinting().create();
}
private static ZSConfig config;
public static ZSConfig getConfig() {
return config;
}
public static void configure(ZSConfig c) {
config = c;
if (c.readTimeout != null || c.writeTimeout != null) {
OkHttpClient.Builder b = new OkHttpClient.Builder();
if (c.readTimeout != null) {
b.readTimeout(c.readTimeout, TimeUnit.MILLISECONDS);
}
if (c.writeTimeout != null) {
b.writeTimeout(c.writeTimeout, TimeUnit.MILLISECONDS);
}
http = b.build();
}
}
public static void webHookCallback(HttpServletRequest req, HttpServletResponse rsp) {
try {
StringBuilder jb = new StringBuilder();
BufferedReader reader = req.getReader();
String line;
while ((line = reader.readLine()) != null) {
jb.append(line);
}
String jobUuid = req.getHeader(Constants.HEADER_JOB_UUID);
if (jobUuid == null) {
// TODO: log error
rsp.sendError(400, String.format("missing header[%s]", Constants.HEADER_JOB_UUID));
return;
}
String jobSuccess = req.getHeader(Constants.HEADER_JOB_SUCCESS);
if (jobSuccess == null) {
// TODO: log error
rsp.sendError(400, String.format("missing header[%s]", Constants.HEADER_JOB_SUCCESS));
return;
}
boolean success = Boolean.valueOf(jobSuccess);
ApiResult res = new ApiResult();
if (!success) {
res = gson.fromJson(jb.toString(), ApiResult.class);
} else {
res.setResultString(jb.toString());
}
Api api = waittingApis.get(jobUuid);
if (api == null) {
//TODO: log error
rsp.sendError(404, String.format("no job[uuid:%s] found", jobUuid));
return;
}
api.wakeUpFromWebHook(res);
rsp.setStatus(200);
rsp.getWriter().write("");
} catch (Exception e) {
throw new ApiException(e);
}
}
static String join(Collection lst, String sep) {
String ret = "";
boolean first = true;
for (Object s : lst) {
if (first) {
ret = s.toString();
first = false;
continue;
}
ret = ret + sep + s.toString();
}
return ret;
}
static class Api {
AbstractAction action;
RestInfo info;
InternalCompletion completion;
String jobUuid = UUID.randomUUID().toString().replaceAll("-", "");
private ApiResult resultFromWebHook;
Api(AbstractAction action) {
this.action = action;
info = action.getRestInfo();
if (action.apiId != null) {
jobUuid = action.apiId;
}
}
void wakeUpFromWebHook(ApiResult res) {
if (completion == null) {
resultFromWebHook = res;
synchronized (this) {
this.notifyAll();
}
} else {
try {
completion.complete(res);
} catch (Throwable t) {
res = new ApiResult();
res.error = new ErrorCode();
res.error.code = Constants.INTERNAL_ERROR;
res.error.details = t.getMessage();
completion.complete(res);
}
}
}
private String substituteUrl(String url, Map<String, Object> tokens) {
Pattern pattern = Pattern.compile("\\{(.+?)\\}");
Matcher matcher = pattern.matcher(url);
StringBuffer buffer = new StringBuffer();
while (matcher.find()) {
String varName = matcher.group(1);
Object replacement = tokens.get(varName);
if (replacement == null) {
throw new ApiException(String.format("cannot find value for URL variable[%s]", varName));
}
matcher.appendReplacement(buffer, "");
buffer.append(replacement.toString());
}
matcher.appendTail(buffer);
return buffer.toString();
}
private List<String> getVarNamesFromUrl(String url) {
Pattern pattern = Pattern.compile("\\{(.+?)\\}");
Matcher matcher = pattern.matcher(url);
List<String> urlVars = new ArrayList<>();
while (matcher.find()) {
urlVars.add(matcher.group(1));
}
return urlVars;
}
void call(InternalCompletion completion) {
this.completion = completion;
doCall();
}
ApiResult doCall() {
action.checkParameters();
Request.Builder reqBuilder = new Request.Builder()
.addHeader(Constants.HEADER_JOB_UUID, jobUuid)
.addHeader(Constants.HEADER_JSON_SCHEMA, Boolean.TRUE.toString());
if (config.webHook != null) {
reqBuilder.addHeader(Constants.HEADER_WEBHOOK, config.webHook);
}
if (action instanceof QueryAction) {
fillQueryApiRequestBuilder(reqBuilder);
} else {
fillNonQueryApiRequestBuilder(reqBuilder);
}
Request request = reqBuilder.build();
try {
if (config.webHook != null) {
waittingApis.put(jobUuid, this);
}
try (Response response = http.newCall(request).execute()) {
if (!response.isSuccessful()) {
return httpError(response.code(), response.body().string());
}
if (response.code() == 200 || response.code() == 204) {
return writeApiResult(response);
} else if (response.code() == 202) {
if (config.webHook != null) {
return webHookResult();
} else {
return pollResult(response);
}
} else {
throw new ApiException(String.format("[Internal Error] the server returns an unknown status code[%s]", response.code()));
}
}
} catch (IOException e) {
throw new ApiException(e);
}
}
private ApiResult syncWebHookResult() {
synchronized (this) {
Long timeout = (Long)action.getParameterValue("timeout", false);
timeout = timeout == null ? config.defaultPollingTimeout : timeout;
try {
this.wait(timeout);
} catch (InterruptedException e) {
throw new ApiException(e);
}
if (resultFromWebHook == null) {
resultFromWebHook = new ApiResult();
resultFromWebHook.error = errorCode(
Constants.POLLING_TIMEOUT_ERROR,
"timeout of polling async API result",
String.format("polling result of api[%s] timeout after %s ms", action.getClass().getSimpleName(), timeout)
);
}
waittingApis.remove(jobUuid);
return resultFromWebHook;
}
}
private ApiResult webHookResult() {
if (completion == null) {
return syncWebHookResult();
} else {
return null;
}
}
private void fillQueryApiRequestBuilder(Request.Builder reqBuilder) {
QueryAction qaction = (QueryAction) action;
HttpUrl.Builder urlBuilder = new HttpUrl.Builder().scheme("http")
.host(config.hostname)
.port(config.port);
if (config.contextPath != null) {
urlBuilder.addPathSegments(config.contextPath);
}
urlBuilder.addPathSegment("v1")
.addPathSegments(info.path.replaceFirst("/", ""));
if (!qaction.conditions.isEmpty()) {
for (String cond : qaction.conditions) {
urlBuilder.addQueryParameter("q", cond);
}
}
if (qaction.limit != null) {
urlBuilder.addQueryParameter("limit", String.format("%s", qaction.limit));
}
if (qaction.start != null) {
urlBuilder.addQueryParameter("start", String.format("%s", qaction.start));
}
if (qaction.count != null) {
urlBuilder.addQueryParameter("count", String.format("%s", qaction.count));
}
if (qaction.groupBy != null) {
urlBuilder.addQueryParameter("groupBy", qaction.groupBy);
}
if (qaction.replyWithCount != null) {
urlBuilder.addQueryParameter("replyWithCount", String.format("%s", qaction.replyWithCount));
}
if (qaction.sortBy != null) {
if (qaction.sortDirection == null) {
urlBuilder.addQueryParameter("sort", String.format("%s", qaction.sortBy));
} else {
String d = "asc".equals(qaction.sortDirection) ? "+" : "-";
urlBuilder.addQueryParameter("sort", String.format("%s%s", d, qaction.replyWithCount));
}
}
if (qaction.fields != null && !qaction.fields.isEmpty()) {
urlBuilder.addQueryParameter("fields", join(qaction.fields, ","));
}
reqBuilder.addHeader(Constants.HEADER_AUTHORIZATION, String.format("%s %s", Constants.OAUTH, qaction.sessionId));
reqBuilder.url(urlBuilder.build()).get();
}
private void fillNonQueryApiRequestBuilder(Request.Builder reqBuilder) {
HttpUrl.Builder builder = new HttpUrl.Builder()
.scheme("http")
.host(config.hostname)
.port(config.port);
if (config.contextPath != null) {
builder.addPathSegments(config.contextPath);
}
// HttpUrl will add an extra / to the path segment
// so /v1/zones will become //v1//zones
// we remove the extra / here
builder.addPathSegment("v1");
List<String> varNames = getVarNamesFromUrl(info.path);
if (!varNames.isEmpty()) {
Map<String, Object> vars = new HashMap<>();
for (String vname : varNames) {
Object value = action.getParameterValue(vname);
if (value == null) {
throw new ApiException(String.format("missing required field[%s]", vname));
}
vars.put(vname, value);
}
String path = substituteUrl(info.path, vars);
builder.addPathSegments(path.replaceFirst("/", ""));
} else {
builder.addPathSegments(info.path.replaceFirst("/", ""));
}
final Map<String, Object> params = new HashMap<>();
for (String pname : action.getAllParameterNames()) {
if (varNames.contains(pname) || Constants.SESSION_ID.equals(pname)) {
// the field is set in URL variables
continue;
}
Object value = action.getParameterValue(pname);
if (value != null) {
params.put(pname, value);
}
}
if (info.httpMethod.equals("GET") || info.httpMethod.equals("DELETE")) {
for (Map.Entry<String, Object> e : params.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
if (v instanceof Collection) {
for (Object o : (Collection) v) {
builder.addQueryParameter(k, o.toString());
}
} else if (v instanceof Map) {
for (Object o : ((Map) v).entrySet()) {
Map.Entry pe = (Map.Entry) o;
if (!(pe.getKey() instanceof String)) {
throw new ApiException(String.format("%s only supports map parameter whose keys and values are both string. %s.%s.%s is not key string",
info.httpMethod, action.getClass(), k, pe.getKey()));
}
if (pe.getValue() instanceof Collection) {
for (Object i : (Collection)pe.getValue()) {
builder.addQueryParameter(String.format("%s.%s", k, pe.getKey()), i.toString());
}
} else {
builder.addQueryParameter(String.format("%s.%s", k, pe.getKey()), pe.getValue().toString());
}
}
} else {
builder.addQueryParameter(k, v.toString());
}
}
if (info.httpMethod.equals("GET")) {
reqBuilder.url(builder.build().url().toString()).get();
} else if (info.httpMethod.equals("DELETE")) {
reqBuilder.url(builder.build().url().toString()).delete();
} else {
throw new RuntimeException("should not be here");
}
} else {
Map m = new HashMap();
m.put(info.parameterName, params);
reqBuilder.url(builder.build().url().toString()).method(info.httpMethod, RequestBody.create(Constants.JSON, gson.toJson(m)));
}
if (info.needSession) {
Object sessionId = action.getParameterValue(Constants.SESSION_ID);
reqBuilder.addHeader(Constants.HEADER_AUTHORIZATION, String.format("%s %s", Constants.OAUTH, sessionId));
}
}
private ApiResult pollResult(Response response) throws IOException {
if (!info.needPoll) {
throw new ApiException(String.format("[Internal Error] the api[%s] is not an async API but" +
" the server returns 201 status code", action.getClass().getSimpleName()));
}
Map body = gson.fromJson(response.body().string(), LinkedHashMap.class);
String pollingUrl = (String) body.get(Constants.LOCATION);
if (pollingUrl == null) {
throw new ApiException(String.format("Internal Error] the api[%s] is an async API but the server" +
" doesn't return the polling location url", action.getClass().getSimpleName()));
}
if (completion == null) {
// sync polling
return syncPollResult(pollingUrl);
} else {
// async polling
asyncPollResult(pollingUrl);
return null;
}
}
private void asyncPollResult(final String url) {
final long current = System.currentTimeMillis();
final Long timeout = (Long)action.getParameterValue("timeout", false);
final long expiredTime = current + (timeout == null ? config.defaultPollingTimeout : timeout);
final Long i = (Long) action.getParameterValue("pollingInterval", false);
final Object sessionId = action.getParameterValue(Constants.SESSION_ID);
final Timer timer = new Timer();
timer.schedule(new TimerTask() {
long count = current;
long interval = i == null ? config.defaultPollingInterval : i;
private void done(ApiResult res) {
completion.complete(res);
timer.cancel();
}
@Override
public void run() {
Request req = new Request.Builder()
.url(url)
.addHeader(Constants.HEADER_AUTHORIZATION, String.format("%s %s", Constants.OAUTH, sessionId))
.addHeader(Constants.HEADER_JSON_SCHEMA, Boolean.TRUE.toString())
.get()
.build();
try {
try (Response response = http.newCall(req).execute()) {
if (response.code() != 200 && response.code() != 503 && response.code() != 202) {
done(httpError(response.code(), response.body().string()));
return;
}
// 200 means the task has been completed successfully,
// or a 505 indicates a failure,
// otherwise a 202 returned means it is still
// in processing
if (response.code() == 200 || response.code() == 503) {
done(writeApiResult(response));
return;
}
count += interval;
if (count >= expiredTime) {
ApiResult res = new ApiResult();
res.error = errorCode(
Constants.POLLING_TIMEOUT_ERROR,
"timeout of polling async API result",
String.format("polling result of api[%s] timeout after %s ms", action.getClass().getSimpleName(), timeout)
);
done(res);
}
}
} catch (Throwable e) {
//TODO: logging
ApiResult res = new ApiResult();
res.error = errorCode(
Constants.INTERNAL_ERROR,
"an internal error happened",
e.getMessage()
);
done(res);
}
}
}, 0, i);
}
private ErrorCode errorCode(String id, String s, String d) {
ErrorCode err = new ErrorCode();
err.code = id;
err.description = s;
err.details = d;
return err;
}
private ApiResult syncPollResult(String url) {
long current = System.currentTimeMillis();
Long timeout = (Long)action.getParameterValue("timeout", false);
long expiredTime = current + (timeout == null ? config.defaultPollingTimeout : timeout);
Long interval = (Long) action.getParameterValue("pollingInterval", false);
interval = interval == null ? config.defaultPollingInterval : interval;
Object sessionId = action.getParameterValue(Constants.SESSION_ID);
while (current < expiredTime) {
Request req = new Request.Builder()
.url(url)
.addHeader(Constants.HEADER_AUTHORIZATION, String.format("%s %s", Constants.OAUTH, sessionId))
.addHeader(Constants.HEADER_JSON_SCHEMA, Boolean.TRUE.toString())
.get()
.build();
try {
try (Response response = http.newCall(req).execute()) {
if (response.code() != 200 && response.code() != 503 && response.code() != 202) {
return httpError(response.code(), response.body().string());
}
// 200 means the task has been completed
// otherwise a 202 returned means it is still
// in processing
if (response.code() == 200 || response.code() == 503) {
return writeApiResult(response);
}
TimeUnit.MILLISECONDS.sleep(interval);
current += interval;
}
} catch (InterruptedException e) {
//ignore
} catch (IOException e) {
throw new ApiException(e);
}
}
ApiResult res = new ApiResult();
res.error = errorCode(
Constants.POLLING_TIMEOUT_ERROR,
"timeout of polling async API result",
String.format("polling result of api[%s] timeout after %s ms", action.getClass().getSimpleName(), timeout)
);
return res;
}
private ApiResult writeApiResult(Response response) throws IOException {
ApiResult res = new ApiResult();
if (response.code() == 200) {
res.setResultString(response.body().string());
} else if (response.code() == 503) {
res = gson.fromJson(response.body().string(), ApiResult.class);
} else {
throw new ApiException(String.format("unknown status code: %s", response.code()));
}
return res;
}
private ApiResult httpError(int code, String details) {
ApiResult res = new ApiResult();
res.error = errorCode(
Constants.HTTP_ERROR,
String.format("the http status code[%s] indicates a failure happened", code),
details
);
return res;
}
ApiResult call() {
return doCall();
}
}
private static void errorIfNotConfigured() {
if (config == null) {
throw new RuntimeException("setConfig() must be called before any methods");
}
}
static void call(AbstractAction action, InternalCompletion completion) {
errorIfNotConfigured();
new Api(action).call(completion);
}
static ApiResult call(AbstractAction action) {
errorIfNotConfigured();
return new Api(action).call();
}
}