Skip to content

Commit

Permalink
Add support for Bitbucket Cloud and Server (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedtwigg authored Jun 23, 2021
2 parents 6ed8d42 + 8d3bc3d commit d0e91a9
Show file tree
Hide file tree
Showing 8 changed files with 437 additions and 13 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Support for Bitbucket Cloud and Server ([#23](https://github.com/diffplug/blowdryer/pull/23)).

## [1.2.1] - 2021-06-01
### Fixed
- `repoSubfolder` doesn't do anything in `localJar` mode, so setting `repoSubfolder` ought to be an error, and now it is ([#22](https://github.com/diffplug/blowdryer/pull/22)).
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,15 @@ blowdryerSetup {
github('acme/blowdryer-acme', 'tag', 'v1.4.5')
// or 'commit', '07f588e52eb0f31e596eab0228a5df7233a98a14'
// or 'tree', 'a5df7233a98a1407f588e52eb0f31e596eab0228'
// or gitlab('acme/blowdryer-acme', 'tag', 'v1.4.5').customDomainHttp('acme.org').authToken('abc123')
// or gitlab('acme/blowdryer-acme', 'tag', 'v1.4.5').authToken('abc123').customDomainHttp('acme.org')
// or bitbucket('acme/blowdryer-acme', 'tag', 'v1.4.5').authToken('abc123').customDomainHttps('acme.org')
}
```
* Reference on how to create [application password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/)
for Bitbucket Cloud private repo access.<br/>
* Reference on how to create [personal access token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html)
for Bitbucket Server private repo access.

Now, in *only* your root `build.gradle`, do this: `apply plugin: 'com.diffplug.blowdryer'`. Now, in any project throughout your gradle build (including subprojects), you can do this:

Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ apply from: 干.file('spotless/java.gradle')
dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.2.2'
implementation 'com.squareup.okio:okio:2.4.1'
implementation 'com.google.code.gson:gson:2.8.7'
implementation 'com.diffplug.durian:durian-core:1.2.0'
implementation 'com.diffplug.durian:durian-io:1.2.0'
testImplementation 'junit:junit:4.12'
testImplementation 'org.assertj:assertj-core:3.14.0'
testImplementation 'org.mockito:mockito-core:3.11.2'
}
22 changes: 20 additions & 2 deletions src/main/java/com/diffplug/blowdryer/Blowdryer.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,14 @@ public static File immutableUrl(String url) {
if (metaFile.exists() && dataFile.exists()) {
Map<String, String> props = loadPropertyFile(metaFile);
String propUrl = props.get(PROP_URL);
if (propUrl == null) {
throw new IllegalArgumentException("Unexpected content, recommend deleting file at " + metaFile);
}
if (propUrl.equals(url)) {
urlToContent.put(url, dataFile);
return dataFile;
} else {
throw new IllegalStateException("Expected url " + url + " but was " + propUrl + " for " + metaFile.getAbsolutePath());
throw new IllegalStateException("Expected url " + url + " but was " + propUrl + ", recommend deleting file at " + metaFile.getAbsolutePath());
}
} else {
Files.createParentDirs(dataFile);
Expand Down Expand Up @@ -191,6 +194,7 @@ private static void downloadRemote(String url, File dst) throws IOException {

/** Returns either the filename safe URL, or (first40)--(Base64 filenamesafe)(last40). */
static String filenameSafe(String url) {
url = preserveFileExtensionBitbucket(url);
String allSafeCharacters = url.replaceAll("[^a-zA-Z0-9-+_.]", "-");
String noDuplicateDash = allSafeCharacters.replaceAll("-+", "-");
if (noDuplicateDash.length() <= MAX_FILE_LENGTH) {
Expand All @@ -209,6 +213,19 @@ static String filenameSafe(String url) {
}
}

// preserve the filename and extension if query parameters are present in original url.
// required to retrieve XML files.
// From: https://mycompany.bitbucket.com/projects/PRJ/repos/my-repo/raw/src/main/resources/checkstyle/spotless.gradle?at=07f588e52eb0f31e596eab0228a5df7233a98a14
// To: https://mycompany.bitbucket.com/projects/PRJ/repos/my-repo/raw/src/main/resources/checkstyle/spotless.gradle?at=07f588e52eb0f31e596eab0228a5df7233a98a14-spotless.gradle
private static String preserveFileExtensionBitbucket(String url) {
int atIdx = url.indexOf("?at=");
if (atIdx != -1) {
String fileNameWithoutQuery = url.substring(0, atIdx);
url = String.format("%s-%s", url, fileNameWithoutQuery.substring(fileNameWithoutQuery.lastIndexOf("/") + 1));
}
return url;
}

//////////////////////
// plugin interface //
//////////////////////
Expand Down Expand Up @@ -253,7 +270,8 @@ private static void assertInitialized() {
}
}

static interface AuthPlugin {
@FunctionalInterface
interface AuthPlugin {
void addAuthToken(String url, Request.Builder builder) throws MalformedURLException;
}

Expand Down
207 changes: 203 additions & 4 deletions src/main/java/com/diffplug/blowdryer/BlowdryerSetup.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,26 @@
package com.diffplug.blowdryer;


import com.diffplug.common.annotations.VisibleForTesting;
import com.diffplug.common.base.Errors;
import com.diffplug.common.base.Unhandled;
import com.google.gson.Gson;
import groovy.lang.Closure;
import java.io.File;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Request.Builder;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.jetbrains.annotations.NotNull;

/** Configures where {@link Blowdryer#file(String)} downloads files from. */
Expand All @@ -32,6 +44,10 @@ public class BlowdryerSetup {

private static final String GITHUB_HOST = "raw.githubusercontent.com";
private static final String GITLAB_HOST = "gitlab.com";
private static final String BITBUCKET_HOST = "api.bitbucket.org/2.0/repositories";

private static final String HTTP_PROTOCOL = "http://";
private static final String HTTPS_PROTOCOL = "https://";

private final File referenceDirectory;

Expand All @@ -46,7 +62,7 @@ public BlowdryerSetup(File referenceDirectory) {

/**
* Default value is `src/main/resources`. If you change, you must change as the *first* call.
*
*
* The nice thing about the default `src/main/resources` is that if you ever want to, you could
* copy the blowdryer code into your blowdryer repo, and deploy your own plugin that pulls resources
* from the local jar rather than from github. Keeping the default lets you switch to that approach
Expand Down Expand Up @@ -86,7 +102,7 @@ public GitHub authToken(String authToken) {

private GitHub setGlobals() {
Blowdryer.setResourcePluginNull();
String root = "https://" + GITHUB_HOST + "/" + repoOrg + "/" + anchor + "/";
String root = HTTPS_PROTOCOL + GITHUB_HOST + "/" + repoOrg + "/" + anchor + "/";
Blowdryer.setResourcePlugin(resource -> root + getFullResourcePath(resource), authToken == null ? null : (url, builder) -> {
if (url.startsWith(root)) {
builder.addHeader("Authorization", "Bearer " + authToken);
Expand Down Expand Up @@ -121,11 +137,11 @@ public GitLab authToken(String authToken) {
}

public GitLab customDomainHttp(String domain) {
return customProtocolAndDomain("http://", domain);
return customProtocolAndDomain(HTTP_PROTOCOL, domain);
}

public GitLab customDomainHttps(String domain) {
return customProtocolAndDomain("https://", domain);
return customProtocolAndDomain(HTTPS_PROTOCOL, domain);
}

private GitLab customProtocolAndDomain(String protocol, String domain) {
Expand All @@ -147,6 +163,188 @@ private GitLab setGlobals() {
}
}

public enum BitbucketType {
CLOUD, SERVER
}

/** Sets the source where we will grab these scripts. */
public Bitbucket bitbucket(String repoOrg, GitAnchorType anchorType, String anchor) {
return new Bitbucket(repoOrg, anchorType, anchor, BitbucketType.CLOUD);
}

public class Bitbucket {

private String repoOrg;
private String repoName;
private String anchor;
private GitAnchorType anchorType;
private BitbucketType bitbucketType;
private @Nullable String auth;
private @Nullable String authToken;
private String protocol, host;

private Bitbucket(String repoOrg, GitAnchorType anchorType, String anchor, BitbucketType bitbucketType) {
Blowdryer.assertPluginNotSet();
final String[] repoOrgAndName = assertNoLeadingOrTrailingSlash(repoOrg).split("/");
if (repoOrgAndName.length != 2) {
throw new IllegalArgumentException("repoOrg must be in format 'repoOrg/repoName'");
}
this.repoOrg = repoOrgAndName[0];
this.repoName = repoOrgAndName[1];
this.anchorType = anchorType;
this.bitbucketType = bitbucketType;
this.anchor = assertNoLeadingOrTrailingSlash(anchor);
customProtocolAndDomain(BitbucketType.CLOUD, HTTPS_PROTOCOL, BITBUCKET_HOST);
}

public Bitbucket authToken(String auth) {
this.auth = auth;
return setGlobals();
}

public Bitbucket customDomainHttp(String domain) {
return customProtocolAndDomain(BitbucketType.SERVER, HTTP_PROTOCOL, domain);
}

public Bitbucket customDomainHttps(String domain) {
return customProtocolAndDomain(BitbucketType.SERVER, HTTPS_PROTOCOL, domain);
}

private Bitbucket customProtocolAndDomain(BitbucketType type, String protocol, String domain) {
this.bitbucketType = type;
this.protocol = protocol;
this.host = domain;
return setGlobals();
}

private Bitbucket setGlobals() {
if (auth == null) {
authToken = null;
} else {
switch (bitbucketType) {
case SERVER:
authToken = String.format("Bearer %s", auth);
break;
case CLOUD:
String base64 = Base64.getEncoder().encodeToString((auth).getBytes(StandardCharsets.UTF_8));
authToken = String.format("Basic %s", base64);
break;
default:
throw Unhandled.enumException(bitbucketType);
}
}
Blowdryer.setResourcePluginNull();
String urlStart = getUrlStart();
Blowdryer.setResourcePlugin(resource -> getFullUrl(urlStart, encodeUrlParts(getFullResourcePath(resource))), (url, builder) -> {
if (authToken != null) {
builder.addHeader("Authorization", authToken);
}
});
return this;
}

private String getUrlStart() {
// Bitbucket Cloud and Bitbucket Server (premium, company hosted) has different url structures.
// Bitbucket Cloud uses "org/repo" in URLs, where org is your (or someone else's) account name.
// Bitbucket Server uses "projects/PROJECT_KEY/repos/REPO_NAME" in urls.
if (isServer()) {
return String.format("%s%s/projects/%s/repos/%s", protocol, host, repoOrg, repoName);
} else {
return String.format("%s%s/%s/%s", protocol, host, repoOrg, repoName);
}
}

private String getFullUrl(String urlStart, String filePath) {
if (isServer()) {
return String.format("%s/raw/%s?at=%s", urlStart, filePath, encodeUrlPart(getAnchorForServer()));
} else {
return String.format("%s/src/%s/%s", urlStart, encodeUrlParts(getAnchorForCloud()), filePath);
}
}

private boolean isServer() {
return BitbucketType.SERVER.equals(this.bitbucketType);
}

private String getAnchorForServer() {
switch (anchorType) {
case COMMIT:
return anchor;
case TAG:
return "refs/tags/" + anchor;
default:
throw new UnsupportedOperationException(anchorType + " not supported for Bitbucket");
}
}

private String getAnchorForCloud() {
switch (anchorType) {
case COMMIT:
return anchor;
case TAG:
// rewrite the tag into the commit it points to
anchor = getCommitHash("refs/tags/");
anchorType = GitAnchorType.COMMIT;
return anchor;
default:
throw new UnsupportedOperationException(anchorType + " not supported for Bitbucket");
}
}

// Bitbucket API: https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Bworkspace%7D/%7Brepo_slug%7D/src/%7Bcommit%7D/%7Bpath%7D
private String getCommitHash(String baseRefs) {
String requestUrl = String.format("%s/%s%s", getUrlStart(), baseRefs, encodeUrlParts(anchor));

return getCommitHashFromBitbucket(requestUrl);
}

@VisibleForTesting
String getCommitHashFromBitbucket(String requestUrl) {
OkHttpClient client = new OkHttpClient.Builder().build();
Builder requestBuilder = new Builder().url(requestUrl);
if (authToken != null) {
requestBuilder.addHeader("Authorization", authToken);
}
Request request = requestBuilder.build();

try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IllegalArgumentException(String.format("%s\nreceived http code %s \n %s", request.url(), response.code(),
Objects.requireNonNull(response.body()).string()));
}
try (ResponseBody body = response.body()) {
RefsTarget refsTarget = new Gson().fromJson(Objects.requireNonNull(body).string(), RefsTarget.class);
return refsTarget.target.hash;
}
} catch (Exception e) {
throw new IllegalArgumentException("Body was expected to be non-null", e);
}
}

// Do not encode '/'.
private String encodeUrlParts(String part) {
return Arrays.stream(part.split("/"))
.map(BlowdryerSetup::encodeUrlPart)
.collect(Collectors.joining("/"));
}

private class RefsTarget {
private final Target target;

private RefsTarget(Target target) {
this.target = target;
}

private class Target {
private final String hash;

private Target(String hash) {
this.hash = hash;
}
}
}
}

/**
* Uses the provided {@code jarFile} to extract a file resource.
* @param jarFile Absolute path to JAR on the file system.
Expand Down Expand Up @@ -214,4 +412,5 @@ private static String encodeUrlPart(String part) {
throw new IllegalArgumentException("error encoding part", e);
}
}

}
Loading

0 comments on commit d0e91a9

Please sign in to comment.