spring-hateoas icon indicating copy to clipboard operation
spring-hateoas copied to clipboard

Add capabilities for prefixing WebMvcLinkBuilder?

Open onacit opened this issue 4 years ago • 7 comments

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?

onacit avatar Mar 14 '20 08:03 onacit

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.

gregturn avatar Mar 14 '20 14:03 gregturn

Hi, wondering if you found a workaround, I have the same issue and stumbled on this post...

atomeel avatar May 14 '20 10:05 atomeel

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

	}

}

gregturn avatar May 14 '20 13:05 gregturn

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.

gregturn avatar May 14 '20 13:05 gregturn

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.

gregturn avatar May 18 '20 16:05 gregturn

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.

gregturn avatar May 18 '20 16:05 gregturn

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

ralf-ueberfuhr-ars avatar Dec 04 '23 10:12 ralf-ueberfuhr-ars