Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Combining @PathVariable and @RequestBody

I have a DTO:

public class UserDto {
  private Long id;
  private String name;
}

and Controller:

@RestController
@RequestMapping("user")
public Class UserController {
  @PostMapping(value = "{id}")
  public String update(@PathVariable String id, @RequestBody UserDto userDto){
    userDto.setId(id);
    service.update(userDto);
  }
}

What I don't like is manually putting ID from @PathVariable to DTO: userDto.setId(id);.

For POST request /user/5 with body: { name: "test" }, how could I automatically set ID in DTO, so that you'd get DTO like below?

{
  id: 5,
  name: "test"
}

Basically, I'd like to have something like:

@RestController
@RequestMapping("user")
public Class UserController {
  @PostMapping(value = "{id}")
  public String update(@RequestBody UserDto userDto){
    service.update(userDto);
  }
}

Is there a way to accomplish this?

Thank you! :)

EDIT: this is an old question, still unanswered, so I'd like to add new perspective to this question.

Another problem we had is validation, to specific - defining custom constraint that does validation based on some field and id.

if we remove id from request body, then how can we access it from custom constraint? :)

like image 239
Matija Folnovic Avatar asked Sep 12 '19 11:09

Matija Folnovic


2 Answers

It seems this endpoint is performing an update operation, so let's do two steps back.

PUT requests are used to update a single resource, and it is best practice to prefer POST over PUT for creation of (at least top-level) resources. Instead, PATCH requests are used to update parts of single resources, i.e. where only a specific subset of resource fields should be replaced.

In PUT requests, the primary resource ID is passed as a URL path segment and the associated resource is replaced (in case of success) with the representation passed in the payload.

For the payload, you can extract another model domain class that contains all the fields of UserDto except the ID.

According to this, I suggest to design your controller in this way:

@RestController
@RequestMapping("/api/{api}/users")
public class UserController {

  @PutMapping("/{id}")
  String update(@PathVariable String id, @RequestBody UpdateUserRequest request){
      service.update(id, request);
  }
}
like image 195
lmarx Avatar answered Oct 16 '22 15:10

lmarx


I just got this working by using AspectJ.
Just copy-paste this class into your project.
Spring should pick it up automatically.

Capabilities:

  • This should copy path variables from your controller and method onto your request DTO.
  • In my case, I needed to also map any HTTP headers onto the request. Feel free to disable this.
  • This also sets properties of any super-classes your request DTOs may extend.
  • This should work with POST, PUT, PATCH DELETE, and GET methods.
  • Performs validation using annotations which you've defined on your request properties.

Very minor caveat:

  • Note that any WebDataBinders you've registered will not apply in this situation. I haven't figured out how to pick that up yet. This is why I have created the coerceValue() method that converts strings from your path into the desired data type as declared on your DTO.

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.validation.Validator;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
import java.util.stream.Collectors;

/**
 * This class extracts values from the following places:
 * - {@link PathVariable}s from the controller request path
 * - {@link PathVariable}s from the method request path
 * - HTTP headers
 * and attempts to set those values onto controller method arguments.
 * It also performs validation
 */
@Aspect
@Component
public class RequestDtoMapper {

    private final HttpServletRequest request;
    private final Validator validator;

    public RequestDtoMapper(HttpServletRequest request, Validator validator) {
        this.request = request;
        this.validator = validator;
    }

