【问题标题】:Combining @PathVariable and @RequestBody结合@PathVariable 和@RequestBody
【发布时间】:2020-01-14 06:12:51
【问题描述】:

我有一个DTO

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

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

我不喜欢手动将 ID@PathVariable 放入 DTO: userDto.setId(id);

对于带有正文:{ name: "test" } 的 POST 请求 /user/5,我怎样才能在 DTO 中自动设置 ID,这样你就可以像下面那样得到 DTO

{
  id: 5,
  name: "test"
}

基本上,我想要类似的东西:

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

有没有办法做到这一点?

谢谢! :)

编辑:这是一个老问题,仍然没有答案,所以我想为这个问题添加新的观点。

我们遇到的另一个问题是验证,具体而言 - 定义基于某些字段和 id 进行验证的自定义约束。

如果我们从请求正文中删除id,那么我们如何从自定义约束中访问它? :)

【问题讨论】:

  • 这是一个听起来像重复的问题:stackoverflow.com/questions/39728571/… - 结论是这样的事情是不可能的。您可以尝试自己在 Spring 中调试各种路径,以检查这是否真的是真的——您可能会发现一些可能有帮助的东西。
  • 为什么不只将 id 传递到请求正文中。传递路径变量有什么价值?
  • @DivanshuAggarwal,这是 hist REST API 设计的一部分。如,GET /entity/{id} 读取并返回实体,POST /entity/{id} X --body 更新实体。 POST /entity X --body 创建新实体,返回 id。那种东西。他的实际设置可能有所不同,ofc。
  • @M.Prokhorov:是的,我已经看到了这个问题,希望自 2016 年以来情况有所改变。:) 正如你所说,REST API 设计约定 :)
  • @MatijaFolnovic,嗯,类似于this question 中的内容显然自 2013 年以来就有效,但具体如何 - 我不确定。不过,也许附加的答案会有所帮助。

标签: java spring spring-boot spring-mvc


【解决方案1】:

看来这个端点正在执行更新操作,所以让我们退后两步。

PUT 请求用于更新单个资源,最好使用POST 而不是PUT 来创建(至少是顶级)资源。相反,PATCH 请求用于更新单个资源的部分内容,即仅应替换特定资源字段子集的位置。

PUT 请求中,主要资源 ID 作为 URL 路径段传递,并且相关资源被替换为有效负载中传递的表示形式(在成功的情况下)。

对于payload,您可以提取另一个模型域类,其中包含UserDto除ID之外的所有字段。

据此,我建议这样设计你的控制器:

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

  @PutMapping("/{id}")
  String update(@PathVariable String id, @RequestBody UpdateUserRequest request){
      service.update(id, request);
  }
}

【讨论】:

  • tnx,我们希望避免将 id 转发给服务 :)
  • @MatijaFolnovic 为什么要避免?用自定义 dto 更新提供的 id 的 dto 是一个很好的合同。这是很常见的方法,很好的解释 in this answer
  • 从更新的角度来看,是的,这是有道理的。但从更一般的情况(对实体的任何非 CRUD 操作)来看,我认为将id 作为dto 的一部分更有意义
  • @Zavael,例如,您希望在控制器方法中接收@Valid UpdateUserRequest,并且验证的一部分是确保它具有有效的 id。
【解决方案2】:

我刚刚使用 AspectJ 完成了这项工作。
只需将此类复制粘贴到您的项目中即可。
Spring 应该会自动拾取它。

能力:

  • 这应该将路径变量从您的控制器和方法复制到您的请求 DTO。
  • 就我而言,我还需要将任何 HTTP 标头映射到请求上。随意禁用此功能。
  • 这还会设置您的请求 DTO 可能扩展的任何超类的属性。
  • 这应该适用于 POST、PUT、PATCH DELETE 和 GET 方法。
  • 使用您在请求属性中定义的注释执行验证。

非常小的警告:

  • 请注意,您注册的任何WebDataBinders 都不适用于这种情况。我还没想好怎么捡。这就是我创建 coerceValue() 方法的原因,该方法将字符串从路径转换为 ​​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;
    }

}

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2013-11-02
    • 1970-01-01
    • 2020-06-02
    • 2015-01-21
    • 1970-01-01
    • 1970-01-01
    • 2018-03-18
    • 2018-01-31
    相关资源
    最近更新 更多