diff options
Diffstat (limited to 'backend')
12 files changed, 355 insertions, 1 deletions
diff --git a/backend/build.gradle b/backend/build.gradle index f1abff02..612c32c7 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -24,6 +24,8 @@ repositories { dependencies { compile 'com.google.code.gson:gson:2.8.0' compile 'ch.qos.logback:logback-classic:1.1.8' + compile 'org.jsondoc:spring-boot-starter-jsondoc:1.2.17' + compile 'org.jsondoc:jsondoc-ui-webjar:1.2.17' compile 'org.springframework.boot:spring-boot-starter-websocket' compile 'org.springframework.security:spring-security-core:4.2.0.RELEASE' @@ -49,4 +51,4 @@ jar { } // make sure we build the frontend before creating the jar -jar.dependsOn(':frontend:assemble')
\ No newline at end of file +jar.dependsOn(':frontend:assemble') diff --git a/backend/src/main/java/org/luxons/sevenwonders/SevenWonders.java b/backend/src/main/java/org/luxons/sevenwonders/SevenWonders.java index 2c20c5d3..eba0c607 100644 --- a/backend/src/main/java/org/luxons/sevenwonders/SevenWonders.java +++ b/backend/src/main/java/org/luxons/sevenwonders/SevenWonders.java @@ -1,9 +1,11 @@ package org.luxons.sevenwonders; +import org.jsondoc.spring.boot.starter.EnableJSONDoc; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication +@EnableJSONDoc public class SevenWonders { public static void main(String[] args) { diff --git a/backend/src/main/java/org/luxons/sevenwonders/actions/ChooseNameAction.java b/backend/src/main/java/org/luxons/sevenwonders/actions/ChooseNameAction.java index 670e44ff..9b0ed42b 100644 --- a/backend/src/main/java/org/luxons/sevenwonders/actions/ChooseNameAction.java +++ b/backend/src/main/java/org/luxons/sevenwonders/actions/ChooseNameAction.java @@ -3,8 +3,18 @@ package org.luxons.sevenwonders.actions; import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; +import org.jsondoc.core.annotation.ApiObject; +import org.jsondoc.core.annotation.ApiObjectField; + +@ApiObject + (name = "Choose name action", + description = "The action to choose the player's name. This is the first action that should be called.", + group = "actions") public class ChooseNameAction { + @ApiObjectField(description = "The display name of the player. May contain spaces and special characters.", + required = true, + format = ".{2,20}") @NotNull @Size(min = 2, max = 20) private String playerName; diff --git a/backend/src/main/java/org/luxons/sevenwonders/controllers/GameBrowserController.java b/backend/src/main/java/org/luxons/sevenwonders/controllers/GameBrowserController.java index 46e4531e..23b03127 100644 --- a/backend/src/main/java/org/luxons/sevenwonders/controllers/GameBrowserController.java +++ b/backend/src/main/java/org/luxons/sevenwonders/controllers/GameBrowserController.java @@ -4,6 +4,8 @@ import java.security.Principal; import java.util.Collection; import java.util.Collections; +import org.jsondoc.core.annotation.Api; +import org.jsondoc.core.annotation.ApiMethod; import org.luxons.sevenwonders.actions.CreateGameAction; import org.luxons.sevenwonders.actions.JoinGameAction; import org.luxons.sevenwonders.errors.ApiMisuseException; @@ -21,6 +23,7 @@ import org.springframework.messaging.simp.annotation.SubscribeMapping; import org.springframework.stereotype.Controller; import org.springframework.validation.annotation.Validated; +@Api(name = "Game Browser", description = "This is the place where the player looks for a game") @Controller public class GameBrowserController { @@ -43,12 +46,14 @@ public class GameBrowserController { this.template = template; } + @ApiMethod(description = "Created or updated games. List of existing games received here on subscribe.") @SubscribeMapping("/games") // prefix /topic not shown public Collection<Lobby> listGames(Principal principal) { logger.info("Player '{}' subscribed to /topic/games", principal.getName()); return lobbyRepository.list(); } + @ApiMethod(description = "Create a new lobby.") @MessageMapping("/lobby/create") @SendToUser("/queue/lobby/joined") public Lobby createGame(@Validated CreateGameAction action, Principal principal) { @@ -66,6 +71,7 @@ public class GameBrowserController { return lobby; } + @ApiMethod(description = "Join an existing lobby.") @MessageMapping("/lobby/join") @SendToUser("/queue/lobby/joined") public Lobby joinGame(@Validated JoinGameAction action, Principal principal) { diff --git a/backend/src/main/java/org/luxons/sevenwonders/controllers/GameController.java b/backend/src/main/java/org/luxons/sevenwonders/controllers/GameController.java index 7b03a976..0ec631f6 100644 --- a/backend/src/main/java/org/luxons/sevenwonders/controllers/GameController.java +++ b/backend/src/main/java/org/luxons/sevenwonders/controllers/GameController.java @@ -2,6 +2,8 @@ package org.luxons.sevenwonders.controllers; import java.security.Principal; +import org.jsondoc.core.annotation.Api; +import org.jsondoc.core.annotation.ApiMethod; import org.luxons.sevenwonders.actions.PrepareCardAction; import org.luxons.sevenwonders.game.Game; import org.luxons.sevenwonders.game.api.PlayerTurnInfo; @@ -18,6 +20,7 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; +@Api(name = "Game", description = "In-game events management") @Controller public class GameController { @@ -33,6 +36,7 @@ public class GameController { this.playerRepository = playerRepository; } + @ApiMethod(description = "Prepares the user's card") @MessageMapping("/game/{gameId}/prepare") public void prepareCard(@DestinationVariable long gameId, PrepareCardAction action, Principal principal) { Player player = playerRepository.find(principal.getName()); diff --git a/backend/src/main/java/org/luxons/sevenwonders/controllers/HomeController.java b/backend/src/main/java/org/luxons/sevenwonders/controllers/HomeController.java index 4ac236d3..dd6cad7f 100644 --- a/backend/src/main/java/org/luxons/sevenwonders/controllers/HomeController.java +++ b/backend/src/main/java/org/luxons/sevenwonders/controllers/HomeController.java @@ -2,6 +2,8 @@ package org.luxons.sevenwonders.controllers; import java.security.Principal; +import org.jsondoc.core.annotation.Api; +import org.jsondoc.core.annotation.ApiMethod; import org.luxons.sevenwonders.actions.ChooseNameAction; import org.luxons.sevenwonders.lobby.Player; import org.luxons.sevenwonders.repositories.PlayerRepository; @@ -13,6 +15,7 @@ import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.stereotype.Controller; import org.springframework.validation.annotation.Validated; +@Api(name = "Home", description = "The home page of the game") @Controller public class HomeController { @@ -25,6 +28,7 @@ public class HomeController { this.playerRepository = playerRepository; } + @ApiMethod(description = "Creates/updates the player's name (for the user's session)") @MessageMapping("/chooseName") @SendToUser("/queue/nameChoice") public Player chooseName(@Validated ChooseNameAction action, Principal principal) { diff --git a/backend/src/main/java/org/luxons/sevenwonders/controllers/LobbyController.java b/backend/src/main/java/org/luxons/sevenwonders/controllers/LobbyController.java index 839e27b9..b3345388 100644 --- a/backend/src/main/java/org/luxons/sevenwonders/controllers/LobbyController.java +++ b/backend/src/main/java/org/luxons/sevenwonders/controllers/LobbyController.java @@ -3,6 +3,8 @@ package org.luxons.sevenwonders.controllers; import java.security.Principal; import java.util.Collections; +import org.jsondoc.core.annotation.Api; +import org.jsondoc.core.annotation.ApiMethod; import org.luxons.sevenwonders.actions.ReorderPlayersAction; import org.luxons.sevenwonders.actions.UpdateSettingsAction; import org.luxons.sevenwonders.errors.ApiMisuseException; @@ -17,6 +19,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; import org.springframework.validation.annotation.Validated; +@Api(name = "Lobby", description = "The place where players gather before a game") @Controller public class LobbyController { @@ -32,6 +35,7 @@ public class LobbyController { this.template = template; } + @ApiMethod @MessageMapping("/lobby/reorderPlayers") public void reorderPlayers(@Validated ReorderPlayersAction action, Principal principal) { Lobby lobby = getLobby(principal); @@ -41,6 +45,7 @@ public class LobbyController { sendLobbyUpdateToPlayers(lobby); } + @ApiMethod @MessageMapping("/lobby/updateSettings") public void updateSettings(@Validated UpdateSettingsAction action, Principal principal) { Lobby lobby = getLobby(principal); @@ -55,6 +60,7 @@ public class LobbyController { template.convertAndSend("/topic/games", Collections.singletonList(lobby)); } + @ApiMethod @MessageMapping("/lobby/start") public void startGame(Principal principal) { Lobby lobby = getOwnedLobby(principal); diff --git a/backend/src/main/java/org/luxons/sevenwonders/doc/GlobalDocumentation.java b/backend/src/main/java/org/luxons/sevenwonders/doc/GlobalDocumentation.java new file mode 100644 index 00000000..b925c1cf --- /dev/null +++ b/backend/src/main/java/org/luxons/sevenwonders/doc/GlobalDocumentation.java @@ -0,0 +1,10 @@ +package org.luxons.sevenwonders.doc; + +import org.jsondoc.core.annotation.global.ApiGlobal; +import org.jsondoc.core.annotation.global.ApiGlobalSection; + +@ApiGlobal(sections = { + @ApiGlobalSection(title = "First steps", paragraphs = {"Welcome to JsonDoc."}) +}) +public class GlobalDocumentation { +} diff --git a/backend/src/main/java/org/luxons/sevenwonders/doc/JsonDocController.java b/backend/src/main/java/org/luxons/sevenwonders/doc/JsonDocController.java new file mode 100644 index 00000000..383ae520 --- /dev/null +++ b/backend/src/main/java/org/luxons/sevenwonders/doc/JsonDocController.java @@ -0,0 +1,49 @@ +package org.luxons.sevenwonders.doc; + +import java.util.Arrays; +import java.util.List; + +import org.jsondoc.core.annotation.Api; +import org.jsondoc.core.annotation.ApiMethod; +import org.jsondoc.core.pojo.JSONDoc; +import org.jsondoc.core.pojo.JSONDoc.MethodDisplay; +import org.jsondoc.core.scanner.JSONDocScanner; +import org.luxons.sevenwonders.doc.scanner.JsonDocWebSocketScanner; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; + +@Api(name = "SevenWonders API Documentation", + description = "This controller provides a JSON description of this documentation") +@Controller +public class JsonDocController { + + private String version; + + private String basePath; + + private List<String> packages; + + private JSONDocScanner jsondocScanner; + + private boolean playgroundEnabled = true; + + private MethodDisplay displayMethodAs = MethodDisplay.URI; + + public JsonDocController() { + this.version = "1.0.0"; + this.basePath = "http://localhost:8080"; + this.packages = Arrays.asList("org.luxons.sevenwonders.controllers", "org.luxons.sevenwonders.doc", + "org.luxons.sevenwonders.actions", "org.luxons.sevenwonders.game"); + this.jsondocScanner = new JsonDocWebSocketScanner(); + } + + @ApiMethod(description = "Get the Websocket API documentation for this game") + @RequestMapping(value = "/doc", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody + JSONDoc getApi() { + return jsondocScanner.getJSONDoc(version, basePath, packages, playgroundEnabled, displayMethodAs); + } +} diff --git a/backend/src/main/java/org/luxons/sevenwonders/doc/builders/SpringPathBuilder.java b/backend/src/main/java/org/luxons/sevenwonders/doc/builders/SpringPathBuilder.java new file mode 100644 index 00000000..ca97fb41 --- /dev/null +++ b/backend/src/main/java/org/luxons/sevenwonders/doc/builders/SpringPathBuilder.java @@ -0,0 +1,99 @@ +package org.luxons.sevenwonders.doc.builders; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.annotation.SubscribeMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +public class SpringPathBuilder { + + public static Set<String> buildPath(Method method) { + Set<String> paths = new HashSet<String>(); + + if (method.isAnnotationPresent(MessageMapping.class)) { + paths.addAll(getMappings(method, MessageMapping.class)); + } + if (method.isAnnotationPresent(SubscribeMapping.class)) { + paths.addAll(getMappings(method, SubscribeMapping.class)); + } + if (method.isAnnotationPresent(RequestMapping.class)) { + paths.addAll(getMappings(method, RequestMapping.class)); + } + + return paths; + } + + private static Set<String> getMappings(Method method, Class<? extends Annotation> annotationClass) { + Set<String> controllerMappings = getControllerMappings(method, annotationClass); + Set<String> methodMappings = getMappedPaths(method.getAnnotation(annotationClass)); + + Set<String> mappings = new HashSet<>(); + for (String controllerPath : controllerMappings) { + for (String methodPath : methodMappings) { + mappings.add(join(controllerPath, methodPath)); + } + } + return mappings; + } + + private static String join(String path1, String path2) { + boolean path1HasSep = path1.endsWith("/"); + boolean path2HasSep = path2.startsWith("/"); + if (path1HasSep && path2HasSep) { + return path1 + path2.substring(1); + } + if (!path1HasSep && !path2HasSep && (path1.isEmpty() || !path2.isEmpty())) { + return path1 + '/' + path2; + } + return path1 + path2; + } + + private static Set<String> getControllerMappings(Method method, Class<? extends Annotation> annotationClass) { + Class<?> controller = method.getDeclaringClass(); + if (controller.isAnnotationPresent(annotationClass)) { + return getMappedPaths(controller.getAnnotation(annotationClass)); + } + return Collections.singleton(""); + } + + private static Set<String> getMappedPaths(Annotation mapping) { + Set<String> paths = new HashSet<>(); + paths.addAll(Arrays.asList(valueMapping(mapping))); + paths.addAll(Arrays.asList(pathMapping(mapping))); + if (paths.isEmpty()) { + paths.add(""); + } + return paths; + } + + private static String[] pathMapping(Annotation mapping) { + try { + if (mapping instanceof RequestMapping) { + return ((RequestMapping) mapping).path(); + } + return new String[0]; + } catch (NoSuchMethodError e) { + //Handle the fact that this method is only in Spring 4, not available in Spring 3 + return new String[0]; + } + } + + private static String[] valueMapping(Annotation mapping) { + if (mapping instanceof RequestMapping) { + return ((RequestMapping) mapping).value(); + } + if (mapping instanceof MessageMapping) { + return ((MessageMapping) mapping).value(); + } + if (mapping instanceof SubscribeMapping) { + return ((SubscribeMapping) mapping).value(); + } + return new String[0]; + } +} diff --git a/backend/src/main/java/org/luxons/sevenwonders/doc/scanner/JsonDocWebSocketScanner.java b/backend/src/main/java/org/luxons/sevenwonders/doc/scanner/JsonDocWebSocketScanner.java new file mode 100644 index 00000000..7dc0854e --- /dev/null +++ b/backend/src/main/java/org/luxons/sevenwonders/doc/scanner/JsonDocWebSocketScanner.java @@ -0,0 +1,151 @@ +package org.luxons.sevenwonders.doc.scanner; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.Sets; +import org.jsondoc.core.annotation.Api; +import org.jsondoc.core.annotation.ApiMethod; +import org.jsondoc.core.pojo.ApiMethodDoc; +import org.jsondoc.core.pojo.JSONDocTemplate; +import org.jsondoc.core.scanner.builder.JSONDocApiMethodDocBuilder; +import org.jsondoc.core.util.JSONDocUtils; +import org.jsondoc.springmvc.scanner.Spring4JSONDocScanner; +import org.jsondoc.springmvc.scanner.builder.SpringConsumesBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringHeaderBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringPathVariableBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringProducesBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringQueryParamBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringRequestBodyBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringResponseBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringResponseStatusBuilder; +import org.jsondoc.springmvc.scanner.builder.SpringVerbBuilder; +import org.luxons.sevenwonders.doc.builders.SpringPathBuilder; +import org.reflections.Reflections; +import org.springframework.beans.BeanUtils; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.annotation.SubscribeMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +public class JsonDocWebSocketScanner extends Spring4JSONDocScanner { + + @Override + public Set<Method> jsondocMethods(Class<?> controller) { + Set<Method> annotatedMethods = new LinkedHashSet<Method>(); + for (Method method : controller.getDeclaredMethods()) { + if (shouldDocument(method)) { + annotatedMethods.add(method); + } + } + return annotatedMethods; + } + + private boolean shouldDocument(Method method) { + return method.isAnnotationPresent(RequestMapping.class) || method.isAnnotationPresent(MessageMapping.class) + || method.isAnnotationPresent(SubscribeMapping.class); + } + + @Override + public ApiMethodDoc initApiMethodDoc(Method method, Map<Class<?>, JSONDocTemplate> jsondocTemplates) { + ApiMethodDoc apiMethodDoc = new ApiMethodDoc(); + apiMethodDoc.setPath(SpringPathBuilder.buildPath(method)); + apiMethodDoc.setMethod(method.getName()); + apiMethodDoc.setVerb(SpringVerbBuilder.buildVerb(method)); + apiMethodDoc.setProduces(SpringProducesBuilder.buildProduces(method)); + apiMethodDoc.setConsumes(SpringConsumesBuilder.buildConsumes(method)); + apiMethodDoc.setHeaders(SpringHeaderBuilder.buildHeaders(method)); + apiMethodDoc.setPathparameters(SpringPathVariableBuilder.buildPathVariable(method)); + apiMethodDoc.setQueryparameters(SpringQueryParamBuilder.buildQueryParams(method)); + apiMethodDoc.setBodyobject(SpringRequestBodyBuilder.buildRequestBody(method)); + apiMethodDoc.setResponse(SpringResponseBuilder.buildResponse(method)); + apiMethodDoc.setResponsestatuscode(SpringResponseStatusBuilder.buildResponseStatusCode(method)); + + Integer index = JSONDocUtils.getIndexOfParameterWithAnnotation(method, RequestBody.class); + if (index != -1) { + apiMethodDoc.getBodyobject().setJsondocTemplate(jsondocTemplates.get(method.getParameterTypes()[index])); + } + + return apiMethodDoc; + } + + @Override + public ApiMethodDoc mergeApiMethodDoc(Method method, ApiMethodDoc apiMethodDoc) { + if (method.isAnnotationPresent(ApiMethod.class) && method.getDeclaringClass().isAnnotationPresent(Api.class)) { + ApiMethodDoc jsondocApiMethodDoc = JSONDocApiMethodDocBuilder.build(method); + BeanUtils.copyProperties(jsondocApiMethodDoc, apiMethodDoc, "path", "verb", "produces", "consumes", + "headers", "pathparameters", "queryparameters", "bodyobject", "response", "responsestatuscode", + "apierrors", "supportedversions", "auth", "displayMethodAs"); + } + return apiMethodDoc; + } + + @Override + public Set<Class<?>> jsondocObjects(List<String> packages) { + Set<Method> methodsToDocument = getMethodsToDocument(); + + Set<Class<?>> candidates = Sets.newHashSet(); + Set<Class<?>> subCandidates = Sets.newHashSet(); + + for (Method method : methodsToDocument) { + buildJSONDocObjectsCandidates(candidates, method.getReturnType(), method.getGenericReturnType(), + reflections); + Integer requestBodyParameterIndex = + JSONDocUtils.getIndexOfParameterWithAnnotation(method, RequestBody.class); + if (requestBodyParameterIndex != -1) { + candidates.addAll( + buildJSONDocObjectsCandidates(candidates, method.getParameterTypes()[requestBodyParameterIndex], + method.getGenericParameterTypes()[requestBodyParameterIndex], reflections)); + } + } + + // This is to get objects' fields that are not returned nor part of the body request of a method, but that + // are a field + // of an object returned or a body of a request of a method + for (Class<?> clazz : candidates) { + appendSubCandidates(clazz, subCandidates, reflections); + } + + candidates.addAll(subCandidates); + + return candidates.stream().filter(clazz -> inWhiteListedPackages(packages, clazz)).collect(Collectors.toSet()); + } + + private Set<Method> getMethodsToDocument() { + Set<Method> methodsAnnotatedWith = reflections.getMethodsAnnotatedWith(RequestMapping.class); + methodsAnnotatedWith.addAll(reflections.getMethodsAnnotatedWith(SubscribeMapping.class)); + methodsAnnotatedWith.addAll(reflections.getMethodsAnnotatedWith(MessageMapping.class)); + return methodsAnnotatedWith; + } + + private boolean inWhiteListedPackages(List<String> packages, Class<?> clazz) { + Package p = clazz.getPackage(); + return p != null && packages.stream().anyMatch(whiteListedPkg -> p.getName().startsWith(whiteListedPkg)); + } + + private void appendSubCandidates(Class<?> clazz, Set<Class<?>> subCandidates, Reflections reflections) { + if (clazz.isPrimitive() || clazz.equals(Class.class)) { + return; + } + + for (Field field : clazz.getDeclaredFields()) { + Class<?> fieldClass = field.getType(); + Set<Class<?>> fieldCandidates = new HashSet<>(); + buildJSONDocObjectsCandidates(fieldCandidates, fieldClass, field.getGenericType(), reflections); + + for (Class<?> candidate : fieldCandidates) { + if (!subCandidates.contains(candidate)) { + subCandidates.add(candidate); + + appendSubCandidates(candidate, subCandidates, reflections); + } + } + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 00000000..17c83826 --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,11 @@ +# mandatory configuration +jsondoc.version=1.0 +jsondoc.basePath=http://localhost:8080 +jsondoc.packages[0]=org.luxons.sevenwonders +#jsondoc.packages[1]=org.luxons.sevenwonders.doc +#jsondoc.packages[2]=org.luxons.sevenwonders.controllers +#jsondoc.packages[3]=org.luxons.sevenwonders.actions +#jsondoc.packages[4]=org.luxons.sevenwonders.game +# optional configuration +jsondoc.playgroundEnabled=true +jsondoc.displayMethodAs=URI |