8000 Client support for API versioning · spring-projects/spring-framework@7bf628c · GitHub
[go: up one dir, main page]

Skip to content

Commit 7bf628c

Browse files
committed
Client support for API versioning
Closes gh-34567
1 parent 483abd9 commit 7bf628c

File tree

22 files changed

+661
-41
lines changed
< 8000 span role="status" aria-live="polite" aria-atomic="true" class="_VisuallyHidden__VisuallyHidden-sc-11jhm7a-0 brGdpi">
  • invoker
  • test/java/org/springframework/web/client
  • spring-webflux/src/main/java/org/springframework/web/reactive/function/client
  • 22 files changed

    +661
    -41
    lines changed
    Lines changed: 36 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -0,0 +1,36 @@
    1+
    /*
    2+
    * Copyright 2002-2025 the original author or authors.
    3+
    *
    4+
    * Licensed under the Apache License, Version 2.0 (the "License");
    5+
    * you may not use this file except in compliance with the License.
    6+
    * You may obtain a copy of the License at
    7+
    *
    8+
    * https://www.apache.org/licenses/LICENSE-2.0
    9+
    *
    10+
    * Unless required by applicable law or agreed to in writing, software
    11+
    * distributed under the License is distributed on an "AS IS" BASIS,
    12+
    * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13+
    * See the License for the specific language governing permissions and
    14+
    * limitations under the License.
    15+
    */
    16+
    17+
    package org.springframework.web.client;
    18+
    19+
    /**
    20+
    * Contract to format the API version for a request.
    21+
    *
    22+
    * @author Rossen Stoyanchev
    23+
    * @since 7.0
    24+
    * @see DefaultApiVersionInserter.Builder#withVersionFormatter(ApiVersionFormatter)
    25+
    */
    26+
    @FunctionalInterface
    27+
    public interface ApiVersionFormatter {
    28+
    29+
    /**
    30+
    * Format the given version Object into a String value.
    31+
    * @param version the version to format
    32+
    * @return the final String version to use
    33+
    */
    34+
    String formatVersion(Object version);
    35+
    36+
    }
    Lines changed: 50 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -0,0 +1,50 @@
    1+
    /*
    2+
    * Copyright 2002-2025 the original author or authors.
    3+
    *
    4+
    * Licensed under the Apache License, Version 2.0 (the "License");
    5+
    * you may not use this file except in compliance with the License.
    6+
    * You may obtain a copy of the License at
    7+
    *
    8+
    * https://www.apache.org/licenses/LICENSE-2.0
    9+
    *
    10+
    * Unless required by applicable law or agreed to in writing, software
    11+
    * distributed under the License is distributed on an "AS IS" BASIS,
    12+
    * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13+
    * See the License for the specific language governing permissions and
    14+
    * limitations under the License.
    15+
    */
    16+
    17+
    package org.springframework.web.client;
    18+
    19+
    import java.net.URI;
    20+
    21+
    import org.springframework.http.HttpHeaders;
    22+
    23+
    /**
    24+
    * Contract to determine how to insert an API version into the URI or headers
    25+
    * of a request.
    26+
    *
    27+
    * @author Rossen Stoyanchev
    28+
    * @since 7.0
    29+
    */
    30+
    public interface ApiVersionInserter {
    31+
    32+
    /**
    33+
    * Allows inserting the version into the URI.
    34+
    * @param version the version to insert
    35+
    * @param uri the URI for the request
    36+
    * @return the updated or the same URI
    37+
    */
    38+
    default URI insertVersion(Object version, URI uri) {
    39+
    return uri;
    40+
    }
    41+
    42+
    /**
    43+
    * Allows inserting the version into request headers.
    44+
    * @param version the version to insert
    45+
    * @param headers the request headers
    46+
    */
    47+
    default void insertVersion(Object version, HttpHeaders headers) {
    48+
    }
    49+
    50+
    }
    Lines changed: 193 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -0,0 +1,193 @@
    1+
    /*
    2+
    * Copyright 2002-2025 the original author or authors.
    3+
    *
    4+
    * Licensed under the Apache License, Version 2.0 (the "License");
    5+
    * you may not use this file except in compliance with the License.
    6+
    * You may obtain a copy of the License at
    7+
    *
    8+
    * https://www.apache.org/licenses/LICENSE-2.0
    9+
    *
    10+
    * Unless required by applicable law or agreed to in writing, software
    11+
    * distributed under the License is distributed on an "AS IS" BASIS,
    12+
    * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13+
    * See the License for the specific language governing permissions and
    14+
    * limitations under the License.
    15+
    */
    16+
    17+
    package org.springframework.web.client;
    18+
    19+
    import java.net.URI;
    20+
    import java.util.ArrayList;
    21+
    import java.util.List;
    22+
    23+
    import org.jspecify.annotations.Nullable;
    24+
    25+
    import org.springframework.http.HttpHeaders;
    26+
    import org.springframework.util.Assert;
    27+
    import org.springframework.web.util.UriComponentsBuilder;
    28+
    29+
    /**
    30+
    * Default implementation of {@link ApiVersionInserter} to insert the version
    31+
    * into a request header, query parameter, or the URL path.
    32+
    *
    33+
    * <p>Use {@link #builder()} to create an instance.
    34+
    *
    35+
    * @author Rossen Stoyanchev
    36+
    * @since 7.0
    37+
    */
    38+
    public final class DefaultApiVersionInserter implements ApiVersionInserter {
    39+
    40+
    private final @Nullable String header;
    41+
    42+
    private final @Nullable String queryParam;
    43+
    44+
    private final @Nullable Integer pathSegmentIndex;
    45+
    46+
    private final ApiVersionFormatter versionFormatter;
    47+
    48+
    49+
    private DefaultApiVersionInserter(
    50+
    @Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex,
    51+
    @Nullable ApiVersionFormatter formatter) {
    52+
    53+
    Assert.isTrue(header != null || queryParam != null || pathSegmentIndex != null,
    54+
    "Expected 'header', 'queryParam', or 'pathSegmentIndex' to be configured");
    55+
    56+
    this.header = header;
    57+
    this.queryParam = queryParam;
    58+
    this.pathSegmentIndex = pathSegmentIndex;
    59+
    this.versionFormatter = (formatter != null ? formatter : Object::toString);
    60+
    }
    61+
    62+
    63+
    @Override
    64+
    public URI insertVersion(Object version, URI uri) {
    65+
    if (this.queryParam == null && this.pathSegmentIndex == null) {
    66+
    return uri;
    67+
    }
    68+
    String formattedVersion = this.versionFormatter.formatVersion(version);
    69+
    UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri);
    70+
    if (this.queryParam != null) {
    71+
    builder.queryParam(this.queryParam, formattedVersion);
    72+
    }
    73+
    if (this.pathSegmentIndex != null) {
    74+
    List<String> pathSegments = new ArrayList<>(builder.build().getPathSegments());
    75+
    assertPathSegmentIndex(this.pathSegmentIndex, pathSegments.size(), uri);
    76+
    pathSegments.add(this.pathSegmentIndex, formattedVersion);
    77+
    builder.replacePath(null);
    78+
    pathSegments.forEach(builder::pathSegment);
    79+
    }
    80+
    return builder.build().toUri();
    81+
    }
    82+
    83+
    private void assertPathSegmentIndex(Integer index, int pathSegmentsSize, URI uri) {
    84+
    Assert.state(index <= pathSegmentsSize,
    85+
    "Cannot insert version into '" + uri.getPath() + "' at path segment index " + index);
    86+
    }
    87+
    88+
    @Override
    89+
    public void insertVersion(Object version, HttpHeaders headers) {
    90+
    if (this.header != null) {
    91+
    headers.set(this.header, this.versionFormatter.formatVersion(version));
    92+
    }
    93+
    }
    94+
    95+
    96+
    /**
    97+
    * Create a builder for an inserter that sets a header.
    98+
    * @param header the name of a header to hold the version
    99+
    */
    100+
    public static Builder fromHeader(@Nullable String header) {
    101+
    return new Builder(header, null, null);
    102+
    }
    103+
    104+
    /**
    105+
    * Create a builder for an inserter that sets a query parameter.
    106+
    * @param queryParam the name of a query parameter to hold the version
    107+
    */
    108+
    public static Builder fromQueryParam(@Nullable String queryParam) {
    109+
    return new Builder(null, queryParam, null);
    110+
    }
    111+
    112+
    /**
    113+
    * Create a builder for an inserter that inserts a path segment.
    114+
    * @param pathSegmentIndex the index of the path segment to hold the version
    115+
    */
    116+
    public static Builder fromPathSegment(@Nullable Integer pathSegmentIndex) {
    117+
    return new Builder(null, null, pathSegmentIndex);
    118+
    }
    119+
    120+
    /**
    121+
    * Create a builder.
    122+
    */
    123+
    public static Builder builder() {
    124+
    return new Builder(null, null, null);
    125+
    }
    126+
    127+
    128+
    /**
    129+
    * A builder for {@link DefaultApiVersionInserter}.
    130+
    */
    131+
    public static final class Builder {
    132+
    133+
    private @Nullable String header;
    134+
    135+
    private @Nullable String queryParam;
    136+
    137+
    private @Nullable Integer pathSegmentIndex;
    138+
    139+
    private @Nullable ApiVersionFormatter versionFormatter;
    140+
    141+
    private Builder(@Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex) {
    142+
    this.header = header;
    143+
    this.queryParam = queryParam;
    144+
    this.pathSegmentIndex = pathSegmentIndex;
    145+
    }
    146+
    147+
    /**
    148+
    * Configure the inserter to set a header.
    149+
    * @param header the name of the header to hold the version
    150+
    */
    151+
    public Builder fromHeader(@Nullable String header) {
    152+
    this.header = header;
    153+
    return this;
    154+
    }
    155+
    156+
    /**
    157+
    * Configure the inserter to set a query parameter.
    158+
    * @param queryParam the name of the query parameter to hold the version
    159+
    */
    160+
    public Builder fromQueryParam(@Nullable String queryParam) {
    161+
    this.queryParam = queryParam;
    162+
    return this;
    163+
    }
    164+
    165+
    /**
    166+
    * Configure the inserter to insert a path segment.
    167+
    * @param pathSegmentIndex the index of the path segment to hold the version
    168+
    */
    169+
    public Builder fromPathSegment(@Nullable Integer pathSegmentIndex) {
    170+
    this.pathSegmentIndex = pathSegmentIndex;
    171+
    return this;
    172+
    }
    173+
    174+
    /**
    175+
    * Format the version Object into a String using the given {@link ApiVersionFormatter}.
    176+
    * <p>By default, the version is formatted with {@link Object#toString()}.
    177+
    * @param versionFormatter the formatter to use
    178+
    */
    179+
    public Builder withVersionFormatter(ApiVersionFormatter versionFormatter) {
    180+
    this.versionFormatter = versionFormatter;
    181+
    return this;
    182+
    }
    183+
    184+
    /**
    185+
    * Build the inserter.
    186+
    */
    187+
    public ApiVersionInserter build() {
    188+
    return new DefaultApiVersionInserter(
    189+
    this.header, this.queryParam, this.pathSegmentIndex, this.versionFormatter);
    190+
    }
    191+
    }
    192+
    193+
    }

    spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java

    Lines changed: 37 additions & 9 deletions
    Original file line numberDiff line numberDiff line change
    @@ -108,6 +108,8 @@ final class DefaultRestClient implements RestClient {
    108108

    109109
    private final @Nullable MultiValueMap<String, String> defaultCookies;
    110110

    111+
    private final @Nullable ApiVersionInserter apiVersionInserter;
    112+
    111113
    private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest;
    112114

    113115
    private final List<StatusHandler> defaultStatusHandlers;
    @@ -128,6 +130,7 @@ final class DefaultRestClient implements RestClient {
    128130
    UriBuilderFactory uriBuilderFactory,
    129131
    @Nullable HttpHeaders defaultHeaders,
    130132
    @Nullable MultiValueMap<String, String> defaultCookies,
    133+
    @Nullable ApiVersionInserter apiVersionInserter,
    131134
    @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
    132135
    @Nullable List<StatusHandler> statusHandlers,
    133136
    List<HttpMessageConverter<?>> messageConverters,
    @@ -142,6 +145,7 @@ final class DefaultRestClient implements RestClient {
    142145
    this.uriBuilderFactory = uriBuilderFactory;
    143146
    this.defaultHeaders = defaultHeaders;
    144147
    this.defaultCookies = defaultCookies;
    148+
    this.apiVersionInserter = apiVersionInserter;
    145149
    this.defaultRequest = defaultRequest;
    146150
    this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>());
    147151
    this.messageConverters = messageConverters;
    @@ -293,6 +297,8 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
    293297

    294298
    private @Nullable MultiValueMap<String, String> cookies;
    295299

    300+
    private @Nullable Object apiVersion;
    301+
    296302
    private @Nullable InternalBody body;
    297303

    298304
    private @Nullable Map<String, Object> attributes;
    @@ -417,6 +423,12 @@ public DefaultRequestBodyUriSpec ifNoneMatch(String... ifNoneMatches) {
    417423
    return this;
    418424
    }
    419425

    426+
    @Override
    427+
    public RequestBodySpec apiVersion(Object version) {
    428+
    this.apiVersion = version;
    429+
    return this;
    430+
    }
    431+
    420432
    @Override
    421433
    public RequestBodySpec attribute(String name, Object value) {
    422434
    getAttributes().put(name, value);
    @@ -589,7 +601,12 @@ public ResponseSpec retrieve() {
    589601
    }
    590602

    591603
    private URI initUri() {
    592-
    return (this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand(""));
    604+
    URI uriToUse = this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand("");
    605+
    if (this.apiVersion != null) {
    606+
    Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
    607+
    uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse);
    608+
    }
    609+
    return uriToUse;
    593610
    }
    594611

    595612
    private @Nullable String serializeCookies() {
    @@ -628,18 +645,29 @@ private static String serializeCookies(MultiValueMap<String, String> map) {
    628645

    629646
    private @Nullable HttpHeaders initHeaders() {
    630647
    HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders;
    631-
    if (this.headers == null || this.headers.isEmpty()) {
    632-
    return defaultHeaders;
    633-
    }
    634-
    else if (defaultHeaders == null || defaultHeaders.isEmpty()) {
    635-
    return this.headers;
    648+
    if (this.apiVersion == null) {
    649+
    if (this.headers == null || this.headers.isEmpty()) {
    650+
    return defaultHeaders;
    651+
    }
    652+
    else if (defaultHeaders == null || defaultHeaders.isEmpty()) {
    653+
    return this.headers;
    654+
    }
    636655
    }
    637-
    else {
    638-
    HttpHeaders result = new HttpHeaders();
    656+
    657+
    HttpHeaders result = new HttpHeaders();
    658+
    if (defaultHeaders != null) {
    639659
    result.putAll(defaultHeaders);
    660+
    }
    661+
    if (this.headers != null) {
    640662
    result.putAll(this.headers);
    641-
    return result;
    642663
    }
    664+
    665+
    if (this.apiVersion != null) {
    666+
    Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
    667+
    apiVersionInserter.insertVersion(this.apiVersion, result);
    668+
    }
    669+
    670+
    return result;
    643671
    }
    644672

    645673
    private ClientHttpRequest createRequest(URI uri) throws IOException {

    0 commit comments

    Comments
     (0)
    0