package restservices.publish; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Map.Entry; import org.apache.commons.httpclient.HttpStatus; import org.apache.commons.io.IOUtils; import com.mendix.thirdparty.org.json.JSONObject; import restservices.RestServices; import restservices.proxies.ChangeItem; import restservices.proxies.DataServiceDefinition; import restservices.proxies.HttpMethod; import restservices.publish.RestPublishException.RestExceptionType; import restservices.publish.RestServiceHandler.HandlerRegistration; import restservices.publish.RestServiceRequest.ResponseType; import restservices.util.ICloseable; import restservices.util.JsonDeserializer; import restservices.util.JsonSerializer; import restservices.util.Utils; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.mendix.core.Core; import com.mendix.core.CoreException; import com.mendix.m2ee.api.IMxRuntimeResponse; import com.mendix.systemwideinterfaces.connectionbus.requests.IRetrievalSchema; import com.mendix.systemwideinterfaces.connectionbus.requests.ISortExpression.SortDirection; import com.mendix.systemwideinterfaces.core.IContext; import com.mendix.systemwideinterfaces.core.IMendixIdentifier; import com.mendix.systemwideinterfaces.core.IMendixObject; import com.mendix.systemwideinterfaces.core.meta.IMetaObject; import communitycommons.XPath; import communitycommons.XPath.IBatchProcessor; public class DataService { private static Map<Long, DataService> servicesByGuid = Maps.newHashMap(); DataServiceDefinition def; public DataService(DataServiceDefinition def, IContext context) { this.def = def; try { changeLogManager = new ChangeLogManager(this, context); } catch (Exception e) { throw new RuntimeException(e); } } public String getConstraint(IContext context) { String constraint = def.getSourceConstraint() == null ? "" : def.getSourceConstraint(); if (constraint.contains(RestServices.CURRENTUSER_TOKEN)) constraint = constraint.replace(RestServices.CURRENTUSER_TOKEN, "'" + context.getSession().getUser().getMendixObject().getId() + "'"); return constraint; } private IMetaObject sourceMetaEntity; private ChangeLogManager changeLogManager; private List<RestServiceHandler.HandlerRegistration> serviceHandlers = Lists.newArrayList(); private ICloseable metaServiceHandler; public ChangeLogManager getChangeLogManager() { return changeLogManager; } public String getRelativeUrl() { return Utils.removeLeadingAndTrailingSlash(def.getName()); } public String getSourceEntity() { return def.getSourceEntity(); } public String getKeyAttribute() { return def.getSourceKeyAttribute(); } public String getServiceUrl() { return RestServices.getAbsoluteUrl(getRelativeUrl()); } private IMendixObject getObjectByKey(IContext context, String key) throws CoreException { try { String xpath = XPath.create(context, getSourceEntity()).eq(getKeyAttribute(), key).getXPath() + this.getConstraint(context); List<IMendixObject> results = Core.retrieveXPathQuery(context, xpath, 1, 0, ImmutableMap.of("id", "ASC")); return results.size() == 0 ? null : results.get(0); } catch(Throwable e) { if (e.getClass().getSimpleName().equals("CoreRuntimeException")) { //Somehow the exception is not properly catched. Other classloader? RestServices.LOGPUBLISH.warn("Failed to retrieve " + getRelativeUrl() + "/" + key + ". Assuming that the key is invalid. 404 will be returned", e); return null; } throw new RuntimeException(e); } } private ChangeItem getObjectStateByKey(IContext context, String key) throws CoreException { return XPath.create(context, ChangeItem.class) .eq(ChangeItem.MemberNames.Key,key) .eq(ChangeItem.MemberNames.ChangeItem_ChangeLog, getChangeLogManager().getChangeLog()) .first(); } public void serveCount(RestServiceRequest rsr) throws CoreException, RestPublishException { if (!def.getEnableListing()) throw new RestPublishException(RestExceptionType.METHOD_NOT_ALLOWED, "List is not enabled for this service"); rsr.startDoc(); rsr.datawriter.object(); long count; if (def.getEnableChangeLog()) { count = XPath.create(rsr.getContext(), ChangeItem.class) .eq(ChangeItem.MemberNames.ChangeItem_ChangeLog, getChangeLogManager().getChangeLog()) .eq(ChangeItem.MemberNames.IsDeleted, false) .count(); } else count = Core.retrieveXPathQueryAggregate(rsr.getContext(), "count(//" + getSourceEntity() + getConstraint(rsr.getContext()) + ")"); rsr.datawriter.key("count").value(count).endObject(); rsr.endDoc(); } public void serveListing(RestServiceRequest rsr, boolean includeData, int offset, int limit) throws Exception { if (!def.getEnableListing()) throw new RestPublishException(RestExceptionType.METHOD_NOT_ALLOWED, "List is not enabled for this service"); if (offset >= 0 ^ limit >= 0) throw new RestPublishException(RestExceptionType.BAD_REQUEST, "'offset' and 'limit' parameters should both be provided and positive, or none of them"); if (offset >= 0 && limit < 1) throw new RestPublishException(RestExceptionType.BAD_REQUEST, "'limit' should be positive and larget than zero"); rsr.startDoc(); if (rsr.getResponseContentType() == ResponseType.HTML) rsr.write("<h1>" + getRelativeUrl() + "</h1>"); rsr.datawriter.array(); if (def.getEnableChangeLog()) serveListingFromIndex(rsr, includeData, offset, limit); else serveListingFromDB(rsr, includeData, offset, limit); rsr.datawriter.endArray(); rsr.endDoc(); } private void serveListingFromIndex(final RestServiceRequest rsr, final boolean includeData, int offset, int limit) throws CoreException { XPath<ChangeItem> xp = XPath.create(rsr.getContext(), ChangeItem.class) .eq(ChangeItem.MemberNames.ChangeItem_ChangeLog, getChangeLogManager().getChangeLog()) .eq(ChangeItem.MemberNames.IsDeleted, false) .eq(ChangeItem.MemberNames._IsDirty, false) .addSortingAsc(ChangeItem.MemberNames.Key); if (offset > -1) xp.offset(offset); //MWE: note that the combination of offset/limit and batch only works in community commons 4.3.2 or higher! if (limit > 0) xp.limit(limit); xp.batch(RestServices.BATCHSIZE, new IBatchProcessor<ChangeItem>() { @Override public void onItem(ChangeItem item, long offset, long total) throws Exception { if (includeData) rsr.datawriter.value(new JSONObject(item.getJson())); else rsr.datawriter.value(getServiceUrl() + item.getKey()); } }); } private void serveListingFromDB(RestServiceRequest rsr, boolean includeData, int baseoffset, int limit) throws Exception { boolean hasOffset = baseoffset >= 0; int offset = hasOffset ? baseoffset : 0; String xpath = "//" + getSourceEntity() + getConstraint(rsr.getContext()); List<IMendixObject> result = null; do { int amount = hasOffset && limit > 0 ? Math.min(baseoffset + limit - offset, RestServices.BATCHSIZE) : RestServices.BATCHSIZE; result = Core.retrieveXPathQuery(rsr.getContext(), xpath, amount, offset, ImmutableMap.of(getKeyAttribute(), "ASC")); for(IMendixObject item : result) { if (!includeData) { if (!Utils.isValidKey(getKey(rsr.getContext(), item))) continue; rsr.datawriter.value(getObjecturl(rsr.getContext(), item)); } else { rsr.datawriter.value(serializeToJson(rsr.getContext(), item)); } } offset += result.size(); } while(!result.isEmpty()); } public void serveGet(RestServiceRequest rsr, String key) throws Exception { if (!def.getEnableGet()) throw new RestPublishException(RestExceptionType.METHOD_NOT_ALLOWED, "GET is not enabled for this service"); if(def.getEnableChangeLog()) serveGetFromIndex(rsr, key); else serveGetFromDB(rsr, key); } private void serveGetFromIndex(RestServiceRequest rsr, String key) throws Exception { ChangeItem source = getObjectStateByKey(rsr.getContext(), key); if (source == null || source.getIsDeleted() || source.get_IsDirty()) throw new RestPublishException(RestExceptionType.NOT_FOUND, getRelativeUrl() + "/" + key); if (Utils.isNotEmpty(rsr.getETag()) && rsr.getETag().equals(source.getEtag())) { rsr.setStatus(IMxRuntimeResponse.NOT_MODIFIED); rsr.close(); return; } writeGetResult(rsr,key, new JSONObject(source.getJson()), source.getEtag()); } private void serveGetFromDB(RestServiceRequest rsr, String key) throws Exception { IMendixObject source = getObjectByKey(rsr.getContext(), key); if (source == null) throw new RestPublishException( keyExists(rsr.getContext(), key) && !isWorldReadable()? RestExceptionType.UNAUTHORIZED : RestExceptionType.NOT_FOUND, getRelativeUrl() + "/" + key); JSONObject result = serializeToJson(rsr.getContext(), source); String jsonString = result.toString(4); String eTag = Utils.getMD5Hash(jsonString); writeGetResult(rsr, key, result, eTag); } private void writeGetResult(RestServiceRequest rsr, String key, JSONObject result, String eTag) { if (eTag.equals(rsr.getETag())) { rsr.setStatus(IMxRuntimeResponse.NOT_MODIFIED); rsr.close(); return; } rsr.response.setHeader(RestServices.HEADER_ETAG, eTag); rsr.startDoc(); if (rsr.getResponseContentType() == ResponseType.HTML) rsr.write("<h1>").write(getRelativeUrl()).write("/").write(key).write("</h1>"); rsr.datawriter.value(result); rsr.endDoc(); } public void serveDelete(RestServiceRequest rsr, String key, String etag) throws Exception { if (!def.getEnableDelete()) throw new RestPublishException(RestExceptionType.METHOD_NOT_ALLOWED, "List is not enabled for this service"); IMendixObject source = getObjectByKey(rsr.getContext(), key); if (source == null) throw new RestPublishException(keyExists(rsr.getContext(), key) && !isWorldReadable() ? RestExceptionType.UNAUTHORIZED : RestExceptionType.NOT_FOUND, getRelativeUrl() + "/" + key); verifyEtag(rsr.getContext(), key, source, etag); if (Utils.isNotEmpty(def.getOnDeleteMicroflow())) Core.execute(rsr.getContext(), def.getOnDeleteMicroflow(), source); else Core.delete(rsr.getContext(), source); rsr.setStatus(204); //no content rsr.close(); } public void servePost(RestServiceRequest rsr, JSONObject data) throws Exception { if (!def.getEnableCreate()) throw new RestPublishException(RestExceptionType.METHOD_NOT_ALLOWED, "Create (POST) is not enabled for this service"); IMendixObject target = Core.instantiate(rsr.getContext(), getSourceEntity()); updateObject(rsr.getContext(), target, data); Object keyValue = target.getValue(rsr.getContext(), getKeyAttribute()); String key = keyValue == null ? null : String.valueOf(keyValue); if (!Utils.isValidKey(key)) throw new RuntimeException("Failed to serve POST request: microflow '" + def.getOnPublishMicroflow() + "' should have created a new key"); rsr.setStatus(201); //created String eTag = getETag(rsr.getContext(), key, target); if (eTag != null) rsr.response.setHeader(RestServices.HEADER_ETAG, eTag); rsr.datawriter.object().key(getKeyAttribute()).value(key).endObject(); rsr.close(); } public void servePut(RestServiceRequest rsr, String key, JSONObject data, String etag) throws Exception { IContext context = rsr.getContext(); IMendixObject target = getObjectByKey(context, key); if (!Utils.isValidKey(key)) rsr.setStatus(HttpStatus.SC_NOT_FOUND); else if (target == null) { if (keyExists(rsr.getContext(), key)){ //key exists, but this user cannot access it. rsr.setStatus(HttpStatus.SC_FORBIDDEN); rsr.close(); return; } if (!def.getEnableCreate()) throw new RestPublishException(RestExceptionType.METHOD_NOT_ALLOWED, "Create (PUT) is not enabled for this service"); target = Core.instantiate(context, getSourceEntity()); target.setValue(context, getKeyAttribute(), key); rsr.setStatus(HttpStatus.SC_CREATED); } else { //already existing target if (!def.getEnableUpdate()) throw new RestPublishException(RestExceptionType.METHOD_NOT_ALLOWED, "Update (PUT) is not enabled for this service"); verifyEtag(rsr.getContext(), key, target, etag); rsr.setStatus(204); } updateObject(rsr.getContext(), target, data); String eTag = getETag(rsr.getContext(), key, target); if (eTag != null) rsr.response.setHeader(RestServices.HEADER_ETAG, eTag); rsr.close(); } private boolean keyExists(IContext context, String key) throws CoreException { return getObjectByKey(context.isSudo() ? context : context.createSudoClone(), key) != null; } /** * Returns an array with [viewArgName, viewArgType, targetArgName]. * TargetARgType is the sourceEntity of this microflow. * Or throws an exception if the microflow does not supplies these argements * @return */ static String[] extractArgInfoForUpdateMicroflow(DataServiceDefinition serviceDef) { Map<String, String> argtypes = Utils.getArgumentTypes(serviceDef.getOnUpdateMicroflow()); if (argtypes.size() != 2) throw new RuntimeException("Expected exactly two arguments for microflow " + serviceDef.getOnUpdateMicroflow()); //Determine argnames String viewArgName = null; String targetArgName = null; String viewArgType = null; for(Entry<String, String> e : argtypes.entrySet()) { if (e.getValue().equals(serviceDef.getSourceEntity())) targetArgName = e.getKey(); else if (Core.getMetaObject(e.getValue()) != null) { viewArgName = e.getKey(); viewArgType = e.getValue(); } } if (targetArgName == null || viewArgName == null || Core.getMetaObject(viewArgType).isPersistable()) throw new RuntimeException("Microflow '" + serviceDef.getOnUpdateMicroflow() + "' should have one argument of type " + serviceDef.getSourceEntity() + ", and one argument typed with an persistent entity"); return new String[] { viewArgName, viewArgType, targetArgName }; } private void updateObject(IContext context, IMendixObject target, JSONObject data) throws Exception, Exception { String[] argInfo = extractArgInfoForUpdateMicroflow(def); IMendixObject view = Core.instantiate(context, argInfo[1]); JsonDeserializer.readJsonDataIntoMendixObject(context, data, view, false); Core.commit(context, view); Core.execute(context, def.getOnUpdateMicroflow(), ImmutableMap.of(argInfo[2], (Object) target, argInfo[0], (Object) view)); } private void verifyEtag(IContext context, String key, IMendixObject source, String etag) throws Exception { if (!this.def.getUseStrictVersioning()) return; String currentETag = getETag(context, key, source); if (currentETag == null || !currentETag.equals(etag)) throw new RestPublishException(RestExceptionType.CONFLICTED, "Update conflict detected, expected change based on version '" + currentETag + "', but found '" + etag + "'"); } private String getETag(final IContext context, String key, IMendixObject source) throws CoreException, Exception, UnsupportedEncodingException { String currentETag = null; if (def.getEnableChangeLog()) { ChangeItem objectState = getObjectStateByKey(context, key); if (objectState != null) currentETag = objectState.getEtag(); } else { JSONObject result = serializeToJson(context, source); String jsonString = result.toString(4); currentETag = Utils.getMD5Hash(jsonString); } return currentETag; } public IMetaObject getSourceMetaEntity() { if (this.sourceMetaEntity == null) this.sourceMetaEntity = Core.getMetaObject(getSourceEntity()); return this.sourceMetaEntity; } public IMendixObject convertSourceToView(IContext context, IMendixObject source) throws CoreException { IMendixObject res = (IMendixObject) Core.execute(context, def.getOnPublishMicroflow(), source); if (res == null) throw new IllegalStateException("Exception during serialization: " + def.getOnPublishMicroflow() + " microflow didn't return an object"); return res; } JSONObject serializeToJson(final IContext context, IMendixObject source) throws CoreException, Exception { IMendixObject view = convertSourceToView(context, source); return JsonSerializer.writeMendixObjectToJson(context, view, true); } public boolean identifierInConstraint(IContext c, IMendixIdentifier id) throws CoreException { if (this.getConstraint(c).isEmpty()) return true; return Core.retrieveXPathQueryAggregate(c, "count(//" + getSourceEntity() + "[id='" + id.toLong() + "']" + this.getConstraint(c) + ")") == 1; } public String getObjecturl(IContext c, IMendixObject obj) { //Pre: inConstraint is checked!, obj is not null String key = getKey(c, obj); if (!Utils.isValidKey(key)) throw new IllegalStateException("Invalid key for object " + obj.toString()); return this.getServiceUrl() + Utils.urlEncode(key); } public String getKey(IContext c, IMendixObject obj) { return obj.getMember(c, getKeyAttribute()).parseValueToString(c); } public boolean isGetObjectEnabled() { return def.getEnableGet(); } public boolean isWorldReadable() { return "*".equals(def.getAccessRole().trim()); } public String getRequiredRoleOrMicroflow() { return def.getAccessRole().trim(); } public void register() { unregister(); if (def.getEnableGet()) RestServices.registerServiceByEntity(def.getSourceEntity(), this); servicesByGuid.put(def.getMendixObject().getId().toLong(), this); metaServiceHandler = RestServiceHandler.registerServiceHandlerMetaUrl(getRelativeUrl()); registerHandlers(); } public void unregister() { this.changeLogManager.dispose(); for(HandlerRegistration handler : serviceHandlers) { handler.close(); } serviceHandlers.clear(); if (def != null) { servicesByGuid.remove(def.getMendixObject().getId().toLong()); RestServices.unregisterServiceByEntity(def.getSourceEntity(), this); } if (metaServiceHandler != null) { metaServiceHandler.close(); } } private void registerHandlers() { String base = Utils.appendSlashToUrl(getRelativeUrl()); String baseWithKey = base + "{" + getKeyAttribute() + "}"; serviceHandlers.add(RestServiceHandler.registerServiceHandler(HttpMethod.GET, base, getRequiredRoleOrMicroflow(), new IRestServiceHandler() { @Override public void execute(RestServiceRequest rsr, Map<String, String> params) throws Exception { if (rsr.request.getParameter(RestServices.PARAM_ABOUT) != null) new ServiceDescriber(rsr, def).serveServiceDescription(); else if (rsr.request.getParameter(RestServices.PARAM_COUNT) != null) serveCount(rsr); else serveListing(rsr, "true".equals(rsr.getRequestParameter(RestServices.PARAM_DATA,"false")), Integer.valueOf(rsr.getRequestParameter(RestServices.PARAM_OFFSET, "-1")), Integer.valueOf(rsr.getRequestParameter(RestServices.PARAM_LIMIT, "-1"))); } })); // Create object serviceHandlers.add(RestServiceHandler.registerServiceHandler(HttpMethod.POST, base, getRequiredRoleOrMicroflow(), new IRestServiceHandler() { @Override public void execute(RestServiceRequest rsr, Map<String, String> params) throws Exception { JSONObject data; if (RestServices.CONTENTTYPE_FORMENCODED.equalsIgnoreCase(rsr.request.getContentType())) { data = new JSONObject(); RestServiceHandler.paramMapToJsonObject(params, data); } else { String body = IOUtils.toString(rsr.request.getInputStream()); data = new JSONObject(body); } servePost(rsr, data); } })); // Get Object serviceHandlers.add(RestServiceHandler.registerServiceHandler(HttpMethod.GET, baseWithKey, getRequiredRoleOrMicroflow(), new IRestServiceHandler() { @Override public void execute(RestServiceRequest rsr, Map<String, String> params) throws Exception { serveGet(rsr, params.get(getKeyAttribute())); } })); // Update Object serviceHandlers.add(RestServiceHandler.registerServiceHandler(HttpMethod.PUT, baseWithKey, getRequiredRoleOrMicroflow(), new IRestServiceHandler() { @Override public void execute(RestServiceRequest rsr, Map<String, String> params) throws Exception { String body = IOUtils.toString(rsr.request.getInputStream()); servePut(rsr, params.get(getKeyAttribute()), new JSONObject(body), rsr.getETag()); } })); // Delete Object serviceHandlers.add(RestServiceHandler.registerServiceHandler(HttpMethod.DELETE, baseWithKey, getRequiredRoleOrMicroflow(), new IRestServiceHandler() { @Override public void execute(RestServiceRequest rsr, Map<String, String> params) throws Exception { serveDelete(rsr, params.get(getKeyAttribute()), rsr.getETag()); } })); // Changes list serviceHandlers.add(RestServiceHandler.registerServiceHandler(HttpMethod.GET, base + "changes/list", getRequiredRoleOrMicroflow(), new IRestServiceHandler() { @Override public void execute(RestServiceRequest rsr, Map<String, String> params) throws Exception { getChangeLogManager().serveChanges(rsr, false); } })); // Changes feed serviceHandlers.add(RestServiceHandler.registerServiceHandler(HttpMethod.GET, base + "changes/feed", getRequiredRoleOrMicroflow(), new IRestServiceHandler() { @Override public void execute(RestServiceRequest rsr, Map<String, String> params) throws Exception { getChangeLogManager().serveChanges(rsr, true); } })); } public static DataService getServiceByDefinition(DataServiceDefinition def) { Preconditions.checkNotNull(def); return servicesByGuid.get(def.getMendixObject().getId().toLong()); } }