22 changed files with 661 additions and 41 deletions
@ -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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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