22 changed files with 661 additions and 41 deletions
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
/* |
||||
* Copyright 2002-2025 the original author or authors. |
||||
* |
||||
* 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 |
||||
* |
||||
* https://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.springframework.web.client; |
||||
|
||||
/** |
||||
* Contract to format the API version for a request. |
||||
* |
||||
* @author Rossen Stoyanchev |
||||
* @since 7.0 |
||||
* @see DefaultApiVersionInserter.Builder#withVersionFormatter(ApiVersionFormatter) |
||||
*/ |
||||
@FunctionalInterface |
||||
public interface ApiVersionFormatter { |
||||
|
||||
/** |
||||
* Format the given version Object into a String value. |
||||
* @param version the version to format |
||||
* @return the final String version to use |
||||
*/ |
||||
String formatVersion(Object version); |
||||
|
||||
} |
||||
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
/* |
||||
* Copyright 2002-2025 the original author or authors. |
||||
* |
||||
* 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 |
||||
* |
||||
* https://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.springframework.web.client; |
||||
|
||||
import java.net.URI; |
||||
|
||||
import org.springframework.http.HttpHeaders; |
||||
|
||||
/** |
||||
* Contract to determine how to insert an API version into the URI or headers |
||||
* of a request. |
||||
* |
||||
* @author Rossen Stoyanchev |
||||
* @since 7.0 |
||||
*/ |
||||
public interface ApiVersionInserter { |
||||
|
||||
/** |
||||
* Allows inserting the version into the URI. |
||||
* @param version the version to insert |
||||
* @param uri the URI for the request |
||||
* @return the updated or the same URI |
||||
*/ |
||||
default URI insertVersion(Object version, URI uri) { |
||||
return uri; |
||||
} |
||||
|
||||
/** |
||||
* Allows inserting the version into request headers. |
||||
* @param version the version to insert |
||||
* @param headers the request headers |
||||
*/ |
||||
default void insertVersion(Object version, HttpHeaders headers) { |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
|
||||
/* |
||||
* Copyright 2002-2025 the original author or authors. |
||||
* |
||||
* 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 |
||||
* |
||||
* https://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.springframework.web.client; |
||||
|
||||
import java.net.URI; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.web.util.UriComponentsBuilder; |
||||
|
||||
/** |
||||
* Default implementation of {@link ApiVersionInserter} to insert the version |
||||
* into a request header, query parameter, or the URL path. |
||||
* |
||||
* <p>Use {@link #builder()} to create an instance. |
||||
* |
||||
* @author Rossen Stoyanchev |
||||
* @since 7.0 |
||||
*/ |
||||
public final class DefaultApiVersionInserter implements ApiVersionInserter { |
||||
|
||||
private final @Nullable String header; |
||||
|
||||
private final @Nullable String queryParam; |
||||
|
||||
private final @Nullable Integer pathSegmentIndex; |
||||
|
||||
private final ApiVersionFormatter versionFormatter; |
||||
|
||||
|
||||
private DefaultApiVersionInserter( |
||||
@Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex, |
||||
@Nullable ApiVersionFormatter formatter) { |
||||
|
||||
Assert.isTrue(header != null || queryParam != null || pathSegmentIndex != null, |
||||
"Expected 'header', 'queryParam', or 'pathSegmentIndex' to be configured"); |
||||
|
||||
this.header = header; |
||||
this.queryParam = queryParam; |
||||
this.pathSegmentIndex = pathSegmentIndex; |
||||
this.versionFormatter = (formatter != null ? formatter : Object::toString); |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public URI insertVersion(Object version, URI uri) { |
||||
if (this.queryParam == null && this.pathSegmentIndex == null) { |
||||
return uri; |
||||
} |
||||
String formattedVersion = this.versionFormatter.formatVersion(version); |
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri); |
||||
if (this.queryParam != null) { |
||||
builder.queryParam(this.queryParam, formattedVersion); |
||||
} |
||||
if (this.pathSegmentIndex != null) { |
||||
List<String> pathSegments = new ArrayList<>(builder.build().getPathSegments()); |
||||
assertPathSegmentIndex(this.pathSegmentIndex, pathSegments.size(), uri); |
||||
pathSegments.add(this.pathSegmentIndex, formattedVersion); |
||||
builder.replacePath(null); |
||||
pathSegments.forEach(builder::pathSegment); |
||||
} |
||||
return builder.build().toUri(); |
||||
} |
||||
|
||||
private void assertPathSegmentIndex(Integer index, int pathSegmentsSize, URI uri) { |
||||
Assert.state(index <= pathSegmentsSize, |
||||
"Cannot insert version into '" + uri.getPath() + "' at path segment index " + index); |
||||
} |
||||
|
||||
@Override |
||||
public void insertVersion(Object version, HttpHeaders headers) { |
||||
if (this.header != null) { |
||||
headers.set(this.header, this.versionFormatter.formatVersion(version)); |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Create a builder for an inserter that sets a header. |
||||
* @param header the name of a header to hold the version |
||||
*/ |
||||
public static Builder fromHeader(@Nullable String header) { |
||||
return new Builder(header, null, null); |
||||
} |
||||
|
||||
/** |
||||
* Create a builder for an inserter that sets a query parameter. |
||||
* @param queryParam the name of a query parameter to hold the version |
||||
*/ |
||||
public static Builder fromQueryParam(@Nullable String queryParam) { |
||||
return new Builder(null, queryParam, null); |
||||
} |
||||
|
||||
/** |
||||
* Create a builder for an inserter that inserts a path segment. |
||||
* @param pathSegmentIndex the index of the path segment to hold the version |
||||
*/ |
||||
public static Builder fromPathSegment(@Nullable Integer pathSegmentIndex) { |
||||
return new Builder(null, null, pathSegmentIndex); |
||||
} |
||||
|
||||
/** |
||||
* Create a builder. |
||||
*/ |
||||
public static Builder builder() { |
||||
return new Builder(null, null, null); |
||||
} |
||||
|
||||
|
||||
/** |
||||
* A builder for {@link DefaultApiVersionInserter}. |
||||
*/ |
||||
public static final class Builder { |
||||
|
||||
private @Nullable String header; |
||||
|
||||
private @Nullable String queryParam; |
||||
|
||||
private @Nullable Integer pathSegmentIndex; |
||||
|
||||
private @Nullable ApiVersionFormatter versionFormatter; |
||||
|
||||
private Builder(@Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex) { |
||||
this.header = header; |
||||
this.queryParam = queryParam; |
||||
this.pathSegmentIndex = pathSegmentIndex; |
||||
} |
||||
|
||||
/** |
||||
* Configure the inserter to set a header. |
||||
* @param header the name of the header to hold the version |
||||
*/ |
||||
public Builder fromHeader(@Nullable String header) { |
||||
this.header = header; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Configure the inserter to set a query parameter. |
||||
* @param queryParam the name of the query parameter to hold the version |
||||
*/ |
||||
public Builder fromQueryParam(@Nullable String queryParam) { |
||||
this.queryParam = queryParam; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Configure the inserter to insert a path segment. |
||||
* @param pathSegmentIndex the index of the path segment to hold the version |
||||
*/ |
||||
public Builder fromPathSegment(@Nullable Integer pathSegmentIndex) { |
||||
this.pathSegmentIndex = pathSegmentIndex; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Format the version Object into a String using the given {@link ApiVersionFormatter}. |
||||
* <p>By default, the version is formatted with {@link Object#toString()}. |
||||
* @param versionFormatter the formatter to use |
||||
*/ |
||||
public Builder withVersionFormatter(ApiVersionFormatter versionFormatter) { |
||||
this.versionFormatter = versionFormatter; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Build the inserter. |
||||
*/ |
||||
public ApiVersionInserter build() { |
||||
return new DefaultApiVersionInserter( |
||||
this.header, this.queryParam, this.pathSegmentIndex, this.versionFormatter); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
|
||||
/* |
||||
* Copyright 2002-2025 the original author or authors. |
||||
* |
||||
* 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 |
||||
* |
||||
* https://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.springframework.web.client; |
||||
|
||||
import java.io.IOException; |
||||
import java.util.function.Consumer; |
||||
|
||||
import okhttp3.mockwebserver.MockResponse; |
||||
import okhttp3.mockwebserver.MockWebServer; |
||||
import okhttp3.mockwebserver.RecordedRequest; |
||||
import org.junit.jupiter.api.AfterEach; |
||||
import org.junit.jupiter.api.BeforeEach; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException; |
||||
|
||||
/** |
||||
* {@link RestClient} tests for sending API versions. |
||||
* @author Rossen Stoyanchev |
||||
*/ |
||||
public class RestClientVersionTests { |
||||
|
||||
private final MockWebServer server = new MockWebServer(); |
||||
|
||||
private final RestClient.Builder restClientBuilder = RestClient.builder() |
||||
.requestFactory(new JdkClientHttpRequestFactory()) |
||||
.baseUrl(this.server.url("/").toString()); |
||||
|
||||
|
||||
@BeforeEach |
||||
void setUp() { |
||||
MockResponse response = new MockResponse(); |
||||
response.setHeader("Content-Type", "text/plain").setBody("body"); |
||||
this.server.enqueue(response); |
||||
} |
||||
|
||||
@AfterEach |
||||
void shutdown() throws IOException { |
||||
this.server.shutdown(); |
||||
} |
||||
|
||||
|
||||
@Test |
||||
void header() { |
||||
performRequest(DefaultApiVersionInserter.fromHeader("X-API-Version")); |
||||
expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); |
||||
} |
||||
|
||||
@Test |
||||
void queryParam() { |
||||
performRequest(DefaultApiVersionInserter.fromQueryParam("api-version")); |
||||
expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path?api-version=1.2")); |
||||
} |
||||
|
||||
@Test |
||||
void pathSegmentIndexLessThanSize() { |
||||
performRequest(DefaultApiVersionInserter.fromPathSegment(0).withVersionFormatter(v -> "v" + v)); |
||||
expectRequest(request -> assertThat(request.getPath()).isEqualTo("/v1.2/path")); |
||||
} |
||||
|
||||
@Test |
||||
void pathSegmentIndexEqualToSize() { |
||||
performRequest(DefaultApiVersionInserter.fromPathSegment(1).withVersionFormatter(v -> "v" + v)); |
||||
expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path/v1.2")); |
||||
} |
||||
|
||||
@Test |
||||
void pathSegmentIndexGreaterThanSize() { |
||||
assertThatIllegalStateException() |
||||
.isThrownBy(() -> performRequest(DefaultApiVersionInserter.fromPathSegment(2))) |
||||
.withMessage("Cannot insert version into '/path' at path segment index 2"); |
||||
} |
||||
|
||||
private void performRequest(DefaultApiVersionInserter.Builder builder) { |
||||
ApiVersionInserter versionInserter = builder.build(); |
||||
RestClient restClient = restClientBuilder.apiVersionInserter(versionInserter).build(); |
||||
|
||||
restClient.get() |
||||
.uri("/path") |
||||
.apiVersion(1.2) |
||||
.retrieve() |
||||
.body(String.class); |
||||
} |
||||
|
||||
private void expectRequest(Consumer<RecordedRequest> consumer) { |
||||
try { |
||||
consumer.accept(this.server.takeRequest()); |
||||
} |
||||
catch (InterruptedException ex) { |
||||
throw new IllegalStateException(ex); |
||||
} |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue