Spring Boot 3: Auto-Convert URL JSON to Objects with @ConvertJson Annotation
This tutorial demonstrates how to create a custom @ConvertJson annotation in Spring Boot 3.5.0 that automatically fetches JSON from a URL parameter and converts it into a typed Java object, using HandlerMethodArgumentResolver and BeanPostProcessor for both method-parameter and field-level injection with SpEL support.
1. Introduction
In real-world development, a common scenario arises: an endpoint receives only a URL as a parameter, but the actual data needed resides in the JSON response from that URL. The traditional approach mixes HTTP requests, JSON parsing, and exception handling directly inside controller methods, leading to repetitive boilerplate code.
Spring MVC provides powerful parameter resolution extension points. By implementing a custom HandlerMethodArgumentResolver, this conversion logic can be moved to the framework layer. A simple @ConvertJson annotation then allows controller method parameters to directly receive the deserialized Java object, hiding the underlying HTTP and JSON details and keeping interface code clean and aligned with Spring's design philosophy.
2. Practical Example
2.1 Environment Setup
Environment: Spring Boot 3.5.0
A simple User POJO and a test controller that returns User objects are defined:
public class User {</code><code> private Long id;</code><code> private String name;</code><code> private Integer age;</code><code> private String email;</code><code>}</code><code></code><code>@RestController</code><code>@RequestMapping("/api")</code><code>public class ApiController {</code><code> @GetMapping("/query")</code><code> public ResponseEntity<User> query() {</code><code> return ResponseEntity.ok(new User(1L, "Pack_xg", 33, "[email protected]"));</code><code> }</code><code>}2.2 Traditional ObjectMapper Approach
Before the custom annotation, developers manually use ObjectMapper inside the controller:
@RestController</code><code>@RequestMapping("/convert")</code><code>public class ConvertController {</code><code> private final ObjectMapper objectMapper;</code><code> public ConvertController(ObjectMapper objectMapper) {</code><code> this.objectMapper = objectMapper;</code><code> }</code><code></code><code> @GetMapping("/1")</code><code> public ResponseEntity<Map<String, Object>> convert2() throws Exception {</code><code> URL url = URI.create("http://localhost:8080/api/query").toURL();</code><code> TypeReference<Map<String, Object>> typeReference = new TypeReference<Map<String, Object>>() {};</code><code> return ResponseEntity.ok(this.objectMapper.readValue(url, typeReference));</code><code> }</code><code>}For more flexible parsing, JsonNode can be used via mapper.readTree(url).
2.3 Custom Argument Resolver
To enable automatic parsing based on a URL parameter, a custom argument resolver is implemented.
Define the Annotation
@Retention(RetentionPolicy.RUNTIME)</code><code>@Target({ElementType.PARAMETER, ElementType.FIELD})</code><code>public @interface ConvertJson {</code><code> /** Parameter name; supports SpEL expressions */</code><code> String value() default "";</code><code>}Implement HandlerMethodArgumentResolver
@Component</code><code>public class URLConvertObjectArgumentResolver implements HandlerMethodArgumentResolver, BeanFactoryAware {</code><code> private ConfigurableBeanFactory beanFactory;</code><code> private BeanExpressionContext expressionContext;</code><code> private final ObjectMapper objectMapper;</code><code> private final HttpServletRequest request;</code><code></code><code> public URLConvertObjectArgumentResolver(ObjectMapper objectMapper, HttpServletRequest request) {</code><code> this.objectMapper = objectMapper;</code><code> this.request = request;</code><code> }</code><code></code><code> @Override</code><code> public boolean supportsParameter(MethodParameter parameter) {</code><code> return parameter.hasParameterAnnotation(ConvertJson.class);</code><code> }</code><code></code><code> @Override</code><code> public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,</code><code> NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {</code><code> ConvertJson annotation = parameter.getParameterAnnotation(ConvertJson.class);</code><code> String name = annotation.value();</code><code> if (name.isEmpty()) {</code><code> name = parameter.getParameterName();</code><code> }</code><code> if (name == null) {</code><code> throw new IllegalArgumentException(</code><code> "Parameter of type [%s] has no name specified and cannot be obtained via reflection. Ensure compiler uses '-parameters' flag.".formatted(parameter.getNestedParameterType().getName()));</code><code> }</code><code></code><code> Object resolvedName = resolveEmbeddedValuesAndExpressions(name);</code><code> if (resolvedName == null) {</code><code> throw new IllegalArgumentException("Parameter name cannot be empty: [" + name + "]");</code><code> }</code><code></code><code> String url = this.request.getParameter(name);</code><code> if (!isValidUrl(url)) {</code><code> throw new IllegalArgumentException("Invalid URL: [" + url + "]");</code><code> }</code><code></code><code> Class<?> parameterType = parameter.getNestedParameterType();</code><code> return this.objectMapper.readValue(URI.create(url).toURL(), parameterType);</code><code> }</code><code></code><code> private boolean isValidUrl(String urlString) {</code><code> if (urlString == null || urlString.isEmpty()) {</code><code> return false;</code><code> }</code><code> try {</code><code> URI.create(urlString).toURL();</code><code> return true;</code><code> } catch (MalformedURLException e) {</code><code> return false;</code><code> }</code><code> }</code><code></code><code> private Object resolveEmbeddedValuesAndExpressions(String value) {</code><code> if (this.beanFactory == null || this.expressionContext == null) {</code><code> return value;</code><code> }</code><code> String placeholdersResolved = this.beanFactory.resolveEmbeddedValue(value);</code><code> BeanExpressionResolver exprResolver = this.beanFactory.getBeanExpressionResolver();</code><code> if (exprResolver == null) {</code><code> return value;</code><code> }</code><code> return exprResolver.evaluate(placeholdersResolved, this.expressionContext);</code><code> }</code><code></code><code> @Override</code><code> public void setBeanFactory(BeanFactory beanFactory) throws BeansException {</code><code> if (beanFactory instanceof ConfigurableBeanFactory cbf) {</code><code> this.beanFactory = cbf;</code><code> this.expressionContext = new BeanExpressionContext(cbf, new RequestScope());</code><code> }</code><code> }</code><code>}Register the Resolver
@Configuration</code><code>public class WebConfig implements WebMvcConfigurer {</code><code> private final URLConvertObjectArgumentResolver argumentResolver;</code><code> public WebConfig(URLConvertObjectArgumentResolver argumentResolver) {</code><code> this.argumentResolver = argumentResolver;</code><code> }</code><code> @Override</code><code> public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {</code><code> resolvers.add(this.argumentResolver);</code><code> }</code><code>}Test the Annotation on Method Parameter
@GetMapping("/3")</code><code>public ResponseEntity<User> convert3(@ConvertJson User user) throws Exception {</code><code> return ResponseEntity.ok(user);</code><code>}The test passes a URL query parameter (e.g., ?url=http://localhost:8080/api/query) and the resolver fetches, parses, and injects the User object automatically.
2.4 Field-Level Injection (Bean Post-Processor)
Beyond method parameters, the same annotation can be used for field injection at bean initialization time, similar to @Resource / @Autowired.
@Component</code><code>public class ConvertJsonAnnotationPostProcessor implements InstantiationAwareBeanPostProcessor, BeanFactoryAware {</code><code> private ConfigurableBeanFactory beanFactory;</code><code> private BeanExpressionContext expressionContext;</code><code></code><code> private final ObjectMapper objectMapper = new ObjectMapper();</code><code></code><code> @Override</code><code> public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) throws BeansException {</code><code> ReflectionUtils.doWithFields(bean.getClass(), field -> {</code><code> ConvertJson annotation = field.getAnnotation(ConvertJson.class);</code><code> String name = annotation.value();</code><code> if (name.isEmpty()) {</code><code> throw new IllegalArgumentException("URL address not set");</code><code> }</code><code> String value = resolveEmbeddedValuesAndExpressions(name).toString();</code><code> if (value == null) {</code><code> throw new IllegalArgumentException("URL could not be resolved");</code><code> }</code><code> try {</code><code> Object result = objectMapper.readValue(URI.create(value).toURL(), field.getType());</code><code> field.setAccessible(true);</code><code> field.set(bean, result);</code><code> } catch (IOException e) {</code><code> throw new IllegalArgumentException("Failed to read JSON data from %s".formatted(value), e);</code><code> }</code><code> }, field -> field.isAnnotationPresent(ConvertJson.class));</code><code> return pvs;</code><code> }</code><code></code><code> private Object resolveEmbeddedValuesAndExpressions(String value) {</code><code> if (this.beanFactory == null || this.expressionContext == null) {</code><code> return value;</code><code> }</code><code> String placeholdersResolved = this.beanFactory.resolveEmbeddedValue(value);</code><code> BeanExpressionResolver exprResolver = this.beanFactory.getBeanExpressionResolver();</code><code> if (exprResolver == null) {</code><code> return value;</code><code> }</code><code> return exprResolver.evaluate(placeholdersResolved, this.expressionContext);</code><code> }</code><code></code><code> @Override</code><code> public void setBeanFactory(BeanFactory beanFactory) throws BeansException {</code><code> if (beanFactory instanceof ConfigurableBeanFactory cbf) {</code><code> this.beanFactory = cbf;</code><code> this.expressionContext = new BeanExpressionContext(cbf, new RequestScope());</code><code> }</code><code> }</code><code>}Usage with SpEL
@ConvertJson("${pack.json.url}")</code><code>private User user;</code><code></code><code>@GetMapping("/4")</code><code>public ResponseEntity<User> convert4() throws Exception {</code><code> return ResponseEntity.ok(user);</code><code>}The URL can be externalized in configuration (e.g., pack.json.url=http://localhost:8080/api/query) and resolved via SpEL at startup.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
