Search code examples
javaspring-bootopenapi-generator

Set query param value to default on type mismatch in spring boot rest api


We have a rest api built with spring boot and the openapi code generator. Let's assume there is a path /user in our api spec:

...
"paths": {
    "/user": {
        "get": {
            "parameters": [{
                "name": "id",
                "in": "query",
                "required": false,
                "type": "integer",
                "format": "int64"
            }],
            "responses": { ... }
        },
    }
    // more paths
}
...

A call to this path could then be: /user?id=1234.

The code generator creates an interface MyControllerApi:

@javax.annotation.Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "some date")
@Api(value="user")
public interface MyControllerApi {
    @ApiOperation(value="", nickname="userGet", response = User.class, /* ... */)
    @ApiResponse(/* ... */)
    @GetMapping(value="/user", produces = { "application/json" })
    ResponseEntity<User> userGet(@ApiParam(value = "id") @RequestParam(value = "id", required = false) Long id);
}    

The controller then looks like this:

@RestController
public class MyController implements MyControllerApi
{
    @Autowired
    UserService service;
    
    @Override
    public ResponseEntity<User> userGet(@RequestParam(value = "id") Long id) {
        return service.get(id);
    }
}

If /user?id=<value> is requested, spring boot automatically checks if the type of the passed parameter value <value> matches the required type. If not BAD_REQUEST is returned:

{
    "timestamp": "2022-10-19T17:20:48.393+0000",
    "status": 400,
    "error": "Bad Request",
    "path": "/user"
}

We are now in a situation in which we want to pass null to each parameter of userGet that would cause a type mismatch. To be more clear: if /user?id=abcd is requested, userGet should be called with id set to null, so that we can return some default user. Is there a way to achieve this?

Of course there are a lot more paths and parameters and this behavior should apply for every query parameter of type Long or Boolean.

This example may not make much sense, but it is also just an example.

Thanks in advance

What I tried myself in the meantime

1. Setting the spring-boot useOptional option ...

... to true in the pom.xml (see here).

This has the affect that the query parameters in the controllers method are of type Optional<?>. In my example above this would lead to:

@Override
public ResponseEntity<User> userGet(Optional<Long> id) {
    Long id_val = id.isPresent() ? id.get() : null;
    return service.get(id_val);
}

But this did not work either and spring boot also creates a BAD_REQUEST response on parameter type mismatches.

2. Using a request interceptor

A request interceptor is a kind of middleware and can be created by implementing HandlerInterceptor and enables you to process the request before it is passed to the controller.

My interceptor looks like this:

public class CustomRequestInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
    {
        HandlerMethod method = (HandlerMethod) handler;
        MethodParameter[] handlerParams = method.getMethodParameters();

        while (request.getParameterNames().hasMoreElements())
        {
            // the param name of the query
            String paramName = request.getParameterNames().nextElement();
             the param value
            String paramValue = request.getParameter(paramName);
            MethodParameter methodParam = null;

            for (MethodParameter mp : handlerParams)
            {
                // We can access annotations here ...
                Annotation anno = mp.getParameterAnnotation(RequestParam.class);
                if(anno != null)
                {
                    RequestParam rp = (RequestParam) anno;

                    // ... and am able to get the param name in the controller
                    // Check if we found the param
                    String requestParamName = rp.value();
                    if (requestParamName.toLowerCase().equals(paramName.toLowerCase()))
                    {
                        // and now?
                        break;
                    }
                }
            }

            if (methodParam != null)
            {
                Type type = methodParam.getGenericParameterType();
            }
        }

        return HandlerInterceptor.super.preHandle(request, response, handler);
    }
}

So far so good, but the RequestParam-Object does not hold any informations about the parameter type, nor about the index of that param in the methods param list. And the MethodParameter does not hold the name of the parameter (because its a compiled class I think).

What I'm wondering is how spring boot itself maps the query parameters to the ones in the controllers methods..... ?


Solution

  • After doing some research I found a solution by using a custom HandlerMethodArgumentResolver:

    public class MyCustomResolver implements HandlerMethodArgumentResolver {
        @Override
        @Nullable
        public Object resolveArgument(
            MethodParameter methodParam,
            @Nullable ModelAndViewContainer mvContainer,
            NativeWebRequest request,
            @Nullable WebDataBinderFactory binderFac
        ) throws Exception {
            String paramValue = request.getParameter(methodParam.getParameterName());
            if (paramValue == null)
                return null;
    
            if (methodParam.getParameterType().equals(Long.class)) {
                try {
                    // Set param value to the parsed one, if it can be parsed
                    return Long.parseLong(paramValue);
                } catch (Exception e) {
                    // Set param value to null otherwise
                    return null;
                }
            }
    
            return null;
        }
    
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
            return parameter.getParameterType().equals(Long.class));
        }
    }
    

    The method supportsParameter() gives you the opportunity to decide whether or not the passed parameter will be processed in resolveArgument.

    resolveArgument then gets the parameter as MethodParameter. Unlike in the HandlerInterceptor the MethodParameter now contains the parameter name and type.

    Using this approach id will have the following values:

    id = 1     on /user?id=1
    id = null  on /user?id=abc
    

    Now I'am able to handle falsy parameter values in the controller directly.

    Remarks

    If you use a parameter of type Boolean the param value will not be null but "false" for every parameter value except true. Only if "true" is passed in the request resolveArgument will parse it to true and getUser will receive true.