com.kttdevelopment.mal4j.APICall Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of mal4j Show documentation
Show all versions of mal4j Show documentation
Java wrapper for the official MyAnimeList API
/*
* Copyright (C) 2021 Katsute
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package com.kttdevelopment.mal4j;
import java.io.*;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.lang.reflect.*;
import java.net.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.function.Function;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static com.kttdevelopment.mal4j.APIStruct.*;
/**
* Represents an API call.
*/
@SuppressWarnings({"UnusedReturnValue", "SameParameterValue"})
class APICall {
private final String method;
private final String baseURL;
private final String path;
/**
* API call builder.
*
* @param method request method
* @param baseURL base url
* @param path path
*/
APICall(final String method, final String baseURL, final String path){
this.method = method;
this.baseURL = baseURL;
this.path = path;
}
/**
* API calls from annotated interface method.
*
* @param baseURL base url
* @param method method
* @param args method arguments
*
* @see APIStruct
*/
APICall(final String baseURL, final Method method, final Object... args){
this.baseURL = baseURL;
final Endpoint endpoint = method.getAnnotation(Endpoint.class);
if(endpoint != null){
this.method = endpoint.method();
path = endpoint.value();
}else{
this.method = "GET";
this.path = "";
}
if(method.getAnnotation(FormUrlEncoded.class) != null)
formUrlEncoded = true;
for(int i = 0, size = method.getParameterAnnotations().length; i < size; i++){
final Object arg = args[i];
for(final Annotation annotation : method.getParameterAnnotations()[i]){
if(arg != null){
final Class extends Annotation> type = annotation.annotationType();
if(type == Path.class)
withPathVar(((Path) annotation).value(), arg, ((Path) annotation).encoded());
else if(type == Header.class)
withHeader(((Header) annotation).value(), Objects.toString(arg));
else if(type == Query.class)
withQuery(((Query) annotation).value(), arg, ((Query) annotation).encoded());
else if(type == APIStruct.Field.class)
withField(((APIStruct.Field) annotation).value(), arg, ((APIStruct.Field) annotation).encoded());
}
}
}
}
private final Map headers = new HashMap<>();
// \{(.*?)\}
@SuppressWarnings("RegExpRedundantEscape") // android requires this syntax (#133)
private static final Pattern pathArg = Pattern.compile("\\{(.*?)\\}");
private final Map pathVars = new HashMap<>();
private final Map queries = new HashMap<>();
private boolean formUrlEncoded = false;
private final Map fields = new HashMap<>();
final APICall withHeader(final String header, final String value){
if(value == null)
headers.remove(header);
else
headers.put(header, value);
return this;
}
final APICall withPathVar(final String pathVar, final String value){
return withPathVar(pathVar, value, false);
}
final APICall withPathVar(final String pathVar, final Object value, final boolean encoded){
if(value == null)
pathVars.remove(pathVar);
else
pathVars.put(pathVar, encoded ? Objects.toString(value) : Java9.URLEncoder.encode(Objects.toString(value), StandardCharsets.UTF_8));
return this;
}
final APICall withQuery(final String query, final Object value){
return withQuery(query, value, false);
}
final APICall withQuery(final String query, final Object value, final boolean encoded){
if(value == null)
queries.remove(query);
else
queries.put(query, encoded ? Objects.toString(value) : Java9.URLEncoder.encode(Objects.toString(value), StandardCharsets.UTF_8));
return this;
}
final APICall formUrlEncoded(){
return formUrlEncoded(true);
}
final APICall formUrlEncoded(final boolean formUrlEncoded){
this.formUrlEncoded = formUrlEncoded;
return this;
}
final APICall withField(final String field, final Object value){
return withField(field, value, false);
}
final APICall withField(final String field, final Object value, final boolean encoded){
if(value == null)
fields.remove(field);
else
fields.put(field, encoded ? Objects.toString(value) : Java9.URLEncoder.encode(Objects.toString(value), StandardCharsets.UTF_8));
return this;
}
// call
private static final boolean useNetHttp;
@SuppressWarnings("FieldCanBeLocal")
private static class JDK11 {
private static Class> HttpRequest;
private static Method HttpRequest_NewBuilder;
private static Class> HttpRequestBuilder;
private static Method HttpRequestBuilder_URI;
private static Method HttpRequestBuilder_Method;
private static Method HttpRequestBuilder_Header;
private static Method BodyPublishers_NoBody;
private static Method BodyPublishers_StringBody;
private static Method HttpRequestBuilder_Build;
private static Class> HttpClientBuilder;
private static Method HttpClientBuilder_ConnectTimeout;
private static Method HttpClientBuilder_Build;
private static Class> HttpClient;
private static Method HttpClient_NewBuilder;
private static Method HttpClient_Send;
private static Method BodyHandlers_StringBody;
private static Method HttpResponse_Body;
private static Method HttpResponse_Code;
static {
if(useNetHttp)
try{
HttpRequest = Class.forName("java.net.http.HttpRequest");
HttpRequest_NewBuilder = HttpRequest.getDeclaredMethod("newBuilder");
HttpRequestBuilder = Class.forName("java.net.http.HttpRequest$Builder");
HttpRequestBuilder_URI = HttpRequestBuilder.getDeclaredMethod("uri", URI.class);
HttpRequestBuilder_Method = HttpRequestBuilder.getDeclaredMethod("method", String.class, Class.forName("java.net.http.HttpRequest$BodyPublisher"));
HttpRequestBuilder_Header = HttpRequestBuilder.getDeclaredMethod("header", String.class, String.class);
BodyPublishers_NoBody = Class.forName("java.net.http.HttpRequest$BodyPublishers").getDeclaredMethod("noBody");
BodyPublishers_StringBody = Class.forName("java.net.http.HttpRequest$BodyPublishers").getDeclaredMethod("ofString", String.class);
HttpRequestBuilder_Build = HttpRequestBuilder.getDeclaredMethod("build");
HttpClientBuilder = Class.forName("java.net.http.HttpClient$Builder");
HttpClientBuilder_ConnectTimeout = HttpClientBuilder.getDeclaredMethod("connectTimeout", Duration.class);
HttpClientBuilder_Build = HttpClientBuilder.getDeclaredMethod("build");
HttpClient = Class.forName("java.net.http.HttpClient");
HttpClient_NewBuilder = HttpClient.getDeclaredMethod("newBuilder");
HttpClient_Send = HttpClient.getDeclaredMethod("send", Class.forName("java.net.http.HttpRequest"), Class.forName("java.net.http.HttpResponse$BodyHandler"));
BodyHandlers_StringBody = Class.forName("java.net.http.HttpResponse$BodyHandlers").getDeclaredMethod("ofString", Charset.class);
HttpResponse_Body = Class.forName("java.net.http.HttpResponse").getDeclaredMethod("body");
HttpResponse_Code = Class.forName("java.net.http.HttpResponse").getDeclaredMethod("statusCode");
}catch(final ClassNotFoundException | NoSuchMethodException e){
throw new StaticInitializerException("Failed to initialize HttpClient, please report this to the maintainers of Mal4J", e);
}
}
}
// try to initialize HTTPUrlConnection
static {
final String version = System.getProperty("java.version");
useNetHttp = (version != null ? Integer.parseInt(version.contains(".") ? version.substring(0, version.indexOf(".")) : version) : 0) >= 11;
if(!useNetHttp)
try{
Field methods = null;
try{ // Standard Java implementation and Android API 23+ (6.0+)
methods = HttpURLConnection.class.getDeclaredField("methods");
}catch(final NoSuchFieldException ignored){ // Android compatibility fixes below
try{ // Android API 13-22 (3.2 - 5.1.1)
//noinspection JavaReflectionMemberAccess
methods = HttpURLConnection.class.getDeclaredField("PERMITTED_USER_METHODS");
}catch(final NoSuchFieldException ignored1){
try{ // Android API 9-12 (2.3 - 3.1)
//noinspection SpellCheckingInspection
methods = Class.forName("libcore.net.http.HttpURLConnectionImpl").getDeclaredField("PERMITTED_USER_METHODS");
}catch(final ClassNotFoundException | NoSuchFieldException ignored2){
try{ // Android API 1-8 (1 - 2.2.3)
//noinspection JavaReflectionMemberAccess
methods = HttpURLConnection.class.getDeclaredField("methodTokens");
}catch(final NoSuchFieldException ignored3){ }
}
}
}
if(methods != null){
Field modifiers = null;
try{ // Standard Java implementation
modifiers = Field.class.getDeclaredField("modifiers");
}catch(final NoSuchFieldException ignored){ // Android compatibility fixes below
try{ // Android API 2-17 (1.1 - 4.2.2) & Android API 26+ (8.0+)
//noinspection JavaReflectionMemberAccess
modifiers = Field.class.getDeclaredField("accessFlags");
}catch(final NoSuchFieldException ignored1){
try{ // Android API 18-25 (4.3 - 7.1.2)
modifiers = Class.forName("java.lang.reflect.ArtField").getDeclaredField("accessFlags");
}catch(final ClassNotFoundException | NoSuchFieldException ignored2){
// Android API 1 (1.0) [NOT SUPPORTED]
}
}
}
if(modifiers != null){
modifiers.setAccessible(true);
try{
// remove FINAL from field
modifiers.setInt(methods, methods.getModifiers() & ~Modifier.FINAL);
methods.setAccessible(true);
// add PATCH to methods array
final String[] nativeMethods = (String[]) methods.get(null);
final Set newMethods = new HashSet<>(Arrays.asList(nativeMethods));
newMethods.add("PATCH");
methods.set(null, newMethods.toArray(new String[0]));
// reset field to FINAL
modifiers.setInt(methods, methods.getModifiers() | Modifier.FINAL);
methods.setAccessible(false);
modifiers.setAccessible(false);
}catch(final IllegalAccessException ignored){ }
}
}
}catch(final RuntimeException e){
if(e.getClass().getSimpleName().equals("InaccessibleObjectException"))
throw new StaticInitializerException("Reflect module is not accessible in JDK 9+; add '--add-opens java.base/java.lang.reflect=Mal4J --add-opens java.base/java.net=Mal4J' to VM options, remove module-info.java, or compile the project in JDK 8 or JDK 11+", e);
}
}
// [{}|\\^\[\]`]
private static final Pattern blockedURI = Pattern.compile("[{}|\\\\^\\[\\]`]");
private static final URIEncoder encoder = new URIEncoder();
@SuppressWarnings("RedundantThrows")
private APIStruct.Response call() throws IOException, InterruptedException{
final String URL =
baseURL +
Java9.Matcher.replaceAll(path, pathArg.matcher(path), result -> pathVars.get(result.group(1))) + // path args
(queries.isEmpty() ? "" : '?' + queries.entrySet().stream().map(e -> e.getKey() + '=' + e.getValue()).collect(Collectors.joining("&"))); // query
final String data = fields.isEmpty() ? "" : fields.entrySet().stream().map(e -> e.getKey() + '=' + e.getValue()).collect(Collectors.joining("&"));
{
Logging.debug();
Logging.debug("-- BEGIN CONNECTION ------------------------------");
Logging.debug("▼ Request");
Logging.debug('\t' + "URL: " + URL);
Logging.debug('\t' + "Method: " + method.toUpperCase());
if(!headers.entrySet().isEmpty()){
Logging.debug('\t' + "▼ Headers");
for(final Map.Entry entry : headers.entrySet())
Logging.debug("\t\t" + entry.getKey() + ": " + entry.getValue());
}
if(formUrlEncoded){
Logging.debug('\t' + "Body:");
Logging.debug("\t\t" + data);
}
}
String body;
int code;
if(useNetHttp)
try{
// final HttpRequest.Builder request = HttpRequest.newBuilder();
final Object HttpRequestBuilder_Instance = JDK11.HttpRequest_NewBuilder.invoke(null);
// request.uri(URI.create(blockedURI.matcher(URL).replaceAll(encoder)));
JDK11.HttpRequestBuilder_URI
.invoke(HttpRequestBuilder_Instance,
URI.create(Java9.Matcher.replaceAll(URL, blockedURI.matcher(URL),encoder))
);
// request.method(method, HttpRequest.BodyPublishers.noBody());
JDK11.HttpRequestBuilder_Method
.invoke(HttpRequestBuilder_Instance,
method,
JDK11.BodyPublishers_NoBody.invoke(null)
);
for(final Map.Entry entry : headers.entrySet())
// request.header(entry.getKey(), entry.getValue());
JDK11.HttpRequestBuilder_Header
.invoke(HttpRequestBuilder_Instance,
entry.getKey(),
entry.getValue()
);
// request.header("Cache-Control", "no-cache, no-store, must-revalidate");
JDK11.HttpRequestBuilder_Header
.invoke(HttpRequestBuilder_Instance,
"Cache-Control",
"no-cache, no-store, must-revalidate"
);
// request.header("Accept", "application/json; charset=UTF-8");
JDK11.HttpRequestBuilder_Header
.invoke(HttpRequestBuilder_Instance,
"Accept",
"application/json; charset=UTF-8"
);
if(formUrlEncoded){
// request.header("Content-Type", "application/x-www-form-urlencoded");
JDK11.HttpRequestBuilder_Header
.invoke(HttpRequestBuilder_Instance,
"Content-Type",
"application/x-www-form-urlencoded"
);
// request.method(method, HttpRequest.BodyPublishers.ofString(data));
JDK11.HttpRequestBuilder_Method
.invoke(HttpRequestBuilder_Instance,
method,
JDK11.BodyPublishers_StringBody.invoke(null, data)
);
}
// final HttpResponse response = HttpClient
// .newBuilder()
final Object HttpClientBuilder_Instance = JDK11.HttpClient_NewBuilder.invoke(null);
// .connectTimeout(Duration.ofSeconds(10))
JDK11.HttpClientBuilder_ConnectTimeout
.invoke(HttpClientBuilder_Instance, Duration.ofSeconds(10));
// .build()
final Object HttpClient_Instance = JDK11.HttpClientBuilder_Build
.invoke(HttpClientBuilder_Instance);
// .send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
final Object HttpResponse_Instance = JDK11.HttpClient_Send
.invoke(HttpClient_Instance,
JDK11.HttpRequestBuilder_Build.invoke(HttpRequestBuilder_Instance),
JDK11.BodyHandlers_StringBody.invoke(null, StandardCharsets.UTF_8)
);
// response.body()
body = (String) JDK11.HttpResponse_Body.invoke(HttpResponse_Instance);
// response.responseCode()
code = (int) JDK11.HttpResponse_Code.invoke(HttpResponse_Instance);
}catch(final IllegalAccessException | InvocationTargetException | ClassCastException e){
throw new ReflectedClassException("Failed to use reflected HttpClient, please report this to the maintainers of Mal4J", e);
}
else{
final HttpURLConnection conn = (HttpURLConnection) URI.create(Java9.Matcher.replaceAll(URL, blockedURI.matcher(URL), encoder)).toURL().openConnection();
for(final Map.Entry entry : headers.entrySet())
conn.setRequestProperty(entry.getKey(), entry.getValue());
conn.setRequestProperty("Cache-Control", "no-cache, no-store, must-revalidate");
conn.setRequestProperty("Accept", "application/json; charset=UTF-8");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(10_000);
// set request method
try{
conn.setRequestMethod(method);
}catch(final ProtocolException ignored){
// MyAnimeList API supports `X-HTTP-Method-Override`
conn.setRequestMethod("POST");
conn.setRequestProperty("X-HTTP-Method-Override", "PATCH");
}
if(formUrlEncoded){
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.setDoOutput(true);
try(final DataOutputStream OUT = new DataOutputStream(conn.getOutputStream())){
OUT.writeBytes(data);
OUT.flush();
}
}
try(final BufferedReader IN = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))){
String buffer;
final StringBuilder OUT = new StringBuilder();
while((buffer = IN.readLine()) != null)
OUT.append(buffer);
body = OUT.toString();
}catch(final IOException ignored){
body = "{}";
}finally{
conn.disconnect();
}
code = conn.getResponseCode();
}
{
Logging.debug("▼ Response");
Logging.debug('\t' + "Code: " + code);
Logging.debug('\t' + "Body:");
Logging.debug("\t\t" + body);
Logging.debug("-- END CONNECTION --------------------------------");
}
return new APIStruct.Response<>(URL, body, body, code);
}
final Response call(final Function processor) throws IOException, InterruptedException{
final Response response = call();
final String body = response.body();
return new Response<>(response.URL(), body, processor.apply(body), response.code());
}
@Override
public final String toString(){
return "APICall{" +
"useNetHttp=" + useNetHttp +
", method='" + method + '\'' +
", baseURL='" + baseURL + '\'' +
", headers=" + headers +
", path='" + path + '\'' +
", pathVars=" + pathVars +
", queries=" + queries +
", formUrlEncoded=" + formUrlEncoded +
", fields=" + fields +
'}';
}
// replace bad URI chars
private static class URIEncoder implements Function {
@Override
public final String apply(final MatchResult matchResult){
final char ch = matchResult.group().charAt(0);
switch(ch){
case '{':
return "%7B";
case '}':
return "%7D";
case '|':
return "%7C";
case '\\':
return "%5C";
case '^':
return "%5E";
case '[':
return "%5B";
case ']':
return "%5D";
case '`':
return "%60";
default:
return matchResult.group(0);
}
}
}
// interface instantiation
@SuppressWarnings("unchecked")
static C create(final String baseURL, final Class service){
if(!service.isInterface())
throw new IllegalArgumentException("Service must be an interface");
final InvocationHandler handler = new InterfaceInvocation(baseURL, service);
return (C)
Proxy.newProxyInstance(
service.getClassLoader(),
new Class>[]{service},
handler
);
}
private static class InterfaceInvocation implements InvocationHandler {
private final String baseURL;
private final Class> service;
public InterfaceInvocation(final String baseURL, final Class> service){
this.baseURL = baseURL;
this.service = service;
}
@Override
public final Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable{
if(method.getDeclaringClass() != service)
return method.invoke(this, args);
try{
return new APICall(
baseURL,
method,
args
).call(Json::parse);
}catch(final IOException e){
throw new UncheckedIOException(e);
}
}
}
}