Hacktoberfest 2026:維護者為十月標記出來的 issue,仍然開放、適合新手。 瀏覽 Hacktoberfest issue

Destination Service fails to authenticate

未關閉
#1,291 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

維護者通常 1 天內回覆

還沒有人認領這個 Issue。

評估

難度
4/5
預估耗時
3-5 天
新手友好度
48/100
Issue 類型
缺陷
描述清晰度
基本清楚
活躍度
活躍
技術堆疊
java

研究方向

Start at lookupDestinationByName in src/main/java/com/sap/oc/sdk/handler/OutputControlClient.java around line 328, then inspect the DestinationOptions configured for subscriber-only retrieval and user-token forwarding. Reproduce the multitenant BTP setup described in the issue and verify that the subscriber destination can obtain a token and successfully call the OData API without a 401 invalid_client error.

由索引模型根據 Issue 內容生成。

描述

bug
Describe the Bug

Destination service is unable to call the API provided in the BTP Destination.
We get the error when accessing the destination maintained in the BTP subscriber subaccount even though destination is added as subscription dependency under multitenancy section.

Image

Http status code 401. Response body '{"error":"invalid_client","error_description":"Bad credentials"}'

caused by: com.sap.cloud.security.xsuaa.client.OAuth2ServiceException: Error requesting access token!. Server URI https://test-app-provider.authentication.eu12.hana.ondemand.com/oauth/token. Http status code 401. Response body '{"error":"invalid_client","error_description":"Bad credentials"}'. Request Headers [X-zid: 2b0a175e-0f9a-46e7-a62c-40f3c3bf8c0b, Content-Type: application/x-www-form-urlencoded, X-CorrelationID: 3266bc7e-dd0b-4df3-4a64-6099d75968ee, Accept: application/json, User-Agent: token-client/3.6.5]. Response Headers [cache-control: no-store, content-type: application/json, date: Tue, 29 Sep 2026 11:25:56 GMT, pragma: no-cache, server: openresty, www-authenticate: Basic realm="UAA/client", error="unauthorized", error_description="Bad credentials", x-content-type-options: nosniff, x-frame-options: DENY, x-vcap-request-id: 4df39f45-d6a5-4d17-534c-9faf8e7a7235, x-xss-protection: 0, transfer-encoding: chunked, strict-transport-security: max-age=31536000; includeSubDomains; preload;]
Steps to Reproduce

We can help to reproduce the issue as the setup is a bit complicated.
However, this is the code that throws this error: https://github.tools.sap/erp4sme/suite-output-control-java-sdk/blob/15fbdd30a92ab0da910568954e64b4e3ced9870c/src/main/java/com/sap/oc/sdk/handler/OutputControlClient.java#L328

Expected Behavior

The destination should be picked up from the subscriber subaccount and the OData APIs using the destination URI should be called successfully

Screenshots

No response

Used Versions
  • Java and Maven version via mvn --version:
    • Apache Maven 3.9.12 (848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1)
    • Java version: 21.0.10
  • SAP Cloud SDK version: 5.26.0
  • Spring Boot or CAP version:
    • Spring Boot: 3.5.5
    • CAP Java SDK: 4.6.1
Dependency tree via mvn dependency:tree
[INFO] Scanning for projects...
[INFO] 
[INFO] -------------< com.sap.soc:suite-output-control-java-sdk >--------------
[INFO] Building Suite Output Control Java SDK 1.0.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] 
[INFO] --- dependency:3.7.0:tree (default-cli) @ suite-output-control-java-sdk ---
[INFO] com.sap.soc:suite-output-control-java-sdk:jar:1.0.0
[INFO] +- com.sap.cloud.sdk.cloudplatform:connectivity-destination-service:jar:5.26.0:compile
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:cloudplatform-core:jar:5.26.0:compile
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:caching:jar:5.26.0:compile
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:cloudplatform-connectivity:jar:5.26.0:compile
[INFO] |  |  +- com.sap.cloud.sdk.cloudplatform:resilience-api:jar:5.26.0:compile
[INFO] |  |  +- com.fasterxml.jackson.core:jackson-databind:jar:2.21.0:compile
[INFO] |  |  |  \- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile
[INFO] |  |  +- com.mikesamuel:json-sanitizer:jar:1.2.3:compile
[INFO] |  |  +- org.bouncycastle:bcprov-jdk18on:jar:1.83:compile
[INFO] |  |  \- org.bouncycastle:bcpkix-jdk18on:jar:1.83:compile
[INFO] |  |     \- org.bouncycastle:bcutil-jdk18on:jar:1.83:compile
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:connectivity-apache-httpclient4:jar:5.26.0:compile
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:connectivity-oauth:jar:5.26.0:compile
[INFO] |  |  +- com.sap.cloud.security.xsuaa:token-client:jar:3.6.5:compile
[INFO] |  |  |  \- org.json:json:jar:20250517:compile
[INFO] |  |  +- com.sap.cloud.security:java-api:jar:3.6.5:compile
[INFO] |  |  \- com.sap.cloud.security:java-security:jar:3.6.5:runtime
[INFO] |  |     \- com.sap.cloud.security:env:jar:3.6.5:runtime
[INFO] |  |        +- com.sap.cloud.environment.servicebinding:java-sap-vcap-services:jar:0.21.0:runtime
[INFO] |  |        \- com.sap.cloud.environment.servicebinding:java-sap-service-operator:jar:0.21.0:runtime
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:resilience:jar:5.26.0:compile
[INFO] |  |  \- com.sap.cloud.sdk.cloudplatform:resilience4j:jar:5.26.0:compile
[INFO] |  |     +- io.github.resilience4j:resilience4j-core:jar:2.3.0:compile
[INFO] |  |     |  \- org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:1.9.0:compile
[INFO] |  |     |     +- org.jetbrains.kotlin:kotlin-stdlib:jar:1.9.0:compile
[INFO] |  |     |     |  +- org.jetbrains.kotlin:kotlin-stdlib-common:jar:1.9.0:compile
[INFO] |  |     |     |  \- org.jetbrains:annotations:jar:13.0:compile
[INFO] |  |     |     \- org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:1.9.0:compile
[INFO] |  |     +- io.github.resilience4j:resilience4j-circuitbreaker:jar:2.3.0:compile
[INFO] |  |     +- io.github.resilience4j:resilience4j-bulkhead:jar:2.3.0:compile
[INFO] |  |     +- io.github.resilience4j:resilience4j-timelimiter:jar:2.3.0:compile
[INFO] |  |     +- io.github.resilience4j:resilience4j-retry:jar:2.3.0:compile
[INFO] |  |     +- io.github.resilience4j:resilience4j-ratelimiter:jar:2.3.0:compile
[INFO] |  |     \- javax.cache:cache-api:jar:1.1.1:compile
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:security:jar:5.26.0:compile
[INFO] |  +- com.sap.cloud.sdk.cloudplatform:tenant:jar:5.26.0:compile
[INFO] |  +- org.slf4j:slf4j-api:jar:2.0.17:compile
[INFO] |  +- org.slf4j:jcl-over-slf4j:jar:2.0.17:runtime
[INFO] |  +- com.google.guava:guava:jar:33.5.0-jre:compile
[INFO] |  |  +- com.google.guava:failureaccess:jar:1.0.3:compile
[INFO] |  |  +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO] |  |  +- org.jspecify:jspecify:jar:1.0.0:compile
[INFO] |  |  +- com.google.errorprone:error_prone_annotations:jar:2.41.0:compile
[INFO] |  |  \- com.google.j2objc:j2objc-annotations:jar:3.1:compile
[INFO] |  +- com.github.ben-manes.caffeine:caffeine:jar:3.2.3:compile
[INFO] |  +- com.auth0:java-jwt:jar:4.5.0:compile
[INFO] |  |  \- com.fasterxml.jackson.core:jackson-core:jar:2.15.4:compile
[INFO] |  +- commons-io:commons-io:jar:2.21.0:compile
[INFO] |  +- org.apache.httpcomponents:httpcore:jar:4.4.16:compile
[INFO] |  +- org.apache.httpcomponents:httpclient:jar:4.5.14:compile
[INFO] |  |  \- commons-codec:commons-codec:jar:1.11:compile
[INFO] |  +- io.vavr:vavr:jar:0.11.0:compile
[INFO] |  |  \- io.vavr:vavr-match:jar:0.11.0:compile
[INFO] |  +- org.apache.commons:commons-lang3:jar:3.20.0:compile
[INFO] |  +- com.sap.cloud.environment.servicebinding.api:java-core-api:jar:0.21.0:compile
[INFO] |  +- com.sap.cloud.environment.servicebinding.api:java-access-api:jar:0.21.0:compile
[INFO] |  \- com.sap.cloud.environment.servicebinding.api:java-consumption-api:jar:0.21.0:compile
[INFO] +- com.google.code.gson:gson:jar:2.10.1:compile
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:5.10.2:test
[INFO] |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] |  +- org.junit.platform:junit-platform-commons:jar:1.10.2:test
[INFO] |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-engine:jar:5.10.2:test
[INFO] |  \- org.junit.platform:junit-platform-engine:jar:1.10.2:test
[INFO] +- org.mockito:mockito-core:jar:5.14.2:test
[INFO] |  \- org.objenesis:objenesis:jar:3.3:test
[INFO] +- org.mockito:mockito-junit-jupiter:jar:5.14.2:test
[INFO] +- net.bytebuddy:byte-buddy:jar:1.17.5:test
[INFO] +- net.bytebuddy:byte-buddy-agent:jar:1.17.5:test
[INFO] \- org.assertj:assertj-core:jar:3.25.3:test
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  0.627 s
[INFO] Finished at: 2026-09-30T15:54:09+05:30
[INFO] ------------------------------------------------------------------------
Code Examples

Method name: lookupDestinationByName

package com.sap.oc.sdk.handler;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultCsrfTokenRetriever;
import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationAccessor;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationOptions;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationService;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationServiceOptionsAugmenter;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationServiceRetrievalStrategy;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationServiceTokenExchangeStrategy;
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpClientAccessor;
import com.sap.oc.sdk.exception.OutputControlException;

/**
 * Base class for all OData handlers in the Output Control SDK.
 *
 * <p>
 * Provides common HTTP primitives (GET, POST, PATCH, DELETE) for invoking
 * the Output Control OData v4 service, along with shared infrastructure such
 * as:
 * <ul>
 * <li>Standardized request construction and header management.</li>
 * <li>CSRF token handling with automatic refresh on 403 responses.</li>
 * <li>ETag / {@code If-Match} handling for concurrency-safe updates.</li>
 * <li>JSON (de)serialization via a shared {@link Gson} instance.</li>
 * <li>Consistent error handling and mapping to
 * {@link OutputControlException}.</li>
 * </ul>
 * Concrete handler classes extend this base to implement entity-specific
 * operations against the Output Control service.
 */
public abstract class OutputControlClient {

    // logger used for diagnostic and error logging across all subclasses.
    private static final Logger LOGGER = LoggerFactory.getLogger(OutputControlClient.class);

    // BTP destination name used to connect to the Output Management Service on ABAP
    // Cloud via the SDK.
    private static final String DESTINATION_NAME = "output-management-service";

    // System property to be set for local development
    private static final String ENV_FALLBACK_ENABLED = "DESTINATION_FALLBACK_ENABLED";

    // Destination lookup options: subscriber-only tenant isolation + user-token forwarding.
    private static final DestinationOptions DESTINATION_OPTIONS = DestinationOptions.builder()
            .augmentBuilder(DestinationServiceOptionsAugmenter.augmenter()
                    .retrievalStrategy(DestinationServiceRetrievalStrategy.ONLY_SUBSCRIBER)
                    .tokenExchangeStrategy(DestinationServiceTokenExchangeStrategy.FORWARD_USER_TOKEN))
            .build();

    // Base OData v4 service path for the Output Control API.
    private static final String SERVICE_PATH = "/sap/opu/odata4/sap/api_outputcontroloutputrequest/srvd_a2x/sap/api_outputcontroloutputrequest/0001";

