org.sonatype.nexus.repository.proxy.ProxyFacetSupport.java Source code

Java tutorial

Introduction

Here is the source code for org.sonatype.nexus.repository.proxy.ProxyFacetSupport.java

Source

/*
 * Sonatype Nexus (TM) Open Source Version
 * Copyright (c) 2008-present Sonatype, Inc.
 * All rights reserved. Includes the third-party code listed at http://links.sonatype.com/products/nexus/oss/attributions.
 *
 * This program and the accompanying materials are made available under the terms of the Eclipse Public License Version 1.0,
 * which accompanies this distribution and is available at http://www.eclipse.org/legal/epl-v10.html.
 *
 * Sonatype Nexus (TM) Professional Version is available from Sonatype, Inc. "Sonatype" and "Sonatype Nexus" are trademarks
 * of Sonatype, Inc. Apache Maven is a trademark of the Apache Software Foundation. M2eclipse is a trademark of the
 * Eclipse Foundation. All other trademarks are the property of their respective owners.
 */
package org.sonatype.nexus.repository.proxy;

import java.io.IOException;
import java.net.URI;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;

import org.sonatype.goodies.common.Time;
import org.sonatype.nexus.repository.BadRequestException;
import org.sonatype.nexus.repository.FacetSupport;
import org.sonatype.nexus.repository.InvalidContentException;
import org.sonatype.nexus.repository.Repository;
import org.sonatype.nexus.repository.cache.CacheController;
import org.sonatype.nexus.repository.cache.CacheControllerHolder;
import org.sonatype.nexus.repository.cache.CacheInfo;
import org.sonatype.nexus.repository.cache.NegativeCacheFacet;
import org.sonatype.nexus.repository.config.Configuration;
import org.sonatype.nexus.repository.config.ConfigurationFacet;
import org.sonatype.nexus.repository.httpclient.HttpClientFacet;
import org.sonatype.nexus.repository.httpclient.internal.RemoteBlockedIOException;
import org.sonatype.nexus.repository.view.Content;
import org.sonatype.nexus.repository.view.Context;
import org.sonatype.nexus.repository.view.payloads.HttpEntityPayload;
import org.sonatype.nexus.validation.constraint.Url;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.io.Closeables;
import com.google.common.net.HttpHeaders;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.utils.DateUtils;
import org.apache.http.client.utils.HttpClientUtils;
import org.joda.time.DateTime;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;

/**
 * A support class which implements basic payload logic; subclasses provide format-specific operations.
 *
 * @since 3.0
 */
public abstract class ProxyFacetSupport extends FacetSupport implements ProxyFacet {
    @VisibleForTesting
    static final String CONFIG_KEY = "proxy";

    @VisibleForTesting
    static class Config {
        @Url
        @NotNull
        public URI remoteUrl;

        /**
         * Content max-age minutes.
         */
        @NotNull
        public Integer contentMaxAge = Time.hours(24).toMinutesI();

        /**
         * Metadata max-age minutes.
         */
        @NotNull
        public Integer metadataMaxAge = Time.hours(24).toMinutesI();

        @Override
        public String toString() {
            return getClass().getSimpleName() + "{" + "remoteUrl=" + remoteUrl + ", contentMaxAge=" + contentMaxAge
                    + '}';
        }
    }

    private Config config;

    private HttpClientFacet httpClient;

    private boolean remoteUrlChanged;

    protected CacheControllerHolder cacheControllerHolder;

    @Override
    protected void doValidate(final Configuration configuration) throws Exception {
        facet(ConfigurationFacet.class).validateSection(configuration, CONFIG_KEY, Config.class);
    }

    @Override
    protected void doConfigure(final Configuration configuration) throws Exception {
        config = facet(ConfigurationFacet.class).readSection(configuration, CONFIG_KEY, Config.class);

        cacheControllerHolder = new CacheControllerHolder(
                new CacheController(Time.minutes(config.contentMaxAge).toSecondsI(), null),
                new CacheController(Time.minutes(config.metadataMaxAge).toSecondsI(), null));

        // normalize URL path to contain trailing slash
        if (!config.remoteUrl.getPath().endsWith("/")) {
            config.remoteUrl = config.remoteUrl.resolve(config.remoteUrl.getPath() + "/");
        }

        log.debug("Config: {}", config);
    }

    @Override
    protected void doUpdate(final Configuration configuration) throws Exception {
        // detect URL changes
        URI previousUrl = config.remoteUrl;
        super.doUpdate(configuration);
        remoteUrlChanged = !config.remoteUrl.equals(previousUrl);
    }

    @Override
    protected void doDestroy() throws Exception {
        config = null;
    }

    @Override
    protected void doStart() throws Exception {
        httpClient = facet(HttpClientFacet.class);

        if (remoteUrlChanged) {
            remoteUrlChanged = false;

            optionalFacet(NegativeCacheFacet.class).ifPresent((nfc) -> nfc.invalidate());
        }
    }

    @Override
    protected void doStop() throws Exception {
        httpClient = null;
    }

    public URI getRemoteUrl() {
        return config.remoteUrl;
    }

    @Override
    public Content get(final Context context) throws IOException {
        checkNotNull(context);

        Content content = getCachedContent(context);

        if (isStale(context, content)) {
            Content remote = null;
            try {
                remote = fetch(context, content);
                if (remote != null) {
                    content = store(context, remote);
                }
            } catch (ProxyServiceException e) {
                int sc = e.getHttpResponse().getStatusLine().getStatusCode();
                String repoName = this.getRepository().getName();
                String contextUrl = getUrl(context);
                log.trace("Proxy repo {} received status {} attempting to retrieve resource {}", repoName, sc,
                        contextUrl, e);
                logContentOrThrow(content, contextUrl, e);
            } catch (RemoteBlockedIOException e) {
                Repository repository = context.getRepository();
                log.trace("Failed to fetch: {} from repository: {} - {}", getUrl(context), repository.getName(), e);
                logContentOrThrow(content, getUrl(context), e);
            } catch (IOException e) {
                Repository repository = context.getRepository();
                log.trace("Failed to fetch: {}, from repository: {}", getUrl(context), repository.getName(), e);
                logContentOrThrow(content, getUrl(context), e);
            } finally {
                if (remote != null && !remote.equals(content)) {
                    Closeables.close(remote, true);
                }
            }
        }

        return content;
    }

    private <X extends Throwable> void logContentOrThrow(@Nullable final Content content, final String contextUrl,
            final X exception) throws X {
        log.debug("Unable to check remote for updates.");
        if (content != null) {
            log.debug("Returning content {} from cache.", contextUrl);
        } else {
            log.warn("Content not present for {}, throwing exception.", contextUrl);
            throw exception;
        }
    }

    @Override
    public void invalidateProxyCaches() {
        log.info("Invalidating proxy caches of {}", getRepository().getName());
        cacheControllerHolder.invalidateCaches();
    }

    /**
     * If we have the content cached locally already, return that along with applicable cache controller - otherwise
     * {@code null}.
     */
    @Nullable
    protected abstract Content getCachedContent(final Context context) throws IOException;

    /**
     * Store a new Payload, freshly fetched from the remote URL.
     *
     * The Context indicates which component was being requested.
     *
     * @throws IOException
     * @throws InvalidContentException
     */
    protected abstract Content store(final Context context, final Content content) throws IOException;

    @Nullable
    protected Content fetch(final Context context, Content stale) throws IOException {
        return fetch(getUrl(context), context, stale);
    }

    protected Content fetch(String url, Context context, @Nullable Content stale) throws IOException {
        HttpClient client = httpClient.getHttpClient();

        checkState(config.remoteUrl.isAbsolute(),
                "Invalid remote URL '%s' for proxy repository %s, please fix your configuration", config.remoteUrl,
                getRepository().getName());
        URI uri;
        try {
            uri = config.remoteUrl.resolve(url);
        } catch (IllegalArgumentException e) { // NOSONAR
            log.warn("Unable to resolve url. Reason: {}", e.getMessage());
            throw new BadRequestException("Invalid repository path");
        }
        HttpRequestBase request = buildFetchHttpRequest(uri, context);
        if (stale != null) {
            final DateTime lastModified = stale.getAttributes().get(Content.CONTENT_LAST_MODIFIED, DateTime.class);
            if (lastModified != null) {
                request.addHeader(HttpHeaders.IF_MODIFIED_SINCE, DateUtils.formatDate(lastModified.toDate()));
            }
            final String etag = stale.getAttributes().get(Content.CONTENT_ETAG, String.class);
            if (etag != null) {
                request.addHeader(HttpHeaders.IF_NONE_MATCH, "\"" + etag + "\"");
            }
        }
        log.debug("Fetching: {}", request);

        HttpResponse response = execute(context, client, request);
        log.debug("Response: {}", response);

        StatusLine status = response.getStatusLine();
        log.debug("Status: {}", status);

        final CacheInfo cacheInfo = getCacheController(context).current();

        if (status.getStatusCode() == HttpStatus.SC_OK) {
            HttpEntity entity = response.getEntity();
            log.debug("Entity: {}", entity);

            final Content result = createContent(context, response);
            result.getAttributes().set(Content.CONTENT_LAST_MODIFIED, extractLastModified(request, response));
            result.getAttributes().set(Content.CONTENT_ETAG, extractETag(response));
            result.getAttributes().set(CacheInfo.class, cacheInfo);
            return result;
        }

        try {
            if (status.getStatusCode() == HttpStatus.SC_NOT_MODIFIED) {
                checkState(stale != null, "Received 304 without conditional GET (bad server?) from %s", uri);
                indicateVerified(context, stale, cacheInfo);
            }
            mayThrowProxyServiceException(response);
        } finally {
            HttpClientUtils.closeQuietly(response);
        }

        return null;
    }

