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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";

Expand All @@ -55,30 +55,29 @@ 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 List<InputStream> 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<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);
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);
final String closingBoundary = CARRIAGE_RETURN_LINE_FEED + BOUNDARY_SEPARATOR + boundary + BOUNDARY_SEPARATOR;
streams.add(new ByteArrayInputStream(closingBoundary.getBytes(HEADERS_CHARACTER_SET)));

final Enumeration<InputStream> enumeratedStreams = Collections.enumeration(streams);
return new SequenceInputStream(enumeratedStreams);
}

/**
Expand All @@ -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");
Expand All @@ -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<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 = 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);

Expand All @@ -156,21 +166,6 @@ private String getPartHeaders(final Part part) {
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);
}

return footerBuilder.toString();
}

private record MultipartHttpContentType(String contentType) implements HttpContentType {
@Override
public String getContentType() {
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -116,9 +126,10 @@ 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,
Expand All @@ -136,14 +147,17 @@ protected List<PropertyDescriptor> 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())
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading