se.natusoft.osgi.aps.rpchttpextender.servlet.RPCServlet.java Source code

Java tutorial

Introduction

Here is the source code for se.natusoft.osgi.aps.rpchttpextender.servlet.RPCServlet.java

Source

/* 
 * 
 * PROJECT
 *     Name
 *         APS External Protocol HTTP Transport Provider
 *     
 *     Code Version
 *         0.10.0
 *     
 *     Description
 *         This uses aps-external-protocol-extender to provide remote calls over HTTP. It makes
 *         any published service implementing se.natusoft.osgi.aps.net.rpc.streamed.service.StreamedRPCProtocolService
 *         available for calling services over HTTP.
 *         
 * COPYRIGHTS
 *     Copyright (C) 2012 by Natusoft AB All rights reserved.
 *     
 * LICENSE
 *     Apache 2.0 (Open Source)
 *     
 *     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
 *     distributed 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 the specific language governing permissions and
 *     limitations under the License.
 *     
 * AUTHORS
 *     Tommy Svensson (tommy@natusoft.se)
 *         Changes:
 *         2012-01-06: Created!
 *         
 */
package se.natusoft.osgi.aps.rpchttpextender.servlet;

import org.apache.commons.codec.binary.Base64;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceReference;
import se.natusoft.osgi.aps.api.external.extprotocolsvc.APSExternalProtocolService;
import se.natusoft.osgi.aps.api.external.extprotocolsvc.model.APSExternalProtocolListener;
import se.natusoft.osgi.aps.api.external.extprotocolsvc.model.APSExternallyCallable;
import se.natusoft.osgi.aps.api.external.model.type.DataType;
import se.natusoft.osgi.aps.api.external.model.type.DataTypeDescription;
import se.natusoft.osgi.aps.api.external.model.type.ParameterDataTypeDescription;
import se.natusoft.osgi.aps.api.misc.json.model.JSONValue;
import se.natusoft.osgi.aps.api.misc.json.service.APSJSONExtendedService;
import se.natusoft.osgi.aps.api.net.discovery.model.ServiceDescriptionProvider;
import se.natusoft.osgi.aps.api.net.discovery.service.APSSimpleDiscoveryService;
import se.natusoft.osgi.aps.api.net.rpc.errors.ErrorType;
import se.natusoft.osgi.aps.api.net.rpc.errors.HTTPError;
import se.natusoft.osgi.aps.api.net.rpc.errors.RPCError;
import se.natusoft.osgi.aps.api.net.rpc.model.RPCRequest;
import se.natusoft.osgi.aps.api.net.rpc.model.RequestIntention;
import se.natusoft.osgi.aps.api.net.rpc.service.StreamedRPCProtocol;
import se.natusoft.osgi.aps.exceptions.APSException;
import se.natusoft.osgi.aps.rpchttpextender.config.RPCServletConfig;
import se.natusoft.osgi.aps.tools.APSLogger;
import se.natusoft.osgi.aps.tools.APSServiceTracker;
import se.natusoft.osgi.aps.tools.exceptions.APSNoServiceAvailableException;
import se.natusoft.osgi.aps.tools.tracker.OnServiceAvailable;
import se.natusoft.osgi.aps.tools.tracker.WithService;
import se.natusoft.osgi.aps.tools.web.APSAdminWebLoginHandler;

import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.util.*;

/**
 * The servlet we make the RPC http transport available on.
 * <p/>
 * The http transport will be available on "http://host:port/apsrpc/protocolname/protocolversion/protocoldata/[service]/..." on http put.
 * <p/>
 * If you bring up "http://host:port/apsrpc/" in a browser you will get information about available protocols and services.
 */
