Search code examples
xpathwiremockwiremock-standalonehandlebars.java

Wiremock request templating in standalone mode: can I use a XML file as response template and inject value with XPATH?


I know that request template supports XPath, so that I can get value from request like {{xPath request.body '/outer/inner/text()'}}. I already have a XML file as response, and I want to inject this value I got from request, but keep the other parts of this response XML intact. For example, I want to inject it to XPATH /svc_result/slia/pos/msid.

And I need to use it in standalone mode.

I see another question(Wiremock Stand alone - How to manipulate response with request data) but that was with JSON, I have XML request/response.

How can it be done? Thanks.

For example, I have this definition of mapping:

{
    "request": {
        "method": "POST",
        "bodyPatterns": [
            {
                "matchesXPath": {
                    "expression": "/svc_init/slir/msids/msid[@type='MSISDN']/text()",
                    "equalTo": "200853000105614"
                }
            },
            {
                "matchesXPath": "/svc_init/hdr/client[id and pwd]"
            }
        ]
    },
    "response": {
        "status": 200,
        "bodyFileName": "slia.xml",
        "headers": {
            "Content-Type": "application/xml;charset=UTF-8"
        }
    }
}

And this request:

<?xml version="1.0"?>
<!DOCTYPE svc_init>
<svc_init ver="3.2.0">
    <hdr ver="3.2.0">
        <client>
            <id>dummy</id>
            <pwd>dummy</pwd>
        </client>
    </hdr>
    <slir ver="3.2.0" res_type="SYNC">
        <msids>
            <msid type="MSISDN">200853000105614</msid>
        </msids>
    </slir>
</svc_init>

I expect this response, with xxxxxxxxxxx replaced with the <msid> in the request.

<?xml version="1.0" ?>
<!DOCTYPE svc_result SYSTEM "MLP_SVC_RESULT_320.DTD">
<svc_result ver="3.2.0">
    <slia ver="3.0.0">
        <pos>
            <msid type="MSISDN" enc="ASC">xxxxxxxxxxx</msid>
            <pd>
                <time utc_off="+0800">20111122144915</time>
                <shape>
                    <EllipticalArea srsName="www.epsg.org#4326">
                        <coord>
                            <X>00 01 01N</X>
                            <Y>016 31 53E</Y>
                        </coord>
                        <angle>0</angle>
                        <semiMajor>2091</semiMajor>
                        <semiMinor>2091</semiMinor>
                        <angularUnit>Degrees</angularUnit>
                    </EllipticalArea>
                </shape>
                <lev_conf>90</lev_conf>
            </pd>
            <gsm_net_param>
                <cgi>
                    <mcc>100</mcc>
                    <mnc>01</mnc>
                    <lac>2222</lac>
                    <cellid>10002</cellid>
                </cgi>
                <neid>
                    <vmscid>
                        <vmscno>00004946000</vmscno>
                    </vmscid>
                    <vlrid>
                        <vlrno>99994946000</vlrno>
                    </vlrid>
                </neid>
            </gsm_net_param>
        </pos>
    </slia>
</svc_result>

