spring-projects / spring-hateoas

Spring HATEOAS - Library to support implementing representations for hyper-text driven REST web services.
https://spring.io/projects/spring-hateoas
Apache License 2.0
1.04k stars 478 forks source link

ControllerLinkBuilder does not take Spring Data REST's base path into account #434

Open rheicide opened 8 years ago

rheicide commented 8 years ago

I have the repository REST base path set to /rest, and a @RepositoryRestController with some custom handlers:

@RepositoryRestController
public class RestController {
    @RequestMapping(value = "/foo", method = RequestMethod.GET)
    public ResponseEntity<Bar> foo() {
        return new ResponseEntity<>(new Bar(), HttpStatus.OK);
    }
    ...
}
public class Bar extends ResourceSupport {
    public Bar() {
        ...
        add(linkTo(methodOn(RestController.class).foo()).withSelfRel());
    }
}

The endpoint works fine at http://localhost:8080/rest/foo, however, the link is incorrect:

{
  ...
  _links: {
    self: {
      href: "http://localhost:8080/foo"
    }
  }
}

For some reason, the base path (/rest) was ignored.

I'm using Spring HATEOAS 0.19.0.RELEASE (comes with Spring Boot 1.3.1.RELEASE).

ajmesa9891 commented 8 years ago

I just came across this issue as well. Did you find a workaround?

snekse commented 7 years ago

Out of curiousity, is this a spring-hateoas problem or a spring-data-rest problem stemming from @RepositoryRestController?

gregturn commented 7 years ago

You should be using @BasePathAwareController to mark up a controller which you want to recognize the prefix.

gregturn commented 7 years ago

For more details, check out http://docs.spring.io/spring-data/rest/docs/2.5.4.RELEASE/reference/html/#customizing-sdr.overriding-sdr-response-handlers

snekse commented 7 years ago

Maybe I'm confused about what this means:

If you’re NOT interested in entity-specific operations

I have a @RepositoryRestResource for Invoice at /api/invoices. I need some special hand holding to transition the status of the Invoice (e.g. Going from Processing to Paid). I'm trying to create a URI of /api/invoices/{id}/transitionTo?newStatus=Paid handled by my @RepositoryRestController.

Am I not still interested in entity-specific operations in this case?

gregturn commented 7 years ago

Sorry. @RepositoryRestController extends @BasePathAwareController, so that isn't the source of our problem.

progys commented 7 years ago

Have exactly the same problem.

Doogiemuc commented 7 years ago

Same problem here. I also created a JIRA ticket: https://jira.spring.io/browse/DATAREST-972

Intersting fact: When you create a controller with @RepositoryRestController (which extends BasePathAwareController), then the resource is added twice: Once under the root and once with basePath prefix. This can be seen in the spring logs. But only the version mapped under root actually does work. Smells like a bug for me ...

gregturn commented 7 years ago

Please don't open duplicate tickets. If you are moving to JIRA for data rest, then close this ticket.

Doogiemuc commented 7 years ago

Sorry for the duplicate. I already opened the spring-data-rest JIRA ticket. before I found this here. I am actually not sure, if this is more a spring-hateoas or a spring-data-rest issue ... What do you think?

mperktold commented 7 years ago

I have the same problem and implemented the following service that prepends the base path as a workaround:

@Service
public class BasePathAwareLinks {

  private final URI contextBaseURI;
  private final URI restBaseURI;

  @Autowired
  public BasePathAwareLinks(ServletContext servletContext, RepositoryRestConfiguration config) {
    contextBaseURI = URI.create(servletContext.getContextPath());
    restBaseURI = config.getBasePath();
  }

  public LinkBuilder underBasePath(ControllerLinkBuilder linkBuilder) {
    return BaseUriLinkBuilder.create(contextBaseURI)
      .slash(restBaseURI)
      .slash(contextBaseURI.relativize(URI.create(linkBuilder.toUri().getPath())));
  }
}

To use it, just pass the LinkBuilder returned from linkTo to underBasePath before actually building the link:

public class MyResourceProcessor<Person> implements ResourceProcessor<Person> {

  @Autowired
  private BasePathAwareLinks service;

  public Resource<Person> process(Resource<Person> resource) {
    resource.add(
      service.underBasePath(
        linkTo(methodOn(SpecialPersonController.class).doSomething())
      )
      .withRel("something")
    );
    return resource;
  }
}

Hope this helps.

jowave commented 7 years ago

