I am writing a microservice based application with spring-boot services.
For communication I use REST (with hateoas links). Each service registers with eureka, so I the links I provide are based on these names, so that the ribbon enhanced resttemplates can use the loadbalancing and failover capabilities of the stack.
This works fine for internal communication, but I have a single page admin app that accesses the services through a zuul based reverse proxy. When the links are using the real hostname and port the links are correctly rewritten to match the url visible from the outside. This of course doesn't work for the symbolic links that I need in the inside...
So internally I have links like:
http://adminusers/myfunnyusername
The zuul proxy should rewrite this to
http://localhost:8090/api/adminusers/myfunnyusername
Is there something that I am missing in zuul or somewhere along the way that would make this easier?
Right now I'm thinking how to reliably rewrite the urls myself without collateral damage.
There should be a simpler way, right?
Aparrently Zuul is not capable of rewriting links from the symbolic eureka names to "outside links".
For that I just wrote a Zuul filter that parses the json response, and looks for "links" nodes and rewrites the links to my schema.
For example, my services are named: adminusers and restaurants The result from the service has links like http://adminusers/{id} and http://restaurants/cuisine/{id}
Then it would be rewritten to http://localhost:8090/api/adminusers/{id} and http://localhost:8090/api/restaurants/cuisine/{id}
private String fixLink(String href) {
//Right now all "real" links contain ports and loadbalanced links not
//TODO: precompile regexes
if (!href.matches("http[s]{0,1}://[a-zA-Z0-9]+:[0-9]+.*")) {
String newRef = href.replaceAll("http[s]{0,1}://([a-zA-Z0-9]+)", BasicLinkBuilder.linkToCurrentMapping().toString() + "/api/$1");
LOG.info("OLD: {}", href);
LOG.info("NEW: {}", newRef);
href = newRef;
}
return href;
}
(This needs to be optimized a little, as you could compile the regexp only once, I'll do that once I'm sure that this is what I really need in the long run)
UPDATE
Thomas asked for the full filter code, so here it is. Be aware, it makes some assumptions about the URLs! I assume that internal links do not contain a port and have the servicename as host, which is a valid assumption for eureka based apps, as ribbon etc. are able to work with those. I rewrite that to a link like $PROXY/api/$SERVICENAME/... Feel free to use this code.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.CharStreams;
import com.netflix.util.Pair;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.hateoas.mvc.BasicLinkBuilder;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import static com.google.common.base.Preconditions.checkNotNull;
@Component
public final class ContentUrlRewritingFilter extends ZuulFilter {
private static final Logger LOG = LoggerFactory.getLogger(ContentUrlRewritingFilter.class);
private static final String CONTENT_TYPE = "Content-Type";
private static final ImmutableSet<MediaType> DEFAULT_SUPPORTED_TYPES = ImmutableSet.of(MediaType.APPLICATION_JSON);
private final String replacement;
private final ImmutableSet<MediaType> supportedTypes;
//Right now all "real" links contain ports and loadbalanced links not
private final Pattern detectPattern = Pattern.compile("http[s]{0,1}://[a-zA-Z0-9]+:[0-9]+.*");
private final Pattern replacePattern;
public ContentUrlRewritingFilter() {
this.replacement = checkNotNull("/api/$1");
this.supportedTypes = ImmutableSet.copyOf(checkNotNull(DEFAULT_SUPPORTED_TYPES));
replacePattern = Pattern.compile("http[s]{0,1}://([a-zA-Z0-9]+)");
}
private static boolean containsContent(final RequestContext context) {
assert context != null;
return context.getResponseDataStream() != null || context.getResponseBody() != null;
}
private static boolean supportsType(final RequestContext context, final Collection<MediaType> supportedTypes) {
assert supportedTypes != null;
for (MediaType supportedType : supportedTypes) {
if (supportedType.isCompatibleWith(getResponseMediaType(context))) return true;
}
return false;
}
private static MediaType getResponseMediaType(final RequestContext context) {
assert context != null;
for (final Pair<String, String> header : context.getZuulResponseHeaders()) {
if (header.first().equalsIgnoreCase(CONTENT_TYPE)) {
return MediaType.parseMediaType(header.second());
}
}
return MediaType.APPLICATION_OCTET_STREAM;
}
@Override
public String filterType() {
return "post";
}
@Override
public int filterOrder() {
return 100;
}
@Override
public boolean shouldFilter() {
final RequestContext context = RequestContext.getCurrentContext();
return hasSupportedBody(context);
}
public boolean hasSupportedBody(RequestContext context) {
return containsContent(context) && supportsType(context, this.supportedTypes);
}
@Override
public Object run() {
try {
rewriteContent(RequestContext.getCurrentContext());
} catch (final Exception e) {
Throwables.propagate(e);
}
return null;
}
private void rewriteContent(final RequestContext context) throws Exception {
assert context != null;
String responseBody = getResponseBody(context);
if (responseBody != null) {
ObjectMapper mapper = new ObjectMapper();
LinkedHashMap<String, Object> map = mapper.readValue(responseBody, LinkedHashMap.class);
traverse(map);
String body = mapper.writeValueAsString(map);
context.setResponseBody(body);
}
}
private String getResponseBody(RequestContext context) throws IOException {
String responseData = null;
if (context.getResponseBody() != null) {
context.getResponse().setCharacterEncoding("UTF-8");
responseData = context.getResponseBody();
} else if (context.getResponseDataStream() != null) {
context.getResponse().setCharacterEncoding("UTF-8");
try (final InputStream responseDataStream = context.getResponseDataStream()) {
//FIXME What about character encoding of the stream (depends on the response content type)?
responseData = CharStreams.toString(new InputStreamReader(responseDataStream));
}
}
return responseData;
}
private void traverse(Map<String, Object> node) {
for (Map.Entry<String, Object> entry : node.entrySet()) {
if (entry.getKey().equalsIgnoreCase("links") && entry.getValue() instanceof Collection) {
replaceLinks((Collection<Map<String, String>>) entry.getValue());
} else {
if (entry.getValue() instanceof Collection) {
traverse((Collection) entry.getValue());
} else if (entry.getValue() instanceof Map) {
traverse((Map<String, Object>) entry.getValue());
}
}
}
}
private void traverse(Collection<Map> value) {
for (Object entry : value) {
if (entry instanceof Collection) {
traverse((Collection) entry);
} else if (entry instanceof Map) {
traverse((Map<String, Object>) entry);
}
}
}
private void replaceLinks(Collection<Map<String, String>> value) {
for (Map<String, String> node : value) {
if (node.containsKey("href")) {
node.put("href", fixLink(node.get("href")));
} else {
LOG.debug("Link Node did not contain href! {}", value.toString());
}
}
}
private String fixLink(String href) {
if (!detectPattern.matcher(href).matches()) {
href = replacePattern.matcher(href).replaceAll(BasicLinkBuilder.linkToCurrentMapping().toString() + replacement);
}
return href;
}
}
Improvements are welcome :-)