Refactor URL replacing

This commit is contained in:
yushijinhun 2018-12-29 20:39:49 +08:00
parent ffd2a94b3d
commit 15f766ab29
No known key found for this signature in database
GPG key ID: 5BC167F73EA558E4
14 changed files with 322 additions and 269 deletions

View file

@ -14,17 +14,22 @@ import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import moe.yushi.authlibinjector.httpd.DefaultURLRedirector;
import moe.yushi.authlibinjector.httpd.LegacySkinAPIFilter;
import moe.yushi.authlibinjector.httpd.URLFilter;
import moe.yushi.authlibinjector.httpd.URLProcessor;
import moe.yushi.authlibinjector.transform.AuthlibLogInterceptor;
import moe.yushi.authlibinjector.transform.ClassTransformer;
import moe.yushi.authlibinjector.transform.ConstantURLTransformUnit;
import moe.yushi.authlibinjector.transform.DumpClassListener;
import moe.yushi.authlibinjector.transform.SkinWhitelistTransformUnit;
import moe.yushi.authlibinjector.transform.LocalYggdrasilApiTransformUnit;
import moe.yushi.authlibinjector.transform.RemoteYggdrasilTransformUnit;
import moe.yushi.authlibinjector.transform.YggdrasilKeyTransformUnit;
import moe.yushi.authlibinjector.util.Logging;
@ -54,11 +59,6 @@ public final class AuthlibInjector {
*/
public static final String PROP_PREFETCHED_DATA_OLD = "org.to2mbn.authlibinjector.config.prefetched";
/**
* Whether to disable the local httpd server.
*/
public static final String PROP_DISABLE_HTTPD = "authlibinjector.httpd.disable";
/**
* The name of loggers to have debug level turned on.
*/
@ -267,9 +267,22 @@ public final class AuthlibInjector {
}
}
private static ClassTransformer createTransformer(YggdrasilConfiguration config) {
ClassTransformer transformer = new ClassTransformer();
private static URLProcessor createURLProcessor(YggdrasilConfiguration config) {
List<URLFilter> filters = new ArrayList<>();
if (Boolean.TRUE.equals(config.getMeta().get("feature.legacy_skin_api"))) {
Logging.CONFIG.info("Disabled local redirect for legacy skin API, as the remote Yggdrasil server supports it");
} else {
filters.add(new LegacySkinAPIFilter(config));
}
return new URLProcessor(filters, new DefaultURLRedirector(config));
}
private static ClassTransformer createTransformer(YggdrasilConfiguration config) {
URLProcessor urlProcessor = createURLProcessor(config);
ClassTransformer transformer = new ClassTransformer();
for (String ignore : nonTransformablePackages) {
transformer.ignores.add(ignore);
}
@ -282,11 +295,7 @@ public final class AuthlibInjector {
transformer.units.add(new AuthlibLogInterceptor());
}
if (!"true".equals(System.getProperty(PROP_DISABLE_HTTPD))) {
transformer.units.add(new LocalYggdrasilApiTransformUnit(config));
}
transformer.units.add(new RemoteYggdrasilTransformUnit(config.getApiRoot()));
transformer.units.add(new ConstantURLTransformUnit(urlProcessor));
transformer.units.add(new SkinWhitelistTransformUnit(config.getSkinDomains().toArray(new String[0])));

View file

@ -0,0 +1,37 @@
package moe.yushi.authlibinjector.httpd;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import moe.yushi.authlibinjector.YggdrasilConfiguration;
public class DefaultURLRedirector implements URLRedirector {
private Map<String, String> domainMapping = new HashMap<>();
private String apiRoot;
public DefaultURLRedirector(YggdrasilConfiguration config) {
initDomainMapping();
apiRoot = config.getApiRoot();
}
private void initDomainMapping() {
domainMapping.put("api.mojang.com", "api");
domainMapping.put("authserver.mojang.com", "authserver");
domainMapping.put("sessionserver.mojang.com", "sessionserver");
domainMapping.put("skins.minecraft.net", "skins");
}
@Override
public Optional<String> redirect(String domain, String path) {
String subdirectory = domainMapping.get(domain);
if (subdirectory == null) {
return Optional.empty();
}
return Optional.of(apiRoot + subdirectory + path);
}
}

View file

@ -1,10 +1,12 @@
package moe.yushi.authlibinjector.httpd;
import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.singleton;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static java.util.Optional.ofNullable;
import static moe.yushi.authlibinjector.util.IOUtils.CONTENT_TYPE_JSON;
import static moe.yushi.authlibinjector.util.IOUtils.asString;
import static moe.yushi.authlibinjector.util.IOUtils.getURL;
import static moe.yushi.authlibinjector.util.IOUtils.newUncheckedIOException;
@ -13,6 +15,7 @@ import static moe.yushi.authlibinjector.util.JsonUtils.asJsonArray;
import static moe.yushi.authlibinjector.util.JsonUtils.asJsonObject;
import static moe.yushi.authlibinjector.util.JsonUtils.asJsonString;
import static moe.yushi.authlibinjector.util.JsonUtils.parseJson;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -21,7 +24,9 @@ import java.util.Optional;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.NanoHTTPD.IHTTPSession;
import fi.iki.elonen.NanoHTTPD.Response;
import fi.iki.elonen.NanoHTTPD.Response.Status;
import moe.yushi.authlibinjector.YggdrasilConfiguration;
import moe.yushi.authlibinjector.internal.org.json.simple.JSONArray;
@ -29,28 +34,28 @@ import moe.yushi.authlibinjector.internal.org.json.simple.JSONObject;
import moe.yushi.authlibinjector.util.JsonUtils;
import moe.yushi.authlibinjector.util.Logging;
public class LocalYggdrasilHttpd extends NanoHTTPD {
public class LegacySkinAPIFilter implements URLFilter {
public static final String CONTENT_TYPE_JSON = "application/json; charset=utf-8";
private static final Pattern URL_SKINS = Pattern.compile("^/skins/MinecraftSkins/(?<username>[^/]+)\\.png$");
private static final Pattern PATH_SKINS = Pattern.compile("^/MinecraftSkins/(?<username>[^/]+)\\.png$");
private YggdrasilConfiguration configuration;
public LocalYggdrasilHttpd(int port, YggdrasilConfiguration configuration) {
super("127.0.0.1", port);
public LegacySkinAPIFilter(YggdrasilConfiguration configuration) {
this.configuration = configuration;
}
@Override
public Response serve(IHTTPSession session) {
return processAsSkin(session)
.orElseGet(() -> super.serve(session));
public boolean canHandle(String domain, String path) {
return domain.equals("skins.minecraft.net");
}
private Optional<Response> processAsSkin(IHTTPSession session) {
Matcher matcher = URL_SKINS.matcher(session.getUri());
if (!matcher.find()) return empty();
@Override
public Optional<Response> handle(String domain, String path, IHTTPSession session) {
if (!domain.equals("skins.minecraft.net"))
return empty();
Matcher matcher = PATH_SKINS.matcher(path);
if (!matcher.find())
return empty();
String username = matcher.group("username");
Optional<String> skinUrl;
@ -138,5 +143,4 @@ public class LocalYggdrasilHttpd extends NanoHTTPD {
.map(JsonUtils::asJsonString)
.orElseThrow(() -> newUncheckedIOException("Invalid JSON: Missing texture url")));
}
}

View file

@ -1,44 +0,0 @@
package moe.yushi.authlibinjector.httpd;
import java.io.IOException;
import moe.yushi.authlibinjector.YggdrasilConfiguration;
import moe.yushi.authlibinjector.util.Logging;
public class LocalYggdrasilHandle {
private boolean started = false;
private YggdrasilConfiguration configuration;
private LocalYggdrasilHttpd httpd;
private final Object _lock = new Object();
public LocalYggdrasilHandle(YggdrasilConfiguration configuration) {
this.configuration = configuration;
}
public void ensureStarted() {
if (started)
return;
synchronized (_lock) {
if (started)
return;
if (configuration == null)
throw new IllegalStateException("Configuration hasn't been set yet");
httpd = new LocalYggdrasilHttpd(0, configuration);
try {
httpd.start();
} catch (IOException e) {
throw new IllegalStateException("Httpd failed to start");
}
Logging.HTTPD.info("Httpd is running on port " + getLocalApiPort());
started = true;
}
}
public int getLocalApiPort() {
if (httpd == null)
return -1;
return httpd.getListeningPort();
}
}

View file

@ -0,0 +1,13 @@
package moe.yushi.authlibinjector.httpd;
import java.util.Optional;
import fi.iki.elonen.NanoHTTPD.IHTTPSession;
import fi.iki.elonen.NanoHTTPD.Response;
public interface URLFilter {
boolean canHandle(String domain, String path);
Optional<Response> handle(String domain, String path, IHTTPSession session);
}

View file

@ -0,0 +1,108 @@
package moe.yushi.authlibinjector.httpd;
import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR;
import static fi.iki.elonen.NanoHTTPD.Response.Status.NOT_FOUND;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import fi.iki.elonen.NanoHTTPD;
import moe.yushi.authlibinjector.util.Logging;
public class URLProcessor {
private static final Pattern URL_REGEX = Pattern.compile("^https?:\\/\\/(?<domain>[^\\/]+)(?<path>\\/.*)$");
private static final Pattern LOCAL_URL_REGEX = Pattern.compile("^/(?<domain>[^\\/]+)(?<path>\\/.*)$");
private List<URLFilter> filters;
private URLRedirector redirector;
public URLProcessor(List<URLFilter> filters, URLRedirector redirector) {
this.filters = filters;
this.redirector = redirector;
}
public Optional<String> transformURL(String inputUrl) {
Matcher matcher = URL_REGEX.matcher(inputUrl);
if (!matcher.find()) {
return Optional.empty();
}
String domain = matcher.group("domain");
String path = matcher.group("path");
Optional<String> result = transform(domain, path);
if (result.isPresent()) {
Logging.TRANSFORM.fine("Transformed url [" + inputUrl + "] to [" + result.get() + "]");
}
return result;
}
private Optional<String> transform(String domain, String path) {
boolean handleLocally = false;
for (URLFilter filter : filters) {
if (filter.canHandle(domain, path)) {
handleLocally = true;
break;
}
}
if (handleLocally) {
return Optional.of("http://127.0.0.1:" + getLocalApiPort() + "/" + domain + path);
}
return redirector.redirect(domain, path);
}
private volatile NanoHTTPD httpd;
private final Object httpdLock = new Object();
private int getLocalApiPort() {
synchronized (httpdLock) {
if (httpd == null) {
httpd = createHttpd();
try {
httpd.start();
} catch (IOException e) {
throw new IllegalStateException("Httpd failed to start");
}
Logging.HTTPD.info("Httpd is running on port " + httpd.getListeningPort());
}
return httpd.getListeningPort();
}
}
private NanoHTTPD createHttpd() {
return new NanoHTTPD("127.0.0.1", 0) {
@Override
public Response serve(IHTTPSession session) {
Matcher matcher = LOCAL_URL_REGEX.matcher(session.getUri());
if (matcher.find()) {
String domain = matcher.group("domain");
String path = matcher.group("path");
for (URLFilter filter : filters) {
if (filter.canHandle(domain, path)) {
Optional<Response> result;
try {
result = filter.handle(domain, path, session);
} catch (Throwable e) {
Logging.HTTPD.log(Level.WARNING, "An error occurred while processing request [" + session.getUri() + "]", e);
return newFixedLengthResponse(INTERNAL_ERROR, null, null);
}
if (result.isPresent()) {
Logging.HTTPD.fine("Request to [" + session.getUri() + "] is handled by [" + filter + "]");
return result.get();
}
}
}
}
Logging.HTTPD.fine("No handler is found for [" + session.getUri() + "]");
return newFixedLengthResponse(NOT_FOUND, MIME_PLAINTEXT, "Not Found");
}
};
}
}

View file

@ -0,0 +1,7 @@
package moe.yushi.authlibinjector.httpd;
import java.util.Optional;
public interface URLRedirector {
Optional<String> redirect(String domain, String path);
}

View file

@ -0,0 +1,24 @@
package moe.yushi.authlibinjector.transform;
import java.util.Optional;
import moe.yushi.authlibinjector.httpd.URLProcessor;
public class ConstantURLTransformUnit extends LdcTransformUnit {
private URLProcessor urlProcessor;
public ConstantURLTransformUnit(URLProcessor urlProcessor) {
this.urlProcessor = urlProcessor;
}
@Override
protected Optional<String> transformLdc(String input) {
return urlProcessor.transformURL(input);
}
@Override
public String toString() {
return "Constant URL Transformer";
}
}

View file

@ -1,38 +0,0 @@
package moe.yushi.authlibinjector.transform;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public abstract class DomainBasedTransformUnit extends LdcTransformUnit {
private static final Pattern URL_REGEX = Pattern.compile("^https?:\\/\\/(?<domain>[^\\/]+)(?<path>\\/.*)$");
private Map<String, String> domainMapping = new ConcurrentHashMap<>();
public Map<String, String> getDomainMapping() {
return domainMapping;
}
@Override
public Optional<String> transformLdc(String input) {
Matcher matcher = URL_REGEX.matcher(input);
if (!matcher.find()) {
return Optional.empty();
}
String domain = matcher.group("domain");
String subdirectory = domainMapping.get(domain);
if (subdirectory == null) {
return Optional.empty();
}
String path = matcher.group("path");
return Optional.of(getApiRoot() + subdirectory + path);
}
protected abstract String getApiRoot();
}

View file

@ -1,34 +0,0 @@
package moe.yushi.authlibinjector.transform;
import java.util.Map;
import moe.yushi.authlibinjector.YggdrasilConfiguration;
import moe.yushi.authlibinjector.httpd.LocalYggdrasilHandle;
import moe.yushi.authlibinjector.util.Logging;
public class LocalYggdrasilApiTransformUnit extends DomainBasedTransformUnit {
private LocalYggdrasilHandle handle;
public LocalYggdrasilApiTransformUnit(YggdrasilConfiguration config) {
handle = new LocalYggdrasilHandle(config);
Map<String, String> mapping = getDomainMapping();
if (Boolean.TRUE.equals(config.getMeta().get("feature.legacy_skin_api"))) {
Logging.CONFIG.info("Disabled local redirect for legacy skin API, as the remote Yggdrasil server supports it");
} else {
mapping.put("skins.minecraft.net", "skins");
}
}
@Override
protected String getApiRoot() {
handle.ensureStarted();
return "http://127.0.0.1:" + handle.getLocalApiPort() + "/";
}
@Override
public String toString() {
return "Local Yggdrasil API Transformer";
}
}

View file

@ -1,28 +0,0 @@
package moe.yushi.authlibinjector.transform;
import java.util.Map;
public class RemoteYggdrasilTransformUnit extends DomainBasedTransformUnit {
private String apiRoot;
public RemoteYggdrasilTransformUnit(String apiRoot) {
this.apiRoot = apiRoot;
Map<String, String> mapping = getDomainMapping();
mapping.put("api.mojang.com", "api");
mapping.put("authserver.mojang.com", "authserver");
mapping.put("sessionserver.mojang.com", "sessionserver");
mapping.put("skins.minecraft.net", "skins");
}
@Override
protected String getApiRoot() {
return apiRoot;
}
@Override
public String toString() {
return "Yggdrasil API Transformer";
}
}

View file

@ -11,6 +11,8 @@ import java.net.URL;
public final class IOUtils {
public static final String CONTENT_TYPE_JSON = "application/json; charset=utf-8";
public static byte[] getURL(String url) throws IOException {
try (InputStream in = new URL(url).openStream()) {
return asBytes(in);

View file

@ -0,0 +1,90 @@
package moe.yushi.authlibinjector.test;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;
import static org.junit.Assert.assertEquals;
import java.util.Optional;
import org.junit.Test;
import moe.yushi.authlibinjector.YggdrasilConfiguration;
import moe.yushi.authlibinjector.httpd.DefaultURLRedirector;
public class DefaultURLRedirectorTest {
private String apiRoot = "https://yggdrasil.example.com/";
private DefaultURLRedirector redirector = new DefaultURLRedirector(new YggdrasilConfiguration(apiRoot, emptyList(), emptyMap(), Optional.empty()));
private void testTransform(String domain, String path, String output) {
assertEquals(redirector.redirect(domain, path).get(), output);
}
@Test
public void testReplace() {
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilGameProfileRepository
"api.mojang.com", "/profiles/",
"https://yggdrasil.example.com/api/profiles/");
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService
"sessionserver.mojang.com", "/session/minecraft/join",
"https://yggdrasil.example.com/sessionserver/session/minecraft/join");
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService
"sessionserver.mojang.com", "/session/minecraft/hasJoined",
"https://yggdrasil.example.com/sessionserver/session/minecraft/hasJoined");
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"authserver.mojang.com", "/authenticate",
"https://yggdrasil.example.com/authserver/authenticate");
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"authserver.mojang.com", "/refresh",
"https://yggdrasil.example.com/authserver/refresh");
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"authserver.mojang.com", "/validate",
"https://yggdrasil.example.com/authserver/validate");
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"authserver.mojang.com", "/invalidate",
"https://yggdrasil.example.com/authserver/invalidate");
testTransform(
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"authserver.mojang.com", "/signout",
"https://yggdrasil.example.com/authserver/signout");
testTransform(
// from: [mcp940]/net.minecraft.client.entity.AbstractClientPlayer
// issue: yushijinhun/authlib-injector#7 <https://github.com/yushijinhun/authlib-injector/issues/7>
"skins.minecraft.net", "/MinecraftSkins/%s.png",
"https://yggdrasil.example.com/skins/MinecraftSkins/%s.png");
testTransform(
// from: [bungeecord@806a6dfacaadb7538860889f8a50612bb496a2d3]/net.md_5.bungee.connection.InitialHandler
// url: https://github.com/SpigotMC/BungeeCord/blob/806a6dfacaadb7538860889f8a50612bb496a2d3/proxy/src/main/java/net/md_5/bungee/connection/InitialHandler.java#L409
"sessionserver.mojang.com", "/session/minecraft/hasJoined?username=",
"https://yggdrasil.example.com/sessionserver/session/minecraft/hasJoined?username=");
testTransform(
// from: [wiki.vg]/Mojang_API/Username -> UUID at time
// url: http://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time
// issue: yushijinhun/authlib-injector#6 <https://github.com/yushijinhun/authlib-injector/issues/6>
"api.mojang.com", "/users/profiles/minecraft/",
"https://yggdrasil.example.com/api/users/profiles/minecraft/");
}
@Test
public void testEmpty() {
assertEquals(redirector.redirect("example.com", "/path"), Optional.empty());
}
}

View file

@ -1,97 +0,0 @@
package moe.yushi.authlibinjector.test;
import static org.junit.Assert.assertEquals;
import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;
import moe.yushi.authlibinjector.transform.RemoteYggdrasilTransformUnit;
@RunWith(Parameterized.class)
public class UrlReplaceTest {
private static final String apiRoot = "https://yggdrasil.example.com/";
@Parameters
public static Collection<Object[]> data() {
// @formatter:off
return Arrays.asList(new Object[][] {
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilGameProfileRepository
"https://api.mojang.com/profiles/",
"https://yggdrasil.example.com/api/profiles/"
},
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService
"https://sessionserver.mojang.com/session/minecraft/join",
"https://yggdrasil.example.com/sessionserver/session/minecraft/join"
},
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService
"https://sessionserver.mojang.com/session/minecraft/hasJoined",
"https://yggdrasil.example.com/sessionserver/session/minecraft/hasJoined"
},
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"https://authserver.mojang.com/authenticate",
"https://yggdrasil.example.com/authserver/authenticate"
},
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"https://authserver.mojang.com/refresh",
"https://yggdrasil.example.com/authserver/refresh"
},
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"https://authserver.mojang.com/validate",
"https://yggdrasil.example.com/authserver/validate"
},
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"https://authserver.mojang.com/invalidate",
"https://yggdrasil.example.com/authserver/invalidate"
},
{
// from: [com.mojang:authlib:1.5.24]/com.mojang.authlib.yggdrasil.YggdrasilUserAuthentication
"https://authserver.mojang.com/signout",
"https://yggdrasil.example.com/authserver/signout"
},
{
// from: [mcp940]/net.minecraft.client.entity.AbstractClientPlayer
// issue: yushijinhun/authlib-injector#7 <https://github.com/yushijinhun/authlib-injector/issues/7>
"http://skins.minecraft.net/MinecraftSkins/%s.png",
"https://yggdrasil.example.com/skins/MinecraftSkins/%s.png"
},
{
// from: [bungeecord@806a6dfacaadb7538860889f8a50612bb496a2d3]/net.md_5.bungee.connection.InitialHandler
// url: https://github.com/SpigotMC/BungeeCord/blob/806a6dfacaadb7538860889f8a50612bb496a2d3/proxy/src/main/java/net/md_5/bungee/connection/InitialHandler.java#L409
"https://sessionserver.mojang.com/session/minecraft/hasJoined?username=",
"https://yggdrasil.example.com/sessionserver/session/minecraft/hasJoined?username="
},
{
// from: [wiki.vg]/Mojang_API/Username -> UUID at time
// url: http://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time
// issue: yushijinhun/authlib-injector#6 <https://github.com/yushijinhun/authlib-injector/issues/6>
"https://api.mojang.com/users/profiles/minecraft/",
"https://yggdrasil.example.com/api/users/profiles/minecraft/"
}
});
// @formatter:on
}
@Parameter(0)
public String input;
@Parameter(1)
public String output;
@Test
public void test() {
RemoteYggdrasilTransformUnit transformer = new RemoteYggdrasilTransformUnit(apiRoot);
assertEquals(output, transformer.transformLdc(input).get());
}
}