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 4595c19dabab..6873bf623bfa 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,12 @@ public interface MultipartFormDataStreamBuilder { * @return Builder */ MultipartFormDataStreamBuilder addPart(String name, HttpContentType httpContentType, byte[] bytes); + + default MultipartFormDataStreamBuilder addPart(String name, String fileName, HttpContentType httpContentType, InputStream inputStream) { + return addPart(name, httpContentType, inputStream); + } + + 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 3032db2b0e8e..5bf66fc9389d 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.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 @@ */ 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,29 @@ public class StandardMultipartFormDataStreamBuilder implements MultipartFormData private final List 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 partInputStreams = new ArrayList<>(); + final List streams = new ArrayList<>(); + for (int index = 0; index < parts.size(); index++) { + final Part part = parts.get(index); + final String boundaryPrefix = (index == 0 ? BOUNDARY_SEPARATOR + boundary + CARRIAGE_RETURN_LINE_FEED + : CARRIAGE_RETURN_LINE_FEED + BOUNDARY_SEPARATOR + boundary + CARRIAGE_RETURN_LINE_FEED); - final Iterator 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); + 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 enumeratedPartInputStreams = Collections.enumeration(partInputStreams); - return new SequenceInputStream(enumeratedPartInputStreams); + final String closingBoundary = CARRIAGE_RETURN_LINE_FEED + BOUNDARY_SEPARATOR + boundary + BOUNDARY_SEPARATOR; + streams.add(new ByteArrayInputStream(closingBoundary.getBytes(HEADERS_CHARACTER_SET))); + + final Enumeration enumeratedStreams = Collections.enumeration(streams); + return new SequenceInputStream(enumeratedStreams); } /** @@ -102,13 +101,23 @@ public HttpContentType getHttpContentType() { */ @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 +141,19 @@ public MultipartFormDataStreamBuilder addPart(final String name, final HttpConte 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 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 = part.fileName == null + ? CONTENT_DISPOSITION_HEADER.formatted(part.name) + : CONTENT_DISPOSITION_FILE_HEADER.formatted(part.name, part.fileName); headersBuilder.append(contentDispositionHeader); headersBuilder.append(CARRIAGE_RETURN_LINE_FEED); @@ -156,21 +166,6 @@ private String getPartHeaders(final Part part) { return headersBuilder.toString(); } - private String getFooter(final Iterator 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); - } - - return footerBuilder.toString(); - } - private record MultipartHttpContentType(String contentType) implements HttpContentType { @Override public String getContentType() { @@ -180,8 +175,23 @@ public String getContentType() { 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 fb363072ac19..8aee7b5e10bd 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 @@ -21,8 +21,9 @@ public enum BitbucketAuthenticationType implements DescribedValue { - BASIC_AUTH("Basic Auth", """ + BASIC_AUTH("Basic Auth & API Token", """ 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 3d08f16e896c..44c3ab4a0012 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 @@ -34,19 +34,19 @@ @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 +55,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 +65,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,7 +101,8 @@ public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient { static final PropertyDescriptor APP_PASSWORD = new PropertyDescriptor.Builder() .name("App Password") - .description("The App Password to use for authentication") + .displayName("App Password or API Token") + .description("The App Password or API Token to use for authentication") .addValidator(StandardValidators.NON_BLANK_VALIDATOR) .required(true) .sensitive(true) @@ -116,9 +126,10 @@ public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient { static final List PROPERTY_DESCRIPTORS = List.of( WEBCLIENT_SERVICE, + FORM_FACTOR, BITBUCKET_API_URL, - BITBUCKET_API_VERSION, WORKSPACE_NAME, + PROJECT_KEY, REPOSITORY_NAME, AUTHENTICATION_TYPE, ACCESS_TOKEN, @@ -136,14 +147,17 @@ protected List createPropertyDescriptors() { @Override protected GitRepositoryClient createRepositoryClient(final FlowRegistryClientConfigurationContext context) throws FlowRegistryException { + final BitbucketFormFactor formFactor = context.getProperty(FORM_FACTOR).asAllowableValue(BitbucketFormFactor.class); + 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()) @@ -160,7 +174,17 @@ public boolean isStorageLocationApplicable(FlowRegistryClientConfigurationContex @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@" + apiHost + ":" + projectKey + "/" + bitbucketRepositoryClient.getRepoName() + ".git"; + } + 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/BitbucketFormFactor.java b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFormFactor.java new file mode 100644 index 000000000000..a7450c556fcc --- /dev/null +++ b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFormFactor.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.nifi.atlassian.bitbucket; + +import org.apache.nifi.components.DescribedValue; + +public enum BitbucketFormFactor implements DescribedValue { + + 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; + + BitbucketFormFactor(final String displayName, final String description) { + this.displayName = displayName; + this.description = description; + } + + @Override + public String getValue() { + return name(); + } + + @Override + public String getDisplayName() { + return displayName; + } + + @Override + public String getDescription() { + return 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 61b36f04b93b..7eb85117ea8a 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,15 @@ 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.IOException; import java.io.InputStream; import java.net.HttpURLConnection; @@ -42,6 +45,7 @@ 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,6 +61,9 @@ 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"; @@ -66,6 +73,12 @@ public class BitbucketRepositoryClient implements GitRepositoryClient { 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 apiBasePathSegments; private final String clientId; private final String workspace; private final String repoName; @@ -75,21 +88,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 +186,10 @@ public String getWorkspace() { return workspace; } + public String getProjectKey() { + return projectKey; + } + /** * @return the name of the repository */ @@ -154,32 +197,75 @@ public String getRepoName() { return repoName; } + public BitbucketFormFactor getFormFactor() { + return formFactor; + } + + public String getApiHost() { + return apiHost; + } + @Override public Set 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 getBranchesCloud() throws FlowRegistryException { + final URI uri = getRepositoryUriBuilder().addPathSegment("refs").addPathSegment("branches").build(); + 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))); } - JsonNode jsonResponse; + final JsonNode jsonResponse; try { jsonResponse = this.objectMapper.readTree(response.body()); } catch (IOException e) { throw new FlowRegistryException("Could not parse response from Bitbucket API", e); } - Iterator branches = jsonResponse.get("values").elements(); - Set result = new HashSet<>(); - while (branches.hasNext()) { - JsonNode branch = branches.next(); - result.add(branch.get("name").asText()); + final JsonNode values = jsonResponse.get("values"); + final Set result = new HashSet<>(); + if (values != null && values.isArray()) { + for (JsonNode branch : values) { + final JsonNode branchName = branch.get("name"); + if (branchName != null) { + result.add(branchName.asText()); + } + } + } + return result; + } + + private Set getBranchesDataCenter() throws FlowRegistryException { + final URI uri = getRepositoryUriBuilder().addPathSegment("branches").build(); + 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))); + } + + final 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 values = jsonResponse.get("values"); + final Set result = new HashSet<>(); + if (values != null && values.isArray()) { + for (JsonNode branch : values) { + final JsonNode displayId = branch.get("displayId"); + if (displayId != null) { + result.add(displayId.asText()); + } + } + } return result; } @@ -193,9 +279,12 @@ public Set getTopLevelDirectoryNames(final String branch) throws FlowReg final Set 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 +301,12 @@ public Set getFileNames(final String directory, final String branch) thr final Set 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,9 +345,17 @@ public InputStream getContentFromBranch(final String path, final String branch) 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(); + 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 HttpResponseEntity response = this.webClient.getWebClientService().get().uri(uri).header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue()).retrieve(); if (response.statusCode() != HttpURLConnection.HTTP_OK) { @@ -266,6 +366,22 @@ public InputStream getContentFromCommit(final String path, final String commitSh return response.body(); } + private InputStream getContentFromCommitDataCenter(final String resolvedPath, final String commitSha) throws FlowRegistryException { + final URI uri = buildRawUri(resolvedPath, commitSha); + 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 response.body(); + } + @Override public Optional getContentSha(final String path, final String branch) throws FlowRegistryException { final String resolvedPath = getResolvedPath(path); @@ -278,14 +394,18 @@ public String createContent(final GitCreateContentRequest request) throws FlowRe 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)); - // https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/src - final URI uri = getUriBuilder().addPathSegment("src").build(); + final URI uri = getRepositoryUriBuilder().addPathSegment("src").build(); final HttpResponseEntity response = this.webClient.getWebClientService() .post() .uri(uri) @@ -299,13 +419,43 @@ public String createContent(final GitCreateContentRequest request) throws FlowRe String.format("Error while committing content for repository [%s] on branch %s at path %s: %s", repoName, branch, resolvedPath, getErrorMessage(response))); } - final Optional lastCommit = getLatestCommit(branch, resolvedPath); + return getRequiredLatestCommit(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)); + 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("content", fileName, StandardHttpContentType.APPLICATION_OCTET_STREAM, request.getContent().getBytes(StandardCharsets.UTF_8)); + multipartBuilder.addPart("branch", StandardHttpContentType.TEXT_PLAIN, branch.getBytes(StandardCharsets.UTF_8)); + + final String message = request.getMessage(); + if (message != null && !message.isEmpty()) { + multipartBuilder.addPart("message", StandardHttpContentType.TEXT_PLAIN, message.getBytes(StandardCharsets.UTF_8)); } - return lastCommit.get(); + final String existingContentSha = request.getExistingContentSha(); + if (existingContentSha != null && !existingContentSha.isEmpty()) { + multipartBuilder.addPart("sourceCommitId", StandardHttpContentType.TEXT_PLAIN, existingContentSha.getBytes(StandardCharsets.UTF_8)); + } + + final HttpUriBuilder uriBuilder = getRepositoryUriBuilder().addPathSegment("browse"); + addPathSegments(uriBuilder, resolvedPath); + final URI uri = uriBuilder.build(); + final byte[] requestBody = toByteArray(multipartBuilder.build()); + 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(); + + if (response.statusCode() != HttpURLConnection.HTTP_OK && 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))); + } + + return getRequiredLatestCommit(branch, resolvedPath); } @Override @@ -315,13 +465,22 @@ public InputStream deleteContent(final String filePath, final String commitMessa final InputStream fileToBeDeleted = getContentFromBranch(filePath, branch); + if (formFactor == BitbucketFormFactor.DATA_CENTER) { + deleteContentDataCenter(resolvedPath, commitMessage, branch); + } else { + deleteContentCloud(resolvedPath, commitMessage, branch); + } + + return fileToBeDeleted; + } + + private void deleteContentCloud(final String resolvedPath, final String commitMessage, final String branch) throws FlowRegistryException { 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)); - // https://api.bitbucket.org/2.0/repositories/{workspace}/{repoName}/src - final URI uri = getUriBuilder().addPathSegment("src").build(); + final URI uri = getRepositoryUriBuilder().addPathSegment("src").build(); final HttpResponseEntity response = this.webClient.getWebClientService() .post() .uri(uri) @@ -334,34 +493,249 @@ public InputStream deleteContent(final String filePath, final String commitMessa throw new FlowRegistryException( String.format("Error while deleting content for repository [%s] on branch %s at path %s: %s", repoName, branch, resolvedPath, getErrorMessage(response))); } + } - return fileToBeDeleted; + private void deleteContentDataCenter(final String resolvedPath, final String commitMessage, final String branch) throws FlowRegistryException { + final Optional latestCommit = getLatestCommit(branch, resolvedPath); + final HttpUriBuilder uriBuilder = getRepositoryUriBuilder().addPathSegment("browse"); + addPathSegments(uriBuilder, resolvedPath); + uriBuilder.addQueryParameter("branch", branch); + if (commitMessage != null) { + uriBuilder.addQueryParameter("message", commitMessage); + } + latestCommit.ifPresent(commit -> uriBuilder.addQueryParameter("sourceCommitId", commit)); + + final URI uri = uriBuilder.build(); + final HttpResponseEntity response = this.webClient.getWebClientService() + .delete() + .uri(uri) + .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue()) + .retrieve(); + + final int statusCode = response.statusCode(); + if (statusCode != HttpURLConnection.HTTP_OK && statusCode != HttpURLConnection.HTTP_ACCEPTED + && statusCode != HttpURLConnection.HTTP_NO_CONTENT && 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 Iterator getFiles(final String branch, final String resolvedPath) throws FlowRegistryException { final Optional 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 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 getFilesDataCenter(final String branch, final String resolvedPath, final String commit) throws FlowRegistryException { + final List allValues = new ArrayList<>(); + Integer nextPageStart = null; + + do { + final URI uri = buildBrowseUri(resolvedPath, commit, nextPageStart, false); + final HttpResponseEntity response = this.webClient.getWebClientService() + .get() + .uri(uri) + .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue()) + .retrieve(); + + if (response.statusCode() != HttpURLConnection.HTTP_OK) { + final String errorMessage = String.format("Error while listing content for repository [%s] on branch %s at path %s", repoName, branch, resolvedPath); + throw new FlowRegistryException(errorMessage + ": " + getErrorMessage(response)); + } + + final JsonNode root; + try { + root = objectMapper.readTree(response.body()); + } catch (IOException e) { + throw new FlowRegistryException(String.format("Could not parse Bitbucket API response at %s", uri), e); + } + + final JsonNode children = root.path("children"); + final JsonNode values = children.path("values"); + if (values.isArray()) { + values.forEach(allValues::add); + } + + if (children.path("isLastPage").asBoolean(true)) { + nextPageStart = null; + } else { + final JsonNode nextPageStartNode = children.get("nextPageStart"); + nextPageStart = nextPageStartNode != null && nextPageStartNode.isInt() ? nextPageStartNode.intValue() : null; + } + } 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 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 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 HttpResponseEntity response = webClient.getWebClientService() + .get() + .uri(uri) + .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue()) + .retrieve(); + + if (response.statusCode() != HttpURLConnection.HTTP_OK) { + final String errorMessage = String.format("Error while listing commits for repository [%s] on branch %s", repoName, branch); + throw new FlowRegistryException(errorMessage + ": " + getErrorMessage(response)); + } + + final JsonNode root; + try { + root = objectMapper.readTree(response.body()); + } catch (IOException e) { + throw new FlowRegistryException(String.format("Could not parse Bitbucket API response at %s", uri), e); + } + + final JsonNode values = root.path("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; + } + } + + 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 getListCommitsDataCenter(final String branch, final String path) throws FlowRegistryException { + final List allValues = new ArrayList<>(); + Integer nextPageStart = null; + + do { + if (!hasCommits) { + // very specific behavior when there is no commit at all yet in the repo + final URI uri = buildCommitsUri(null, null, null); + final HttpResponseEntity response = this.webClient.getWebClientService() + .get() + .uri(uri) + .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue()) + .retrieve(); + if (response.statusCode() != HttpURLConnection.HTTP_OK) { + final String errorMessage = String.format("Error while listing commits for repository [%s] on branch %s", repoName, branch); + throw new FlowRegistryException(errorMessage + ": " + getErrorMessage(response)); + } + final JsonNode root; + try { + root = objectMapper.readTree(response.body()); + } catch (IOException e) { + throw new FlowRegistryException(String.format("Could not parse Bitbucket API response at %s", uri), e); + } + + final JsonNode values = root.path("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; + } + } + + final URI uri = buildCommitsUri(branch, path, nextPageStart); + final HttpResponseEntity response = this.webClient.getWebClientService() + .get() + .uri(uri) + .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue()) + .retrieve(); + + if (response.statusCode() != HttpURLConnection.HTTP_OK) { + final String errorMessage = String.format("Error while listing commits for repository [%s] on branch %s", repoName, branch); + throw new FlowRegistryException(errorMessage + ": " + getErrorMessage(response)); + } + + final JsonNode root; + try { + root = objectMapper.readTree(response.body()); + } catch (IOException e) { + throw new FlowRegistryException(String.format("Could not parse Bitbucket API response at %s", uri), e); + } + + final JsonNode values = root.path("values"); + if (values.isArray()) { + values.forEach(allValues::add); + } + + if (root.path("isLastPage").asBoolean(true)) { + nextPageStart = null; + } else { + final JsonNode nextPageStartNode = root.get("nextPageStart"); + nextPageStart = nextPageStartNode != null && nextPageStartNode.isInt() ? nextPageStartNode.intValue() : null; + } + } 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 getPagedResponseValues(final URI uri, final String errorMessage) throws FlowRegistryException { final List allValues = new ArrayList<>(); URI nextUri = uri; @@ -402,66 +776,111 @@ private Iterator getPagedResponseValues(final URI uri, final String er private Optional getLatestCommit(final String branch, final String path) throws FlowRegistryException { Iterator 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 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("id").asText(""); + } + return commit.path("hash").asText(""); + } + 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 (formFactor == BitbucketFormFactor.DATA_CENTER) { + return checkReadByListingBranches(); + } - if (response.statusCode() != HttpURLConnection.HTTP_OK) { - throw new FlowRegistryException(String.format("Error while retrieving permission metadata for specified repo - %s", getErrorMessage(response))); - } + return switch (authenticationType) { + case OAUTH2 -> checkOAuthPermissions(); + case ACCESS_TOKEN, BASIC_AUTH -> 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 repoPermissions = jsonResponse.get("values").elements(); + private String checkOAuthPermissions() throws FlowRegistryException { + logger.debug("Retrieving information about current user"); - 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"; - } + 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 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))); + } + + final 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 values = jsonResponse.get("values"); + if (values != null && values.isArray() && values.elements().hasNext()) { + final JsonNode permissionNode = values.elements().next().get("permission"); + return permissionNode == null ? "none" : permissionNode.asText(); + } + 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("id").asText(); + final JsonNode authorNode = commit.path("author"); + final String authorName = authorNode.path("displayName").asText(authorNode.path("name").asText("")); + final String authorEmail = authorNode.path("emailAddress").asText(""); + final String author = authorEmail.isBlank() ? authorName : authorName + " <" + authorEmail + ">"; + final String message = commit.path("message").asText(); + final long timestamp = commit.path("authorTimestamp").asLong(0L); + final Instant date = timestamp == 0L ? Instant.now() : Instant.ofEpochMilli(timestamp); + return new GitCommit(hash, author, message, date); + } + + final JsonNode authorNode = commit.path("author"); + final String author = authorNode.path("raw").asText(); + final String message = commit.path("message").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("hash").asText(), author, message, date); } private String getErrorMessage(HttpResponseEntity response) throws FlowRegistryException { @@ -471,21 +890,174 @@ private String getErrorMessage(HttpResponseEntity response) throws FlowRegistryE } 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("error"); + if (errorNode != null) { + final String type = jsonResponse.path("type").asText("Error"); + final String message = errorNode.path("message").asText(errorNode.toString()); + return String.format("[%s] - %s", type, message); + } + + final JsonNode errorsNode = jsonResponse.get("errors"); + if (errorsNode != null && errorsNode.isArray() && errorsNode.size() > 0) { + final JsonNode firstError = errorsNode.get(0); + return firstError.path("message").asText(firstError.toString()); + } + + final JsonNode messageNode = jsonResponse.get("message"); + if (messageNode != null) { + return messageNode.asText(); + } + + return jsonResponse.toString(); } 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("type"); + if (typeNode == null) { + return false; + } + + final String type = typeNode.asText(); + if (formFactor == BitbucketFormFactor.DATA_CENTER) { + return "DIRECTORY".equalsIgnoreCase(type); + } + return "commit_directory".equals(type); + } + + private boolean isFileEntry(final JsonNode entry) { + final JsonNode typeNode = entry.get("type"); + if (typeNode == null) { + return false; + } + + final String type = typeNode.asText(); + if (formFactor == BitbucketFormFactor.DATA_CENTER) { + return "FILE".equalsIgnoreCase(type); + } + return "commit_file".equals(type); + } + + private String getEntryPath(final JsonNode entry) { + if (formFactor == BitbucketFormFactor.DATA_CENTER) { + final JsonNode pathNode = entry.get("path"); + if (pathNode == null) { + return ""; + } + + final JsonNode toStringNode = pathNode.get("toString"); + if (toStringNode != null && toStringNode.isTextual()) { + return toStringNode.asText(); + } + + return pathNode.asText(""); + } + + final JsonNode pathNode = entry.get("path"); + return pathNode == null ? "" : 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 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 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 { @@ -542,7 +1114,7 @@ public static Builder builder() { 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 +1122,7 @@ public static class Builder { private OAuth2AccessTokenProvider oauthService; private WebClientServiceProvider webClient; private String workspace; + private String projectKey; private String repoName; private String repoPath; private ComponentLog logger; @@ -564,8 +1137,8 @@ public Builder apiUrl(final String apiUrl) { return this; } - public Builder apiVersion(final String apiVersion) { - this.apiVersion = apiVersion; + public Builder formFactor(final BitbucketFormFactor formFactor) { + this.formFactor = formFactor; return this; } @@ -604,6 +1177,11 @@ public Builder workspace(final String workspace) { return this; } + public Builder projectKey(final String projectKey) { + this.projectKey = projectKey; + return this; + } + public Builder repoName(final String repoName) { this.repoName = repoName; return this;