Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/main/java/com/checkout/AbstractClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

import static com.checkout.common.CheckoutUtils.validateParams;

import java.nio.charset.StandardCharsets;

public abstract class AbstractClient {

private static final char[] HEX = "0123456789ABCDEF".toCharArray();
private static final String INVALID_PATH_PARAMETER =
"Invalid path parameter: it must be valid text, not null, empty, '.' or '..'";

protected final ApiClient apiClient;
protected final CheckoutConfiguration configuration;
private final SdkAuthorizationType authorizationType;
Expand All @@ -25,8 +31,62 @@
return configuration.getSdkCredentials().getAuthorization(authorizationType);
}

/**
* Joins the parameters into a request path, percent-encoding each one as exactly one segment. A
* prefix of several segments must be passed as separate parameters.
*
* @param pathParams the path segments, in order
* @return the request path, relative to the base URI
* @throws CheckoutArgumentException if a parameter is null, empty, "." or "..", or is not valid
* UTF-16 text (an unpaired surrogate)
*/
protected static String buildPath(final String... pathParams) {
return String.join("/", pathParams);
final StringBuilder path = new StringBuilder();
for (final String pathParam : pathParams) {
if (path.length() > 0) {
path.append('/');
}
path.append(encodePathSegment(pathParam));
}
return path.toString();
}

/**
* Percent-encodes a path parameter as UTF-8, keeping only the RFC 3986 unreserved characters
* (letters, digits, "-", ".", "_" and "~"), so the result is exactly one path segment.
*/
private static String encodePathSegment(final String pathParam) {
if (pathParam == null || pathParam.isEmpty() || ".".equals(pathParam) || "..".equals(pathParam)
|| hasUnpairedSurrogate(pathParam)) {
throw new CheckoutArgumentException(INVALID_PATH_PARAMETER);
}
final StringBuilder encoded = new StringBuilder(pathParam.length());
for (final byte b : pathParam.getBytes(StandardCharsets.UTF_8)) {
final int c = b & 0xFF;
if (isUnreserved(c)) {
encoded.append((char) c);
} else {
encoded.append('%').append(HEX[c >> 4]).append(HEX[c & 0x0F]);
}
}
return encoded.toString();
}

private static boolean isUnreserved(final int c) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|| c == '-' || c == '.' || c == '_' || c == '~';
}

private static boolean hasUnpairedSurrogate(final String value) {
for (int i = 0; i < value.length(); i++) {
final char c = value.charAt(i);
if (Character.isHighSurrogate(c) && i + 1 < value.length() && Character.isLowSurrogate(value.charAt(i + 1))) {
i++;

Check warning on line 84 in src/main/java/com/checkout/AbstractClient.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code in order to not assign to this loop counter from within the loop body.

See more on https://sonarcloud.io/project/issues?id=checkout_checkout-sdk-java&issues=AaHAdvp2tQ8hVcTh0Y0z&open=AaHAdvp2tQ8hVcTh0Y0z&pullRequest=707
} else if (Character.isSurrogate(c)) {
return true;
}
}
return false;
}

protected boolean isSandbox() {
Expand Down
72 changes: 69 additions & 3 deletions src/main/java/com/checkout/ApacheHttpClientTransport.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.retry.Retry;
Expand All @@ -62,6 +63,8 @@ class ApacheHttpClientTransport implements Transport {
private static final String FILE = "file";
private static final String PURPOSE = "purpose";
private static final String PATH = "path";
private static final String INVALID_REQUEST_PATH = "Invalid request path";
private static final Pattern ENCODED_QUERY = Pattern.compile("[A-Za-z0-9._~*+%=&-]+");

private final URI baseUri;
private final CloseableHttpClient httpClient;
Expand All @@ -80,7 +83,7 @@ class ApacheHttpClientTransport implements Transport {
final CheckoutConfiguration configuration
) {
CheckoutUtils.validateParams("baseUri", baseUri, "httpClientBuilder", httpClientBuilder, "executor", executor);
this.baseUri = baseUri;
this.baseUri = withRootPath(baseUri);
this.httpClient = httpClientBuilder
.setRedirectStrategy(new CustomAwsRedirectStrategy())
.build();
Expand Down Expand Up @@ -381,11 +384,74 @@ private HttpUriRequest buildRequest(final ClientOperation clientOperation, final
return request;
}

private String getRequestUrl(final String path) {
/**
* Resolves a request path against the base URI. The path must be relative and made of non-empty
* segments, none of them "." or ".." (also when percent-encoded or followed by ";"). It may end with
* an already encoded query ("?name=value&amp;..." made only of unreserved characters, "%", "+", "="
* and "&amp;"), and never with a "#". The resolved URL must keep the scheme, host, port and base path
* of the base URI.
*
* @param path the request path, as built by {@code AbstractClient.buildPath}
* @return the absolute request URL
* @throws CheckoutArgumentException if the path is invalid or resolves outside the base URI
*/
String getRequestUrl(final String path) {
validateRequestPath(path);
final URI resolved = baseUri.resolve(path);
if (!isUnderBaseUri(resolved)) {
throw new CheckoutArgumentException(INVALID_REQUEST_PATH);
}
try {
return baseUri.resolve(path).toURL().toString();
return resolved.toURL().toString();
} catch (final MalformedURLException e) {
throw new CheckoutException(e);
}
}

/**
* A base URI with no path ("https://api.example.com") resolves "payments" to the host
* "api.example.compayments"; the root path "/" keeps the request on the configured host.
*/
private static URI withRootPath(final URI baseUri) {
final String path = baseUri.getRawPath();
return baseUri.isAbsolute() && !baseUri.isOpaque() && (path == null || path.isEmpty()) ? baseUri.resolve("/") : baseUri;
}

private static void validateRequestPath(final String path) {
if (path == null || path.isEmpty() || path.indexOf('#') >= 0) {
throw new CheckoutArgumentException(INVALID_REQUEST_PATH);
}
final int queryStart = path.indexOf('?');
if (queryStart >= 0 && !ENCODED_QUERY.matcher(path.substring(queryStart + 1)).matches()) {
throw new CheckoutArgumentException(INVALID_REQUEST_PATH);
}
final String pathOnly = queryStart >= 0 ? path.substring(0, queryStart) : path;
for (final String segment : pathOnly.split("/", -1)) {
if (isEmptyOrDotSegment(segment)) {
throw new CheckoutArgumentException(INVALID_REQUEST_PATH);
}
}
}

private static boolean isEmptyOrDotSegment(final String segment) {
final int parameters = segment.indexOf(';');
final String name = (parameters >= 0 ? segment.substring(0, parameters) : segment).replaceAll("(?i)%2e", ".");
return name.isEmpty() || ".".equals(name) || "..".equals(name);
}

/**
* True when the URL has the scheme, host and port of the base URI and a path under the base URI's
* directory, the part of its path that {@link URI#resolve(String)} keeps.
*/
private boolean isUnderBaseUri(final URI resolved) {
final String basePath = baseUri.getRawPath() == null ? "" : baseUri.getRawPath();
final String baseDirectory = basePath.substring(0, basePath.lastIndexOf('/') + 1);
return baseUri.getScheme().equalsIgnoreCase(resolved.getScheme())
&& baseUri.getHost() != null
&& baseUri.getHost().equalsIgnoreCase(resolved.getHost())
&& baseUri.getPort() == resolved.getPort()
&& resolved.getRawPath() != null
&& resolved.getRawPath().startsWith(baseDirectory)
&& resolved.getRawFragment() == null;
}
}
7 changes: 4 additions & 3 deletions src/main/java/com/checkout/apm/bacs/BacsClientImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
public class BacsClientImpl extends AbstractClient implements BacsClient {

private static final String APMS = "apms";
private static final String BACS_NOTIFICATIONS = "bacs/notifications";
private static final String BACS = "bacs";
private static final String NOTIFICATIONS = "notifications";
private static final String BACS_NOTIFICATION_REQUEST = "bacsNotificationRequest";

/**
Expand All @@ -26,14 +27,14 @@ public BacsClientImpl(final ApiClient apiClient, final CheckoutConfiguration con
@Override
public CompletableFuture<BacsNotificationResponse> sendNotification(final BacsNotificationRequest bacsNotificationRequest) {
validateParams(BACS_NOTIFICATION_REQUEST, bacsNotificationRequest);
return apiClient.postAsync(buildPath(APMS, BACS_NOTIFICATIONS), sdkAuthorization(), BacsNotificationResponse.class, bacsNotificationRequest, null);
return apiClient.postAsync(buildPath(APMS, BACS, NOTIFICATIONS), sdkAuthorization(), BacsNotificationResponse.class, bacsNotificationRequest, null);
}

// Synchronous methods
@Override
public BacsNotificationResponse sendNotificationSync(final BacsNotificationRequest bacsNotificationRequest) {
validateParams(BACS_NOTIFICATION_REQUEST, bacsNotificationRequest);
return apiClient.post(buildPath(APMS, BACS_NOTIFICATIONS), sdkAuthorization(), BacsNotificationResponse.class, bacsNotificationRequest, null);
return apiClient.post(buildPath(APMS, BACS, NOTIFICATIONS), sdkAuthorization(), BacsNotificationResponse.class, bacsNotificationRequest, null);
}

}
2 changes: 1 addition & 1 deletion src/main/java/com/checkout/apm/ideal/IdealClientImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

public class IdealClientImpl extends AbstractClient implements IdealClient {

private static final String IDEAL_EXTERNAL_PATH = "/ideal-external";
private static final String IDEAL_EXTERNAL_PATH = "ideal-external";
private static final String ISSUERS = "issuers";

public IdealClientImpl(final ApiClient apiClient, final CheckoutConfiguration configuration) {
Expand Down
19 changes: 10 additions & 9 deletions src/main/java/com/checkout/apm/previous/sepa/SepaClientImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
public class SepaClientImpl extends AbstractClient implements SepaClient {

private static final String APMS = "apms";
private static final String SEPA_MANDATES = "sepa/mandates";
private static final String SEPA = "sepa";
private static final String MANDATES = "mandates";
private static final String PPRO = "ppro";
private static final String CANCEL = "cancel";
private static final String MANDATE_ID = "mandateId";
Expand All @@ -24,50 +25,50 @@ public SepaClientImpl(final ApiClient apiClient, final CheckoutConfiguration con
@Override
public CompletableFuture<MandateResponse> getMandate(final String mandateId) {
validateMandateId(mandateId);
return apiClient.getAsync(buildPath(SEPA_MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
return apiClient.getAsync(buildPath(SEPA, MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
}

@Override
public CompletableFuture<SepaResource> cancelMandate(final String mandateId) {
validateMandateId(mandateId);
return apiClient.postAsync(buildPath(SEPA_MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
return apiClient.postAsync(buildPath(SEPA, MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
}

@Override
public CompletableFuture<MandateResponse> getMandateViaPPRO(final String mandateId) {
validateMandateId(mandateId);
return apiClient.getAsync(buildPath(APMS, PPRO, SEPA_MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
return apiClient.getAsync(buildPath(APMS, PPRO, SEPA, MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
}

@Override
public CompletableFuture<SepaResource> cancelMandateViaPPRO(final String mandateId) {
validateMandateId(mandateId);
return apiClient.postAsync(buildPath(APMS, PPRO, SEPA_MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
return apiClient.postAsync(buildPath(APMS, PPRO, SEPA, MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
}

// Synchronous methods
@Override
public MandateResponse getMandateSync(final String mandateId) {
validateMandateId(mandateId);
return apiClient.get(buildPath(SEPA_MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
return apiClient.get(buildPath(SEPA, MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
}

@Override
public SepaResource cancelMandateSync(final String mandateId) {
validateMandateId(mandateId);
return apiClient.post(buildPath(SEPA_MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
return apiClient.post(buildPath(SEPA, MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
}

@Override
public MandateResponse getMandateViaPPROSync(final String mandateId) {
validateMandateId(mandateId);
return apiClient.get(buildPath(APMS, PPRO, SEPA_MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
return apiClient.get(buildPath(APMS, PPRO, SEPA, MANDATES, mandateId), sdkAuthorization(), MandateResponse.class);
}

@Override
public SepaResource cancelMandateViaPPROSync(final String mandateId) {
validateMandateId(mandateId);
return apiClient.post(buildPath(APMS, PPRO, SEPA_MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
return apiClient.post(buildPath(APMS, PPRO, SEPA, MANDATES, mandateId, CANCEL), sdkAuthorization(), SepaResource.class, null, null);
}

// Common methods
Expand Down
25 changes: 16 additions & 9 deletions src/main/java/com/checkout/events/previous/EventsClientImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import static com.checkout.common.CheckoutUtils.validateParams;

import java.io.UnsupportedEncodingException;
import java.lang.reflect.Type;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;

import com.checkout.AbstractClient;
Expand Down Expand Up @@ -30,8 +33,7 @@ public EventsClientImpl(final ApiClient apiClient, final CheckoutConfiguration c

@Override
public CompletableFuture<ItemsResponse<EventTypes>> retrieveAllEventTypes(final String version) {
final StringBuilder path = buildEventTypesPath(version);
return apiClient.getAsync(path.toString(), sdkAuthorization(), EVENT_TYPES_TYPE);
return apiClient.getAsync(eventTypesPath(version), sdkAuthorization(), EVENT_TYPES_TYPE);
}

@Override
Expand Down Expand Up @@ -67,8 +69,7 @@ public CompletableFuture<EmptyResponse> retryAllWebhooks(final String eventId) {
// Synchronous methods
@Override
public ItemsResponse<EventTypes> retrieveAllEventTypesSync(final String version) {
final StringBuilder path = buildEventTypesPath(version);
return apiClient.get(path.toString(), sdkAuthorization(), EVENT_TYPES_TYPE);
return apiClient.get(eventTypesPath(version), sdkAuthorization(), EVENT_TYPES_TYPE);
}

@Override
Expand Down Expand Up @@ -102,12 +103,18 @@ public EmptyResponse retryAllWebhooksSync(final String eventId) {
}

// Common methods
private StringBuilder buildEventTypesPath(final String version) {
final StringBuilder path = new StringBuilder(EVENT_TYPES_PATH);
if (version != null) {
path.append("?version=").append(version);
/**
* The event types path, with {@code version} as a form-encoded query parameter when it is set.
*/
private static String eventTypesPath(final String version) {
if (version == null) {
return EVENT_TYPES_PATH;
}
try {
return EVENT_TYPES_PATH + "?version=" + URLEncoder.encode(version, StandardCharsets.UTF_8.name());
} catch (final UnsupportedEncodingException e) {
throw new IllegalStateException("UTF-8 is always supported", e);
}
return path;
}

protected void validateRetrieveEventsRequest(final RetrieveEventsRequest retrieveEventsRequest) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
*/
public class PaymentSetupsClientImpl extends AbstractClient implements PaymentSetupsClient {

private static final String PAYMENT_SETUPS_PATH = "payments/setups";
private static final String PAYMENTS_PATH = "payments";
private static final String SETUPS_PATH = "setups";
private static final String CONFIRM_PATH = "confirm";

public PaymentSetupsClientImpl(final ApiClient apiClient, final CheckoutConfiguration configuration) {
Expand All @@ -34,7 +35,7 @@ public PaymentSetupsClientImpl(final ApiClient apiClient, final CheckoutConfigur
public CompletableFuture<PaymentSetupsResponse> createPaymentSetup(
final PaymentSetupsRequest paymentSetupsRequest) {
validateParams("paymentSetupsRequest", paymentSetupsRequest);
return apiClient.postAsync(PAYMENT_SETUPS_PATH, sdkAuthorization(), PaymentSetupsResponse.class,
return apiClient.postAsync(buildPath(PAYMENTS_PATH, SETUPS_PATH), sdkAuthorization(), PaymentSetupsResponse.class,
paymentSetupsRequest, null);
}

Expand All @@ -49,7 +50,7 @@ public CompletableFuture<PaymentSetupsResponse> createPaymentSetup(
public CompletableFuture<PaymentSetupsResponse> updatePaymentSetup(final String id,
final PaymentSetupsRequest paymentSetupsRequest) {
validateParams("id", id, "paymentSetupsRequest", paymentSetupsRequest);
return apiClient.putAsync(buildPath(PAYMENT_SETUPS_PATH, id), sdkAuthorization(), PaymentSetupsResponse.class,
return apiClient.putAsync(buildPath(PAYMENTS_PATH, SETUPS_PATH, id), sdkAuthorization(), PaymentSetupsResponse.class,
paymentSetupsRequest);
}

Expand All @@ -62,7 +63,7 @@ public CompletableFuture<PaymentSetupsResponse> updatePaymentSetup(final String
@Override
public CompletableFuture<PaymentSetupsResponse> getPaymentSetup(final String id) {
validateParams("id", id);
return apiClient.getAsync(buildPath(PAYMENT_SETUPS_PATH, id), sdkAuthorization(), PaymentSetupsResponse.class);
return apiClient.getAsync(buildPath(PAYMENTS_PATH, SETUPS_PATH, id), sdkAuthorization(), PaymentSetupsResponse.class);
}

/**
Expand All @@ -76,7 +77,7 @@ public CompletableFuture<PaymentSetupsResponse> getPaymentSetup(final String id)
public CompletableFuture<PaymentSetupsConfirmResponse> confirmPaymentSetup(final String id,
final String paymentMethodName) {
validateParams("id", id, "paymentMethodName", paymentMethodName);
return apiClient.postAsync(buildPath(PAYMENT_SETUPS_PATH, id, CONFIRM_PATH, paymentMethodName),
return apiClient.postAsync(buildPath(PAYMENTS_PATH, SETUPS_PATH, id, CONFIRM_PATH, paymentMethodName),
sdkAuthorization(), PaymentSetupsConfirmResponse.class, null, null);
}
}
Loading
Loading