net.community.chest.gitcloud.facade.frontend.git.GitController.java Source code

Java tutorial

Introduction

Here is the source code for net.community.chest.gitcloud.facade.frontend.git.GitController.java

Source

/* Copyright 2013 Lyor Goldstein
 *
 * 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.
 */
package net.community.chest.gitcloud.facade.frontend.git;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.security.Principal;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.logging.Level;

import javax.inject.Inject;
import javax.management.JMException;
import javax.management.MBeanInfo;
import javax.management.MBeanServer;
import javax.management.ObjectName;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import net.community.chest.gitcloud.facade.ServletUtils;

import org.apache.commons.beanutils.AbstractSimpleJavaBean;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.collections15.SetUtils;
import org.apache.commons.io.HexDumpOutputStream;
import org.apache.commons.io.input.TeeInputStream;
import org.apache.commons.io.output.LineLevelAppender;
import org.apache.commons.io.output.TeeOutputStream;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.ExtendedStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.logging.ExtendedLogUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpMessage;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.ProtocolException;
import org.apache.http.StatusLine;
import org.apache.http.auth.AUTH;
import org.apache.http.client.RedirectStrategy;
import org.apache.http.client.config.AuthSchemes;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.entity.InputStreamEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.eclipse.jgit.http.server.GitSmartHttpTools;
import org.eclipse.jgit.lib.Constants;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.RefreshedContextAttacher;
import org.springframework.stereotype.Controller;
import org.springframework.util.SystemPropertyUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

/**
 * @author Lyor Goldstein
 * @since Sep 12, 2013 1:17:34 PM
 */
@Controller // TODO make it a @ManagedObject and expose internal configuration values for JMX management (Read/Write)
public class GitController extends RefreshedContextAttacher implements DisposableBean {
    public static final Set<String> ALLOWED_SERVICES = Collections.unmodifiableSet(
            new TreeSet<String>(Arrays.asList(GitSmartHttpTools.UPLOAD_PACK, GitSmartHttpTools.RECEIVE_PACK)));
    public static final String LOOP_DETECT_TIMEOUT = "gitcloud.frontend.git.controller.loop.detect.timeout";
    public static final long DEFAULT_LOOP_DETECT_TIMEOUT = 0L; // disabled
    private static final String LOOP_DETECT_TIMEOUT_VALUE = SystemPropertyUtils.PLACEHOLDER_PREFIX
            + LOOP_DETECT_TIMEOUT + SystemPropertyUtils.VALUE_SEPARATOR + DEFAULT_LOOP_DETECT_TIMEOUT
            + SystemPropertyUtils.PLACEHOLDER_SUFFIX;
    // TODO move this to some 'util' artifact
    public static final RedirectStrategy NO_REDIRECTION = new RedirectStrategy() {
        @Override
        public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context)
                throws ProtocolException {
            return false;
        }

        @Override
        public HttpUriRequest getRedirect(HttpRequest request, HttpResponse response, HttpContext context)
                throws ProtocolException {
            throw new ProtocolException("getRedirect(" + request + ")[" + response + "] N/A");
        }
    };

    private final MBeanServer mbeanServer;
    private final CloseableHttpClient client;
    private final long loopRetryTimeout;
    private volatile long initTimestamp = System.currentTimeMillis();
    private volatile boolean loopDetected;

    @Inject
    public GitController(MBeanServer localMbeanServer, HttpClientConnectionManager connectionsManager,
            @Value(LOOP_DETECT_TIMEOUT_VALUE) long loopDetectTimeout) {
        mbeanServer = Validate.notNull(localMbeanServer, "No MBean server", ArrayUtils.EMPTY_OBJECT_ARRAY);

        client = HttpClientBuilder.create().setConnectionManager(
                Validate.notNull(connectionsManager, "No connections manager", ArrayUtils.EMPTY_OBJECT_ARRAY))
                .setRedirectStrategy(NO_REDIRECTION).build();
        loopRetryTimeout = loopDetectTimeout;
    }

    @Override
    public void destroy() throws Exception {
        logger.info("destroy()");
        client.close();
    }

    @Override
    protected void onContextInitialized(ApplicationContext context) {
        super.onContextInitialized(context);
        initTimestamp = System.currentTimeMillis();

        logger.info("MBeanServer default domain: " + mbeanServer.getDefaultDomain());

        String[] domains = mbeanServer.getDomains();
        if (!ArrayUtils.isEmpty(domains)) {
            for (String d : domains) {
                logger.info("MBeanServer extra domain: " + d);
            }
        }
    }

    @RequestMapping(method = RequestMethod.GET)
    public void serveGetRequests(HttpServletRequest req, HttpServletResponse rsp)
            throws IOException, ServletException {
        serveRequest(RequestMethod.GET, req, rsp);
    }

    @RequestMapping(method = RequestMethod.POST)
    public void servePostRequests(HttpServletRequest req, HttpServletResponse rsp)
            throws IOException, ServletException {
        serveRequest(RequestMethod.POST, req, rsp);
    }

    private void serveRequest(RequestMethod method, HttpServletRequest req, HttpServletResponse rsp)
            throws IOException, ServletException {
        if (logger.isDebugEnabled()) {
            logger.debug("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]");
        }

        if ((loopRetryTimeout > 0L) && (!loopDetected)) {
            long now = System.currentTimeMillis(), diff = now - initTimestamp;
            if ((diff > 0L) && (diff < loopRetryTimeout)) {
                try {
                    MBeanInfo mbeanInfo = mbeanServer.getMBeanInfo(new ObjectName(
                            "net.community.chest.gitcloud.facade.backend.git:name=BackendRepositoryResolver"));
                    if (mbeanInfo != null) {
                        logger.info("serveRequest(" + method + ")[" + req.getRequestURI() + "]["
                                + req.getQueryString() + "]" + " detected loop: " + mbeanInfo.getClassName() + "["
                                + mbeanInfo.getDescription() + "]");
                        loopDetected = true;
                    }
                } catch (JMException e) {
                    if (logger.isDebugEnabled()) {
                        logger.debug("serveRequest(" + method + ")[" + req.getRequestURI() + "]["
                                + req.getQueryString() + "]" + " failed " + e.getClass().getSimpleName()
                                + " to detect loop: " + e.getMessage());
                    }
                }
            }
        }

        ResolvedRepositoryData repoData = resolveTargetRepository(method, req);
        if (repoData == null) {
            throw ExtendedLogUtils.thrownLogging(logger, Level.WARNING,
                    "serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]",
                    new NoSuchElementException("Failed to resolve repository"));
        }

        String username = authenticate(req);
        // TODO check if the user is allowed to access the repository via the resolve operation (push/pull) if at all (e.g., private repo)
        logger.info("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "] user="
                + username);

        /*
         * NOTE: this feature requires enabling cross-context forwarding.
         * In Tomcat, the 'crossContext' attribute in 'Context' element of
         * 'TOMCAT_HOME\conf\context.xml' must be set to true, to enable cross-context 
         */
        if (loopDetected) {
            // TODO see if can find a more efficient way than splitting and re-constructing
            URI uri = repoData.getRepoLocation();
            ServletContext curContext = req.getServletContext();
            String urlPath = uri.getPath(), urlQuery = uri.getQuery();
            String[] comps = StringUtils.split(urlPath, '/');
            String appName = comps[0];
            ServletContext loopContext = Validate.notNull(curContext.getContext("/" + appName),
                    "No cross-context for %s", appName);
            // build the relative path in the re-directed context
            StringBuilder sb = new StringBuilder(
                    urlPath.length() + 1 + (StringUtils.isEmpty(urlQuery) ? 0 : urlQuery.length()));
            for (int index = 1; index < comps.length; index++) {
                sb.append('/').append(comps[index]);
            }
            if (!StringUtils.isEmpty(urlQuery)) {
                sb.append('?').append(urlQuery);
            }

            String redirectPath = sb.toString();
            RequestDispatcher dispatcher = Validate.notNull(loopContext.getRequestDispatcher(redirectPath),
                    "No dispatcher for %s", redirectPath);
            dispatcher.forward(req, rsp);
            if (logger.isDebugEnabled()) {
                logger.debug("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString()
                        + "]" + " forwarded to " + loopContext.getContextPath() + "/" + redirectPath);
            }
        } else {
            executeRemoteRequest(method, repoData.getRepoLocation(), req, rsp);
        }
    }

    String authenticate(HttpServletRequest req) throws IOException {
        Principal principal = req.getUserPrincipal(); // check if already authenticated
        String username = (principal == null) ? null : principal.getName();
        if (!StringUtils.isEmpty(username)) {
            if (logger.isDebugEnabled()) {
                logger.debug("authenticate(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                        + req.getQueryString() + "]" + " using principal=" + username);
            }

            return username;
        }

        // TODO try to authenticate by cookie (if feature allowed) - see GitBlit#authenticate
        String authorization = StringUtils.trimToEmpty(req.getHeader(AUTH.WWW_AUTH_RESP));
        if (StringUtils.isEmpty(authorization)) {
            if (logger.isDebugEnabled()) {
                logger.debug("authenticate(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                        + req.getQueryString() + "] no authorization data");
            }
            return null;
        }

        // TODO add support for more authorization schemes - including password-less HTTP
        if (!authorization.startsWith(AuthSchemes.BASIC)) {
            logger.warn("authenticate(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString()
                    + "]" + " unsupported authentication scheme: " + authorization);
            return null;
        }

        String b64Credentials = authorization.substring(AuthSchemes.BASIC.length()).trim();
        byte[] credBytes = Base64.decodeBase64(b64Credentials);
        String credentials = new String(credBytes, Charset.forName("UTF-8"));
        String[] credValues = StringUtils.split(credentials, ':');
        Validate.isTrue(credValues.length == 2, "Bad " + AuthSchemes.BASIC + " credentials format: %s",
                credentials);

        username = StringUtils.trimToEmpty(credValues[0]);
        String password = StringUtils.trimToEmpty(credValues[1]);
        if (authenticate(username, password)) {
            return username;
        } else {
            return null;
        }
    }

    boolean authenticate(String username, String password) {
        Validate.notEmpty(username, "No username", ArrayUtils.EMPTY_OBJECT_ARRAY);
        Validate.notEmpty(password, "No password", ArrayUtils.EMPTY_OBJECT_ARRAY);
        // TODO inject an AuthenticationProvider or Manager and use it
        logger.warn("authenticate(" + username + ") N/A");
        return false;
    }

    private void executeRemoteRequest(RequestMethod method, URI uri, HttpServletRequest req,
            HttpServletResponse rsp) throws IOException {
        if (logger.isDebugEnabled()) {
            logger.debug("executeRemoteRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString()
                    + "]" + " redirected to " + uri.toASCIIString());
        }

        HttpRequestBase request = resolveRequest(method, uri);
        executeRemoteRequest(request, req, rsp);
    }

    private StatusLine executeRemoteRequest(HttpRequestBase request, HttpServletRequest req,
            HttpServletResponse rsp) throws IOException {
        copyRequestHeadersValues(req, request);

        final CloseableHttpResponse response;
        if (HttpPost.METHOD_NAME.equalsIgnoreCase(request.getMethod())) {
            response = transferPostedData((HttpEntityEnclosingRequestBase) request, req);
        } else {
            response = client.execute(request);
        }

        try {
            HttpEntity rspEntity = response.getEntity();
            StatusLine statusLine = response.getStatusLine();
            int statusCode = statusLine.getStatusCode();
            if ((statusCode < HttpServletResponse.SC_OK) || (statusCode >= 300)) {
                String reason = StringUtils.trimToEmpty(statusLine.getReasonPhrase());
                logger.warn("executeRemoteRequest(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                        + req.getQueryString() + "]" + " bad response (" + statusCode + ") from remote end: "
                        + reason);
                EntityUtils.consume(rspEntity);
                rsp.sendError(statusCode, reason);
            } else {
                rsp.setStatus(statusCode);

                copyResponseHeadersValues(req, response, rsp);
                transferBackendResponse(req, rspEntity, rsp);
            }

            return statusLine;
        } finally {
            response.close();
        }
    }

    private CloseableHttpResponse transferPostedData(HttpEntityEnclosingRequestBase postRequest,
            final HttpServletRequest req) throws IOException {
        InputStream postData = req.getInputStream();
        try {
            if (logger.isTraceEnabled()) {
                LineLevelAppender appender = new LineLevelAppender() {
                    @Override
                    @SuppressWarnings("synthetic-access")
                    public void writeLineData(CharSequence lineData) throws IOException {
                        logger.trace("transferPostedData(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                                + req.getQueryString() + "] C: " + lineData);
                    }

                    @Override
                    public boolean isWriteEnabled() {
                        return true;
                    }
                };
                postData = new TeeInputStream(postData, new HexDumpOutputStream(appender), true);
            }
            postRequest.setEntity(new InputStreamEntity(postData));
            return client.execute(postRequest);
        } finally {
            postData.close();
        }
    }

    private void transferBackendResponse(final HttpServletRequest req, HttpEntity rspEntity,
            HttpServletResponse rsp) throws IOException {
        final String method = req.getMethod();
        OutputStream rspTarget = rsp.getOutputStream();
        try {
            if (logger.isTraceEnabled()) {
                LineLevelAppender appender = new LineLevelAppender() {
                    @Override
                    @SuppressWarnings("synthetic-access")
                    public void writeLineData(CharSequence lineData) throws IOException {
                        logger.trace("transferBackendResponse(" + method + ")[" + req.getRequestURI() + "]["
                                + req.getQueryString() + "]" + " S: " + lineData);
                    }

                    @Override
                    public boolean isWriteEnabled() {
                        return true;
                    }
                };
                rspTarget = new TeeOutputStream(rspTarget, new HexDumpOutputStream(appender));
            }

            rspEntity.writeTo(rspTarget);
        } finally {
            rspTarget.close();
        }
    }

    private ResolvedRepositoryData resolveTargetRepository(RequestMethod method, HttpServletRequest req)
            throws IOException {
        ResolvedRepositoryData repoData = new ResolvedRepositoryData();
        String op = StringUtils.trimToEmpty(req.getParameter("service")), uriPath = req.getPathInfo();
        if (StringUtils.isEmpty(op)) {
            int pos = uriPath.lastIndexOf('/');
            if ((pos > 0) && (pos < (uriPath.length() - 1))) {
                op = uriPath.substring(pos + 1);
            }
        }

        if (!ALLOWED_SERVICES.contains(op)) {
            throw ExtendedLogUtils.thrownLogging(logger, Level.WARNING,
                    "resolveTargetRepository(" + method + " " + uriPath + ")",
                    new UnsupportedOperationException("Unsupported operation: " + op));
        }
        repoData.setOperation(op);

        String repoName = extractRepositoryName(uriPath);
        if (StringUtils.isEmpty(repoName)) {
            throw ExtendedLogUtils.thrownLogging(logger, Level.WARNING,
                    "resolveTargetRepository(" + method + " " + uriPath + ")",
                    new IllegalArgumentException("Failed to extract repo name from " + uriPath));
        }
        repoData.setRepoName(repoName);

        // TODO access an injected resolver that returns the back-end location URL
        String query = req.getQueryString();
        try {
            if (StringUtils.isEmpty(query)) {
                repoData.setRepoLocation(new URI("http://localhost:8080/git-backend/git" + uriPath));
            } else {
                repoData.setRepoLocation(new URI("http://localhost:8080/git-backend/git" + uriPath + "?" + query));
            }
        } catch (URISyntaxException e) {
            throw new MalformedURLException(e.getClass().getSimpleName() + ": " + e.getMessage());
        }

        return repoData;
    }

    // TODO move this to a common location for the front-end - to be used by SSH as well
    public static class ResolvedRepositoryData extends AbstractSimpleJavaBean implements Cloneable, Serializable {
        private static final long serialVersionUID = -2619946255863098003L;

        private String operation;
        private String repoName;
        private URI repoLocation;

        public ResolvedRepositoryData() {
            super();
        }

        public String getOperation() {
            return operation;
        }

        public void setOperation(String op) {
            operation = op;
        }

        public String getRepoName() {
            return repoName;
        }

        public void setRepoName(String name) {
            repoName = name;
        }

        public URI getRepoLocation() {
            return repoLocation;
        }

        public void setRepoLocation(URI location) {
            repoLocation = location;
        }

        @Override
        public ResolvedRepositoryData clone() {
            try {
                return getClass().cast(super.clone());
            } catch (CloneNotSupportedException e) {
                throw new RuntimeException(e); // unexpected
            }
        }
    }

    public static final SortedSet<String> FILTERED_REQUEST_HEADERS = SetUtils
            .unmodifiableSortedSet(new TreeSet<String>(String.CASE_INSENSITIVE_ORDER) {
                // we're not serializing it anywhere
                private static final long serialVersionUID = 1L;

                {
                    // see RequestContent#process method
                    add(HTTP.TRANSFER_ENCODING);
                    add(HTTP.CONTENT_LEN);

                    // other headers we don't want to echo as-is
                    add(HTTP.CONN_DIRECTIVE);
                    add(HTTP.TARGET_HOST);
                }
            });

    // TODO move this to some generic util location
    // NOTE: returns ALL request headers - even those that were filtered out
    private Map<String, String> copyRequestHeadersValues(HttpServletRequest req, HttpRequestBase request) {
        Map<String, String> hdrsMap = ServletUtils.getRequestHeaders(req);
        for (Map.Entry<String, String> hdrEntry : hdrsMap.entrySet()) {
            String hdrName = hdrEntry.getKey(), hdrValue = StringUtils.trimToEmpty(hdrEntry.getValue());
            if (StringUtils.isEmpty(hdrValue)) {
                logger.warn("copyRequestHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                        + req.getQueryString() + "]" + " no value for header " + hdrName);

            }

            if (FILTERED_REQUEST_HEADERS.contains(hdrName)) {
                if (logger.isTraceEnabled()) {
                    logger.trace("copyRequestHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                            + req.getQueryString() + "]" + " filtered " + hdrName + ": " + hdrValue);
                }
            } else {
                if (logger.isTraceEnabled()) {
                    logger.trace("copyRequestHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                            + req.getQueryString() + "]" + " " + hdrName + ": " + hdrValue);
                }
                request.addHeader(hdrName, hdrValue);
            }

            hdrsMap.put(hdrName, hdrValue);
        }

        return hdrsMap;
    }

    public static final SortedSet<String> FILTERED_RESPONSE_HEADERS = SetUtils
            .unmodifiableSortedSet(new TreeSet<String>(String.CASE_INSENSITIVE_ORDER) {
                // we're not serializing it anywhere
                private static final long serialVersionUID = 1L;

                {
                    /*
                     * Apache HTTP client de-chunks it for us and subsequently
                     * the HttpServletResponse implementation decides how to
                     * re-wrap it 
                     */
                    add(HTTP.TRANSFER_ENCODING);
                    // other headers we don't want to echo as-is
                    add(HTTP.SERVER_HEADER);
                }
            });
    public static final Comparator<Header> BY_NAME_COMPARATOR = new Comparator<Header>() {
        @Override
        public int compare(Header h1, Header h2) {
            String n1 = (h1 == null) ? null : h1.getName();
            String n2 = (h2 == null) ? null : h2.getName();
            // header names are case-insensitive
            return ExtendedStringUtils.safeCompare(n1, n2, false);
        }
    };

    // TODO move this to some generic util location
    // NOTE: returns ALL response headers - even those that were filtered out
    private Map<String, String> copyResponseHeadersValues(HttpServletRequest req, HttpMessage response,
            HttpServletResponse rsp) {
        Header[] hdrs = response.getAllHeaders();
        if (ArrayUtils.isEmpty(hdrs)) {
            logger.warn("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                    + req.getQueryString() + "] no headers");
            return Collections.emptyMap();
        }
        Arrays.sort(hdrs, BY_NAME_COMPARATOR);

        // NOTE: map must be case insensitive as per HTTP requirements
        Map<String, String> hdrsMap = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER);
        for (Header hdrEntry : hdrs) {
            // TODO add support for multi-valued headers
            String hdrName = ServletUtils.capitalizeHttpHeaderName(hdrEntry.getName()),
                    hdrValue = StringUtils.trimToEmpty(hdrEntry.getValue());
            hdrsMap.put(hdrName, hdrValue);
            if (FILTERED_RESPONSE_HEADERS.contains(hdrName)) {
                if (logger.isTraceEnabled()) {
                    logger.trace("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                            + req.getQueryString() + "]" + " filtered " + hdrName + ": " + hdrValue);
                }
                continue;
            }

            if (StringUtils.isEmpty(hdrValue)) {
                logger.warn("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                        + req.getQueryString() + "]" + " no value for header " + hdrName);
                rsp.setHeader(hdrName, "");
                continue;
            }

            if (logger.isTraceEnabled()) {
                logger.trace("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "]["
                        + req.getQueryString() + "]" + " " + hdrName + ": " + hdrValue);
            }

            rsp.setHeader(hdrName, hdrValue);
        }

        return hdrsMap;
    }

    static final HttpRequestBase resolveRequest(RequestMethod method, URI uri) {
        if (RequestMethod.GET.equals(method)) {
            return new HttpGet(uri);
        } else if (RequestMethod.POST.equals(method)) {
            return new HttpPost(uri);
        } else { // TODO add support for HEAD, OPTIONS, TRACE, PUT if necessary
            throw new UnsupportedOperationException(uri.toString() + " - unknown method: " + method);
        }
    }

    // TODO move this to some generic util location
    public static final String extractRepositoryName(String uriPath) {
        if (StringUtils.isEmpty(uriPath)) {
            return null;
        }

        int gitPos = uriPath.indexOf(Constants.DOT_GIT_EXT);
        if (gitPos <= 0) {
            return null;
        }

        int startPos = gitPos;
        for (; startPos >= 0; startPos--) {
            if (uriPath.charAt(startPos) == '/') {
                startPos++;
                break;
            }
        }

        if (startPos < 0) {
            startPos = 0; // in case did not start with '/'
        }

        int endPos = gitPos;
        for (; endPos < uriPath.length(); endPos++) {
            if (uriPath.charAt(endPos) == '/') {
                break;
            }
        }

        String pureName = uriPath.substring(startPos, endPos);
        if (Constants.DOT_GIT_EXT.equals(pureName)) {
            return null;
        } else {
            return pureName;
        }
    }
}