Last active
August 25, 2023 09:07
-
-
Save kennytv/a227d82249f54e0ad35005330256fee2 to your computer and use it in GitHub Desktop.
Hangar version uploader example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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!" | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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