diff --git a/docs/src/main/asciidoc/spring-cloud-netflix.adoc b/docs/src/main/asciidoc/spring-cloud-netflix.adoc index 6ef71485..cf37eff9 100644 --- a/docs/src/main/asciidoc/spring-cloud-netflix.adoc +++ b/docs/src/main/asciidoc/spring-cloud-netflix.adoc @@ -1093,6 +1093,26 @@ users: listOfServers: example.com,google.com ---- +You can provide convention between serviceId and routes using regexmapper. +It uses regular expression named group to extract variables from serviceId and inject them +into a route pattern. + +.application.yml +[source,yaml] +---- + zuul: + regexMapper: + enabled: true + servicePattern: "(?^.+)-(?v.+$)" + routePattern: "${version}/${name}" +---- + +This means that a serviceId "myusers-v1" will be mapped to route "/v1/myusers/**". +Any regular expression is accepted but all named group must be present in both servicePattern and routePattern. +If servicePattern do not match a serviceId, the default behavior is used. In exemple above, +a serviceId "myusers" will be mapped to route "/myusers/**" (no version detected) +These feature is disable by default and is only applied to discovered services. + To add a prefix to all mappings, set `zuul.prefix` to a value, such as `/api`. The proxy prefix is stripped from the request before the request is forwarded by default (switch this behaviour off with diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java index d2bd54e6..83eb924c 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.boot.actuate.endpoint.Endpoint; import org.springframework.boot.actuate.trace.TraceRepository; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.cloud.client.actuator.HasFeatures; import org.springframework.cloud.client.discovery.DiscoveryClient; @@ -32,8 +33,11 @@ import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEven import org.springframework.cloud.netflix.ribbon.SpringClientFactory; import org.springframework.cloud.netflix.zuul.filters.ProxyRequestHelper; import org.springframework.cloud.netflix.zuul.filters.ProxyRouteLocator; +import org.springframework.cloud.netflix.zuul.filters.ServiceRouteMapper; +import org.springframework.cloud.netflix.zuul.filters.SimpleServiceRouteMapper; import org.springframework.cloud.netflix.zuul.filters.ZuulProperties; import org.springframework.cloud.netflix.zuul.filters.pre.PreDecorationFilter; +import org.springframework.cloud.netflix.zuul.filters.regex.RegExServiceRouteMapper; import org.springframework.cloud.netflix.zuul.filters.route.RestClientRibbonCommandFactory; import org.springframework.cloud.netflix.zuul.filters.route.RibbonCommandFactory; import org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter; @@ -66,6 +70,9 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { @Autowired private ServerProperties server; + @Autowired + private ServiceRouteMapper serviceRouteMapper; + @Override public HasFeatures zuulFeature() { return HasFeatures.namedFeature("Zuul (Discovery)", ZuulProxyConfiguration.class); @@ -75,7 +82,7 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { @Override public ProxyRouteLocator routeLocator() { return new ProxyRouteLocator(this.server.getServletPrefix(), this.discovery, - this.zuulProperties); + this.zuulProperties, serviceRouteMapper); } @Bean @@ -86,8 +93,8 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { // pre filters @Bean - public PreDecorationFilter preDecorationFilter() { - return new PreDecorationFilter(routeLocator(), + public PreDecorationFilter preDecorationFilter(ProxyRouteLocator routeLocator) { + return new PreDecorationFilter(routeLocator, this.zuulProperties.isAddProxyHeaders()); } @@ -117,6 +124,27 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { return new ZuulRefreshListener(); } + @Configuration + @ConditionalOnProperty(name = "zuul.regexMapper.enabled", matchIfMissing = false) + protected static class RegexServiceRouteMapperConfiguration { + + @Bean + public ServiceRouteMapper serviceRouteMapper(ZuulProperties props) { + return new RegExServiceRouteMapper(props.getRegexMapper().getServicePattern(), + props.getRegexMapper().getRoutePattern()); + } + } + + @Configuration + @ConditionalOnMissingBean(ServiceRouteMapper.class) + protected static class SimpleServiceRouteMapperConfiguration { + @Bean + public ServiceRouteMapper serviceRouteMapper() { + return new SimpleServiceRouteMapper(); + } + } + + @Configuration @ConditionalOnClass(Endpoint.class) protected static class RoutesEndpointConfiguration { diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocator.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocator.java index b0b1e70c..c059bba6 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocator.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocator.java @@ -55,6 +55,8 @@ public class ProxyRouteLocator implements RouteLocator { private String servletPath; + private ServiceRouteMapper serviceRouteMapper; + public ProxyRouteLocator(String servletPath, DiscoveryClient discovery, ZuulProperties properties) { if (StringUtils.hasText(servletPath)) { // a servletPath is passed explicitly @@ -75,11 +77,17 @@ public class ProxyRouteLocator implements RouteLocator { } } } - + this.serviceRouteMapper = new SimpleServiceRouteMapper(); this.discovery = discovery; this.properties = properties; } + public ProxyRouteLocator(String servletPath, DiscoveryClient discovery, + ZuulProperties properties, ServiceRouteMapper serviceRouteMapper) { + this(servletPath, discovery, properties); + this.serviceRouteMapper = serviceRouteMapper; + } + public void addRoute(String path, String location) { this.staticRoutes.put(path, new ZuulRoute(path, location)); resetRoutes(); @@ -196,7 +204,7 @@ public class ProxyRouteLocator implements RouteLocator { for (String serviceId : services) { // Ignore specifically ignored services and those that were manually // configured - String key = "/" + serviceId + "/**"; + String key = "/" + mapRouteToService(serviceId) + "/**"; if (staticServices.containsKey(serviceId) && staticServices.get(serviceId).getUrl() == null) { // Explicitly configured with no URL, cannot be ignored @@ -238,6 +246,10 @@ public class ProxyRouteLocator implements RouteLocator { return values; } + protected String mapRouteToService(String serviceId) { + return this.serviceRouteMapper.apply(serviceId); + } + protected void addConfiguredRoutes(Map routes) { Map routeEntries = this.properties.getRoutes(); for (ZuulRoute entry : routeEntries.values()) { diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ServiceRouteMapper.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ServiceRouteMapper.java new file mode 100644 index 00000000..f05bc6a2 --- /dev/null +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ServiceRouteMapper.java @@ -0,0 +1,18 @@ +package org.springframework.cloud.netflix.zuul.filters; + +/** + * @author Stéphane LEROY + * + * Provide a way to apply convention between routes and discovered services name. + * + */ +public interface ServiceRouteMapper { + + /** + * Take a service Id (its discovered name) and return a route path. + * + * @param serviceId service discovered name + * @return route path + */ + String apply(String serviceId); +} diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleServiceRouteMapper.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleServiceRouteMapper.java new file mode 100644 index 00000000..b7d7eefa --- /dev/null +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleServiceRouteMapper.java @@ -0,0 +1,13 @@ +package org.springframework.cloud.netflix.zuul.filters; + +/** + * @author Stéphane Leroy + * + * A simple passthru service route mapper. + */ +public class SimpleServiceRouteMapper implements ServiceRouteMapper { + @Override + public String apply(String serviceId) { + return serviceId; + } +} diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java index a5a2a4be..4aec27a7 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java @@ -21,16 +21,15 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; - import javax.annotation.PostConstruct; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; + import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.util.StringUtils; - /** * @author Spencer Gibb * @author Dave Syer @@ -57,6 +56,8 @@ public class ZuulProperties { private boolean ignoreLocalService = true; + private RegexMapper regexMapper = new RegexMapper(); + @PostConstruct public void init() { for (Entry entry : this.routes.entrySet()) { @@ -73,6 +74,17 @@ public class ZuulProperties { } } + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class RegexMapper { + private boolean enabled = false; + + private String servicePattern = "(?.*)-(?v.*$)"; + + private String routePattern = "${version}/${name}"; + } + @Data @AllArgsConstructor @NoArgsConstructor diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapper.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapper.java new file mode 100644 index 00000000..9f76e7bb --- /dev/null +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapper.java @@ -0,0 +1,70 @@ +package org.springframework.cloud.netflix.zuul.filters.regex; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.cloud.netflix.zuul.filters.ServiceRouteMapper; +import org.springframework.util.StringUtils; + +/** + * @author Stéphane Leroy + * + * This service route mapper use Java 7 RegEx named group feature to rewrite a discovered + * service Id into a route. + * + * Ex : If we want to map service Id [rest-service-v1] to /v1/rest-service/** route + * service pattern : "(?.*)-(?v.*$)" route pattern : "${version}/${name}" + * + * /!\ This implementation use Matcher.replaceFirst so only one match will be replace. + */ +public class RegExServiceRouteMapper implements ServiceRouteMapper { + + /** + * A RegExp Pattern that extract needed information from a service ID. Ex : + * "(?.*)-(?v.*$)" + */ + private Pattern servicePattern; + /** + * A RegExp that refer to named groups define in servicePattern. Ex : + * "${version}/${name}" + */ + private String routePattern; + + public RegExServiceRouteMapper(String servicePattern, String routePattern) { + this.servicePattern = Pattern.compile(servicePattern); + this.routePattern = routePattern; + } + + /** + * Use servicePattern to extract groups and routePattern to construct the route. + * + * If there is no matches, the serviceId is returned. + * + * @param serviceId service discovered name + * @return route path + */ + @Override + public String apply(String serviceId) { + Matcher matcher = servicePattern.matcher(serviceId); + String route = matcher.replaceFirst(routePattern); + route = cleanRoute(route); + return (StringUtils.hasText(route) ? route : serviceId); + } + + /** + * Route with regex and replace can be a bit messy when used with conditional named + * group. We clean here first and trailing '/' and remove multiple consecutive '/' + * @param route + * @return + */ + private String cleanRoute(final String route) { + String routeToClean = route.replaceAll("/{2,}", "/"); + if (routeToClean.startsWith("/")) { + routeToClean = routeToClean.substring(1); + } + if (routeToClean.endsWith("/")) { + routeToClean = routeToClean.substring(0, routeToClean.length() - 1); + } + return routeToClean; + } +} diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocatorTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocatorTests.java index 22a96078..d287366e 100644 --- a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocatorTests.java +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocatorTests.java @@ -16,14 +16,6 @@ package org.springframework.cloud.netflix.zuul.filters; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.BDDMockito.given; -import static org.mockito.MockitoAnnotations.initMocks; - import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -35,8 +27,17 @@ import org.springframework.cloud.client.DefaultServiceInstance; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.netflix.zuul.filters.ProxyRouteLocator.ProxyRouteSpec; import org.springframework.cloud.netflix.zuul.filters.ZuulProperties.ZuulRoute; +import org.springframework.cloud.netflix.zuul.filters.regex.RegExServiceRouteMapper; import org.springframework.core.env.ConfigurableEnvironment; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.BDDMockito.given; +import static org.mockito.MockitoAnnotations.initMocks; + /** * @author Spencer Gibb * @author Dave Syer @@ -518,6 +519,34 @@ public class ProxyRouteLocatorTests { assertMapping(routesMap, MYSERVICE); } + @Test + public void testRegExServiceRouteMapperNoServiceIdMatches() { + given(this.discovery.getServices()).willReturn(Collections.singletonList(MYSERVICE)); + + RegExServiceRouteMapper regExServiceRouteMapper = new RegExServiceRouteMapper(properties.getRegexMapper().getServicePattern(), + properties.getRegexMapper().getRoutePattern()); + ProxyRouteLocator routeLocator = new ProxyRouteLocator("/", this.discovery, + this.properties, regExServiceRouteMapper); + Map routesMap = routeLocator.getRoutes(); + assertNotNull("routesMap was null", routesMap); + assertFalse("routesMap was empty", routesMap.isEmpty()); + assertMapping(routesMap, MYSERVICE); + } + + @Test + public void testRegExServiceRouteMapperServiceIdMatches() { + given(this.discovery.getServices()).willReturn(Collections.singletonList("rest-service-v1")); + + RegExServiceRouteMapper regExServiceRouteMapper = new RegExServiceRouteMapper(properties.getRegexMapper().getServicePattern(), + properties.getRegexMapper().getRoutePattern()); + ProxyRouteLocator routeLocator = new ProxyRouteLocator("/", this.discovery, + this.properties, regExServiceRouteMapper); + Map routesMap = routeLocator.getRoutes(); + assertNotNull("routesMap was null", routesMap); + assertFalse("routesMap was empty", routesMap.isEmpty()); + assertMapping(routesMap, "rest-service-v1", "v1/rest-service"); + } + protected void assertMapping(Map routesMap, String serviceId) { assertMapping(routesMap, serviceId, serviceId); diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperIntegrationTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperIntegrationTests.java new file mode 100644 index 00000000..562dd951 --- /dev/null +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperIntegrationTests.java @@ -0,0 +1,127 @@ +package org.springframework.cloud.netflix.zuul.filters.regex; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.boot.test.TestRestTemplate; +import org.springframework.boot.test.WebIntegrationTest; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.cloud.netflix.ribbon.RibbonClient; +import org.springframework.cloud.netflix.ribbon.StaticServerList; +import org.springframework.cloud.netflix.zuul.EnableZuulProxy; +import org.springframework.cloud.netflix.zuul.RoutesEndpoint; +import org.springframework.cloud.netflix.zuul.filters.ProxyRouteLocator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import com.netflix.loadbalancer.Server; +import com.netflix.loadbalancer.ServerList; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.netflix.zuul.filters.regex.RegExServiceRouteMapperIntegrationTests.SERVICE_ID; + +/** + * @author Stéphane Leroy + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = SampleCustomZuulProxyApplication.class) +@WebIntegrationTest(value = { "spring.application.name=regex-test-application", + "spring.jmx.enabled=true" }, randomPort = true) +@TestPropertySource(properties = { "eureka.client.enabled=false", + "zuul.regexMapper.enabled=true", + "zuul.regexMapper.servicePattern=(?^.+)-(?.+)-(?v.+$)", + "zuul.regexMapper.routePattern=${version}/${domain}/${name}" }) +@DirtiesContext +public class RegExServiceRouteMapperIntegrationTests { + + protected static final String SERVICE_ID = "domain-service-v1"; + + @Value("${local.server.port}") + private int port; + + @Autowired + private ProxyRouteLocator routes; + + @Autowired + private RoutesEndpoint endpoint; + + @Test + public void getRegexMappedService() { + endpoint.reset(); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/v1/domain/service/get/1", + HttpMethod.GET, new HttpEntity<>((Void) null), String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Get 1", result.getBody()); + } + + @Test + public void getStaticRoute() { + this.routes.addRoute("/self/**", "http://localhost:" + this.port); + endpoint.reset(); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/self/get/1", HttpMethod.GET, + new HttpEntity<>((Void) null), String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Get 1", result.getBody()); + } + +} + +@Configuration +@EnableAutoConfiguration +@RestController +@EnableZuulProxy +@RibbonClient(value = SERVICE_ID, configuration = SimpleRibbonClientConfiguration.class) +class SampleCustomZuulProxyApplication { + + @Bean + public DiscoveryClient discoveryClient() { + DiscoveryClient discoveryClient = mock(DiscoveryClient.class); + List services = new ArrayList<>(); + services.add(SERVICE_ID); + when(discoveryClient.getServices()).thenReturn(services); + return discoveryClient; + } + + @RequestMapping(value = "/get/{id}", method = RequestMethod.GET) + public String get(@PathVariable String id) { + return "Get " + id; + } + + public static void main(String[] args) { + SpringApplication.run(SampleCustomZuulProxyApplication.class, args); + } +} + +@Configuration +class SimpleRibbonClientConfiguration { + + @Value("${local.server.port}") + private int port = 0; + + @Bean + public ServerList ribbonServerList() { + return new StaticServerList<>(new Server("localhost", this.port)); + } +} diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperTests.java new file mode 100644 index 00000000..02dac1b3 --- /dev/null +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperTests.java @@ -0,0 +1,48 @@ +package org.springframework.cloud.netflix.zuul.filters.regex; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +/** + * @author Stéphane Leroy + */ +public class RegExServiceRouteMapperTests { + + /** + * Service pattern that follow convention {domain}-{name}-{version}. The name is + * optional + */ + public static final String SERVICE_PATTERN = "(?^\\w+)(-(?\\w+)-|-)(?v\\d+$)"; + public static final String ROUTE_PATTERN = "${version}/${domain}/${name}"; + + @Test + public void test_return_mapped_route_if_serviceid_matches() { + RegExServiceRouteMapper toTest = new RegExServiceRouteMapper(SERVICE_PATTERN, + ROUTE_PATTERN); + + assertEquals("service version convention", "v1/rest/service", + toTest.apply("rest-service-v1")); + } + + @Test + public void test_return_serviceid_if_no_matches() { + RegExServiceRouteMapper toTest = new RegExServiceRouteMapper(SERVICE_PATTERN, + ROUTE_PATTERN); + + // No version here + assertEquals("No matches for this service id", "rest-service", + toTest.apply("rest-service")); + } + + @Test + public void test_route_should_be_cleaned_before_returned() { + // Messy patterns + RegExServiceRouteMapper toTest = new RegExServiceRouteMapper(SERVICE_PATTERN + + "(?.)?", "/${version}/${nevermatch}/${domain}/${name}/"); + assertEquals("No matches for this service id", "v1/domain/service", + toTest.apply("domain-service-v1")); + assertEquals("No matches for this service id", "v1/domain", + toTest.apply("domain-v1")); + } +}