    /**
     * Create {@link Content} out of HTTP response.
     */
    protected Content createContent(final Context context, final HttpResponse response) {
        return new Content(new HttpEntityPayload(response, response.getEntity()));
    }

    /**
     * May throw {@link ProxyServiceException} based on response statuses.
     */
    private void mayThrowProxyServiceException(final HttpResponse httpResponse) {
        final StatusLine status = httpResponse.getStatusLine();
        if (HttpStatus.SC_UNAUTHORIZED == status.getStatusCode()
                || HttpStatus.SC_PAYMENT_REQUIRED == status.getStatusCode()
                || HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED == status.getStatusCode()
                || HttpStatus.SC_INTERNAL_SERVER_ERROR <= status.getStatusCode()) {
            throw new ProxyServiceException(httpResponse);
        }
    }

    /**
     * Execute http client request.
     */
    protected HttpResponse execute(final Context context, final HttpClient client, final HttpRequestBase request)
            throws IOException {
        return client.execute(request);
    }

    /**
     * Builds the {@link HttpRequestBase} for a particular set of parameters (mapping to GET by default).
     */
    protected HttpRequestBase buildFetchHttpRequest(URI uri, Context context) {
        return new HttpGet(uri);
    }

    /**
     * Extract Last-Modified date from response if possible, or {@code null}.
     */
    @Nullable
    private DateTime extractLastModified(final HttpRequestBase request, final HttpResponse response) {
        final Header lastModifiedHeader = response.getLastHeader(HttpHeaders.LAST_MODIFIED);
        if (lastModifiedHeader != null) {
            try {
                return new DateTime(DateUtils.parseDate(lastModifiedHeader.getValue()).getTime());
            } catch (Exception ex) {
                log.warn(
                        "Could not parse date '{}' received from {}; using system current time as item creation time",
                        lastModifiedHeader, request.getURI());
            }
        }
        return null;
    }

    /**
     * Extract ETag from response if possible, or {@code null}.
     */
    @Nullable
    private String extractETag(final HttpResponse response) {
        final Header etagHeader = response.getLastHeader(HttpHeaders.ETAG);
        if (etagHeader != null) {
            final String etag = etagHeader.getValue();
            if (!Strings.isNullOrEmpty(etag)) {
                if (etag.startsWith("\"") && etag.endsWith("\"")) {
                    return etag.substring(1, etag.length() - 1);
                } else {
                    return etag;
                }
            }
        }
        return null;
    }

    /**
     * For whatever component/asset
     */
    protected abstract void indicateVerified(final Context context, final Content content,
            final CacheInfo cacheInfo) throws IOException;

    /**
     * Provide the URL of the content relative to the repository root.
     */
    protected abstract String getUrl(@Nonnull final Context context);

    /**
     * Get the appropriate cache controller for the type of content being requested. Must never return {@code null}.
     */
    @Nonnull
    protected CacheController getCacheController(@Nonnull final Context context) {
        return cacheControllerHolder.getContentCacheController();
    }

    private boolean isStale(final Context context, final Content content) {
        if (content == null) {
            // not in cache, consider it stale
            return true;
        }
        final CacheInfo cacheInfo = content.getAttributes().get(CacheInfo.class);
        return cacheInfo == null || getCacheController(context).isStale(cacheInfo);
    }
}