public class RPCServlet extends HttpServlet
        implements APSExternalProtocolListener, OnServiceAvailable<APSSimpleDiscoveryService> {
    //
    // Cosntants
    //

    private static final String BG_COLOR = "bgcolor=\"#f5f5f5\"";

    private static final int AUTH_FAILED = -1;

    //
    // Private Members
    //

    /**
     * The logger to log to.
     */
    private APSLogger logger = null;

    /**
     * This is used to access the externally available services.
     */
    private APSServiceTracker<APSExternalProtocolService> externalProtocolServiceTracker = null;

    /**
     * The tracked service.
     */
    private APSExternalProtocolService externalProtocolService = null;

    /**
     * Used for displaying result of text executions of no args methods on method display.
     */
    private APSServiceTracker<APSJSONExtendedService> jsonServiceTracker = null;

    /**
     * The tracked service.
     */
    private APSJSONExtendedService jsonService = null;

    /**
     * Used for registering services that are remotely available.
     */
    private APSServiceTracker<APSSimpleDiscoveryService> discoveryServiceTracker = null;

    /**
     * The context of the bundle we belong to.
     */
    private BundleContext bundleContext = null;

    /**
     * The host name of the server we are being served on.
     */
    private String serverHost = null;

    /**
     * The port of the server we are being served on.
     */
    private int serverPort = 0;

    /**
     * The base url for RPC calls.
     */
    private String rpcBaseUrl = null;

    /**
     * The admin web login handler.
     */
    private APSAdminWebLoginHandler loginHandler = null;

    //
    // Constructors
    //

    /**
     * Creates a new JSONRPCServlet instance.
     */
    public RPCServlet() {
    }

    //
    // Setup and shutdown.
    //

    /**
     * First time setup.
     *
     * @param servletConfig The configuration for the servlet.
     * @throws ServletException on failure.
     */
    public void init(javax.servlet.ServletConfig servletConfig) throws javax.servlet.ServletException {
        servletConfig.getServletContext().getServerInfo();
        if (this.bundleContext == null) {
            this.bundleContext = (BundleContext) servletConfig.getServletContext()
                    .getAttribute("osgi-bundlecontext");

            if (this.bundleContext == null) {
                throw new ServletException(
                        "BundleContext not found! This war must be deployed in an OSGi compatible web container!");
            }

            this.loginHandler = new APSAdminWebLoginHandler(this.bundleContext);

            try {
                this.logger = new APSLogger(System.out);
                this.logger.setLoggingFor("aps-rpc-http-transport-provider");
                this.logger.start(this.bundleContext);

                this.externalProtocolServiceTracker = new APSServiceTracker<>(this.bundleContext,
                        APSExternalProtocolService.class, APSServiceTracker.SHORT_TIMEOUT);
                this.externalProtocolServiceTracker.start();
                this.externalProtocolService = this.externalProtocolServiceTracker.getWrappedService();
                this.externalProtocolService.addExternalProtocolListener(this);

                this.jsonServiceTracker = new APSServiceTracker<>(this.bundleContext, APSJSONExtendedService.class,
                        APSServiceTracker.SHORT_TIMEOUT);
                this.jsonServiceTracker.start();
                this.jsonService = this.jsonServiceTracker.getWrappedService();

                this.discoveryServiceTracker = new APSServiceTracker<>(this.bundleContext,
                        APSSimpleDiscoveryService.class, APSServiceTracker.SHORT_TIMEOUT);
                this.discoveryServiceTracker.start();
                this.discoveryServiceTracker.onServiceAvailable(this);
            } catch (APSNoServiceAvailableException nsae) {
                throw new ServletException(nsae.getMessage(), nsae);
            }
        }
    }

    /**
     * This means the servlet is going away and thus we cleanup.
     */
    @Override
    public void destroy() {
        if (this.bundleContext != null) {

            if (this.externalProtocolServiceTracker != null) {
                this.externalProtocolService.removeExternalProtocolListener(this);
                this.externalProtocolServiceTracker.stop(this.bundleContext);
                this.externalProtocolServiceTracker = null;
                this.externalProtocolService = null;
            }

            if (this.jsonServiceTracker != null) {
                this.jsonServiceTracker.stop(this.bundleContext);
                this.jsonServiceTracker = null;
                this.jsonService = null;
            }

            if (this.discoveryServiceTracker != null) {
                this.discoveryServiceTracker.onServiceAvailable(null);
                this.discoveryServiceTracker.onServiceLeaving(null);
                this.discoveryServiceTracker.stop(this.bundleContext);
                this.discoveryServiceTracker = null;
            }

            this.loginHandler.shutdown();

            if (this.logger != null) {
                this.logger.stop(this.bundleContext);
            }
            this.bundleContext = null;
        }
    }

    //
    // RPC call handling
    //

    /**
     * Handles a get/put/post request.
     * <p/>
     * The format of requests are:
     * @code {http://host:port/apsrpc/<i>protocol</i>/<i>version</i>[/<i>service</i>][/<i>method</i>]}
     * <p/>
     * <i>protocol</i> - This is the name of the protocol to use. For example JSONRPC. For this to work there must be
     * a registered service available that implements StreamedRPCProtocolService and whose getServiceProtocolName()
     * method matches the specified protocol name.
     * <p/>
     * <i>version</i> - This is the version of the named protocol to use. It is possible for more than one version of
     * a protocol to be available at the same time. Again, for this to work there must be a registered service available
     * that implements StreamedRPCProtocolService whose getServiceProtocolVersion() matches the specified version in
     * addition to matching the protocol name.
     * <p/>
     * <i>service</i> - Depending on the RPC protocol implementation a service is specified on the URL or in the data
     * on the PUT stream. This case is when it is specified on the URL. The service always have to be a fully qualified
     * name to the service.
     * <p/>
     * <i>method</i> - Depending on the RPC protocol implementation a method can be specified on the URL. When specified
     * on the URL that will override any method passed in the request.
     * <p/>
     * The rest of the request data is read from the input stream, which should be passed on to the matching
     * StreamedRPCProtocolService.
     *
     * @param req  The request
     * @param resp The response
     * @throws ServletException    on servlet related failures.
     * @throws java.io.IOException on IO failure.
     */
    protected void doReq(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        this.loginHandler.setSessionIdFromRequestCookie(req);

        String pathInfo = req.getPathInfo();
        if (pathInfo.startsWith("/")) {
            pathInfo = pathInfo.substring(1);
        }
        if (pathInfo.startsWith("_help")) {
            // If the first page help does not end in '/' then we want to redirect so that it does. Otherwise the
            // relative links generated for services will be incorrect.
            if (pathInfo.equals("_help")) {
                resp.sendRedirect(pathInfo + "/");
            } else {
                if (!RPCServletConfig.mc.isManaged()) {
                    RPCServletConfig.mc.waitUtilManaged();
                }
                if (this.loginHandler.hasValidLogin()) {
                    if (RPCServletConfig.mc.get().enableHelpWeb.toBoolean()) {
                        doHelp(req, resp);
                    } else {
                        resp.sendError(HttpServletResponse.SC_FORBIDDEN, "The help web has been disabled!");
                    }
                } else {
                    resp.sendError(HttpServletResponse.SC_UNAUTHORIZED,
                            "Authentication is required! Please login.");
                }
            }
        } else {
            doService(req, resp);
        }
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doReq(req, resp);
    }

    @Override
    protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doReq(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doReq(req, resp);
    }

    /**
     * Copies the request parameters providing only the first value of multivalues since we currently only support one value per name.
     *
     * @param req The http request to get parameters from.
     */
    private Map<String, String> getParameters(HttpServletRequest req) {
        Map<String, String> params = new HashMap<>();
        Map<String, String[]> reqParams = req.getParameterMap();

        for (String name : reqParams.keySet()) {
            String[] value = reqParams.get(name);
            String rvalue = "";
            if (value != null) {
                rvalue = value[0];
            }
            params.put(name, rvalue);
        }

        return params;
    }

    /**
     * Handles a service call.
     *
     * @param req
     * @param resp
     * @throws ServletException
     * @throws IOException
     */
    @SuppressWarnings("unchecked")
    private void doService(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        String pathInfo = req.getPathInfo();
        if (pathInfo.startsWith("/")) {
            pathInfo = pathInfo.substring(1);
        }
        String[] pathParts = pathInfo.split("/");

        if (pathParts.length < 2) {
            String urlstart = "http://" + req.getServerName() + ":" + req.getServerPort();
            resp.sendError(HttpServletResponse.SC_NOT_FOUND,
                    "Too short path in URL! The URL should look like this: " + "'" + urlstart
                            + "/apsrpc/[auth:<user>:<password>/]<protocol>/<version>[/<service>]', or " + "'"
                            + urlstart + "/apsrpc/_help/' to get a bit of help as HTML.");
            return;
        }

        String version = null;
        String service = null;
        String method = null;
        String protocolName = null;
        int part = 0;

        if (RPCServletConfig.mc.get().requireAuthentication.toBoolean()) {
            part = checkAuth(pathParts, part, req, resp);
            if (part == AUTH_FAILED) {
                resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }

        protocolName = pathParts[part++];
        version = pathParts[part++];
        if (pathParts.length > part) {
            service = pathParts[part++];
        }
        if (pathParts.length > part) {
            method = pathParts[part];
        }

        // A service with no methods
        Class serviceClass = this.externalProtocolService.getCallables(service).get(0).getServiceClass();

        StreamedRPCProtocol protocol = this.externalProtocolService
                .getStreamedProtocolByNameAndVersion(protocolName, version);
        if (protocol != null) {

            List<RPCRequest> requests = null;

            String reqMethod = req.getMethod().toUpperCase();
            switch (reqMethod) {
            case "GET":
                requests = new LinkedList<>();
                requests.add(protocol.parseRequest(service, serviceClass, method, getParameters(req),
                        RequestIntention.READ));
                break;
            case "PUT":
                requests = protocol.parseRequests(service, serviceClass, method, req.getInputStream(),
                        RequestIntention.UPDATE);
                break;
            case "POST":
                requests = protocol.parseRequests(service, serviceClass, method, req.getInputStream(),
                        RequestIntention.CREATE);
                break;
            case "DELETE":
                requests = new LinkedList<>();
                requests.add(protocol.parseRequest(service, serviceClass, method, getParameters(req),
                        RequestIntention.DELETE));
                break;
            default:
                // We fallback on READ if we get something unexpected here!
                requests = new LinkedList<>();
                requests.add(protocol.parseRequest(service, serviceClass, method, getParameters(req),
                        RequestIntention.READ));
                break;
            }

            // It is possible to send multiple requests on the stream!
            for (RPCRequest rpcRequest : requests) {

                try {
                    if (rpcRequest.isValid()) {
                        if (method == null) {
                            method = rpcRequest.getMethod();
                        }

                        APSExternallyCallable<Object> callable = this.externalProtocolService.getCallable(service,
                                method);

                        if (callable != null) {
                            // Handle parameters
                            List<Object> params = new LinkedList<Object>();
                            int param = 0;
                            for (ParameterDataTypeDescription paramDesc : callable.getParameterDataDescriptions()) {
                                Class paramClass = Void.class;
                                try {
                                    // Since we don't have a dependency to any of the services we will be calling, our bundle
                                    // will not have the correct classpath required for creating arguments to the service.
                                    // Therefore we need to let the bundle of the service we are about to call load argument
                                    // classes for us.
                                    if (paramDesc.getObjectQName() != null) {
                                        paramClass = callable.getServiceBundle()
                                                .loadClass(paramDesc.getObjectQName());
                                    }
                                } catch (ClassNotFoundException cnfe) {
                                    throw new RPCErrorException(protocol.createRPCError(ErrorType.SERVICE_NOT_FOUND,
                                            cnfe.getMessage(), null, cnfe));
                                }

                                params.add(rpcRequest.getIndexedParameter(param++, paramClass));
                            }

                            Object[] paramsArray = new Object[params.size()];
                            paramsArray = params.toArray(paramsArray);

                            // Call service
                            Object result = null;
                            try {
                                result = callable.call(paramsArray);
                            } catch (APSNoServiceAvailableException nsae) {
                                throw new RPCErrorException(protocol.createRPCError(ErrorType.SERVICE_NOT_FOUND,
                                        "Service '" + service + "' is not available!", null, nsae));
                            } catch (Exception e) {
                                throw new RPCErrorException(
                                        protocol.createRPCError(ErrorType.SERVER_ERROR, e.getMessage(), null, e));
                            }

                            // Write the normal OK response.
                            protocol.writeResponse(result, rpcRequest, resp.getOutputStream());

                        } else {
                            throw new RPCErrorException(protocol.createRPCError(ErrorType.METHOD_NOT_FOUND,
                                    "Method '" + method + "' is not available!", null, null));
                        }
                    } else {
                        this.logger.error(rpcRequest.getServiceQName() + ":" + rpcRequest.getMethod() + " - "
                                + rpcRequest.getError().getErrorType().name() + ":"
                                + rpcRequest.getError().getMessage());
                    }
                }
                // Write error responses.
                catch (RPCErrorException ree) {
                    if (ree.getError() instanceof HTTPError) {
                        resp.sendError(((HTTPError) ree.getError()).getHttpStatusCode(),
                                ree.getError().getMessage());
                    } else {
                        protocol.writeErrorResponse(ree.getError(), rpcRequest, resp.getOutputStream());
                    }
                } catch (Exception e) {
                    RPCError error = protocol.createRPCError(ErrorType.SERVER_ERROR, e.getMessage(), null, e);
                    if (error instanceof HTTPError) {
                        resp.sendError(((HTTPError) error).getHttpStatusCode(), error.getMessage());
                    } else {
                        protocol.writeErrorResponse(error, rpcRequest, resp.getOutputStream());
                    }
                }
            }
        } else {
            resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No RPC protocol provider found for protocol '"
                    + protocol + "' with version '" + version + "'!");
        }
    }

    /**
     * Handles the authentication part of the request.
     *
     * @param pathParts The split parts of the path to potentially extract credentials from.
     * @param part      the current index in the pathParts array.
     * @param req       The http request.
     * @param resp      The http response.
     * @return The new current index in the pathParts array.
     * @throws IOException On failure to set header or error on response.
     */
    private int checkAuth(String[] pathParts, int part, HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String user = null;
        String password = null;

        if (pathParts[0].startsWith("auth:")) {
            String auth = pathParts[part++];
            String[] authParts = auth.split(":");
            if (authParts.length != 3) {
                resp.setHeader("WWW-Authenticate", "Basic realm=\"aps\"");
                resp.sendError(401, "Bad authorisation!");
                return AUTH_FAILED;
            }

            user = authParts[1];
            password = authParts[2];
        }
        // Check for basic http auth as an alternative.
        if (user == null) {
            String auth = req.getHeader("Authorization");
            if (auth != null) {
                if (auth.startsWith("Basic")) {
                    String encoded = auth.substring(6);
                    Base64 base64 = new Base64();
                    byte[] userPwBytes = base64.decodeBase64(encoded.getBytes());
                    String[] userPw = new String(userPwBytes).split(":");
                    if (userPw.length != 2) {
                        resp.setHeader("WWW-Authenticate", "Basic realm=\"aps\"");
                        resp.sendError(401, "Bad authorisation!");
                        return AUTH_FAILED;
                    }
                    user = userPw[0];
                    password = userPw[1];
                }
            }
        }

        if (user != null && password != null) {
            String role = null;
            if (!this.loginHandler.login(user, password, role)) {
                resp.setHeader("WWW-Authenticate", "Basic realm=\"aps\"");
                resp.sendError(401, "Authorisation failed!");
                return AUTH_FAILED;
            }
        } else {
            if (RPCServletConfig.mc.get().requireAuthentication.toBoolean()) {
                resp.setHeader("WWW-Authenticate", "Basic realm=\"aps\"");
                resp.sendError(401, "Authorisation required!");
                return AUTH_FAILED;
            }
        }

        return part;
    }

    /**
     * This is an exception used internally to handle errors and write them to the response in the end.
     * This is never thrown outside of a method!
     */
    private static class RPCErrorException extends APSException {
        //
        // Private Members
        //

        /**
         * A passed along RPCError.
         */
        private RPCError error = null;

        //
        // Constructors
        //

        /**
         * Creates a new RPCErrorException.
         *
         * @param error The RPCError to pass along.
         */
        public RPCErrorException(RPCError error) {
            super("");
            this.error = error;
        }

        //
        // Methods
        //

        /**
         * @return The RPCError supplied in constructor.
         */
        public RPCError getError() {
            return this.error;
        }
    }

    //
    // APSDiscoveryService registration and removal.
    //

    /**
     * Catch our host and port information which as far as I can determine is only possible to get from a request.
     *
     * @param req
     * @param resp
     * @throws ServletException
     * @throws IOException
     */
    public void service(ServletRequest req, ServletResponse resp) throws ServletException, IOException {
        if (this.rpcBaseUrl == null) {
            String protocol = req.getProtocol().split("/")[0].toLowerCase();
            if (req.getServerName() != null) {
                this.serverHost = req.getServerName();
            } else if (req.getLocalName() != null) {
                this.serverHost = req.getLocalName();
            }
            if (this.serverHost.equals("localhost")) {
                this.serverHost = InetAddress.getLocalHost().getHostName();
            }
            this.serverPort = req.getServerPort();
            this.rpcBaseUrl = protocol + "://" + this.serverHost + ":" + this.serverPort + "/apsrpc/";

            try {
                onServiceAvailable(this.discoveryServiceTracker.allocateService(), null);
                this.discoveryServiceTracker.releaseService();
            } catch (Exception e) {
            }
        }
        super.service(req, resp);
    }

    /**
     * This gets called when a new externally available service becomes available.
     *
     * @param service The fully qualified name of the newly available service.
     */
    @Override
    public void externalServiceAvailable(String service, String version) {
        if (this.rpcBaseUrl != null) {
            for (StreamedRPCProtocol protocol : this.externalProtocolService.getAllStreamedProtocols()) {
                ServiceDescriptionProvider serviceDescription = new ServiceDescriptionProvider();
                serviceDescription.setDescription("Published by aps-rpc-http-transport-provider.");
                serviceDescription.setServiceHost(this.serverHost);
                serviceDescription.setServicePort(this.serverPort);
                serviceDescription.setServiceURL(this.rpcBaseUrl + protocol.getServiceProtocolName() + "/"
                        + protocol.getServiceProtocolVersion() + "/" + service);
                serviceDescription.setVersion(version);
                serviceDescription.setServiceId(service);

                this.discoveryServiceTracker.withAllAvailableServices(new WithService<APSSimpleDiscoveryService>() {

                    public void withService(APSSimpleDiscoveryService discoverySvc,
                            ServiceDescriptionProvider serviceDescription) throws Exception {
                        discoverySvc.publishService(serviceDescription);//                                            ^
                    } //                                                                            |
                      //                                                                            |
                }, serviceDescription); //----------------------------------------------------------------------------+
            }
        }
    }

    /**
     * This gets called when an externally available service no longer is available.
     *
     * @param service The fully qualified name of the service leaving.
     */
    @Override
    public void externalServiceLeaving(String service, String version) {
        if (this.rpcBaseUrl != null) {
            for (StreamedRPCProtocol protocol : this.externalProtocolService.getAllStreamedProtocols()) {
                ServiceDescriptionProvider serviceDescription = new ServiceDescriptionProvider();
                serviceDescription.setDescription("Published by aps-rpc-http-transport-provider.");
                serviceDescription.setServiceHost(this.serverHost);
                serviceDescription.setServicePort(this.serverPort);
                serviceDescription.setServiceURL(this.rpcBaseUrl + protocol.getServiceProtocolName() + "/"
                        + protocol.getServiceProtocolVersion() + "/" + service);
                serviceDescription.setVersion(version);
                serviceDescription.setServiceId(service);

                this.discoveryServiceTracker.withAllAvailableServices(new WithService<APSSimpleDiscoveryService>() {
                    /**
                     * Receives a service to do something with.
                     *
                     * @param discoverySvc The received service.
                     *
                     * @throws Exception Implementation can throw any exception. How it is handled depends on the APSServiceTracker method this
                     *                   gets passed to.
                     */
                    public void withService(APSSimpleDiscoveryService discoverySvc,
                            ServiceDescriptionProvider serviceDescription) throws Exception {
                        discoverySvc.unpublishService(serviceDescription);
                    }

                }, serviceDescription);
            }
        }
    }

    /**
     * This gets called when a new protocol becomes available.
     *
     * @param protocolName    The name of the protocol.
     * @param protocolVersion The version of the protocol.
     */
    @Override
    public void protocolAvailable(String protocolName, String protocolVersion) {
        for (String service : this.externalProtocolService.getAvailableServices()) {
            ServiceDescriptionProvider serviceDescription = new ServiceDescriptionProvider();
            serviceDescription.setDescription("Published by aps-rpc-http-extender.");
            serviceDescription.setServiceHost(this.serverHost);
            serviceDescription.setServicePort(this.serverPort);
            serviceDescription
                    .setServiceURL(this.rpcBaseUrl + protocolName + "/" + protocolVersion + "/" + service);
            serviceDescription.setVersion(this.externalProtocolService.getCallables(service).get(0)
                    .getServiceBundle().getVersion().toString());
            serviceDescription.setServiceId(service);

            this.discoveryServiceTracker.withAllAvailableServices(new WithService<APSSimpleDiscoveryService>() {
                /**
                 * Receives a service to do something with.
                 *
                 * @param discoverySvc The received service.
                 *
                 * @throws Exception Implementation can throw any exception. How it is handled depends on the APSServiceTracker method this
                 *                   gets passed to.
                 */
                public void withService(APSSimpleDiscoveryService discoverySvc,
                        ServiceDescriptionProvider serviceDescription) throws Exception {
                    discoverySvc.publishService(serviceDescription);
                }

            }, serviceDescription);
        }
    }

    /**
     * This gets called when a new protocol is leaving.
     *
     * @param protocolName    The name of the protocol.
     * @param protocolVersion The version of the protocol.
     */
    @Override
    public void protocolLeaving(String protocolName, String protocolVersion) {
        for (String service : this.externalProtocolService.getAvailableServices()) {
            ServiceDescriptionProvider serviceDescription = new ServiceDescriptionProvider();
            serviceDescription.setDescription("Published by aps-rpc-http-extender.");
            serviceDescription.setServiceHost(this.serverHost);
            serviceDescription.setServicePort(this.serverPort);
            serviceDescription
                    .setServiceURL(this.rpcBaseUrl + protocolName + "/" + protocolVersion + "/" + service);
            serviceDescription.setVersion(this.externalProtocolService.getCallables(service).get(0)
                    .getServiceBundle().getVersion().toString());
            serviceDescription.setServiceId(service);

            this.discoveryServiceTracker.withAllAvailableServices(new WithService<APSSimpleDiscoveryService>() {
                /**
                 * Receives a service to do something with.
                 *
                 * @param discoverySvc The received service.
                 *
                 * @throws Exception Implementation can throw any exception. How it is handled depends on the APSServiceTracker method this
                 *                   gets passed to.
                 */
                public void withService(APSSimpleDiscoveryService discoverySvc,
                        ServiceDescriptionProvider serviceDescription) throws Exception {
                    discoverySvc.unpublishService(serviceDescription);
                }

            }, serviceDescription);
        }
    }

    /**
     * This gets called whenever a new APSSimpleDiscoveryService becomes available. Thereby we handle restart of the discovery service
     * or new implementations of it, or the case where this bundle is up and running before any discovery service.
     *
     * @param discoverySvc     The received service.
     * @param serviceReference The reference to the received service.
     * @throws Exception Implementation can throw any exception. How it is handled depends on the APSServiceTracker method this
     *                   gets passed to.
     */
    @Override
    public void onServiceAvailable(APSSimpleDiscoveryService discoverySvc, ServiceReference serviceReference)
            throws Exception {
        if (this.rpcBaseUrl != null) {
            for (StreamedRPCProtocol protocol : this.externalProtocolService.getAllStreamedProtocols()) {
                for (String service : this.externalProtocolService.getAvailableServices()) {
                    ServiceDescriptionProvider serviceDescription = new ServiceDescriptionProvider();
                    serviceDescription.setDescription("Published by aps-rpc-http-extender.");
                    serviceDescription.setServiceHost(this.serverHost);
                    serviceDescription.setServicePort(this.serverPort);
                    serviceDescription.setServiceURL(this.rpcBaseUrl + protocol.getServiceProtocolName() + "/"
                            + protocol.getServiceProtocolVersion() + "/" + service);
                    serviceDescription.setVersion(this.externalProtocolService.getCallables(service).get(0)
                            .getServiceBundle().getVersion().toString());
                    serviceDescription.setServiceId(service);
                    discoverySvc.publishService(serviceDescription);
                }
            }
        }
    }

    //
    // RPC service information handling on HTTP GET.
    //

    /**
     * Handles help information.
     * <p/>
     * Since all service calls are done using HTTP PUT we provide information about the service and available services and methods
     * on HTTP GET.
     *
     * @param req  The request
     * @param resp The response
     * @throws ServletException
     * @throws java.io.IOException
     */
    protected void doHelp(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String queryStr = req.getPathInfo();
        if (queryStr.startsWith("/")) {
            queryStr = queryStr.substring(1);
        }
        String[] query = queryStr.split("/");

        HTMLWriter html = new HTMLWriter(resp.getOutputStream());

        if (query.length == 1 || query[1].equals("")) {
            handleFirstPage(html, req);
        } else if (query.length == 2 && query[1].length() > 0) {
            String service = query[1];
            handleServicePage(html, service, req);
        } else if (query.length == 3 && query[1].length() > 0 && query[2].length() > 0) {
            String service = query[1];
            String method = query[2];
            handleMethodPage(html, service, method, req);
        } else {
            resp.sendError(401, "Invalid path!");
        }
    }

    /**
     * Handles the first page with general information inlcuding protocols and services.
     *
     * @param html The HTMLWriter to write to.
     * @param req  The HttpServletRequest.
     * @throws IOException
     */
    private void handleFirstPage(HTMLWriter html, HttpServletRequest req) throws IOException {
        html.tag("html");
        {
            html.tag("body", "", BG_COLOR);
            {
                html.tagc("h1",
                        "ApplicationPlatformServices (APS) Remote service call over HTTP transport provider");
                html.tagc("p",
                        "This provides an http transport for simple remote requests to OSGi services that have the \"APS-Externalizable: "
                                + "true\" in their META-INF/MANIFEST.MF. This follows the OSGi extender pattern and makes any registered "
                                + "OSGi services of bundles having the above manifest entry available for remote calls over HTTP. This "
                                + "transport makes use of the aps-external-protocol-extender which exposes services with the above "
                                + "mentioned manifest entry with each service method available as an APSExternallyCallable."
                                + "The aps-ext-protocol-http-transport acts as a mediator between the protocol implementations and "
                                + "aps-external-protocol-extender for requests over HTTP.");
                html.tagc("p",
                        "<b>Please note</b> that depending on protocol not every service method will be callable. It depends on "
                                + "its arguments and return value. It mostly depends on how well the protocol handles types and can convert "
                                + "between the caller and the service. Also note that bundles can specify \"APS-Externalizable: false\" in their "
                                + "META-INF/MANIFEST.MF. In that case none of the bundles services will be callable this way!");
                html.tagc("p", "This does not provide any protocol, only transport! For services "
                        + "to be able to be called at least one protocol is needed. Protocols are provided by providing an "
                        + "implementation of se.natusoft.osgi.aps.api.net.rpc.service.StreamedRPCProtocolService and registering "
                        + "it as an OSGi service. The StreamedRPCProtocolService API provides a protocol name and protocol "
                        + "version getter which is used to identify it. A call to an RPC service looks like this:");
                html.text(
                        "<ul><code>http://host:port/apsrpc/<i>protocol</i>/<i>version</i>[/<i>service</i>][/<i>method</i>]</code></ul>");
                html.text("<ul>" + "<i>protocol</i>" + "<ul>"
                        + "This is the name of the protocol to use. An implementation of that protocol must of course be available "
                        + "for this to work. If it isn't you will get a 404 back!" + "</ul>" + "</ul>");
                html.text("<ul>" + "<i>version</i>" + "<ul>"
                        + "This is the version of the protocol. If this doesn't match any protocols available you will also get a "
                        + "404 back." + "</ul>" + "</ul>");
                html.text("<ul>" + "<i>service</i>" + "<ul>"
                        + "This is the service to call. Depending on the protocol you might not need this. But for protocols that "
                        + "only provide method in the stream data like JSONRPC for example, then this is needed. When provided it "
                        + "has to be a fully qualified service interface class name." + "</ul>" + "</ul>");
                html.text("<ul>" + "<i>method</i>" + "<ul>"
                        + "This is a method of the service to call. The requirement for this also depends on the protocol. "
                        + "The JSONRPC protocols does not need this since they provide the method in the request. A REST "
                        + "protocol however would need this." + "</ul>" + "</ul>");

                html.tagc("h2", "Security");
                html.tagc("p",
                        "This help page always require authentication. This is because it register itself with the APSAdminWeb and "
                                + "is available as a tab there and thereby joins in the admin web authentication. For service calls however "
                                + "authentication is only required if you enable it in the configuration (network/rpc-http-transport). "
                                + "There are 2 variants of authentication for services:" + "<ul>"
                                + "<li>http://.../apsrpc/<b>auth:user:password</b>/protocol/...</li>"
                                + "<li>Basic HTTP authentication using header: 'Authorization: Basic {base 64 encoded user:password}'.</li>"
                                + "</ul>");
                html.tagc("p",
                        "Note that this is only a transport (over http)! It has nothing to say about protocols which is why the "
                                + "above auth methods are outside of the protocol, only part of this transport. If you make services that you "
                                + "expose this way it is also possible to leave the authentication config at false and provide authentication "
                                + "in your service by using the APSSimpleUserService or something else.");

                html.tagc("h2", "Found Protocols");

                for (StreamedRPCProtocol protocol : this.externalProtocolService.getAllStreamedProtocols()) {
                    html.tagc("h3",
                            protocol.getServiceProtocolName() + " : " + protocol.getServiceProtocolVersion());
                    html.tagc("p", protocol.getRPCProtocolDescription());

                    html.tagc("p",
                            "<b>Request URL:</b>&nbsp;http://" + req.getLocalName() + ":" + req.getLocalPort()
                                    + "/apsrpc/" + protocol.getServiceProtocolName() + "/"
                                    + protocol.getServiceProtocolVersion() + "[/&lt;service&gt;][/&lt;method&gt;]");

                    String reqContentType = protocol.getRequestContentType();
                    String respContentType = protocol.getResponseContentType();
                    if (reqContentType != null && reqContentType.trim().length() > 0) {
                        html.tagc("p", "<b>Request Content-type:</b> " + reqContentType);
                    }
                    if (respContentType != null && respContentType.trim().length() > 0) {
                        html.tagc("p", "<b>Response Content-type:</b> " + respContentType);
                    }
                }

                html.tagc("h2", "Found Services");
                for (String service : this.externalProtocolService.getAvailableServices()) {
                    ServiceReference sref = this.bundleContext.getServiceReference(service);
                    if (sref != null) {
                        html.tagc("p",
                                "<a href=\"" + service + "\">" + service + "</a> <i>Bundle version:</i> "
                                        + sref.getBundle().getVersion() + ", <i>Bundle symbolic name:</i> "
                                        + sref.getBundle().getSymbolicName() + ", " + "<i>Bundle id:</i> "
                                        + sref.getBundle().getBundleId());
                    }
                }
            }
            html.tage("body");
        }
        html.tage("html");
    }

    /**
     * Handles the service page showing information about a specific service, like all its methods.
     *
     * @param html    The HTMLWriter to write to.
     * @param service The service to show information about.
     * @param req     The HTTPServletRequest.
     * @throws IOException
     */
    private void handleServicePage(HTMLWriter html, String service, HttpServletRequest req) throws IOException {
        Set<String> methodNames = this.externalProtocolService.getAvailableServiceFunctionNames(service);

        html.tag("html");
        {
            html.tag("body", "", BG_COLOR);
            {
                html.tagc("h1",
                        "ApplicationPlatformServices (APS) Remote service call over HTTP transport provider");
                html.tagc("p",
                        "Here the service and all its methods are displayed. Each method is clickable for details on the method.");

                html.tagc("h2", "Service");
                if (!methodNames.isEmpty()) {
                    html.tagc("h3", service + " {");
                    html.tag("ul");
                    for (String method : methodNames) {
                        APSExternallyCallable<Object> callable = this.externalProtocolService.getCallable(service,
                                method);

                        //                        String params = "";
                        //                        String comma = "";
                        //                        for (DataTypeDescription parameter : callable.getParameterDataDescriptions()) {
                        //                            params = params + comma + toTypeName(parameter);
                        //                            comma = ", ";
                        //                        }

                        html.tagc("h4", toMethodDecl(callable, service, method));
                    }
                    html.tage("ul");
                    html.tagc("h3", "}");

                    html.tagc("h2", "Protocol URLs");
                    html.tagc("p",
                            "Please note that even though these urls include the service, not all protocols require the service in "
                                    + "the URL!");

                    for (StreamedRPCProtocol protocol : this.externalProtocolService.getAllStreamedProtocols()) {
                        html.tagc("h3",
                                protocol.getServiceProtocolName() + " : " + protocol.getServiceProtocolVersion());

                        html.tagc("p",
                                "http://" + req.getLocalName() + ":" + req.getLocalPort() + "/apsrpc/"
                                        + protocol.getServiceProtocolName() + "/"
                                        + protocol.getServiceProtocolVersion() + "/" + service + "/");
                    }

                } else {
                    html.tagc("h2", "Service '" + service + "' not found!");
                }
            }
            html.tage("body");
        }
        html.tage("html");
    }

    /**
     * Handles the method page showing details about a method including parameters, return type, and if no args method the
     * result of the execution of the method.
     *
     * @param html    The HTMLWriter to write to.
     * @param service The service the method belongs to.
     * @param method  The method to show information for.
     * @throws IOException
     * @pararm request
     */
    private void handleMethodPage(HTMLWriter html, String service, String method, HttpServletRequest request)
            throws IOException {
        boolean execute = false;
        int paramPos = 0;
        if (method.indexOf("-") > 0) {
            String[] parts = method.split("-");
            if (parts.length >= 2) {
                if (parts[1].equals("exec")) {
                    execute = true;
                    method = parts[0];
                }
            }
        }
        APSExternallyCallable<Object> callable = this.externalProtocolService.getCallable(service, method);
        html.tag("html");
        {
            html.tag("body", "", BG_COLOR);
            {
                if (callable != null) {
                    html.tagc("h1",
                            "ApplicationPlatformServices (APS) Remote service call over HTTP transport provider");
                    html.tagc("p", "This page provides details about a method. ");
                    html.tagc("p",
                            "The method can be executed by providing the arguments and pressing 'Execute'. For boolean values "
                                    + "specify 'true' or 'false', for floating point numbers specify 'n.n', for long and ints, etc specify "
                                    + "'n', for string values specify \"<i>value</i>\", and for objects specify the object in JSON format "
                                    + "starting with { and ending with }. This is very useful for testing/debugging services.");
                    html.tagc("h2", toMethodDecl(callable, null, method));

                    html.tagc("h3", "Parameters");
                    html.text("<ul>");
                    html.text("<form name=\"input\" action=\"" + method + "-exec\" method=\"post\">");
                    String comma = "";
                    paramPos = 0;
                    for (DataTypeDescription parameter : callable.getParameterDataDescriptions()) {
                        html.text(comma);
                        displayDataType(html, parameter);
                        String value = request.getParameter("param" + paramPos);
                        if (value == null)
                            value = "";
                        html.text("&nbsp;&nbsp;&nbsp;&nbsp;<textarea cols=\"60\" rows=\"2\" name=\"param" + paramPos
                                + "\">" + value + "</textarea>");
                        comma = ",<br/>";
                        ++paramPos;
                    }
                    html.text("</ul>");

                    html.tagc("h3", "Returntype");
                    html.text("<ul>");
                    displayDataType(html, callable.getReturnDataDescription());
                } else {
                    html.tagc("h2", "Method '" + method + "' was not found!");
                }
                html.text("</ul>");

                html.tagc("h2", "Execution");
                html.text("<ul>");
                if (execute) {
                    html.tagc("p", execute(callable, paramPos, request));
                } else {
                    html.text("<input type=\"submit\" value=\"Execute\"");
                }
                html.text("</ul>");
                html.text("</form>");
            }
            html.tage("body");
        }
        html.tage("html");
    }

    private String execute(APSExternallyCallable<Object> callable, int noParams, HttpServletRequest request)
            throws IOException {
        try {
            String paramFail = null;
            Object[] args = new Object[noParams];
            int p = 0;
            for (DataTypeDescription parameter : callable.getParameterDataDescriptions()) {
                String paramValue = request.getParameter("param" + p);
                if (paramValue != null) {
                    Object paramJavaValue = null;
                    if (paramValue.toLowerCase().equals("true") || paramValue.toLowerCase().equals("false")) {
                        paramJavaValue = Boolean.valueOf(paramValue);
                    } else if (paramValue.trim().startsWith("\"")) {
                        paramValue = paramValue.trim().substring(1);
                        paramValue = paramValue.substring(0, paramValue.length() - 1);
                        paramJavaValue = paramValue;
                    } else if (paramValue.trim().startsWith("{")) {
                        ByteArrayInputStream bais = new ByteArrayInputStream(paramValue.getBytes());
                        JSONValue jsonObj = this.jsonService.readJSON(bais, null);
                        bais.close();
                        Class javaType = callable.getServiceBundle().loadClass(parameter.getObjectQName());
                        paramJavaValue = this.jsonService.jsonToJava(jsonObj, javaType);
                    } else {
                        if (paramValue.indexOf(".") >= 0) {
                            if (parameter.getDataType() == DataType.DOUBLE)
                                paramJavaValue = Double.valueOf(paramValue);
                            else if (parameter.getDataType() == DataType.FLOAT)
                                paramJavaValue = Float.valueOf(paramValue);
                        } else if (parameter.getDataType() == DataType.LONG)
                            paramJavaValue = Long.valueOf(paramValue);
                        else if (parameter.getDataType() == DataType.INT)
                            paramJavaValue = Integer.valueOf(paramValue);
                        else if (parameter.getDataType() == DataType.SHORT)
                            paramJavaValue = Short.valueOf(paramValue);
                        else if (parameter.getDataType() == DataType.BYTE)
                            paramJavaValue = Byte.valueOf(paramValue);
                        else {
                            paramFail = "Did you forget to quote a string value ?";
                        }

                    }
                    args[p] = paramJavaValue;
                } else {
                    paramFail = "Parameter #" + p + " was null!";
                }
                ++p;
            }
            if (paramFail == null) {
                Object result = callable.call(args);
                JSONValue jsonValue = this.jsonService.javaToJSON(result);
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                this.jsonService.writeJSON(baos, jsonValue, false);
                baos.close();
                return "<pre>" + baos.toString() + "</pre>";
            } else {
                return "Bad parameter: " + paramFail;
            }
        } catch (Exception e) {
            return "Failed: " + e.getMessage();
        }
    }

    /**
     * Displays the specified data type.
     *
     * @param html     The HMTLWriter to display on.
     * @param dataType The data type to display.
     * @throws IOException
     */
    private void displayDataType(HTMLWriter html, DataTypeDescription dataType) throws IOException {
        if (!dataType.getDataType().isStructured()) {
            html.text(toTypeName(dataType));
        } else {
            if (dataType.hasMembers()) {
                html.text(toTypeName(dataType) + " {");
                html.text("<ul>");
                for (String memberName : dataType.getMemberNames()) {
                    displayDataType(html, dataType.getMemberDataDescriptionByName(memberName));
                    html.text(" " + memberName + ";<br/>");
                }
                html.text("</ul>");
                html.text("}");
            } else {
                if (dataType.getDataType() == DataType.LIST) {
                    html.text(dataType.getDataType().getTypeName() + "&lt;?&gt;");
                } else if (dataType.getDataType() == DataType.MAP) {
                    html.text(dataType.getDataType().getTypeName() + "&lt;?,?&gt;");
                } else {
                    html.text(toTypeName(dataType));
                }
            }
        }
    }

    /**
     * Creates a displayable method declaration.
     *
     * @param callable The callable to create the method declaration from.
     * @param service  The service the method belongs to. If this is provided the method name will be a link. If null no link will be created.
     * @param method   The name of the method.
     * @return A String with a full method declaration.
     */
    private String toMethodDecl(APSExternallyCallable<Object> callable, String service, String method) {
        if (callable == null) {
            return "";
        }
        String params = "";
        String comma = "";
        for (DataTypeDescription parameter : callable.getParameterDataDescriptions()) {
            String typeName = toTypeName(parameter);
            if (typeName.indexOf('.') > 0) {
                String[] parts = typeName.split("\\.");
                typeName = parts[parts.length - 1];
            }
            params = params + comma + typeName;
            comma = ", ";
        }

        if (service != null) {
            return toTypeName(callable.getReturnDataDescription()) + " <a href=\"" + service + "/" + method + "\">"
                    + method + "</a>;";
        } else {
            return toTypeName(callable.getReturnDataDescription()) + " " + method + ";";
        }
    }

    /**
     * Builds a displayable type name from a DataTypeDescription.
     *
     * @param dataTypeDescription The DataTypeDescripion to build a display name from.
     * @return A type name.
     */
    private String toTypeName(DataTypeDescription dataTypeDescription) {
        String retData = dataTypeDescription.getObjectQName();
        if (retData == null) {
            retData = dataTypeDescription.getDataType().getTypeName();
        }
        return retData;
    }

    /**
     * Small utility class to produce HTML output.
     */
    private class HTMLWriter {
        //
        // Private Members
        //

        /**
         * The stream to write to.
         */
        private OutputStream out = null;

        //
        // Constructors
        //

        /**
         * Creates a new HTMLWriter instance.
         *
         * @param out The OutputStream to write to.
         */
        public HTMLWriter(OutputStream out) {
            this.out = out;
        }

        //
        // Methods
        //

        /**
         * Writes to output stream with some conversions.
         *
         * @param text The text to write.
         * @throws IOException
         */
        private void write(String text) throws IOException {
            String[] parts = text.split(" ");

            for (String part : parts) {
                if (part.startsWith("http://") || part.startsWith("https://")) {
                    String link = "<a href=\"" + part + "\">" + part + "</a>";
                    text = text.replace(part, link);
                }
            }

            this.out.write(text.getBytes());
        }

        /**
         * Writes text.
         *
         * @param content The text to write.
         * @throws IOException
         */
        public void text(String content) throws IOException {
            write(content);
        }

        /**
         * Writes an html tag.
         *
         * @param tag        The tag to write.
         * @param content    The content of the tag.
         * @param attributes The attributes of the tag. Example: "color=#ffffff".
         * @throws IOException The one and only!
         */
        public void tag(String tag, String content, String... attributes) throws IOException {
            String attrs = "";
            if (attributes != null) {
                String comma = "";
                attrs = " ";
                for (String attribute : attributes) {
                    attrs += comma;
                    attrs += attribute;
                    comma = ", ";
                }
            }
            write(("<" + tag + attrs + ">" + content));
        }

        /**
         * Writes an html tag.
         *
         * @param tag     The tag to write.
         * @param content The content of the tag.
         * @throws IOException The one and only!
         */
        public void tag(String tag, String content) throws IOException {
            tag(tag, content, null);
        }

        /**
         * Writes a complete ending tag.
         *
         * @param tag        The tag to write.
         * @param content    The content of the tag.
         * @param attributes The attributes of the tag.
         * @throws IOException
         */
        public void tagc(String tag, String content, String... attributes) throws IOException {
            tag(tag, content, attributes);
            tage(tag);
        }

        /**
         * Writes a complete ending tag.
         *
         * @param tag     The tag to write.
         * @param content The content of the tag.
         * @throws IOException
         */
        public void tagc(String tag, String content) throws IOException {
            tag(tag, content, null);
            tage(tag);
        }

        /**
         * Writes an html tag.
         *
         * @param tag The tag to write.
         * @throws IOException The one and only!
         */
        public void tag(String tag) throws IOException {
            tag(tag, "", null);
        }

        /**
         * Writes an end tag.
         *
         * @param tag The tag to end.
         * @throws IOException
         */
        public void tage(String tag) throws IOException {
            write(("</" + tag + ">"));
        }
    }
}