package valandur.webapi.swagger; import io.swagger.annotations.*; import io.swagger.annotations.Contact; import io.swagger.annotations.Info; import io.swagger.annotations.License; import io.swagger.converter.ModelConverter; import io.swagger.converter.ModelConverterContextImpl; import io.swagger.converter.ModelConverters; import io.swagger.jaxrs.Reader; import io.swagger.jaxrs.config.ReaderListener; import io.swagger.models.*; import io.swagger.models.parameters.QueryParameter; import io.swagger.models.properties.IntegerProperty; import io.swagger.models.properties.ObjectProperty; import io.swagger.models.properties.Property; import io.swagger.models.properties.StringProperty; import org.spongepowered.api.data.manipulator.DataManipulator; import valandur.webapi.WebAPI; import valandur.webapi.servlet.base.BaseServlet; import valandur.webapi.util.Constants; import javax.ws.rs.core.MediaType; import java.lang.reflect.Field; import java.util.*; import java.util.stream.Collectors; @io.swagger.annotations.SwaggerDefinition( info = @Info( title = Constants.NAME, version = Constants.VERSION, description = "Access Sponge powered Minecraft servers through a WebAPI\n\n" + "# Introduction\n" + "This is the documentation of the various API routes offered by the WebAPI plugin.\n\n" + "This documentation assumes that you are familiar with the basic concepts of Web API's, " + "such as `GET`, `PUT`, `POST` and `DELETE` methods, request `HEADERS` and `RESPONSE CODES` " + "and `JSON` data.\n\n" + "By default this documentation can be found at http:/localhost:8080 " + "(while your minecraft server is running) and the various routes start with " + "http:/localhost:8080/api/v5...\n\n" + "As a quick test try reaching the route http:/localhost:8080/api/v5/info " + "(remember that you can only access \\\"localhost\\\" routes on the server on which you " + "are running minecraft).\n" + "This route should show you basic information about your server, like the motd and " + "player count.\n\n" + "# List endpoints\n" + "Lots of objects offer an endpoint to list all objects (e.g. `GET: /world` to get all worlds). " + "These endpoints return only the properties marked 'required' by default, because the list " + "might be quite large. If you want to return ALL data for a list endpoint add the query " + "parameter `details`, (e.g. `GET: /world?details`).\n\n" + "> Remember that in this case the data returned by the endpoint might be quite large.\n\n" + "# Debugging endpoints\n" + "Apart from the `?details` flag you can also pass some other flags for debugging purposes. " + "Remember that you must include the first query parameter with `?`, and further ones with `&`:\n\n" + "`details`: Includes details for list endpoints\n\n" + "`accept=[json/xml]`: Manually set the accept content type. This is good for browser testing, " + "**BUT DON'T USE THIS IN PRODUCTION, YOU CAN SUPPLY THE `Accepts` HEADER FOR THAT**\n\n" + "`pretty`: Pretty prints the data, also good for debugging in the browser.\n\n" + "An example request might look like this: " + "`http://localhost:8080/api/v5/world?details&accpet=json&pretty&key=MY-API-KEY`\n\n" + "# Additional data\n" + "Certain endpoints (such as `/player`, `/entity` and `/tile-entity` have additional " + "properties which are not documented here, because the data depends on the concrete " + "object type (eg. `Sheep` have a wool color, others do not) and on the other plugins/mods " + "that are running on your server which might add additional data.\n\n" + "You can also find more information in the github docs " + "(https:/github.com/Valandur/Web-API/tree/master/docs/DATA.md)", contact = @Contact( name = "Valandur", email = "[email protected]", url = "https://github.com/Valandur" ), license = @License( name = "MIT", url = "https://github.com/Valandur/Web-API/blob/master/LICENSE" ) ), consumes = { MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }, produces = { MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }, securityDefinition = @SecurityDefinition(apiKeyAuthDefinitions = { @ApiKeyAuthDefinition( key = "ApiKeyHeader", name = "X-WebAPI-Key", description = "Authorize using an HTTP header. This can also be done using the " + "`Authorization` header with a `Bearer` token", in = ApiKeyAuthDefinition.ApiKeyLocation.HEADER), @ApiKeyAuthDefinition( key = "ApiKeyQuery", name = "key", description = "Authorize using a query value.", in = ApiKeyAuthDefinition.ApiKeyLocation.QUERY), }) ) public class SwaggerDefinition implements ReaderListener { private static QueryParameter constructQueryParameter(String name, String description, String... values) { QueryParameter param = new QueryParameter(); param.setName(name); param.setDescription(description); if (values != null && values.length > 0) { param.setType("string"); param.setEnum(Arrays.asList(values)); } else { param.setType("boolean"); } return param; } private static Response constructResponse(int status, String error) { Property statusProp = new IntegerProperty() .description("The status code of the error (also provided in the HTTP header)"); statusProp.setExample(status); Property errorProp = new StringProperty() .description("The error message describing the error"); errorProp.setExample((Object)error); return new Response() .description(error) .schema(new ObjectProperty() .property("status", statusProp) .property("error", errorProp)); } @Override public void beforeScan(Reader reader, Swagger swagger) { swagger.addParameter("details", constructQueryParameter( "details", "Add to include additional details, omit or false otherwise")); swagger.addParameter("accept", constructQueryParameter( "accept", "Override the 'Accept' request header (useful for debugging your requests)", "json", "xml")); swagger.addParameter("pretty", constructQueryParameter( "pretty", "Add to make the Web-API pretty print the response (useful for debugging your requests)")); swagger.response("400", constructResponse(400, "Bad request")); swagger.response("401", constructResponse(401, "Unauthorized")); swagger.response("403", constructResponse(403, "Access denied")); swagger.response("404", constructResponse(404, "Not found")); swagger.response("500", constructResponse(500, "Internal server error")); swagger.response("501", constructResponse(501, "Not implemented")); } @Override public void afterScan(Reader reader, Swagger swagger) { List<String> webapiTags = new ArrayList<>(); List<String> integrationTags = new ArrayList<>(); swagger.setTags(new ArrayList<>()); // Collect tags for servlets for (Class<? extends BaseServlet> servletClass : WebAPI.getServletService().getRegisteredServlets().values()) { Api api = servletClass.getAnnotation(Api.class); String descr = api.value(); Set<String> tags = new HashSet<>(Arrays.asList(api.tags())); tags.addAll(Arrays.stream(servletClass.getMethods()) .flatMap(m -> Arrays.stream(m.getAnnotationsByType(ApiOperation.class))) .flatMap(a -> Arrays.stream(a.tags())) .filter(t -> !t.isEmpty()) .collect(Collectors.toList())); if (servletClass.getPackage().getName().startsWith("valandur.webapi.servlet")) { for (String tag : tags) { webapiTags.add(tag); swagger.addTag(new io.swagger.models.Tag().name(tag).description(descr)); } } else { for (String tag : tags) { integrationTags.add(tag); swagger.addTag(new io.swagger.models.Tag().name(tag).description(descr)); } } } // Sort properties by "required" and alphabetically for (Model model : swagger.getDefinitions().values()) { Map<String, Property> props = new LinkedHashMap<>(); if (model.getProperties() == null) continue; List<Map.Entry<String, Property>> newProps = model.getProperties().entrySet().stream() .sorted((p1, p2) -> { int req = Boolean.compare(p2.getValue().getRequired(), p1.getValue().getRequired()); if (req != 0) return req; return p1.getKey().compareTo(p2.getKey()); }).collect(Collectors.toList()); for (Map.Entry<String, Property> newProp : newProps) { props.put(newProp.getKey(), newProp.getValue()); } model.getProperties().clear(); model.setProperties(props); } // Dirty hack to set up our model converter, because we need access to the context, // otherwise we have to do multiple calls resolving the model, which isn't worth it List<ModelConverter> converters = new ArrayList<>(); try { Field f = ModelConverters.class.getDeclaredField("converters"); f.setAccessible(true); converters = (List<ModelConverter>) f.get(ModelConverters.getInstance()); } catch (IllegalAccessException | NoSuchFieldException e) { e.printStackTrace(); WebAPI.sentryCapture(e); } Set<String> oldModels = swagger.getDefinitions().keySet(); // Generate types for additional data Map<String, Property> props = new LinkedHashMap<>(); // Collect all additional data from our serializer List<Map.Entry<String, Class<? extends DataManipulator<?, ?>>>> dataList = WebAPI.getSerializeService().getSupportedData().entrySet().stream() .sorted(Comparator.comparing(Map.Entry::getKey)) .collect(Collectors.toList()); // Iterate all the additional data for (Map.Entry<String, Class<? extends DataManipulator<?, ?>>> entry : dataList) { String key = entry.getKey(); // Create our context and resolve the model (manually, instead of ModelConverters.getInstance().readYYY() ModelConverterContextImpl context = new ModelConverterContextImpl(converters); Property prop = context.resolveProperty(entry.getValue(), null); // Read the view as a model, and add all the read models to the definition for (Map.Entry<String, Model> modelEntry : context.getDefinedModels().entrySet()) { if (oldModels.contains(modelEntry.getKey())) { continue; } swagger.addDefinition(modelEntry.getKey(), modelEntry.getValue()); } // Add the data we read props.put(key, prop); } // Collect all additional properties from our serializer List<Map.Entry<Class<? extends org.spongepowered.api.data.Property<?, ?>>, String>> propList = WebAPI.getSerializeService().getSupportedProperties().entrySet().stream() .sorted(Comparator.comparing(Map.Entry::getValue)) .collect(Collectors.toList()); // Iterate all the additional props for (Map.Entry<Class<? extends org.spongepowered.api.data.Property<?, ?>>, String> entry : propList) { String key = entry.getValue(); // Create our context and resolve the model (manually, instead of ModelConverters.getInstance().readYYY() ModelConverterContextImpl context = new ModelConverterContextImpl(converters); Property prop = context.resolveProperty(entry.getKey(), null); // Read the view as a model, and add all the read models to the definition for (Map.Entry<String, Model> modelEntry : context.getDefinedModels().entrySet()) { if (oldModels.contains(modelEntry.getKey())) { continue; } swagger.addDefinition(modelEntry.getKey(), modelEntry.getValue()); } // Add the property we read to the data props props.put(key, prop); } // Add the additional properties to the required DataObjects // TODO: Automate this with an annotation Map<String, Model> defs = swagger.getDefinitions(); attachAdditionalProps(defs.get("Player"), props); attachAdditionalProps(defs.get("World"), props); attachAdditionalProps(defs.get("Entity"), props); attachAdditionalProps(defs.get("TileEntity"), props); attachAdditionalProps(defs.get("ItemStack"), props); attachAdditionalProps(defs.get("FluidStack"), props); attachAdditionalProps(defs.get("Slot"), props); // Sort tags alphabetically webapiTags.sort(String::compareTo); integrationTags.sort(String::compareTo); // Sort paths alphabetically List<Map.Entry<String, Path>> paths = swagger.getPaths().entrySet().stream() .sorted(Comparator.comparing(Map.Entry::getKey)) .collect(Collectors.toList()); swagger.setPaths(new LinkedHashMap<>()); for (Map.Entry<String, Path> entry : paths) { swagger.path(entry.getKey(), entry.getValue()); } // Add tag groups for redoc swagger.vendorExtension("x-tagGroups", Arrays.asList( new TagGroup("Web-API", webapiTags), new TagGroup("Integrations", integrationTags) )); } private void attachAdditionalProps(Model model, Map<String, Property> dataProps) { if (model instanceof ComposedModel) { model = ((ComposedModel) model).getChild(); } Map<String, Property> props = model.getProperties(); if (props == null) { props = new HashMap<>(); } for (Map.Entry<String, Property> entry : dataProps.entrySet()) { props.put(entry.getKey(), entry.getValue()); } model.setProperties(props); } public static class TagGroup { private String name; public String getName() { return name; } private List<String> tags; public List<String> getTags() { return tags; } public TagGroup(String name, List<String> tags) { this.name = name; this.tags = tags; } } }