diff --git a/Readme.md b/Readme.md index d7eca91..29528df 100644 --- a/Readme.md +++ b/Readme.md @@ -345,8 +345,8 @@ public class SpringApplicationDemo { Finally, to have services available as injectable beans add the `@RestAheadService` annotation to the service: ```java - -@RestAheadService(url = "https://httpbin.org", converter = JacksonConverter.class) +// Instead of placeholder you can also use a hardcoded URL +@RestAheadService(url = "${placeholder.url}", converter = JacksonConverter.class) public interface DemoService { @Get("/get") Map performGet(); diff --git a/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinations.java b/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinations.java index 1bd7d46..cc3f6ef 100644 --- a/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinations.java +++ b/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinations.java @@ -44,4 +44,10 @@ interface AdapterService { @Get("/get") Supplier get(); } + + @RestAheadService(url = "${placeholder.url}") + interface PlaceholderService { + @Get("/get") + Response get(); + } } diff --git a/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/DummyClient.java b/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/DummyClient.java index 1f08453..4574be9 100644 --- a/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/DummyClient.java +++ b/demo/src/main/java/io/github/zskamljic/restahead/demo/spring/DummyClient.java @@ -12,8 +12,8 @@ import java.util.concurrent.CompletableFuture; public class DummyClient extends Client { - public static final List requests = new ArrayList<>(); - public final List interceptors = new ArrayList<>(); + private final List interceptors = new ArrayList<>(); + protected static final List requests = new ArrayList<>(); public List getInterceptors() { return interceptors; diff --git a/demo/src/main/resources/application.properties b/demo/src/main/resources/application.properties new file mode 100644 index 0000000..fdd8d6c --- /dev/null +++ b/demo/src/main/resources/application.properties @@ -0,0 +1 @@ +placeholder.url=https://httpbin.org \ No newline at end of file diff --git a/demo/src/test/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinationsTest.java b/demo/src/test/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinationsTest.java index 2f27aff..1c579a7 100644 --- a/demo/src/test/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinationsTest.java +++ b/demo/src/test/java/io/github/zskamljic/restahead/demo/spring/ConfigCombinationsTest.java @@ -8,6 +8,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest @@ -25,6 +26,9 @@ class ConfigCombinationsTest { @Autowired private ConfigCombinations.AdapterService adapterService; + @Autowired + private ConfigCombinations.PlaceholderService placeholderService; + @AfterEach void tearDown() { DummyClient.requests.clear(); @@ -37,7 +41,7 @@ void clientOnlyServiceHasClient() { assertFalse(requests.isEmpty()); var request = requests.get(0); - assertTrue(((DummyClient) request.client()).interceptors.isEmpty()); + assertTrue(((DummyClient) request.client()).getInterceptors().isEmpty()); } @Test @@ -47,7 +51,7 @@ void clientAndInterceptorService() { assertFalse(requests.isEmpty()); var request = requests.get(0); - assertTrue(((DummyClient) request.client()).interceptors.get(0) instanceof ConfigCombinations.PassThroughInterceptor); + assertTrue(((DummyClient) request.client()).getInterceptors().get(0) instanceof ConfigCombinations.PassThroughInterceptor); } @Test @@ -65,4 +69,11 @@ void adapterService() { assertTrue(requests.isEmpty()); } + + @Test + void placeholderService() { + var response = placeholderService.get(); + + assertNotNull(response); + } } \ No newline at end of file diff --git a/demo/src/test/java/io/github/zskamljic/restahead/demo/spring/PlaceholderServiceBeanTest.java b/demo/src/test/java/io/github/zskamljic/restahead/demo/spring/PlaceholderServiceBeanTest.java new file mode 100644 index 0000000..82f3a58 --- /dev/null +++ b/demo/src/test/java/io/github/zskamljic/restahead/demo/spring/PlaceholderServiceBeanTest.java @@ -0,0 +1,42 @@ +package io.github.zskamljic.restahead.demo.spring; + +import io.github.zskamljic.restahead.client.Client; +import io.github.zskamljic.restahead.client.responses.Response; +import io.github.zskamljic.restahead.conversion.Converter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; + +@SpringBootTest +@ExtendWith(SpringExtension.class) +class PlaceholderServiceBeanTest { + @MockBean + private Client mockClient; + + @MockBean + private Converter mockConverter; + + @Autowired + private ConfigCombinations.PlaceholderService placeholderService; + + @Test + void placeHolderServiceInjectsBeans() { + doReturn(CompletableFuture.completedFuture(new Response(200, Map.of(), InputStream.nullInputStream()))) + .when(mockClient).execute(any()); + + var response = placeholderService.get(); + + assertNotNull(response); + } +} diff --git a/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadRegistrar.java b/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadRegistrar.java index 0c05cff..0dbbc62 100644 --- a/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadRegistrar.java +++ b/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadRegistrar.java @@ -4,8 +4,10 @@ import io.github.zskamljic.restahead.client.Client; import io.github.zskamljic.restahead.conversion.Converter; import io.github.zskamljic.restahead.intercepting.Interceptor; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.config.BeanDefinitionHolder; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.beans.factory.support.BeanDefinitionReaderUtils; @@ -18,6 +20,7 @@ import org.springframework.core.io.ResourceLoader; import org.springframework.core.type.AnnotationMetadata; import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; @@ -84,7 +87,7 @@ private void registerService(AnnotatedBeanDefinition definition, BeanDefinitionR var clazz = ClassUtils.resolveClassName(className, null); var definitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(clazz, - () -> instantiateService(attributes, clazz) + () -> instantiateService(registry, attributes, clazz) ) .setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE) .setLazyInit(true); @@ -97,16 +100,18 @@ private void registerService(AnnotatedBeanDefinition definition, BeanDefinitionR /** * Instantiate a new service with appropriate url, converter etc. * + * @param the type to cast to (required since clazz has wildcard type + * @param registry the registry to which to add the new bean * @param attributes the attributes from which to get the data used during creation * @param clazz the service to instantiate - * @param the type to cast to (required since clazz has wildcard type * @return the instance of the service */ - private T instantiateService(Map attributes, Class clazz) { - var url = (String) attributes.get(BASE_URL); - var converter = getConverterIfPossible(attributes); - var client = getClientIfPossible(attributes); - var adapters = getAdaptersIfPossible(attributes); + private T instantiateService(BeanDefinitionRegistry registry, Map attributes, Class clazz) { + var url = environment.resolvePlaceholders((String) attributes.get(BASE_URL)); + var beanFactory = registry instanceof ConfigurableBeanFactory factory ? factory : null; + var converter = getConverterIfPossible(beanFactory, attributes); + var client = getClientIfPossible(beanFactory, attributes); + var adapters = getAdaptersIfPossible(beanFactory, attributes); var builder = RestAhead.builder(url); converter.ifPresent(builder::converter); client.ifPresent(builder::client); @@ -119,24 +124,32 @@ private T instantiateService(Map attributes, Class clazz) /** * Find a converter from attributes, or return empty optional if none was provided. * - * @param attributes the attributes from which to extract the converter + * @param beanFactory the bean factory to fetch bean instances from + * @param attributes the attributes from which to extract the converter * @return converter if a valid class was provided, empty if value was the default converter instance * @throws IllegalArgumentException if converter is not a valid subclass or if no valid constructors are present. */ - private Optional getConverterIfPossible(Map attributes) { + private Optional getConverterIfPossible( + @Nullable ConfigurableBeanFactory beanFactory, + Map attributes + ) { var value = attributes.get(CONVERTER); - return getInstanceForClass(value, CONVERTER, Converter.class); + return getInstanceForClass(beanFactory, value, CONVERTER, Converter.class); } /** * Get instance of client if specified. If interceptors are found they are added to client instance as well. * - * @param attributes the full set of attributes + * @param beanFactory the bean factory to fetch bean instances from + * @param attributes the full set of attributes * @return client if one could be found from config, empty otherwise */ - private Optional getClientIfPossible(Map attributes) { + private Optional getClientIfPossible( + @Nullable ConfigurableBeanFactory beanFactory, + Map attributes + ) { var value = attributes.get(CLIENT); - var client = getInstanceForClass(value, CLIENT, Client.class); + var client = getInstanceForClass(beanFactory, value, CLIENT, Client.class); if (client.isEmpty()) return Optional.empty(); var interceptorValues = attributes.get(INTERCEPTORS); @@ -144,7 +157,7 @@ private Optional getClientIfPossible(Map attributes) { throw new IllegalStateException(INTERCEPTORS + " must be an array of classes."); } var interceptors = Arrays.stream(interceptorClasses) - .flatMap(clazz -> getInstanceForClass(clazz, INTERCEPTORS, Interceptor.class).stream()) + .flatMap(clazz -> getInstanceForClass(beanFactory, clazz, INTERCEPTORS, Interceptor.class).stream()) .toList(); var clientInstance = client.get(); interceptors.forEach(clientInstance::addInterceptor); @@ -154,36 +167,50 @@ private Optional getClientIfPossible(Map attributes) { /** * Find and instantiate adapters from specified classes. * - * @param attributes the attributes from which to get the adapters + * @param beanFactory the bean factory to fetch bean instances from + * @param attributes the attributes from which to get the adapters * @return list of adapter instances */ - private List getAdaptersIfPossible(Map attributes) { + private List getAdaptersIfPossible( + @Nullable ConfigurableBeanFactory beanFactory, + Map attributes + ) { var values = attributes.get(ADAPTERS); if (!(values instanceof Class[] classes)) { throw new IllegalStateException(ADAPTERS + " must be an array of classes."); } return Arrays.stream(classes) - .flatMap(clazz -> getInstanceForClass(clazz, ADAPTERS, Object.class).stream()) + .flatMap(clazz -> getInstanceForClass(beanFactory, clazz, ADAPTERS, Object.class).stream()) .toList(); } /** * Create a new instance of provided class and cast accordingly. * + * @param beanFactory the bean factory to fetch existing bean from if possible * @param value the attribute value * @param name the name of the attribute * @param targetClass class that needs to be instantiated * @param the type to return * @return instance of type if possible, empty if class is not the default value */ - private Optional getInstanceForClass(Object value, String name, Class targetClass) { + private Optional getInstanceForClass( + @Nullable ConfigurableBeanFactory beanFactory, + Object value, + String name, + Class targetClass + ) { if (!(value instanceof Class candidateClass)) { throw new IllegalStateException(name + " must be a subclass of " + targetClass.getCanonicalName()); } + var selectedClass = candidateClass.asSubclass(targetClass); + var beanInstance = findBeanInstance(beanFactory, selectedClass); + if (beanInstance.isPresent()) { + return beanInstance.map(selectedClass::cast); + } if (targetClass.equals(candidateClass)) { return Optional.empty(); } - var selectedClass = candidateClass.asSubclass(targetClass); try { var constructor = selectedClass.getConstructor(); return Optional.of(constructor.newInstance()); @@ -194,6 +221,21 @@ private Optional getInstanceForClass(Object value, String name, Class } } + private Optional findBeanInstance( + @Nullable ConfigurableBeanFactory beanFactory, + Class selectedClass + ) { + if (beanFactory == null) { + return Optional.empty(); + } + + try { + return Optional.of(beanFactory.getBean(selectedClass)); + } catch (NoSuchBeanDefinitionException ignored) { + return Optional.empty(); + } + } + /** * Create a scanner that only finds independent interfaces from environment. * diff --git a/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadService.java b/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadService.java index 9507179..cc36200 100644 --- a/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadService.java +++ b/rest-ahead-spring/src/main/java/io/github/zskamljic/restahead/spring/RestAheadService.java @@ -26,23 +26,24 @@ String url(); /** - * Converter will be automatically instantiated. This requires the provided class to have a public, - * no-args constructor. + * Converter will be automatically instantiated. The class specified needs to be a bean or has to have + * a public no-args constructor. * * @return the Converter class to use with this service. Can be left empty if no converter is required. */ Class converter() default Converter.class; /** - * Client will be automatically instantiated. This requires the provided client to have a public, no-args - * constructor. + * Client will be automatically instantiated. The class specified needs to be a bean or has to have + * a public no-args constructor. * * @return the Client to use with this service. Can be left empty for default client. */ Class client() default Client.class; /** - * Interceptors will be automatically instantiated. This requires them to have a public, no-args constructor. + * Interceptors will be automatically instantiated. The class specified needs to be a bean or has to have + * a public no-args constructor. * This will only be honored if a client is provided. * * @return the Interceptor classes to use with the client. @@ -50,8 +51,8 @@ Class[] interceptors() default {}; /** - * Adapters will be automatically instantiated. This requires the provided classes to have a public, - * no-args constructor. + * Adapters will be automatically instantiated. The class specified needs to be a bean or has to have + * a public no-args constructor. * * @return the Adapter classes to use with this service. Can be left empty if no adapters are required. */