// Copyright 2015 The Project Buendia Authors // // Licensed under the Apache License, Version 2.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://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software distrib- // uted under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES // OR CONDITIONS OF ANY KIND, either express or implied. See the License for // specific language governing permissions and limitations under the License. package org.openmrs.projectbuendia.webservices.rest; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.openmrs.BaseOpenmrsMetadata; import org.openmrs.Field; import org.openmrs.Form; import org.openmrs.FormField; import org.openmrs.Provider; import org.openmrs.api.FormService; import org.openmrs.api.ProviderService; import org.openmrs.api.context.Context; import org.openmrs.module.webservices.rest.SimpleObject; import org.openmrs.module.webservices.rest.web.RequestContext; import org.openmrs.module.webservices.rest.web.annotation.Resource; import org.openmrs.module.webservices.rest.web.representation.Representation; import org.openmrs.module.xforms.buendia.BuendiaXformBuilderEx; import org.openmrs.module.xforms.buendia.FormData; import org.openmrs.module.xforms.util.XformsUtil; import org.openmrs.util.FormConstants; import org.projectbuendia.openmrs.webservices.rest.RestController; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.xml.sax.SAXException; import java.io.IOException; import java.util.ArrayList; import java.util.Date; import java.util.List; import javax.annotation.Nullable; import static org.openmrs.projectbuendia.webservices.rest.XmlUtil.getElementOrThrowNS; import static org.openmrs.projectbuendia.webservices.rest.XmlUtil.removeNode; import static org.openmrs.projectbuendia.webservices.rest.XmlUtil.toElementIterable; /** * Resource for "form models" (not-yet-filled-in forms). Note: this is under * org.openmrs as otherwise the resource annotation isn't picked up. * @see AbstractReadOnlyResource */ @Resource(name = RestController.REST_VERSION_1_AND_NAMESPACE + "/xforms", supportedClass = Form.class, supportedOpenmrsVersions = "1.10.*,1.11.*") public class XformResource extends AbstractReadOnlyResource<Form> { private static final String HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; private static final String XFORMS_NAMESPACE = "http://www.w3.org/2002/xforms"; @SuppressWarnings("unused") private final Log log = LogFactory.getLog(getClass()); private final FormService formService; private final ProviderService providerService; public XformResource() { super("xform", Representation.DEFAULT, Representation.FULL, Representation.REF); this.formService = Context.getFormService(); this.providerService = Context.getProviderService(); } /** * Adds the following fields to the {@link SimpleObject}: * <ul> * <li>name: display name of the form * <li>date_created: the date the form was created, as ms since epoch * <li>version: the version number of the form (e.g. 0.2.1) * <li>date_changed: the date the form was last modified, as ms since epoch; * for forms that contain a provider field, this date will also be * updated whenever the set of providers on the server changes * </ul> * <p/> * If the query parameter "?v=full" is present, also adds the "xml" field * containing the XML of the form model definition. * @param context the request context; specify "v=full" in the URL params * to include the XML for the form model in the response * @param snapshotTime ignored */ @Override protected void populateJsonProperties( Form form, RequestContext context, SimpleObject json, long snapshotTime) { json.add("name", form.getName()); json.add("id", form.getFormId()); json.add("version", form.getVersion()); Date dateChanged = form.getDateChanged(); json.add("date_created", form.getDateCreated()); json.add("version", form.getVersion()); boolean includesProviders = false; if (context.getRepresentation() == Representation.FULL) { try { // TODO: Use description instead of name? FormData formData = BuendiaXformBuilderEx.buildXform( form, new BuendiaXformCustomizer()); String xml = convertToOdkCollect(formData.xml, form.getName()); includesProviders = formData.includesProviders; xml = removeRelationshipNodes(xml); json.add("xml", xml); } catch (Exception e) { throw new RuntimeException(e); } } else { // Do a linear search, as otherwise it puts too many assumptions on // comparison order. Also FormField overrides compare to be based // on lots of fields, but leaves .equals() based on UUID unchanged, // which is really dangerous. for (FormField formField : form.getFormFields()) { Field field = formField.getField(); if (FormConstants.FIELD_TYPE_DATABASE.equals( field.getFieldType().getFieldTypeId()) && "encounter".equals(field.getTableName())) { includesProviders = true; } dateChanged = maxDate(dateChanged, dateChanged(formField)); } } if (includesProviders) { for (Provider provider : providerService.getAllProviders()) { dateChanged = maxDate(dateChanged, dateChanged(provider)); } } json.add("date_changed", dateChanged); } /** * Converts a vanilla Xform into one that ODK Collect is happy to work with. * This requires: * <ul> * <li>Changing the namespace of the the root element to http://www.w3.org/1999/xhtml * <li>Wrapping the model element in an HTML head element, with a title child element * <li>Wrapping remaining elements in an HTML body element * </ul> */ static String convertToOdkCollect(String xml, String title) throws IOException, SAXException { // Change the namespace of the root element. I haven't figured out a way // to do // this within a document; removing the root element from the document // seems // to do odd things... so instead, we import it into a new document. Document oldDoc = XmlUtil.parse(xml); Document doc = XmlUtil.getDocumentBuilder().newDocument(); Element root = (Element) doc.importNode(oldDoc.getDocumentElement(), true); root = (Element) doc.renameNode(root, HTML_NAMESPACE, "h:form"); doc.appendChild(root); // Prepare the new wrapper elements Element head = doc.createElementNS(HTML_NAMESPACE, "h:head"); Element titleElement = XmlUtil.appendElementNS(head, HTML_NAMESPACE, "h:title"); titleElement.setTextContent(title); Element body = doc.createElementNS(HTML_NAMESPACE, "h:body"); // Find the model element to go in the head, and all its following // siblings to go in the body. // We do this before moving any elements, for the sake of sanity. Element model = getElementOrThrowNS(root, XFORMS_NAMESPACE, "model"); List<Node> nodesAfterModel = new ArrayList<>(); Node nextSibling = model.getNextSibling(); while (nextSibling != null) { nodesAfterModel.add(nextSibling); nextSibling = nextSibling.getNextSibling(); } // Now we're done with the preparation, we can move everything. head.appendChild(model); for (Node node : nodesAfterModel) { body.appendChild(node); } // Having removed the model and everything after it, we can now just // append the head and body to the document element... root.appendChild(head); root.appendChild(body); return XformsUtil.doc2String(doc); } /** * Removes the relationship nodes added (unconditionally) by xforms. If * XFRM-189 is fixed, this method can go away. */ static String removeRelationshipNodes(String xml) throws IOException, SAXException { Document doc = XmlUtil.parse(xml); removeBinding(doc, "patient_relative"); removeBinding(doc, "patient_relative.person"); removeBinding(doc, "patient_relative.relationship"); for (Element relative : toElementIterable(doc .getElementsByTagNameNS("", "patient_relative"))) { removeNode(relative); } // Remove every parent of a label element with a text of // "RELATIONSHIPS". (Easiest way to find the ones added...) for (Element label : toElementIterable(doc .getElementsByTagNameNS(XFORMS_NAMESPACE, "label"))) { Element parent = (Element) label.getParentNode(); if (XFORMS_NAMESPACE.equals(parent.getNamespaceURI()) && parent.getLocalName().equals("group") && "RELATIONSHIPS".equals(label.getTextContent())) { removeNode(parent); // We don't need to find other labels now, especially if they // may already have been removed. break; } } return XformsUtil.doc2String(doc); } /** Returns the later of two nullable dates. */ private Date maxDate(@Nullable Date d1, @Nullable Date d2) { if (d1 == null) return d2; if (d2 == null) return d1; return d1.before(d2) ? d2 : d1; } /** * Returns the actual last modification time of an OpenMRS object. * Because OpenMRS doesn't set the modification time upon initial * creation (sigh) we have to check both dateChanged and dateCreated. */ private Date dateChanged(BaseOpenmrsMetadata d) { Date dateChanged = d.getDateChanged(); if (dateChanged != null) return dateChanged; return d.getDateCreated(); } // Visible for testing private static void removeBinding(Document doc, String id) { for (Element binding : toElementIterable( doc.getElementsByTagNameNS(XFORMS_NAMESPACE, "bind"))) { if (binding.getAttribute("id").equals(id)) { removeNode(binding); return; } } } // VisibleForTesting /** * Retrieves a single xform with the given UUID. See * {@link #populateJsonProperties(Form, RequestContext, SimpleObject, long)} * for details on the context and snapshotTime arguments. * @param context unused here * @param snapshotTime unused here * @see AbstractReadOnlyResource#retrieve(String, RequestContext) */ @Override protected Form retrieveImpl(String uuid, RequestContext context, long snapshotTime) { return formService.getFormByUuid(uuid); } /** * Returns all xforms (there is no support for query parameters). See * {@link #populateJsonProperties(Form, RequestContext, SimpleObject, long)} * for details on the context and snapshotTime arguments. * @param context unused here * @param snapshotTime unused here * Note: because of a bug in parsing form definitions, "v=full" is currently * broken for this function * @see AbstractReadOnlyResource#search(RequestContext) */ @Override protected Iterable<Form> searchImpl(RequestContext context, long snapshotTime) { // TODO/bug: Fix verbose mode. Currently produces the error: // "No bind node for bindName _3._bleeding_sites". // No query parameters supported - just give all the forms List<Form> forms = new ArrayList<>(); for (Form form : formService.getAllForms()) { if (form.getPublished()) { forms.add(form); } } return forms; } }