Solution

  • At last I created my own transformer:

    package com.company.department.app.extensions;
    
    import com.github.tomakehurst.wiremock.common.FileSource;
    import com.github.tomakehurst.wiremock.extension.Parameters;
    import com.github.tomakehurst.wiremock.extension.ResponseTransformer;
    import com.github.tomakehurst.wiremock.http.Request;
    import com.github.tomakehurst.wiremock.http.Response;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.w3c.dom.Document;
    import org.w3c.dom.Node;
    import org.xml.sax.EntityResolver;
    import org.xml.sax.InputSource;
    import org.xml.sax.SAXException;
    
    import java.io.ByteArrayInputStream;
    import java.io.IOException;
    import java.io.StringReader;
    import java.io.StringWriter;
    import java.time.Instant;
    import java.time.LocalDateTime;
    import java.time.ZoneId;
    import java.time.ZonedDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.List;
    
    import javax.xml.parsers.DocumentBuilder;
    import javax.xml.parsers.DocumentBuilderFactory;
    import javax.xml.transform.OutputKeys;
    import javax.xml.transform.Transformer;
    import javax.xml.transform.TransformerException;
    import javax.xml.transform.TransformerFactory;
    import javax.xml.transform.dom.DOMSource;
    import javax.xml.transform.stream.StreamResult;
    import javax.xml.xpath.XPath;
    import javax.xml.xpath.XPathConstants;
    import javax.xml.xpath.XPathExpressionException;
    import javax.xml.xpath.XPathFactory;
    
    public class NLGResponseTransformer extends ResponseTransformer {
    
        private static final Logger LOG = LoggerFactory.getLogger(NLGResponseTransformer.class);
    
        private static final String SLIA_FILE = "/stubs/__files/slia.xml";
        private static final String REQ_IMSI_XPATH = "/svc_init/slir/msids/msid";
        private static final String[] RES_IMSI_XPATHS = {
                "/svc_result/slia/pos/msid",
                "/svc_result/slia/company_mlp320_slia/company_netinfo/company_ms_netinfo/msid"
        };
        private static final String[] RES_TIME_XPATHS = {
                // for slia.xml
                "/svc_result/slia/company_mlp320_slia/company_netinfo/company_ms_netinfo/time",
                // for slia_poserror.xml
                "/svc_result/slia/pos/poserror/time"
        };
    
        private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance();
        private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
        private static final String UTC_OFF = "utc_off";
        private static final String TRANSFORM_FACTORY_ATTRIBUTE_INDENT_NUMBER = "indent-number";
        protected static final String COMPANY_MLP_320_SLIA_EXTENSION_DTD = "company_mlp320_slia_extension.dtd";
        protected static final String MLP_SVC_RESULT_320_DTD = "MLP_SVC_RESULT_320.DTD";
    
        @Override
        public String getName() {
            return "inject-request-values";
        }
    
        @Override
        public Response transform(Request request, Response response, FileSource fileSource, Parameters parameters) {
            Document responseDocument = injectValuesFromRequest(request);
            String transformedResponse = transformToString(responseDocument);
            if (transformedResponse == null) {
                return response;
            }
            return Response.Builder.like(response)
                    .but()
                    .body(transformedResponse)
                    .build();
        }
    
        private Document injectValuesFromRequest(Request request) {
            // NOTE: according to quickscan:
            // "time" element in the MLP is the time MME reports cell_id to GMLC (NLG), NOT the time when MME got the cell_id.
            LocalDateTime now = LocalDateTime.now();
            Document responseTemplate = readDocument(SLIA_FILE);
            Document requestDocument = readDocumentFromBytes(request.getBody());
            if (responseTemplate == null || requestDocument == null) {
                return null;
            }
            try {
                injectIMSI(responseTemplate, requestDocument);
                injectTime(responseTemplate, now);
            } catch (XPathExpressionException e) {
                LOG.error("Cannot parse XPath expression {}. Cause: ", REQ_IMSI_XPATH, e);
            }
            return responseTemplate;
        }
    
        private Document readDocument(String inputStreamPath) {
            try {
                DocumentBuilder builder = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder();
                // ignore missing dtd
                builder.setEntityResolver((publicId, systemId) -> {
                    if (systemId.contains(COMPANY_MLP_320_SLIA_EXTENSION_DTD) ||
                            systemId.contains(MLP_SVC_RESULT_320_DTD)) {
                        return new InputSource(new StringReader(""));
                    } else {
                        return null;
                    }
                });
                return builder.parse(this.getClass().getResourceAsStream(inputStreamPath));
            } catch (Exception e) {
                LOG.error("Cannot construct document from resource path. ", e);
                return null;
            }
        }
    
        private Document readDocumentFromBytes(byte[] array) {
            try {
                DocumentBuilder builder = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder();
                // ignore missing dtd
                builder.setEntityResolver((publicId, systemId) -> {
                    if (systemId.contains(COMPANY_MLP_320_SLIA_EXTENSION_DTD) ||
                            systemId.contains(MLP_SVC_RESULT_320_DTD)) {
                        return new InputSource(new StringReader(""));
                    } else {
                        return null;
                    }
                });
                return builder.parse(new ByteArrayInputStream(array));
            } catch (Exception e) {
                LOG.error("Cannot construct document from byte array. ", e);
                return null;
            }
        }
    
        private XPath newXPath() {
            return XPathFactory.newInstance().newXPath();
        }
    
    
        private void injectTime(Document responseTemplate, LocalDateTime now) throws XPathExpressionException {
            for (String timeXPath: RES_TIME_XPATHS) {
                Node timeTarget = (Node) (newXPath().evaluate(timeXPath, responseTemplate, XPathConstants.NODE));
                if (timeTarget != null) {
                    // set offset in attribute
                    Node offset = timeTarget.getAttributes().getNamedItem(UTC_OFF);
                    offset.setNodeValue(getOffsetString());
                    // set value
                    timeTarget.setTextContent(TIME_FORMAT.format(now));
                }
            }
        }
    
        private void injectIMSI(Document responseTemplate, Document requestDocument) throws XPathExpressionException {
            Node imsiSource = (Node) (newXPath().evaluate(REQ_IMSI_XPATH, requestDocument, XPathConstants.NODE));
            String imsi = imsiSource.getTextContent();
            for (String xpath : RES_IMSI_XPATHS) {
                Node imsiTarget = (Node) (newXPath().evaluate(xpath, responseTemplate, XPathConstants.NODE));
                if (imsiTarget != null) {
                    imsiTarget.setTextContent(imsi);
                }
            }
        }
    
    
        private String transformToString(Document document) {
            if (document == null) {
                return null;
            }
            document.setXmlStandalone(true); // make document to be standalone, so we can avoid outputing standalone="no" in first line
            TransformerFactory tf = TransformerFactory.newInstance();
            Transformer trans;
            try {
                trans = tf.newTransformer();
                trans.setOutputProperty(OutputKeys.INDENT, "no"); // no extra indent; file already has intent of 4
                // cannot find a workaround to inject dtd in doctype line. TODO
                //trans.setOutputProperty(OutputKeys.DOCTYPE_SYSTEM, "MLP_SVC_RESULT_320.DTD [<!ENTITY % extension SYSTEM \"company_mlp320_slia_extension.dtd\"> %extension;]");
                StringWriter sw = new StringWriter();
                trans.transform(new DOMSource(document), new StreamResult(sw));
                // Spaces between tags are considered as text node, so when outputing we need to remove the extra empty lines
                return sw.toString().replaceAll("\\n\\s*\\n", "\n");
            } catch (TransformerException e) {
                LOG.error("Cannot transform response document to String. ", e);
                return null;
            }
        }
    
    
        /**
         * Compare system default timezone with UTC and get zone offset in form of (+/-)XXXX.
         * Dependent on the machine default timezone/locale.
         * @return
         */
        private String getOffsetString() {
            // getting offset in (+/-)XX:XX format, or "Z" if is UTC
            String offset = ZonedDateTime.ofInstant(Instant.now(), ZoneId.systemDefault()).getOffset().toString();
            if (offset.equals("Z")) {
                return "+0000";
            }
            return offset.replace(":", "");
        }
    }
    
    

    And use it like this:

    1. mvn package it as a JAR(non-runnable), put it aside wiremock standalone jar, for example libs
    2. Run this:
    java -cp libs/* com.github.tomakehurst.wiremock.standalone.WireMockServerRunner --extensions com.company.department.app.extensions NLGResponseTransformer --https-port 8443 --verbose
    
    • Put the whole command on the same line.

    • Notice the app jar which contains this transformer and wiremock standalone jar should be among classpath. Also, other dependencies under libs are needed. (I use jib maven plugin which copies all dependencies under libs/; I also move app and wiremock jars to libs/, so I can put "-cp libs/*"). If that does not work, try to specify the location of these two jars in -cp. Be ware that Wiremock will runs OK even when the extension class is not found. So maybe add some loggings.

    • You can use --root-dir to point to stubs files root, for example --root-dir resources/stubs in my case. By default it points to .(where java runs).