From cff1788c9cb0f4b488dabcfc4e8643bdda6ee061 Mon Sep 17 00:00:00 2001 From: Alexandre Dutra Date: Wed, 17 May 2023 20:06:55 +0200 Subject: [PATCH] Add an API compatibility check to Nessie clients (#6818) --- api/client/build.gradle.kts | 2 + .../client/NessieConfigConstants.java | 6 + .../client/http/HttpClientBuilder.java | 20 +++ .../client/http/NessieApiCompatibility.java | 48 +++++++ .../http/NessieApiCompatibilityException.java | 81 ++++++++++++ .../http/TestNessieApiCompatibility.java | 123 ++++++++++++++++++ .../TestNessieApiCompatibilityException.java | 45 +++++++ .../client/http/TestNessieHttpClient.java | 90 +++++++++++++ .../model/NessieConfiguration.java | 13 ++ .../model/TestModelObjectsSerialization.java | 2 + .../tools/compatibility/api/Version.java | 4 + .../internal/AbstractNessieApiHolder.java | 21 ++- .../internal/TestNessieApiHolder.java | 10 +- .../compatibility-tests/build.gradle.kts | 2 +- .../tests/AbstractCompatibilityTests.java | 19 ++- .../compatibility/tests/ITOlderServers.java | 40 +++++- .../META-INF/nessie-compatibility.properties | 6 +- gradle/libs.versions.toml | 2 + .../services/rest/RestConfigService.java | 2 +- .../services/rest/RestV2ConfigResource.java | 2 +- .../services/impl/ConfigApiImpl.java | 5 +- .../services/impl/AbstractTestMisc.java | 1 + .../services/impl/BaseTestServiceImpl.java | 2 +- 23 files changed, 530 insertions(+), 16 deletions(-) create mode 100644 api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibility.java create mode 100644 api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibilityException.java create mode 100644 api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibility.java create mode 100644 api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibilityException.java diff --git a/api/client/build.gradle.kts b/api/client/build.gradle.kts index fa270f41cfa..845ca2053f8 100644 --- a/api/client/build.gradle.kts +++ b/api/client/build.gradle.kts @@ -80,6 +80,8 @@ dependencies { testFixturesApi(libs.undertow.servlet) testFixturesImplementation(libs.logback.classic) + testImplementation(libs.wiremock) + intTestImplementation(libs.testcontainers.testcontainers) intTestImplementation(libs.testcontainers.junit) intTestImplementation(libs.testcontainers.keycloak) { diff --git a/api/client/src/main/java/org/projectnessie/client/NessieConfigConstants.java b/api/client/src/main/java/org/projectnessie/client/NessieConfigConstants.java index 2fddda8a4ae..916f6f910b2 100644 --- a/api/client/src/main/java/org/projectnessie/client/NessieConfigConstants.java +++ b/api/client/src/main/java/org/projectnessie/client/NessieConfigConstants.java @@ -312,6 +312,12 @@ public final class NessieConfigConstants { public static final String CONF_FORCE_URL_CONNECTION_CLIENT = "nessie.force-url-connection-client"; + /** + * Enables API compatibility check when creating the Nessie client. The default is {@code true}. + */ + public static final String CONF_ENABLE_API_COMPATIBILITY_CHECK = + "nessie.enable-api-compatibility-check"; + public static final int DEFAULT_READ_TIMEOUT_MILLIS = 25000; public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 5000; diff --git a/api/client/src/main/java/org/projectnessie/client/http/HttpClientBuilder.java b/api/client/src/main/java/org/projectnessie/client/http/HttpClientBuilder.java index 40f264e6831..7ebd57cb05d 100644 --- a/api/client/src/main/java/org/projectnessie/client/http/HttpClientBuilder.java +++ b/api/client/src/main/java/org/projectnessie/client/http/HttpClientBuilder.java @@ -16,6 +16,7 @@ package org.projectnessie.client.http; import static org.projectnessie.client.NessieConfigConstants.CONF_CONNECT_TIMEOUT; +import static org.projectnessie.client.NessieConfigConstants.CONF_ENABLE_API_COMPATIBILITY_CHECK; import static org.projectnessie.client.NessieConfigConstants.CONF_FORCE_URL_CONNECTION_CLIENT; import static org.projectnessie.client.NessieConfigConstants.CONF_NESSIE_DISABLE_COMPRESSION; import static org.projectnessie.client.NessieConfigConstants.CONF_NESSIE_HTTP_2; @@ -67,6 +68,8 @@ public class HttpClientBuilder implements NessieClientBuilder private boolean tracing; + private boolean enableApiCompatibilityCheck = true; + protected HttpClientBuilder() {} public static HttpClientBuilder builder() { @@ -174,6 +177,11 @@ public HttpClientBuilder fromConfig(Function configuration) { withForceUrlConnectionClient(Boolean.parseBoolean(s.trim())); } + s = configuration.apply(CONF_ENABLE_API_COMPATIBILITY_CHECK); + if (s != null) { + withEnableApiCompatibilityCheck(Boolean.parseBoolean(s)); + } + return this; } @@ -304,6 +312,12 @@ public HttpClientBuilder withForceUrlConnectionClient(boolean forceUrlConnection return this; } + @CanIgnoreReturnValue + public HttpClientBuilder withEnableApiCompatibilityCheck(boolean enable) { + enableApiCompatibilityCheck = enable; + return this; + } + @CanIgnoreReturnValue public HttpClientBuilder withResponseFactory(HttpResponseFactory responseFactory) { builder.setResponseFactory(responseFactory); @@ -323,12 +337,18 @@ public API build(Class apiVersion) { if (apiVersion.isAssignableFrom(HttpApiV1.class)) { builder.setJsonView(Views.V1.class); HttpClient httpClient = builder.build(); + if (enableApiCompatibilityCheck) { + NessieApiCompatibility.check(1, httpClient); + } return (API) new HttpApiV1(new NessieHttpClient(httpClient)); } if (apiVersion.isAssignableFrom(HttpApiV2.class)) { builder.setJsonView(Views.V2.class); HttpClient httpClient = builder.build(); + if (enableApiCompatibilityCheck) { + NessieApiCompatibility.check(2, httpClient); + } return (API) new HttpApiV2(httpClient); } diff --git a/api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibility.java b/api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibility.java new file mode 100644 index 00000000000..00442c8be4b --- /dev/null +++ b/api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibility.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.http; + +import com.fasterxml.jackson.databind.JsonNode; + +public class NessieApiCompatibility { + + private static final String MIN_API_VERSION = "minSupportedApiVersion"; + private static final String MAX_API_VERSION = "maxSupportedApiVersion"; + private static final String ACTUAL_API_VERSION = "actualApiVersion"; + + /** + * Checks if the API version of the client is compatible with the server's. + * + * @param clientApiVersion the API version of the client + * @param httpClient the underlying HTTP client. + * @throws NessieApiCompatibilityException if the API version is not compatible. + */ + public static void check(int clientApiVersion, HttpClient httpClient) + throws NessieApiCompatibilityException { + JsonNode config = httpClient.newRequest().path("config").get().readEntity(JsonNode.class); + int minServerApiVersion = + config.hasNonNull(MIN_API_VERSION) ? config.get(MIN_API_VERSION).asInt() : 1; + int maxServerApiVersion = config.get(MAX_API_VERSION).asInt(); + int actualServerApiVersion = + config.hasNonNull(ACTUAL_API_VERSION) ? config.get(ACTUAL_API_VERSION).asInt() : 0; + if (clientApiVersion < minServerApiVersion + || clientApiVersion > maxServerApiVersion + || (actualServerApiVersion > 0 && clientApiVersion != actualServerApiVersion)) { + throw new NessieApiCompatibilityException( + clientApiVersion, minServerApiVersion, maxServerApiVersion, actualServerApiVersion); + } + } +} diff --git a/api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibilityException.java b/api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibilityException.java new file mode 100644 index 00000000000..77266a8f77e --- /dev/null +++ b/api/client/src/main/java/org/projectnessie/client/http/NessieApiCompatibilityException.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.http; + +public class NessieApiCompatibilityException extends RuntimeException { + + private final int clientApiVersion; + private final int minServerApiVersion; + private final int maxServerApiVersion; + private final int actualServerApiVersion; + + public NessieApiCompatibilityException( + int clientApiVersion, + int minServerApiVersion, + int maxServerApiVersion, + int actualServerApiVersion) { + super( + formatMessage( + clientApiVersion, minServerApiVersion, maxServerApiVersion, actualServerApiVersion)); + this.clientApiVersion = clientApiVersion; + this.minServerApiVersion = minServerApiVersion; + this.maxServerApiVersion = maxServerApiVersion; + this.actualServerApiVersion = actualServerApiVersion; + } + + private static String formatMessage( + int clientApiVersion, + int minServerApiVersion, + int maxServerApiVersion, + int actualServerApiVersion) { + if (clientApiVersion < minServerApiVersion) { + return String.format( + "API version %d is too old for server (minimum supported version is %d)", + clientApiVersion, minServerApiVersion); + } + if (clientApiVersion > maxServerApiVersion) { + return String.format( + "API version %d is too new for server (maximum supported version is %d)", + clientApiVersion, maxServerApiVersion); + } + return String.format( + "API version mismatch, check URI prefix (expected: %d, actual: %d)", + clientApiVersion, actualServerApiVersion); + } + + /** The client's API version. */ + public int getClientApiVersion() { + return clientApiVersion; + } + + /** The minimum API version supported by the server. */ + public int getMinServerApiVersion() { + return minServerApiVersion; + } + + /** The maximum API version supported by the server. */ + public int getMaxServerApiVersion() { + return maxServerApiVersion; + } + + /** + * The actual API version used by the server, or zero if the server does not report its actual API + * version. + */ + public int getActualServerApiVersion() { + return actualServerApiVersion; + } +} diff --git a/api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibility.java b/api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibility.java new file mode 100644 index 00000000000..1052a098888 --- /dev/null +++ b/api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibility.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static java.net.HttpURLConnection.HTTP_OK; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.projectnessie.client.rest.NessieHttpResponseFilter; + +@ExtendWith(MockitoExtension.class) +@WireMockTest +class TestNessieApiCompatibility { + + enum Expectation { + OK, + TOO_OLD, + TOO_NEW, + MISMATCH; + + public String expectedErrorMessage() { + switch (this) { + case TOO_OLD: + return "too old"; + case TOO_NEW: + return "too new"; + case MISMATCH: + return "mismatch"; + default: + return null; + } + } + } + + @ParameterizedTest + @CsvSource( + value = { + "1, 1, 1, 0, OK", + "1, 1, 2, 1, OK", + "1, 1, 2, 2, MISMATCH", // v2 endpoint mistakenly called with v1 client + "1, 2, 2, 2, TOO_OLD", + "2, 1, 1, 1, TOO_NEW", + "2, 1, 2, 1, MISMATCH", // v1 endpoint mistakenly called with v2 client + "2, 1, 2, 2, OK", + "2, 2, 2, 2, OK", + }) + void checkApiCompatibility( + int client, + int serverMin, + int serverMax, + int serverActual, + Expectation expectation, + WireMockRuntimeInfo wireMock) { + + ObjectNode config = JsonNodeFactory.instance.objectNode(); + config.set("minSupportedApiVersion", JsonNodeFactory.instance.numberNode(serverMin)); + config.set("maxSupportedApiVersion", JsonNodeFactory.instance.numberNode(serverMax)); + if (serverActual > 0) { + config.set("actualApiVersion", JsonNodeFactory.instance.numberNode(serverActual)); + } + + stubFor( + get("/config") + .willReturn( + ResponseDefinitionBuilder.responseDefinition() + .withStatus(HTTP_OK) + .withBody(config.toString()) + .withHeader("Content-Type", "application/json"))); + + try (HttpClient httpClient = + HttpClient.builder() + .setBaseUri(URI.create(wireMock.getHttpBaseUrl())) + .setObjectMapper(new ObjectMapper()) + .addResponseFilter(new NessieHttpResponseFilter()) + .build()) { + + if (expectation == Expectation.OK) { + + assertThatCode(() -> NessieApiCompatibility.check(client, httpClient)) + .doesNotThrowAnyException(); + + } else { + + assertThatThrownBy(() -> NessieApiCompatibility.check(client, httpClient)) + .hasMessageContaining(expectation.expectedErrorMessage()) + .asInstanceOf(type(NessieApiCompatibilityException.class)) + .extracting( + NessieApiCompatibilityException::getClientApiVersion, + NessieApiCompatibilityException::getMinServerApiVersion, + NessieApiCompatibilityException::getMaxServerApiVersion, + NessieApiCompatibilityException::getActualServerApiVersion) + .containsExactly(client, serverMin, serverMax, serverActual); + } + } + } +} diff --git a/api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibilityException.java b/api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibilityException.java new file mode 100644 index 00000000000..56c34a48c19 --- /dev/null +++ b/api/client/src/test/java/org/projectnessie/client/http/TestNessieApiCompatibilityException.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class TestNessieApiCompatibilityException { + + @Test + void testMessages() { + NessieApiCompatibilityException e = new NessieApiCompatibilityException(1, 2, 3, 3); + assertThat(e.getMessage()) + .isEqualTo("API version 1 is too old for server (minimum supported version is 2)"); + e = new NessieApiCompatibilityException(5, 3, 4, 4); + assertThat(e.getMessage()) + .isEqualTo("API version 5 is too new for server (maximum supported version is 4)"); + e = new NessieApiCompatibilityException(3, 2, 4, 2); + assertThat(e.getMessage()) + .isEqualTo("API version mismatch, check URI prefix (expected: 3, actual: 2)"); + } + + @Test + void testGetters() { + NessieApiCompatibilityException e = new NessieApiCompatibilityException(1, 2, 4, 3); + assertThat(e.getClientApiVersion()).isEqualTo(1); + assertThat(e.getMinServerApiVersion()).isEqualTo(2); + assertThat(e.getMaxServerApiVersion()).isEqualTo(4); + assertThat(e.getActualServerApiVersion()).isEqualTo(3); + } +} diff --git a/api/client/src/test/java/org/projectnessie/client/http/TestNessieHttpClient.java b/api/client/src/test/java/org/projectnessie/client/http/TestNessieHttpClient.java index bb2c433ba6b..3e0fa806bfe 100644 --- a/api/client/src/test/java/org/projectnessie/client/http/TestNessieHttpClient.java +++ b/api/client/src/test/java/org/projectnessie/client/http/TestNessieHttpClient.java @@ -27,11 +27,16 @@ import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Scope; import java.util.concurrent.atomic.AtomicReference; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.projectnessie.client.api.NessieApi; import org.projectnessie.client.api.NessieApiV1; import org.projectnessie.client.api.NessieApiV2; import org.projectnessie.client.http.v1api.HttpApiV1; @@ -44,8 +49,11 @@ import org.projectnessie.client.util.JaegerTestTracer; import org.projectnessie.model.Branch; +@ExtendWith(SoftAssertionsExtension.class) class TestNessieHttpClient { + @InjectSoftAssertions SoftAssertions soft; + static final String W3C_PROPAGATION_HEADER_NAME = "traceparent"; @BeforeAll @@ -65,6 +73,7 @@ void testNonJsonResponse() throws Exception { HttpClientBuilder.builder() .withUri(server.getUri()) .withTracing(true) + .withEnableApiCompatibilityCheck(false) .build(NessieApiV1.class)) { assertThatThrownBy(api::getDefaultBranch) .isInstanceOf(NessieBadResponseException.class) @@ -94,6 +103,7 @@ void testValidJsonResponse(String contentType) throws Exception { HttpClientBuilder.builder() .withUri(server.getUri()) .withTracing(true) + .withEnableApiCompatibilityCheck(false) .build(NessieApiV1.class)) { api.getDefaultBranch(); } @@ -158,6 +168,7 @@ void testNotFoundOnBaseUri() throws Exception { NessieApiV1 api = HttpClientBuilder.builder() .withUri(server.getUri().resolve("/unknownPath")) + .withEnableApiCompatibilityCheck(false) .build(NessieApiV1.class)) { assertThatThrownBy(api::getConfig) .isInstanceOf(RuntimeException.class) @@ -171,6 +182,7 @@ void testInternalServerError() throws Exception { NessieApiV1 api = HttpClientBuilder.builder() .withUri(server.getUri().resolve("/broken")) + .withEnableApiCompatibilityCheck(false) .build(NessieApiV1.class)) { assertThatThrownBy(api::getConfig) .isInstanceOf(NessieInternalServerException.class) @@ -184,6 +196,7 @@ void testUnauthorized() throws Exception { NessieApiV1 api = HttpClientBuilder.builder() .withUri(server.getUri().resolve("/unauthorized")) + .withEnableApiCompatibilityCheck(false) .build(NessieApiV1.class)) { assertThatThrownBy(api::getConfig) .isInstanceOf(NessieNotAuthorizedException.class) @@ -191,6 +204,63 @@ void testUnauthorized() throws Exception { } } + @Test + void testApiCompatibility() { + // Good cases + soft.assertThatCode(() -> testConfig(NessieApiV1.class, 1, 1, 0, true)) + .doesNotThrowAnyException(); + soft.assertThatCode(() -> testConfig(NessieApiV1.class, 1, 2, 0, true)) + .doesNotThrowAnyException(); + soft.assertThatCode(() -> testConfig(NessieApiV2.class, 1, 2, 0, true)) + .doesNotThrowAnyException(); + soft.assertThatCode(() -> testConfig(NessieApiV2.class, 1, 2, 2, true)) + .doesNotThrowAnyException(); + soft.assertThatCode(() -> testConfig(NessieApiV2.class, 2, 2, 2, true)) + .doesNotThrowAnyException(); + soft.assertThatCode(() -> testConfig(NessieApiV2.class, 2, 3, 2, true)) + .doesNotThrowAnyException(); + // Bad cases + // 1. v1 client called a server that doesn't support v1 + soft.assertThatThrownBy(() -> testConfig(NessieApiV1.class, 2, 2, 2, true)) + .isInstanceOf(NessieApiCompatibilityException.class); + // 2. v2 client called a server that doesn't support v2 + soft.assertThatThrownBy(() -> testConfig(NessieApiV2.class, 1, 1, 1, true)) + .isInstanceOf(NessieApiCompatibilityException.class); + // 3. v1 client called a v2 endpoint + soft.assertThatThrownBy(() -> testConfig(NessieApiV1.class, 1, 2, 2, true)) + .isInstanceOf(NessieApiCompatibilityException.class); + // 4. v2 client called a v1 endpoint + soft.assertThatThrownBy(() -> testConfig(NessieApiV2.class, 1, 2, 1, true)) + .isInstanceOf(NessieApiCompatibilityException.class); + // Bad cases with compatibility check disabled + // 1. v1 client called a server that doesn't support v1 + soft.assertThatCode(() -> testConfig(NessieApiV1.class, 2, 2, 2, false)) + .doesNotThrowAnyException(); + // 2. v2 client called a server that doesn't support v2 + soft.assertThatCode(() -> testConfig(NessieApiV2.class, 1, 1, 1, false)) + .doesNotThrowAnyException(); + // 3. v1 client called a v2 endpoint + soft.assertThatCode(() -> testConfig(NessieApiV1.class, 1, 2, 2, false)) + .doesNotThrowAnyException(); + // 4. v2 client called a v1 endpoint + soft.assertThatCode(() -> testConfig(NessieApiV2.class, 1, 2, 1, false)) + .doesNotThrowAnyException(); + } + + @SuppressWarnings("EmptyTryBlock") + private void testConfig( + Class apiClass, int min, int max, int actual, boolean check) + throws Exception { + try (HttpTestServer server = forConfig(min, max, actual); + NessieApi ignored = + HttpClientBuilder.builder() + .withUri(server.getUri()) + .withEnableApiCompatibilityCheck(check) + .build(apiClass)) { + // no-op + } + } + @Test void testCloseApiV1() { NessieApiClient client = mock(NessieApiClient.class); @@ -224,4 +294,24 @@ static HttpTestServer.RequestHandler handlerForHeaderTest( HttpTestUtil.writeResponseBody(resp, "{\"maxSupportedApiVersion\":1}"); }; } + + static HttpTestServer forConfig(int minApiVersion, int maxApiVersion, int actual) + throws Exception { + return new HttpTestServer( + (req, resp) -> { + req.getInputStream().close(); + resp.addHeader("Content-Type", "application/json"); + StringBuilder json = + new StringBuilder() + .append("{\"minSupportedApiVersion\":") + .append(minApiVersion) + .append(",\"maxSupportedApiVersion\":") + .append(maxApiVersion); + if (actual > 0) { + json.append(",\"actualApiVersion\":").append(actual); + } + json.append("}"); + HttpTestUtil.writeResponseBody(resp, json.toString()); + }); + } } diff --git a/api/model/src/main/java/org/projectnessie/model/NessieConfiguration.java b/api/model/src/main/java/org/projectnessie/model/NessieConfiguration.java index 7e743834d83..66d7f5c3a64 100644 --- a/api/model/src/main/java/org/projectnessie/model/NessieConfiguration.java +++ b/api/model/src/main/java/org/projectnessie/model/NessieConfiguration.java @@ -69,6 +69,19 @@ public int getMinSupportedApiVersion() { */ public abstract int getMaxSupportedApiVersion(); + /** + * The actual API version that was used to handle the REST request to the configuration endpoint. + * + *

If this value is 0, then the server does not support returning the actual API version. + * Otherwise, this value is guaranteed to be between {@link #getMinSupportedApiVersion()} and + * {@link #getMaxSupportedApiVersion()} (inclusive). + */ + @JsonView(Views.V2.class) + @Value.Default + public int getActualApiVersion() { + return 0; + } + /** Semver version representing the behavior of the Nessie server. */ @JsonView(Views.V2.class) @Nullable diff --git a/api/model/src/test/java/org/projectnessie/model/TestModelObjectsSerialization.java b/api/model/src/test/java/org/projectnessie/model/TestModelObjectsSerialization.java index 5650778cdd0..3db92a54dc0 100644 --- a/api/model/src/test/java/org/projectnessie/model/TestModelObjectsSerialization.java +++ b/api/model/src/test/java/org/projectnessie/model/TestModelObjectsSerialization.java @@ -114,6 +114,7 @@ static List goodCases() { .defaultBranch("default-branch") .minSupportedApiVersion(11) .maxSupportedApiVersion(42) + .actualApiVersion(42) .specVersion("42.1.2") .build()) .jsonNode( @@ -121,6 +122,7 @@ static List goodCases() { o.put("defaultBranch", "default-branch") .put("minSupportedApiVersion", 11) .put("maxSupportedApiVersion", 42) + .put("actualApiVersion", 42) .put("specVersion", "42.1.2")), new Case(Branch.class) .obj(Branch.of(branchName, HASH)) diff --git a/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/api/Version.java b/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/api/Version.java index 58abfbce336..2c95c92f809 100644 --- a/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/api/Version.java +++ b/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/api/Version.java @@ -35,11 +35,15 @@ public class Version implements Comparable { // CLIENT_LOG4J_UNDECLARED_* is the version range where :nessie-client uses log4j without // declaring an explicit dependency in its POM. public static final Version CLIENT_LOG4J_UNDECLARED_LOW = Version.parseVersion("0.46.0"); + public static final Version API_V2 = Version.parseVersion("0.46.0"); public static final Version CLIENT_LOG4J_UNDECLARED_HIGH = Version.parseVersion("0.47.1"); // COMPAT_COMMON_DEPENDENCIES_START is the version where dependency declarations for // "compatibility" tests moved to :nessie-compatibility-common public static final Version COMPAT_COMMON_DEPENDENCIES_START = Version.parseVersion("0.48.2"); public static final Version OLD_GROUP_IDS = Version.parseVersion("0.50.0"); + public static final Version SPEC_VERSION_IN_CONFIG_V2 = Version.parseVersion("0.55.0"); + public static final Version SPEC_VERSION_IN_CONFIG_V2_SEMVER = Version.parseVersion("0.57.0"); + public static final Version ACTUAL_VERSION_IN_CONFIG_V2 = Version.parseVersion("0.59.0"); public static final String CURRENT_STRING = "current"; public static final String NOT_CURRENT_STRING = "not-current"; diff --git a/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/internal/AbstractNessieApiHolder.java b/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/internal/AbstractNessieApiHolder.java index a7ee3b7da25..3acf324f689 100644 --- a/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/internal/AbstractNessieApiHolder.java +++ b/compatibility/common/src/main/java/org/projectnessie/tools/compatibility/internal/AbstractNessieApiHolder.java @@ -102,7 +102,10 @@ protected AbstractNessieApiHolder(ClientKey clientKey) { @Override public void close() { LOGGER.info("Closing Nessie client for version {}", clientKey.getVersion()); - getApiInstance().close(); + NessieApi api = getApiInstance(); + if (api != null) { + api.close(); + } } public abstract NessieApi getApiInstance(); @@ -140,7 +143,16 @@ protected static AutoCloseable createNessieClient(ClassLoader classLoader, Clien } Method buildMethod = builderInstance.getClass().getMethod("build", Class.class); - Object apiInstance = buildMethod.invoke(builderInstance, targetClass); + Object apiInstance = null; + try { + apiInstance = buildMethod.invoke(builderInstance, targetClass); + } catch (InvocationTargetException e) { + // Let the test continue with a null instance, if the Nessie API is not compatible. + // Test methods must check for nulls and skip the test if necessary. + if (!isNessieApiCompatibilityException(e)) { + throw e; + } + } LOGGER.info( "Created Nessie client for version {} for {}", @@ -154,4 +166,9 @@ protected static AutoCloseable createNessieClient(ClassLoader classLoader, Clien throw throwUnchecked(e); } } + + private static boolean isNessieApiCompatibilityException(InvocationTargetException e) { + return e.getCause() != null + && e.getCause().getClass().getSimpleName().equals("NessieApiCompatibilityException"); + } } diff --git a/compatibility/common/src/test/java/org/projectnessie/tools/compatibility/internal/TestNessieApiHolder.java b/compatibility/common/src/test/java/org/projectnessie/tools/compatibility/internal/TestNessieApiHolder.java index c0a3d5f255b..45a16a85e3b 100644 --- a/compatibility/common/src/test/java/org/projectnessie/tools/compatibility/internal/TestNessieApiHolder.java +++ b/compatibility/common/src/test/java/org/projectnessie/tools/compatibility/internal/TestNessieApiHolder.java @@ -20,8 +20,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.google.common.collect.ImmutableMap; import java.lang.reflect.Proxy; -import java.util.Collections; import org.assertj.core.api.SoftAssertions; import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; @@ -57,7 +57,9 @@ void currentVersionServer() { Version.CURRENT, "org.projectnessie.client.http.HttpClientBuilder", NessieApiV1.class, - Collections.singletonMap("nessie.uri", "http://127.42.42.42:19120"))); + ImmutableMap.of( + "nessie.uri", "http://127.42.42.42:19120", + "nessie.enable-api-compatibility-check", "false"))); try { soft.assertThat(apiHolder) .extracting(AbstractNessieApiHolder::getApiInstance) @@ -90,7 +92,9 @@ void oldVersionServer() { Version.parseVersion("0.42.0"), "org.projectnessie.client.http.HttpClientBuilder", NessieApiV1.class, - Collections.singletonMap("nessie.uri", "http://127.42.42.42:19120"))); + ImmutableMap.of( + "nessie.uri", "http://127.42.42.42:19120", + "nessie.enable-api-compatibility-check", "false"))); try { soft.assertThat(apiHolder) .satisfies( diff --git a/compatibility/compatibility-tests/build.gradle.kts b/compatibility/compatibility-tests/build.gradle.kts index 323790a5e31..0b9a7391a32 100644 --- a/compatibility/compatibility-tests/build.gradle.kts +++ b/compatibility/compatibility-tests/build.gradle.kts @@ -55,6 +55,6 @@ tasks.withType().configureEach { // with uncaught exception of type std::__1::system_error: mutex lock failed: Invalid argument` // // Compatibility tests fail, because Windows not supported by testcontainers (logged message) -if (Os.isFamily(Os.FAMILY_MAC) || Os.isFamily(Os.FAMILY_WINDOWS)) { +if ((Os.isFamily(Os.FAMILY_MAC) || Os.isFamily(Os.FAMILY_WINDOWS)) && System.getenv("CI") != null) { tasks.withType().configureEach { this.enabled = false } } diff --git a/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/AbstractCompatibilityTests.java b/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/AbstractCompatibilityTests.java index 39c2d5a94fa..a27f451e21a 100644 --- a/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/AbstractCompatibilityTests.java +++ b/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/AbstractCompatibilityTests.java @@ -113,9 +113,24 @@ void getDefaultBranchV2() throws Exception { } @Test - void getConfig() { + void getConfigV1() { NessieConfiguration config = api.getConfig(); - assertThat(config).extracting(NessieConfiguration::getDefaultBranch).isEqualTo("main"); + assertThat(config.getDefaultBranch()).isEqualTo("main"); + assertThat(config.getMinSupportedApiVersion()).isEqualTo(1); + assertThat(config.getMaxSupportedApiVersion()).isBetween(1, 2); + assertThat(config.getActualApiVersion()).isEqualTo(0); + assertThat(config.getSpecVersion()).isNull(); + } + + @Test + @VersionCondition(minVersion = "0.59.0") + void getConfigV2() { + NessieConfiguration config = apiV2.getConfig(); + assertThat(config.getDefaultBranch()).isEqualTo("main"); + assertThat(config.getMinSupportedApiVersion()).isEqualTo(1); + assertThat(config.getMaxSupportedApiVersion()).isBetween(1, 2); + assertThat(config.getActualApiVersion()).isBetween(0, 2); + assertThat(config.getSpecVersion()).isIn(null, "2.0-beta.1", "2.0.0-beta.1"); } @Test diff --git a/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/ITOlderServers.java b/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/ITOlderServers.java index 6fc422be7fb..09594b2cdc0 100644 --- a/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/ITOlderServers.java +++ b/compatibility/compatibility-tests/src/intTest/java/org/projectnessie/tools/compatibility/tests/ITOlderServers.java @@ -15,10 +15,13 @@ */ package org.projectnessie.tools.compatibility.tests; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.projectnessie.error.BaseNessieClientServerException; +import org.projectnessie.model.NessieConfiguration; import org.projectnessie.tools.compatibility.api.Version; +import org.projectnessie.tools.compatibility.api.VersionCondition; import org.projectnessie.tools.compatibility.internal.OlderNessieServersExtension; @ExtendWith(OlderNessieServersExtension.class) @@ -29,9 +32,40 @@ Version getClientVersion() { return Version.CURRENT; } + @Test @Override + void getConfigV1() { + NessieConfiguration config = api.getConfig(); + assertThat(config.getDefaultBranch()).isEqualTo("main"); + assertThat(config.getMinSupportedApiVersion()).isEqualTo(1); + if (version.isLessThan(Version.API_V2)) { + assertThat(config.getMaxSupportedApiVersion()).isEqualTo(1); + } else { + assertThat(config.getMaxSupportedApiVersion()).isEqualTo(2); + } + assertThat(config.getActualApiVersion()).isEqualTo(0); + assertThat(config.getSpecVersion()).isNull(); + } + @Test - public void mergeBehavior() throws BaseNessieClientServerException { - super.mergeBehavior(); + @VersionCondition(minVersion = "0.47.0") + @Override + void getConfigV2() { + NessieConfiguration config = apiV2.getConfig(); + assertThat(config.getDefaultBranch()).isEqualTo("main"); + assertThat(config.getMinSupportedApiVersion()).isEqualTo(1); + assertThat(config.getMaxSupportedApiVersion()).isEqualTo(2); + if (version.isLessThan(Version.ACTUAL_VERSION_IN_CONFIG_V2)) { + assertThat(config.getActualApiVersion()).isEqualTo(0); + } else { + assertThat(config.getActualApiVersion()).isEqualTo(2); + } + if (version.isLessThan(Version.SPEC_VERSION_IN_CONFIG_V2)) { + assertThat(config.getSpecVersion()).isNull(); + } else if (version.isLessThan(Version.SPEC_VERSION_IN_CONFIG_V2_SEMVER)) { + assertThat(config.getSpecVersion()).isEqualTo("2.0-beta.1"); + } else { + assertThat(config.getSpecVersion()).isEqualTo("2.0.0-beta.1"); + } } } diff --git a/compatibility/compatibility-tests/src/intTest/resources/META-INF/nessie-compatibility.properties b/compatibility/compatibility-tests/src/intTest/resources/META-INF/nessie-compatibility.properties index 915bd07826e..004c2feb231 100644 --- a/compatibility/compatibility-tests/src/intTest/resources/META-INF/nessie-compatibility.properties +++ b/compatibility/compatibility-tests/src/intTest/resources/META-INF/nessie-compatibility.properties @@ -33,8 +33,12 @@ # - introduced open-addressing key-list # 0.51.1: # - moved to new Maven group ID (with relocation poms for the old group ID) +# 0.55.0 +# - introduced minSupportedApiVersion and specVersion in NessieConfiguration +# 0.57.0 +# - specVersion in NessieConfiguration changed to semver syntax # # MINIMUM VERSION is 0.23.1 !!! # # Untested (for CI duration): 0.23.1,0.26.0,0.30.0,0.40.3 -nessie.versions=0.42.0,0.47.0,0.48.2,0.51.1,current +nessie.versions=0.42.0,0.47.0,0.48.2,0.51.1,0.55.0,0.57.0,current diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 790fd1601ee..5bf309a2503 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ rocksdb = "8.1.1.1" slf4j = "1.7.36" testcontainers = "1.18.1" undertow = "2.2.19.Final" +wiremock = "2.35.0" [bundles] # Bundles serve two purposes: @@ -225,6 +226,7 @@ threeten-extra = { module = "org.threeten:threeten-extra", version = "1.7.2" } undertow-core = { module = "io.undertow:undertow-core", version.ref = "undertow" } undertow-servlet = { module = "io.undertow:undertow-servlet", version.ref = "undertow" } weld-se-core = { module = "org.jboss.weld.se:weld-se-core", version = "3.1.9.Final" } +wiremock = { module = "com.github.tomakehurst:wiremock-jre8-standalone", version.ref = "wiremock" } [plugins] annotations-stripper = { id = "org.projectnessie.annotation-stripper", version = "0.1.2" } diff --git a/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestConfigService.java b/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestConfigService.java index 7e210acf5d6..b8140522e38 100644 --- a/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestConfigService.java +++ b/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestConfigService.java @@ -32,6 +32,6 @@ public RestConfigService() { @Inject @jakarta.inject.Inject public RestConfigService(ServerConfig config, VersionStore store) { - super(config, store); + super(config, store, 1); } } diff --git a/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestV2ConfigResource.java b/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestV2ConfigResource.java index 60f3a7a858d..b98b8fa6c3a 100644 --- a/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestV2ConfigResource.java +++ b/servers/rest-services/src/main/java/org/projectnessie/services/rest/RestV2ConfigResource.java @@ -40,7 +40,7 @@ public RestV2ConfigResource() { @Inject @jakarta.inject.Inject public RestV2ConfigResource(ServerConfig config, VersionStore store) { - this.config = new ConfigApiImpl(config, store); + this.config = new ConfigApiImpl(config, store, 2); } @Override diff --git a/servers/services/src/main/java/org/projectnessie/services/impl/ConfigApiImpl.java b/servers/services/src/main/java/org/projectnessie/services/impl/ConfigApiImpl.java index 0ae88d3c8d1..5ab562f414c 100644 --- a/servers/services/src/main/java/org/projectnessie/services/impl/ConfigApiImpl.java +++ b/servers/services/src/main/java/org/projectnessie/services/impl/ConfigApiImpl.java @@ -25,10 +25,12 @@ public class ConfigApiImpl implements ConfigService { private final VersionStore store; private final ServerConfig config; + private final int actualApiVersion; - public ConfigApiImpl(ServerConfig config, VersionStore store) { + public ConfigApiImpl(ServerConfig config, VersionStore store, int actualApiVersion) { this.store = store; this.config = config; + this.actualApiVersion = actualApiVersion; } @Override @@ -36,6 +38,7 @@ public NessieConfiguration getConfig() { return ImmutableNessieConfiguration.builder() .from(NessieConfiguration.getBuiltInConfig()) .defaultBranch(this.config.getDefaultBranch()) + .actualApiVersion(actualApiVersion) .build(); } } diff --git a/servers/services/src/testFixtures/java/org/projectnessie/services/impl/AbstractTestMisc.java b/servers/services/src/testFixtures/java/org/projectnessie/services/impl/AbstractTestMisc.java index 1e44c4ed040..1068a0adf4b 100644 --- a/servers/services/src/testFixtures/java/org/projectnessie/services/impl/AbstractTestMisc.java +++ b/servers/services/src/testFixtures/java/org/projectnessie/services/impl/AbstractTestMisc.java @@ -54,6 +54,7 @@ public void testSupportedApiVersions() { ImmutableNessieConfiguration.builder() .from(NessieConfiguration.getBuiltInConfig()) .defaultBranch(serverConfig.getDefaultBranch()) + .actualApiVersion(2) .build(); assertThat(serverConfig).isEqualTo(expectedConfig); } diff --git a/servers/services/src/testFixtures/java/org/projectnessie/services/impl/BaseTestServiceImpl.java b/servers/services/src/testFixtures/java/org/projectnessie/services/impl/BaseTestServiceImpl.java index cdb4f4045b6..3e571dcb7b6 100644 --- a/servers/services/src/testFixtures/java/org/projectnessie/services/impl/BaseTestServiceImpl.java +++ b/servers/services/src/testFixtures/java/org/projectnessie/services/impl/BaseTestServiceImpl.java @@ -113,7 +113,7 @@ public boolean sendStacktraceToClient() { private Principal principal; protected final ConfigApiImpl configApi() { - return new ConfigApiImpl(config(), versionStore()); + return new ConfigApiImpl(config(), versionStore(), 2); } protected final TreeApiImpl treeApi() {