spring-hateoas
spring-hateoas copied to clipboard
Add capabilities for prefixing WebMvcLinkBuilder?
Please see https://stackoverflow.com/q/60680748/330457.
I'm trying to make my controllers prefixed with, say, /api/v1
.
And it seems WebMvcLinkBuilder#linkTo(Class<?>)
method is not aware of PathMatchConfigurer#addPathPrefix(String, Predicate<Class<?>>)
.
Can anybody please add some capability of adding prefixes to WebMvcLinkBuilder
?
WebMvcLinkBuilder
// .prefix("api")
// .prefix("v1")
// .prefixes("api", "v1")
// .linkTo(controller)
// .linkTo(controller, "api", "v1")
.linkTo(controller)
.withPrefixes("api", "v1")
;
Or do I have already an intrinsic way to do this?
Can you paste an example controller into this ticket that you'd like to handle, so there's no confusion between a meandering SO question and a desired feature?
Thanks.
Hi, wondering if you found a workaround, I have the same issue and stumbled on this post...
I wrote this test case and verified this is a problem.
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration
class WebMvcConfigurerUnitTest {
@Autowired WebApplicationContext context;
MockMvc mockMvc;
@BeforeEach
void setUp() {
this.mockMvc = webAppContextSetup(this.context).build();
}
@Test
void customWebMvcConfigurerShouldWork() throws Exception {
this.mockMvc.perform(get("/api").accept(MediaTypes.HAL_JSON)) //
.andDo(print()) //
.andExpect(status().isOk()) //
.andExpect(jsonPath("$._links.self.href", is("http://localhost/api")));
}
@RestController
static class TestController {
@GetMapping
RepresentationModel<?> root() {
return new RepresentationModel<>(linkTo(methodOn(TestController.class).root()).withSelfRel());
}
}
@Configuration
@EnableWebMvc
@EnableHypermediaSupport(type = HypermediaType.HAL)
static class TestConfig {
@Bean
TestController controller() {
return new TestController();
}
@Bean
WebMvcConfigurer configureCustomPathPrefix() {
return new WebMvcConfigurer() {
@Override
public void configurePathMatch(PathMatchConfigurer configurer1) {
configurer1.addPathPrefix("/api", HandlerTypePredicate.forAnyHandlerType());
}
};
}
}
}
The issue appears to be in here => https://github.com/spring-projects/spring-hateoas/blob/0d25b2e66e3800969ecb07c773d936ee3e6f0b69/src/main/java/org/springframework/hateoas/server/mvc/UriComponentsBuilderFactory.java#L47-L58
This is where Spring HATEOAS has access to the current servlet context. In the debugger I can see the /api
as another attribute.
Simply put, we don't access it and apply it to the newly built URI.
I've traveled quite a ways down this rabbit hole, and I'm not sure the answer is feasible.
I tried to switch from "servlet mapping" to "current request" details deep inside link building, because my simple test case above happily turned green when you do that. This required that I reimplement "building a link outside a web call".
I made much progress down that path, but ran into what may be a fatal issue, one of our integration test cases.
If you are in the middle of a single-item method /employees/0
, and trying to build a link to an aggregate root (/employees
), then you run into issues.
Essentially, you either need the base URI (http://localhost:8080
) + the target mapping (/employees
based on annotations), which is what the project currently does.
Or you need the ability to look at the whole request (http://localhost:8080/employees/0
) and know how to strip off what is ONLY covered in annotations, and THEN apply the NEW mapping.
So far, it appears that Spring MVC does not have a special designation for "prefixing" things. It basically uses what you provide (annotations, WebMvcConfigurer
details) and builds up a the route for a method.
Configuring path prefixes and then later inspecting them to insert into the middle of a URI doesn't appear to be a primary use case built into Spring MVC.
Simply put, we may need to provide a configuration API to set a prefix (like /api
), register our own WebMvcConfigurer
, and then leverage that when building links.
This is very similar to what Spring Data REST currently does.
Perhaps it would be possible to pull that feature from SDR into Spring HATEOAS, and then update SDR to use it from Spring HATEOAS.
It's bad to fix here because WebMvcLinkBuilder works on a static way, while the path prefixes are available in the context, so in a non-static context.
I have a workaround for WebMvc. Create a UriMappingResolver
that uses WebMvcLinkBuilder
, but extends the URI's path by the prefix:
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import java.net.URI;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
@RequiredArgsConstructor
public class UriMappingResolver {
private final Supplier<Map<String, Predicate<Class<?>>>> pathPrefixes;
protected Optional<String> getBasePath(Class<?> controllerClass) {
return this.pathPrefixes
.get()
.entrySet()
.stream()
.filter(e -> e.getValue().test(controllerClass))
.findFirst()
.map(Map.Entry::getKey);
}
@SneakyThrows
private static URI derive(URI original, String basePath) {
return new URI(
original.getScheme(),
original.getUserInfo(),
original.getHost(),
original.getPort(),
basePath + original.getPath(),
original.getQuery(),
original.getFragment()
);
}
public <ControllerClass> URI resolve(Class<ControllerClass> c, Function<ControllerClass, ?> methodOn) {
final var uri = WebMvcLinkBuilder.linkTo(
methodOn.apply(WebMvcLinkBuilder.methodOn(c))
).toUri();
return this.getBasePath(c)
.map(basePath -> derive(uri, basePath))
.orElse(uri);
}
}
To register it correctly to the context, use this configuration:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Configuration
public class UriMappingResolverConfiguration {
@Bean
UriMappingResolver uriMappingResolver(@Lazy List<RequestMappingHandlerMapping> m) {
return new UriMappingResolver(
() -> m.stream()
.map(RequestMappingHandlerMapping::getPathPrefixes)
.map(Map::entrySet)
.flatMap(Set::stream)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))
);
}
@Bean
WebMvcConfigurer configureUriMappingResolverToWebMvc(final UriMappingResolver resolver) {
return new WebMvcConfigurer() {
@Override
public void addArgumentResolvers(
@SuppressWarnings("NullableProblems")
List<HandlerMethodArgumentResolver> resolvers
) {
resolvers.add(new HandlerMethodArgumentResolver() {
@Override
public boolean supportsParameter(
@SuppressWarnings("NullableProblems")
MethodParameter parameter
) {
return parameter
.getParameterType()
.equals(UriMappingResolver.class);
}
@Override
public Object resolveArgument(
@SuppressWarnings("NullableProblems")
MethodParameter parameter,
ModelAndViewContainer mavContainer,
@SuppressWarnings("NullableProblems")
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) throws Exception {
return resolver;
}
});
}
};
}
}
If done so, we can use it as a method parameter withing the controller's method:
ResponseEntity<AuthorDto> create(
@Valid
@RequestBody
AuthorDto author,
UriMappingResolver uriMappingResolver
) {
author.setId(null); // just to be sure
this.save(author);
return ResponseEntity
.created(
uriMappingResolver.resolve(
this.getClass(),
c -> c.findById(author.getId())
)
)
.body(author);
}