diff --git a/spring-aop/src/main/java/org/springframework/aop/interceptor/AdapterFinderInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/interceptor/AdapterFinderInterceptor.java new file mode 100644 index 000000000000..bb374d173320 --- /dev/null +++ b/spring-aop/src/main/java/org/springframework/aop/interceptor/AdapterFinderInterceptor.java @@ -0,0 +1,169 @@ +/* + * Copyright 2002-2024 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 + * + * https://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.aop.interceptor; + +import java.lang.reflect.InvocationTargetException; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.beans.factory.AdapterFinderBean; +/** + * A proxy interceptor for finding a concrete adapter implementation of an abstracted interface using a + * {@link AdapterFinderBean}. This is in the vein of + * {@link org.springframework.beans.factory.config.ServiceLocatorFactoryBean}, but also without the client code + * pollution of acquiring the service prior to calling the intended interceptor. The {@code AdapterFinderBean} uses + * the method and arguments to determine the appropriate concrete adapter to call. + * + *

By way of an example, consider the following adapter interface. + * Note that this interface is not dependent on any Spring APIs. + * + *

package a.b.c;
+ *
+ *public interface MyService {
+ *
+ * byte[] convert(IMAGE_TYPE to, IMAGE_TYPE from, byte[] source);
+ * enum IMAGE_TYPE {
+ *  GIF,
+ *  JPEG,
+ *  PNG
+ * }
+ *}
+ * + *

An {@link AdapterFinderBean}. + *

package a.b.c;
+ *
+ *public class MyServiceFinder implements AdapterFinderBean<MyService> {
+ *
+ * private final MyService gifService;
+ * private final MyService jpgService;
+ * private final MyService pngService;
+ *
+ * public MyServiceFinder(MyService gifService, MyService jpgService, MyService pngService) {
+ *   this.gifService = gifService;
+ *   this.jpgService = jpgService;
+ *   this.pngService = pngService;
+ * }
+ *
+ * @Nullable MyService findBean(Method method, Object[] args) {
+ *  IMAGE_TYPE type = (IMAGE_TYPE) args[0];
+ *  if (type == GIF) {
+ *    return gifService;
+ *  }
+ *
+ *  if (type == JPEG) {
+ *    return jpgService;
+ *  }
+ *
+ *  if (type == PNG) {
+ *    return pngService;
+ *  }
+ *
+ *  return null; // will throw an IllegalArgumentException!
+ * }
+ *}
+ * + *

A spring configuration file. + *

package a.b.c;
+ *
+ *@Configuration
+ *class MyServiceConfiguration {
+ *
+ * @Bean
+ * MyServiceFinder myServiceFinder(MyGifService gifService, MyJpegService jpgService, MyPngService pngService) {
+ *  return new MyServiceFinder(gifService, jpgService, pngService);
+ * }
+ *
+ * @Bean
+ * @Primary
+ * MyService myService(MyServiceFinder finder) {
+ *  return AdapterFinderInterceptor.proxyOf(finder, MyService.class);
+ * }
+ *}
+ * 
+ * + *

A client bean may look something like this: + * + *

package a.b.c;
+ *
+ *public class MyClientBean {
+ *
+ * private final MyService myService;
+ *
+ * public MyClientBean(MyService myService) {
+ *  this.myService = myService;
+ * }
+ *
+ * public void doSomeBusinessMethod(byte[] background, byte[] foreground, byte[] border) {
+ *  byte[] gifBackground = myService.convert(PNG, GIF, background);
+ *  byte[] gifForeground = myService.convert(PNG, GIF, foreground);
+ *  byte[] gifBorder = myService.convert(PNG, GIF, border);
+ *
+ *  // no do something with the gif stuff.
+ * }
+ *}
+ * + * @author Joe Chambers + * @param the service the interceptor proxy's. + */ +public final class AdapterFinderInterceptor implements MethodInterceptor { + + private final AdapterFinderBean finder; + + /** + * Constructor. + * @param finder the {@code AdapterFinder} to use for obtaining concrete instances + */ + private AdapterFinderInterceptor(AdapterFinderBean finder) { + this.finder = finder; + } + + /** + * The implementation of the {@link MethodInterceptor#invoke(MethodInvocation)} method called by the proxy. + * @param invocation the method invocation joinpoint + * @return the results of the concrete invocation call + * @throws Throwable if no adapter is found will throw {@link IllegalArgumentException} otherwise will re-throw what the concrete invocation throws + */ + @Override + public @Nullable Object invoke(@NonNull MethodInvocation invocation) throws Throwable { + T implementation = this.finder.findAdapter(invocation.getMethod(), invocation.getArguments()); + if (implementation == null) { + throw new IllegalArgumentException("Adapter not found: " + invocation.getMethod()); + } + + try { + return invocation.getMethod().invoke(implementation, invocation.getArguments()); + } + catch (InvocationTargetException ex) { + throw ex.getTargetException(); + } + } + + /** + * Create a proxy using an {@code AdapterFinderInterceptor}. + * @param finder the finder bean to create the proxy around. + * @param proxyClass the {@link Class} of the {@code Interface} being exposed. + * @param the type of the interface the proxy exposes. + * @return a {@code Proxy} that uses the finder to determine which adapter to direct on a call by call basis. + */ + public static T proxyOf(AdapterFinderBean finder, Class proxyClass) { + return ProxyFactory.getProxy(proxyClass, new AdapterFinderInterceptor<>(finder)); + } +} diff --git a/spring-aop/src/test/java/org/springframework/aop/interceptor/AdapterFinderInterceptorTests.java b/spring-aop/src/test/java/org/springframework/aop/interceptor/AdapterFinderInterceptorTests.java new file mode 100644 index 000000000000..24e443e1cd92 --- /dev/null +++ b/spring-aop/src/test/java/org/springframework/aop/interceptor/AdapterFinderInterceptorTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2024 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 + * + * https://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.aop.interceptor; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.beans.factory.AdapterFinderBean; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Tests for class {@link AdapterFinderInterceptor} + * + * @author Joe Chambers + */ +@ExtendWith(MockitoExtension.class) +class AdapterFinderInterceptorTests { + + @Mock + EvenOddService evenService; + + @Mock + EvenOddService oddService; + + @Spy + EvenOddServiceFinder evenOddFinder = new EvenOddServiceFinder(); + + Method expectedMethod; + + EvenOddService evenOddService; + + @BeforeEach + void setup() throws Exception { + evenOddService = AdapterFinderInterceptor.proxyOf(evenOddFinder, EvenOddService.class); + expectedMethod = EvenOddService.class.getMethod("toMessage", int.class, String.class); + } + + @Test + void callEvenService() { + String expectedMessage = "4 even message"; + + given(evenService.toMessage(eq(4), eq("message"))).willReturn(expectedMessage); + + String actualMessage = evenOddService.toMessage(4, "message"); + + assertThat(actualMessage) + .isEqualTo(expectedMessage); + + verify(evenService).toMessage(eq(4), eq("message")); + verify(oddService, never()).toMessage(anyInt(), anyString()); + verify(evenOddFinder).findAdapter(eq(expectedMethod), eq(new Object[] { 4, "message" })); + } + + @Test + void callOddService() { + String expectedMessage = "5 odd message"; + + given(oddService.toMessage(eq(5), eq("message"))) + .willReturn(expectedMessage); + + String actualMessage = evenOddService.toMessage(5, "message"); + + assertThat(actualMessage) + .isEqualTo(expectedMessage); + + verify(oddService).toMessage(eq(5), eq("message")); + verify(evenService, never()).toMessage(anyInt(), anyString()); + verify(evenOddFinder).findAdapter(eq(expectedMethod), eq(new Object[] { 5, "message" })); + } + + @Test + void throwExceptionWhenNumberIsZero() { + String expectedMessage = "Adapter not found: public abstract java.lang.String org.springframework.aop.interceptor.AdapterFinderInterceptorTests$EvenOddService.toMessage(int,java.lang.String)"; + + assertThatThrownBy(() -> evenOddService.toMessage(0, "message")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage) + .hasNoCause(); + + verify(evenService, never()).toMessage(anyInt(), anyString()); + verify(oddService, never()).toMessage(anyInt(), anyString()); + verify(evenOddFinder).findAdapter(eq(expectedMethod), eq(new Object[] { 0, "message" })); + } + + protected interface EvenOddService { + String toMessage(int number, String message); + } + + protected class EvenOddServiceFinder implements AdapterFinderBean { + @Override + @Nullable + public EvenOddService findAdapter(Method method, @Nullable Object[] args) { + if (method.getParameterCount() > 0 && method.getParameterTypes()[0] == int.class && args[0] != null) { + int number = (int) args[0]; + if (number != 0) { + return ((number % 2 == 0) ? evenService : oddService); + } + } + return null; // method not found, or 0. + } + } +} diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/AdapterFinderBean.java b/spring-beans/src/main/java/org/springframework/beans/factory/AdapterFinderBean.java new file mode 100644 index 000000000000..a4e34736eae7 --- /dev/null +++ b/spring-beans/src/main/java/org/springframework/beans/factory/AdapterFinderBean.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 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 + * + * https://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.beans.factory; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +/** + * A {@code bean} to locate an {@code Adapter} based on the method called and + * the parameters passed in. + * + *

For use when multiple implementations of an {@code interface} (Adapters) exist + * to handle functionality in various ways such as sending and monitoring shipments + * from different providers. The {@link AdapterFinderBean} can look at the method + * parameters and determine which shipping provider {@code Adapter} to use. + * + *

If the {@code AdapterFinderBean} cannot find an implementation appropriate for + * the parameters, then it will return {@code null}. + * + * @author Joe Chambers + * @param the service type the finder returns + */ +public interface AdapterFinderBean { + + /** + * Lookup the adapter appropriate for the {@link Method} and {@code Arguments} + * passed to the implementation. + * @param method the {@link Method} being called + * @param args the {@code Arguments} being passed to the invocation + * @return the implementation of the {@code Adapter} that is appropriate or {@code null} + */ + @Nullable + T findAdapter(Method method, @Nullable Object[] args); +}