Automating HTTP requests in the context of Spring

Background



A few months ago, the task was to write an HTTP API for working with a company product, namely to wrap all requests using RestTemplate and then intercepting information from the application and modifying the response. An approximate implementation of the service for working with the application was as follows:







if (headers == null) { headers = new HttpHeaders(); } if (headers.getFirst("Content-Type") == null) { headers.add("Content-Type", MediaType.APPLICATION_JSON_VALUE); } HttpEntity<Object> entity; if (body == null) { entity = new HttpEntity<>(headers); } else { entity = new HttpEntity<>(body, headers); } final String uri = String.format("%s%s/%s", workingUrl, apiPath, request.info()); final Class<O> type = (Class<O>) request.type(); final O response = (O)restTemplate.exchange(uri, request.method(), entity, type);
      
      





... a simple method that accepts the type, body, and request headers. And everything would be fine, but it looked like a crutch and not very usable in the context of Spring.







And while fellow colleagues wrote functional in their branches on the old mechanism, I had the most ingenious idea - why not write these requests β€œin one line” (like Feign).







Idea



We have a powerful Spring DI container in our hands, so why not use its functionality to the full? In particular, initializing Data repositories using the Jpa example. I was faced with the task of initializing a class of an interface type in the context of Spring and three solutions for intercepting a method call as a typical implementation - Aspect, PostProcess and BeanDefinitionRegistrar.







Code base



First of all, annotations, where without them, otherwise how to configure queries.







1) Mapping - annotation identifying the interface as a component of HTTP calls.







 @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Mapping { /** * Registered service application name, need for config */ String alias(); }
      
      





The alias parameter is responsible for assigning the root routing of the service, be it https://habr.com , https://github.com , etc.







2) ServiceMapping - an annotation that identifies an interface method that should be called as a standard HTTP request to the application, from where we want to get a response or perform some action.







 @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) @Documented public @interface ServiceMapping { /** * Registered service application route */ String path(); /** * Registered service application route http-method */ HttpMethod method(); Header[] defaultHeaders() default {}; Class<?> fallbackClass() default Object.class; String fallbackMethod() default ""; }
      
      





Options:









3) Header - annotation identifying static headers in request settings







 @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.ANNOTATION_TYPE}) @Documented public @interface Header { String name(); String value(); }
      
      





Options:









The next step is to implement your FactoryBean to intercept the invocation of interface methods.







MappingFactoryBean.java
 package org.restclient.factory; import lombok.ToString; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ArrayUtils; import org.restclient.annotations.RestInterceptor; import org.restclient.annotations.ServiceMapping; import org.restclient.annotations.Type; import org.restclient.config.ServicesConfiguration; import org.restclient.config.ServicesConfiguration.RouteSettings; import org.restclient.interceptor.Interceptor; import org.restclient.model.MappingMetadata; import org.restclient.model.Pair; import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.beans.factory.FactoryBean; import org.springframework.cglib.proxy.Enhancer; import org.springframework.cglib.proxy.MethodInterceptor; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.jmx.access.InvocationFailureException; import org.springframework.lang.NonNull; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.client.RestClientResponseException; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient.ResponseSpec; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import javax.naming.ConfigurationException; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.*; /** * @author: GenCloud * @created: 2019/08 */ @Slf4j @ToString public class MappingFactoryBean implements BeanFactoryAware, FactoryBean<Object>, ApplicationContextAware { private static final Collection<String> ignoredMethods = Arrays.asList("equals", "hashCode", "toString"); private Class<?> type; private List<Object> fallbackInstances; private List<MappingMetadata> metadatas; private String alias; private ApplicationContext applicationContext; private BeanFactory beanFactory; @Override public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; } @Override public Class<?> getObjectType() { return type; } @Override public boolean isSingleton() { return true; } @Override public Object getObject() { return Enhancer.create(type, (MethodInterceptor) (instance, method, args, methodProxy) -> { final boolean skip = ignoredMethods.stream().anyMatch(ignore -> method.getName().equals(ignore)); final ServiceMapping annotation = method.getAnnotation(ServiceMapping.class); if (!skip && annotation != null) { return invokeMethod(annotation, method, args); } return null; }); } /** * It determines the meta-information of the executing method, calling an HTTP request based on the * meta-information found; interceptors are also called. * * @param annotation - main annotation that defines the path, type, standard request parameters. * @param method - callable method * @param args - method arguments * @return if the request is executed without errors, returns a clean server response in wrappers Mono/Flux. * @throws Throwable */ private Object invokeMethod(ServiceMapping annotation, Method method, Object[] args) throws Throwable { final MappingMetadata metadata = findMetadataByMethodName(method.getName()); if (metadata == null) { throw new NoSuchMethodException(String.format("Cant find metadata for method %s. Check your mapping configuration!", method.getName())); } final RouteSettings routeSettings = findSettingsByAlias(alias); final String host = routeSettings.getHost(); String url = metadata.getUrl().replace(String.format("${%s}", alias), host); final HttpMethod httpMethod = metadata.getHttpMethod(); final HttpHeaders httpHeaders = metadata.getHttpHeaders(); final List<Pair<String, Object>> foundVars = new ArrayList<>(); final List<Pair<String, Object>> foundParams = new ArrayList<>(); final List<Pair<String, Object>> foundHeaders = new ArrayList<>(); final Parameter[] parameters = method.getParameters(); final Object body = initHttpVariables(args, parameters, foundVars, foundParams, foundHeaders); url = replaceHttpVariables(url, foundVars, foundParams, foundHeaders, httpHeaders); preHandle(args, body, httpHeaders); if (log.isDebugEnabled()) { log.debug("Execute Service Mapping request"); log.debug("Url: {}", url); log.debug("Headers: {}", httpHeaders); if (body != null) { log.debug("Body: {}", body); } } final Object call = handleHttpCall(annotation, args, url, httpMethod, body, httpHeaders, metadata); postHandle(ResponseEntity.ok(call)); return call; } private Object handleHttpCall(ServiceMapping annotation, Object[] args, String url, HttpMethod httpMethod, Object body, HttpHeaders httpHeaders, MappingMetadata metadata) throws Throwable { final WebClient webClient = WebClient.create(url); ResponseSpec responseSpec; final Class<?> returnType = metadata.getReturnType(); try { if (body != null) { responseSpec = webClient .method(httpMethod) .headers(c -> c.addAll(httpHeaders)) .body(BodyInserters.fromPublisher(Mono.just(body), Object.class)) .retrieve(); } else { responseSpec = webClient .method(httpMethod) .headers(c -> c.addAll(httpHeaders)) .retrieve(); } } catch (RestClientResponseException ex) { if (log.isDebugEnabled()) { log.debug("Error on execute route request - Code: {}, Error: {}, Route: {}", ex.getRawStatusCode(), ex.getResponseBodyAsString(), url); } final String fallbackMethod = metadata.getFallbackMethod(); final Object target = fallbackInstances.stream() .filter(o -> o.getClass().getSimpleName().equals(annotation.fallbackClass().getSimpleName())) .findFirst().orElse(null); Method fallback = null; if (target != null) { fallback = Arrays.stream(target.getClass().getMethods()) .filter(m -> m.getName().equals(fallbackMethod)) .findFirst() .orElse(null); } if (fallback != null) { args = Arrays.copyOf(args, args.length + 1); args[args.length - 1] = ex; final Object result = fallback.invoke(target, args); return Mono.just(result); } else if (returnType == Mono.class) { return Mono.just(ResponseEntity.status(ex.getRawStatusCode()).body(ex.getResponseBodyAsString())); } else if (returnType == Flux.class) { return Flux.just(ResponseEntity.status(ex.getRawStatusCode()).body(ex.getResponseBodyAsString())); } else { return Mono.empty(); } } final Method method = metadata.getMethod(); final Type classType = method.getDeclaredAnnotation(Type.class); final Class<?> type = classType == null ? Object.class : classType.type(); if (returnType == Mono.class) { return responseSpec.bodyToMono(type); } else if (returnType == Flux.class) { return responseSpec.bodyToFlux(type); } return null; } private String replaceHttpVariables(String url, final List<Pair<String, Object>> foundVars, final List<Pair<String, Object>> foundParams, final List<Pair<String, Object>> foundHeaders, final HttpHeaders httpHeaders) { for (Pair<String, Object> pair : foundVars) { url = url.replace(String.format("${%s}", pair.getKey()), String.valueOf(pair.getValue())); } for (Pair<String, Object> pair : foundParams) { url = url.replace(String.format("${%s}", pair.getKey()), String.valueOf(pair.getValue())); } foundHeaders.forEach(pair -> { final String headerName = pair.getKey(); if (httpHeaders.getFirst(headerName) != null) { httpHeaders.set(headerName, String.valueOf(pair.getValue())); } else { log.warn("Undefined request header name '{}'! Check mapping configuration!", headerName); } }); return url; } private Object initHttpVariables(final Object[] args, final Parameter[] parameters, final List<Pair<String, Object>> foundVars, final List<Pair<String, Object>> foundParams, final List<Pair<String, Object>> foundHeaders) { Object body = null; for (int i = 0; i < parameters.length; i++) { final Object value = args[i]; final Parameter parameter = parameters[i]; final PathVariable pv = parameter.getDeclaredAnnotation(PathVariable.class); final RequestParam rp = parameter.getDeclaredAnnotation(RequestParam.class); final RequestHeader rh = parameter.getDeclaredAnnotation(RequestHeader.class); final RequestBody rb = parameter.getDeclaredAnnotation(RequestBody.class); if (rb != null) { body = value; } if (rh != null) { foundHeaders.add(new Pair<>(rh.value(), value)); } if (pv != null) { final String name = pv.value(); foundVars.add(new Pair<>(name, value)); } if (rp != null) { final String name = rp.value(); foundParams.add(new Pair<>(name, value)); } } return body; } private void preHandle(Object[] args, Object body, HttpHeaders httpHeaders) { final Map<String, Interceptor> beansOfType = applicationContext.getBeansOfType(Interceptor.class); beansOfType.values() .stream() .filter(i -> i.getClass().isAnnotationPresent(RestInterceptor.class) && ArrayUtils.contains(i.getClass().getDeclaredAnnotation(RestInterceptor.class).aliases(), alias)) .forEach(i -> i.preHandle(args, body, httpHeaders)); } private void postHandle(ResponseEntity<?> responseEntity) { final Map<String, Interceptor> beansOfType = applicationContext.getBeansOfType(Interceptor.class); beansOfType.values() .stream() .filter(i -> i.getClass().isAnnotationPresent(RestInterceptor.class) && ArrayUtils.contains(i.getClass().getDeclaredAnnotation(RestInterceptor.class).aliases(), alias)) .forEach(i -> i.postHandle(responseEntity)); } private MappingMetadata findMetadataByMethodName(String methodName) { return metadatas .stream() .filter(m -> m.getMethodName().equals(methodName)).findFirst() .orElseThrow(() -> new InvocationFailureException("")); } private RouteSettings findSettingsByAlias(String alias) throws ConfigurationException { final ServicesConfiguration servicesConfiguration = applicationContext.getAutowireCapableBeanFactory().getBean(ServicesConfiguration.class); return servicesConfiguration.getRoutes() .stream() .filter(r -> r.getAlias().equals(alias)) .findFirst() .orElseThrow(() -> new ConfigurationException(String.format("Cant find service host! Check configuration. Alias: %s", alias))); } @SuppressWarnings("unused") public Class<?> getType() { return type; } @SuppressWarnings("unused") public void setType(Class<?> type) { this.type = type; } @SuppressWarnings("unused") public List<MappingMetadata> getMetadatas() { return metadatas; } @SuppressWarnings("unused") public void setMetadatas(List<MappingMetadata> metadatas) { this.metadatas = metadatas; } @SuppressWarnings("unused") public String getAlias() { return alias; } @SuppressWarnings("unused") public void setAlias(String alias) { this.alias = alias; } @SuppressWarnings("unused") public List<Object> getFallbackInstances() { return fallbackInstances; } @SuppressWarnings("unused") public void setFallbackInstances(List<Object> fallbackInstances) { this.fallbackInstances = fallbackInstances; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MappingFactoryBean that = (MappingFactoryBean) o; return Objects.equals(type, that.type); } @Override public int hashCode() { return Objects.hash(type); } }
      
      





