18

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? :)

EDIT 2: italktothewind created issue on Github: https://github.com/spring-projects/spring-framework/issues/28637

Matija Folnovic
  • 906
  • 2
  • 10
  • 22
  • Here's a question that sounds like a duplicate: https://stackoverflow.com/questions/39728571/spring-map-field-of-requestbody-with-pathvariable - with a conclusion that such thing is not possible. You can try debugging various paths in Spring yourself to check whether that actually is true, though - you may discover something that may help. – M. Prokhorov Sep 12 '19 at 11:27
  • Why are you not passing id into request body only.What value would it serve to pass in path vaiable? – Divanshu Aggarwal Sep 12 '19 at 11:30
  • @DivanshuAggarwal, that's a part of hist REST API design. As in, `GET /entity/{id}` reads and returns entity, `POST /entity/{id} X --body` updates the entity. `POST /entity X --body` creates new entity, returns id. That kind of stuff. His actual setup may be different, ofc. – M. Prokhorov Sep 12 '19 at 11:33
  • 2
    @M.Prokhorov: yep, I've seen that question, hoped things changed since 2016. :) and exactly as you said, REST API design convention :) – Matija Folnovic Sep 12 '19 at 11:45
  • @MatijaFolnovic, well, something like what's in [this question](https://stackoverflow.com/questions/17149425/bind-path-variables-to-a-custom-model-object-in-spring) apparently works since 2013, but how exactly - I'm not sure. Maybe attached answers help though. – M. Prokhorov Sep 12 '19 at 11:55
  • @M.Prokhorov tnx, we also saw that - it doesn't work if you use `@RequestBody` :( – Matija Folnovic Sep 12 '19 at 12:09
  • @MatijaFolnovic, even the method parameter resolver one, that processes raw requests? That's odd. – M. Prokhorov Sep 12 '19 at 12:10
  • If you want this feature to be done in Spring please upvote this issue: https://github.com/spring-projects/spring-framework/issues/28637 . Maybe you can put it the question :) – italktothewind Jun 17 '22 at 03:33

2 Answers2

12

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);
  }
}
lmarx
  • 476
  • 4
  • 9
  • 4
    tnx, we were hoping to avoid forwarding `id` to service :) – Matija Folnovic Sep 12 '19 at 12:10
  • @MatijaFolnovic why to avoid? its a good contract to update dto of the provided id with custom dto.. it is pretty common approach, good explanation [in this answer](https://stackoverflow.com/a/11881628/418031) – Zavael Sep 12 '19 at 12:13
  • 4
    From update perspective, yes, that makes sense. But from more general case (any non-CRUD action on entity), I'd say it makes more sense to have `id` as part of `dto` – Matija Folnovic Sep 12 '19 at 12:51
  • @Zavael, for example, you'd like to require to receive a `@Valid` UpdateUserRequest in your controller method, and a part of the validation is to ensure it has a valid id. – Alex Andrienko Jun 07 '20 at 11:19
2

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;
    }

}
Stephen Paul
  • 37,253
  • 15
  • 92
  • 74