    @Around("execution(public * *(..)) && (@annotation(org.springframework.web.bind.annotation.PostMapping) || @annotation(org.springframework.web.bind.annotation.PutMapping) || @annotation(org.springframework.web.bind.annotation.PatchMapping) || @annotation(org.springframework.web.bind.annotation.DeleteMapping) || @annotation(org.springframework.web.bind.annotation.GetMapping))")
    public Object process(ProceedingJoinPoint call) throws Throwable {
        MethodSignature signature = (MethodSignature) call.getSignature();
        Method method = signature.getMethod();

        // Extract path from controller annotation
        Annotation requestMappingAnnotation = Arrays.stream(call.getTarget().getClass().getDeclaredAnnotations())
                .filter(ann -> ann.annotationType() == RequestMapping.class)
                .findFirst()
                .orElseThrow();
        String controllerPath = ((RequestMapping) requestMappingAnnotation).value()[0];

        // Extract path from method annotation
        List<Class<?>> classes = Arrays.asList(PostMapping.class, PutMapping.class, PatchMapping.class, DeleteMapping.class, GetMapping.class);
        Annotation methodMappingAnnotation = Arrays.stream(method.getDeclaredAnnotations())
                .filter(ann -> classes.contains(ann.annotationType()))
                .findFirst()
                .orElseThrow();
        String methodPath = methodMappingAnnotation.annotationType().equals(PostMapping.class)
                ? ((PostMapping) methodMappingAnnotation).value()[0]
                : methodMappingAnnotation.annotationType().equals(PutMapping.class)
                ? ((PutMapping) methodMappingAnnotation).value()[0]
                : methodMappingAnnotation.annotationType().equals(PatchMapping.class)
                ? ((PatchMapping) methodMappingAnnotation).value()[0]
                : methodMappingAnnotation.annotationType().equals(DeleteMapping.class)
                ? ((DeleteMapping) methodMappingAnnotation).value()[0]
                : methodMappingAnnotation.annotationType().equals(GetMapping.class)
                ? ((GetMapping) methodMappingAnnotation).value()[0]
                : null;

        // Extract parameters from request URI
        Map<String, String> paramsMap = extractParamsMapFromUri(controllerPath + "/" + methodPath);

        // Add HTTP headers to params map
        Map<String, String> headers =
                Collections.list(request.getHeaderNames())
                        .stream()
                        .collect(Collectors.toMap(h -> h, request::getHeader));
        paramsMap.putAll(headers);

        // Set properties onto request object
        List<Class<?>> requestBodyClasses = Arrays.asList(PostMapping.class, PutMapping.class, PatchMapping.class, DeleteMapping.class);
        Arrays.stream(call.getArgs()).filter(arg ->
                (requestBodyClasses.contains(methodMappingAnnotation.annotationType()) && arg.getClass().isAnnotationPresent(RequestBody.class))
                        || methodMappingAnnotation.annotationType().equals(GetMapping.class))
                .forEach(methodArg -> getMapOfClassesToFields(methodArg.getClass())
                        .forEach((key, value1) -> value1.stream().filter(field -> paramsMap.containsKey(field.getName())).forEach(field -> {
                            field.setAccessible(true);
                            try {
                                String value = paramsMap.get(field.getName());
                                Object valueCoerced = coerceValue(field.getType(), value);
                                field.set(methodArg, valueCoerced);
                            } catch (Exception e) {
                                throw new RuntimeException(e);
                            }
                        })));

        // Perform validation
        for (int i = 0; i < call.getArgs().length; i++) {
            Object arg = call.getArgs();
            BeanPropertyBindingResult result = new BeanPropertyBindingResult(arg, arg.getClass().getName());
            SpringValidatorAdapter adapter = new SpringValidatorAdapter(this.validator);
            adapter.validate(arg, result);
            if (result.hasErrors()) {
                MethodParameter methodParameter = new MethodParameter(method, i);
                throw new MethodArgumentNotValidException(methodParameter, result);
            }
        }

        // Execute remainder of method
        return call.proceed();
    }

    private Map<String, String> extractParamsMapFromUri(String path) {
        List<String> paramNames = Arrays.stream(path.split("/"))
                .collect(Collectors.toList());
        Map<String, String> result = new HashMap<>();
        List<String> pathValues = Arrays.asList(request.getRequestURI().split("/"));
        for (int i = 0; i < paramNames.size(); i++) {
            String seg = paramNames.get(i);
            if (seg.startsWith("{") && seg.endsWith("}")) {
                result.put(seg.substring(1, seg.length() - 1), pathValues.get(i));
            }
        }
        return result;
    }

    /**
     * Convert provided String value to provided class so that it can ultimately be set onto the request DTO property.
     * Ideally it would be better to hook into any registered WebDataBinders however we are manually casting here.
     * Add your own conditions as required
     */
    private Object coerceValue(Class<?> valueType, String value) {
        if (valueType == Integer.class || valueType == int.class) {
            return Integer.parseInt(value);
        } else if (valueType == Boolean.class || valueType == boolean.class) {
            return Integer.parseInt(value);
        } else if (valueType == UUID.class) {
            return UUID.fromString(value);
        } else if (valueType != String.class) {
            throw new RuntimeException(String.format("Cannot convert '%s' to type of '%s'. Add another condition to `%s.coerceValue()` to resolve this error", value, valueType, RequestDtoMapper.class.getSimpleName()));
        }
        return value;
    }

    /**
     * Recurse up the class hierarchy and gather a map of classes to fields
     */
    private Map<Class<?>, List<Field>> getMapOfClassesToFields(Class<?> t) {
        Map<Class<?>, List<Field>> fields = new HashMap<>();
        Class<?> clazz = t;
        while (clazz != Object.class) {
            if (!fields.containsKey(clazz)) {
                fields.put(clazz, new ArrayList<>());
            }
            fields.get(clazz).addAll(Arrays.asList(clazz.getDeclaredFields()));
            clazz = clazz.getSuperclass();
        }
        return fields;
    }

}
like image 45
Stephen Paul Avatar answered Oct 16 '22 14:10

Stephen Paul