Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import net.buildtheearth.buildteamtools.BuildTeamTools;
import net.buildtheearth.buildteamtools.modules.Module;
import net.buildtheearth.buildteamtools.modules.common.commands.BttCommandManager;
import net.buildtheearth.buildteamtools.modules.network.api.NominatimAPI;
import net.buildtheearth.buildteamtools.modules.network.api.PhotonAPI;
import net.buildtheearth.buildteamtools.modules.network.model.Permissions;
import net.buildtheearth.model.GeographicalCoordinate;
Expand All @@ -16,6 +17,7 @@
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.incendo.cloud.parser.flag.CommandFlag;
import org.incendo.cloud.permission.Permission;
import org.incendo.cloud.parser.standard.StringParser;
import org.jspecify.annotations.NonNull;
Expand All @@ -42,11 +44,14 @@ public void register(Module owner) {
});
commandManager.registerSubcommand(owner, COMMAND_NAME, "get",
"Look up the address at your current location.",
builder -> builder.permission(Permissions.ADDRESS_GET),
builder -> builder
.permission(Permissions.ADDRESS_GET)
.flag(CommandFlag.builder("international").build()),
context -> {
if (context.sender().getSender() instanceof Player player)
handleGetCommand(player);
else
if (context.sender().getSender() instanceof Player player) {
boolean international = context.flags().isPresent("international");
handleGetCommand(player, international);
} else
context.sender().getSender().sendMessage(
ChatHelper.getErrorComponent(PLAYER_ONLY_MESSAGE));
});
Expand Down Expand Up @@ -74,7 +79,7 @@ private void execute(@NonNull CommandSender sender, String @NonNull [] args) {
player.sendMessage(ChatHelper.getErrorComponent("Usage: /btt address <get|teleport>"));
}

private void handleGetCommand(@NonNull Player player) {
private void handleGetCommand(@NonNull Player player, boolean international) {
if (!player.hasPermission(Permissions.ADDRESS_GET)) {
Permissions.sendNoPermissionMessage(player, Permissions.ADDRESS_GET);
return;
Expand All @@ -90,7 +95,7 @@ private void handleGetCommand(@NonNull Player player) {
loc.getZ()
);

PhotonAPI.getAddressFromCoordinatesAsync(geoCoord)
NominatimAPI.getAddressFromCoordinatesAsync(geoCoord, international)
.thenAccept(address -> {
if (address.isBlank()) {
player.sendMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,16 @@ public static String get(String url) throws IOException {
* @param url The URL to perform a get request on
*/
public static void getAsync(String url, ApiResponseCallback callback) {
Request request = new Request.Builder()
.url(url)
.build();
String userAgent = BuildTeamTools.getInstance().getMainConfig().userAgent();

Request.Builder requestBuilder = new Request.Builder()
.url(url);

if (userAgent != null && !userAgent.isBlank()) {
requestBuilder.header("User-Agent", userAgent);
}

Request request = requestBuilder.build();

httpClient.newCall(request).enqueue(new Callback() {
@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package net.buildtheearth.buildteamtools.modules.network.api;

import org.jetbrains.annotations.NotNull;

import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

/**
* Executes asynchronous requests in FIFO order while enforcing a minimum
* interval between consecutive request starts.
*
* <p>Requests do not need to finish before the next request can start.
* Each submitted operation has its own future, which completes when the
* underlying asynchronous operation completes.</p>
*
* <p>Each instance owns a dedicated daemon scheduler and an independent
* request queue.</p>
*/
public final class AsyncRequestQueue implements AutoCloseable {

private final long intervalMs;
private final int maxQueueSize;

private final ScheduledExecutorService scheduler;
private final Queue<QueuedRequest<?>> queue = new ArrayDeque<>();

private boolean dispatchScheduled;
private boolean closed;

/**
* Creates an asynchronous request queue.
*
* @param intervalMs minimum delay between request starts, in milliseconds
* @param threadName name of the scheduler thread
* @param maxQueueSize maximum number of waiting requests
* @throws IllegalArgumentException if the interval is negative
* or the queue capacity is not positive
* @throws NullPointerException if the thread name is null
*/
public AsyncRequestQueue(
long intervalMs,
@NotNull String threadName,
int maxQueueSize
) {
if (intervalMs < 0) {
throw new IllegalArgumentException(
"Interval must not be negative"
);
}

if (maxQueueSize <= 0) {
throw new IllegalArgumentException(
"Queue capacity must be positive"
);
}

if (threadName.isBlank()) {
throw new IllegalArgumentException(
"Thread name must not be blank"
);
}

this.intervalMs = intervalMs;
this.maxQueueSize = maxQueueSize;

this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, threadName);
thread.setDaemon(true);
return thread;
});
}

/**
* Submits an asynchronous operation to the queue.
*
* @param operation operation to execute when its turn arrives
* @param <T> result type of the asynchronous operation
* @return a future completed with the operation's result or exception
* @throws NullPointerException if the operation is null
* @throws RejectedExecutionException if the queue is closed or full
*/
public <T> @NotNull CompletableFuture<T> submit(
@NotNull Supplier<CompletableFuture<T>> operation
) {
CompletableFuture<T> result = new CompletableFuture<>();

synchronized (this) {
if (closed) {
throw new RejectedExecutionException(
"Request queue is closed"
);
}

if (queue.size() >= maxQueueSize) {
throw new RejectedExecutionException(
"Request queue is full"
);
}

queue.add(new QueuedRequest<>(operation, result));

if (!dispatchScheduled) {
dispatchScheduled = true;
scheduler.schedule(
this::dispatchNext,
0,
TimeUnit.MILLISECONDS
);
}
}

return result;
}

/**
* Starts the next queued operation and schedules the following dispatch.
* Request completion does not block queue progression.
*/
private void dispatchNext() {
QueuedRequest<?> request;

synchronized (this) {
request = queue.poll();

if (request == null) {
dispatchScheduled = false;
return;
}
}

execute(request);

synchronized (this) {
if (queue.isEmpty()) {
dispatchScheduled = false;
} else {
scheduler.schedule(
this::dispatchNext,
intervalMs,
TimeUnit.MILLISECONDS
);
}
}
}

/**
* Executes a queued operation and forwards its outcome to its future.
*
* @param request queued operation to execute
* @param <T> operation result type
*/
private <T> void execute(QueuedRequest<T> request) {
try {
CompletableFuture<T> operationFuture =
request.operation.get();

if (operationFuture == null) {
throw new NullPointerException(
"Operation returned a null future"
);
}

operationFuture.whenComplete((value, error) -> {
if (error != null) {
request.result.completeExceptionally(error);
} else {
request.result.complete(value);
}
});
} catch (Exception e) {
request.result.completeExceptionally(e);
}
}

/**
* Cancels waiting requests and shuts down the scheduler.
*
* <p>An operation that has already started may still complete.
* Its underlying HTTP request is not automatically cancelled.</p>
*/
@Override
public void close() {
synchronized (this) {
if (closed) {
return;
}

closed = true;

RejectedExecutionException exception =
new RejectedExecutionException(
"Request queue was shut down"
);

QueuedRequest<?> request;

while ((request = queue.poll()) != null) {
request.result.completeExceptionally(exception);
}

scheduler.shutdownNow();
}
}

/**
* Holds a queued operation and its associated result future.
*
* @param <T> operation result type
*/
private static final class QueuedRequest<T> {
private final Supplier<CompletableFuture<T>> operation;
private final CompletableFuture<T> result;

private QueuedRequest(
Supplier<CompletableFuture<T>> operation,
CompletableFuture<T> result
) {
this.operation = operation;
this.result = result;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package net.buildtheearth.buildteamtools.modules.network.api;

import com.alpsbte.alpslib.utils.ChatHelper;
import net.buildtheearth.model.GeographicalCoordinate;
import org.jetbrains.annotations.NotNull;
import org.json.simple.JSONObject;

import java.io.IOException;
import java.util.concurrent.CompletableFuture;

public class NominatimAPI {
private static final String BASE_URL = "https://nominatim.openstreetmap.org/";
private static final AsyncRequestQueue REQUEST_QUEUE =
new AsyncRequestQueue(1_100, "nominatim-api", 500);

public static @NotNull CompletableFuture<String> getAddressFromCoordinatesAsync(
@NotNull GeographicalCoordinate coordinates,
boolean international
) {
StringBuilder urlBuilder = new StringBuilder(
BASE_URL + "reverse?format=jsonv2"
+ "&lat=" + coordinates.latitude()
+ "&lon=" + coordinates.longitude()
+ "&addressdetails=1"
);

if (international) {
urlBuilder.append("&accept-language=en");
}

final String url = urlBuilder.toString();

ChatHelper.getStandardComponent(false, url);

return REQUEST_QUEUE.submit(() -> {
CompletableFuture<String> future = new CompletableFuture<>();

API.getAsync(url, new API.ApiResponseCallback() {
@Override
public void onResponse(String response) {
try {
String address = formatAddressFromResponse(response);
future.complete(address);
} catch (Exception e) {
future.completeExceptionally(e);
}
}

@Override
public void onFailure(IOException e) {
future.completeExceptionally(e);
}
});

return future;
});
}


private static String formatAddressFromResponse(String response) {
JSONObject jsonObject = API.createJSONObject(response);

ChatHelper.logDebug(
"Response from Nominatim: %s",
jsonObject
);

Object displayName = jsonObject.get("display_name");

if (displayName == null || displayName.toString().isBlank()) {
ChatHelper.logError(
"No address data found for these coordinates."
);
return "";
}

return displayName.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ default String apiKey() {
return "00000000-0000-0000-0000-000000000000";
}

@Comments("Specifies the User-Agent header sent with API requests.")
@Comments("Works by default with the provided value, but can be customized if needed.")
default String userAgent() {
return "BuildTeamTools/1.0 (contact: [email protected])";
}

@Comments("The main world using the custom BTE Dynmaxion projection")
default String earthWorld() {
return "world";
Expand Down
Loading