Skip to content

Commit 5f44977

Browse files
committed
NIFI-14968 - Add support for Bitbucket Data Center
1 parent f3adaca commit 5f44977

File tree

6 files changed

+821
-151
lines changed

6 files changed

+821
-151
lines changed

nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,12 @@ public interface MultipartFormDataStreamBuilder {
5555
* @return Builder
5656
*/
5757
MultipartFormDataStreamBuilder addPart(String name, HttpContentType httpContentType, byte[] bytes);
58+
59+
default MultipartFormDataStreamBuilder addPart(String name, String fileName, HttpContentType httpContentType, InputStream inputStream) {
60+
return addPart(name, httpContentType, inputStream);
61+
}
62+
63+
default MultipartFormDataStreamBuilder addPart(String name, String fileName, HttpContentType httpContentType, byte[] bytes) {
64+
return addPart(name, httpContentType, bytes);
65+
}
5866
}

nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import java.util.ArrayList;
2525
import java.util.Collections;
2626
import java.util.Enumeration;
27-
import java.util.Iterator;
2827
import java.util.List;
2928
import java.util.Objects;
3029
import java.util.UUID;
@@ -36,6 +35,7 @@
3635
*/
3736
public class StandardMultipartFormDataStreamBuilder implements MultipartFormDataStreamBuilder {
3837
private static final String CONTENT_DISPOSITION_HEADER = "Content-Disposition: form-data; name=\"%s\"";
38+
private static final String CONTENT_DISPOSITION_FILE_HEADER = "Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"";
3939

4040
private static final String CONTENT_TYPE_HEADER = "Content-Type: %s";
4141

@@ -47,38 +47,37 @@ public class StandardMultipartFormDataStreamBuilder implements MultipartFormData
4747

4848
private static final String BOUNDARY_FORMAT = "FormDataBoundary-%s";
4949

50-
private static final String MULTIPART_FORM_DATA_FORMAT = "multipart/form-data; boundary=\"%s\"";
50+
private static final String MULTIPART_FORM_DATA_FORMAT = "multipart/form-data; boundary=%s";
5151

5252
private static final Charset HEADERS_CHARACTER_SET = StandardCharsets.US_ASCII;
5353

5454
private final String boundary = BOUNDARY_FORMAT.formatted(UUID.randomUUID());
5555

5656
private final List<Part> parts = new ArrayList<>();
5757

58-
/**
59-
* Build Sequence Input Stream from collection of Form Data Parts formatted with boundaries
60-
*
61-
* @return Input Stream
62-
*/
6358
@Override
6459
public InputStream build() {
6560
if (parts.isEmpty()) {
6661
throw new IllegalStateException("Parts required");
6762
}
6863

69-
final List<InputStream> partInputStreams = new ArrayList<>();
64+
final List<InputStream> streams = new ArrayList<>();
65+
for (int index = 0; index < parts.size(); index++) {
66+
final Part part = parts.get(index);
67+
final String boundaryPrefix = (index == 0 ? BOUNDARY_SEPARATOR + boundary + CARRIAGE_RETURN_LINE_FEED
68+
: CARRIAGE_RETURN_LINE_FEED + BOUNDARY_SEPARATOR + boundary + CARRIAGE_RETURN_LINE_FEED);
7069

71-
final Iterator<Part> selectedParts = parts.iterator();
72-
while (selectedParts.hasNext()) {
73-
final Part part = selectedParts.next();
74-
final String footer = getFooter(selectedParts);
75-
76-
final InputStream partInputStream = getPartInputStream(part, footer);
77-
partInputStreams.add(partInputStream);
70+
streams.add(new ByteArrayInputStream(boundaryPrefix.getBytes(HEADERS_CHARACTER_SET)));
71+
final String partHeaders = getPartHeaders(part);
72+
streams.add(new ByteArrayInputStream(partHeaders.getBytes(HEADERS_CHARACTER_SET)));
73+
streams.add(part.inputStream);
7874
}
7975

80-
final Enumeration<InputStream> enumeratedPartInputStreams = Collections.enumeration(partInputStreams);
81-
return new SequenceInputStream(enumeratedPartInputStreams);
76+
final String closingBoundary = CARRIAGE_RETURN_LINE_FEED + BOUNDARY_SEPARATOR + boundary + BOUNDARY_SEPARATOR + CARRIAGE_RETURN_LINE_FEED;
77+
streams.add(new ByteArrayInputStream(closingBoundary.getBytes(HEADERS_CHARACTER_SET)));
78+
79+
final Enumeration<InputStream> enumeratedStreams = Collections.enumeration(streams);
80+
return new SequenceInputStream(enumeratedStreams);
8281
}
8382

8483
/**
@@ -102,13 +101,23 @@ public HttpContentType getHttpContentType() {
102101
*/
103102
@Override
104103
public MultipartFormDataStreamBuilder addPart(final String name, final HttpContentType httpContentType, final InputStream inputStream) {
104+
return addPartInternal(name, null, httpContentType, inputStream);
105+
}
106+
107+
@Override
108+
public MultipartFormDataStreamBuilder addPart(final String name, final String fileName, final HttpContentType httpContentType, final InputStream inputStream) {
109+
final String sanitizedFileName = sanitizeFileName(fileName);
110+
return addPartInternal(name, sanitizedFileName, httpContentType, inputStream);
111+
}
112+
113+
private MultipartFormDataStreamBuilder addPartInternal(final String name, final String fileName, final HttpContentType httpContentType, final InputStream inputStream) {
105114
Objects.requireNonNull(name, "Name required");
106115
Objects.requireNonNull(httpContentType, "Content Type required");
107116
Objects.requireNonNull(inputStream, "Input Stream required");
108117

109118
final Matcher nameMatcher = ALLOWED_NAME_PATTERN.matcher(name);
110119
if (nameMatcher.matches()) {
111-
final Part part = new Part(name, httpContentType, inputStream);
120+
final Part part = new Part(name, fileName, httpContentType, inputStream);
112121
parts.add(part);
113122
} else {
114123
throw new IllegalArgumentException("Name contains characters outside of ASCII character set");
@@ -132,18 +141,19 @@ public MultipartFormDataStreamBuilder addPart(final String name, final HttpConte
132141
return addPart(name, httpContentType, inputStream);
133142
}
134143

135-
private InputStream getPartInputStream(final Part part, final String footer) {
136-
final String partHeaders = getPartHeaders(part);
137-
final InputStream headersInputStream = new ByteArrayInputStream(partHeaders.getBytes(HEADERS_CHARACTER_SET));
138-
final InputStream footerInputStream = new ByteArrayInputStream(footer.getBytes(HEADERS_CHARACTER_SET));
139-
final Enumeration<InputStream> inputStreams = Collections.enumeration(List.of(headersInputStream, part.inputStream, footerInputStream));
140-
return new SequenceInputStream(inputStreams);
144+
@Override
145+
public MultipartFormDataStreamBuilder addPart(final String name, final String fileName, final HttpContentType httpContentType, final byte[] bytes) {
146+
Objects.requireNonNull(bytes, "Byte Array required");
147+
final InputStream inputStream = new ByteArrayInputStream(bytes);
148+
return addPart(name, fileName, httpContentType, inputStream);
141149
}
142150

143151
private String getPartHeaders(final Part part) {
144152
final StringBuilder headersBuilder = new StringBuilder();
145153

146-
final String contentDispositionHeader = CONTENT_DISPOSITION_HEADER.formatted(part.name);
154+
final String contentDispositionHeader = part.fileName == null
155+
? CONTENT_DISPOSITION_HEADER.formatted(part.name)
156+
: CONTENT_DISPOSITION_FILE_HEADER.formatted(part.name, part.fileName);
147157
headersBuilder.append(contentDispositionHeader);
148158
headersBuilder.append(CARRIAGE_RETURN_LINE_FEED);
149159

@@ -156,21 +166,6 @@ private String getPartHeaders(final Part part) {
156166
return headersBuilder.toString();
157167
}
158168

159-
private String getFooter(final Iterator<Part> selectedParts) {
160-
final StringBuilder footerBuilder = new StringBuilder();
161-
footerBuilder.append(CARRIAGE_RETURN_LINE_FEED);
162-
footerBuilder.append(BOUNDARY_SEPARATOR);
163-
footerBuilder.append(boundary);
164-
if (selectedParts.hasNext()) {
165-
footerBuilder.append(CARRIAGE_RETURN_LINE_FEED);
166-
} else {
167-
// Add boundary separator after last part indicating end
168-
footerBuilder.append(BOUNDARY_SEPARATOR);
169-
}
170-
171-
return footerBuilder.toString();
172-
}
173-
174169
private record MultipartHttpContentType(String contentType) implements HttpContentType {
175170
@Override
176171
public String getContentType() {
@@ -180,8 +175,23 @@ public String getContentType() {
180175

181176
private record Part(
182177
String name,
178+
String fileName,
183179
HttpContentType httpContentType,
184180
InputStream inputStream
185181
) {
186182
}
183+
184+
private String sanitizeFileName(final String fileName) {
185+
if (fileName == null || fileName.isBlank()) {
186+
throw new IllegalArgumentException("File Name required");
187+
}
188+
189+
final String sanitized = fileName;
190+
final Matcher fileNameMatcher = ALLOWED_NAME_PATTERN.matcher(sanitized);
191+
if (!fileNameMatcher.matches()) {
192+
throw new IllegalArgumentException("File Name contains characters outside of ASCII character set");
193+
}
194+
195+
return sanitized;
196+
}
187197
}

nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketAuthenticationType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121

2222
public enum BitbucketAuthenticationType implements DescribedValue {
2323

24-
BASIC_AUTH("Basic Auth", """
24+
BASIC_AUTH("Basic Auth & API Token", """
2525
Username (not email) and App Password (https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/).
26+
Or email and API Token (https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/).
2627
Required permissions: repository, repository:read.
2728
"""),
2829

nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketFlowRegistryClient.java

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,19 @@
3434
@CapabilityDescription("Flow Registry Client that uses the Bitbucket REST API to version control flows in a Bitbucket Repository.")
3535
public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient {
3636

37-
static final PropertyDescriptor BITBUCKET_API_URL = new PropertyDescriptor.Builder()
38-
.name("Bitbucket API Instance")
39-
.description("The instance of the Bitbucket API")
40-
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
41-
.defaultValue("api.bitbucket.org")
37+
static final PropertyDescriptor FORM_FACTOR = new PropertyDescriptor.Builder()
38+
.name("Form Factor")
39+
.description("The Bitbucket deployment form factor")
40+
.allowableValues(BitbucketFormFactor.class)
41+
.defaultValue(BitbucketFormFactor.CLOUD.getValue())
4242
.required(true)
4343
.build();
4444

45-
static final PropertyDescriptor BITBUCKET_API_VERSION = new PropertyDescriptor.Builder()
46-
.name("Bitbucket API Version")
47-
.description("The version of the Bitbucket API")
48-
.defaultValue("2.0")
45+
static final PropertyDescriptor BITBUCKET_API_URL = new PropertyDescriptor.Builder()
46+
.name("Bitbucket API Instance")
47+
.description("The Bitbucket API host or base URL (for example, api.bitbucket.org for Cloud or https://bitbucket.example.com for Data Center)")
4948
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
49+
.defaultValue("api.bitbucket.org")
5050
.required(true)
5151
.build();
5252

@@ -55,6 +55,7 @@ public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient {
5555
.description("The name of the workspace that contains the repository to connect to")
5656
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
5757
.required(true)
58+
.dependsOn(FORM_FACTOR, BitbucketFormFactor.CLOUD)
5859
.build();
5960

6061
static final PropertyDescriptor REPOSITORY_NAME = new PropertyDescriptor.Builder()
@@ -64,9 +65,17 @@ public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient {
6465
.required(true)
6566
.build();
6667

68+
static final PropertyDescriptor PROJECT_KEY = new PropertyDescriptor.Builder()
69+
.name("Project Key")
70+
.description("The key of the Bitbucket project that contains the repository (required for Data Center)")
71+
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
72+
.required(true)
73+
.dependsOn(FORM_FACTOR, BitbucketFormFactor.DATA_CENTER)
74+
.build();
75+
6776
static final PropertyDescriptor AUTHENTICATION_TYPE = new PropertyDescriptor.Builder()
6877
.name("Authentication Type")
69-
.description("The type of authentication to use for accessing Bitbucket")
78+
.description("The type of authentication to use for accessing Bitbucket (Data Center supports only Access Token authentication)")
7079
.allowableValues(BitbucketAuthenticationType.class)
7180
.defaultValue(BitbucketAuthenticationType.ACCESS_TOKEN)
7281
.required(true)
@@ -92,7 +101,8 @@ public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient {
92101

93102
static final PropertyDescriptor APP_PASSWORD = new PropertyDescriptor.Builder()
94103
.name("App Password")
95-
.description("The App Password to use for authentication")
104+
.displayName("App Password or API Token")
105+
.description("The App Password or API Token to use for authentication")
96106
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
97107
.required(true)
98108
.sensitive(true)
@@ -116,9 +126,10 @@ public class BitbucketFlowRegistryClient extends AbstractGitFlowRegistryClient {
116126

117127
static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
118128
WEBCLIENT_SERVICE,
129+
FORM_FACTOR,
119130
BITBUCKET_API_URL,
120-
BITBUCKET_API_VERSION,
121131
WORKSPACE_NAME,
132+
PROJECT_KEY,
122133
REPOSITORY_NAME,
123134
AUTHENTICATION_TYPE,
124135
ACCESS_TOKEN,
@@ -136,14 +147,17 @@ protected List<PropertyDescriptor> createPropertyDescriptors() {
136147

137148
@Override
138149
protected GitRepositoryClient createRepositoryClient(final FlowRegistryClientConfigurationContext context) throws FlowRegistryException {
150+
final BitbucketFormFactor formFactor = context.getProperty(FORM_FACTOR).asAllowableValue(BitbucketFormFactor.class);
151+
139152
return BitbucketRepositoryClient.builder()
140153
.clientId(getIdentifier())
141154
.logger(getLogger())
155+
.formFactor(formFactor)
142156
.apiUrl(context.getProperty(BITBUCKET_API_URL).getValue())
143-
.apiVersion(context.getProperty(BITBUCKET_API_VERSION).getValue())
144157
.workspace(context.getProperty(WORKSPACE_NAME).getValue())
145158
.repoName(context.getProperty(REPOSITORY_NAME).getValue())
146159
.repoPath(context.getProperty(REPOSITORY_PATH).getValue())
160+
.projectKey(context.getProperty(PROJECT_KEY).getValue())
147161
.authenticationType(context.getProperty(AUTHENTICATION_TYPE).asAllowableValue(BitbucketAuthenticationType.class))
148162
.accessToken(context.getProperty(ACCESS_TOKEN).evaluateAttributeExpressions().getValue())
149163
.username(context.getProperty(USERNAME).evaluateAttributeExpressions().getValue())
@@ -160,7 +174,17 @@ public boolean isStorageLocationApplicable(FlowRegistryClientConfigurationContex
160174

161175
@Override
162176
protected String getStorageLocation(GitRepositoryClient repositoryClient) {
163-
final BitbucketRepositoryClient gitLabRepositoryClient = (BitbucketRepositoryClient) repositoryClient;
164-
return STORAGE_LOCATION_FORMAT.formatted(gitLabRepositoryClient.getWorkspace(), gitLabRepositoryClient.getRepoName());
177+
final BitbucketRepositoryClient bitbucketRepositoryClient = (BitbucketRepositoryClient) repositoryClient;
178+
179+
if (bitbucketRepositoryClient.getFormFactor() == BitbucketFormFactor.DATA_CENTER) {
180+
final String apiHost = bitbucketRepositoryClient.getApiHost();
181+
final String projectKey = bitbucketRepositoryClient.getProjectKey();
182+
if (apiHost != null && projectKey != null) {
183+
return "git@" + apiHost + ":" + projectKey + "/" + bitbucketRepositoryClient.getRepoName() + ".git";
184+
}
185+
return bitbucketRepositoryClient.getRepoName();
186+
}
187+
188+
return STORAGE_LOCATION_FORMAT.formatted(bitbucketRepositoryClient.getWorkspace(), bitbucketRepositoryClient.getRepoName());
165189
}
166190
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.nifi.atlassian.bitbucket;
19+
20+
import org.apache.nifi.components.DescribedValue;
21+
22+
public enum BitbucketFormFactor implements DescribedValue {
23+
24+
CLOUD("Cloud", "Use the Bitbucket Cloud REST API (uses API version 2.0)."),
25+
DATA_CENTER("Data Center", "Use the Bitbucket Data Center REST API (uses API version 1.0 and requires Project Key).");
26+
27+
private final String displayName;
28+
private final String description;
29+
30+
BitbucketFormFactor(final String displayName, final String description) {
31+
this.displayName = displayName;
32+
this.description = description;
33+
}
34+
35+
@Override
36+
public String getValue() {
37+
return name();
38+
}
39+
40+
@Override
41+
public String getDisplayName() {
42+
return displayName;
43+
}
44+
45+
@Override
46+
public String getDescription() {
47+
return description;
48+
}
49+
}

0 commit comments

Comments
 (0)