Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add capabilities for prefixing WebMvcLinkBuilder? #1221

Open
onacit opened this issue Mar 14, 2020 · 7 comments
Open

Add capabilities for prefixing WebMvcLinkBuilder? #1221

onacit opened this issue Mar 14, 2020 · 7 comments
Labels
in: core Core parts of the project stack: webmvc type: bug

Comments

@onacit
Copy link

onacit commented Mar 14, 2020

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?

@gregturn
Copy link
Contributor

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.

@onacit onacit closed this as completed Mar 18, 2020
@atomeel
Copy link

atomeel commented May 14, 2020

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

@gregturn gregturn reopened this May 14, 2020
@gregturn
Copy link
Contributor

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 gregturn added this to the 1.2.0-M1 milestone May 14, 2020
@gregturn gregturn added in: core Core parts of the project stack: webmvc type: bug labels May 14, 2020
@gregturn
Copy link
Contributor

gregturn commented May 14, 2020

The issue appears to be in here =>

public static UriComponentsBuilder getBuilder() {
if (RequestContextHolder.getRequestAttributes() == null) {
return UriComponentsBuilder.fromPath("/");
}
URI baseUri = getCachedBaseUri();
return baseUri != null //
? UriComponentsBuilder.fromUri(baseUri) //
: cacheBaseUri(ServletUriComponentsBuilder.fromCurrentServletMapping());
}

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
Copy link
Contributor

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
Copy link
Contributor

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.

@odrotbohm odrotbohm modified the milestones: 1.2 M1, 1.2 RC1 Aug 11, 2020
@gregturn gregturn modified the milestones: 1.2 RC1, 1.2 GA Sep 15, 2020
@odrotbohm odrotbohm modified the milestones: 1.2 GA, 1.3 M1 Oct 27, 2020
@odrotbohm odrotbohm modified the milestones: 1.3 M1, 1.3 M2 Jan 13, 2021
@odrotbohm odrotbohm modified the milestones: 1.3 M2, 1.3 RC1 Feb 16, 2021
@odrotbohm odrotbohm modified the milestones: 1.3 M3, 1.4 M1 Mar 15, 2021
@odrotbohm odrotbohm modified the milestones: 1.4 M1, 1.4 M2 Jul 14, 2021
@odrotbohm odrotbohm modified the milestones: 1.4 M2, 1.4 M3 Aug 12, 2021
@odrotbohm odrotbohm modified the milestones: 1.4 M3, 1.4 candidates Sep 16, 2021
@odrotbohm odrotbohm removed this from the 1.4 candidates milestone Nov 13, 2023
@ralf-ueberfuhr-ars
Copy link

ralf-ueberfuhr-ars commented Dec 4, 2023

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: core Core parts of the project stack: webmvc type: bug
Projects
None yet
Development

No branches or pull requests

5 participants