Template variables with ControllerLinkBuilder
Asked Answered
A

7

13

I want my response to include this:

"keyMaps":{
  "href":"http://localhost/api/keyMaps{/keyMapId}",
  "templated":true
 }

That's easy enough to achieve:

add(new Link("http://localhost/api/keyMaps{/keyMapId}", "keyMaps"));

But, of course, I'd rather use the ControllerLinkBuilder, like this:

add(linkTo(methodOn(KeyMapController.class).getKeyMap("{keyMapId}")).withRel("keyMaps"));

The problem is that by the time the variable "{keyMapId}" reaches the UriTemplate constructor, it's been included in an encoded URL:

http://localhost/api/keyMaps/%7BkeyMapId%7D

So UriTemplate's constructor doesn't recognise it as containing a variable.

How can I persuade ControllerLinkBuilder that I want to use template variables?

Ammerman answered 29/7, 2014 at 13:51 Comment(0)
W
4

Starting with this commit:

https://github.com/spring-projects/spring-hateoas/commit/2daf8aabfb78b6767bf27ac3e473832c872302c7

You can now pass null where path variable is expected. It works for me, without workarounds.

resource.add(linkTo(methodOn(UsersController.class).someMethod(null)).withRel("someMethod"));

And the result:

    "someMethod": {
        "href": "http://localhost:8080/api/v1/users/{userId}",
        "templated": true
    },

Also check related issues: https://github.com/spring-projects/spring-hateoas/issues/545

Whiteheaded answered 26/1, 2018 at 8:20 Comment(0)
D
8

It looks to me like the current state of Spring-HATEOAS doesn't allow this via the ControllerLinkBuilder (I'd very much like to be proven wrong), so I have implemented this myself using the following classes for templating query parameters:

public class TemplatedLinkBuilder {

    private static final TemplatedLinkBuilderFactory FACTORY = new TemplatedLinkBuilderFactory();
    public static final String ENCODED_LEFT_BRACE = "%7B";
    public static final String ENCODED_RIGHT_BRACE = "%7D";

    private UriComponentsBuilder uriComponentsBuilder;

    TemplatedLinkBuilder(UriComponentsBuilder builder) {
        uriComponentsBuilder = builder;
    }

    public static TemplatedLinkBuilder linkTo(Object invocationValue) {
        return FACTORY.linkTo(invocationValue);
    }

    public static <T> T methodOn(Class<T> controller, Object... parameters) {
        return DummyInvocationUtils.methodOn(controller, parameters);
    }

    public Link withRel(String rel) {
        return new Link(replaceTemplateMarkers(uriComponentsBuilder.build().toString()), rel);
    }

    public Link withSelfRel() {
        return withRel(Link.REL_SELF);
    }

    private String replaceTemplateMarkers(String encodedUri) {
        return encodedUri.replaceAll(ENCODED_LEFT_BRACE, "{").replaceAll(ENCODED_RIGHT_BRACE, "}");
    }

}

and

public class TemplatedLinkBuilderFactory {

    private final ControllerLinkBuilderFactory controllerLinkBuilderFactory;

    public TemplatedLinkBuilderFactory() {
        this.controllerLinkBuilderFactory = new ControllerLinkBuilderFactory();
    }

    public TemplatedLinkBuilder linkTo(Object invocationValue) {
        ControllerLinkBuilder controllerLinkBuilder = controllerLinkBuilderFactory.linkTo(invocationValue);
        UriComponentsBuilder uriComponentsBuilder = controllerLinkBuilder.toUriComponentsBuilder();

        Assert.isInstanceOf(DummyInvocationUtils.LastInvocationAware.class, invocationValue);
        DummyInvocationUtils.LastInvocationAware invocations = (DummyInvocationUtils.LastInvocationAware) invocationValue;
        DummyInvocationUtils.MethodInvocation invocation = invocations.getLastInvocation();
        Object[] arguments = invocation.getArguments();
        MethodParameters parameters = new MethodParameters(invocation.getMethod());

        for (MethodParameter requestParameter : parameters.getParametersWith(RequestParam.class)) {
            Object value = arguments[requestParameter.getParameterIndex()];
            if (value == null) {
                uriComponentsBuilder.queryParam(requestParameter.getParameterName(), "{" + requestParameter.getParameterName() + "}");
            }
        }
        return new TemplatedLinkBuilder(uriComponentsBuilder);
    }
}

Which embeds the normal ControllerLinkBuilder and then uses similar logic to parse for @RequestParam annotated parameters that are null and add these on to the query parameters. Also, our client resuses these templated URIs to perform further requests to the server. To achieve this and not need to worry about stripping out the unused templated params, I have to perform the reverse operation (swapping {params} with null), which I'm doing using a custom Spring RequestParamMethodArgumentResolver as follows

public class TemplatedRequestParamResolver extends RequestParamMethodArgumentResolver {

    public TemplatedRequestParamResolver() {
        super(false);
    }

    @Override
    protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest webRequest) throws Exception {
        Object value = super.resolveName(name, parameter, webRequest);
        if (value instanceof Object[]) {
            Object[] valueAsCollection = (Object[])value;
            List<Object> resultList = new LinkedList<Object>();
            for (Object collectionEntry : valueAsCollection) {
                if (nullifyTemplatedValue(collectionEntry) != null) {
                    resultList.add(collectionEntry);
                }
            }
            if (resultList.isEmpty()) {
                value = null;
            } else {
                value = resultList.toArray();
            }
        } else{
            value = nullifyTemplatedValue(value);
        }
        return value;
    }

    private Object nullifyTemplatedValue(Object value) {
        if (value != null && value.toString().startsWith("{") && value.toString().endsWith("}")) {
            value = null;
        }
        return value;
    }

}

Also this needs to replace the existing RequestParamMethodArgumentResolver which I do with:

@Configuration
public class ConfigureTemplatedRequestParamResolver {

    private @Autowired RequestMappingHandlerAdapter adapter;

    @PostConstruct
    public void replaceArgumentMethodHandlers() {
        List<HandlerMethodArgumentResolver> argumentResolvers = new ArrayList<HandlerMethodArgumentResolver>(adapter.getArgumentResolvers());
        for (int cursor = 0; cursor < argumentResolvers.size(); ++cursor) {
            HandlerMethodArgumentResolver handlerMethodArgumentResolver = argumentResolvers.get(cursor);
            if (handlerMethodArgumentResolver instanceof RequestParamMethodArgumentResolver) {
                argumentResolvers.remove(cursor);
                argumentResolvers.add(cursor, new TemplatedRequestParamResolver());
                break;
            }
        }
        adapter.setArgumentResolvers(argumentResolvers);
    }

}

Unfortunately, although { and } are valid characters in a templated URI, they are not valid in a URI, which may be a problem for your client code depending on how strict it is. I'd much prefer a neater solution built into Spring-HATEOAS!

Dextral answered 30/7, 2014 at 16:13 Comment(1)
As a further note, the ControllerLinkBuilder looks to act in the opposite way to what we're asking of it, if you point it at a templated URL, it attempts to replace the templated section with request parameters and it will throw an exception if these don't exist.Dextral
S
6

With latest versions of spring-hateoas you can do the following:

UriComponents uriComponents = UriComponentsBuilder.fromUri(linkBuilder.toUri()).build();
UriTemplate template = new UriTemplate(uriComponents.toUriString())
   .with("keyMapId", TemplateVariable.SEGMENT);

will give you: http://localhost:8080/bla{/keyMapId}",

Sixtyfourmo answered 4/7, 2016 at 11:54 Comment(0)
C
5

We've run into the same problem. General workaround is we have our own LinkBuilder class with a bunch of static helpers. Templated ones look like this:

public static Link linkToSubcategoriesTemplated(String categoryId){

    return new Link(
        new UriTemplate(
            linkTo(methodOn(CategoryController.class).subcategories(null, null, categoryId))
                .toUriComponentsBuilder().build().toUriString(),
            // register it as variable
            getBaseTemplateVariables()
        ),
        REL_SUBCATEGORIES
    );
}

private static TemplateVariables getBaseTemplateVariables() {
    return new TemplateVariables(
        new TemplateVariable("page", TemplateVariable.VariableType.REQUEST_PARAM),
        new TemplateVariable("sort", TemplateVariable.VariableType.REQUEST_PARAM),
        new TemplateVariable("size", TemplateVariable.VariableType.REQUEST_PARAM)
    );
}

This is for exposing the parameters of a controller response of a PagedResource.

then in the controllers we call this an append a withRel as needed.

Conceptualism answered 4/8, 2014 at 20:47 Comment(4)
Good answer. If you're also using ResourceAssembler classes to add your links, you can do this type of thing there as well.Musette
Did you get this to work with path variable arguments? The ControllerLinkBuilderFactory.linkTo method throws when setting a path variable to null, and any other value doesn't cause it to be templated. Your example only seems to work for (optional) request params.Optative
you know we never tried that, we always provided the path. TBH last month we gave up on the HATEOAS link builder, and just have our own nowConceptualism
ya also adopted the same approach . seems this is the way to go as of now .. For use for entity methods u can also use entityLinks to get links to enities rest endpointsMalocclusion
A
4

According to this issue comment, this will be addressed in an upcoming release of spring-hateoas.

For now, there's a drop-in replacement for ControllerLinkBuilder available from de.escalon.hypermedia:spring-hateoas-ext in Maven Central.

I can now do this:

import static de.escalon.hypermedia.spring.AffordanceBuilder.*

...

add(linkTo(methodOn(KeyMapController.class).getKeyMap(null)).withRel("keyMaps"));

I pass in null as the parameter value to indicate I want to use a template variable. The name of the variable is automatically pulled from the controller.

Ammerman answered 11/11, 2016 at 22:47 Comment(1)
Now it is built-in to the ControllerLinkBuilder. However it doesn't work properly on @RequestParam with defaultValue. DetailsStain
W
4

Starting with this commit:

https://github.com/spring-projects/spring-hateoas/commit/2daf8aabfb78b6767bf27ac3e473832c872302c7

You can now pass null where path variable is expected. It works for me, without workarounds.

resource.add(linkTo(methodOn(UsersController.class).someMethod(null)).withRel("someMethod"));

And the result:

    "someMethod": {
        "href": "http://localhost:8080/api/v1/users/{userId}",
        "templated": true
    },

Also check related issues: https://github.com/spring-projects/spring-hateoas/issues/545

Whiteheaded answered 26/1, 2018 at 8:20 Comment(0)
F
2

I needed to include a link with template variables in the root of a spring data rest application, to get access via traverson to an oauth2 token. This is working fine, maybe useful:

@Component
class RepositoryLinksResourceProcessor implements ResourceProcessor<RepositoryLinksResource> {

    @Override
    RepositoryLinksResource process(RepositoryLinksResource resource) {

        UriTemplate uriTemplate =  new UriTemplate(
                ControllerLinkBuilder.
                        linkTo(
                                TokenEndpoint,
                                TokenEndpoint.getDeclaredMethod("postAccessToken", java.security.Principal, Map )).
                        toUriComponentsBuilder().
                        build().
                        toString(),
                new TemplateVariables([
                        new TemplateVariable("username", TemplateVariable.VariableType.REQUEST_PARAM),
                        new TemplateVariable("password", TemplateVariable.VariableType.REQUEST_PARAM),
                        new TemplateVariable("clientId", TemplateVariable.VariableType.REQUEST_PARAM),
                        new TemplateVariable("clientSecret", TemplateVariable.VariableType.REQUEST_PARAM)
                ])
        )

        resource.add(
                new Link( uriTemplate,
                        "token"
                )
        )

        return resource
    }
}
Footstalk answered 16/4, 2016 at 14:5 Comment(0)
B
0

Based on the previous comments I have implemented a generic helper method (against spring-hateoas-0.20.0) as a "temporary" workaround. The implementation does consider only RequestParameters and is far from being optimized or well tested. It might come handy to some other poor soul traveling down the same rabbit hole though:

public static Link getTemplatedLink(final Method m, final String rel) {
    DefaultParameterNameDiscoverer disco = new DefaultParameterNameDiscoverer();

    ControllerLinkBuilder builder = ControllerLinkBuilder.linkTo(m.getDeclaringClass(), m);
    UriTemplate uriTemplate = new UriTemplate(UriComponentsBuilder.fromUri(builder.toUri()).build().toUriString());
    Annotation[][] parameterAnnotations = m.getParameterAnnotations();

    int param = 0;
    for (Annotation[] parameterAnnotation : parameterAnnotations) {
        for (Annotation annotation : parameterAnnotation) {
            if (annotation.annotationType().equals(RequestParam.class)) {
                RequestParam rpa = (RequestParam) annotation;
                String parameterName = rpa.name();
                if (StringUtils.isEmpty(parameterName)) parameterName = disco.getParameterNames(m)[param];
                uriTemplate = uriTemplate.with(parameterName, TemplateVariable.VariableType.REQUEST_PARAM);
            }
        }
        param++;
    }
    return new Link(uriTemplate, rel);
}
Ballyhoo answered 11/10, 2016 at 16:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.