Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import io.gravitee.gateway.reactive.core.v4.endpoint.EndpointManager;
import io.gravitee.gateway.reactive.core.v4.endpoint.ManagedEndpoint;
import io.reactivex.rxjava3.core.Completable;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand All @@ -49,6 +50,8 @@ public class HttpEndpointInvoker implements HttpInvoker, Invoker {
"^(?<" + MATCH_GROUP_ENDPOINT + ">[^:]+):(?<" + MATCH_GROUP_PATH + ">.*)$"
);

static final Set<String> KNOWN_URL_SCHEMES = Set.of("http", "https", "ws", "wss");

public static final String NO_ENDPOINT_FOUND_KEY = "NO_ENDPOINT_FOUND";
public static final String INVALID_HTTP_METHOD = "INVALID_HTTP_METHOD";
public static final String ATTR_INTERNAL_FAILOVER_MANAGED_ENDPOINT = "failover.managedEndpoint";
Expand Down Expand Up @@ -106,16 +109,16 @@ private <T extends HttpEndpointConnector> T resolveConnector(final HttpExecution

if (endpointTarget != null) {
final String evaluatedTarget = ctx.getTemplateEngine().getValue(endpointTarget, String.class);
if (URIUtils.isAbsolute(evaluatedTarget)) {
ctx.setAttribute(ATTR_REQUEST_ENDPOINT, evaluatedTarget);
} else {
final Matcher matcher = ENDPOINT_PATTERN.matcher(evaluatedTarget);

if (matcher.matches()) {
// Set endpoint name into the criteria.
endpointCriteria.setName(matcher.group(MATCH_GROUP_ENDPOINT));

// Replace the attribute to remove the endpoint reference part ("my-endpoint:/foo/bar' -> '/foo/bar').
final Matcher matcher = ENDPOINT_PATTERN.matcher(evaluatedTarget);

if (matcher.matches()) {
final String endpointName = matcher.group(MATCH_GROUP_ENDPOINT);
if (URIUtils.isAbsolute(evaluatedTarget) && KNOWN_URL_SCHEMES.contains(endpointName.toLowerCase())) {
// Real absolute URL (e.g., http://backend/path)
ctx.setAttribute(ATTR_REQUEST_ENDPOINT, evaluatedTarget);
} else {
// Endpoint reference (e.g., "default:/path" or "default://path" from dynamic routing)
endpointCriteria.setName(endpointName);
ctx.setAttribute(ATTR_REQUEST_ENDPOINT, matcher.group(MATCH_GROUP_PATH));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,45 @@ void shouldConnectAndReplaceWithEvaluatedEndpointAttributeWhenUrlEndpointAttribu
verify(ctx).setAttribute(ATTR_REQUEST_ENDPOINT, "http://api.gravitee.io/echo");
}

@ParameterizedTest
@ValueSource(
strings = { "http://api.gravitee.io/echo", "https://api.gravitee.io/echo", "ws://stream.gravitee.io", "wss://stream.gravitee.io" }
)
void shouldTreatKnownSchemesAsAbsoluteUrls(String url) {
final HttpEntrypointAsyncConnector httpEntrypointAsyncConnector = mock(HttpEntrypointAsyncConnector.class);
when(ctx.getInternalAttribute(ATTR_INTERNAL_ENTRYPOINT_CONNECTOR)).thenReturn(httpEntrypointAsyncConnector);
when(ctx.getAttribute(ATTR_REQUEST_ENDPOINT)).thenReturn(url);
when(ctx.getTemplateEngine()).thenReturn(templateEngine);
when(templateEngine.getValue(anyString(), eq(String.class))).thenAnswer(i -> i.getArgument(0));
when(endpointManager.next(any(EndpointCriteria.class))).thenReturn(managedEndpoint);
when(managedEndpoint.getConnector()).thenReturn(endpointConnector);
when(endpointConnector.connect(ctx)).thenReturn(Completable.complete());

cut.invoke(ctx).test().assertNoValues();

verify(endpointManager).next(argThat(criteria -> criteria.getName() == null));
verify(ctx).setAttribute(ATTR_REQUEST_ENDPOINT, url);
}

@Test
void shouldConnectToNamedEndpointWhenDynamicRoutingProducesDoubleSlash() {
final HttpEntrypointAsyncConnector httpEntrypointAsyncConnector = mock(HttpEntrypointAsyncConnector.class);
when(ctx.getInternalAttribute(ATTR_INTERNAL_ENTRYPOINT_CONNECTOR)).thenReturn(httpEntrypointAsyncConnector);
when(ctx.getAttribute(ATTR_REQUEST_ENDPOINT)).thenReturn("default://foo");
when(ctx.getTemplateEngine()).thenReturn(templateEngine);
when(templateEngine.getValue(anyString(), eq(String.class))).thenAnswer(i -> i.getArgument(0));
when(endpointManager.next(any(EndpointCriteria.class))).thenReturn(managedEndpoint);
when(managedEndpoint.getConnector()).thenReturn(endpointConnector);
when(endpointConnector.connect(ctx)).thenReturn(Completable.complete());

final TestObserver<Void> obs = cut.invoke(ctx).test();

obs.assertNoValues();

verify(endpointManager).next(argThat(criteria -> criteria.getName().equals("default")));
verify(ctx).setAttribute(ATTR_REQUEST_ENDPOINT, "//foo");
}

@Test
void shouldFailWith503WhenNoEndpointConnectorHasBeenResolved() {
final HttpEntrypointAsyncConnector httpEntrypointAsyncConnector = mock(HttpEntrypointAsyncConnector.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright © 2015 The Gravitee team (http://gravitee.io)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.gravitee.apim.integration.tests.http.dynamicrouting;

import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.ok;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static org.assertj.core.api.Assertions.assertThat;

import io.gravitee.apim.gateway.tests.sdk.AbstractGatewayTest;
import io.gravitee.apim.gateway.tests.sdk.annotations.DeployApi;
import io.gravitee.apim.gateway.tests.sdk.annotations.GatewayTest;
import io.gravitee.apim.gateway.tests.sdk.connector.EndpointBuilder;
import io.gravitee.apim.gateway.tests.sdk.connector.EntrypointBuilder;
import io.gravitee.apim.gateway.tests.sdk.policy.PolicyBuilder;
import io.gravitee.plugin.endpoint.EndpointConnectorPlugin;
import io.gravitee.plugin.endpoint.http.proxy.HttpProxyEndpointConnectorFactory;
import io.gravitee.plugin.entrypoint.EntrypointConnectorPlugin;
import io.gravitee.plugin.entrypoint.http.proxy.HttpProxyEntrypointConnectorFactory;
import io.gravitee.plugin.policy.PolicyPlugin;
import io.gravitee.policy.dynamicrouting.DynamicRoutingPolicy;
import io.gravitee.policy.dynamicrouting.configuration.DynamicRoutingPolicyConfiguration;
import io.vertx.core.http.HttpMethod;
import io.vertx.rxjava3.core.http.HttpClient;
import io.vertx.rxjava3.core.http.HttpClientRequest;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Test;

@GatewayTest
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class DynamicRoutingV4IntegrationTest extends AbstractGatewayTest {

@Override
public void configurePolicies(Map<String, PolicyPlugin> policies) {
policies.put(
"dynamic-routing",
PolicyBuilder.build("dynamic-routing", DynamicRoutingPolicy.class, DynamicRoutingPolicyConfiguration.class)
);
}

@Override
public void configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints) {
entrypoints.putIfAbsent("http-proxy", EntrypointBuilder.build("http-proxy", HttpProxyEntrypointConnectorFactory.class));
}

@Override
public void configureEndpoints(Map<String, EndpointConnectorPlugin<?, ?>> endpoints) {
endpoints.putIfAbsent("http-proxy", EndpointBuilder.build("http-proxy", HttpProxyEndpointConnectorFactory.class));
}

@Test
@DisplayName("Should route correctly when endpoints reference and group capture produce double slash")
@DeployApi("/apis/v4/http/dynamic-routing/api-endpoint-reference-with-group-capture.json")
void should_route_when_endpoint_reference_and_group_capture_produce_double_slash(HttpClient httpClient) throws InterruptedException {
wiremock.stubFor(get("//hello").willReturn(ok("response from backend")));

httpClient
.rxRequest(HttpMethod.GET, "/test/plan/hello")
.flatMap(HttpClientRequest::rxSend)
.flatMapPublisher(response -> {
assertThat(response.statusCode()).isEqualTo(200);
return response.toFlowable();
})
.test()
.await()
.assertComplete()
.assertValue(body -> {
assertThat(body).hasToString("response from backend");
return true;
})
.assertNoErrors();

wiremock.verify(getRequestedFor(urlPathEqualTo("//hello")));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;

import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import com.github.tomakehurst.wiremock.stubbing.Scenario;
import com.graviteesource.entrypoint.http.get.HttpGetEntrypointConnectorFactory;
import com.graviteesource.reactor.message.MessageApiReactorFactory;
Expand Down Expand Up @@ -70,6 +71,8 @@
import io.gravitee.policy.apikey.ApiKeyPolicy;
import io.gravitee.policy.apikey.ApiKeyPolicyInitializer;
import io.gravitee.policy.apikey.configuration.ApiKeyPolicyConfiguration;
import io.gravitee.policy.dynamicrouting.DynamicRoutingPolicy;
import io.gravitee.policy.dynamicrouting.configuration.DynamicRoutingPolicyConfiguration;
import io.gravitee.reporter.api.v4.metric.Metrics;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Flowable;
Expand Down Expand Up @@ -1186,6 +1189,114 @@ void should_success_on_second_retry(HttpClient client) {
}
}

@Nested
@GatewayTest
class DynamicRoutingGroupCaptureWithFailover extends AbstractGatewayTest {

protected final int wiremockPort = getAvailablePort();

@Override
protected void configureWireMock(WireMockConfiguration configuration) {
configuration.port(wiremockPort);
}

@Override
public void configurePolicies(Map<String, PolicyPlugin> policies) {
policies.put(
"dynamic-routing",
PolicyBuilder.build("dynamic-routing", DynamicRoutingPolicy.class, DynamicRoutingPolicyConfiguration.class)
);
}

@Override
public void configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints) {
entrypoints.putIfAbsent("http-proxy", EntrypointBuilder.build("http-proxy", HttpProxyEntrypointConnectorFactory.class));
}

@Override
public void configureEndpoints(Map<String, EndpointConnectorPlugin<?, ?>> endpoints) {
endpoints.putIfAbsent("http-proxy", EndpointBuilder.build("http-proxy", HttpProxyEndpointConnectorFactory.class));
}

@Override
public void configureApi(ReactableApi<?> api, Class<?> definitionClass) {
if (isLegacyApi(definitionClass)) {
throw new IllegalStateException("should be testing a v4 API");
}
final Api definition = (Api) api.getDefinition();
definition
.getEndpointGroups()
.stream()
.filter(group -> group.getName().equals("second"))
.flatMap(group -> group.getEndpoints().stream())
.forEach(endpoint ->
endpoint.setConfiguration(endpoint.getConfiguration().replace("8080", Integer.toString(wiremockPort)))
);
var manager = applicationContext.getBean(ApiManager.class);
manager.unregister(api.getId());
manager.register(api);
}

@Test
@DeployApi("/apis/v4/http/failover/api-failover-dynamic-routing-group-capture.json")
void should_route_to_second_group_when_group_capture_produces_double_slash(HttpClient client) {
wiremock.stubFor(get("/second-group//hello").willReturn(ok("ok from backend")));

client
.rxRequest(HttpMethod.GET, "/test/plan/hello")
.flatMap(HttpClientRequest::rxSend)
.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(200);
return response.body();
})
.test()
.awaitDone(30, TimeUnit.SECONDS)
.assertComplete()
.assertValue(response -> {
assertThat(response).hasToString("ok from backend");
return true;
});

wiremock.verify(getRequestedFor(urlPathEqualTo("/second-group//hello")));
}

@Test
@DeployApi("/apis/v4/http/failover/api-failover-dynamic-routing-group-capture.json")
void should_retry_to_second_group_on_slow_first_call(HttpClient client) {
wiremock.stubFor(
get("/second-group//hello")
.inScenario("Slow first call")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(ok("ok from backend").withFixedDelay(750))
.willSetStateTo("First Retry")
);

wiremock.stubFor(
get("/second-group//hello")
.inScenario("Slow first call")
.whenScenarioStateIs("First Retry")
.willReturn(ok("ok from backend"))
);

client
.rxRequest(HttpMethod.GET, "/test/plan/hello")
.flatMap(HttpClientRequest::rxSend)
.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(200);
return response.body();
})
.test()
.awaitDone(30, TimeUnit.SECONDS)
.assertComplete()
.assertValue(response -> {
assertThat(response).hasToString("ok from backend");
return true;
});

wiremock.verify(2, getRequestedFor(urlPathEqualTo("/second-group//hello")));
}
}

@NonNull
private static Flowable<JsonObject> extractPlainTextMessages(Buffer body) {
final List<JsonObject> messages = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"id": "dynamic-routing-endpoint-ref",
"name": "dynamic-routing-endpoint-ref",
"gravitee": "4.0.0",
"type": "proxy",
"listeners": [
{
"type": "http",
"paths": [
{
"path": "/test"
}
],
"entrypoints": [
{
"type": "http-proxy"
}
]
}
],
"endpointGroups": [
{
"name": "default",
"type": "http-proxy",
"endpoints": [
{
"name": "backend",
"type": "http-proxy",
"weight": 1,
"inheritConfiguration": false,
"configuration": {
"target": "http://localhost:8080"
}
}
]
}
],
"flows": [
{
"name": "dynamic-routing-flow",
"enabled": true,
"selectors": [
{
"type": "http",
"path": "/",
"pathOperator": "START_WITH",
"methods": ["GET"]
}
],
"request": [
{
"name": "Dynamic routing",
"enabled": true,
"policy": "dynamic-routing",
"configuration": {
"rules": [
{
"pattern": "/plan(.*)",
"url": "{#endpoints['default']}/{#group[0]}"
}
]
}
}
]
}
],
"plans": [
{
"id": "plan-keyless",
"status": "published",
"type": "API",
"definitionVersion": "V4",
"security": {
"type": "key-less",
"configuration": {}
},
"mode": "standard",
"flows": [],
"name": "default"
}
]
}
Loading