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

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

Open
rheicide opened this issue Feb 15, 2016 · 29 comments

Comments

@rheicide
Copy link

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

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

@snekse
Copy link

snekse commented Nov 4, 2016

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

@gregturn
Copy link
Contributor

gregturn commented Nov 9, 2016

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

@gregturn gregturn closed this as completed Nov 9, 2016
@gregturn gregturn reopened this Nov 9, 2016
@gregturn
Copy link
Contributor

gregturn commented Nov 9, 2016

@gregturn gregturn closed this as completed Nov 9, 2016
@snekse
Copy link

snekse commented Nov 10, 2016

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

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

@gregturn gregturn reopened this Nov 11, 2016
@progys
Copy link

progys commented Nov 11, 2016

Have exactly the same problem.

@Doogiemuc
Copy link

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

gregturn commented Jan 7, 2017

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

@Doogiemuc
Copy link

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

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

jowave commented Apr 24, 2017

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

gregturn commented Jun 2, 2017

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

Selindek commented Jun 20, 2017

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

@gregturn
Copy link
Contributor

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

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

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

rolando-ebi pushed a commit to EMBL-EBI-SUBS/subs-api that referenced this issue Aug 11, 2017
spring-projects/spring-hateoas#434

Links built for @BasePathAwareControllers do not include the base path
The workaround in the ticket does not work for templated links
Therefore, manually add the base path to the controller code
@drenda
Copy link

drenda commented Dec 5, 2017

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

@mperktold
Copy link

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

drenda commented Dec 18, 2017

@HiaslTiasl Thanks it works perfectly!

@Sam-Kruglov
Copy link

Sam-Kruglov commented Jan 17, 2018

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

m1m3-50 commented Feb 8, 2018

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

gkislin commented Aug 7, 2018

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

gkislin commented Aug 9, 2018

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

new Link(ServletUriComponentsBuilder.fromCurrentRequest().path(addPath).build().toUriString())

@lmtoo
Copy link

lmtoo commented Nov 8, 2019

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

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

Gre3eN commented Mar 8, 2021

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

Hi,

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

Thanks you.

@Laures
Copy link
Contributor

Laures commented Mar 28, 2023

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:

  • If the BasePathAwareController annotation contains more than one path, there will be an exception
  • the api is a carbon-copy of WebMvcLinkBuilder; just put the variable in front of any linkTo(...) you already have
  • no use of toUri(...) or toUriString(...) because these break links that contain parameter-placeholders
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);
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests