Skip to content

Commit

Permalink
Generate default methods
Browse files Browse the repository at this point in the history
  • Loading branch information
sonallux committed Mar 19, 2021
1 parent e66d333 commit 3c4047a
Show file tree
Hide file tree
Showing 31 changed files with 336 additions and 302 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.google.common.base.CaseFormat;
import de.sonallux.spotify.core.model.SpotifyWebApi;
import de.sonallux.spotify.core.model.SpotifyWebApiEndpoint;
import de.sonallux.spotify.core.model.SpotifyWebApiObject;
import de.sonallux.spotify.generator.java.util.JavaUtils;

import java.util.List;
Expand Down Expand Up @@ -43,19 +42,12 @@ public static String getEndpointRequestBodyName(SpotifyWebApiEndpoint endpoint)
*/
public static void fixDuplicateEndpointParameters(SpotifyWebApiEndpoint endpoint) {
String paramName;
switch (endpoint.getId()) {
case "endpoint-remove-albums-user":
case "endpoint-save-albums-user":
case "endpoint-follow-artists-users":
case "endpoint-unfollow-artists-users":
paramName = "ids";
break;
case "endpoint-replace-playlists-tracks":
case "endpoint-add-tracks-to-playlist":
paramName = "uris";
break;
default:
return;
if (endpoint.getParameters().stream().filter(p -> "ids".equals(p.getName())).count() == 2) {
paramName = "ids";
} else if (endpoint.getParameters().stream().filter(p -> "uris".equals(p.getName())).count() == 2) {
paramName = "uris";
} else {
return;
}
endpoint.getParameters().removeIf(p -> p.getLocation() == QUERY && paramName.equals(p.getName()));
for (var param : endpoint.getParameters()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,11 @@
import lombok.Getter;
import lombok.Setter;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;

import static de.sonallux.spotify.core.model.SpotifyWebApiEndpoint.ParameterLocation.*;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;

public class CategoryTemplate extends AbstractTemplate<SpotifyWebApiCategory> {

Expand All @@ -41,7 +39,7 @@ Map<String, Object> buildContext(SpotifyWebApiCategory category, Map<String, Obj
context.put("name", category.getName());
context.put("className", JavaUtils.getClassName(category));
context.put("documentationLink", category.getLink());
context.put("endpoints", category.getEndpointList().stream().flatMap(e -> buildEndpointContext(e).stream()).collect(Collectors.toList()));
context.put("endpoints", category.getEndpointList().stream().flatMap(e -> buildEndpointContext(e).stream()).collect(toList()));
return context;
}

Expand All @@ -66,106 +64,143 @@ private List<Map<String, Object>> buildEndpointContext(SpotifyWebApiEndpoint end
baseContext.put("responseType", getResponseType(endpoint));
baseContext.put("documentationLink", endpoint.getLink());

List<Map<String, Object>> contexts = new ArrayList<>();
var arguments = getArguments(endpoint);
for (var args : arguments) {
var context = new HashMap<>(baseContext);
context.put("arguments", args.stream().map(Argument::asMethodArgument).collect(Collectors.joining(", ")));
context.put("javaDocParams", args.stream().map(Argument::asJavaDoc).collect(Collectors.toList()));
if (endpoint.getHttpMethod().equals("DELETE") && args.stream().anyMatch(a -> a.getAnnotation().startsWith("@Body"))) {
// Officially DELETE does not allow a request body, but Spotify uses it.
// This adjusts the http method annotation, so retrofit does not throw an error.
context.put("deleteWithBody", true);
}
contexts.add(context);
}
return contexts;
}

private List<List<Argument>> getArguments(SpotifyWebApiEndpoint endpoint) {
EndpointRequestBodyHelper.fixDuplicateEndpointParameters(endpoint);

List<Argument> requiredArgs = new ArrayList<>();
endpoint.getParameters().stream()
.filter(p -> p.getLocation() == PATH)
.map(p -> new Argument("@Path(\"" + p.getName() + "\")", JavaUtils.mapToPrimitiveJavaType(p.getType()), p.getName(), p.getDescription()))
.forEach(requiredArgs::add);

endpoint.getParameters().stream()
.filter(p -> p.getLocation() == QUERY && p.isRequired())
.map(p -> new Argument("@Query(\"" + p.getName() + "\")", JavaUtils.mapToPrimitiveJavaType(p.getType()), p.getName(), p.getDescription()))
.forEach(requiredArgs::add);

boolean requestBodyArgAdded = false;
if (endpoint.getParameters().stream().anyMatch(p -> p.getLocation() == BODY && p.isRequired())) {
requestBodyArgAdded = true;
requiredArgs.add(new Argument(
"@Body", EndpointRequestBodyHelper.getEndpointRequestBodyName(endpoint), "requestBody", "the request body"));
var rawArguments = argumentsFromEndpoint(endpoint);
List<Parameter> requiredParameters = new ArrayList<>();
List<Parameter> optionalParameters = new ArrayList<>();
for (var rawArgument : rawArguments) {
if (rawArgument.isRequired()) {
requiredParameters.add(rawArgument);
} else {
optionalParameters.add(rawArgument);
}
}

List<List<Argument>> args = new ArrayList<>();
args.add(new ArrayList<>(requiredArgs));

var optionalQueryArgs = endpoint.getParameters().stream().filter(p -> p.getLocation() == QUERY && !p.isRequired()).collect(Collectors.toList());
if (optionalQueryArgs.size() == 1) {
var p = optionalQueryArgs.get(0);
requiredArgs.add(new Argument(
"@Query(\"" + p.getName() + "\")", JavaUtils.mapToPrimitiveJavaType(p.getType()), p.getName(), p.getDescription()));
args.add(requiredArgs);
} else if (optionalQueryArgs.size() > 1) {
requiredArgs.add(new Argument(
"@QueryMap", "java.util.Map<String, Object>", "queryParameters", "A map of optional query parameters"));
args.add(requiredArgs);
requiredParameters.sort(Comparator.comparing(Parameter::getOrder));
optionalParameters.sort(Comparator.comparing(Parameter::getOrder));

if (endpoint.getHttpMethod().equals("DELETE") && rawArguments.stream().anyMatch(a -> a.getAnnotation().startsWith("@Body"))) {
// Officially DELETE does not allow a request body, but Spotify uses it.
// This adjusts the http method annotation, so retrofit does not throw an error.
baseContext.put("deleteWithBody", true);
}

if (!requestBodyArgAdded && endpoint.getParameters().stream().anyMatch(p -> p.getLocation() == BODY)) {
List<List<Argument>> endpointArguments = new ArrayList<>();
for (var arguments : args) {
var duplicateArguments = new ArrayList<>(arguments);
duplicateArguments.add(new Argument(
"@Body", EndpointRequestBodyHelper.getEndpointRequestBodyName(endpoint), "requestBody", "The request body"));
endpointArguments.add(arguments);
endpointArguments.add(duplicateArguments);
List<Parameter> allParameters;
if (optionalParameters.size() == 0) {
baseContext.put("requiredParametersMethod", false);
allParameters = requiredParameters;
} else {
allParameters = new ArrayList<>(requiredParameters);
var arguments = requiredParameters.stream().map(Parameter::getFieldName).collect(toList());

// If multiple query parameters are optional, wrap them together in one @QueryMap parameter
var optionalArgumentsWithoutQuery = optionalParameters.stream().filter(a -> !a.getAnnotation().startsWith("@Query")).collect(toList());
if (optionalParameters.size() - optionalArgumentsWithoutQuery.size() > 1) {
allParameters.add(new Parameter("@QueryMap", "java.util.Map<String, Object>", "queryParameters", "A map of optional query parameters", false, 4));
arguments.add("java.util.Map.of()");
optionalParameters = optionalArgumentsWithoutQuery;
}
return endpointArguments;

optionalParameters.forEach(optionalArg -> {
allParameters.add(optionalArg);
arguments.add(getDefaultArgumentValue(endpoint, optionalArg));
});
baseContext.put("requiredParametersMethod", true);
baseContext.put("requiredParameters", requiredParameters.stream().map(Parameter::asMethodArgumentWithoutAnnotation).collect(joining(", ")));
baseContext.put("requiredJavaDocParameters", requiredParameters.stream().map(Parameter::asJavaDoc).collect(toList()));
baseContext.put("arguments", String.join(", ", arguments));
}

return args;
baseContext.put("parameters", allParameters.stream().map(Parameter::asMethodArgument).collect(joining(", ")));
baseContext.put("javaDocParameters", allParameters.stream().map(Parameter::asJavaDoc).collect(toList()));
return List.of(baseContext);
}

private String getDefaultArgumentValue(SpotifyWebApiEndpoint endpoint, Parameter parameter) {
if (parameter.getAnnotation().startsWith("@Body")) {
return "new " + EndpointRequestBodyHelper.getEndpointRequestBodyName(endpoint) + "()";
} else {
return "null";
}
}

private String getResponseType(SpotifyWebApiEndpoint endpoint) {
if (endpoint.getResponseTypes().size() == 1
|| 1 == endpoint.getResponseTypes().stream()
.map(SpotifyWebApiEndpoint.ResponseType::getType).distinct().count()) {
if (endpoint.getResponseTypes().stream()
.map(SpotifyWebApiEndpoint.ResponseType::getType)
.distinct().count() == 1) {
return JavaUtils.mapToPrimitiveJavaType(endpoint.getResponseTypes().get(0).getType());
}
var nonVoidResponseTypes = endpoint.getResponseTypes().stream()
.map(SpotifyWebApiEndpoint.ResponseType::getType).filter(t -> !"Void".equals(t)).distinct().collect(Collectors.toList());
.map(SpotifyWebApiEndpoint.ResponseType::getType).filter(t -> !"Void".equals(t)).distinct().collect(toList());
if (nonVoidResponseTypes.size() == 1) {
return JavaUtils.mapToPrimitiveJavaType(endpoint.getResponseTypes().get(0).getType());
}
return "";
}

private List<Parameter> argumentsFromEndpoint(SpotifyWebApiEndpoint endpoint) {
var hasBodyParameter = new AtomicBoolean(false);
var hasRequiredBodyParameter = new AtomicBoolean(false);

var arguments = endpoint.getParameters().stream().map(parameter -> {
switch (parameter.getLocation()) {
case PATH: {
return new Parameter("@Path(\"" + parameter.getName() + "\")", parameter, 1);
}
case QUERY: {
return new Parameter("@Query(\"" + parameter.getName() + "\")", parameter, 2);
}
case BODY: {
hasBodyParameter.set(true);
if (parameter.isRequired()) {
hasRequiredBodyParameter.set(true);
}
return null;
}
case HEADER: // Ignore header parameters because they are only Authorization and Content-Type header
default: return null;
}
}).filter(Objects::nonNull).collect(toList());

if (hasBodyParameter.get()) {
var requestBodyType = EndpointRequestBodyHelper.getEndpointRequestBodyName(endpoint);
arguments.add(new Parameter("@Body", requestBodyType, "requestBody", "The request body", hasRequiredBodyParameter.get(), 3));
}
return arguments;
}

@Getter
@Setter
private static class Argument {
private static class Parameter {
private String annotation;
private String type;
private String fieldName;
private String description;
private boolean isRequired;
private int order;

public Argument(String annotation, String type, String fieldName, String description) {
public Parameter(String annotation, String type, String fieldName, String description, boolean isRequired, int order) {
this.annotation = annotation;
this.type = type;
this.fieldName = JavaUtils.escapeFieldName(fieldName);
this.description = Markdown2Html.convertToSingleLine(description);
this.isRequired = isRequired;
this.order = order;
}

private Parameter(String annotation, SpotifyWebApiEndpoint.Parameter parameter, int order) {
// Do not map type to primitive type, so we can pass null to it if it is optional
this(annotation, JavaUtils.mapToJavaType(parameter.getType()), parameter.getName(), parameter.getDescription(), parameter.isRequired(), order);
}

public String asMethodArgument() {
return annotation + " " + type + " " + fieldName;
}

public String asMethodArgumentWithoutAnnotation() {
return type + " " + fieldName;
}
public String asJavaDoc() {
return "@param " + fieldName + " " + description;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package de.sonallux.spotify.generator.java.templates;

import com.google.common.base.CaseFormat;
import com.google.common.base.Strings;
import de.sonallux.spotify.core.model.SpotifyWebApiObject;
import de.sonallux.spotify.generator.java.util.JavaPackage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ private Map<String, Object> buildPropertyContext(Property property) {
context.put("hasDescription", true);
context.put("description", Markdown2Html.convertToLines(description));
}
context.put("type", JavaUtils.mapToPrimitiveJavaType(property.getType()));

// Do not use primitive type here, so parameters can be set to null
context.put("type", JavaUtils.mapToJavaType(property.getType()));
context.put("required", property.isRequired());

return context;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package {{package}};

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
Expand Down Expand Up @@ -45,6 +46,7 @@ public class SpotifyWebApi extends BaseSpotifyApi {
private static Retrofit createDefaultRetrofit(OkHttpClient okHttpClient, HttpUrl baseUrl) {
var mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
.registerModule(new JavaTimeModule());
return new Retrofit.Builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import retrofit2.http.*;
*/
public interface {{className}} {
{{#endpoints}}
{{#requiredParametersMethod}}

/**
* <h3>{{name}}</h3>
Expand All @@ -25,9 +26,38 @@ public interface {{className}} {
{{/notes}}
*
{{/hasNotes}}
{{#javaDocParams}}
{{#requiredJavaDocParameters}}
* {{.}}
{{/javaDocParams}}
{{/requiredJavaDocParameters}}
* @return {{responseDescriptionFirstLine}}
{{#responseDescriptionOthers}}
* {{.}}
{{/responseDescriptionOthers}}
* @see <a href="{{documentationLink}}">{{name}}</a>
*/
default Call<{{responseType}}> {{methodName}}({{requiredParameters}}) {
return {{methodName}}({{arguments}});
}
{{/requiredParametersMethod}}

/**
* <h3>{{name}}</h3>
* {{description}}
{{#scopes}}
* <h3>Required OAuth scopes</h3>
* <code>{{scopes}}</code>
{{/scopes}}
*
{{#hasNotes}}
* <h3>Notes</h3>
{{#notes}}
* {{.}}
{{/notes}}
*
{{/hasNotes}}
{{#javaDocParameters}}
* {{.}}
{{/javaDocParameters}}
* @return {{responseDescriptionFirstLine}}
{{#responseDescriptionOthers}}
* {{.}}
Expand All @@ -40,6 +70,6 @@ public interface {{className}} {
{{^deleteWithBody}}
@{{httpMethod}}("{{path}}")
{{/deleteWithBody}}
Call<{{responseType}}> {{methodName}}({{arguments}});
Call<{{responseType}}> {{methodName}}({{parameters}});
{{/endpoints}}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.sonallux.spotify.api;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
Expand Down Expand Up @@ -67,6 +68,7 @@ public SpotifyWebApi() {
private static Retrofit createDefaultRetrofit(OkHttpClient okHttpClient, HttpUrl baseUrl) {
var mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
.registerModule(new JavaTimeModule());
return new Retrofit.Builder()
Expand Down
Loading

0 comments on commit 3c4047a

Please sign in to comment.