Skip to content

Instantly share code, notes, and snippets.

@kennytv
Last active August 25, 2023 09:07
Show Gist options
  • Save kennytv/a227d82249f54e0ad35005330256fee2 to your computer and use it in GitHub Desktop.
Save kennytv/a227d82249f54e0ad35005330256fee2 to your computer and use it in GitHub Desktop.
Hangar version uploader example
package eu.kennytv.test.hangar;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import eu.kennytv.test.hangar.VersionUpload.MultipartFileOrUrl;
import eu.kennytv.test.hangar.VersionUpload.Platform;
import eu.kennytv.test.hangar.VersionUpload.PluginDependency;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.entity.mime.FileBody;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.entity.mime.StringBody;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpMessage;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class HangarVersionUploader {
private static final String HANGAR_API_PRODUCTION = "https://hangar.papermc.io/api/v1";
private static final String HANGAR_API_STAGING = "https://hangar.papermc.io/api/v1";
private static final String HANGAR_API_LOCAL = "http://localhost:3333/api/v1";
private static final Logger LOGGER = LoggerFactory.getLogger(HangarVersionUploader.class);
private static final String HANGAR_API_URL = HANGAR_API_STAGING; // TODO: Use appropriate URL
private static final Gson GSON = new Gson();
private final String apiKey;
private ActiveJWT activeJWT;
public HangarVersionUploader(final String apiKey) {
this.apiKey = apiKey;
}
public static void main(final String[] args) throws IOException {
// TODO: Get the API key from the Profile Dropdown -> Settings -> API Keys
// TODO: Insert your own project's namespace, dependencies, and version data
final String projectName = "YourUniqueProjectName";
final String apiKey = "API KEY GOES HERE";
final List<Path> filePaths = List.of(Path.of("YourPluginJar.jar"));
final List<PluginDependency> paperPluginDependencies = List.of(
PluginDependency.createWithHangarNamespace("ViaVersion", true),
PluginDependency.createWithUrl("Maintenance", "https://github.com/kennytv/Maintenance", false)
);
final List<MultipartFileOrUrl> fileInfo = List.of(
new MultipartFileOrUrl(List.of(Platform.PAPER), null), // Since the url is null here, the file from the filePaths list will be used
new MultipartFileOrUrl(List.of(Platform.WATERFALL, Platform.VELOCITY), "https://somedownloadurl.test")
);
final VersionUpload versionUpload = new VersionUpload(
"1.0.0",
Map.of(
Platform.PAPER, paperPluginDependencies,
Platform.WATERFALL, List.of(),
Platform.VELOCITY, List.of()
),
Map.of(
Platform.PAPER, List.of("1.18", "1.19"),
Platform.WATERFALL, List.of("1.19"),
Platform.VELOCITY, List.of("3.1")
),
"Cool description!",
fileInfo,
"Release" // Make sure you never publish unstable or ongoing development builds to the release channel
);
final HangarVersionUploader uploader = new HangarVersionUploader(apiKey);
try (final CloseableHttpClient client = HttpClients.createDefault()) {
uploader.uploadVersion(client, projectName, versionUpload, filePaths);
}
}
/**
* Uploads a new version to Hangar.
*
* @param client http client to use
* @param project unique project name
* @param versionUpload version upload data
* @param filePaths paths to the files to upload for platforms without external urls
* @throws IOException if an error occurs while uploading
*/
public void uploadVersion(
final HttpClient client,
final String project,
final VersionUpload versionUpload,
final List<Path> filePaths
) throws IOException {
// The data needs to be sent as multipart form data
final MultipartEntityBuilder builder = MultipartEntityBuilder.create();
builder.addPart("versionUpload", new StringBody(GSON.toJson(versionUpload), ContentType.APPLICATION_JSON));
// Attach files (one file for each platform where no external url is defined in the version upload data)
for (final Path filePath : filePaths) {
builder.addPart("files", new FileBody(filePath.toFile(), ContentType.DEFAULT_BINARY));
}
final HttpPost post = new HttpPost("%s/projects/%s/upload".formatted(HANGAR_API_URL, project));
post.setEntity(builder.build());
this.addAuthorizationHeader(client, post);
final boolean success = client.execute(post, response -> {
if (response.getCode() != 200) {
LOGGER.error("Error uploading version {}: {}", response.getCode(), response.getReasonPhrase());
return false;
}
return true;
});
if (!success) {
throw new RuntimeException("Error uploading version");
}
}
private synchronized void addAuthorizationHeader(final HttpClient client, final HttpMessage message) throws IOException {
if (this.activeJWT != null && !this.activeJWT.hasExpired()) {
// Add the active JWT
message.addHeader("Authorization", this.activeJWT.jwt());
return;
}
// Request a new JWT
final ActiveJWT jwt = client.execute(new HttpPost("%s/authenticate?apiKey=%s".formatted(HANGAR_API_URL, this.apiKey)), response -> {
if (response.getCode() == 400) {
LOGGER.error("Bad JWT request; is the API key correct?");
return null;
} else if (response.getCode() != 200) {
LOGGER.error("Error requesting JWT {}: {}", response.getCode(), response.getReasonPhrase());
return null;
}
final String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
final JsonObject object = GSON.fromJson(json, JsonObject.class);
final String token = object.getAsJsonPrimitive("token").getAsString();
final long expiresIn = object.getAsJsonPrimitive("expiresIn").getAsLong();
return new ActiveJWT(token, System.currentTimeMillis() + expiresIn);
});
if (jwt == null) {
throw new RuntimeException("Error getting JWT");
}
this.activeJWT = jwt;
message.addHeader("Authorization", jwt.jwt());
}
/**
* Represents an active JSON Web Token used for authentication with Hangar.
*
* @param jwt Active JWT
* @param expiresAt time in milliseconds when the JWT expires
*/
private record ActiveJWT(String jwt, long expiresAt) {
public boolean hasExpired() {
// Make sure we request a new one before it expires
return System.currentTimeMillis() < this.expiresAt + TimeUnit.SECONDS.toMillis(3);
}
}
}
{
"version": "1.0.0",
"pluginDependencies": {
"PAPER": [
{
"name": "ViaBackwards",
"required": true,
"namespace": {
"owner": "ViaVersion",
"slug": "ViaBackwards"
}
},
{
"name": "Maintenance",
"required": false,
"externalUrl": "https://github.com/kennytv/Maintenance"
}
],
"WATERFALL": [
]
},
"platformDependencies": {
"PAPER": [
"1.18",
"1.19"
],
"WATERFALL": [
"1.19"
]
},
"files": [
{
"platforms": [
"PAPER"
]
},
{
"platforms": [
"WATERFALL"
],
"externalUrl": "https://somedownloadurl.com"
}
],
"channel": "Release",
"description": "My cool description!"
}
package eu.kennytv.test.hangar;
import java.util.List;
import java.util.Map;
import org.checkerframework.checker.nullness.qual.Nullable;
public record VersionUpload(String version, Map<Platform, List<PluginDependency>> pluginDependencies,
Map<Platform, List<String>> platformDependencies, String description,
List<MultipartFileOrUrl> files, String channel) {
public record PluginDependency(String name, boolean required, @Nullable String externalUrl) {
/**
* Creates a new PluginDependency with the given name, whether the dependency is required, and the namespace of the dependency.
*
* @param hangarProjectName name of the dependency, being its Hangar project id
* @param required whether the dependency is required
* @return a new PluginDependency
*/
public static PluginDependency createWithHangarNamespace(final String hangarProjectName, final boolean required) {
return new PluginDependency(hangarProjectName, required, null);
}
/**
* Creates a new PluginDependency with the given name, external url, and whether the dependency is required.
*
* @param name name of the dependency
* @param required whether the dependency is required
* @param externalUrl url to the dependency
* @return a new PluginDependency
*/
public static PluginDependency createWithUrl(final String name, final String externalUrl, final boolean required) {
return new PluginDependency(name, required, externalUrl);
}
}
/**
* Represents a file that is either uploaded or downloaded from an external url.
*
* @param platforms platforms the download is compatible with
* @param externalUrl external url of the download, or null if the download is a file
*/
public record MultipartFileOrUrl(List<Platform> platforms, @Nullable String externalUrl) {
}
public enum Platform {
PAPER,
WATERFALL,
VELOCITY
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment