This is an automated email from the ASF dual-hosted git repository.
exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 61a7ee8b25 NIFI-14968 Added support for Bitbucket Data Center (#10322)
61a7ee8b25 is described below
commit 61a7ee8b25cbef691a2cd874b0a0949b57134d01
Author: Pierre Villard <[email protected]>
AuthorDate: Fri Nov 7 03:02:18 2025 +0100
NIFI-14968 Added support for Bitbucket Data Center (#10322)
Signed-off-by: David Handermann <[email protected]>
---
.../client/api/MultipartFormDataStreamBuilder.java | 26 +
.../StandardMultipartFormDataStreamBuilder.java | 99 ++-
.../bitbucket/BitbucketAuthenticationType.java | 1 +
.../bitbucket/BitbucketFlowRegistryClient.java | 108 ++-
...nticationType.java => BitbucketFormFactor.java} | 24 +-
.../bitbucket/BitbucketRepositoryClient.java | 948 +++++++++++++++++----
6 files changed, 985 insertions(+), 221 deletions(-)
diff --git
a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java
b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java
index 4595c19dab..157be356c8 100644
---
a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java
+++
b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java
@@ -55,4 +55,30 @@ public interface MultipartFormDataStreamBuilder {
* @return Builder
*/
MultipartFormDataStreamBuilder addPart(String name, HttpContentType
httpContentType, byte[] bytes);
+
+ /**
+ * Add Part using specified Name and File Name with Content-Type and Stream
+ *
+ * @param name Name field of part to be added
+ * @param fileName File Name field of part to be added
+ * @param httpContentType Content-Type of part to be added
+ * @param inputStream Stream content of part to be added
+ * @return Builder
+ */
+ default MultipartFormDataStreamBuilder addPart(String name, String
fileName, HttpContentType httpContentType, InputStream inputStream) {
+ return addPart(name, httpContentType, inputStream);
+ }
+
+ /**
+ * Add Part using specified Name and File Name with Content-Type and byte
array
+ *
+ * @param name Name field of part to be added
+ * @param fileName File Name field of part to be added
+ * @param httpContentType Content-Type of part to be added
+ * @param bytes Byte array content of part to be added
+ * @return Builder
+ */
+ default MultipartFormDataStreamBuilder addPart(String name, String
fileName, HttpContentType httpContentType, byte[] bytes) {
+ return addPart(name, httpContentType, bytes);
+ }
}
diff --git
a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java
b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java
index 3032db2b0e..d1333a9639 100644
---
a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java
+++
b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java
@@ -24,7 +24,6 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
-import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@@ -36,6 +35,7 @@ import java.util.regex.Pattern;
*/
public class StandardMultipartFormDataStreamBuilder implements
MultipartFormDataStreamBuilder {
private static final String CONTENT_DISPOSITION_HEADER =
"Content-Disposition: form-data; name=\"%s\"";
+ private static final String CONTENT_DISPOSITION_FILE_HEADER =
"Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"";
private static final String CONTENT_TYPE_HEADER = "Content-Type: %s";
@@ -55,30 +55,26 @@ public class StandardMultipartFormDataStreamBuilder
implements MultipartFormData
private final List<Part> parts = new ArrayList<>();
- /**
- * Build Sequence Input Stream from collection of Form Data Parts
formatted with boundaries
- *
- * @return Input Stream
- */
@Override
public InputStream build() {
if (parts.isEmpty()) {
throw new IllegalStateException("Parts required");
}
- final List<InputStream> partInputStreams = new ArrayList<>();
-
- final Iterator<Part> selectedParts = parts.iterator();
- while (selectedParts.hasNext()) {
- final Part part = selectedParts.next();
- final String footer = getFooter(selectedParts);
-
- final InputStream partInputStream = getPartInputStream(part,
footer);
- partInputStreams.add(partInputStream);
+ final List<InputStream> streams = new ArrayList<>();
+ for (int index = 0; index < parts.size(); index++) {
+ final Part part = parts.get(index);
+ final String boundaryPrefix = getBoundaryPrefix(index);
+ streams.add(new
ByteArrayInputStream(boundaryPrefix.getBytes(HEADERS_CHARACTER_SET)));
+ final String partHeaders = getPartHeaders(part);
+ streams.add(new
ByteArrayInputStream(partHeaders.getBytes(HEADERS_CHARACTER_SET)));
+ streams.add(part.inputStream);
}
- final Enumeration<InputStream> enumeratedPartInputStreams =
Collections.enumeration(partInputStreams);
- return new SequenceInputStream(enumeratedPartInputStreams);
+ streams.add(new
ByteArrayInputStream(getFooter().getBytes(HEADERS_CHARACTER_SET)));
+
+ final Enumeration<InputStream> enumeratedStreams =
Collections.enumeration(streams);
+ return new SequenceInputStream(enumeratedStreams);
}
/**
@@ -102,13 +98,23 @@ public class StandardMultipartFormDataStreamBuilder
implements MultipartFormData
*/
@Override
public MultipartFormDataStreamBuilder addPart(final String name, final
HttpContentType httpContentType, final InputStream inputStream) {
+ return addPartInternal(name, null, httpContentType, inputStream);
+ }
+
+ @Override
+ public MultipartFormDataStreamBuilder addPart(final String name, final
String fileName, final HttpContentType httpContentType, final InputStream
inputStream) {
+ final String sanitizedFileName = sanitizeFileName(fileName);
+ return addPartInternal(name, sanitizedFileName, httpContentType,
inputStream);
+ }
+
+ private MultipartFormDataStreamBuilder addPartInternal(final String name,
final String fileName, final HttpContentType httpContentType, final InputStream
inputStream) {
Objects.requireNonNull(name, "Name required");
Objects.requireNonNull(httpContentType, "Content Type required");
Objects.requireNonNull(inputStream, "Input Stream required");
final Matcher nameMatcher = ALLOWED_NAME_PATTERN.matcher(name);
if (nameMatcher.matches()) {
- final Part part = new Part(name, httpContentType, inputStream);
+ final Part part = new Part(name, fileName, httpContentType,
inputStream);
parts.add(part);
} else {
throw new IllegalArgumentException("Name contains characters
outside of ASCII character set");
@@ -132,18 +138,17 @@ public class StandardMultipartFormDataStreamBuilder
implements MultipartFormData
return addPart(name, httpContentType, inputStream);
}
- private InputStream getPartInputStream(final Part part, final String
footer) {
- final String partHeaders = getPartHeaders(part);
- final InputStream headersInputStream = new
ByteArrayInputStream(partHeaders.getBytes(HEADERS_CHARACTER_SET));
- final InputStream footerInputStream = new
ByteArrayInputStream(footer.getBytes(HEADERS_CHARACTER_SET));
- final Enumeration<InputStream> inputStreams =
Collections.enumeration(List.of(headersInputStream, part.inputStream,
footerInputStream));
- return new SequenceInputStream(inputStreams);
+ @Override
+ public MultipartFormDataStreamBuilder addPart(final String name, final
String fileName, final HttpContentType httpContentType, final byte[] bytes) {
+ Objects.requireNonNull(bytes, "Byte Array required");
+ final InputStream inputStream = new ByteArrayInputStream(bytes);
+ return addPart(name, fileName, httpContentType, inputStream);
}
private String getPartHeaders(final Part part) {
final StringBuilder headersBuilder = new StringBuilder();
- final String contentDispositionHeader =
CONTENT_DISPOSITION_HEADER.formatted(part.name);
+ final String contentDispositionHeader =
getContentDispositionHeader(part);
headersBuilder.append(contentDispositionHeader);
headersBuilder.append(CARRIAGE_RETURN_LINE_FEED);
@@ -156,19 +161,26 @@ public class StandardMultipartFormDataStreamBuilder
implements MultipartFormData
return headersBuilder.toString();
}
- private String getFooter(final Iterator<Part> selectedParts) {
- final StringBuilder footerBuilder = new StringBuilder();
- footerBuilder.append(CARRIAGE_RETURN_LINE_FEED);
- footerBuilder.append(BOUNDARY_SEPARATOR);
- footerBuilder.append(boundary);
- if (selectedParts.hasNext()) {
- footerBuilder.append(CARRIAGE_RETURN_LINE_FEED);
- } else {
- // Add boundary separator after last part indicating end
- footerBuilder.append(BOUNDARY_SEPARATOR);
+ private String getBoundaryPrefix(final int index) {
+ final StringBuilder prefixBuilder = new StringBuilder();
+ if (index > 0) {
+ prefixBuilder.append(CARRIAGE_RETURN_LINE_FEED);
}
+ prefixBuilder.append(BOUNDARY_SEPARATOR);
+ prefixBuilder.append(boundary);
+ prefixBuilder.append(CARRIAGE_RETURN_LINE_FEED);
+ return prefixBuilder.toString();
+ }
- return footerBuilder.toString();
+ private String getFooter() {
+ return CARRIAGE_RETURN_LINE_FEED + BOUNDARY_SEPARATOR + boundary +
BOUNDARY_SEPARATOR;
+ }
+
+ private String getContentDispositionHeader(final Part part) {
+ if (part.fileName == null) {
+ return CONTENT_DISPOSITION_HEADER.formatted(part.name);
+ }
+ return CONTENT_DISPOSITION_FILE_HEADER.formatted(part.name,
part.fileName);
}
private record MultipartHttpContentType(String contentType) implements
HttpContentType {
@@ -180,8 +192,23 @@ public class StandardMultipartFormDataStreamBuilder
implements MultipartFormData
private record Part(
String name,
+ String fileName,
HttpContentType httpContentType,
InputStream inputStream
) {
}
+
+ private String sanitizeFileName(final String fileName) {
+ if (fileName == null || fileName.isBlank()) {
+ throw new IllegalArgumentException("File Name required");
+ }
+
+ final String sanitized = fileName;
+ final Matcher fileNameMatcher =
ALLOWED_NAME_PATTERN.matcher(sanitized);
+ if (!fileNameMatcher.matches()) {
+ throw new IllegalArgumentException("File Name contains characters
outside of ASCII character set");
+ }
+
+ return sanitized;
+ }
}
diff --git
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java
index fb363072ac..58a087a863 100644
---
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java
+++
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java
@@ -23,6 +23,7 @@ public enum BitbucketAuthenticationType implements
DescribedValue {
BASIC_AUTH("Basic Auth", """
Username (not email) and App Password
(https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/).
+ Or email and API Token
(https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/).
Required permissions: repository, repository:read.
"""),
diff --git
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFlowRegistryClient.java
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFlowRegistryClient.java
index 3d08f16e89..fa56233c1c 100644
---
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFlowRegistryClient.java
+++
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFlowRegistryClient.java
@@ -20,6 +20,8 @@ package org.apache.nifi.atlassian.bitbucket;
import org.apache.nifi.annotation.documentation.CapabilityDescription;
import org.apache.nifi.annotation.documentation.Tags;
import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
import org.apache.nifi.oauth2.OAuth2AccessTokenProvider;
import org.apache.nifi.processor.util.StandardValidators;
import org.apache.nifi.registry.flow.FlowRegistryClientConfigurationContext;
@@ -28,25 +30,27 @@ import
org.apache.nifi.registry.flow.git.AbstractGitFlowRegistryClient;
import org.apache.nifi.registry.flow.git.client.GitRepositoryClient;
import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
@Tags({ "atlassian", "bitbucket", "registry", "flow" })
@CapabilityDescription("Flow Registry Client that uses the Bitbucket REST API
to version control flows in a Bitbucket Repository.")
public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient
{
- static final PropertyDescriptor BITBUCKET_API_URL = new
PropertyDescriptor.Builder()
- .name("Bitbucket API Instance")
- .description("The instance of the Bitbucket API")
- .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
- .defaultValue("api.bitbucket.org")
+ static final PropertyDescriptor FORM_FACTOR = new
PropertyDescriptor.Builder()
+ .name("Form Factor")
+ .description("The Bitbucket deployment form factor")
+ .allowableValues(BitbucketFormFactor.class)
+ .defaultValue(BitbucketFormFactor.CLOUD.getValue())
.required(true)
.build();
- static final PropertyDescriptor BITBUCKET_API_VERSION = new
PropertyDescriptor.Builder()
- .name("Bitbucket API Version")
- .description("The version of the Bitbucket API")
- .defaultValue("2.0")
+ static final PropertyDescriptor BITBUCKET_API_URL = new
PropertyDescriptor.Builder()
+ .name("Bitbucket API Instance")
+ .description("The Bitbucket API host or base URL (for example,
api.bitbucket.org for Cloud or https://bitbucket.example.com for Data Center)")
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+ .defaultValue("api.bitbucket.org")
.required(true)
.build();
@@ -55,6 +59,7 @@ public class BitbucketFlowRegistryClient extends
AbstractGitFlowRegistryClient {
.description("The name of the workspace that contains the
repository to connect to")
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
.required(true)
+ .dependsOn(FORM_FACTOR, BitbucketFormFactor.CLOUD)
.build();
static final PropertyDescriptor REPOSITORY_NAME = new
PropertyDescriptor.Builder()
@@ -64,9 +69,17 @@ public class BitbucketFlowRegistryClient extends
AbstractGitFlowRegistryClient {
.required(true)
.build();
+ static final PropertyDescriptor PROJECT_KEY = new
PropertyDescriptor.Builder()
+ .name("Project Key")
+ .description("The key of the Bitbucket project that contains the
repository (required for Data Center)")
+ .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+ .required(true)
+ .dependsOn(FORM_FACTOR, BitbucketFormFactor.DATA_CENTER)
+ .build();
+
static final PropertyDescriptor AUTHENTICATION_TYPE = new
PropertyDescriptor.Builder()
.name("Authentication Type")
- .description("The type of authentication to use for accessing
Bitbucket")
+ .description("The type of authentication to use for accessing
Bitbucket (Data Center supports only Access Token authentication)")
.allowableValues(BitbucketAuthenticationType.class)
.defaultValue(BitbucketAuthenticationType.ACCESS_TOKEN)
.required(true)
@@ -92,9 +105,18 @@ public class BitbucketFlowRegistryClient extends
AbstractGitFlowRegistryClient {
static final PropertyDescriptor APP_PASSWORD = new
PropertyDescriptor.Builder()
.name("App Password")
- .description("The App Password to use for authentication")
+ .description("The App Password to use for authentication when
providing a Bitbucket username")
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
- .required(true)
+ .required(false)
+ .sensitive(true)
+ .dependsOn(AUTHENTICATION_TYPE,
BitbucketAuthenticationType.BASIC_AUTH)
+ .build();
+
+ static final PropertyDescriptor API_TOKEN = new
PropertyDescriptor.Builder()
+ .name("API Token")
+ .description("The API Token to use for authentication when
providing a Bitbucket email address")
+ .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+ .required(false)
.sensitive(true)
.dependsOn(AUTHENTICATION_TYPE,
BitbucketAuthenticationType.BASIC_AUTH)
.build();
@@ -116,14 +138,16 @@ public class BitbucketFlowRegistryClient extends
AbstractGitFlowRegistryClient {
static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
WEBCLIENT_SERVICE,
+ FORM_FACTOR,
BITBUCKET_API_URL,
- BITBUCKET_API_VERSION,
WORKSPACE_NAME,
+ PROJECT_KEY,
REPOSITORY_NAME,
AUTHENTICATION_TYPE,
ACCESS_TOKEN,
USERNAME,
APP_PASSWORD,
+ API_TOKEN,
OAUTH_TOKEN_PROVIDER);
static final String STORAGE_LOCATION_PREFIX = "[email protected]:";
@@ -136,23 +160,63 @@ public class BitbucketFlowRegistryClient extends
AbstractGitFlowRegistryClient {
@Override
protected GitRepositoryClient createRepositoryClient(final
FlowRegistryClientConfigurationContext context) throws FlowRegistryException {
+ final BitbucketFormFactor formFactor =
context.getProperty(FORM_FACTOR).asAllowableValue(BitbucketFormFactor.class);
+
+ final String appPassword = context.getProperty(APP_PASSWORD).isSet()
+ ?
context.getProperty(APP_PASSWORD).evaluateAttributeExpressions().getValue()
+ : null;
+ final String apiToken = context.getProperty(API_TOKEN).isSet()
+ ?
context.getProperty(API_TOKEN).evaluateAttributeExpressions().getValue()
+ : null;
+ final String basicAuthSecret = apiToken != null && !apiToken.isBlank()
? apiToken : appPassword;
+
return BitbucketRepositoryClient.builder()
.clientId(getIdentifier())
.logger(getLogger())
+ .formFactor(formFactor)
.apiUrl(context.getProperty(BITBUCKET_API_URL).getValue())
-
.apiVersion(context.getProperty(BITBUCKET_API_VERSION).getValue())
.workspace(context.getProperty(WORKSPACE_NAME).getValue())
.repoName(context.getProperty(REPOSITORY_NAME).getValue())
.repoPath(context.getProperty(REPOSITORY_PATH).getValue())
+ .projectKey(context.getProperty(PROJECT_KEY).getValue())
.authenticationType(context.getProperty(AUTHENTICATION_TYPE).asAllowableValue(BitbucketAuthenticationType.class))
.accessToken(context.getProperty(ACCESS_TOKEN).evaluateAttributeExpressions().getValue())
.username(context.getProperty(USERNAME).evaluateAttributeExpressions().getValue())
-
.appPassword(context.getProperty(APP_PASSWORD).evaluateAttributeExpressions().getValue())
+ .appPassword(basicAuthSecret)
.oauthService(context.getProperty(OAUTH_TOKEN_PROVIDER).asControllerService(OAuth2AccessTokenProvider.class))
.webClient(context.getProperty(WEBCLIENT_SERVICE).asControllerService(WebClientServiceProvider.class))
.build();
}
+ @Override
+ protected Collection<ValidationResult> customValidate(final
ValidationContext validationContext) {
+ final List<ValidationResult> validationResults = new
ArrayList<>(super.customValidate(validationContext));
+
+ final BitbucketAuthenticationType authenticationType =
validationContext.getProperty(AUTHENTICATION_TYPE)
+ .asAllowableValue(BitbucketAuthenticationType.class);
+
+ if (BitbucketAuthenticationType.BASIC_AUTH.equals(authenticationType))
{
+ final boolean appPasswordSet =
validationContext.getProperty(APP_PASSWORD).isSet();
+ final boolean apiTokenSet =
validationContext.getProperty(API_TOKEN).isSet();
+
+ if (!appPasswordSet && !apiTokenSet) {
+ validationResults.add(new ValidationResult.Builder()
+ .subject("App Password or API Token")
+ .valid(false)
+ .explanation("Configure either an App Password or an
API Token when using Basic Auth authentication")
+ .build());
+ } else if (appPasswordSet && apiTokenSet) {
+ validationResults.add(new ValidationResult.Builder()
+ .subject("App Password or API Token")
+ .valid(false)
+ .explanation("Only one of App Password or API Token
can be configured when using Basic Auth authentication")
+ .build());
+ }
+ }
+
+ return validationResults;
+ }
+
@Override
public boolean
isStorageLocationApplicable(FlowRegistryClientConfigurationContext context,
String location) {
return location != null &&
location.startsWith(STORAGE_LOCATION_PREFIX);
@@ -160,7 +224,17 @@ public class BitbucketFlowRegistryClient extends
AbstractGitFlowRegistryClient {
@Override
protected String getStorageLocation(GitRepositoryClient repositoryClient) {
- final BitbucketRepositoryClient gitLabRepositoryClient =
(BitbucketRepositoryClient) repositoryClient;
- return
STORAGE_LOCATION_FORMAT.formatted(gitLabRepositoryClient.getWorkspace(),
gitLabRepositoryClient.getRepoName());
+ final BitbucketRepositoryClient bitbucketRepositoryClient =
(BitbucketRepositoryClient) repositoryClient;
+
+ if (bitbucketRepositoryClient.getFormFactor() ==
BitbucketFormFactor.DATA_CENTER) {
+ final String apiHost = bitbucketRepositoryClient.getApiHost();
+ final String projectKey =
bitbucketRepositoryClient.getProjectKey();
+ if (apiHost != null && projectKey != null) {
+ return "git@%s:%s/%s.git".formatted(apiHost, projectKey,
bitbucketRepositoryClient.getRepoName());
+ }
+ return bitbucketRepositoryClient.getRepoName();
+ }
+
+ return
STORAGE_LOCATION_FORMAT.formatted(bitbucketRepositoryClient.getWorkspace(),
bitbucketRepositoryClient.getRepoName());
}
}
diff --git
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFormFactor.java
similarity index 51%
copy from
nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java
copy to
nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFormFactor.java
index fb363072ac..a7450c556f 100644
---
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java
+++
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFormFactor.java
@@ -19,31 +19,15 @@ package org.apache.nifi.atlassian.bitbucket;
import org.apache.nifi.components.DescribedValue;
-public enum BitbucketAuthenticationType implements DescribedValue {
+public enum BitbucketFormFactor implements DescribedValue {
- BASIC_AUTH("Basic Auth", """
- Username (not email) and App Password
(https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/).
- Required permissions: repository, repository:read.
- """),
-
- ACCESS_TOKEN("Access Token", """
- Repository, Project or Workspace Token
(https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/).
- Required permissions: repository, repository:read.
- """),
-
- OAUTH2("OAuth 2.0", """
- Only works with client credentials flow which requires OAuth
consumers
-
(https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/).
- OAuth consumer needs to be private, and callback url needs to be
set, but can have any value.
- Other OAuth token service fields can be left empty or with default
values. It is important to also note that
- the permissions/scopes set at the OAuth Consumer level are ignored
and it will inherit the permissions of the
- owner of the OAuth Consumer.
- """);
+ CLOUD("Cloud", "Use the Bitbucket Cloud REST API (uses API version 2.0)."),
+ DATA_CENTER("Data Center", "Use the Bitbucket Data Center REST API (uses
API version 1.0 and requires Project Key).");
private final String displayName;
private final String description;
- BitbucketAuthenticationType(final String displayName, final String
description) {
+ BitbucketFormFactor(final String displayName, final String description) {
this.displayName = displayName;
this.description = description;
}
diff --git
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
index 61b36f04b9..e699975e31 100644
---
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
+++
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
@@ -26,12 +26,16 @@ import org.apache.nifi.registry.flow.FlowRegistryException;
import org.apache.nifi.registry.flow.git.client.GitCommit;
import org.apache.nifi.registry.flow.git.client.GitCreateContentRequest;
import org.apache.nifi.registry.flow.git.client.GitRepositoryClient;
+import org.apache.nifi.stream.io.StreamUtils;
import org.apache.nifi.web.client.api.HttpResponseEntity;
import org.apache.nifi.web.client.api.HttpUriBuilder;
import org.apache.nifi.web.client.api.StandardHttpContentType;
import org.apache.nifi.web.client.api.StandardMultipartFormDataStreamBuilder;
import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
@@ -42,6 +46,7 @@ import java.nio.file.Paths;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
+import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
@@ -57,15 +62,55 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
private final ComponentLog logger;
+ public static final String CLOUD_API_VERSION = "2.0";
+ public static final String DATA_CENTER_API_VERSION = "1.0";
+
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String CONTENT_TYPE_HEADER = "Content-Type";
private static final String BASIC = "Basic";
private static final String BEARER = "Bearer";
+ private static final String FIELD_BRANCH = "branch";
+ private static final String FIELD_MESSAGE = "message";
+ private static final String FIELD_SOURCE_COMMIT_ID = "sourceCommitId";
+ private static final String FIELD_CONTENT = "content";
+ private static final String FIELD_FILES = "files";
+ private static final String FIELD_CHILDREN = "children";
+ private static final String FIELD_VALUES = "values";
+ private static final String FIELD_NEXT = "next";
+ private static final String FIELD_NEXT_PAGE_START = "nextPageStart";
+ private static final String FIELD_IS_LAST_PAGE = "isLastPage";
+ private static final String FIELD_PERMISSION = "permission";
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_PATH = "path";
+ private static final String FIELD_TO_STRING = "toString";
+ private static final String FIELD_DISPLAY_NAME = "displayName";
+ private static final String FIELD_NAME = "name";
+ private static final String FIELD_EMAIL_ADDRESS = "emailAddress";
+ private static final String FIELD_DISPLAY_ID = "displayId";
+ private static final String FIELD_ID = "id";
+ private static final String FIELD_HASH = "hash";
+ private static final String FIELD_MESSAGE_TEXT = "message";
+ private static final String FIELD_AUTHOR = "author";
+ private static final String FIELD_AUTHOR_TIMESTAMP = "authorTimestamp";
+ private static final String FIELD_ERROR_MESSAGE = "error";
+ private static final String FIELD_ERRORS = "errors";
+ private static final String FIELD_RAW = "raw";
+ private static final String EMPTY_STRING = "";
+ private static final String ENTRY_DIRECTORY_DATA_CENTER = "DIRECTORY";
+ private static final String ENTRY_DIRECTORY_CLOUD = "commit_directory";
+ private static final String ENTRY_FILE_DATA_CENTER = "FILE";
+ private static final String ENTRY_FILE_CLOUD = "commit_file";
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private final String apiUrl;
private final String apiVersion;
+ private final BitbucketFormFactor formFactor;
+ private final String projectKey;
+ private final String apiScheme;
+ private final String apiHost;
+ private final int apiPort;
+ private final List<String> apiBasePathSegments;
private final String clientId;
private final String workspace;
private final String repoName;
@@ -75,21 +120,47 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
private final boolean canRead;
private final boolean canWrite;
+ private boolean hasCommits = false;
+
+ public static String getDefaultApiVersion(final BitbucketFormFactor
formFactor) {
+ return formFactor == BitbucketFormFactor.DATA_CENTER ?
DATA_CENTER_API_VERSION : CLOUD_API_VERSION;
+ }
private BitbucketRepositoryClient(final Builder builder) throws
FlowRegistryException {
webClient = Objects.requireNonNull(builder.webClient, "Web Client is
required");
- workspace = Objects.requireNonNull(builder.workspace, "Workspace is
required");
- repoName = Objects.requireNonNull(builder.repoName, "Repository Name
is required");
logger = Objects.requireNonNull(builder.logger, "ComponentLog
required");
+ formFactor = builder.formFactor == null ? BitbucketFormFactor.CLOUD :
builder.formFactor;
+
apiUrl = Objects.requireNonNull(builder.apiUrl, "API Instance is
required");
- apiVersion = Objects.requireNonNull(builder.apiVersion, "API Version
is required");
+
+ final ParsedApiUrl parsedApiUrl = parseApiUrl(apiUrl);
+ apiScheme = parsedApiUrl.scheme();
+ apiHost = parsedApiUrl.host();
+ apiPort = parsedApiUrl.port();
+ apiBasePathSegments = parsedApiUrl.pathSegments();
+
+ if (formFactor == BitbucketFormFactor.CLOUD) {
+ workspace = Objects.requireNonNull(builder.workspace, "Workspace
is required for Bitbucket Cloud");
+ projectKey = null;
+ } else {
+ projectKey = Objects.requireNonNull(builder.projectKey, "Project
Key is required for Bitbucket Data Center");
+ workspace = builder.workspace;
+ }
+
+ repoName = Objects.requireNonNull(builder.repoName, "Repository Name
is required");
clientId = Objects.requireNonNull(builder.clientId, "Client ID is
required");
repoPath = builder.repoPath;
+ apiVersion = getDefaultApiVersion(formFactor);
+
final BitbucketAuthenticationType authenticationType =
Objects.requireNonNull(builder.authenticationType, "Authentication type is
required");
+ if (formFactor == BitbucketFormFactor.DATA_CENTER &&
authenticationType != BitbucketAuthenticationType.ACCESS_TOKEN) {
+ throw new FlowRegistryException("Bitbucket Data Center only
supports Access Token authentication");
+ }
+
switch (authenticationType) {
case ACCESS_TOKEN -> {
Objects.requireNonNull(builder.accessToken, "Access Token is
required");
@@ -147,6 +218,10 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
return workspace;
}
+ public String getProjectKey() {
+ return projectKey;
+ }
+
/**
* @return the name of the repository
*/
@@ -154,33 +229,74 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
return repoName;
}
+ public BitbucketFormFactor getFormFactor() {
+ return formFactor;
+ }
+
+ public String getApiHost() {
+ return apiHost;
+ }
+
@Override
public Set<String> getBranches() throws FlowRegistryException {
logger.debug("Getting branches for repository [{}]", repoName);
+ return formFactor == BitbucketFormFactor.DATA_CENTER
+ ? getBranchesDataCenter()
+ : getBranchesCloud();
+ }
- //
https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/refs/branches
- URI uri =
getUriBuilder().addPathSegment("refs").addPathSegment("branches").build();
- HttpResponseEntity response =
this.webClient.getWebClientService().get().uri(uri).header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue()).retrieve();
+ private Set<String> getBranchesCloud() throws FlowRegistryException {
+ final URI uri =
getRepositoryUriBuilder().addPathSegment("refs").addPathSegment("branches").build();
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .retrieve()) {
- if (response.statusCode() != HttpURLConnection.HTTP_OK) {
- throw new FlowRegistryException(String.format("Error while listing
branches for repository [%s]: %s", repoName, getErrorMessage(response)));
- }
+ verifyStatusCode(response, "Error while listing branches for
repository [%s]".formatted(repoName), HttpURLConnection.HTTP_OK);
- JsonNode jsonResponse;
- try {
- jsonResponse = this.objectMapper.readTree(response.body());
- } catch (IOException e) {
- throw new FlowRegistryException("Could not parse response from
Bitbucket API", e);
+ final JsonNode jsonResponse = parseResponseBody(response, uri);
+ final JsonNode values = jsonResponse.get(FIELD_VALUES);
+ final Set<String> result = new HashSet<>();
+ if (values != null && values.isArray()) {
+ for (JsonNode branch : values) {
+ final String branchName =
branch.path(FIELD_NAME).asText(EMPTY_STRING);
+ if (!branchName.isEmpty()) {
+ result.add(branchName);
+ }
+ }
+ }
+ return result;
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket branch
listing response", e);
}
- Iterator<JsonNode> branches = jsonResponse.get("values").elements();
+ }
- Set<String> result = new HashSet<>();
- while (branches.hasNext()) {
- JsonNode branch = branches.next();
- result.add(branch.get("name").asText());
- }
+ private Set<String> getBranchesDataCenter() throws FlowRegistryException {
+ final URI uri =
getRepositoryUriBuilder().addPathSegment("branches").build();
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .retrieve()) {
- return result;
+ verifyStatusCode(response, "Error while listing branches for
repository [%s]".formatted(repoName), HttpURLConnection.HTTP_OK);
+
+ final JsonNode jsonResponse = parseResponseBody(response, uri);
+ final JsonNode values = jsonResponse.get(FIELD_VALUES);
+ final Set<String> result = new HashSet<>();
+ if (values != null && values.isArray()) {
+ for (JsonNode branch : values) {
+ final String displayId =
branch.path(FIELD_DISPLAY_ID).asText(EMPTY_STRING);
+ if (!displayId.isEmpty()) {
+ result.add(displayId);
+ }
+ }
+ }
+ return result;
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket branch
listing response", e);
+ }
}
@Override
@@ -193,9 +309,12 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
final Set<String> result = new HashSet<>();
while (files.hasNext()) {
JsonNode file = files.next();
- if (file.get("type").asText().equals("commit_directory")) {
- final Path fullPath = Paths.get(file.get("path").asText());
- result.add(fullPath.getFileName().toString());
+ if (isDirectoryEntry(file)) {
+ final String entryPath = getEntryPath(file);
+ if (!entryPath.isEmpty()) {
+ final Path fullPath = Paths.get(entryPath);
+ result.add(fullPath.getFileName().toString());
+ }
}
}
@@ -212,9 +331,12 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
final Set<String> result = new HashSet<>();
while (files.hasNext()) {
JsonNode file = files.next();
- if (file.get("type").asText().equals("commit_file")) {
- final Path fullPath = Paths.get(file.get("path").asText());
- result.add(fullPath.getFileName().toString());
+ if (isFileEntry(file)) {
+ final String entryPath = getEntryPath(file);
+ if (!entryPath.isEmpty()) {
+ final Path fullPath = Paths.get(entryPath);
+ result.add(fullPath.getFileName().toString());
+ }
}
}
@@ -253,17 +375,37 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
public InputStream getContentFromCommit(final String path, final String
commitSha) throws FlowRegistryException {
final String resolvedPath = getResolvedPath(path);
logger.debug("Getting content for path [{}] from commit [{}] in
repository [{}]", resolvedPath, commitSha, repoName);
+ return formFactor == BitbucketFormFactor.DATA_CENTER
+ ? getContentFromCommitDataCenter(resolvedPath, commitSha)
+ : getContentFromCommitCloud(resolvedPath, commitSha);
+ }
- //
https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/src/{commit}/{path}
- final URI uri =
getUriBuilder().addPathSegment("src").addPathSegment(commitSha).addPathSegment(resolvedPath).build();
- final HttpResponseEntity response =
this.webClient.getWebClientService().get().uri(uri).header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue()).retrieve();
+ private InputStream getContentFromCommitCloud(final String resolvedPath,
final String commitSha) throws FlowRegistryException {
+ final HttpUriBuilder builder = getRepositoryUriBuilder()
+ .addPathSegment("src")
+ .addPathSegment(commitSha);
+ addPathSegments(builder, resolvedPath);
+ final URI uri = builder.build();
+ final String errorMessage = "Error while retrieving content for
repository [%s] at path %s".formatted(repoName, resolvedPath);
+ final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .retrieve();
- if (response.statusCode() != HttpURLConnection.HTTP_OK) {
- throw new FlowRegistryException(
- String.format("Error while retrieving content for
repository [%s] at path %s: %s", repoName, resolvedPath,
getErrorMessage(response)));
- }
+ return getResponseBody(response, errorMessage,
HttpURLConnection.HTTP_OK);
+ }
- return response.body();
+ private InputStream getContentFromCommitDataCenter(final String
resolvedPath, final String commitSha) throws FlowRegistryException {
+ final URI uri = buildRawUri(resolvedPath, commitSha);
+ final String errorMessage = "Error while retrieving content for
repository [%s] at path %s".formatted(repoName, resolvedPath);
+ final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .retrieve();
+
+ return getResponseBody(response, errorMessage,
HttpURLConnection.HTTP_OK);
}
@Override
@@ -278,34 +420,71 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
final String resolvedPath = getResolvedPath(request.getPath());
final String branch = request.getBranch();
logger.debug("Creating content at path [{}] on branch [{}] in
repository [{}] ", resolvedPath, branch, repoName);
+ return formFactor == BitbucketFormFactor.DATA_CENTER
+ ? createContentDataCenter(request, resolvedPath, branch)
+ : createContentCloud(request, resolvedPath, branch);
+ }
+ private String createContentCloud(final GitCreateContentRequest request,
final String resolvedPath, final String branch) throws FlowRegistryException {
final StandardMultipartFormDataStreamBuilder multipartBuilder = new
StandardMultipartFormDataStreamBuilder();
multipartBuilder.addPart(resolvedPath,
StandardHttpContentType.APPLICATION_JSON,
request.getContent().getBytes(StandardCharsets.UTF_8));
- multipartBuilder.addPart("message",
StandardHttpContentType.TEXT_PLAIN,
request.getMessage().getBytes(StandardCharsets.UTF_8));
- multipartBuilder.addPart("branch", StandardHttpContentType.TEXT_PLAIN,
branch.getBytes(StandardCharsets.UTF_8));
+ multipartBuilder.addPart(FIELD_MESSAGE,
StandardHttpContentType.TEXT_PLAIN,
request.getMessage().getBytes(StandardCharsets.UTF_8));
+ multipartBuilder.addPart(FIELD_BRANCH,
StandardHttpContentType.TEXT_PLAIN, branch.getBytes(StandardCharsets.UTF_8));
- //
https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/src
- final URI uri = getUriBuilder().addPathSegment("src").build();
- final HttpResponseEntity response =
this.webClient.getWebClientService()
+ final URI uri =
getRepositoryUriBuilder().addPathSegment("src").build();
+ final String errorMessage = "Error while committing content for
repository [%s] on branch %s at path %s"
+ .formatted(repoName, branch, resolvedPath);
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
.post()
.uri(uri)
.body(multipartBuilder.build(), OptionalLong.empty())
.header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
.header(CONTENT_TYPE_HEADER,
multipartBuilder.getHttpContentType().getContentType())
- .retrieve();
+ .retrieve()) {
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_CREATED);
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket create
content response", e);
+ }
+
+ return getRequiredLatestCommit(branch, resolvedPath);
+ }
- if (response.statusCode() != HttpURLConnection.HTTP_CREATED) {
- throw new FlowRegistryException(
- String.format("Error while committing content for
repository [%s] on branch %s at path %s: %s", repoName, branch, resolvedPath,
getErrorMessage(response)));
+ private String createContentDataCenter(final GitCreateContentRequest
request, final String resolvedPath, final String branch) throws
FlowRegistryException {
+ final StandardMultipartFormDataStreamBuilder multipartBuilder = new
StandardMultipartFormDataStreamBuilder();
+ final String fileName = getFileName(resolvedPath);
+ multipartBuilder.addPart(FIELD_CONTENT, fileName,
StandardHttpContentType.APPLICATION_OCTET_STREAM,
request.getContent().getBytes(StandardCharsets.UTF_8));
+ multipartBuilder.addPart(FIELD_BRANCH,
StandardHttpContentType.TEXT_PLAIN, branch.getBytes(StandardCharsets.UTF_8));
+
+ final String message = request.getMessage();
+ if (message != null && !message.isEmpty()) {
+ multipartBuilder.addPart(FIELD_MESSAGE,
StandardHttpContentType.TEXT_PLAIN, message.getBytes(StandardCharsets.UTF_8));
}
- final Optional<String> lastCommit = getLatestCommit(branch,
resolvedPath);
+ final String existingContentSha = request.getExistingContentSha();
+ final boolean existingContentProvided = existingContentSha != null &&
!existingContentSha.isBlank();
+ if (existingContentProvided) {
+ multipartBuilder.addPart(FIELD_SOURCE_COMMIT_ID,
StandardHttpContentType.TEXT_PLAIN,
existingContentSha.getBytes(StandardCharsets.UTF_8));
+ }
- if (lastCommit.isEmpty()) {
- throw new FlowRegistryException(String.format("Could not find
commit for the file %s we just tried to commit on branch %s", resolvedPath,
branch));
+ final HttpUriBuilder uriBuilder =
getRepositoryUriBuilder().addPathSegment("browse");
+ addPathSegments(uriBuilder, resolvedPath);
+ final URI uri = uriBuilder.build();
+ final byte[] requestBody = toByteArray(multipartBuilder.build());
+ final String errorMessage = "Error while committing content for
repository [%s] on branch %s at path %s"
+ .formatted(repoName, branch, resolvedPath);
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .put()
+ .uri(uri)
+ .body(new ByteArrayInputStream(requestBody),
OptionalLong.of(requestBody.length))
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .header(CONTENT_TYPE_HEADER,
multipartBuilder.getHttpContentType().getContentType())
+ .retrieve()) {
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_OK);
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket create
content response", e);
}
- return lastCommit.get();
+ return getRequiredLatestCommit(branch, resolvedPath);
}
@Override
@@ -315,85 +494,284 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
final InputStream fileToBeDeleted = getContentFromBranch(filePath,
branch);
- final StandardMultipartFormDataStreamBuilder multipartBuilder = new
StandardMultipartFormDataStreamBuilder();
- multipartBuilder.addPart("files", StandardHttpContentType.TEXT_PLAIN,
resolvedPath.getBytes(StandardCharsets.UTF_8));
- multipartBuilder.addPart("message",
StandardHttpContentType.TEXT_PLAIN,
commitMessage.getBytes(StandardCharsets.UTF_8));
- multipartBuilder.addPart("branch", StandardHttpContentType.TEXT_PLAIN,
branch.getBytes(StandardCharsets.UTF_8));
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ deleteContentDataCenter(resolvedPath, commitMessage, branch);
+ } else {
+ deleteContentCloud(resolvedPath, commitMessage, branch);
+ }
- //
https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/src
- final URI uri = getUriBuilder().addPathSegment("src").build();
- final HttpResponseEntity response =
this.webClient.getWebClientService()
+ return fileToBeDeleted;
+ }
+
+ private void deleteContentCloud(final String resolvedPath, final String
commitMessage, final String branch) throws FlowRegistryException {
+ final StandardMultipartFormDataStreamBuilder multipartBuilder = new
StandardMultipartFormDataStreamBuilder();
+ multipartBuilder.addPart(FIELD_FILES,
StandardHttpContentType.TEXT_PLAIN,
resolvedPath.getBytes(StandardCharsets.UTF_8));
+ multipartBuilder.addPart(FIELD_MESSAGE,
StandardHttpContentType.TEXT_PLAIN,
commitMessage.getBytes(StandardCharsets.UTF_8));
+ multipartBuilder.addPart(FIELD_BRANCH,
StandardHttpContentType.TEXT_PLAIN, branch.getBytes(StandardCharsets.UTF_8));
+
+ final URI uri =
getRepositoryUriBuilder().addPathSegment("src").build();
+ final String errorMessage = "Error while deleting content for
repository [%s] on branch %s at path %s"
+ .formatted(repoName, branch, resolvedPath);
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
.post()
.uri(uri)
.body(multipartBuilder.build(), OptionalLong.empty())
.header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
.header(CONTENT_TYPE_HEADER,
multipartBuilder.getHttpContentType().getContentType())
- .retrieve();
+ .retrieve()) {
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_CREATED);
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket delete
content response", e);
+ }
+ }
- if (response.statusCode() != HttpURLConnection.HTTP_CREATED) {
- throw new FlowRegistryException(
- String.format("Error while deleting content for repository
[%s] on branch %s at path %s: %s", repoName, branch, resolvedPath,
getErrorMessage(response)));
+ private void deleteContentDataCenter(final String resolvedPath, final
String commitMessage, final String branch) throws FlowRegistryException {
+ final Optional<String> latestCommit = getLatestCommit(branch,
resolvedPath);
+ final HttpUriBuilder uriBuilder =
getRepositoryUriBuilder().addPathSegment("browse");
+ addPathSegments(uriBuilder, resolvedPath);
+ uriBuilder.addQueryParameter(FIELD_BRANCH, branch);
+ if (commitMessage != null) {
+ uriBuilder.addQueryParameter(FIELD_MESSAGE, commitMessage);
}
+ latestCommit.ifPresent(commit ->
uriBuilder.addQueryParameter(FIELD_SOURCE_COMMIT_ID, commit));
- return fileToBeDeleted;
+ final URI uri = uriBuilder.build();
+ final String errorMessage = "Error while deleting content for
repository [%s] on branch %s at path %s"
+ .formatted(repoName, branch, resolvedPath);
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .delete()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .retrieve()) {
+ verifyStatusCode(response, errorMessage,
+ HttpURLConnection.HTTP_OK,
+ HttpURLConnection.HTTP_ACCEPTED,
+ HttpURLConnection.HTTP_NO_CONTENT,
+ HttpURLConnection.HTTP_CREATED);
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket delete
content response", e);
+ }
}
private Iterator<JsonNode> getFiles(final String branch, final String
resolvedPath) throws FlowRegistryException {
final Optional<String> lastCommit = getLatestCommit(branch,
resolvedPath);
if (lastCommit.isEmpty()) {
- throw new FlowRegistryException(String.format("Could not find
committed files at %s on branch %s response from Bitbucket API", resolvedPath,
branch));
+ return Collections.emptyIterator();
}
- // retrieve source data
- //
https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/src/{commit}/{path}
- final URI uri =
getUriBuilder().addPathSegment("src").addPathSegment(lastCommit.get()).addPathSegment(resolvedPath).build();
+ return formFactor == BitbucketFormFactor.DATA_CENTER
+ ? getFilesDataCenter(branch, resolvedPath, lastCommit.get())
+ : getFilesCloud(branch, resolvedPath, lastCommit.get());
+ }
+
+ private Iterator<JsonNode> getFilesCloud(final String branch, final String
resolvedPath, final String commit) throws FlowRegistryException {
+ final URI uri = getRepositoryUriBuilder()
+ .addPathSegment("src")
+ .addPathSegment(commit)
+ .addPathSegment(resolvedPath)
+ .build();
final String errorMessage = String.format("Error while listing content
for repository [%s] on branch %s at path %s", repoName, branch, resolvedPath);
return getPagedResponseValues(uri, errorMessage);
}
+ private Iterator<JsonNode> getFilesDataCenter(final String branch, final
String resolvedPath, final String commit) throws FlowRegistryException {
+ final List<JsonNode> allValues = new ArrayList<>();
+ Integer nextPageStart = null;
+
+ do {
+ final URI uri = buildBrowseUri(resolvedPath, commit,
nextPageStart, false);
+ final String errorMessage = "Error while listing content for
repository [%s] on branch %s at path %s"
+ .formatted(repoName, branch, resolvedPath);
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue())
+ .retrieve()) {
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_OK);
+
+ final JsonNode root = parseResponseBody(response, uri);
+
+ final JsonNode children = root.path(FIELD_CHILDREN);
+ final JsonNode values = children.path(FIELD_VALUES);
+ if (values.isArray()) {
+ values.forEach(allValues::add);
+ }
+
+ if (children.path(FIELD_IS_LAST_PAGE).asBoolean(true)) {
+ nextPageStart = null;
+ } else {
+ final JsonNode nextPageStartNode =
children.get(FIELD_NEXT_PAGE_START);
+ nextPageStart = nextPageStartNode != null &&
nextPageStartNode.isInt() ? nextPageStartNode.intValue() : null;
+ }
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket
browse response", e);
+ }
+ } while (nextPageStart != null);
+
+ return allValues.iterator();
+ }
+
+ private URI buildBrowseUri(final String resolvedPath, final String commit,
final Integer start, final boolean rawContent) {
+ final HttpUriBuilder builder =
getRepositoryUriBuilder().addPathSegment("browse");
+ addPathSegments(builder, resolvedPath);
+ builder.addQueryParameter("at", commit);
+ if (start != null) {
+ builder.addQueryParameter("start", String.valueOf(start));
+ }
+ return builder.build();
+ }
+
+ private URI buildRawUri(final String resolvedPath, final String commit) {
+ final HttpUriBuilder builder =
getRepositoryUriBuilder().addPathSegment("raw");
+ addPathSegments(builder, resolvedPath);
+ builder.addQueryParameter("at", commit);
+ return builder.build();
+ }
+
private Iterator<JsonNode> getListCommits(final String branch, final
String path) throws FlowRegistryException {
- // retrieve latest commit for that branch
- //
https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/commits/{branch}
- final URI uri =
getUriBuilder().addPathSegment("commits").addPathSegment(branch).addQueryParameter("path",
path).build();
- final String errorMessage = String.format("Error while listing commits
for repository [%s] on branch %s", repoName, branch);
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ return getListCommitsDataCenter(branch, path);
+ }
+ return getListCommitsCloud(branch, path);
+ }
+
+ private Iterator<JsonNode> getListCommitsCloud(final String branch, final
String path) throws FlowRegistryException {
+
+ if (!hasCommits) {
+ // very specific behavior when there is no commit at all yet in
the repo
+ final URI uri = getRepositoryUriBuilder()
+ .addPathSegment("commits")
+ .build();
+ final String errorMessage = "Error while listing commits for
repository [%s] on branch %s".formatted(repoName, branch);
+ try (final HttpResponseEntity response =
webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue())
+ .retrieve()) {
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_OK);
+
+ final JsonNode root = parseResponseBody(response, uri);
+
+ final JsonNode values = root.path(FIELD_VALUES);
+ if (values.isArray() && values.isEmpty()) {
+ return Collections.emptyIterator();
+ } else {
+ // There is at least one commit, proceed as usual
+ // and never check again
+ hasCommits = true;
+ }
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket
commits response", e);
+ }
+ }
+ final URI uri = getRepositoryUriBuilder()
+ .addPathSegment("commits")
+ .addPathSegment(branch)
+ .addQueryParameter("path", path)
+ .build();
+ final String errorMessage = String.format("Error while listing commits
for repository [%s] on branch %s", repoName, branch);
return getPagedResponseValues(uri, errorMessage);
}
+ private Iterator<JsonNode> getListCommitsDataCenter(final String branch,
final String path) throws FlowRegistryException {
+ final List<JsonNode> allValues = new ArrayList<>();
+ Integer nextPageStart = null;
+
+ do {
+ if (!hasCommits) {
+ final URI uri = buildCommitsUri(null, null, null);
+ final String errorMessage = "Error while listing commits for
repository [%s] on branch %s".formatted(repoName, branch);
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue())
+ .retrieve()) {
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_OK);
+
+ final JsonNode root = parseResponseBody(response, uri);
+ final JsonNode values = root.path(FIELD_VALUES);
+ if (values.isArray() && values.isEmpty()) {
+ return Collections.emptyIterator();
+ }
+ hasCommits = true;
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket
commits response", e);
+ }
+ }
+
+ final URI uri = buildCommitsUri(branch, path, nextPageStart);
+ final String errorMessage = "Error while listing commits for
repository [%s] on branch %s".formatted(repoName, branch);
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue())
+ .retrieve()) {
+
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_OK);
+
+ final JsonNode root = parseResponseBody(response, uri);
+ final JsonNode values = root.path(FIELD_VALUES);
+ if (values.isArray()) {
+ values.forEach(allValues::add);
+ }
+
+ if (root.path(FIELD_IS_LAST_PAGE).asBoolean(true)) {
+ nextPageStart = null;
+ } else {
+ final JsonNode nextPageStartNode =
root.get(FIELD_NEXT_PAGE_START);
+ nextPageStart = nextPageStartNode != null &&
nextPageStartNode.isInt() ? nextPageStartNode.intValue() : null;
+ }
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket
commits response", e);
+ }
+ } while (nextPageStart != null);
+
+ return allValues.iterator();
+ }
+
+ private URI buildCommitsUri(final String branch, final String path, final
Integer start) {
+ final HttpUriBuilder builder =
getRepositoryUriBuilder().addPathSegment("commits");
+ if (path != null && !path.isBlank()) {
+ builder.addQueryParameter("path", path);
+ }
+ if (branch != null && !branch.isBlank()) {
+ builder.addQueryParameter("until", branch);
+ }
+ if (start != null) {
+ builder.addQueryParameter("start", String.valueOf(start));
+ }
+ return builder.build();
+ }
+
private Iterator<JsonNode> getPagedResponseValues(final URI uri, final
String errorMessage) throws FlowRegistryException {
final List<JsonNode> allValues = new ArrayList<>();
URI nextUri = uri;
while (nextUri != null) {
- final HttpResponseEntity response = webClient.getWebClientService()
+ try (final HttpResponseEntity response =
webClient.getWebClientService()
.get()
.uri(nextUri)
.header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue())
- .retrieve();
+ .retrieve()) {
- if (response.statusCode() != HttpURLConnection.HTTP_OK) {
- final String responseErrorMessage = getErrorMessage(response);
- final String errorMessageFormat = errorMessage + ": %s";
- throw new
FlowRegistryException(errorMessageFormat.formatted(responseErrorMessage));
- }
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_OK);
- JsonNode root;
- try {
- root = objectMapper.readTree(response.body());
- } catch (final IOException e) {
- throw new FlowRegistryException(String.format("Could not parse
Bitbucket API response at %s", nextUri), e);
- }
+ final JsonNode root = parseResponseBody(response, nextUri);
- // collect this page’s values
- JsonNode values = root.get("values");
- if (values != null && values.isArray()) {
- values.forEach(allValues::add);
- }
+ // collect this page’s values
+ final JsonNode values = root.get(FIELD_VALUES);
+ if (values != null && values.isArray()) {
+ values.forEach(allValues::add);
+ }
- // prepare next iteration
- JsonNode next = root.get("next");
- nextUri = (next != null && next.isTextual()) ?
URI.create(next.asText()) : null;
+ // prepare next iteration
+ final JsonNode next = root.get(FIELD_NEXT);
+ nextUri = (next != null && next.isTextual()) ?
URI.create(next.asText()) : null;
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket
paged response", e);
+ }
}
return allValues.iterator();
@@ -402,66 +780,163 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
private Optional<String> getLatestCommit(final String branch, final String
path) throws FlowRegistryException {
Iterator<JsonNode> commits = getListCommits(branch, path);
if (commits.hasNext()) {
- return Optional.of(commits.next().get("hash").asText());
+ return
Optional.ofNullable(getCommitHash(commits.next())).filter(hash ->
!hash.isEmpty());
} else {
return Optional.empty();
}
}
+ private String getRequiredLatestCommit(final String branch, final String
resolvedPath) throws FlowRegistryException {
+ final Optional<String> lastCommit = getLatestCommit(branch,
resolvedPath);
+
+ if (lastCommit.isEmpty()) {
+ throw new FlowRegistryException(String.format("Could not find
commit for the file %s we just tried to commit on branch %s", resolvedPath,
branch));
+ }
+
+ return lastCommit.get();
+ }
+
+ private String getCommitHash(final JsonNode commit) {
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ return commit.path(FIELD_ID).asText(EMPTY_STRING);
+ }
+ return commit.path(FIELD_HASH).asText(EMPTY_STRING);
+ }
+
private String checkRepoPermissions(BitbucketAuthenticationType
authenticationType) throws FlowRegistryException {
- switch (authenticationType) {
- case OAUTH2:
- logger.debug("Retrieving information about current user");
-
- //
'https://api.bitbucket.org/2.0/user/permissions/repositories?q=repository.name="{repoName}"
- URI uri = this.webClient.getHttpUriBuilder()
- .scheme("https")
- .host(apiUrl)
- .addPathSegment(apiVersion)
- .addPathSegment("user")
- .addPathSegment("permissions")
- .addPathSegment("repositories")
- .addQueryParameter("q", "repository.name=\"" +
repoName + "\"")
- .build();
- HttpResponseEntity response =
this.webClient.getWebClientService().get().uri(uri).header(AUTHORIZATION_HEADER,
authToken.getAuthzHeaderValue()).retrieve();
-
- if (response.statusCode() != HttpURLConnection.HTTP_OK) {
- throw new FlowRegistryException(String.format("Error while
retrieving permission metadata for specified repo - %s",
getErrorMessage(response)));
- }
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ return checkReadByListingBranches();
+ }
- JsonNode jsonResponse;
- try {
- jsonResponse = this.objectMapper.readTree(response.body());
- } catch (IOException e) {
- throw new FlowRegistryException("Could not parse response
from Bitbucket API", e);
- }
- Iterator<JsonNode> repoPermissions =
jsonResponse.get("values").elements();
+ return switch (authenticationType) {
+ case OAUTH2 -> checkOAuthPermissions();
+ case ACCESS_TOKEN, BASIC_AUTH -> checkReadByListingBranches();
+ };
+ }
- if (repoPermissions.hasNext()) {
- return repoPermissions.next().get("permission").asText();
- } else {
- return "none";
- }
- case ACCESS_TOKEN, BASIC_AUTH:
- try {
- // we try to list the branches of the repo to confirm read
access
- getBranches();
- // we don't have a good endpoint to confirm write access,
so we assume that if
- // we can read, we can write
- return "admin";
- } catch (FlowRegistryException e) {
- return "none";
- }
+ private String checkOAuthPermissions() throws FlowRegistryException {
+ logger.debug("Retrieving information about current user");
+
+ final HttpUriBuilder builder = this.webClient.getHttpUriBuilder()
+ .scheme(apiScheme)
+ .host(apiHost);
+
+ if (apiPort != -1) {
+ builder.port(apiPort);
}
+
+ apiBasePathSegments.forEach(builder::addPathSegment);
+
+ final URI uri = builder
+ .addPathSegment(apiVersion)
+ .addPathSegment("user")
+ .addPathSegment("permissions")
+ .addPathSegment("repositories")
+ .addQueryParameter("q", "repository.name=\"" + repoName + "\"")
+ .build();
+ final String errorMessage = "Error while retrieving permission
metadata for specified repo";
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .get()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .retrieve()) {
+
+ verifyStatusCode(response, errorMessage,
HttpURLConnection.HTTP_OK);
+
+ final JsonNode jsonResponse = parseResponseBody(response, uri);
+
+ final JsonNode values = jsonResponse.get(FIELD_VALUES);
+ if (values != null && values.isArray() &&
values.elements().hasNext()) {
+ final JsonNode permissionNode =
values.elements().next().get(FIELD_PERMISSION);
+ return permissionNode == null ? "none" :
permissionNode.asText();
+ }
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket
permission response", e);
+ }
+
return "none";
}
+ private String checkReadByListingBranches() {
+ try {
+ getBranches();
+ return "admin";
+ } catch (FlowRegistryException e) {
+ return "none";
+ }
+ }
+
private GitCommit toGitCommit(final JsonNode commit) {
- return new GitCommit(
- commit.get("hash").asText(),
- commit.get("author").get("raw").asText(),
- commit.get("message").asText(),
- Instant.parse(commit.get("date").asText()));
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ final String hash = commit.path(FIELD_ID).asText();
+ final JsonNode authorNode = commit.path(FIELD_AUTHOR);
+ final String authorName =
authorNode.path(FIELD_DISPLAY_NAME).asText(authorNode.path(FIELD_NAME).asText(EMPTY_STRING));
+ final String authorEmail =
authorNode.path(FIELD_EMAIL_ADDRESS).asText(EMPTY_STRING);
+ final String author = authorEmail.isBlank() ? authorName :
authorName + " <" + authorEmail + ">";
+ final String message = commit.path(FIELD_MESSAGE_TEXT).asText();
+ final long timestamp =
commit.path(FIELD_AUTHOR_TIMESTAMP).asLong(0L);
+ final Instant date = timestamp == 0L ? Instant.now() :
Instant.ofEpochMilli(timestamp);
+ return new GitCommit(hash, author, message, date);
+ }
+
+ final JsonNode authorNode = commit.path(FIELD_AUTHOR);
+ final String author = authorNode.path(FIELD_RAW).asText();
+ final String message = commit.path(FIELD_MESSAGE_TEXT).asText();
+ final String dateText = commit.path("date").asText();
+ final Instant date = (dateText == null || dateText.isEmpty()) ?
Instant.now() : Instant.parse(dateText);
+ return new GitCommit(commit.path(FIELD_HASH).asText(), author,
message, date);
+ }
+
+ private InputStream getResponseBody(final HttpResponseEntity response,
final String errorMessage, final int... expectedStatusCodes) throws
FlowRegistryException {
+ try {
+ verifyStatusCode(response, errorMessage, expectedStatusCodes);
+ return new HttpResponseBodyInputStream(response);
+ } catch (final FlowRegistryException e) {
+ closeQuietly(response);
+ throw e;
+ }
+ }
+
+ private void closeQuietly(final HttpResponseEntity response) {
+ try {
+ response.close();
+ } catch (final IOException ioe) {
+ logger.warn("Failed closing Bitbucket HTTP response", ioe);
+ }
+ }
+
+ private static class HttpResponseBodyInputStream extends FilterInputStream
{
+ private final HttpResponseEntity response;
+ private boolean closed;
+
+ protected HttpResponseBodyInputStream(final HttpResponseEntity
response) {
+ super(response.body());
+ this.response = response;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!closed) {
+ closed = true;
+ IOException suppressed = null;
+ try {
+ super.close();
+ } catch (final IOException closeException) {
+ suppressed = closeException;
+ }
+ try {
+ response.close();
+ } catch (final IOException responseCloseException) {
+ if (suppressed != null) {
+ responseCloseException.addSuppressed(suppressed);
+ }
+ throw responseCloseException;
+ }
+ if (suppressed != null) {
+ throw suppressed;
+ }
+ }
+ }
}
private String getErrorMessage(HttpResponseEntity response) throws
FlowRegistryException {
@@ -471,21 +946,192 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
} catch (IOException e) {
throw new FlowRegistryException("Could not parse response from
Bitbucket API", e);
}
- return String.format("[%s] - %s", jsonResponse.get("type").asText(),
jsonResponse.get("error").get("message").asText());
+ if (jsonResponse == null) {
+ return "Unknown error";
+ }
+
+ final JsonNode errorNode = jsonResponse.get(FIELD_ERROR_MESSAGE);
+ if (errorNode != null) {
+ final String type = jsonResponse.path(FIELD_TYPE).asText("Error");
+ final String message =
errorNode.path(FIELD_MESSAGE_TEXT).asText(errorNode.toString());
+ return String.format("[%s] - %s", type, message);
+ }
+
+ final JsonNode errorsNode = jsonResponse.get(FIELD_ERRORS);
+ if (errorsNode != null && errorsNode.isArray() && errorsNode.size() >
0) {
+ final JsonNode firstError = errorsNode.get(0);
+ return
firstError.path(FIELD_MESSAGE_TEXT).asText(firstError.toString());
+ }
+
+ final JsonNode messageNode = jsonResponse.get(FIELD_MESSAGE_TEXT);
+ if (messageNode != null) {
+ return messageNode.asText();
+ }
+
+ return jsonResponse.toString();
+ }
+
+ private JsonNode parseResponseBody(final HttpResponseEntity response,
final URI uri) throws FlowRegistryException {
+ try {
+ return objectMapper.readTree(response.body());
+ } catch (final IOException e) {
+ throw new FlowRegistryException(String.format("Could not parse
Bitbucket API response at %s", uri), e);
+ }
+ }
+
+ private void verifyStatusCode(final HttpResponseEntity response, final
String errorMessage, final int... expectedStatusCodes) throws
FlowRegistryException {
+ final int statusCode = response.statusCode();
+ for (final int expectedStatusCode : expectedStatusCodes) {
+ if (statusCode == expectedStatusCode) {
+ return;
+ }
+ }
+ throw new FlowRegistryException("%s: %s".formatted(errorMessage,
getErrorMessage(response)));
}
private String getResolvedPath(final String path) {
return repoPath == null ? path : repoPath + "/" + path;
}
- private HttpUriBuilder getUriBuilder() {
- return this.webClient.getHttpUriBuilder()
- .scheme("https")
- .host(apiUrl)
- .addPathSegment(apiVersion)
- .addPathSegment("repositories")
- .addPathSegment(workspace)
- .addPathSegment(repoName);
+ private void addPathSegments(final HttpUriBuilder builder, final String
path) {
+ if (path == null || path.isBlank()) {
+ return;
+ }
+
+ final String normalizedPath = path.startsWith("/") ? path.substring(1)
: path;
+ final String[] segments = normalizedPath.split("/");
+ for (final String segment : segments) {
+ if (!segment.isBlank()) {
+ builder.addPathSegment(segment);
+ }
+ }
+ }
+
+ private boolean isDirectoryEntry(final JsonNode entry) {
+ final JsonNode typeNode = entry.get(FIELD_TYPE);
+ if (typeNode == null) {
+ return false;
+ }
+
+ final String type = typeNode.asText();
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ return ENTRY_DIRECTORY_DATA_CENTER.equalsIgnoreCase(type);
+ }
+ return ENTRY_DIRECTORY_CLOUD.equals(type);
+ }
+
+ private boolean isFileEntry(final JsonNode entry) {
+ final JsonNode typeNode = entry.get(FIELD_TYPE);
+ if (typeNode == null) {
+ return false;
+ }
+
+ final String type = typeNode.asText();
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ return ENTRY_FILE_DATA_CENTER.equalsIgnoreCase(type);
+ }
+ return ENTRY_FILE_CLOUD.equals(type);
+ }
+
+ private String getEntryPath(final JsonNode entry) {
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ final JsonNode pathNode = entry.get(FIELD_PATH);
+ if (pathNode == null) {
+ return EMPTY_STRING;
+ }
+
+ final JsonNode toStringNode = pathNode.get(FIELD_TO_STRING);
+ if (toStringNode != null && toStringNode.isTextual()) {
+ return toStringNode.asText();
+ }
+
+ return pathNode.asText(EMPTY_STRING);
+ }
+
+ final JsonNode pathNode = entry.get(FIELD_PATH);
+ return pathNode == null ? EMPTY_STRING : pathNode.asText();
+ }
+
+ private HttpUriBuilder getRepositoryUriBuilder() {
+ final HttpUriBuilder builder = this.webClient.getHttpUriBuilder()
+ .scheme(apiScheme)
+ .host(apiHost);
+
+ if (apiPort != -1) {
+ builder.port(apiPort);
+ }
+
+ apiBasePathSegments.forEach(builder::addPathSegment);
+
+ if (formFactor == BitbucketFormFactor.CLOUD) {
+ builder.addPathSegment(apiVersion)
+ .addPathSegment("repositories")
+ .addPathSegment(workspace)
+ .addPathSegment(repoName);
+ } else {
+ builder.addPathSegment("rest")
+ .addPathSegment("api")
+ .addPathSegment(apiVersion)
+ .addPathSegment("projects")
+ .addPathSegment(projectKey)
+ .addPathSegment("repos")
+ .addPathSegment(repoName);
+ }
+
+ return builder;
+ }
+
+ private static ParsedApiUrl parseApiUrl(final String apiUrl) throws
FlowRegistryException {
+ final String trimmedApiUrl = apiUrl == null ? null : apiUrl.trim();
+ if (trimmedApiUrl == null || trimmedApiUrl.isEmpty()) {
+ throw new FlowRegistryException("API Instance is required");
+ }
+
+ if (!trimmedApiUrl.contains("://")) {
+ return new ParsedApiUrl("https", trimmedApiUrl, -1, List.of());
+ }
+
+ final URI uri = URI.create(trimmedApiUrl);
+ final String scheme = uri.getScheme() == null ? "https" :
uri.getScheme();
+ final String host = uri.getHost();
+ if (host == null || host.isBlank()) {
+ throw new FlowRegistryException("API Instance must include a
host");
+ }
+
+ final List<String> segments = new ArrayList<>();
+ final String path = uri.getPath();
+ if (path != null && !path.isBlank()) {
+ final String[] rawSegments = path.split("/");
+ for (final String segment : rawSegments) {
+ if (!segment.isBlank()) {
+ segments.add(segment);
+ }
+ }
+ }
+
+ return new ParsedApiUrl(scheme, host, uri.getPort(),
List.copyOf(segments));
+ }
+
+ private record ParsedApiUrl(String scheme, String host, int port,
List<String> pathSegments) {
+ }
+
+ private String getFileName(final String resolvedPath) {
+ if (resolvedPath == null || resolvedPath.isBlank()) {
+ return "content";
+ }
+
+ final Path path = Paths.get(resolvedPath);
+ final Path fileName = path.getFileName();
+ return fileName == null ? resolvedPath : fileName.toString();
+ }
+
+ private byte[] toByteArray(final InputStream inputStream) throws
FlowRegistryException {
+ try (inputStream; ByteArrayOutputStream outputStream = new
ByteArrayOutputStream()) {
+ StreamUtils.copy(inputStream, outputStream);
+ return outputStream.toByteArray();
+ } catch (IOException e) {
+ throw new FlowRegistryException("Failed to prepare multipart
request", e);
+ }
}
private interface BitbucketToken<T> {
@@ -542,7 +1188,7 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
public static class Builder {
private String clientId;
private String apiUrl;
- private String apiVersion;
+ private BitbucketFormFactor formFactor;
private BitbucketAuthenticationType authenticationType;
private String accessToken;
private String username;
@@ -550,6 +1196,7 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
private OAuth2AccessTokenProvider oauthService;
private WebClientServiceProvider webClient;
private String workspace;
+ private String projectKey;
private String repoName;
private String repoPath;
private ComponentLog logger;
@@ -564,8 +1211,8 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
return this;
}
- public Builder apiVersion(final String apiVersion) {
- this.apiVersion = apiVersion;
+ public Builder formFactor(final BitbucketFormFactor formFactor) {
+ this.formFactor = formFactor;
return this;
}
@@ -604,6 +1251,11 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
return this;
}
+ public Builder projectKey(final String projectKey) {
+ this.projectKey = projectKey;
+ return this;
+ }
+
public Builder repoName(final String repoName) {
this.repoName = repoName;
return this;