Is there any progress on this issue? I am running into the same problem: the REST base path is set to /api but ignored when building links to a @RepositoryRestController. I have tried with @BasePathAwareController as well, but without success. The code of @HiaslTiasl did not work either. For me contextBaseURI is always empty and therefore the resulting link is missing the host (http://localhost:8080 in my case).

gregturn commented 7 years ago

I have started poking at this issue. It appears, I can get the custom route to work when I use @BasePathAwareController but NOT with @RepositoryRestController. I have marked up https://jira.spring.io/browse/DATAREST-972 with my concerns about having two annotations that appear to do the same thing and will await @olivergierke 's feedback on that.

In the meantime, I reproduced the fact that ControllerLinkBuilder does NOT have insight into BasePathAwareHandlingMapping, a component registered by Spring Data REST, and hence cannot (yet) factor that into the URI it builds.

Selindek commented 7 years ago

I assume we will need a RestControllerLinkBuilder class in the data-rest package...

gregturn commented 7 years ago

We'd either need a link builder in SDR or the concept of a base path must move into Spring HATEOAS. I prefer the latter.

odrotbohm commented 7 years ago

As you probably might have expected, I have a slightly different view on this :).

First of all, due to it's static nature, all a ControllerLinkBuilder can do is interpret the static information attached to the class. I guess we can improve things for situations where client code uses the ControllerLinkBuilderFactory instances via dependency injection. But even in case of the latter I'd argue we should rather inspect the mappings defined in the HandlerMappings to lookup the template to be used as this is the point where SD plugs the API prefix.

The reason we didn't do that already is that this naturally creates a mismatch between static and non-static usage of ControllerLinkBuilder, which I tried to avoid so far. However it seems that putting emphasis on the non-static usage is not a bad idea.

davidrichardson commented 7 years ago

The work around code from @HiaslTiasl is useful, but does not work for templated links.

drenda commented 6 years ago

@HiaslTiasl thanks for your code. It works but it looses the first part "http://ip:port. Is that supposed to happen?

mperktold commented 6 years ago

@drenda happy to help! 😺

