diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index 96e4e8c3..b919c6b7 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -311,6 +311,9 @@ public void onEnable() { var focusCommand = new net.tfminecraft.rpcharacters.focus.FocusCommand(this); getCommand("focus").setExecutor(focusCommand); getCommand("focus").setTabCompleter(focusCommand); + var plushieCommand = new net.tfminecraft.rpcharacters.wardrobe.PlushieCommand(); + getCommand("plushie").setExecutor(plushieCommand); + getCommand("plushie").setTabCompleter(plushieCommand); startManagers(); getCommand(commandManager.cmd1).setExecutor(commandManager); getCommand("rpcharacter").setTabCompleter(new CommandTabCompleter()); diff --git a/src/main/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClient.java b/src/main/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClient.java index 58e5ad37..d007104b 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClient.java +++ b/src/main/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClient.java @@ -298,6 +298,16 @@ public static SimpleResult fetchWornSkin(String playerUuid, String characterId) return request("GET", "/characters/plugin/wardrobe/" + uuid + "/" + cid + "/worn", null); } + /** Read one saved outfit without reconciling slots or changing the worn skin. */ + public static SimpleResult fetchWardrobeSkin(String playerUuid, String characterId, String slot) { + String uuid = sanitizePathSegment(playerUuid); + String cid = sanitizePathSegment(characterId); + if (uuid == null || cid == null || slot == null || !List.of("base", "extra_1", "extra_2").contains(slot)) { + return SimpleResult.fail("invalid player, character or outfit"); + } + return request("GET", "/characters/plugin/wardrobe/" + uuid + "/" + cid + "/slots/" + slot, null); + } + /** * POST /characters/plugin/wardrobe/{playerUuid}/{characterId}/active * Same as PATCH — POST used because Java HttpURLConnection often rejects PATCH. diff --git a/src/main/java/net/tfminecraft/rpcharacters/wardrobe/PlushieCommand.java b/src/main/java/net/tfminecraft/rpcharacters/wardrobe/PlushieCommand.java new file mode 100644 index 00000000..e47f0158 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/wardrobe/PlushieCommand.java @@ -0,0 +1,152 @@ +package net.tfminecraft.rpcharacters.wardrobe; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.SkullMeta; + +import com.destroystokyo.paper.profile.ProfileProperty; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.api.ProvinceSystemClient; +import net.tfminecraft.rpcharacters.playerlist.SkinPortrait; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.Characters; + +/** Gives a static head from an explicitly selected character outfit. */ +public final class PlushieCommand implements CommandExecutor, TabCompleter { + + public static final String PERMISSION = "rpchar.plushie.give"; + private static final List SLOTS = List.of("base", "extra_1", "extra_2"); + private final Set pending = new HashSet<>(); + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission(PERMISSION)) { + sender.sendMessage("You do not have permission to create plushies."); + return true; + } + if (!(sender instanceof Player player)) { + sender.sendMessage("Run this command in game to receive the plushie."); + return true; + } + int outfit = 1; + int nameLength = args.length; + if (args.length > 1 && args[args.length - 1].matches("-?\\d+")) { + try { + outfit = Integer.parseInt(args[--nameLength]); + } catch (NumberFormatException invalid) { + outfit = 0; + } + } + if (nameLength == 0 || outfit < 1 || outfit > SLOTS.size()) { + sender.sendMessage("Usage: /" + label + " [1|2|3]"); + return true; + } + String name = String.join(" ", Arrays.copyOf(args, nameLength)).trim(); + var identity = RPCharacters.getCharacterIdentity(); + List matches; + try { + matches = identity.info(UUID.fromString(name)).stream().toList(); + } catch (IllegalArgumentException notUuid) { + matches = identity.findByName(name); + } + if (matches.size() != 1) { + sender.sendMessage(matches.isEmpty() ? "Character not found: " + name + : "More than one character has that name. Use their character UUID: " + + matches.stream().map(info -> info.ref().characterId().toString()).toList()); + return true; + } + UUID playerId = player.getUniqueId(); + if (!pending.add(playerId)) { + sender.sendMessage("Your previous plushie is still loading."); + return true; + } + CharacterInfo character = matches.getFirst(); + String slot = SLOTS.get(outfit - 1); + int selectedOutfit = outfit; + var session = Characters.currentSession(player).orElse(null); + player.sendMessage("Loading " + character.name() + "'s wardrobe outfit " + outfit + "…"); + Bukkit.getScheduler().runTaskAsynchronously(RPCharacters.plugin, () -> { + String texture = loadTexture(character, slot); + Bukkit.getScheduler().runTask(RPCharacters.plugin, () -> { + pending.remove(playerId); + if (!player.isOnline() || !player.hasPermission(PERMISSION)) return; + if (session == null ? Characters.currentSession(player).isPresent() : !Characters.isCurrent(session)) { + player.sendMessage("Your character changed while loading. Run the command again."); + return; + } + if (texture == null) { + player.sendMessage("That outfit is unavailable, locked or has no ready skin. No plushie was created."); + return; + } + ItemStack head = createHead(character.name(), texture); + if (!player.getInventory().addItem(head).isEmpty()) { + player.sendMessage("Your inventory is full. Make room and try again."); + return; + } + player.sendMessage("Created " + character.name() + " Plushie from wardrobe outfit " + selectedOutfit + "."); + }); + }); + return true; + } + + static String loadTexture(CharacterInfo character, String slot) { + try { + String id = character.ref().characterId().toString(); + var result = ProvinceSystemClient.fetchWardrobeSkin(character.ref().ownerId().toString(), id, slot); + if (!result.ok) return null; + JsonObject body = JsonParser.parseString(result.body).getAsJsonObject(); + if (!id.equalsIgnoreCase(body.get("character_id").getAsString()) + || !slot.equals(body.get("slot").getAsString()) + || body.get("signature").getAsString().isBlank()) return null; + var texture = SkinPortrait.parseTextures(body.get("value").getAsString()); + if (texture == null) return null; + // A URL-only property excludes changing profile timestamps, identities and signatures. + JsonObject skin = new JsonObject(); + skin.addProperty("url", texture.url()); + JsonObject textures = new JsonObject(); + textures.add("SKIN", skin); + JsonObject profile = new JsonObject(); + profile.add("textures", textures); + return Base64.getEncoder().encodeToString(profile.toString().getBytes(StandardCharsets.UTF_8)); + } catch (RuntimeException unavailable) { + return null; + } + } + + static ItemStack createHead(String characterName, String texture) { + ItemStack head = new ItemStack(Material.PLAYER_HEAD); + SkullMeta meta = (SkullMeta) head.getItemMeta(); + UUID id = UUID.nameUUIDFromBytes(("rpcharacters:head:" + texture).getBytes(StandardCharsets.UTF_8)); + var profile = Bukkit.createProfile(id); + profile.setProperty(new ProfileProperty("textures", texture)); + meta.setPlayerProfile(profile); + meta.displayName(Component.text(characterName + " Plushie").decoration(TextDecoration.ITALIC, false)); + head.setItemMeta(meta); + return head; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission(PERMISSION) || args.length < 2) return List.of(); + return List.of("1", "2", "3").stream().filter(number -> number.startsWith(args[args.length - 1])).toList(); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9b6e1222..e774a4f3 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,6 +8,10 @@ depend: [TLibs, MMOCore, TFMCWeb, MMOItems, MythicLib] softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit, Codex, ModelEngine] commands: + plushie: + description: Create a plushie from a character's wardrobe outfit + usage: /plushie [1|2|3] + permission: rpchar.plushie.give class: description: Choose your character's base class aliases: [c] @@ -54,6 +58,9 @@ commands: usage: / collect | list | show | hold | release | decide | assign | retry | waive | revert description: Collect an inheritance; staff estate tools permissions: + rpchar.plushie.give: + description: Create plushies from character wardrobe outfits + default: op rpchar.focus.admin: description: Restore character focus and reload focus configuration default: op diff --git a/src/test/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClientTest.java b/src/test/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClientTest.java index fcc7e768..5113c14f 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClientTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/api/ProvinceSystemClientTest.java @@ -48,6 +48,22 @@ void respond(String json) { gateway.verify(() -> GatewayClient.request("POST", "/characters/plugin/wills/applied", "{\"realm_id\":\"dev\"}")); } + @Test void outfitLookupUsesTheReadOnlySlotRouteAndRejectsOtherSources() { + for (String invalid : Arrays.asList(null, " ", "../x", "x/y", "x\\y")) { + assertFalse(fetchWardrobeSkin(invalid, "char", "base").ok); + assertFalse(fetchWardrobeSkin("owner", invalid, "base").ok); + } + for (String invalid : Arrays.asList(null, "", "masked", "../base", "1")) { + assertFalse(fetchWardrobeSkin("owner", "char", invalid).ok); + } + gateway.verifyNoInteractions(); + for (String slot : List.of("base", "extra_1", "extra_2")) { + assertTrue(fetchWardrobeSkin(" owner ", " char ", slot).ok); + gateway.verify(() -> GatewayClient.request("GET", "/characters/plugin/wardrobe/owner/char/slots/" + slot, null)); + } + gateway.verifyNoMoreInteractions(); + } + @Test void resultFactoriesExposeSuccessPayloadsAndFailureDetails() { var catalog = CatalogPushResult.success(1, 2, 3, 4, "now"); assertTrue(catalog.ok); assertEquals(1, catalog.stages); assertEquals(2, catalog.races); diff --git a/src/test/java/net/tfminecraft/rpcharacters/wardrobe/PlushieCommandTest.java b/src/test/java/net/tfminecraft/rpcharacters/wardrobe/PlushieCommandTest.java new file mode 100644 index 00000000..919af982 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/wardrobe/PlushieCommandTest.java @@ -0,0 +1,253 @@ +package net.tfminecraft.rpcharacters.wardrobe; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import com.destroystokyo.paper.profile.PlayerProfile; +import com.destroystokyo.paper.profile.ProfileProperty; +import com.google.gson.JsonObject; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.api.ProvinceSystemClient; +import net.tfminecraft.rpcharacters.api.ProvinceSystemClient.SimpleResult; +import net.tfminecraft.rpcharacters.identity.CharacterIdentityService; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +class PlushieCommandTest { + + private static final UUID OWNER = UUID.randomUUID(); + private static final UUID CHARACTER = UUID.randomUUID(); + private static final String URL = "https://textures.minecraft.net/texture/" + "a".repeat(64); + private static final CharacterInfo GRINKLE = new CharacterInfo(new CharacterRef(OWNER, CHARACTER), + "Grinkle", CharacterStatus.ALIVE, null, Set.of(), 0, null); + private final PlushieCommand command = new PlushieCommand(); + private final Player player = mock(Player.class); + private final PlayerInventory inventory = mock(PlayerInventory.class); + private final CharacterIdentityService identity = mock(CharacterIdentityService.class); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final SkullMeta meta = mock(SkullMeta.class); + private final PlayerProfile profile = mock(PlayerProfile.class); + private final ArrayDeque async = new ArrayDeque<>(); + private final ArrayDeque main = new ArrayDeque<>(); + private MockedStatic bukkit; + private MockedStatic rp; + private MockedStatic characters; + private MockedStatic client; + private MockedConstruction items; + + @BeforeEach void setup() { + bukkit = mockStatic(Bukkit.class); + rp = mockStatic(RPCharacters.class); + characters = mockStatic(Characters.class); + client = mockStatic(ProvinceSystemClient.class); + items = mockConstruction(ItemStack.class, (item, context) -> when(item.getItemMeta()).thenReturn(meta)); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.createProfile(any(UUID.class))).thenReturn(profile); + rp.when(RPCharacters::getCharacterIdentity).thenReturn(identity); + when(identity.findByName("Grinkle")).thenReturn(List.of(GRINKLE)); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.isOnline()).thenReturn(true); + when(player.hasPermission(PlushieCommand.PERMISSION)).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.empty()); + doAnswer(call -> { async.add(call.getArgument(1)); return null; }) + .when(scheduler).runTaskAsynchronously(nullable(Plugin.class), any(Runnable.class)); + doAnswer(call -> { main.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(nullable(Plugin.class), any(Runnable.class)); + for (String slot : List.of("base", "extra_1", "extra_2")) { + respond(slot, SimpleResult.success(response(CHARACTER.toString(), slot, texture(URL, 1), "signed"))); + } + } + + @AfterEach void close() { + items.close(); client.close(); characters.close(); rp.close(); bukkit.close(); + } + + private void run(String... args) { + assertTrue(command.onCommand(player, null, "plushie", args)); + } + + private void finish() { + async.remove().run(); + main.remove().run(); + } + + private void respond(String slot, SimpleResult result) { + client.when(() -> ProvinceSystemClient.fetchWardrobeSkin(OWNER.toString(), CHARACTER.toString(), slot)).thenReturn(result); + } + + @Test void selectsTheNumberedOutfitOffThreadAndDeliversAStaticPlushieOnMain() { + run("Grinkle", "2"); + client.verifyNoInteractions(); + verifyNoInteractions(inventory); + async.remove().run(); + verifyNoInteractions(inventory); + client.verify(() -> ProvinceSystemClient.fetchWardrobeSkin(OWNER.toString(), CHARACTER.toString(), "extra_1")); + client.verifyNoMoreInteractions(); + main.remove().run(); + verify(inventory).addItem(items.constructed().getFirst()); + verify(meta).displayName(Component.text("Grinkle Plushie").decoration(TextDecoration.ITALIC, false)); + verify(meta).setPlayerProfile(profile); + var property = ArgumentCaptor.forClass(ProfileProperty.class); + verify(profile).setProperty(property.capture()); + assertEquals("textures", property.getValue().getName()); + assertEquals(texture(URL, null), property.getValue().getValue()); + assertFalse(property.getValue().isSigned()); + verify(player).sendMessage("Created Grinkle Plushie from wardrobe outfit 2."); + } + + @Test void supportsBaseDefaultMultiwordNamesAndExactCharacterIds() { + run("Grinkle"); finish(); + when(identity.findByName("Grinkle the Great")).thenReturn(List.of(GRINKLE)); + run("Grinkle", "the", "Great", "3"); finish(); + when(identity.info(CHARACTER)).thenReturn(Optional.of(GRINKLE)); + run(CHARACTER.toString(), "1"); finish(); + client.verify(() -> ProvinceSystemClient.fetchWardrobeSkin(OWNER.toString(), CHARACTER.toString(), "base"), times(2)); + client.verify(() -> ProvinceSystemClient.fetchWardrobeSkin(OWNER.toString(), CHARACTER.toString(), "extra_2")); + } + + @Test void refusesUnauthorisedConsoleInvalidAndAmbiguousRequestsBeforeLoading() { + when(player.hasPermission(PlushieCommand.PERMISSION)).thenReturn(false); + run("Grinkle"); + verify(player).sendMessage(contains("permission")); + CommandSender console = mock(CommandSender.class); + when(console.hasPermission(PlushieCommand.PERMISSION)).thenReturn(true); + command.onCommand(console, null, "plushie", new String[] {"Grinkle"}); + verify(console).sendMessage(contains("in game")); + when(player.hasPermission(PlushieCommand.PERMISSION)).thenReturn(true); + run(); + for (String number : List.of("0", "-1", "4", "999999999999999999999")) run("Grinkle", number); + run("Unknown"); + verify(player).sendMessage("Character not found: Unknown"); + when(identity.findByName("Grinkle")).thenReturn(List.of(GRINKLE, GRINKLE)); + run("Grinkle"); + verify(player).sendMessage(contains("More than one character")); + verifyNoInteractions(scheduler, inventory); + } + + @Test void limitsPendingRequestsAndDoesNotDeliverAfterLogoutOrPermissionLoss() { + run("Grinkle"); run("Grinkle"); + assertEquals(1, async.size()); + verify(player).sendMessage("Your previous plushie is still loading."); + when(player.isOnline()).thenReturn(false); + finish(); + when(player.isOnline()).thenReturn(true); + run("Grinkle"); + when(player.hasPermission(PlushieCommand.PERMISSION)).thenReturn(false); + finish(); + verifyNoInteractions(inventory); + } + + @Test void neverDeliversIntoAnotherCharacterAfterASwitch() { + CharacterSession session = mock(CharacterSession.class); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of(session)); + run("Grinkle"); finish(); + verify(player).sendMessage(contains("character changed")); + verifyNoInteractions(inventory); + characters.when(() -> Characters.isCurrent(session)).thenReturn(true); + run("Grinkle"); finish(); + verify(inventory).addItem(any(ItemStack.class)); + } + + @Test void unavailableOutfitsGiveNothingAndFullInventoriesDoNotDropItems() { + respond("base", SimpleResult.fail("offline")); + run("Grinkle"); finish(); + verify(player).sendMessage(contains("No plushie was created")); + verifyNoInteractions(inventory); + respond("base", SimpleResult.success(response(CHARACTER.toString(), "base", texture(URL, 1), "signed"))); + when(inventory.addItem(any(ItemStack.class))).thenAnswer(call -> new HashMap<>(Map.of(0, call.getArgument(0, ItemStack.class)))); + run("Grinkle"); finish(); + verify(player).sendMessage("Your inventory is full. Make room and try again."); + verify(player, never()).getWorld(); + } + + @Test void enteringACharacterWhileLoadingAlsoCancelsDelivery() { + run("Grinkle"); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of(mock(CharacterSession.class))); + finish(); + verify(player).sendMessage(contains("character changed")); + verifyNoInteractions(inventory); + } + + @Test void rejectsWrongCharactersWrongSlotsUnsignedAndMalformedSkinsWithoutFallback() { + for (String body : List.of("{}", "[]", "invalid", + response("other-character", "base", texture(URL, 1), "signed"), + response(CHARACTER.toString(), "extra_1", texture(URL, 1), "signed"), + response(CHARACTER.toString(), "base", texture(URL, 1), " "), + response(CHARACTER.toString(), "base", "invalid", "signed"), + response(CHARACTER.toString(), "base", texture("https://example.com/skin.png", 1), "signed"))) { + respond("base", SimpleResult.success(body)); + assertNull(PlushieCommand.loadTexture(GRINKLE, "base")); + } + respond("base", SimpleResult.success(null)); + assertNull(PlushieCommand.loadTexture(GRINKLE, "base")); + } + + @Test void ignoresSourceTimestampsAndUsesTheSameStaticIdentityForCopies() { + String first = PlushieCommand.loadTexture(GRINKLE, "base"); + respond("base", SimpleResult.success(response(CHARACTER.toString(), "base", texture(URL, 999), "new-signature"))); + String later = PlushieCommand.loadTexture(GRINKLE, "base"); + assertEquals(first, later); + PlushieCommand.createHead("Grinkle", first); + PlushieCommand.createHead("Grinkle", later); + UUID expected = UUID.nameUUIDFromBytes(("rpcharacters:head:" + first).getBytes(StandardCharsets.UTF_8)); + bukkit.verify(() -> Bukkit.createProfile(expected), times(2)); + verify(profile, times(2)).setProperty(new ProfileProperty("textures", first)); + } + + @Test void outfitCompletionIsPermissionScoped() { + assertEquals(List.of(), command.onTabComplete(player, null, "plushie", new String[] {"Grinkle"})); + assertEquals(List.of("1", "2", "3"), command.onTabComplete(player, null, "plushie", new String[] {"Grinkle", ""})); + assertEquals(List.of("2"), command.onTabComplete(player, null, "plushie", new String[] {"Grinkle", "2"})); + when(player.hasPermission(PlushieCommand.PERMISSION)).thenReturn(false); + assertEquals(List.of(), command.onTabComplete(player, null, "plushie", new String[] {"Grinkle", ""})); + } + + private static String texture(String url, Integer timestamp) { + JsonObject skin = new JsonObject(); skin.addProperty("url", url); + JsonObject textures = new JsonObject(); textures.add("SKIN", skin); + JsonObject profile = new JsonObject(); profile.add("textures", textures); + if (timestamp != null) profile.addProperty("timestamp", timestamp); + return Base64.getEncoder().encodeToString(profile.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String response(String id, String slot, String value, String signature) { + JsonObject body = new JsonObject(); + body.addProperty("character_id", id); body.addProperty("slot", slot); + body.addProperty("value", value); body.addProperty("signature", signature); + return body.toString(); + } +}