All Downloads are FREE. Search and download functionalities are using the official Maven repository.

eu.erasmuswithoutpaper.registryclient.ClientImpl Maven / Gradle / Ivy

The newest version!
package eu.erasmuswithoutpaper.registryclient;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.cert.Certificate;
import java.security.interfaces.RSAPublicKey;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import eu.erasmuswithoutpaper.registryclient.CatalogueDocument.CatalogueParserException;
import eu.erasmuswithoutpaper.registryclient.CatalogueFetcher.Http200RegistryResponse;
import eu.erasmuswithoutpaper.registryclient.CatalogueFetcher.Http200RegistryResponse.CouldNotDeserialize;
import eu.erasmuswithoutpaper.registryclient.CatalogueFetcher.Http304RegistryResponse;
import eu.erasmuswithoutpaper.registryclient.CatalogueFetcher.RegistryResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Element;

/**
 * 

* A thread-safe {@link RegistryClient} implementation with in-memory catalogue copy and background * synchronization options. *

* *
    *
  • {@link ClientImpl} keeps a copy of the Registry's catalogue in memory, and thus allows * all API queries to return immediately (without the need to query the remote server in the * same thread).
  • *
  • The in-memory copy of the catalogue can be refreshed both manually and * automatically (see {@link ClientImplOptions#setAutoRefreshing(boolean)} for details).
  • *
* *

Example 1: Use it as an {@literal @}Autowired Spring bean

* *

* If you're using Spring or a similar IOT container, then you can use this client as a singleton * bean: *

* *
 * @Bean
 * public RegistryClient getEwpRegistryClient() {
 *   ClientImplOptions options = new ClientImplOptions();
 *   options.setAutoRefreshing(true);
 *   return new ClientImpl(options);
 * }
 * 
* *

* If you believe that you won't be using the client much (and you don't want to keep it in memory * all the time), then you can also use a different scope for your bean (e.g. * {@literal @Scope("request")}). In such cases however, it is recommended to supply a * {@link ClientImplOptions#setPersistentCacheMap(Map)} to speed up initialization. *

* *

* Note: Spring will automatically call close() on {@link AutoCloseable} beans, so you don't need to * call it yourself. *

* *

Example 2: Use it in-line

* *

* When used in-line, remember to wrap it in try-with-resources statement (or remember to call * {@link #close()} manually). It is recommended to supply * {@link ClientImplOptions#setPersistentCacheMap(Map)} in your options: *

* *
 * ClientImplOptions options = new ClientImplOptions();
 * options.setAutoRefreshing(true);
 * options.setPersistentCacheMap(...); // e.g. memcached server
 * try (RegistryClient client = new ClientImpl(options)) {
 *   // Your queries.
 * }
 * 
* *

* This approach is rather discouraged because the catalogue does not reside in memory * between runs, and needs to be parsed before each use. It's also more susceptible to suffer from * possible Registry Service downtime. It may however have its merits if you're planning to use the * Registry only rarely. *

* * @since 1.0.0 */ public class ClientImpl implements RegistryClient { private static final String CATALOGUE_CACHE_KEY = "latest-catalogue"; private static final Logger logger = LoggerFactory.getLogger(ClientImpl.class); /** * {@link ClientImplOptions} which we've been constructed with. */ private final ClientImplOptions options; /** * The instance of the {@link CatalogueDocument} which we are currently operating on. * *

* This is not final, because a new instance of the {@link CatalogueDocument} is created whenever * the content of the catalogue changes (see {@link ClientImplOptions#isAutoRefreshing()}). *

*/ private volatile CatalogueDocument doc; /** * This will be used if {@link ClientImplOptions#isAutoRefreshing()} is true. Otherwise, it will * be null. */ private final ScheduledExecutorService executor; /** * Construct {@link ClientImpl} with default {@link ClientImplOptions}. */ public ClientImpl() { this(new ClientImplOptions()); } /** * Construct {@link ClientImpl}. * * @param options Options to use. These options cannot be changed after the constructor is called * (if they do, then behavior will be undetermined). */ public ClientImpl(ClientImplOptions options) { this.options = options; logger.info("Constructing new ClientImpl with options: {}", options); /* * If we are provided with a persistent cache, then will try to load a copy of the catalogue * directly from it. */ Map cache = this.options.getPersistentCacheMap(); if (cache != null) { logger.debug("Attempting to load a catalogue from cache"); byte[] data = cache.get(CATALOGUE_CACHE_KEY); if (data != null) { try { Http200RegistryResponse cachedResponse = Http200RegistryResponse.deserialize(cache.get(CATALOGUE_CACHE_KEY)); this.doc = new CatalogueDocument(cachedResponse); logger.info("Loaded a catalogue from cache: {}", this.doc); } catch (CatalogueParserException | CouldNotDeserialize e) { if (logger.isDebugEnabled()) { logger.debug("Could not load the catalogue from cache: {}", e.getClass().getSimpleName()); } } } } // If no cache was provided, or loading failed, then use an empty placeholder. if (this.doc == null) { StringBuilder sb = new StringBuilder(); sb.append(""); byte[] content = sb.toString().getBytes(StandardCharsets.UTF_8); String newETag = "empty-placeholder"; Date expires = new Date(0); Http200RegistryResponse emptyResponse = new Http200RegistryResponse(content, newETag, expires); try { this.doc = new CatalogueDocument(emptyResponse); } catch (CatalogueParserException e) { throw new RuntimeException(e); } } if (options.isAutoRefreshing()) { /* * If our current copy of the catalogue has expired, then we will attempt to refresh it during * the construction time. This will cause the object to be constructed much slower, but it * seems safer, as the caller might want to use the client right after the call. */ if (this.getExpiryDate().after(new Date())) { logger.debug("The cached copy seems to be acceptable. " + "We won't be refreshing it (this will speed up the construction)."); } else { logger.debug("Our cached copy of the catalogue has expired. " + "We will refresh it now (this might make construction a bit slower)."); try { this.refresh(); } catch (RefreshFailureException e2) { this.logRefreshFailure( "Failed to fetch a fresh copy of the catalogue during construction " + "(we will keep trying in a background thread).", e2); } } /* * Set up a periodical catalogue refresh task. THIS SHOULD BE THE LAST SECTION (because we're * starting new threads here). */ this.executor = Executors.newSingleThreadScheduledExecutor(); this.executor.schedule( new SelfSchedulableTask(this.executor, this.options.getMinTimeBetweenQueries()) { @Override protected Date runAndScheduleNext() { try { logger.trace("runAndScheduleNext was called"); Date now = new Date(); Date expiryDate = ClientImpl.this.getExpiryDate(); if (expiryDate.after(now)) { logger.trace("No refresh was necessary. Will retry at {}", expiryDate); return ClientImpl.this.getExpiryDate(); } ClientImpl.this.refresh(); return ClientImpl.this.getExpiryDate(); } catch (RefreshFailureException e) { ClientImpl.this .logRefreshFailure("Scheduled catalogue refresh failed. Will retry in " + ClientImpl.this.options.getTimeBetweenRetries() + "ms.", e); return new Date( new Date().getTime() + ClientImpl.this.options.getTimeBetweenRetries()); } } }, 0, TimeUnit.MILLISECONDS); } else { this.executor = null; } } @Override public boolean areHeisCoveredByCertificate(Collection heiIds, Certificate clientCert) throws UnacceptableStalenessException { return this.getHeisCoveredByCertificate(clientCert).containsAll(heiIds); } @Override public boolean areHeisCoveredByCertificate(String[] heiIds, Certificate clientCert) throws UnacceptableStalenessException { Collection heis = this.getHeisCoveredByCertificate(clientCert); for (String heiId : heiIds) { if (!heis.contains(heiId)) { return false; } } return true; } @Override public boolean areHeisCoveredByClientKey(Collection heiIds, RSAPublicKey clientKey) throws UnacceptableStalenessException { return this.getHeisCoveredByClientKey(clientKey).containsAll(heiIds); } @Override public boolean areHeisCoveredByClientKey(String[] heiIds, RSAPublicKey clientKey) throws UnacceptableStalenessException { Collection heis = this.getHeisCoveredByClientKey(clientKey); for (String heiId : heiIds) { if (!heis.contains(heiId)) { return false; } } return true; } @Override public void assertApiIsCoveredByServerKey(Element apiElement, RSAPublicKey serverKey) throws AssertionFailedException, InvalidApiEntryElement, UnacceptableStalenessException { if (!this.isApiCoveredByServerKey(apiElement, serverKey)) { throw new AssertionFailedException( "API entry " + apiElement.toString() + " doesn't seem to be covered by this server key: " + Utils.extractFingerprint(serverKey)); } } @Override public void assertCertificateIsKnown(Certificate clientCert) throws AssertionFailedException { if (!this.isCertificateKnown(clientCert)) { throw new AssertionFailedException("Certificate was not recognized as a known EWP Client: " + Utils.extractFingerprint(clientCert)); } } @Override public void assertClientKeyIsKnown(RSAPublicKey clientKey) throws AssertionFailedException { if (!this.isClientKeyKnown(clientKey)) { throw new AssertionFailedException( "Key was not recognized as a known EWP Client: " + Utils.extractFingerprint(clientKey)); } } @Override public void assertHeiIsCoveredByCertificate(String heiId, Certificate clientCert) throws AssertionFailedException { if (!this.isHeiCoveredByCertificate(heiId, clientCert)) { throw new AssertionFailedException("HEI " + heiId + " is not covered by this certificate."); } } @Override public void assertHeiIsCoveredByClientKey(String heiId, RSAPublicKey clientKey) throws AssertionFailedException { if (!this.isHeiCoveredByClientKey(heiId, clientKey)) { throw new AssertionFailedException("HEI " + heiId + " is not covered by this client key."); } } @Override public void assertHeisAreCoveredByCertificate(Collection heiIds, Certificate clientCert) throws AssertionFailedException { if (!this.areHeisCoveredByCertificate(heiIds, clientCert)) { throw new AssertionFailedException("Some of the HEIs are not covered by this certificate."); } } @Override public void assertHeisAreCoveredByCertificate(String[] heiIds, Certificate clientCert) throws AssertionFailedException { if (!this.areHeisCoveredByCertificate(heiIds, clientCert)) { throw new AssertionFailedException("Some of the HEIs are not covered by this certificate."); } } @Override public void assertHeisAreCoveredByClientKey(Collection heiIds, RSAPublicKey clientKey) throws AssertionFailedException { if (!this.areHeisCoveredByClientKey(heiIds, clientKey)) { throw new AssertionFailedException("Some of the HEIs are not covered by this client key."); } } @Override public void assertHeisAreCoveredByClientKey(String[] heiIds, RSAPublicKey clientKey) throws AssertionFailedException, UnacceptableStalenessException { if (!this.areHeisCoveredByClientKey(heiIds, clientKey)) { throw new AssertionFailedException("Some of the HEIs are not covered by this client key."); } } @Override public void close() { logger.info("ClientImpl is closing"); if (this.executor != null) { this.executor.shutdownNow(); try { if (this.executor.awaitTermination(30, TimeUnit.SECONDS)) { logger.info("All threads exited successfully."); } else { logger.warn("Some threads are still running, but we won't wait anymore."); } } catch (InterruptedException e) { logger.warn("Interrupted while waiting for threads to finish."); } } logger.info("ClientImpl finished closing"); } @Override public Element findApi(ApiSearchConditions conditions) { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.findApi(conditions); } @Override public Collection findApis(ApiSearchConditions conditions) { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.findApis(conditions); } @Override public HeiEntry findHei(String id) throws UnacceptableStalenessException { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.findHei(id); } @Override public HeiEntry findHei(String type, String value) throws UnacceptableStalenessException { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.findHei(type, value); } @Override public String findHeiId(String type, String value) { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.findHeiId(type, value); } @Override public Collection findHeis(ApiSearchConditions conditions) throws UnacceptableStalenessException { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.findHeis(conditions); } @Override public RSAPublicKey findRsaPublicKey(String fingerprint) throws UnacceptableStalenessException { this.assertAcceptableStaleness(); return this.doc.findRsaPublicKey(fingerprint); } @Override public Collection getAllHeis() throws UnacceptableStalenessException { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.getAllHeis(); } @Override public Date getExpiryDate() { // No need to synchronize. Simply get the expiry date of the currently held doc. return this.doc.getExpiryDate(); } @Override public Collection getHeisCoveredByCertificate(Certificate clientCert) { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.getHeisCoveredByCertificate(clientCert); } @Override public Collection getHeisCoveredByClientKey(RSAPublicKey clientKey) throws UnacceptableStalenessException { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.getHeisCoveredByClientKey(clientKey); } @Override public RSAPublicKey getServerKeyCoveringApi(Element apiElement) throws UnacceptableStalenessException, InvalidApiEntryElement { this.assertAcceptableStaleness(); return this.doc.getServerKeyCoveringApi(apiElement); } @Override public Collection getServerKeysCoveringApi(Element apiElement) throws UnacceptableStalenessException, InvalidApiEntryElement { this.assertAcceptableStaleness(); return this.doc.getServerKeysCoveringApi(apiElement); } @Override public boolean isApiCoveredByServerKey(Element apiElement, RSAPublicKey serverKey) throws UnacceptableStalenessException, InvalidApiEntryElement { this.assertAcceptableStaleness(); return this.doc.isApiCoveredByServerKey(apiElement, serverKey); } @Override public boolean isCertificateKnown(Certificate clientCert) { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.isCertificateKnown(clientCert); } @Override public boolean isClientKeyKnown(RSAPublicKey clientKey) throws UnacceptableStalenessException { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.doc.isClientKeyKnown(clientKey); } @Override public boolean isHeiCoveredByCertificate(String heiId, Certificate clientCert) { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.areHeisCoveredByCertificate(new String[] { heiId }, clientCert); } @Override public boolean isHeiCoveredByClientKey(String heiId, RSAPublicKey clientKey) throws UnacceptableStalenessException { // Since expiry date can only be extended, there is no need to synchronize. this.assertAcceptableStaleness(); return this.areHeisCoveredByClientKey(new String[] { heiId }, clientKey); } @Override public void refresh() throws RefreshFailureException { logger.trace("Starting a new refresh call"); // Fetch the new catalogue from server. CatalogueFetcher catalogueFetcher = this.options.getCatalogueFetcher(); RegistryResponse someResponse; try { logger.trace("Fetching response from the catalogueFetcher"); someResponse = catalogueFetcher.fetchCatalogue(this.doc.getETag()); if (logger.isTraceEnabled()) { logger.trace("Response fetched successfully: " + someResponse.getClass()); } } catch (IOException e) { logger.debug("CatalogueFetcher has thrown an IOException", e); throw new RefreshFailureException("Problem fetching the catalogue from server", e); } // What kind of response did we receive? if (someResponse instanceof Http304RegistryResponse) { /* * Catalogue did not change since the previous call. This means that we already have the * current version of the catalogue already parsed in our fields. */ if (logger.isInfoEnabled()) { logger .info("Extending the expiry date of our catalogue copy: " + someResponse.getExpires()); } this.doc.extendExpiryDate(someResponse.getExpires()); Map cache = this.options.getPersistentCacheMap(); if (cache != null) { logger.trace("Trying to extend the expiry date of the cached copy too..."); byte[] data = cache.get(CATALOGUE_CACHE_KEY); if (data != null) { try { Http200RegistryResponse oldCachedResponse = Http200RegistryResponse.deserialize(data); Http200RegistryResponse newCachedResponse = new Http200RegistryResponse(oldCachedResponse.getContent(), oldCachedResponse.getETag(), this.doc.getExpiryDate()); cache.put(CATALOGUE_CACHE_KEY, newCachedResponse.serialize()); logger.trace("Successfully updated"); } catch (CouldNotDeserialize e) { logger.info("Could not extend the expiry date of the cached copy"); } } else { logger.debug("Cached copy not found"); } } return; } else if (someResponse instanceof Http200RegistryResponse) { /* * Catalogue has changed. We will create a new document (along with all the indexes), and - * once we complete this - start using it. (In the meantime, we will keep serving the previous * document.) */ logger.trace("Preparing a new catalogue copy"); Http200RegistryResponse response = (Http200RegistryResponse) someResponse; try { this.doc = new CatalogueDocument(response); logger.info("Catalogue copy successfully updated: {}", this.doc); } catch (CatalogueParserException e) { logger.debug("Could not parse the new catalogue", e); throw new RefreshFailureException(e); } // Also store the new response in persistent cache (if we have one). Map cache = this.options.getPersistentCacheMap(); if (cache != null) { logger.trace("Storing the new copy to cache..."); cache.put(CATALOGUE_CACHE_KEY, response.serialize()); } } else { throw new RuntimeException( "CatalogueFetcher returned an unsupported RegistryResponse subclass: " + someResponse.getClass()); } } /** * Make sure that the internal copy of the catalogue is acceptably fresh. * * @throws UnacceptableStalenessException if the age of catalogue exceeds * {@link ClientImplOptions#getMaxAcceptableStaleness()}. */ private void assertAcceptableStaleness() { Date acceptableUntil = new Date(this.getExpiryDate().getTime() + this.options.getMaxAcceptableStaleness()); if (new Date().after(acceptableUntil)) { throw new UnacceptableStalenessException(); } } /** * A helper method for logging {@link RegistryClient.RefreshFailureException} exceptions. * *

* It logs the message with different severity, depending on the current "level of staleness" (if * the cached copy becomes really stale, it will start to produce warnings). *

*/ private void logRefreshFailure(String message, RefreshFailureException ex) { long age = new Date().getTime() - this.getExpiryDate().getTime(); if (age > this.options.getStalenessWarningThreshold()) { if (logger.isWarnEnabled()) { logger.warn(message + ": " + ex); } } else { if (logger.isInfoEnabled()) { logger.info(message + ": " + ex); } } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy