From 561739730a8f7ab6f9237e189629aa5dcc70ba8d Mon Sep 17 00:00:00 2001 From: redstoner507 Date: Sat, 17 Oct 2026 10:57:50 +0200 Subject: [PATCH] feat(address): Switch to NominatimAPI for reverse geocoding and add User Agent for API calls --- .../address/commands/AddressCommand.java | 17 +- .../modules/network/api/API.java | 13 +- .../network/api/AsyncRequestQueue.java | 228 ++++++++++++++++++ .../modules/network/api/NominatimAPI.java | 79 ++++++ .../buildteamtools/utils/io/MainConfig.java | 6 + 5 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/network/api/AsyncRequestQueue.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/network/api/NominatimAPI.java diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/address/commands/AddressCommand.java b/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/address/commands/AddressCommand.java index 4fcdd13a..e01f5724 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/address/commands/AddressCommand.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/address/commands/AddressCommand.java @@ -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; @@ -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; @@ -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)); }); @@ -74,7 +79,7 @@ private void execute(@NonNull CommandSender sender, String @NonNull [] args) { player.sendMessage(ChatHelper.getErrorComponent("Usage: /btt address ")); } - 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; @@ -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( diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/API.java b/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/API.java index 336d2671..bff43f98 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/API.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/API.java @@ -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 diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/AsyncRequestQueue.java b/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/AsyncRequestQueue.java new file mode 100644 index 00000000..e58da803 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/AsyncRequestQueue.java @@ -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. + * + *

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.

+ * + *

Each instance owns a dedicated daemon scheduler and an independent + * request queue.

+ */ +public final class AsyncRequestQueue implements AutoCloseable { + + private final long intervalMs; + private final int maxQueueSize; + + private final ScheduledExecutorService scheduler; + private final Queue> 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 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 @NotNull CompletableFuture submit( + @NotNull Supplier> operation + ) { + CompletableFuture 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 operation result type + */ + private void execute(QueuedRequest request) { + try { + CompletableFuture 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. + * + *

An operation that has already started may still complete. + * Its underlying HTTP request is not automatically cancelled.

+ */ + @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 operation result type + */ + private static final class QueuedRequest { + private final Supplier> operation; + private final CompletableFuture result; + + private QueuedRequest( + Supplier> operation, + CompletableFuture result + ) { + this.operation = operation; + this.result = result; + } + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/NominatimAPI.java b/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/NominatimAPI.java new file mode 100644 index 00000000..df72c95a --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/network/api/NominatimAPI.java @@ -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 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 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(); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/utils/io/MainConfig.java b/src/main/java/net/buildtheearth/buildteamtools/utils/io/MainConfig.java index 88f798fc..af7778fe 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/utils/io/MainConfig.java +++ b/src/main/java/net/buildtheearth/buildteamtools/utils/io/MainConfig.java @@ -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: development@buildtheearth.net)"; + } + @Comments("The main world using the custom BTE Dynmaxion projection") default String earthWorld() { return "world";