    // Standard HTTP header names used throughout the OData requests.
    private static final String HEADER_ACCEPT = "Accept";
    private static final String HEADER_CONTENT_TYPE = "Content-Type";
    private static final String HEADER_X_CSRF_TOKEN = "X-CSRF-Token";
    private static final String HEADER_IF_MATCH = "If-Match";

    // Shared Gson instance used for (de)serializing JSON request/response bodies.
    protected final Gson gson;

    // CSRF token cached per client instance; invalidated (reset to null) upon 403
    // responses.
    private final AtomicReference<String> cachedCsrfToken = new AtomicReference<>(null);

    protected OutputControlClient() {
        // Initialize the shared Gson instance with default configuration.
        this.gson = new GsonBuilder().create();
    }

     // ==================== Destination resolution ====================

    /**
     *
     * <p>
     * This destination must be configured in the BTP subaccount with
     * a technical user and is shared across all consuming CAP applications.
     */
    protected String resolveDestinationName() {
        return DESTINATION_NAME;
    }

     /**
     * Resolves a destination by name in the currently active tenant context.
     * <p>
     * Prefers the BTP {@link DestinationService} (tenant-aware)
     * <p>
     * For local development only: set {@code DESTINATION_FALLBACK_ENABLED=true}
     * to fall back to {@link DestinationAccessor} when the DestinationService
     * is unavailable.
     *
     * @param destinationName the name of the destination to look up.
     * @return the resolved HTTP {@link Destination}.
     * @throws IllegalStateException if destination resolution fails and fallback is
     *                               not enabled.
     */
    private static Destination lookupDestinationByName(String destinationName) {
        try {
            // tenant-aware lookup via the BTP Destination Service.
            return new DestinationService().tryGetDestination(destinationName, DESTINATION_OPTIONS).get().asHttp();
        } catch (Exception e) {
            // Fallback to be enabled for local development
            boolean fallbackEnabled = Boolean.parseBoolean(System.getProperty(ENV_FALLBACK_ENABLED, "false"));

            if (!fallbackEnabled) {
                throw new IllegalStateException(
                        String.format("Failed to resolve destination '%s' via DestinationService. ", destinationName),
                        e);
            }

            LOGGER.warn("Falling back to DestinationAccessor for destination '{}': {}",
                    destinationName, e.getMessage());
            return DestinationAccessor.getDestination(destinationName).asHttp();
        }
    }

    // ==================== HTTP primitives ====================

    /**
     * Executes an HTTP GET request against the given relative service path.
     *
     * @param path relative path appended to the base OData service path.
     * @return the response body as a JSON string.
     */
    protected String httpGet(String path) {
        return executeRequest("GET", path, null, false, null);
    }

    /**
     * Executes an HTTP POST request with the given JSON body.
     *
     * @param path relative path appended to the base OData service path.
     * @param body request payload object to be serialized as JSON.
     * @return the response body as a JSON string.
     */
    protected String httpPost(String path, Object body) {
        return executeRequest("POST", path, body == null ? java.util.Collections.emptyMap() : body, true, null);
    }

    /**
     * Executes an HTTP PATCH request with the given JSON body and optional ETag.
     * <p>
     * When {@code odataEtag} is non-null and non-blank it is sent as the
     * {@code If-Match} header value. When it is {@code null} or blank the
     * {@code If-Match} header is set to {@code *} (wildcard), which bypasses
     * optimistic-concurrency checks on the server side.
     *
     * @param path      relative path appended to the base OData service path.
     * @param body      request payload object to be serialized as JSON.
     * @param odataEtag OData ETag value used in the {@code If-Match} header;
     *                  pass {@code null} to send the wildcard value {@code *}.
     * @return the response body as a JSON string.
     */
    protected String httpPatch(String path, Object body, String odataEtag) {
        return executeRequest("PATCH", path, body == null ? java.util.Collections.emptyMap() : body, true, odataEtag);
    }

    /**
     * Executes an HTTP DELETE request against the given relative service path.
     *
     * @param path relative path appended to the base OData service path.
     */
    protected void httpDelete(String path) {
        executeRequest("DELETE", path, null, true, null);
    }

    /**
     * Central request execution method.
     * <p>
     * Performs per-request destination resolution, builds the
     * HTTP client, delegates to {@link #doExecute}, and handles CSRF token
     * refresh on 403 responses.
     *
     * @param httpMethod       the HTTP method ("GET", "POST", "PATCH", "DELETE").
     * @param path         relative path appended to the base OData service path.
     * @param body         optional request body object; will be serialized to JSON.
     * @param csrfRequired whether a CSRF token and {@code If-Match} header are
     *                     required.
     * @param odataEtag    OData ETag value for {@code If-Match}
     * @return the response body as a JSON string.
     * @throws OutputControlException if the destination cannot be resolved or the
     *                                request fails.
     */
    private String executeRequest(String httpMethod, String path, Object body, boolean csrfRequired, String odataEtag) {
        String fullPath = SERVICE_PATH + path;

        // Resolve destination NAME per request from the environment variable.
        String destinationName = resolveDestinationName();

        // Resolve destination in the currently active tenant context.
        Destination destination = lookupDestinationByName(destinationName);
        if (destination == null) {
            throw new OutputControlException(
                    "Destination '" + destinationName + "' could not be resolved for current request context", null);
        }

        // Obtain a HttpClient
        HttpClient httpClient = HttpClientAccessor.getHttpClient(destination);

        try {
            return executeRequest(httpClient, httpMethod, fullPath, body, csrfRequired, odataEtag);
        } catch (OutputControlException e) {
            // Log structured error information, then rethrow as-is.
            LOGGER.error("{} {} failed: status=", httpMethod, fullPath, e.getStatusCode(), e.getMessage());
            throw e;
        } catch (Exception e) {
            // Wrap any unexpected exception into an OutputControlException.
            LOGGER.error("{} {} failed", httpMethod, fullPath, e);
            throw new OutputControlException(httpMethod + " request failed: " + e.getMessage(), e);
        }
    }

    /**
     * Executes the request and, for CSRF-guarded calls, if the request fails with
     * HTTP 403,
     * then attempts the request another time with fresh CSRF token
     *
     * @param httpClient   destination-aware HTTP client.
     * @param httpMethod       HTTP method.
     * @param fullPath     fully-qualified request URI.
     * @param body         optional request payload.
     * @param csrfRequired whether a CSRF token must be attached.
     * @param odataEtag    OData ETag value for {@code If-Match}; wildcard is used
     *                     if blank.
     * @return the response body as a JSON string.
     * @throws IOException if the underlying HTTP call fails.
     */
    private String executeRequest(HttpClient httpClient, String httpMethod, String fullPath,
            Object body, boolean csrfRequired, String odataEtag) throws IOException {
        try {
            return doExecute(httpClient, httpMethod, fullPath, body, csrfRequired, odataEtag);
        } catch (OutputControlException e) {
            // On 403 for CSRF-guarded requests, invalidate the cached token and retry once.
            if (csrfRequired && e.getStatusCode() == 403) {
                cachedCsrfToken.set(null);
                return doExecute(httpClient, httpMethod, fullPath, body, csrfRequired, odataEtag);
            }
            throw e;
        }
    }

    /**
     * Builds and dispatches a single HTTP request via the given {@link HttpClient}.
     *
     * <p>
     * Header attachment rules:
     * <ul>
     * <li><b>CSRF token</b> – attached whenever {@code csrfRequired} is
     * {@code true}
     * (i.e. POST, PATCH, DELETE).</li>
     * <li><b>{@code If-Match}</b> – attached only for {@code PATCH} and
     * {@code DELETE},
     * where optimistic-concurrency control is meaningful.</li>
     * </ul>
     *
     * @param httpClient   pre-configured, destination-aware HTTP client.
     * @param httpMethod   the HTTP method to execute.
     * @param fullPath     the fully-qualified service path (including base path).
     * @param body         optional request body; ignored for GET/DELETE.
     * @param csrfRequired whether a CSRF token must be attached.
     * @param odataEtag    OData ETag value for {@code If-Match} (PATCH/DELETE
     *                     only);
     *                     {@code null} sends the wildcard value {@code *}.
     * @return the response body as a JSON string.
     * @throws IOException if the underlying HTTP call fails.
     */
    private String doExecute(HttpClient httpClient, String httpMethod, String fullPath,
            Object body, boolean csrfRequired, String odataEtag) throws IOException {

        RequestBuilder requestBuilder = createRequestBuilder(httpMethod, fullPath)
                .addHeader(HEADER_ACCEPT, ContentType.APPLICATION_JSON.getMimeType());

        // ── CSRF token ────────────────────────────────────────────────────────────
        // Required for all state-mutating requests (POST, PATCH, DELETE).
        if (csrfRequired) {
            String token = cachedCsrfToken.get();
            if (token == null) {
                String freshToken = new DefaultCsrfTokenRetriever()
                    .retrieveCsrfToken(httpClient, SERVICE_PATH)
                    .getToken();
                // Only write if still null — if another thread already stored a token, tat thread will win
                cachedCsrfToken.compareAndSet(null, freshToken);
                token = cachedCsrfToken.get();
            }
            requestBuilder.addHeader(HEADER_X_CSRF_TOKEN, token);

        }

        // ── If-Match header ───────────────────────────────────────────────────────
        // PATCH / DELETE: use the caller-supplied ETag, falling back to wildcard.
        // POST: always sent as wildcard (*)
        if ("PATCH".equals(httpMethod) || "DELETE".equals(httpMethod)) {
            String ifMatch = (odataEtag != null && !odataEtag.isBlank()) ? odataEtag : "*";
            requestBuilder.addHeader(HEADER_IF_MATCH, ifMatch);
        } else if ("POST".equals(httpMethod)) {
            requestBuilder.addHeader(HEADER_IF_MATCH, "*");
        }

        // ── Request body ──────────────────────────────────────────────────────────
        if (body != null && ("POST".equals(httpMethod) || "PATCH".equals(httpMethod))) {
            requestBuilder
                    .addHeader(HEADER_CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType())
                    .setEntity(new StringEntity(gson.toJson(body), ContentType.APPLICATION_JSON));
        }

        HttpResponse response = httpClient.execute(requestBuilder.build());
        return ResponseParser.handleResponse(response);
    }

    /**
     * Creates a new {@link RequestBuilder} for the given HTTP method and URI.
     *
     * @param httpMethod   HTTP method name ({@code GET}, {@code POST}, {@code PATCH},
     *                 {@code DELETE}).
     * @param fullPath fully-qualified request URI.
     * @return a configured {@link RequestBuilder}.
     * @throws IllegalArgumentException if the HTTP method is not supported.
     */
    private RequestBuilder createRequestBuilder(String httpMethod, String fullPath) {
        // Select the appropriate RequestBuilder factory based on the HTTP verb.
        RequestBuilder requestBuilder = switch (httpMethod) {
            case "GET" -> RequestBuilder.get();
            case "POST" -> RequestBuilder.post();
            case "PATCH" -> RequestBuilder.patch();
            case "DELETE" -> RequestBuilder.delete();
            default -> throw new IllegalArgumentException("Unsupported HTTP method: " + httpMethod);
        };
        return requestBuilder.setUri(fullPath);
    }

    // ==================== Helper methods ====================

    /**
     * Returns the {@link Gson} instance used for JSON (de)serialization.
     * <p>
     * Exposed for use by subclass handlers that need to serialize custom
     * payloads or parse response bodies.
     *
     * @return the {@link Gson} instance.
     */
    protected Gson getGson() {
        return gson;
    }
}
Stack Trace

No response

Log File

test-obj-srv-2026-09-29 11_31_22.627+0000.txt

file
...

Affected Development Phase

Development

Impact

Blocked

Timeline

GA in 2702 release

主要語言
Java
星號
41
分支
33
平均合併
22 小時 16 分鐘
30 天內合併 PR
19

環境準備

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

SAP/cloud-sdk-java 的其他 Issue

查看 SAP/cloud-sdk-java 的全部 Issue

相似的 Issue

更多 Java Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。