package org.zstack.rest;
import okhttp3.*;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.reflections.Reflections;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.util.UriComponentsBuilder;
import org.zstack.core.Platform;
import org.zstack.core.cloudbus.CloudBus;
import org.zstack.core.cloudbus.CloudBusEventListener;
import org.zstack.core.retry.Retry;
import org.zstack.core.retry.RetryCondition;
import org.zstack.header.Component;
import org.zstack.header.MapField;
import org.zstack.header.apimediator.ApiMediatorConstant;
import org.zstack.header.exception.CloudRuntimeException;
import org.zstack.header.identity.SessionInventory;
import org.zstack.header.identity.SuppressCredentialCheck;
import org.zstack.header.message.*;
import org.zstack.header.query.APIQueryMessage;
import org.zstack.header.query.APIQueryReply;
import org.zstack.header.query.QueryCondition;
import org.zstack.header.query.QueryOp;
import org.zstack.header.rest.APINoSee;
import org.zstack.header.rest.RESTFacade;
import org.zstack.header.rest.RestRequest;
import org.zstack.header.rest.RestResponse;
import org.zstack.rest.sdk.DocumentGenerator;
import org.zstack.rest.sdk.SdkTemplate;
import org.zstack.rest.sdk.SdkFile;
import org.zstack.utils.*;
import org.zstack.utils.gson.JSONObjectUtil;
import org.zstack.utils.logging.CLogger;
import org.zstack.utils.path.PathUtil;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.net.URLDecoder;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static java.util.Arrays.asList;
/**
* Created by xing5 on 2016/12/7.
*/
public class RestServer implements Component, CloudBusEventListener {
private static final CLogger logger = Utils.getLogger(RestServer.class);
private static final Logger requestLogger = LogManager.getLogger("api.request");
private static ThreadLocal<RequestInfo> requestInfo = new ThreadLocal<>();
private static final OkHttpClient http = new OkHttpClient();
private MediaType JSON = MediaType.parse("application/json; charset=utf-8");
@Autowired
private CloudBus bus;
@Autowired
private AsyncRestApiStore asyncStore;
@Autowired
private RESTFacade restf;
static class RequestInfo {
// don't save session to database as JSON
// it's not JSON-dumpable
transient HttpSession session;
String remoteHost;
String requestUrl;
HttpHeaders headers = new HttpHeaders();
public RequestInfo(HttpServletRequest req) {
session = req.getSession();
remoteHost = req.getRemoteHost();
for (Enumeration e = req.getHeaderNames(); e.hasMoreElements() ;) {
String name = e.nextElement().toString();
headers.add(name, req.getHeader(name));
}
try {
requestUrl = URLDecoder.decode(req.getRequestURI(), "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new CloudRuntimeException(e);
}
}
}
private static final String ASYNC_JOB_PATH_PATTERN = String.format("%s/%s/{uuid}", RestConstants.API_VERSION, RestConstants.ASYNC_JOB_PATH);
public static void generateDocTemplate(String path, DocumentGenerator.DocMode mode) {
DocumentGenerator rg = GroovyUtils.newInstance("scripts/RestDocumentationGenerator.groovy");
rg.generateDocTemplates(path, mode);
}
public static void generateMarkdownDoc(String path) {
DocumentGenerator rg = GroovyUtils.newInstance("scripts/RestDocumentationGenerator.groovy");
rg.generateMarkDown(path, PathUtil.join(System.getProperty("user.home"), "zstack-markdown"));
}
public static void generateJavaSdk() {
String path = PathUtil.join(System.getProperty("user.home"), "zstack-sdk/java");
File folder = new File(path);
if (!folder.exists()) {
folder.mkdirs();
}
try {
Class clz = GroovyUtils.getClass("scripts/SdkApiTemplate.groovy", RestServer.class.getClassLoader());
Set<Class<?>> apiClasses = Platform.getReflections().getTypesAnnotatedWith(RestRequest.class)
.stream().filter(it -> it.isAnnotationPresent(RestRequest.class)).collect(Collectors.toSet());
List<SdkFile> allFiles = new ArrayList<>();
for (Class apiClz : apiClasses) {
if (Modifier.isAbstract(apiClz.getModifiers())) {
continue;
}
SdkTemplate tmp = (SdkTemplate) clz.getConstructor(Class.class).newInstance(apiClz);
allFiles.addAll(tmp.generate());
}
SdkTemplate tmp = GroovyUtils.newInstance("scripts/SdkDataStructureGenerator.groovy", RestServer.class.getClassLoader());
allFiles.addAll(tmp.generate());
for (SdkFile f : allFiles) {
//logger.debug(String.format("\n%s", f.getContent()));
String fpath = PathUtil.join(path, f.getFileName());
FileUtils.writeStringToFile(new File(fpath), f.getContent());
}
} catch (Exception e) {
logger.warn(e.getMessage(), e);
throw new CloudRuntimeException(e);
}
}
@Override
public boolean handleEvent(Event e) {
if (e instanceof APIEvent) {
RequestData d = asyncStore.complete((APIEvent) e);
if (d != null && d.webHook != null) {
try {
callWebHook(d);
} catch (Throwable t) {
throw new CloudRuntimeException(t);
}
}
}
return false;
}
static class WebHookRetryException extends RuntimeException {
public WebHookRetryException() {
}
public WebHookRetryException(String message) {
super(message);
}
public WebHookRetryException(String message, Throwable cause) {
super(message, cause);
}
public WebHookRetryException(Throwable cause) {
super(cause);
}
public WebHookRetryException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
private void callWebHook(RequestData d) throws IllegalAccessException, NoSuchMethodException, InvocationTargetException {
requestInfo.set(d.requestInfo);
AsyncRestQueryResult ret = asyncStore.query(d.apiMessage.getId());
ApiResponse response = new ApiResponse();
// task is done
APIEvent evt = ret.getResult();
if (evt.isSuccess()) {
RestResponseWrapper w = responseAnnotationByClass.get(evt.getClass());
if (w == null) {
throw new CloudRuntimeException(String.format("cannot find RestResponseWrapper for the class[%s]", evt.getClass()));
}
writeResponse(response, w, ret.getResult());
} else {
response.setError(evt.getError());
}
String body = JSONObjectUtil.toJsonString(response);
HttpUrl url = HttpUrl.parse(d.webHook);
Request.Builder rb = new Request.Builder().url(url)
.post(RequestBody.create(JSON, body))
.addHeader(RestConstants.HEADER_JOB_UUID, d.apiMessage.getId())
.addHeader(RestConstants.HEADER_JOB_SUCCESS, String.valueOf(evt.isSuccess()));
Request request = rb.build();
new Retry<Void>() {
String __name__ = String.format("call-webhook-%s", d.webHook);
@Override
@RetryCondition(onExceptions = {WebHookRetryException.class}, times = 15, interval = 2)
protected Void call() {
try {
if (requestLogger.isTraceEnabled()) {
StringBuilder sb = new StringBuilder(String.format("Call Web-Hook[%s] (to %s%s)", d.webHook, d.requestInfo.remoteHost, d.requestInfo.requestUrl));
sb.append(String.format(" Body: %s", body));
requestLogger.trace(sb.toString());
}
Response r = http.newCall(request).execute();
if (r.code() < 200 || r.code() >= 300) {
throw new WebHookRetryException(String.format("failed to post to the webhook[%s], %s",
d.webHook, r.toString()));
}
} catch (IOException e) {
throw new WebHookRetryException(e);
}
return null;
}
}.run();
}
class Api {
Class apiClass;
Class apiResponseClass;
RestRequest requestAnnotation;
RestResponse responseAnnotation;
Map<String, String> requestMappingFields;
String path;
List<String> optionalPaths = new ArrayList<>();
String actionName;
Map<String, Field> allApiClassFields = new HashMap<>();
@Override
public String toString() {
return String.format("%s-%s", requestAnnotation.method(), "null".equals(requestAnnotation.path()) ? apiClass.getName() : path);
}
Api(Class clz, RestRequest at) {
apiClass = clz;
requestAnnotation = at;
apiResponseClass = at.responseClass();
path = String.format("%s%s", RestConstants.API_VERSION, at.path());
if (at.mappingFields().length > 0) {
requestMappingFields = new HashMap<>();
for (String mf : at.mappingFields()) {
String[] kv = mf.split("=");
if (kv.length != 2) {
throw new CloudRuntimeException(String.format("bad requestMappingField[%s] of %s", mf, apiClass));
}
requestMappingFields.put(kv[0].trim(), kv[1].trim());
}
}
responseAnnotation = (RestResponse) apiResponseClass.getAnnotation(RestResponse.class);
DebugUtils.Assert(responseAnnotation != null, String.format("%s must be annotated with @RestResponse", apiResponseClass));
Collections.addAll(optionalPaths, at.optionalPaths());
optionalPaths = optionalPaths.stream().map( p -> String.format("%s%s", RestConstants.API_VERSION, p)).collect(Collectors.toList());
if (at.isAction()) {
actionName = StringUtils.removeStart(apiClass.getSimpleName(), "API");
actionName = StringUtils.removeEnd(actionName, "Msg");
actionName = StringUtils.uncapitalize(actionName);
}
if (!at.isAction() && requestAnnotation.parameterName().isEmpty() && requestAnnotation.method() == HttpMethod.PUT) {
throw new CloudRuntimeException(String.format("Invalid @RestRequest of %s, either isAction must be set to true or" +
" parameterName is set to a non-empty string", apiClass.getName()));
}
List<Field> fs = FieldUtils.getAllFields(apiClass);
fs = fs.stream().filter(f -> !f.isAnnotationPresent(APINoSee.class) && !Modifier.isStatic(f.getModifiers())).collect(Collectors.toList());
for (Field f : fs) {
allApiClassFields.put(f.getName(), f);
if (requestAnnotation.method() == HttpMethod.GET) {
if (APIQueryMessage.class.isAssignableFrom(apiClass)) {
// query messages are specially handled
continue;
}
if (Collection.class.isAssignableFrom(f.getType())) {
Class gtype = FieldUtils.getGenericType(f);
if (gtype == null) {
throw new CloudRuntimeException(String.format("%s.%s is of collection type but doesn't not have" +
" a generic type", apiClass, f.getName()));
}
if (!gtype.getName().startsWith("java.")) {
throw new CloudRuntimeException(String.format("%s.%s is of collection type with a generic type" +
"[%s] not belonging to JDK", apiClass, f.getName(), gtype));
}
} else if (Map.class.isAssignableFrom(f.getType())) {
throw new CloudRuntimeException(String.format("%s.%s is of map type, however, the GET method doesn't" +
" support query parameters of map type", apiClass, f.getName()));
}
}
}
}
String getMappingField(String key) {
if (requestMappingFields == null) {
return null;
}
return requestMappingFields.get(key);
}
private void mapQueryParameterToApiFieldValue(String name, String[] vals, Map<String, Object> params) throws RestException {
String[] pairs = name.split("\\.");
String fname = pairs[0];
String key = pairs[1];
Field f = allApiClassFields.get(fname);
if (f == null) {
logger.warn(String.format("unknown map query parameter[%s], ignore", name));
return;
}
MapField at = f.getAnnotation(MapField.class);
DebugUtils.Assert(at!=null, String.format("%s::%s must be annotated by @MapField", apiClass, fname));
Map m = (Map) params.get(fname);
if (m == null) {
m = new HashMap();
params.put(fname, m);
}
if (m.containsKey(key)) {
throw new RestException(HttpStatus.BAD_REQUEST.value(),
String.format("duplicate map query parameter[%s], there has been a parameter with the same map key", name));
}
if (Collection.class.isAssignableFrom(at.valueType())) {
m.put(key, asList(vals));
} else {
if (vals.length > 1) {
throw new RestException(HttpStatus.BAD_REQUEST.value(),
String.format("Invalid query parameter[%s], only one value is allowed for the parameter but" +
" multiple values found", name));
}
m.put(key, vals[0]);
}
}
Object queryParameterToApiFieldValue(String name, String[] vals) throws RestException {
Field f = allApiClassFields.get(name);
if (f == null) {
return null;
}
if (Collection.class.isAssignableFrom(f.getType())) {
Class gtype = FieldUtils.getGenericType(f);
List lst = new ArrayList();
for (String v : vals) {
lst.add(TypeUtils.stringToValue(v, gtype));
}
return lst;
} else {
if (vals.length > 1) {
throw new RestException(HttpStatus.BAD_REQUEST.value(),
String.format("Invalid query parameter[%s], only one value is allowed for the parameter but" +
" multiple values found", name));
}
return TypeUtils.stringToValue(vals[0], f.getType());
}
}
}
class RestException extends Exception {
private int statusCode;
private String error;
public RestException(int statusCode, String error) {
this.statusCode = statusCode;
this.error = error;
}
}
class RestResponseWrapper {
RestResponse annotation;
Map<String, String> responseMappingFields = new HashMap<>();
Class apiResponseClass;
public RestResponseWrapper(RestResponse annotation, Class apiResponseClass) {
this.annotation = annotation;
this.apiResponseClass = apiResponseClass;
if (annotation.fieldsTo().length > 0) {
responseMappingFields = new HashMap<>();
if (annotation.fieldsTo().length == 1 && "all".equals(annotation.fieldsTo()[0])) {
List<Field> apiFields = FieldUtils.getAllFields(apiResponseClass);
apiFields = apiFields.stream().filter(f -> !f.isAnnotationPresent(APINoSee.class) && !Modifier.isStatic(f.getModifiers())).collect(Collectors.toList());
for (Field f : apiFields) {
responseMappingFields.put(f.getName(), f.getName());
}
} else {
for (String mf : annotation.fieldsTo()) {
String[] kv = mf.split("=");
if (kv.length == 2) {
responseMappingFields.put(kv[0].trim(), kv[1].trim());
} else if (kv.length == 1) {
responseMappingFields.put(kv[0].trim(), kv[0].trim());
} else {
throw new CloudRuntimeException(String.format("bad mappingFields[%s] of %s", mf, apiResponseClass));
}
}
}
}
}
}
void init() throws IllegalAccessException, InstantiationException {
bus.subscribeEvent(this, new APIEvent());
}
private AntPathMatcher matcher = new AntPathMatcher();
private Map<String, Object> apis = new HashMap<>();
private Map<Class, RestResponseWrapper> responseAnnotationByClass = new HashMap<>();
private HttpEntity<String> toHttpEntity(HttpServletRequest req) {
try {
String body = IOUtils.toString(req.getReader());
req.getReader().close();
HttpHeaders header = new HttpHeaders();
for (Enumeration e = req.getHeaderNames(); e.hasMoreElements() ;) {
String name = e.nextElement().toString();
header.add(name, req.getHeader(name));
}
return new HttpEntity<>(body, header);
} catch (Exception e) {
logger.warn(e.getMessage(), e);
throw new CloudRuntimeException(e);
}
}
private void sendResponse(int statusCode, String body, HttpServletResponse rsp) throws IOException {
if (requestLogger.isTraceEnabled()) {
RequestInfo info = requestInfo.get();
StringBuilder sb = new StringBuilder(String.format("[ID: %s] Response to %s (%s),", info.session.getId(),
info.remoteHost, info.requestUrl));
sb.append(String.format(" Status Code: %s,", statusCode));
sb.append(String.format(" Body: %s", body == null || body.isEmpty() ? null : body));
requestLogger.trace(sb.toString());
}
rsp.setStatus(statusCode);
rsp.getWriter().write(body == null ? "" : body);
}
private String getDecodedUrl(HttpServletRequest req) {
try {
if (req.getContextPath() == null) {
return URLDecoder.decode(req.getRequestURI(), "UTF-8");
} else {
return URLDecoder.decode(StringUtils.removeStart(req.getRequestURI(), req.getContextPath()), "UTF-8");
}
} catch (UnsupportedEncodingException e) {
throw new CloudRuntimeException(e);
}
}
void handle(HttpServletRequest req, HttpServletResponse rsp) throws IOException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
requestInfo.set(new RequestInfo(req));
rsp.setCharacterEncoding("utf-8");
String path = getDecodedUrl(req);
HttpEntity<String> entity = toHttpEntity(req);
if (requestLogger.isTraceEnabled()) {
StringBuilder sb = new StringBuilder(String.format("[ID: %s, Method: %s] Request from %s (to %s), ",
req.getSession().getId(), req.getMethod(),
req.getRemoteHost(), URLDecoder.decode(req.getRequestURI(), "UTF-8")));
sb.append(String.format(" Headers: %s,", JSONObjectUtil.toJsonString(entity.getHeaders())));
if (req.getQueryString() != null && !req.getQueryString().isEmpty()) {
sb.append(String.format(" Query: %s,", URLDecoder.decode(req.getQueryString(), "UTF-8")));
}
sb.append(String.format(" Body: %s", entity.getBody().isEmpty() ? null : entity.getBody()));
requestLogger.trace(sb.toString());
}
if (matcher.match(ASYNC_JOB_PATH_PATTERN, path)) {
handleJobQuery(req, rsp);
return;
}
Object api = apis.get(path);
if (api == null) {
for (String p : apis.keySet()) {
if (matcher.match(p, path)) {
api = apis.get(p);
break;
}
}
}
if (api == null) {
sendResponse(HttpStatus.NOT_FOUND.value(), String.format("no api mapping to %s", path), rsp);
return;
}
try {
if (api instanceof Api) {
handleUniqueApi((Api) api, entity, req, rsp);
} else {
handleNonUniqueApi((Collection)api, entity, req, rsp);
}
} catch (RestException e) {
sendResponse(e.statusCode, e.error, rsp);
} catch (Throwable e) {
logger.warn(String.format("failed to handle API to %s", path), e);
sendResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), e.getMessage(), rsp);
}
}
private void handleJobQuery(HttpServletRequest req, HttpServletResponse rsp) throws IOException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
if (!req.getMethod().equals(HttpMethod.GET.name())) {
sendResponse(HttpStatus.METHOD_NOT_ALLOWED.value(), "only GET method is allowed for querying job status", rsp);
return;
}
Map<String, String> vars = matcher.extractUriTemplateVariables(ASYNC_JOB_PATH_PATTERN, getDecodedUrl(req));
String uuid = vars.get("uuid");
AsyncRestQueryResult ret = asyncStore.query(uuid);
if (ret.getState() == AsyncRestState.expired) {
sendResponse(HttpStatus.NOT_FOUND.value(), "the job has been expired", rsp);
return;
}
ApiResponse response = new ApiResponse();
if (ret.getState() == AsyncRestState.processing) {
sendResponse(HttpStatus.ACCEPTED.value(), response, rsp);
return;
}
// task is done
APIEvent evt = ret.getResult();
if (evt.isSuccess()) {
RestResponseWrapper w = responseAnnotationByClass.get(evt.getClass());
if (w == null) {
throw new CloudRuntimeException(String.format("cannot find RestResponseWrapper for the class[%s]", evt.getClass()));
}
writeResponse(response, w, ret.getResult());
sendResponse(HttpStatus.OK.value(), response, rsp);
} else {
response.setError(evt.getError());
sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), response, rsp);
}
}
private void sendResponse(int statusCode, ApiResponse response, HttpServletResponse rsp) throws IOException {
sendResponse(statusCode, response.isEmpty() ? "" : JSONObjectUtil.toJsonString(response), rsp);
}
private void handleNonUniqueApi(Collection<Api> apis, HttpEntity<String> entity, HttpServletRequest req, HttpServletResponse rsp) throws RestException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException, IOException {
Map m = JSONObjectUtil.toObject(entity.getBody(), LinkedHashMap.class);
Api api;
String parameterName = null;
if ("POST".equals(req.getMethod())) {
// create API
Optional<Api> o = apis.stream().filter(a -> a.requestAnnotation.method().name().equals("POST")).findAny();
if (!o.isPresent()) {
throw new RestException(HttpStatus.INTERNAL_SERVER_ERROR.value(), String.format("No creational API found" +
" for the path[%s]", req.getRequestURI()));
}
api = o.get();
} else if ("PUT".equals(req.getMethod())) {
// action API
Optional<Api> o = apis.stream().filter(a -> m.containsKey(a.actionName)).findAny();
if (!o.isPresent()) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), String.format("the body doesn't contain action mapping" +
" to the URL[%s]", getDecodedUrl(req)));
}
api = o.get();
parameterName = api.actionName;
} else if ("GET".equals(req.getMethod())) {
// query API
Optional<Api> o = apis.stream().filter(a -> a.requestAnnotation.method().name().equals("GET")).findAny();
if (!o.isPresent()) {
throw new RestException(HttpStatus.INTERNAL_SERVER_ERROR.value(), String.format("No query API found" +
" for the path[%s]", req.getRequestURI()));
}
api = o.get();
} else if ("DELETE".equals(req.getMethod())) {
// DELETE API
Optional<Api> o = apis.stream().filter(a -> a.requestAnnotation.method().name().equals("DELETE")).findAny();
if (!o.isPresent()) {
throw new RestException(HttpStatus.INTERNAL_SERVER_ERROR.value(), String.format("No delete API found" +
" for the path[%s]", req.getRequestURI()));
}
api = o.get();
} else {
throw new RestException(HttpStatus.METHOD_NOT_ALLOWED.value(), String.format("The method[%s] is not allowed for" +
" the path[%s]", req.getMethod(), req.getRequestURI()));
}
parameterName = parameterName == null ? api.requestAnnotation.parameterName() : parameterName;
handleApi(api, m, parameterName, entity, req, rsp);
}
private void handleApi(Api api, Map body, String parameterName, HttpEntity<String> entity, HttpServletRequest req, HttpServletResponse rsp) throws RestException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchMethodException, IOException {
if (body == null) {
// for some POST request, the body may be null, for example, attach primary storage to a cluster
body = new HashMap();
}
String sessionId = null;
if (!api.apiClass.isAnnotationPresent(SuppressCredentialCheck.class)) {
String auth = entity.getHeaders().getFirst("Authorization");
if (auth == null) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), "missing header 'Authorization'");
}
auth = auth.trim();
if (!auth.startsWith(RestConstants.HEADER_OAUTH)) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), String.format("Authorization type must be '%s'", RestConstants.HEADER_OAUTH));
}
sessionId = auth.replaceFirst("OAuth", "").trim();
}
if (APIQueryMessage.class.isAssignableFrom(api.apiClass)) {
handleQueryApi(api, sessionId, req, rsp);
return;
}
Object parameter;
if (req.getMethod().equals(HttpMethod.GET.toString()) || req.getMethod().equals(HttpMethod.DELETE.toString())) {
// GET uses query string to pass parameters
Map<String, Object> m = new HashMap<>();
Map<String, String[]> queryParameters = req.getParameterMap();
for (Map.Entry<String, String[]> e : queryParameters.entrySet()) {
String k = e.getKey();
String[] vals = e.getValue();
if (k.contains(".")) {
// this is a map parameter
api.mapQueryParameterToApiFieldValue(k, vals, m);
} else {
Object val = api.queryParameterToApiFieldValue(k, vals);
if (val == null) {
logger.warn(String.format("unknown query parameter[%s], ignored", k));
continue;
}
m.put(k, val);
}
}
parameter = m;
} else {
parameter = body.get(parameterName);
}
APIMessage msg;
if (parameter == null) {
msg = (APIMessage) api.apiClass.newInstance();
} else {
msg = JSONObjectUtil.rehashObject(parameter, (Class<APIMessage>) api.apiClass);
}
if (requestInfo.get().headers.containsKey(RestConstants.HEADER_JOB_UUID)) {
String jobUuid = requestInfo.get().headers.get(RestConstants.HEADER_JOB_UUID).get(0);
if (jobUuid.length() != 32) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), String.format("Invalid header[%s], it" +
" must be a UUID with '-' stripped", RestConstants.HEADER_JOB_UUID));
}
msg.setId(jobUuid);
}
if (sessionId != null) {
SessionInventory session = new SessionInventory();
session.setUuid(sessionId);
msg.setSession(session);
}
if (!req.getMethod().equals(HttpMethod.GET.toString()) && !req.getMethod().equals(HttpMethod.DELETE.toString())) {
Object systemTags = body.get("systemTags");
if (systemTags != null) {
msg.setSystemTags((List<String>) systemTags);
}
Object userTags = body.get("userTags");
if (userTags != null) {
msg.setUserTags((List<String>) userTags);
}
}
Map<String, String> vars = matcher.extractUriTemplateVariables(api.path, getDecodedUrl(req));
for (Map.Entry<String, String> e : vars.entrySet()) {
// set fields parsed from the URL
String key = e.getKey();
String mappingKey = api.getMappingField(key);
PropertyUtils.setProperty(msg, mappingKey == null ? key : mappingKey, e.getValue());
}
msg.setServiceId(ApiMediatorConstant.SERVICE_ID);
sendMessage(msg, api, rsp);
}
private static final LinkedHashMap<String, String> QUERY_OP_MAPPING = new LinkedHashMap();
static {
// DO NOT change the order
// an operator contained by another operator must be placed
// after the containing operator. For example, "=" is contained
// by "!=" so it must sit after "!="
QUERY_OP_MAPPING.put("!=", QueryOp.NOT_EQ.toString());
QUERY_OP_MAPPING.put(">=", QueryOp.GT_AND_EQ.toString());
QUERY_OP_MAPPING.put("<=", QueryOp.LT_AND_EQ.toString());
QUERY_OP_MAPPING.put("!?=", QueryOp.NOT_IN.toString());
QUERY_OP_MAPPING.put("!~=", QueryOp.NOT_LIKE.toString());
QUERY_OP_MAPPING.put("~=", QueryOp.LIKE.toString());
QUERY_OP_MAPPING.put("?=", QueryOp.IN.toString());
QUERY_OP_MAPPING.put("=", QueryOp.EQ.toString());
QUERY_OP_MAPPING.put(">", QueryOp.GT.toString());
QUERY_OP_MAPPING.put("<", QueryOp.LT.toString());
QUERY_OP_MAPPING.put("is null", QueryOp.IS_NULL.toString());
QUERY_OP_MAPPING.put("not null", QueryOp.NOT_NULL.toString());
}
private void handleQueryApi(Api api, String sessionId, HttpServletRequest req, HttpServletResponse rsp) throws IllegalAccessException, InstantiationException, RestException, IOException, NoSuchMethodException, InvocationTargetException {
Map<String, String[]> vars = req.getParameterMap();
APIQueryMessage msg = (APIQueryMessage) api.apiClass.newInstance();
SessionInventory session = new SessionInventory();
session.setUuid(sessionId);
msg.setSession(session);
msg.setServiceId(ApiMediatorConstant.SERVICE_ID);
Map<String, String> urlvars = matcher.extractUriTemplateVariables(api.path, getDecodedUrl(req));
String uuid = urlvars.get("uuid");
if (uuid != null) {
// this is a GET /xxxx/uuid
// return the resource directly
QueryCondition qc = new QueryCondition();
qc.setName("uuid");
qc.setOp("=");
qc.setValue(uuid);
msg.getConditions().add(qc);
sendMessage(msg, api, rsp);
return;
}
// a query with conditions
for (Map.Entry<String, String[]> e : vars.entrySet()) {
String varname = e.getKey().trim();
String varvalue = e.getValue()[0].trim();
if ("limit".equals(varname)) {
try {
msg.setLimit(Integer.valueOf(varvalue));
} catch (NumberFormatException ex) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), "Invalid query parameter. 'limit' must be an integer");
}
} else if ("start".equals(varname)) {
try {
msg.setStart(Integer.valueOf(varvalue));
} catch (NumberFormatException ex) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), "Invalid query parameter. 'start' must be an integer");
}
} else if ("count".equals(varname)) {
msg.setCount(Boolean.valueOf(varvalue));
} else if ("groupBy".equals(varname)) {
msg.setGroupBy(varvalue);
} else if ("replyWithCount".equals(varname)) {
msg.setReplyWithCount(Boolean.valueOf(varvalue));
} else if ("sort".equals(varname)) {
if (varvalue.startsWith("+")) {
msg.setSortDirection("asc");
varvalue = StringUtils.stripStart(varvalue, "+");
} else if (varvalue.startsWith("-")) {
msg.setSortDirection("desc");
varvalue = StringUtils.stripStart(varvalue, "-");
} else {
msg.setSortDirection("asc");
}
msg.setSortBy(varvalue);
} else if ("q".startsWith(varname)) {
String[] conds = e.getValue();
for (String cond : conds) {
String OP = null;
String delimiter = null;
for (String op : QUERY_OP_MAPPING.keySet()) {
if (cond.contains(op)) {
OP = QUERY_OP_MAPPING.get(op);
delimiter = op;
break;
}
}
if (OP == null) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), String.format("Invalid query parameter." +
" The '%s' in the parameter[q] doesn't contain any query operator. Valid query operators are" +
" %s", cond, asList(QUERY_OP_MAPPING.keySet())));
}
QueryCondition qc = new QueryCondition();
String[] ks = StringUtils.splitByWholeSeparator(cond, delimiter, 2);
if (OP.equals(QueryOp.IS_NULL.toString()) || OP.equals(QueryOp.NOT_NULL.toString())) {
String cname = ks[0].trim();
qc.setName(cname);
qc.setOp(OP);
} else {
if (ks.length != 2) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), String.format("Invalid query parameter." +
" The '%s' in parameter[q] is not a key-value pair split by %s", cond, OP));
}
String cname = ks[0].trim();
String cvalue = ks[1]; // don't trim the value, a space is valid in some conditions
qc.setName(cname);
qc.setOp(OP);
qc.setValue(cvalue);
}
msg.getConditions().add(qc);
}
} else if ("fields".equals(varname)) {
List<String> fs = new ArrayList<>();
for (String f : varvalue.split(",")) {
fs.add(f.trim());
}
if (fs.isEmpty()) {
throw new RestException(HttpStatus.BAD_REQUEST.value(), String.format("Invalid query parameter. 'fields'" +
" contains zero field"));
}
msg.setFields(fs);
}
}
if (msg.getConditions() == null) {
// no condition specified, query all
msg.setConditions(new ArrayList<>());
}
sendMessage(msg, api, rsp);
}
private void handleUniqueApi(Api api, HttpEntity<String> entity, HttpServletRequest req, HttpServletResponse rsp) throws RestException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchMethodException, IOException {
handleApi(api, JSONObjectUtil.toObject(entity.getBody(), LinkedHashMap.class),
api.requestAnnotation.isAction() ? api.actionName : api.requestAnnotation.parameterName(), entity, req, rsp);
}
private void writeResponse(ApiResponse response, RestResponseWrapper w, Object replyOrEvent) throws IllegalAccessException, NoSuchMethodException, InvocationTargetException {
if (!w.annotation.allTo().equals("")) {
response.put(w.annotation.allTo(),
PropertyUtils.getProperty(replyOrEvent, w.annotation.allTo()));
} else {
for (Map.Entry<String, String> e : w.responseMappingFields.entrySet()) {
response.put(e.getKey(),
PropertyUtils.getProperty(replyOrEvent, e.getValue()));
}
}
// TODO: fix hard code hack
if (APIQueryReply.class.isAssignableFrom(w.apiResponseClass)) {
Object total = PropertyUtils.getProperty(replyOrEvent, "total");
if (total != null) {
response.put("total", total);
}
}
if (requestInfo.get().headers.containsKey(RestConstants.HEADER_JSON_SCHEMA)
// set schema anyway if it's a query API
|| APIQueryReply.class.isAssignableFrom(w.apiResponseClass)) {
response.setSchema(new JsonSchemaBuilder(response).build());
}
}
private void sendReplyResponse(MessageReply reply, Api api, HttpServletResponse rsp) throws IOException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
ApiResponse response = new ApiResponse();
if (!reply.isSuccess()) {
response.setError(reply.getError());
sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), JSONObjectUtil.toJsonString(response), rsp);
return;
}
// the api succeeded
writeResponse(response, responseAnnotationByClass.get(api.apiResponseClass), reply);
sendResponse(HttpStatus.OK.value(), response, rsp);
}
private void sendMessage(APIMessage msg, Api api, HttpServletResponse rsp) throws IOException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
if (msg instanceof APISyncCallMessage) {
MessageReply reply = bus.call(msg);
sendReplyResponse(reply, api, rsp);
} else {
RequestData d = new RequestData();
d.apiMessage = msg;
d.requestInfo = requestInfo.get();
List<String> webHook = requestInfo.get().headers.get(RestConstants.HEADER_WEBHOOK);
if (webHook != null && !webHook.isEmpty()) {
d.webHook = webHook.get(0);
}
asyncStore.save(d);
UriComponentsBuilder ub = UriComponentsBuilder.fromHttpUrl(restf.getBaseUrl());
ub.path(RestConstants.API_VERSION);
ub.path(RestConstants.ASYNC_JOB_PATH);
ub.path("/" + msg.getId());
ApiResponse response = new ApiResponse();
response.setLocation(ub.build().toUriString());
bus.send(msg);
sendResponse(HttpStatus.ACCEPTED.value(), response, rsp);
}
}
@Override
public boolean start() {
build();
return true;
}
private String substituteUrl(String url, Map<String, String> 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 CloudRuntimeException(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;
}
private String normalizePath(String p) {
// normalize the path,
// paths for example /backup-storage/{backupStorageUuid}/actions
// and /backup-storage/{uuid}/actions are treated as equal,
// and will be normalized to /backup-storage/{0}/actions
List<String> varNames = getVarNamesFromUrl(p);
if (varNames.isEmpty()) {
return p;
}
Map<String, String> m = new HashMap<>();
for (int i=0; i<varNames.size(); i++) {
m.put(varNames.get(i), String.format("{%s}", i));
}
return substituteUrl(p, m);
}
private void build() {
Reflections reflections = Platform.getReflections();
Set<Class<?>> classes = reflections.getTypesAnnotatedWith(RestRequest.class).stream()
.filter(it -> it.isAnnotationPresent(RestRequest.class)).collect(Collectors.toSet());
for (Class clz : classes) {
RestRequest at = (RestRequest) clz.getAnnotation(RestRequest.class);
Api api = new Api(clz, at);
List<String> paths = new ArrayList<>();
if (!"null".equals(api.path)) {
paths.add(api.path);
}
paths.addAll(api.optionalPaths);
for (String path : paths) {
String normalizedPath = normalizePath(path);
api = new Api(clz, at);
api.path = path;
if (!apis.containsKey(normalizedPath)) {
apis.put(normalizedPath, api);
} else {
Object c = apis.get(normalizedPath);
List lst;
if (c instanceof Api) {
// merge to a list
lst = new ArrayList();
lst.add(c);
apis.put(normalizedPath, lst);
} else {
lst = (List) c;
}
lst.add(api);
}
}
responseAnnotationByClass.put(api.apiResponseClass, new RestResponseWrapper(api.responseAnnotation, api.apiResponseClass));
}
// below codes are checking if there
// are duplicated APIs
for (Object o : apis.values()) {
if (!(o instanceof List)) {
continue;
}
List<Api> as = (List<Api>) o;
List<Api> nonActions = as.stream().filter(a -> !a.requestAnnotation.isAction()).collect(Collectors.toList());
Map<String, Api> set = new HashMap<>();
for (Api a : nonActions) {
Api old = set.get(a.toString());
if (old != null) {
throw new CloudRuntimeException(String.format("duplicate rest API[%s, %s], they both have the same" +
" HTTP methods and paths, and both are not actions. %s", a.apiClass, old.apiClass, a.toString()));
}
set.put(a.toString(), a);
}
List<Api> actions = as.stream().filter(a -> a.requestAnnotation.isAction()).collect(Collectors.toList());
set = new HashMap<>();
for (Api a : actions) {
Api old = set.get(a.actionName);
if (old != null) {
throw new CloudRuntimeException(String.format("duplicate rest API[%s, %s], they are both actions with the" +
" same action name[%s]", a.apiClass, old.apiClass, a.actionName));
}
set.put(a.actionName, a);
}
}
}
@Override
public boolean stop() {
return true;
}
}