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;

Reply via email to