Merge pull request #76 from olivergierke/hypermedia

Added hypermedia support in the form of a DiscoveredResource abstraction
This commit is contained in:
Spencer Gibb
2016-01-13 15:05:53 -07:00
14 changed files with 727 additions and 1 deletions

View File

@@ -48,6 +48,16 @@
<artifactId>spring-integration-jmx</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import lombok.Data;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.client.hypermedia.CloudHypermediaAutoConfiguration.CloudHypermediaProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Registers a default {@link RemoteResourceRefresher} if at least one {@link RemoteResource} is declared in the system
* and applies verification timings defined in the application properties.
*
* @author Oliver Gierke
*/
@Configuration
@ConditionalOnBean(type = "org.springframework.cloud.client.hypermedia.RemoteResource")
@EnableConfigurationProperties(CloudHypermediaProperties.class)
public class CloudHypermediaAutoConfiguration {
@Autowired(required = false) List<RemoteResource> discoveredResources = Collections.emptyList();
@Autowired CloudHypermediaProperties properties;
@Bean
@ConditionalOnMissingBean
public RemoteResourceRefresher discoveredResourceRefresher() {
return new RemoteResourceRefresher(discoveredResources, properties.getRefresh().getFixedDelay(),
properties.getRefresh().getInitialDelay());
}
@Data
@ConfigurationProperties(prefix = "spring.cloud.hypermedia")
public static class CloudHypermediaProperties {
private Refresh refresh = new Refresh();
@Data
public static class Refresh {
private int fixedDelay = 5000;
private int initialDelay = 10000;
}
}
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.client.Traverson;
import org.springframework.util.Assert;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
/**
* A REST resource that is defined by a service reference and a traversal operation within that service.
*
* @author Oliver Gierke
*/
@Slf4j
@RequiredArgsConstructor
public class DiscoveredResource implements RemoteResource {
private final ServiceInstanceProvider provider;
private final TraversalDefinition traversal;
private RestOperations restOperations = new RestTemplate();
private @Getter Link link = null;
/**
* Configures the {@link RestOperations} to use to execute the traversal and verifying HEAD calls.
*
* @param restOperations can be {@literal null}, resorting to a default {@link RestTemplate} in that case.
*/
public void setRestOperations(RestOperations restOperations) {
this.restOperations = restOperations == null ? new RestTemplate() : restOperations;
}
/**
* Verifies the link to the current
*/
public void verifyOrDiscover() {
this.link = link == null ? discoverLink() : verify(link);
}
/**
* Verifies the given {@link Link} by issuing an HTTP HEAD request to the resource.
*
* @param link must not be {@literal null}.
* @return
*/
private Link verify(Link link) {
Assert.notNull(link, "Link must not be null!");
try {
String uri = link.expand().getHref();
log.debug("Verifying link pointing to {}…", uri);
restOperations.headForHeaders(uri);
log.debug("Successfully verified link!");
return link;
} catch (RestClientException o_O) {
log.debug("Verification failed, marking as outdated!");
return null;
}
}
private Link discoverLink() {
try {
ServiceInstance service = provider.getServiceInstance();
if (service == null) {
return null;
}
URI uri = service.getUri();
String serviceId = service.getServiceId();
log.debug("Discovered {} system at {}. Discovering resource…", serviceId, uri);
Traverson traverson = new Traverson(uri, MediaTypes.HAL_JSON);
Link link = traversal.buildTraversal(traverson).asTemplatedLink();
log.debug("Found link pointing to {}.", link.getHref());
return link;
} catch (RuntimeException o_O) {
this.link = null;
log.debug("Target system unavailable. Got: ", o_O.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import lombok.RequiredArgsConstructor;
import java.util.List;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
/**
* {@link ServiceInstanceProvider} to work with a {@link DiscoveryClient} to lookup a service by name. Will pick the
* first one returned by the configured {@link DiscoveryClient}.
*
* @author Oliver Gierke
*/
@RequiredArgsConstructor
public class DynamicServiceInstanceProvider implements ServiceInstanceProvider {
private final DiscoveryClient client;
private final String serviceName;
/*
* (non-Javadoc)
* @see example.customers.integration.ServiceInstanceProvider#getServiceInstance()
*/
@Override
public ServiceInstance getServiceInstance() {
try {
List<ServiceInstance> instances = client.getInstances(serviceName);
return instances.isEmpty() ? null : instances.get(0);
} catch (RuntimeException o_O) {
return null;
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import org.springframework.hateoas.Link;
/**
* A REST resource that can be discovered and can be either gone or available.
*
* @author Oliver Gierke
*/
public interface RemoteResource {
/**
* Returns the {@link Link} to the resource in case it is available or {@literal null} in case it's gone, i.e. either
* generally unavailable or can't be discovered.
*
* @return
*/
Link getLink();
/**
* Discovers the the resource in case it hasn't been yet or became unavailable. In case a link has been discovered
* previously, it is verified and either confirmed or the link is removed to indicate it's not available anymore.
*/
void verifyOrDiscover();
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import lombok.RequiredArgsConstructor;
import java.util.List;
import org.springframework.scheduling.config.ContextLifecycleScheduledTaskRegistrar;
import org.springframework.scheduling.config.IntervalTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
/**
* A {@link ScheduledTaskRegistrar} that verifies all {@link DiscoveredResource} instances in the system based
* on the given timing configuration.
*
* @author Oliver Gierke
*/
@RequiredArgsConstructor
public class RemoteResourceRefresher extends ContextLifecycleScheduledTaskRegistrar {
private final List<RemoteResource> discoveredResources;
private final int fixedDelay, initialDelay;
/*
* (non-Javadoc)
* @see org.springframework.scheduling.config.ContextLifecycleScheduledTaskRegistrar#afterPropertiesSet()
*/
@Override
public void afterPropertiesSet() {
for (final RemoteResource resource : discoveredResources) {
addFixedDelayTask(new IntervalTask(new Runnable() {
@Override
public void run() {
resource.verifyOrDiscover();
}
}, fixedDelay, initialDelay));
}
super.afterPropertiesSet();
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import org.springframework.cloud.client.ServiceInstance;
/**
* A component that will provide a {@link ServiceInstance} or can express the absence of one by returning
* {@literal null}.
*
* @author Oliver Gierke
*/
public interface ServiceInstanceProvider {
/**
* Returns the service instance or {@literal null} in case the service is currently unavailable.
*
* @return the service instance or {@literal null} in case the service is currently unavailable.
*/
ServiceInstance getServiceInstance();
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import lombok.RequiredArgsConstructor;
import org.springframework.cloud.client.ServiceInstance;
/**
* A {@link ServiceInstanceProvider} that will always return the configured {@link ServiceInstance}.
*
* @author Oliver Gierke
*/
@RequiredArgsConstructor
public class StaticServiceInstanceProvider implements ServiceInstanceProvider {
private final ServiceInstance instance;
/*
* (non-Javadoc)
* @see example.customers.integration.ServiceInstanceProvider#getServiceInstance()
*/
@Override
public ServiceInstance getServiceInstance() {
return instance;
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import org.springframework.hateoas.client.Traverson;
import org.springframework.hateoas.client.Traverson.TraversalBuilder;
/**
* Callback to define the traversal to a resource.
*
* @author Oliver Gierke
*/
public interface TraversalDefinition {
/**
* @param traverson the Traverson instance to run the traversal on.
* @return
*/
TraversalBuilder buildTraversal(Traverson traverson);
}

View File

@@ -2,6 +2,7 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.cloud.client.CommonsClientAutoConfiguration,\
org.springframework.cloud.client.discovery.noop.NoopDiscoveryClientAutoConfiguration,\
org.springframework.cloud.client.hypermedia.CloudHypermediaAutoConfiguration,\
org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration,\
org.springframework.cloud.util.UtilAutoConfiguration

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2015-2016 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import org.junit.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.cloud.client.hypermedia.CloudHypermediaAutoConfiguration.CloudHypermediaProperties;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.client.Traverson;
import org.springframework.hateoas.client.Traverson.TraversalBuilder;
/**
* Integration tests for {@link CloudHypermediaAutoConfiguration}.
*
* @author Oliver Gierke
*/
public class CloudHypermediaAutoConfigurationIntegrationTests {
@Test
public void picksUpHypermediaProperties() {
try (ConfigurableApplicationContext context = getApplicationContext(ConfigWithRemoteResource.class)) {
CloudHypermediaProperties properties = context.getBean(CloudHypermediaProperties.class);
assertThat(properties.getRefresh().getInitialDelay(), is(50000));
assertThat(properties.getRefresh().getFixedDelay(), is(10000));
}
}
@Test
public void doesNotCreateCloudHypermediaPropertiesifNotActive() {
try (ConfigurableApplicationContext context = getApplicationContext(Config.class)) {
assertThat(context.getBeanNamesForType(CloudHypermediaProperties.class), is(arrayWithSize(0)));
}
}
@Test
public void doesNotRegisterResourceRefresherIfNoDiscoveredResourceIsDefined() {
try (ConfigurableApplicationContext context = getApplicationContext(Config.class)) {
assertThat(context.getBeansOfType(RemoteResource.class).values(), hasSize(0));
assertThat(context.getBeanNamesForType(RemoteResourceRefresher.class), is(arrayWithSize(0)));
}
}
@Test
public void registersResourceRefresherIfDiscoverredResourceIsDefined() {
try (ConfigurableApplicationContext context = getApplicationContext(ConfigWithRemoteResource.class)) {
assertThat(context.getBeansOfType(RemoteResource.class).values(), hasSize(1));
assertThat(context.getBean(RemoteResourceRefresher.class), is(notNullValue()));
}
}
private static ConfigurableApplicationContext getApplicationContext(Class<?> configuration) {
return SpringApplication.run(configuration, new String[0]);
}
@Configuration
@EnableAutoConfiguration
static class Config {}
@Configuration
@EnableAutoConfiguration
static class ConfigWithRemoteResource {
@Bean
public RemoteResource resource() {
ServiceInstanceProvider provider = new StaticServiceInstanceProvider(
new DefaultServiceInstance("service", "localhost", 80, false));
return new DiscoveredResource(provider, new TraversalDefinition() {
@Override
public TraversalBuilder buildTraversal(Traverson traverson) {
return traverson.follow("rel");
}
});
}
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static org.mockito.Matchers.*;
import static org.mockito.Mockito.*;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Matchers;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.client.Traverson;
import org.springframework.hateoas.client.Traverson.TraversalBuilder;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
/**
* @author Oliver Gierke
*/
@RunWith(MockitoJUnitRunner.class)
public class DiscoveredResourceUnitTests {
@Mock ServiceInstanceProvider provider;
@Mock TraversalDefinition traversal;
@Mock TraversalBuilder builder;
@Mock RestOperations operations;
DiscoveredResource resource;
@Before
public void setUp() {
when(traversal.buildTraversal(Matchers.any(Traverson.class))).thenReturn(builder);
this.resource = new DiscoveredResource(provider, traversal);
this.resource.setRestOperations(operations);
}
@Test
public void isUndiscoveredByDefault() {
assertThat(resource.getLink(), is(nullValue()));
}
@Test
public void verificationTriggersDiscovery() {
Link link = new Link("target", "rel");
when(provider.getServiceInstance()).thenReturn(new DefaultServiceInstance("service", "localhost", 8080, false));
when(builder.asTemplatedLink()).thenReturn(link);
resource.verifyOrDiscover();
assertThat(resource.getLink(), is(link));
verify(provider, times(1)).getServiceInstance();
verify(traversal, times(1)).buildTraversal(Matchers.any(Traverson.class));
}
@Test
public void triggersVerificationOnSubsequentCall() {
verificationTriggersDiscovery();
resource.verifyOrDiscover();
assertThat(resource.getLink(), is(notNullValue()));
verify(operations, times(1)).headForHeaders(anyString());
}
@Test
public void resetsLinkOnFailedVerification() {
verificationTriggersDiscovery();
doThrow(RestClientException.class).when(operations).headForHeaders(anyString());
resource.verifyOrDiscover();
assertThat(resource.getLink(), is(nullValue()));
}
@Test
public void failedDiscoveryTraversalCausesLinkToStayNull() {
doThrow(RuntimeException.class).when(provider).getServiceInstance();
resource.verifyOrDiscover();
assertThat(resource.getLink(), is(nullValue()));
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2015 the original author or authors.
*
* 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 org.springframework.cloud.client.hypermedia;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static org.mockito.Matchers.*;
import static org.mockito.Mockito.*;
import java.util.Arrays;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
/**
* @author Oliver Gierke
*/
@RunWith(MockitoJUnitRunner.class)
public class DynamicServiceInstanceProviderUnitTests {
@Mock DiscoveryClient client;
@Test
public void returnsNoServiceInCaseNoneIsAvailable() {
assertThat(new DynamicServiceInstanceProvider(client, "service").getServiceInstance(), is(nullValue()));
}
@Test
public void returnsFirstServiceInCaseMultipleOnesAreAvailable() {
ServiceInstance first = mock(ServiceInstance.class);
ServiceInstance second = mock(ServiceInstance.class);
when(client.getInstances(anyString())).thenReturn(Arrays.asList(first, second));
assertThat(new DynamicServiceInstanceProvider(client, "service").getServiceInstance(), is(first));
}
}

View File

@@ -2,4 +2,7 @@ message: Hello scope!
delay: 0
debug: true
#logging.level.org.springframework.web: DEBUG
#logging.level.org.springframework.context.annotation: DEBUG
#logging.level.org.springframework.context.annotation: DEBUG
spring.cloud.hypermedia.refresh.initial-delay=50000
spring.cloud.hypermedia.refresh.fixed-delay=10000