I’ll briefly explain what this implementation of the bin object does:









The third step is to implement the low-level DI component of the Spring container, and specifically the ImportBeanDefinitionRegistrar interface.







ServiceMappingRegistrator.java
 package org.restclient.factory; import lombok.extern.slf4j.Slf4j; import org.restclient.annotations.Header; import org.restclient.annotations.Mapping; import org.restclient.annotations.ServiceMapping; import org.restclient.model.MappingMetadata; import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.config.BeanDefinitionHolder; import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.beans.factory.support.BeanDefinitionReaderUtils; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.EnvironmentAware; import org.springframework.context.ResourceLoaderAware; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; import org.springframework.core.env.Environment; import org.springframework.core.io.ResourceLoader; import org.springframework.core.type.AnnotationMetadata; import org.springframework.core.type.ClassMetadata; import org.springframework.core.type.filter.AnnotationTypeFilter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.lang.NonNull; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import javax.naming.ConfigurationException; import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.*; import java.util.stream.Collectors; /** * @author: GenCloud * @created: 2019/08 */ @Slf4j public class ServiceMappingRegistrator implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware { private ResourceLoader resourceLoader; private Environment environment; @Override public void setEnvironment(@NonNull Environment environment) { this.environment = environment; } @Override public void setResourceLoader(@NonNull ResourceLoader resourceLoader) { this.resourceLoader = resourceLoader; } @Override public void registerBeanDefinitions(@NonNull AnnotationMetadata metadata, @NonNull BeanDefinitionRegistry registry) { registerMappings(metadata, registry); } private void registerMappings(AnnotationMetadata metadata, BeanDefinitionRegistry registry) { final ClassPathScanningCandidateComponentProvider scanner = getScanner(); scanner.setResourceLoader(resourceLoader); final Set<String> basePackages = getBasePackages(metadata); final AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(Mapping.class); scanner.addIncludeFilter(annotationTypeFilter); basePackages .stream() .map(scanner::findCandidateComponents) .flatMap(Collection::stream) .filter(candidateComponent -> candidateComponent instanceof AnnotatedBeanDefinition) .map(candidateComponent -> (AnnotatedBeanDefinition) candidateComponent) .map(AnnotatedBeanDefinition::getMetadata) .map(ClassMetadata::getClassName) .forEach(className -> buildGateway(className, registry)); } private void buildGateway(String className, BeanDefinitionRegistry registry) { try { final Class<?> type = Class.forName(className); final List<Method> methods = Arrays .stream(type.getMethods()) .filter(method -> method.isAnnotationPresent(ServiceMapping.class)) .collect(Collectors.toList()); final String alias = type.getDeclaredAnnotation(Mapping.class).alias(); final List<MappingMetadata> metadatas = new ArrayList<>(); final List<Object> fallbackInstances = new ArrayList<>(); for (Method method : methods) { final ServiceMapping serviceMapping = method.getDeclaredAnnotation(ServiceMapping.class); final Class<?>[] args = method.getParameterTypes(); final Header[] defaultHeaders = serviceMapping.defaultHeaders(); final String path = serviceMapping.path(); final HttpMethod httpMethod = serviceMapping.method(); final HttpHeaders httpHeaders = new HttpHeaders(); final StringBuilder url = new StringBuilder(); url.append("${").append(alias).append("}").append(path); final Parameter[] parameters = method.getParameters(); for (int i = 0; i < parameters.length; i++) { final Parameter parameter = parameters[i]; for (Annotation annotation : parameter.getAnnotations()) { if (!checkValidParams(annotation, args)) { break; } if (annotation instanceof RequestParam) { final String argName = ((RequestParam) annotation).value(); if (argName.isEmpty()) { throw new ConfigurationException("Configuration error: defined RequestParam annotation dont have value! Api method: " + method.getName() + ", Api Class: " + type); } final String toString = url.toString(); if (toString.endsWith("&") && i + 1 == args.length) { url.append(argName).append("=").append("${").append(argName).append("}"); } else if (!toString.endsWith("&") && i + 1 == args.length) { url.append("?").append(argName).append("=").append("${").append(argName).append("}"); } else if (!toString.endsWith("&")) { url.append("?").append(argName).append("=").append("${").append(argName).append("}").append("&"); } else { url.append(argName).append("=").append("${").append(argName).append("}").append("&"); } } else if (annotation instanceof PathVariable) { final String argName = ((PathVariable) annotation).value(); if (argName.isEmpty()) { throw new ConfigurationException("Configuration error: defined PathVariable annotation dont have value! Api method: " + method.getName() + ", Api Class: " + type); } final String toString = url.toString(); final String argStr = String.format("${%s}", argName); if (!toString.contains(argStr)) { if (toString.endsWith("/")) { url.append(argStr); } else { url.append("/").append(argStr); } } } else if (annotation instanceof RequestHeader) { final String argName = ((RequestHeader) annotation).value(); if (argName.isEmpty()) { throw new ConfigurationException("Configuration error: defined RequestHeader annotation dont have value! Api method: " + method.getName() + ", Api Class: " + type); } httpHeaders.add(argName, String.format("${%s}", argName)); } } } if (defaultHeaders.length > 0) { Arrays.stream(defaultHeaders) .forEach(header -> httpHeaders.add(header.name(), header.value())); } final Object instance = serviceMapping.fallbackClass().newInstance(); fallbackInstances.add(instance); final String fallbackName = serviceMapping.fallbackMethod(); final String buildedUrl = url.toString(); final MappingMetadata mappingMetadata = new MappingMetadata(method, httpMethod, buildedUrl, httpHeaders, fallbackName); metadatas.add(mappingMetadata); log.info("Bind api path - alias: {}, url: {}", alias, buildedUrl); } final BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(MappingFactoryBean.class); beanDefinitionBuilder.addPropertyValue("type", className); beanDefinitionBuilder.addPropertyValue("alias", alias); beanDefinitionBuilder.addPropertyValue("metadatas", metadatas); beanDefinitionBuilder.addPropertyValue("fallbackInstances", fallbackInstances); final AbstractBeanDefinition beanDefinition = beanDefinitionBuilder.getBeanDefinition(); final BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, new String[]{type.getSimpleName()}); BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry); } catch (IllegalAccessException | InstantiationException | ClassNotFoundException | ConfigurationException e) { e.printStackTrace(); } } private boolean checkValidParams(Annotation annotation, Object[] args) { Arrays .stream(args) .map(Object::getClass) .forEach(type -> { if (annotation instanceof RequestParam) { if (type.isAnnotationPresent(PathVariable.class)) { throw new IllegalArgumentException("Annotation RequestParam cannot be used with PathVariable"); } } else if (annotation instanceof PathVariable) { if (type.isAnnotationPresent(RequestParam.class)) { throw new IllegalArgumentException("Annotation PathVariable cannot be used with RequestParam"); } } }); return true; } private Set<String> getBasePackages(AnnotationMetadata importingClassMetadata) { Map<String, Object> attributes = importingClassMetadata.getAnnotationAttributes(SpringBootApplication.class.getCanonicalName()); if (attributes == null) { attributes = importingClassMetadata.getAnnotationAttributes(ComponentScan.class.getCanonicalName()); } Set<String> basePackages = new HashSet<>(); if (attributes != null) { basePackages = Arrays.stream((String[]) attributes.get("scanBasePackages")).filter(StringUtils::hasText).collect(Collectors.toSet()); Arrays.stream((Class[]) attributes.get("scanBasePackageClasses")).map(ClassUtils::getPackageName).forEach(basePackages::add); } if (basePackages.isEmpty()) { basePackages.add(ClassUtils.getPackageName(importingClassMetadata.getClassName())); } return basePackages; } private ClassPathScanningCandidateComponentProvider getScanner() { return new ClassPathScanningCandidateComponentProvider(false, environment) { @Override protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { boolean isCandidate = false; if (beanDefinition.getMetadata().isIndependent()) { if (!beanDefinition.getMetadata().isAnnotation()) { isCandidate = true; } } return isCandidate; } }; } }
      
      





Those. what happens at the beginning of the application launch - when the Spring REFRESH context event is triggered, all importBeanDefinitionRegistrar interface implementations are imported that are imported into the application context, and the registerBeanDefinitions method is called, which receives information about annotated configuration classes and the factory-registrar / store of beans (components, services, repositories, etc.), and right in this method you can get information about the basic application packages and "which way to dig" to search for our interfaces and their initialization with the power of BeanDefinitionBulder and our implementation of MappingFactoryBean. To import the registrar, it is enough to use the Import annotation with the name of this class (in the current implementation of the module, the RestClientAutoConfiguration configuration class is used, where the necessary annotations are written for the module to work).







How to use



Case - we want to get a list of information from a certain GitHub user repository.







1) Writing the configuration for working with the service (application.yml)







 services: routes: - host: https://api.github.com #    GitHub alias: github-service #  ,    Mapping
      
      





1) Implementation of the interface for interacting with the service







 @Mapping(alias = "github-service") //      public interface RestGateway { /** *       . * * @param userName -  GitHub * @return   LinkedHashMap */ @ServiceMapping(path = "/users/${userName}/repos", method = GET) @Type(type = ArrayList.class) //        Mono/Flux Mono<ArrayList> getRepos(@PathVariable("userName") String userName); }
      
      





2) Service call







 @SprinBootApplication public class RestApp { public static void main(String... args) { final ConfigurableApplicationContext context = SpringApplication.run(RestApp.class, args); final RestGateway restGateway = context.getType(RestGateway.class); final Mono<ArrayList> response = restGateway.getRepos("gencloud"); response.doOnSuccess(list -> log.info("Received response: {}", list)).subscribe(); } }
      
      





As a result of execution in the debug, you can see this (for convenience, you can put an object wrapper for the resulting json response for a place of type ArrayList; the code is different because it used a unit test in a compartment with a reactor-test library, but the principle has not changed):







image







Conclusion



Not everyone of course likes this approach, a complicated debug, poked annotation in the wrong place - received a slap from ConfigurationException, write some configs, oh ...







I will accept constructive wishes and suggestions for developing the API, I hope that the article was useful for reading. Thanks for attention.







All code is available here.








All Articles