package restservices.consume; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static org.apache.commons.lang3.StringUtils.isNotEmpty; import static restservices.RestServices.CHANGE_DATA; import static restservices.RestServices.CHANGE_DELETED; import static restservices.RestServices.CHANGE_KEY; import static restservices.RestServices.CHANGE_SEQNR; import static restservices.RestServices.PARAM_SINCE; import static restservices.RestServices.PARAM_TIMEOUT; import static restservices.RestServices.PATH_CHANGES; import static restservices.RestServices.PATH_FEED; import static restservices.RestServices.PATH_LIST; import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.apache.commons.httpclient.HttpException; import org.apache.commons.httpclient.methods.GetMethod; import com.mendix.thirdparty.org.json.JSONException; import com.mendix.thirdparty.org.json.JSONObject; import com.mendix.thirdparty.org.json.JSONTokener; import restservices.RestServices; import restservices.proxies.DataSyncState; import restservices.proxies.TrackingState; import restservices.util.JsonDeserializer; import restservices.util.Utils; import com.google.common.base.Predicate; import com.google.common.collect.ImmutableMap; import com.mendix.core.Core; import com.mendix.core.CoreException; import com.mendix.m2ee.api.IMxRuntimeResponse; import com.mendix.systemwideinterfaces.core.IContext; import com.mendix.systemwideinterfaces.core.IDataType; import com.mendix.systemwideinterfaces.core.IMendixObject; import communitycommons.XPath; public class ChangeLogListener { private String url; private String onUpdateMF; private String onDeleteMF; private DataSyncState state; private static Map<String, ChangeLogListener> activeListeners = Collections.synchronizedMap(new HashMap<String, ChangeLogListener>()); volatile boolean cancelled = false; private Map<String, String> headers; private long timeout; private volatile GetMethod currentRequest; private Thread listenerThread; private volatile boolean isConnected = false; private ChangeLogListener(String collectionUrl, String onUpdateMF, String onDeleteMF, long timeout) throws Exception { checkNotNull(collectionUrl, "URL should not be null"); checkArgument(isNotEmpty(onUpdateMF), "On update should be non empty"); checkArgument(isNotEmpty(onDeleteMF), "On delete should be non empty"); this.url = collectionUrl; this.onUpdateMF = onUpdateMF; this.onDeleteMF = onDeleteMF; this.timeout = timeout; this.state = XPath.create(Core.createSystemContext(), DataSyncState.class).findOrCreate(DataSyncState.MemberNames.CollectionUrl, url); } public ChangeLogListener follow() { synchronized (activeListeners) { if (activeListeners.containsKey(url)) throw new IllegalStateException("Already listening to " + url); activeListeners.put(url, this); } headers = RestConsumer.nextHeaders.get(); RestConsumer.nextHeaders.set(null); this.listenerThread = (new Thread() { private long nextRetryTime = 10000; @Override public void run() { while(!cancelled) { try { startConnection(); } catch (Exception e) { RestServices.LOGCONSUME.error("Failed to setup follow stream for " + getChangesRequestUrl(true) + ", retrying in " + nextRetryTime + "ms: " + e.getMessage());//, e); try { Thread.sleep(nextRetryTime); if (nextRetryTime < 60*60*1000) nextRetryTime *= 1.3; } catch (InterruptedException e1) { cancelled = true; } //Retry each 10 seconds } } } }); listenerThread.setName("REST consume thread " + url); listenerThread.start(); return this; } void startConnection() throws IOException, HttpException { String requestUrl = getChangesRequestUrl(true); GetMethod get = this.currentRequest = new GetMethod(requestUrl); get.setRequestHeader(RestServices.HEADER_ACCEPT, RestServices.CONTENTTYPE_APPLICATIONJSON); RestConsumer.includeHeaders(get, headers); int status = RestConsumer.client.executeMethod(get); try { if (status != IMxRuntimeResponse.OK) throw new RuntimeException("Failed to setup stream to " + url + ", status: " + status); InputStream inputStream = get.getResponseBodyAsStream(); JSONTokener jt = new JSONTokener(inputStream); JSONObject instr = null; try { isConnected = true; while(true) { instr = new JSONObject(jt); processChange(instr); } } catch(InterruptedException e2) { cancelled = true; RestServices.LOGCONSUME.warn("Changefeed interrupted", e2); } catch(Exception e) { //Not graceful disconnected? if (!cancelled && !(jt.end() && e instanceof JSONException)) throw new RuntimeException(e); } } finally { isConnected = false; get.releaseConnection(); } } public String getChangesRequestUrl(boolean useFeed) { return Utils.appendParamToUrl(Utils.appendParamToUrl( Utils.appendSlashToUrl(url) + PATH_CHANGES + "/" + (useFeed ? PATH_FEED : PATH_LIST), PARAM_SINCE, String.valueOf((long) state.getSequenceNr())), PARAM_TIMEOUT, String.valueOf(timeout)); } void fetch() throws IOException, Exception { RestConsumer.readJsonObjectStream(getChangesRequestUrl(false), new Predicate<Object>() { @Override public boolean apply(Object data) { if (!(data instanceof JSONObject)) throw new RuntimeException("Changefeed expected JSONObject, found " + data.getClass().getSimpleName()); try { processChange((JSONObject) data); } catch (Exception e) { throw new RuntimeException(e); } return true; } }); } void processChange(JSONObject instr) throws Exception { IContext c = Core.createSystemContext(); long revision = instr.getLong(CHANGE_SEQNR); RestServices.LOGCONSUME.info("Receiving update for " + url + " #" + revision + " object: '" + instr.getString(CHANGE_KEY) + "'"); if (instr.getBoolean(CHANGE_DELETED)) { Map<String, String> args = Utils.getArgumentTypes(onDeleteMF); if (args.size() != 1 || !"String".equals(args.values().iterator().next())) throw new RuntimeException(onDeleteMF + " should have one argument of type string"); Core.execute(c, onDeleteMF, ImmutableMap.of(args.keySet().iterator().next(), (Object) instr.getString(CHANGE_KEY))); } else { IDataType type = Utils.getFirstArgumentType(onUpdateMF); if (!type.isMendixObject()) throw new RuntimeException("First argument should be an Entity! " + onUpdateMF); IMendixObject target = Core.instantiate(c, type.getObjectType()); JsonDeserializer.readJsonDataIntoMendixObject(c, instr.getJSONObject(CHANGE_DATA), target, true); Core.commit(c, target); Core.execute(c, onUpdateMF, ImmutableMap.of(Utils.getArgumentTypes(onUpdateMF).keySet().iterator().next(), (Object) target)); } if (revision <= state.getSequenceNr()) RestServices.LOGCONSUME.warn("Received revision (" + revision + ") is smaller than the latest known revision (" + state.getSequenceNr() +"), probably the collections are out of sync?"); state.setSequenceNr(revision); state.commit(); } private void close() { activeListeners.remove(url); cancelled = true; if (this.currentRequest != null) this.currentRequest.abort(); else if (!this.listenerThread.isInterrupted()) //It might be waiting this.listenerThread.interrupt(); } public static synchronized void follow(final String collectionUrl, final String updateMicroflow, final String deleteMicroflow, final long timeout) throws HttpException, IOException, Exception { new ChangeLogListener(collectionUrl, updateMicroflow, deleteMicroflow, timeout).follow(); } public static synchronized void unfollow(String collectionUrl) { if (activeListeners.containsKey(collectionUrl)) activeListeners.get(collectionUrl).close(); } public static synchronized void fetch(String collectionUrl, String updateMicroflow, String deleteMicroflow) throws Exception { new ChangeLogListener(collectionUrl, updateMicroflow, deleteMicroflow, 0L).fetch(); } public static void resetDataSyncState(String collectionUrl) throws CoreException { if (activeListeners.containsKey(collectionUrl)) throw new IllegalStateException("Cannot reset state for collection '" + collectionUrl + "', there is an active listener. Please unfollow first"); XPath.create(Core.createSystemContext(), DataSyncState.class).eq(DataSyncState.MemberNames.CollectionUrl, collectionUrl).deleteAll(); } public static TrackingState getTrackingState(String collectionUrl) { ChangeLogListener feed = activeListeners.get(collectionUrl); if (feed == null) return TrackingState.Paused; if (!feed.cancelled && feed.isConnected) return TrackingState.Tracking; return TrackingState.Connecting; } }