Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Bitbucket Cloud and Server #23

Merged
merged 14 commits into from
Jun 23, 2021
Merged
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