0.1.1 | Scheduler shenanigans

This commit is contained in:
Patrick
2026-06-05 14:08:07 +02:00
parent f9fa1af963
commit dcd8c8597d
9 changed files with 302 additions and 103 deletions
+36 -27
View File
@@ -1,41 +1,50 @@
/*
* This file was generated by the Gradle 'init' task.
*
* This generated file contains a sample Java library project to get you started.
* For more details on building Java & JVM projects, please refer to https://docs.gradle.org/9.5.1/userguide/building_java_projects.html in the Gradle documentation.
*/
plugins {
// Apply the java-library plugin for API and implementation separation.
id 'java-library'
id 'java'
id 'maven-publish'
}
repositories {
// Use Maven Central for resolving dependencies.
mavenCentral()
}
group = 'de.winniepat'
version = '0.1.1'
dependencies {
// Use JUnit Jupiter for testing.
testImplementation libs.junit.jupiter
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// This dependency is exported to consumers, that is to say found on their compile classpath.
api libs.commons.math3
// This dependency is used internally, and not exposed to consumers on their own compile classpath.
implementation libs.guava
}
// Apply a specific Java toolchain to ease working on different environments.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
withSourcesJar()
withJavadocJar()
}
repositories {
mavenCentral()
maven {url = "https://repo.papermc.io/repository/maven-public/" }
}
dependencies {
implementation 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT'
implementation 'com.google.code.gson:gson:2.13.1'
}
tasks.named('test') {
// Use JUnit Platform for unit tests.
useJUnitPlatform()
}
publishing {
publications {
mavenJava(MavenPublication) {
from components.java
}
}
repositories {
maven {
url = uri("https://maven.winniepat.de/repository/maven-releases/")
credentials {
username = project.property("publish.username")
password = project.property("publish.password")
// the credentials from before are not valid anymore (I am not that dumb)
}
}
}
}
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2026 WinniePatGG
*
* Licensed under the MIT License
*/
package licenselib;
import com.google.gson.*;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* A client for checking plugin licenses against a license server.
*/
public class LicenseClient {
private static final Gson GSON = new Gson();
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
/**
* Private constructor to prevent instantiation of this utility class.
*/
private LicenseClient() {
throw new UnsupportedOperationException("Utility class");
}
/**
* Checks the validity of a license key for a given plugin and server ID against the license server API.
* @param apiUrl Server API url
* @param plugin Plugin id matching the one on the backend api
* @param licenseKey License key issued by the api
* @param serverId Server id matching the one on the backend api
* @return LicenseResult with the data from the backend
*/
public static LicenseResult check(String apiUrl, String plugin, String licenseKey, String serverId) {
try {
HttpRequest request = createRequest(apiUrl, plugin, licenseKey, serverId);
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
return fromHttpResponse(response);
} catch (Exception e) {
return errorResult(e);
}
}
/**
* Asynchronously checks the validity of a license key for a given plugin and server ID against the license server API.
* @param apiUrl Server API url
* @param plugin Plugin id matching the one on the backend api
* @param licenseKey License key issued by the api
* @param serverId Server id matching the one on the backend api
* @return CompletableFuture with the LicenseResult from the backend
*/
public static CompletableFuture<LicenseResult> checkAsync(String apiUrl, String plugin, String licenseKey, String serverId) {
try {
HttpRequest request = createRequest(apiUrl, plugin, licenseKey, serverId);
return HTTP_CLIENT
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(LicenseClient::fromHttpResponse)
.exceptionally(LicenseClient::errorResult);
} catch (Exception e) {
return CompletableFuture.completedFuture(errorResult(e));
}
}
/**
* Represents the result of a license check, which can be either a success with license details or an error with a message.
*/
public sealed interface LicenseResult permits LicenseSuccess, LicenseError {}
private static HttpRequest createRequest(
String apiUrl,
String plugin,
String licenseKey,
String serverId
) {
URI uri = validateUrl(apiUrl);
JsonObject payload = new JsonObject();
payload.addProperty("plugin", plugin);
payload.addProperty("licenseKey", licenseKey);
if (serverId != null) {
payload.addProperty("serverId", serverId);
}
return HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(payload)))
.build();
}
private static LicenseResult errorResult(Throwable throwable) {
String message = buildErrorMessage(throwable);
return new LicenseError(message);
}
private static String getString(JsonObject json, String key, String def) {
return json.has(key) && !json.get(key).isJsonNull()
? json.get(key).getAsString()
: def;
}
private static LicenseResult fromHttpResponse(HttpResponse<String> response) {
if (response.statusCode() != 200) {
return errorResult(new RuntimeException(
"HTTP " + response.statusCode() + ": " + response.body()
));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
boolean valid = json.has("valid") && json.get("valid").getAsBoolean();
return new LicenseSuccess(
valid,
getString(json, "status", "unknown"),
getString(json, "message", ""),
getString(json, "plugin", null),
getString(json, "customer", null),
getString(json, "expiresAt", null),
getString(json, "checkedAt", null)
);
}
private static URI validateUrl(String apiUrl) {
URI uri = URI.create(apiUrl);
if (uri.getScheme() == null || (!uri.getScheme().equals("http") && !uri.getScheme().equals("https"))) {
throw new IllegalArgumentException("Invalid URL scheme: " + apiUrl);
}
if (uri.getHost() == null) {
throw new IllegalArgumentException("Invalid URL (missing host): " + apiUrl);
}
return uri;
}
private static String buildErrorMessage(Throwable t) {
Throwable root = t;
while (root.getCause() != null) {
root = root.getCause();
}
String msg = root.getMessage();
if (msg == null || msg.isBlank()) {
msg = root.getClass().getSimpleName();
}
return root.getClass().getSimpleName() + ": " + msg;
}
}
@@ -0,0 +1,10 @@
package licenselib;
/**
* Represents an error that occurred during the license check process, such as network issues or invalid responses from the server.
* @param message A descriptive error message providing details about the failure.
*/
public record LicenseError(
String message
) implements LicenseClient.LicenseResult {
}
@@ -0,0 +1,194 @@
package licenselib;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* Periodically checks a license and exposes the most recent result.
*/
public final class LicenseScheduler implements AutoCloseable {
private final ScheduledExecutorService executor;
private final ScheduledFuture<?> task;
private final AtomicReference<LicenseClient.LicenseResult> lastResult;
private LicenseScheduler(
ScheduledExecutorService executor,
ScheduledFuture<?> task,
AtomicReference<LicenseClient.LicenseResult> lastResult
) {
this.executor = executor;
this.task = task;
this.lastResult = lastResult;
}
/**
* Starts a scheduler that checks the license at the given interval.
* @param interval Interval between checks
* @param apiUrl Server API url
* @param plugin Plugin id matching the one on the backend api
* @param licenseKey License key issued by the api
* @param serverId Server id matching the one on the backend api
* @param onResult Optional callback invoked after every check
* @return Scheduler handle for stopping and reading the last result
*/
public static LicenseScheduler startScheduler(
Duration interval,
String apiUrl,
String plugin,
String licenseKey,
String serverId,
Consumer<LicenseClient.LicenseResult> onResult
) {
Objects.requireNonNull(interval, "interval");
if (interval.isZero() || interval.isNegative()) {
throw new IllegalArgumentException("interval must be positive");
}
AtomicReference<LicenseClient.LicenseResult> lastResult = new AtomicReference<>();
ThreadFactory threadFactory = runnable -> {
Thread thread = new Thread(runnable, "license-scheduler");
thread.setDaemon(true);
return thread;
};
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(threadFactory);
ScheduledFuture<?> task = executor.scheduleAtFixedRate(() -> {
LicenseClient.LicenseResult result = LicenseClient.check(apiUrl, plugin, licenseKey, serverId);
lastResult.set(result);
if (onResult != null) {
onResult.accept(result);
}
}, 0, interval.toMillis(), TimeUnit.MILLISECONDS);
return new LicenseScheduler(executor, task, lastResult);
}
/**
* Starts a scheduler that retries on errors before invoking offline handling.
* @param interval Interval between checks
* @param apiUrl Server API url
* @param plugin Plugin id matching the one on the backend api
* @param licenseKey License key issued by the api
* @param serverId Server id matching the one on the backend api
* @param maxRetries Number of retries after a failed check
* @param retryDelay Delay between retries
* @param onResult Optional callback invoked after every attempt
* @param onOffline Invoked only after all retries fail
* @return Scheduler handle for stopping and reading the last result
*/
public static LicenseScheduler startSchedulerWithRetries(
Duration interval,
String apiUrl,
String plugin,
String licenseKey,
String serverId,
int maxRetries,
Duration retryDelay,
Consumer<LicenseClient.LicenseResult> onResult,
Consumer<LicenseClient.LicenseResult> onOffline
) {
Objects.requireNonNull(interval, "interval");
Objects.requireNonNull(retryDelay, "retryDelay");
if (interval.isZero() || interval.isNegative()) {
throw new IllegalArgumentException("interval must be positive");
}
if (retryDelay.isNegative()) {
throw new IllegalArgumentException("retryDelay must not be negative");
}
if (maxRetries < 0) {
throw new IllegalArgumentException("maxRetries must not be negative");
}
AtomicReference<LicenseClient.LicenseResult> lastResult = new AtomicReference<>();
ThreadFactory threadFactory = runnable -> {
Thread thread = new Thread(runnable, "license-scheduler");
thread.setDaemon(true);
return thread;
};
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(threadFactory);
ScheduledFuture<?> task = executor.scheduleAtFixedRate(() -> {
LicenseClient.LicenseResult result = attemptWithRetries(
apiUrl,
plugin,
licenseKey,
serverId,
maxRetries,
retryDelay,
onResult,
onOffline
);
lastResult.set(result);
}, 0, interval.toMillis(), TimeUnit.MILLISECONDS);
return new LicenseScheduler(executor, task, lastResult);
}
private static LicenseClient.LicenseResult attemptWithRetries(
String apiUrl,
String plugin,
String licenseKey,
String serverId,
int maxRetries,
Duration retryDelay,
Consumer<LicenseClient.LicenseResult> onResult,
Consumer<LicenseClient.LicenseResult> onOffline
) {
int attempt = 0;
LicenseClient.LicenseResult result;
while (true) {
result = LicenseClient.check(apiUrl, plugin, licenseKey, serverId);
if (onResult != null) {
onResult.accept(result);
}
if (!(result instanceof LicenseError)) {
return result;
}
if (attempt >= maxRetries) {
if (onOffline != null) {
onOffline.accept(result);
}
return result;
}
attempt++;
if (!retryDelay.isZero()) {
try {
Thread.sleep(retryDelay.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return result;
}
}
}
}
/**
* Returns the most recent license check result, or null if none has completed yet.
* @return The last license check result, or null if not available
*/
public LicenseClient.LicenseResult getLastResult() {
return lastResult.get();
}
/**
* Stops further checks and shuts down the scheduler.
*/
public void stop() {
task.cancel(false);
executor.shutdown();
}
@Override
public void close() {
stop();
}
}
@@ -0,0 +1,22 @@
package licenselib;
/**
* Represents a successful license check result, containing all relevant information about the license status, plugin, customer, and expiration details.
* @param valid Indicates whether the license is valid or not.
* @param status A string representing the status of the license, such as "valid", "invalid", "expired", etc.
* @param message A message providing additional information about the license status, which can be used for logging or debugging purposes.
* @param plugin The name of the plugin associated with the license, which can be used to identify which plugin the license check was performed for.
* @param customer The name of the customer or organization that owns the license, which can be useful for tracking and support purposes.
* @param expiresAt The expiration date of the license in ISO 8601 format (e.g., "2024-12-31T23:59:59Z"), which indicates when the license will no longer be valid.
* @param checkedAt The date and time when the license check was performed, also in ISO 8601 format, which can be used to determine how recent the license information is.
*/
public record LicenseSuccess(
boolean valid,
String status,
String message,
String plugin,
String customer,
String expiresAt,
String checkedAt
) implements LicenseClient.LicenseResult {
}
@@ -1,10 +0,0 @@
/*
* This source file was generated by the Gradle 'init' task
*/
package org.example;
public class Library {
public boolean someLibraryMethod() {
return true;
}
}
@@ -1,14 +0,0 @@
/*
* This source file was generated by the Gradle 'init' task
*/
package org.example;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LibraryTest {
@Test void someLibraryMethodReturnsTrue() {
Library classUnderTest = new Library();
assertTrue(classUnderTest.someLibraryMethod(), "someLibraryMethod should return 'true'");
}
}