/** * The contents of this file are subject to the OpenMRS Public License * Version 1.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://license.openmrs.org * * Software distributed under the License is distributed on an "AS IS" * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the * License for the specific language governing rights and limitations * under the License. * * Copyright (C) OpenMRS, LLC. All Rights Reserved. */ package org.openmrs.module.sync.api.impl; import org.apache.commons.lang.exception.ExceptionUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.openmrs.Concept; import org.openmrs.ConceptName; import org.openmrs.OpenmrsObject; import org.openmrs.Person; import org.openmrs.api.APIException; import org.openmrs.api.context.Context; import org.openmrs.api.db.SerializedObject; import org.openmrs.module.ModuleUtil; import org.openmrs.module.sync.SyncConstants; import org.openmrs.module.sync.SyncItem; import org.openmrs.module.sync.SyncItemState; import org.openmrs.module.sync.SyncProcessedObject; import org.openmrs.module.sync.SyncRecord; import org.openmrs.module.sync.SyncRecordState; import org.openmrs.module.sync.SyncSubclassStub; import org.openmrs.module.sync.SyncUtil; import org.openmrs.module.sync.api.SyncIngestService; import org.openmrs.module.sync.api.SyncService; import org.openmrs.module.sync.api.db.SyncDAO; import org.openmrs.module.sync.api.db.hibernate.HibernateSyncInterceptor; import org.openmrs.module.sync.ingest.SyncImportItem; import org.openmrs.module.sync.ingest.SyncImportRecord; import org.openmrs.module.sync.ingest.SyncIngestException; import org.openmrs.module.sync.server.RemoteServer; import org.openmrs.module.sync.server.RemoteServerType; import org.openmrs.module.sync.server.SyncServerRecord; import org.openmrs.util.OpenmrsConstants; import org.openmrs.util.OpenmrsUtil; import org.w3c.dom.NodeList; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class SyncIngestServiceImpl implements SyncIngestService { private Log log = LogFactory.getLog(this.getClass()); private SyncDAO dao; public void setSyncDAO(SyncDAO dao) { this.dao = dao; } /** * @see org.openmrs.module.sync.api.SyncIngestService#processSyncImportRecord(SyncImportRecord, RemoteServer) * @param importRecord * @throws APIException */ public void processSyncImportRecord(SyncImportRecord importRecord, RemoteServer server) throws APIException { if ( importRecord != null ) { if ( importRecord.getUuid() != null && importRecord.getState() != null ) { SyncRecord record = Context.getService(SyncService.class).getSyncRecordByOriginalUuid(importRecord.getUuid()); // ignore the incoming ack if matching sync record cannot be found if (record == null) return; if ( server.getServerType().equals(RemoteServerType.PARENT) ) { // with parents, we set the actual state of the record if ( importRecord.getState().equals(SyncRecordState.ALREADY_COMMITTED) ) record.setState(SyncRecordState.COMMITTED); else if ( importRecord.getState().equals(SyncRecordState.REJECTED) ) { record.setState(SyncRecordState.FAILED); log.error("Sync Response for record " + record.getUuid() + " returned REJECTED, meaning that the failure on the target server was caused by openmrs version differences." ); } else if ( importRecord.getState().equals(SyncRecordState.NOT_SUPPOSED_TO_SYNC) ) record.setState(SyncRecordState.REJECTED); else record.setState(importRecord.getState()); } else { // with non-parents we set state in the server-record SyncServerRecord serverRecord = record.getServerRecord(server); if ( importRecord.getState().equals(SyncRecordState.ALREADY_COMMITTED) ) serverRecord.setState(SyncRecordState.COMMITTED); else if ( importRecord.getState().equals(SyncRecordState.REJECTED) ) { serverRecord.setState(SyncRecordState.FAILED); log.error("Sync Response for record " + record.getUuid() + " returned REJECTED, meaning that the failure on the target server was caused by openmrs version differences." ); } else if ( importRecord.getState().equals(SyncRecordState.NOT_SUPPOSED_TO_SYNC) ) serverRecord.setState(SyncRecordState.REJECTED); else serverRecord.setState(importRecord.getState()); // record (or clear out) the error message for this server and this record serverRecord.setErrorMessage(importRecord.getErrorMessage()); } Context.getService(SyncService.class).updateSyncRecord(record); } } } /** * Applies synchronization record against the local data store in single transaction. * <p/> Remarks: Exceptions are always thrown if something goes wrong while processing the record in order to abort sync items as * one transaction. To report back SyncImportRecord accurately in case of exception, notice that SyncIngestException contains * SyncImportRecord. In case of exception, callers should inspect this value as it will contain more information about the status of sync * item as it failed. * <p/> Processing PatientIdentifier updates: *updates* to PatientIdentifier objects are processed last. This is because * patient.identifiers is a TreeSet and any updates to the referenced objects can potentially mess up the treeset. * This is especially the case when patient identifier is changed to voided: voiding it changes its ordering per * PatientIdentifier.CompareTo() method to 'last'. This means that if there is a treeset when we void the first * identifier, the treeset cannot be navigated & all operations such as contains(), remove() will return false. * This is because treesets need to be 'resorted' if such changes are made to held objects..however re-sorting it via * remove/add() is not feasible in our case since the actual type of patient.identifier collection is hibernate * persistensortedset; this overrides remove() method and by calling remove()/add() the actual 'delete' to the * database is generated. * To deal with this issue, simply process all patient identifier inserts and deletes first, and only then * process updates that can potentially mess up the treeset order. The long-term fix to this is not to use * treesets for collection of mutable objects such as patient.identifiers in core. * * @param record SyncRecord to be processed * @param server Server where the record came from * @return */ public SyncImportRecord processSyncRecord(SyncRecord record, RemoteServer server) throws SyncIngestException { ArrayList<SyncItem> deletedItems = new ArrayList<SyncItem>(); Map<String, Class> deletedObjects = new HashMap<String, Class>(); // actual openmrsobjects deleted. so that we don't try to process them again when saving/updating things in the treeSetItems list ArrayList<SyncItem> treeSetItems = new ArrayList<SyncItem>(); //these are processed out of order. See method comments. ArrayList<SyncItem> regularNewAndUpdateItems = new ArrayList<SyncItem>(); //inserts and updates SyncImportRecord importRecord = new SyncImportRecord(); importRecord.setState(SyncRecordState.FAILED); // by default, until we know otherwise importRecord.setRetryCount(record.getRetryCount()); importRecord.setTimestamp(record.getTimestamp()); importRecord.setUuid(record.getOriginalUuid()); importRecord.setSourceServer(server); // map of class name to objects of the classes that were updated in this record Map<String, List<SyncProcessedObject>> processedObjects = new HashMap<String, List<SyncProcessedObject>>(); SyncService syncService = Context.getService(SyncService.class); SyncIngestService syncIngestService = Context.getService(SyncIngestService.class); try { // first, let's see if this server even accepts this kind of syncRecord if ( !server.shouldReceiveSyncRecordFrom(record)) { importRecord.setState(SyncRecordState.NOT_SUPPOSED_TO_SYNC); String errorMessage = "NOT INGESTING RECORD with " + record.getContainedClasses() + " BECAUSE SERVER IS NOT READY TO ACCEPT ALL CONTAINED OBJECTS"; importRecord.setErrorMessage(errorMessage); log.warn("\n" + errorMessage + "\n"); } else if (!isValidVersion(record)) { importRecord.setState(SyncRecordState.REJECTED); String errorMessage = "NOT INGESTING RECORD with version " + record.getDatabaseVersion() + " BECAUSE SERVER IS NOT COMPATIBLE"; importRecord.setErrorMessage(errorMessage); log.warn("\n" + errorMessage + "\n"); } else { //log.warn("\nINGESTING ALL CLASSES: " + recordClasses + " BECAUSE SERVER IS READY TO ACCEPT ALL"); // second, let's see if this SyncRecord has already been imported // use the original record id to locate import_record copy log.debug("AT THIS POINT, ORIGINALUUID FOR RECORD IS " + record.getOriginalUuid()); importRecord = syncService.getSyncImportRecord(record.getOriginalUuid()); boolean isUpdateNeeded = false; if ( importRecord == null ) { log.info("ImportRecord does not exist, so creating new one"); isUpdateNeeded = true; importRecord = new SyncImportRecord(record); importRecord.setState(SyncRecordState.FAILED); importRecord.setUuid(record.getOriginalUuid()); importRecord.setSourceServer(server); syncService.createSyncImportRecord(importRecord); } else { if (log.isWarnEnabled()) { log.warn("ImportRecord already exists and has retry count: " + importRecord.getRetryCount() + ", state: " + importRecord.getState()); } SyncRecordState state = importRecord.getState(); if ( state.isFinal() ) { // apparently, the remote/child server exporting to this server doesn't realize it's // committed, so let's remind by sending back this import record with already_committed importRecord.setState(SyncRecordState.ALREADY_COMMITTED); } else if (state.equals(SyncRecordState.FAILED)) { //mark as failed and retry next time importRecord.setState(SyncRecordState.FAILED); importRecord.setRetryCount(importRecord.getRetryCount() + 1); isUpdateNeeded = true; } else { isUpdateNeeded = true; } } if ( isUpdateNeeded ) { log.debug("Looks like update is needed"); boolean isError = false; //as we start setting properties, suspend session flushing syncService.setFlushModeManual(); // for each sync item, process it and insert/update the database; //put deletes into deletedItems collection -- these will get processed last for ( SyncItem item : record.getItems() ) { //System.out.println("item: " + item.getContainedType() + " state: " + item.getState()); //System.out.println("content: " + item.getContent()); if (item.getState() == SyncItemState.DELETED) { deletedItems.add(item); } else if (item.getState() == SyncItemState.UPDATED && item.getContainedType() != null && ( "org.openmrs.PatientIdentifier".equals(item.getContainedType().getName()) || "org.openmrs.PersonAttribute".equals(item.getContainedType().getName()) || "org.openmrs.PersonAddress".equals(item.getContainedType().getName()) || "org.openmrs.PersonName".equals(item.getContainedType().getName()) )) { treeSetItems.add(item); } else if (Person.class.isAssignableFrom(item.getContainedType()) || Concept.class.isAssignableFrom(item.getContainedType()) || SyncSubclassStub.class.isAssignableFrom(item.getContainedType()) || SerializedObject.class.isAssignableFrom(item.getContainedType())){ //Sync-180: Person items need to be processed first, Concept exhibited same behavior. SyncImportItem importedItem = syncIngestService.processSyncItem(item, record.getOriginalUuid() + "|" + server.getUuid(), processedObjects); importedItem.setKey(item.getKey()); importRecord.addItem(importedItem); if ( !importedItem.getState().equals(SyncItemState.SYNCHRONIZED)) isError = true; } else { regularNewAndUpdateItems.add(item); } } for (SyncItem item : regularNewAndUpdateItems){ SyncImportItem importedItem = syncIngestService.processSyncItem(item, record.getOriginalUuid() + "|" + server.getUuid(), processedObjects); importedItem.setKey(item.getKey()); importRecord.addItem(importedItem); if ( !importedItem.getState().equals(SyncItemState.SYNCHRONIZED)) isError = true; } syncService.flushSession(); syncService.setFlushModeAutomatic(); Context.clearSession(); // so that objects aren't resaved at next flush below /* now run through deletes: deletes must be processed after inserts/updates * because of hibernate flushing semantics inside transactions: * if deleted entity is part of a collection on another object within the same session * and this object gets flushed, error is thrown stating that deleted entities must first be removed * from collection; this happens immediately when stmts are executed (and not at the Tx boundary) because * default hibernate FlushMode is AUTO. To further avoid this issue, explicitly suspend flushing for the * duration of deletes. */ syncService.setFlushModeManual(); for ( SyncItem item : deletedItems ) { SyncImportItem importedItem = this.processSyncItem(item, record.getOriginalUuid() + "|" + server.getUuid(), processedObjects); importedItem.setKey(item.getKey()); importRecord.addItem(importedItem); // save this object for later so we're sure to not update it when processing the "treesetitems" deletedObjects.put((String)item.getKey().getKeyValue(), item.getContainedType()); if ( !importedItem.getState().equals(SyncItemState.SYNCHRONIZED)) isError = true; } syncService.flushSession(); syncService.setFlushModeAutomatic(); Context.clearSession(); // so that objects aren't resaved at next flush below /* Run through the updates for patient props that are treesets, see the method comments to understand * why this is done here. */ syncService.setFlushModeManual(); for ( SyncItem item : treeSetItems ) { if (item.getContainedType().equals(deletedObjects.get((String)item.getKey().getKeyValue()))) { log.debug("skipping update of " + item.getContainedType() + ":" + item.getKey() + " because we just deleted it"); continue; } //why is the identifier not getting into a sync item? SyncImportItem importedItem = syncIngestService.processSyncItem(item, record.getOriginalUuid() + "|" + server.getUuid(), processedObjects); importedItem.setKey(item.getKey()); importRecord.addItem(importedItem); if ( !importedItem.getState().equals(SyncItemState.SYNCHRONIZED)) isError = true; } syncService.flushSession(); syncService.setFlushModeAutomatic(); Context.clearSession(); // so that objects aren't resaved at next flush below /* * finally execute the pending actions that resulted from processing all sync items */ syncService.setFlushModeManual(); syncIngestService.applyPreCommitRecordActions(processedObjects); syncService.flushSession(); syncService.setFlushModeAutomatic(); Context.clearSession(); // so that objects aren't resaved at next flush below if ( !isError ) { importRecord.setState(SyncRecordState.COMMITTED); } else { //One of SyncItem commits failed, throw to rollback and set failure information. log.warn("Error while processing SyncRecord with original uuid " + record.getOriginalUuid() + " (" + record.getContainedClasses() + ")"); importRecord.setState(SyncRecordState.FAILED); throw new SyncIngestException(SyncConstants.ERROR_ITEM_NOT_COMMITTED,null,null,importRecord); } } } } catch (SyncIngestException e) { log.error("Unable to ingest a sync request", e); //fill in sync import record and rethrow to abort tx importRecord.setState(SyncRecordState.FAILED); importRecord.setErrorMessage(e.getMessage() + ": " + OpenmrsUtil.shortenedStackTrace(ExceptionUtils.getFullStackTrace(e))); e.setSyncImportRecord(importRecord); throw (e); } catch (Exception e ) { log.error("Unexpected exception occurred when processing sync records", e); //fill in sync import record and rethrow to abort tx importRecord.setState(SyncRecordState.FAILED); importRecord.setErrorMessage(e.getMessage() + ": " + OpenmrsUtil.shortenedStackTrace(ExceptionUtils.getFullStackTrace(e))); throw new SyncIngestException(e,SyncConstants.ERROR_RECORD_UNEXPECTED,null,null,importRecord); } finally { syncService.updateSyncImportRecord(importRecord); //reset the flush mode back to automatic, no matter what syncService.setFlushModeAutomatic(); } //for hibernate SYNC-175 server = null; return importRecord; } /** * Compares the code/database version for the incoming sync record against this server's code * version. If they are different, the record should be denied. * * @param record the incoming SyncRecord * @return true if the record's database version matches this server's version */ private boolean isValidVersion(SyncRecord record) { return ModuleUtil.compareVersion(OpenmrsConstants.OPENMRS_VERSION_SHORT, record.getDatabaseVersion()) == 0; } /** * Applies the 'actions' identified during the processing of the record that need to be * processed (for whatever reason) just before the sync record is to be committed. * * The actions understood by this method: * <br/>REBUILD XSN * <br/>- call to formentry module and attempt to rebuild XSN, * <br/>- HashMap object will contain instance of Form object to be rebuilt * <br/>UPDATE CONCEPT WORDS * <br/>- call to concept service to update concept words for given concept * <br/>- HashMap object will contain instance of Concept object which concept words are to be rebuilt * */ // TODO: does this really happen precommit? wouldn't he call to updateConceptWord force a commit? public void applyPreCommitRecordActions(Map<String, List<SyncProcessedObject>> processedObjects) { if (processedObjects == null) return; // rebuild xsns if a form edit comes through List<SyncProcessedObject> xsns = processedObjects.get("org.openmrs.module.formentry.FormEntryXsn"); if (xsns != null) { for (SyncProcessedObject xsn : xsns) { SyncUtil.rebuildXSN(xsn.getObject()); } } else { //even if XSNs aren't sync-ed, look for forms to update form.template if needed List<SyncProcessedObject> forms = processedObjects.get("org.openmrs.Form"); if (forms != null) { for (SyncProcessedObject form : forms) { if (form.getObject() instanceof org.openmrs.Form) { SyncUtil.rebuildXSNForForm((org.openmrs.Form)form.getObject()); } } } } // fix concept words for all names found List<SyncProcessedObject> names = processedObjects.get("org.openmrs.ConceptName"); if (names != null) { for (SyncProcessedObject o : names) { // we only want to update the concept words if this is NOT a delete action if (o.getState() != SyncItemState.DELETED) { // we need to reload the concept here because the session has been cleared earlier Concept c = Context.getConceptService().getConcept(((ConceptName) o.getObject()).getConcept().getId()); Context.getConceptService().updateConceptWord(c); } } } } /** * Note: preCommitRecordActions collection is provided as a way for the OpenmrsObject instances to 'schedule' action that is necessary * for processing of the object yet it cannot be applied until the end of the processing of the parent sync record. For example, rebuild XSN * cannot happen until all form fields held in the sync items are applied first; thus the call to rebuild XSN need to happen after all * sync items were processed and before committing the sync record. * * HashMap contained in the collection is to capture the action, and the necessary object to resolve that action. The action * is understood and applied by applyPreCommitRecordActions * * @see org.openmrs.module.sync.api.SyncIngestService#processSyncItem(org.openmrs.module.sync.SyncItem, java.lang.String, java.util.Map) * */ public SyncImportItem processSyncItem(SyncItem item, String originalRecordUuid, Map<String, List<SyncProcessedObject>> processedObjects) throws APIException { String itemContent = null; SyncImportItem ret = null; try { ret = new SyncImportItem(); //ret.setContent(itemContent); - no need to copy content back: the server that send it knows it already ret.setState(SyncItemState.UNKNOWN); Object o = null; itemContent = item.getContent(); if (log.isDebugEnabled()) { log.debug("STARTING TO PROCESS: " + itemContent); log.debug("SyncItem state is: " + item.getState()); } o = SyncUtil.getRootObject(itemContent); if (o instanceof org.hibernate.collection.PersistentCollection) { log.debug("Processing a persistent collection"); dao.processCollection(o.getClass(),itemContent,originalRecordUuid); } else { // do the saving of the object to the database, etc OpenmrsObject openmrsObject = processOpenmrsObject((OpenmrsObject)o, item, originalRecordUuid); // add this object to the proccessedObjects list String className = o.getClass().getName(); if (!processedObjects.containsKey(className)) { List<SyncProcessedObject> objects = new ArrayList<SyncProcessedObject>(); objects.add(new SyncProcessedObject(openmrsObject, item.getState())); processedObjects.put(className, objects); } else { processedObjects.get(className).add(new SyncProcessedObject(openmrsObject, item.getState())); } } ret.setState(SyncItemState.SYNCHRONIZED); } catch (SyncIngestException e) { e.setSyncItemContent(itemContent); //MUST RETHROW to abort transaction throw (e); } catch (Exception e) { throw new SyncIngestException(e,SyncConstants.ERROR_ITEM_UNEXPECTED, null, itemContent, null); //MUST RETHROW to abort transaction } return ret; } /** * Takes steps necessary to handle ingest of {@link SyncSubclassStub} by calling * {@link SyncDAO#processSyncSubclassStub(SyncSubclassStub)} * * param stub {@link SyncSubclassStub} to be saved. */ public void processSyncSubclassStub(SyncSubclassStub stub) throws APIException { dao.processSyncSubclassStub(stub); return; } /** * Processes serialized SyncItem state by attempting to hydrate the object SyncItem represents and then using OpenMRS service layer to * update the hydrated instance of OpenmrsObject object. * <p/>Remarks: This implementation relies on internal knowledge of how SyncItems are serialized: it iterates over direct child nodes of the root xml * node in incoming assuming they are serialized public properties of the object that is being hydrated. Consequently, for each child node, * property setter is determined and then called. After setting all properties, OpenMRS service layer API is used to actually save * the object into persistent store. The details of how property setters are determined and how appropriate service layer methods * are determined are contained in SyncUtil class. * <p/> * SyncItem with status of DELETED is handled differently from insert/update: In case of a delete, all that is needed (and sent) * is the object type and its UUID. Consequently, the process for handling deletes consists of first fetching * existing object by uuid and then deleting it by a call to sync service API. Note, if object is not found in DB by its uuid, we * skip the delete and record warning message. * <p/> * preCommitRecordActions collection is provided as a way for the OpenmrsObject instances to 'schedule' action that is necessary * for processing of the object yet it cannot be applied until the end of the processing of the parent sync record. For example, rebuild XSN * cannot happen until all form fields held in the sync items are applied first; thus the call to rebuild XSN need to happen after all * sync items were processed and before committing the sync record. * * @param o empty instance of class that this SyncItem represents * @param item SyncItem. * @param originalRecordUuid Unique id of the sync record that this SyncItem recorded in when this object was first created. NOTE: * this value is retained and forwarded unchanged throughout the network of synchronizing servers in order to avoid re-applying * same changes over and over. * @return the saved OpenmrsObject (could be different than what is passed in if updating a record) * * @see SyncUtil#setProperty(Object, String, Object) * @see SyncUtil#getOpenmrsObj(String, String) * @see SyncUtil#updateOpenmrsObject(OpenmrsObject, String, String) */ private OpenmrsObject processOpenmrsObject(OpenmrsObject o, SyncItem item, String originalRecordUuid) throws Exception { String itemContent = null; String className = null; boolean alreadyExists = false; boolean isDelete = false; ArrayList<Field> allFields = null; NodeList nodes = null; isDelete = (item.getState() == SyncItemState.DELETED) ? true : false; itemContent = item.getContent(); className = o.getClass().getName(); allFields = SyncUtil.getAllFields(o); // get fields, both in class and superclass - we'll need to know what type each field is nodes = SyncUtil.getChildNodes(itemContent); // get all child nodes (xml) of the root object if ( o == null || className == null || allFields == null || nodes == null ) { log.warn("Item is missing a className or all fields or nodes"); throw new SyncIngestException(SyncConstants.ERROR_ITEM_NOCLASS, className, itemContent,null); } String uuid = SyncUtil.getAttribute(nodes, "uuid", allFields); OpenmrsObject objOld = SyncUtil.getOpenmrsObj(className, uuid); if ( objOld != null ) { o = objOld; alreadyExists = true; } if (log.isDebugEnabled()) { log.debug("isUpdate: " + alreadyExists); log.debug("isDelete: " + isDelete); } //Pass the original uuid to interceptor: this will prevent the change //from being sent back to originating server. HibernateSyncInterceptor.setOriginalRecordUuid(originalRecordUuid); //execute delete if instance was found and operation is delete if (alreadyExists && isDelete) { SyncUtil.deleteOpenmrsObject(o); }else if (!alreadyExists && isDelete) { log.warn("Object to be deleted was not found in the database. skipping delete operation:"); log.warn("-object type: " + o.getClass().toString()); log.warn("-object uuid: " + uuid); } else { //if we are doing insert/update: //1. set serialized props state //2. force it down the hibernate's throat with help of openmrs api for ( int i = 0; i < nodes.getLength(); i++ ) { try { log.debug("trying to set property: " + nodes.item(i).getNodeName() + " in className " + className); SyncUtil.setProperty(o, nodes.item(i), allFields); } catch ( Exception e ) { log.error("Error when trying to set " + nodes.item(i).getNodeName() + ", which is a " + className, e); throw new SyncIngestException(e, SyncConstants.ERROR_ITEM_UNSET_PROPERTY, nodes.item(i).getNodeName() + "," + className + "," + e.getMessage(), itemContent,null); } } // now try to commit this fully inflated object try { log.debug("About to update or create a " + className + " object, uuid: '" + uuid + "'"); SyncUtil.updateOpenmrsObject(o, className, uuid); Context.getService(SyncService.class).flushSession(); } catch ( Exception e ) { // don't include stacktrace here because the parent classes log it sufficiently log.error("Unexpected exception occurred while saving openmrsobject: " + className + ", uuid '" + uuid + "'"); throw new SyncIngestException(e, SyncConstants.ERROR_ITEM_NOT_COMMITTED, e.getMessage(), itemContent, null); } } return o; } /** * (non-Javadoc) * @see org.openmrs.module.sync.api.SyncIngestService#isConceptIdValidForUuid(Integer, java.lang.String) */ public boolean isConceptIdValidForUuid(Integer conceptId, String uuid) throws APIException { return dao.isConceptIdValidForUuid(conceptId, uuid); } }