For me the first part was not important, but in general we probably should consider it as well. I guess you could extract it from linkBuilder.toUri(), maybe something like the following (didn't test) could work:

  public LinkBuilder underBasePath(ControllerLinkBuilder linkBuilder) {
    URI uri = linkBuilder.toUri();
    URI origin = new URI(uri.getScheme(), uri.getAuthority(), null, null, null);
    URI suffix = new URI(null, null, uri.getPath(), uri.getQuery(), uri.getFragment());
    return BaseUriLinkBuilder.create(origin)
      .slash(contextBaseURI)
      .slash(restBaseURI)
      .slash(suffix);
  }

We basically take the link from the ControllerLinkBuilder and just prepend the context base URI and the rest base URI to its path, while leaving everything else intact. So maybe now it also works for templated links.

drenda commented 6 years ago

@HiaslTiasl Thanks it works perfectly!

Sam-Kruglov commented 6 years ago

Here is my workaround: Inject basepath: @Value("${spring.data.rest.base-path}") String dataRestBasePath through constructor end inside the constructor make sure it ends with '/' and does not start with '/':

   if(dataRestBasePath.charAt(dataRestBasePath.length() - 1) != '/'){
        dataRestBasePath += "/";
    }

    if(dataRestBasePath.charAt(0) == '/'){
        this.dataRestBasePath = dataRestBasePath.substring(1);
    } else {
        this.dataRestBasePath = dataRestBasePath;
    }

Then I have a method which inserts dataRestBasePath right after the third slash (I assume the url is http://blabla.com:7777/blabla):

private Link prependBasePath(Link link){

    String hrefSrc = link.getHref();

    int count = 0;
    int i = 0;
    //noinspection StatementWithEmptyBody
    for (; count != 3 && i < hrefSrc.length(); i++){
        if(hrefSrc.charAt(i) == '/') count++;
    }

    String href = hrefSrc.substring(0, i) + dataRestBasePath + hrefSrc.substring(i, hrefSrc.length());

    return new Link(href, link.getRel());
}

And now I just wrap all Link instances with this method call.

Example: basePath="/api/v1.1" url="http://localhost:8085/jobs/1" resultUrl="http://localhost:8085/api/v1.1/jobs/1"

m1m3-50 commented 6 years ago

Hey, I was fighting with this issue myself and found out that you can add base path of SDR to @RequestMapping annotation explicitly and here is what happens: you get proper link using static ControllerLinkBuilder AND your method gets registered properly as you want it - the prefix with base path will be ignored somehow.

Although I'm not sure how it is happening, this is what I'm using and it works fine for me.

gkislin commented 6 years ago

Simple workaround, based on Michael Tran solution:

@Autowired
private final RepositoryRestConfiguration config;

private Link fixLinkSelf(Object invocationValue) {
    return fixLinkTo(invocationValue).withSelfRel();
}

@SneakyThrows
private Link fixLinkTo(Object invocationValue) {
    UriComponentsBuilder uriComponentsBuilder = linkTo(invocationValue).toUriComponentsBuilder();
    URL url = new URL(uriComponentsBuilder.toUriString());
    uriComponentsBuilder.replacePath(config.getBasePath() + url.getPath());
    return new Link(uriComponentsBuilder.toUriString());
}

Usage is the same as linkTo:

resources.add(fixLinkSelf(methodOn(VoteController.class).history()));    
resources.add(fixLinkTo(methodOn(VoteController.class).current()).withRel("current"));
gkislin commented 6 years ago

Other workaround for simple case based on current request with addPath:

new Link(ServletUriComponentsBuilder.fromCurrentRequest().path(addPath).build().toUriString())
lmtoo commented 4 years ago

this code work fine for me :

    /**
     * bugfix https://github.com/spring-projects/spring-hateoas/issues/434
     */
    private fun linkTo(invocationValue: Any): LinkBuilder {
        val target = ControllerLinkBuilder.linkTo(invocationValue)
        val context = BaseUriLinkBuilder.create(ServletUriComponentsBuilder.fromCurrentContextPath().build().toUri())
        val basedContext = context.slash(config.getBasePath()).toUri()
        val suffix = context.toUri().relativize(target.toUri())
        return BaseUriLinkBuilder.create(basedContext).slash(suffix)
    }
BrentWillems commented 4 years ago

Faced the same problem,

You can now use RepositoryEntityLinks https://docs.spring.io/spring-data/rest/docs/current/reference/html/#integration

public class MyWebApp {

    private RepositoryEntityLinks entityLinks;

    @Autowired
    public MyWebApp(RepositoryEntityLinks entityLinks) {
        this.entityLinks = entityLinks;
    }
}
Method Description
entityLinks.linkToCollectionResource(Person.class) Provide a link to the collection resource of the specified type (Person, in this case).
entityLinks.linkToSingleResource(Person.class, 1) Provide a link to a single resource.
entityLinks.linkToPagedResource(Person.class, new PageRequest(…​)) Provide a link to a paged resource.
entityLinks.linksToSearchResources(Person.class) Provides a list of links for all the finder methods exposed by the corresponding repository.
entityLinks.linkToSearchResource(Person.class, "findByLastName") Provide a finder link by rel (that is, the name of the finder
Gre3eN commented 3 years ago

I recently faced the same problem and tried to solve the issue with the above mentioned fixes. Unfortunately all of them seem to have the same problem: If you have a character in your url that needs to be escaped, using toUri() will escape these characters for you. But in a link builder context, you don't want these characters to be escaped. Simple example: You want to link to a controller with a request parameter. The expected result for a link builder would be: http://localhost:8080/api/endpoint?requestParam={requestParam} With the above mentioned fixes you would get: http://localhost:8080/api/endpoint?requestParam=%7BrequestParam%7D because toUri() escapes the curly brackets.

My solution would look like this:

@Component
public class RepositoryRestControllerLinkBuilder {

    private final RepositoryRestConfiguration repositoryRestConfiguration;

    public RepositoryRestControllerLinkBuilder(RepositoryRestConfiguration repositoryRestConfiguration) {
        this.repositoryRestConfiguration = repositoryRestConfiguration;
    }

    public <T> Builder<T> of(@NonNull Class<T> controllerType) {
        Assert.notNull(controllerType, "ControllerType cannot be null!");
        return new Builder<>(controllerType, this);
    }

    private String uriWithBasePath(WebMvcLinkBuilder linkBuilder) {
        var uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(linkBuilder.toString());
        var basePath = repositoryRestConfiguration.getBasePath().getPath();
        var originalUri = linkBuilder.toUri();
        return uriComponentsBuilder
                .replacePath(basePath + originalUri.getPath())
                .build()
                .toString();
    }

    public static class Builder<T> {

        private final Class<T> controllerType;
        private final RepositoryRestControllerLinkBuilder origin;

        private WebMvcLinkBuilder linkBuilder;

        private Builder(Class<T> controllerType, RepositoryRestControllerLinkBuilder origin) {
            this.controllerType = controllerType;
            this.origin = origin;
            this.linkBuilder = WebMvcLinkBuilder.linkTo(controllerType);
        }

        public Builder<T> forMethod(@NonNull Function<T, Object> invocationFunction) {
            Assert.notNull(invocationFunction, "InvocationFunction cannot be null!");
            this.linkBuilder = WebMvcLinkBuilder.linkTo(
                    invocationFunction.apply(WebMvcLinkBuilder.methodOn(controllerType))
            );
            return this;
        }

        public Link withRel(@NonNull String rel) {
            Assert.hasText(rel, "Rel cannot be null, empty or blank!");
            return Link.of(origin.uriWithBasePath(linkBuilder), rel);
        }

        public String asUriString() {
            return origin.uriWithBasePath(linkBuilder);
        }
    }
}

It would be used like this:

linkBuilder.of(Controller.class).forMethod(controller -> controller.method(parameter)).withRel("relation");

StPessina commented 2 years ago

Hi,

There was a bug fix in the Spring HATEOAS library or an official workaround for this?

Thanks you.

Laures commented 1 year ago

I anybody needs a solution for this, here is mine: a LinkBuilder implementation based on WebMvcLinkBuilder that handles the base-path and the BasePathAwareController Annotation.

A few details:

import static org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation;

/**
 * Wrapper for {@link org.springframework.hateoas.server.mvc.WebMvcLinkBuilder} that adds data-rests base-path
 */
public class BasePathAwareLinks {

    private String baseUri;

    public BasePathAwareLinks(RepositoryRestConfiguration repositoryRestConfiguration) {
        this.baseUri = repositoryRestConfiguration.getBasePath().toString();
    }

    public BasePathAwareLinkBuilder linkTo(Class<?> controller) {
        return new BasePathAwareLinkBuilder(controller, WebMvcLinkBuilder.linkTo(controller));
    }

    /**
     * Creates a new {@link BasePathAwareLinkBuilder} with a base of the mapping annotated to the given controller class. The
     * additional parameters are used to fill up potentially available path variables in the class scop request mapping.
     *
     * @param controller the class to discover the annotation on, must not be {@literal null}.
     * @param parameters additional parameters to bind to the URI template declared in the annotation, must not be
     *                   {@literal null}.
     * @return
     */
    public BasePathAwareLinkBuilder linkTo(Class<?> controller, Object... parameters) {
        return new BasePathAwareLinkBuilder(controller, WebMvcLinkBuilder.linkTo(controller, parameters));
    }

    /**
     * Creates a new {@link BasePathAwareLinkBuilder} with a base of the mapping annotated to the given controller class.
     * Parameter map is used to fill up potentially available path variables in the class scope request mapping.
     *
     * @param controller the class to discover the annotation on, must not be {@literal null}.
     * @param parameters additional parameters to bind to the URI template declared in the annotation, must not be
     *                   {@literal null}.
     * @return
     */
    public BasePathAwareLinkBuilder linkTo(Class<?> controller, Map<String, ?> parameters) {
        return new BasePathAwareLinkBuilder(controller, WebMvcLinkBuilder.linkTo(controller, parameters));
    }

    /*
     * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Method)
     */
    public BasePathAwareLinkBuilder linkTo(Method method) {
        return linkTo(method.getDeclaringClass(), method, new Object[method.getParameterTypes().length]);
    }

    /*
     * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Method, Object...)
     */
    public BasePathAwareLinkBuilder linkTo(Method method, Object... parameters) {
        return linkTo(method.getDeclaringClass(), method, parameters);
    }

    /*
     * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Class<?>, Method)
     */
    public BasePathAwareLinkBuilder linkTo(Class<?> controller, Method method) {
        return linkTo(controller, method, new Object[method.getParameterTypes().length]);
    }

    /*
     * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Class<?>, Method, Object...)
     */
    public BasePathAwareLinkBuilder linkTo(Class<?> controller, Method method, Object... parameters) {
        return new BasePathAwareLinkBuilder(controller, WebMvcLinkBuilder.linkTo(controller, method, parameters));

    }

    /**
     * Creates a {@link BasePathAwareLinkBuilder} pointing to a controller method. Hand in a dummy method invocation result you
     * can create via {@link #methodOn(Class, Object...)} or {@link DummyInvocationUtils#methodOn(Class, Object...)}.
     *
     * <pre>
     * &#64;BasePathAwareController("/customers")
     * class CustomerController {
     *
     *   &#64;RequestMapping("/{id}/addresses")
     *   HttpEntity&lt;Addresses&gt; showAddresses(@PathVariable Long id) { … }
     * }
     *
     * Link link = basePathAwareLinks.linkTo(methodOn(CustomerController.class).showAddresses(2L)).withRel("addresses");
     * </pre>
     * <p>
     * The resulting {@link Link} instance will point to {@code <base-path>/customers/2/addresses} and have a rel of
     * {@code addresses}. For more details on the method invocation constraints, see
     * {@link DummyInvocationUtils#methodOn(Class, Object...)}.
     *
     * @param invocationValue
     * @return
     */
    public BasePathAwareLinkBuilder linkTo(Object invocationValue) {
        Assert.isInstanceOf(LastInvocationAware.class, invocationValue);

        LastInvocationAware invocations = DummyInvocationUtils
            .getLastInvocationAware(invocationValue);

        return new BasePathAwareLinkBuilder(invocations.getLastInvocation().getTargetType(), WebMvcLinkBuilder.linkTo(invocationValue));
    }

    /**
     * Extract a {@link Link} from the {@link BasePathAwareLinkBuilder} and look up the related {@link Affordance}. Should only
     * be one.
     *
     * <pre>
     * Link findOneLink = basePathAwareLinks.linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel()
     *      .andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id)));
     * </pre>
     * <p>
     * This takes a link and adds an {@link Affordance} based on another Spring MVC handler method.
     *
     * @param invocationValue
     * @return
     */
    public Affordance afford(Object invocationValue) {
        BasePathAwareLinkBuilder linkBuilder = linkTo(invocationValue);

        Assert.isTrue(linkBuilder.getAffordances().size() == 1, "A base can only have one affordance, itself");

        return linkBuilder.getAffordances().get(0);
    }

    /**
     * Wrapper for {@link DummyInvocationUtils#methodOn(Class, Object...)} to be available in case you work with static
     * imports of {@link BasePathAwareLinks}.
     *
     * @param controller must not be {@literal null}.
     * @param parameters parameters to extend template variables in the type level mapping.
     * @return
     */
    public static <T> T methodOn(Class<T> controller, Object... parameters) {
        return DummyInvocationUtils.methodOn(controller, parameters);
    }

    protected UriComponents uriWithBasePath(Class<?> controller, WebMvcLinkBuilder linkBuilder) {
        var uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(linkBuilder.toString());

        var basePath = getBasePathedPrefix(controller);
        var linkPath = uriComponentsBuilder.build().getPath();

        return uriComponentsBuilder.replacePath(basePath + linkPath).build();
    }

    protected String getBasePathedPrefix(Class<?> handlerType) {

        Assert.notNull(handlerType, "Handler type must not be null");

        BasePathAwareController mergedAnnotation = findMergedAnnotation(handlerType, BasePathAwareController.class);
        String[] customPrefixes = mergedAnnotation == null ? new String[0] : mergedAnnotation.value();

        String[] basePathPrefixes = customPrefixes.length == 0 //
            ? new String[]{baseUri} //
            : Arrays.stream(customPrefixes).map(baseUri::concat).toArray(String[]::new);

        Assert.isTrue(basePathPrefixes.length == 1, "Cant build links to controller with more than one path");

        return basePathPrefixes[0];
    }

    public class BasePathAwareLinkBuilder extends TemplateVariableAwareLinkBuilderSupport<BasePathAwareLinkBuilder> {

        BasePathAwareLinkBuilder(Class<?> controller, WebMvcLinkBuilder webMvcLinkBuilder) {
            super(uriWithBasePath(controller, webMvcLinkBuilder), TemplateVariables.NONE, Collections.emptyList());
        }

        BasePathAwareLinkBuilder(UriComponents uriComponents, TemplateVariables variables, List<Affordance> affordances) {
            super(uriComponents, variables, affordances);
        }

        /*
         * (non-Javadoc)
         * @see org.springframework.hateoas.UriComponentsLinkBuilder#getThis()
         */
        @Override
        protected BasePathAwareLinkBuilder getThis() {
            return this;
        }

        /*
         * (non-Javadoc)
         * @see org.springframework.hateoas.server.core.TemplateVariableAwareLinkBuilderSupport#createNewInstance(org.springframework.web.util.UriComponents, java.util.List, org.springframework.hateoas.TemplateVariables)
         */
        @Override
        protected BasePathAwareLinkBuilder createNewInstance(UriComponents components, List<Affordance> affordances,
                                                             TemplateVariables variables) {
            return new BasePathAwareLinkBuilder(components, variables, affordances);
        }
    }
}