From aec1a8d5f675adba6c4e76053ba5700822f08784 Mon Sep 17 00:00:00 2001 From: lguerin Date: Sun, 7 Jun 2015 18:05:08 +0200 Subject: [PATCH] Improve MappingRules in order to match dynamic rule path of custom Rest controllers as declared into UrlMappings when using grouping and constraints --- .../org/restapidoc/utils/BuildPathMap.groovy | 25 ++-- .../restapidoc/utils/JSONDocUtilsLight.groovy | 82 ++++++----- .../restapidoc/utils/MappingRulesEntry.groovy | 132 +++++++++++++++--- .../restapidoc/utils/MappingRulesSpec.groovy | 92 ++++++++++++ 4 files changed, 260 insertions(+), 71 deletions(-) create mode 100644 test/unit/org/restapidoc/utils/MappingRulesSpec.groovy diff --git a/src/groovy/org/restapidoc/utils/BuildPathMap.groovy b/src/groovy/org/restapidoc/utils/BuildPathMap.groovy index 12dc600..46fadeb 100755 --- a/src/groovy/org/restapidoc/utils/BuildPathMap.groovy +++ b/src/groovy/org/restapidoc/utils/BuildPathMap.groovy @@ -48,27 +48,23 @@ class BuildPathMap extends AnsiConsoleUrlMappingsRenderer { final controllerUrlMappings = mappingsByController.get(controller) for (UrlMapping urlMapping in controllerUrlMappings) { def urlPattern = establishUrlPattern(urlMapping, isAnsiEnabled, longestMapping) - - if (urlMapping?.actionName) { - // urlMapping can be either a string or a closure that returns the result - if (urlMapping?.actionName instanceof String) { - rules.addRule(controller.toString(), urlMapping.actionName.toString(), cleanString(urlPattern), urlMapping.httpMethod, grailsApplication.mergedConfig.grails.plugins.restapidoc.defaultFormat) - } else { - urlMapping?.actionName.each { actName -> - urlPattern = urlPattern.replace("\${", "{") //replace ${format} with {format} - rules.addRule(controller.toString(), actName.value, cleanString(urlPattern), actName.key, grailsApplication.mergedConfig.grails.plugins.restapidoc.defaultFormat) - } + // urlMapping can be either a string or a closure that returns the map result + if (urlMapping?.actionName instanceof Map) { + urlMapping?.actionName.each { actName -> + urlPattern = urlPattern.replace("\${", "{") //replace ${format} with {format} + rules.addRule(controller.toString(), actName.value, cleanString(urlPattern), actName.key, grailsApplication.mergedConfig.grails.plugins.restapidoc.defaultFormat) } } + else { + String actionName = urlMapping.actionName?.toString() ?: "" + rules.addRule(controller.toString(), actionName, cleanString(urlPattern), urlMapping.httpMethod, grailsApplication.mergedConfig.grails.plugins.restapidoc.defaultFormat) + } } - } - return rules } //string from url mapping are dirty (escape char,...) public static String cleanString(String dirtyString) { - Pattern escapeCodePattern = Pattern.compile( "\u001B" // escape code + "\\[" @@ -77,7 +73,6 @@ class BuildPathMap extends AnsiConsoleUrlMappingsRenderer { + "[@-~]" + "|\\\$" // Url mapping returns parameters formatted like this : ${parameter}. ); - escapeCodePattern.matcher(dirtyString).replaceAll(""); - + escapeCodePattern.matcher(dirtyString).replaceAll("").trim(); } } diff --git a/src/groovy/org/restapidoc/utils/JSONDocUtilsLight.groovy b/src/groovy/org/restapidoc/utils/JSONDocUtilsLight.groovy index 48e3cbe..ec1a1a6 100755 --- a/src/groovy/org/restapidoc/utils/JSONDocUtilsLight.groovy +++ b/src/groovy/org/restapidoc/utils/JSONDocUtilsLight.groovy @@ -149,26 +149,55 @@ public class JSONDocUtilsLight extends JSONDocUtils { } public RestApiMethodDoc extractMethodDocs(Set> objectClasses, Method method, Class controller, MappingRules rules, String extension) { + //Controller name + String controllerName = controller.simpleName + if (controllerName.endsWith(CONTROLLER_SUFFIX)) { + controllerName = controllerName.substring(0, controllerName.size() - CONTROLLER_SUFFIX.size()) + } - //Retrieve the path/verb to go to this method - MappingRulesEntry rule = rules.getRule(controller.simpleName, method.name) - String verb = method.getAnnotation(RestApiMethod.class).verb().name() + //URL Params + def urlParams = [] + def queryParameters = [] + if (method.isAnnotationPresent(RestApiParams.class)) { + urlParams = RestApiParamDoc.buildFromAnnotation(method.getAnnotation(RestApiParams.class), RestApiParamType.PATH,method) + queryParameters = RestApiParamDoc.buildFromAnnotation(method.getAnnotation(RestApiParams.class), RestApiParamType.QUERY,method) + } + + //Retrieve the path to go to this method + MappingRulesEntry rule = rules.matchRule(controllerName, method.name, urlParams, extension) String path + //Retrieve the verb to go to this method def annotation = method.getAnnotation(RestApiMethod.class) + String verb = method.getAnnotation(RestApiMethod.class).verb().name() + println "annotation.verb()=${annotation.verb()}" + + if (annotation.verb() != RestApiVerb.NULL) { + //verb is defined in the annotation + verb = method.getAnnotation(RestApiMethod.class).verb().name().toUpperCase() + } + else if (rule) { + //verb is defined in the urlmapping + verb = rule.verb + } + else { + verb = "GET" + //if no explicit url mapping rules, take dynamic rule + VERB_PER_METHOD_PREFIX.each { + if (method.name.startsWith(it.key)) { + verb = it.value + } + } + } + if (!annotation.path().equals("Undefined")) { //path is defined in the annotation path = method.getAnnotation(RestApiMethod.class).path() } else if (rule) { //path is defined in the urlmapping - path = rule.path - + path = fillPathAction(rule.path, method.name) } else { //nothing is defined - String controllerName = controller.simpleName - if (controllerName.endsWith(CONTROLLER_SUFFIX)) { - controllerName = controllerName.substring(0, controllerName.size() - CONTROLLER_SUFFIX.size()) - } controllerName = splitCamelToBlank(Introspector.decapitalize(controllerName)) String actionWithPathParam = "/" + method.name @@ -184,29 +213,8 @@ public class JSONDocUtilsLight extends JSONDocUtils { path = "/" + controllerName + actionWithPathParam + format } - path = extension ? path.replace(DEFAULT_FORMAT_NAME, extension) : path - println "annotation.verb()=${annotation.verb()}" - - if (annotation.verb() != RestApiVerb.NULL) { - //verb is defined in the annotation - verb = method.getAnnotation(RestApiMethod.class).verb().name().toUpperCase() - } else if (rule) { - //verb is defined in the urlmapping - verb = rule.verb - - } else { - verb = "GET" - //if no explicit url mapping rules, take dynamic rule - VERB_PER_METHOD_PREFIX.each { - if (method.name.startsWith(it.key)) { - verb = it.value - } - } - - } - RestApiMethodDoc apiMethodDoc = RestApiMethodDoc.buildFromAnnotation(method.getAnnotation(RestApiMethod.class), path, verb, DEFAULT_TYPE); apiMethodDoc.methodName = method.name @@ -214,13 +222,6 @@ public class JSONDocUtilsLight extends JSONDocUtils { apiMethodDoc.setHeaders(RestApiMethodDoc.buildFromAnnotation(method.getAnnotation(RestApiHeaders.class))); } - def urlParams = [] - def queryParameters = [] - if (method.isAnnotationPresent(RestApiParams.class)) { - urlParams = RestApiParamDoc.buildFromAnnotation(method.getAnnotation(RestApiParams.class), RestApiParamType.PATH,method) - queryParameters = RestApiParamDoc.buildFromAnnotation(method.getAnnotation(RestApiParams.class), RestApiParamType.QUERY,method) - } - DEFAULT_PARAMS_QUERY_ALL.each { queryParameters.add(new RestApiParamDoc(it.name, it.description, it.type, "false", new String[0], Enum, "")) } @@ -371,4 +372,11 @@ public class JSONDocUtilsLight extends JSONDocUtils { } return str } + + static String fillPathAction(String path, String action) { + if (path.contains('{action}')) { + return path.replaceFirst(/\{action}/, action) + } + return path + } } diff --git a/src/groovy/org/restapidoc/utils/MappingRulesEntry.groovy b/src/groovy/org/restapidoc/utils/MappingRulesEntry.groovy index ab0019c..661f454 100644 --- a/src/groovy/org/restapidoc/utils/MappingRulesEntry.groovy +++ b/src/groovy/org/restapidoc/utils/MappingRulesEntry.groovy @@ -1,42 +1,136 @@ package org.restapidoc.utils +import groovy.util.logging.Log +import org.restapidoc.pojo.RestApiParamDoc + /** * Created by lrollus on 1/10/14. */ +@Log class MappingRules { -// static String FIRSTCHARPATH = "/api" + Map> rules = new TreeMap() + + public void addRule(String controllerName, String actionName, String path, String verb, String defaultFormat = "") { + def entries = rules.get(controllerName) ?: new ArrayList() + entries.push(new MappingRulesEntry(path: path, verb: verb, action: actionName, format: defaultFormat)) + rules.put(controllerName, entries) + } + + /** + * Match a mapping rule from grails UrlMappings + * @param controller Controller name to look at + * @param action Controller action to match + * @param urlParams Paths params as declared in UrlMappings and constraints + * @param format Output format (json, xml, ...) + * @return + */ + MappingRulesEntry matchRule(String controller, String action, List urlParams = [], String format) { + MappingRulesEntry matchRule + String controllerName = camelUpperCase(controller) + log.info("controller: $controllerName / action: $action") + List entries = rules.get(controllerName) + if (!entries) return + + // Find by action + matchRule = matchByAction(controllerName, action) + if (matchRule) { + println "Matched rule by action: " + matchRule + return matchRule + } + else { + // Find by URL params + matchRule = matchByURLParams(controllerName, urlParams, format) + println "Matched rule by URL params: " + matchRule + } + return matchRule + } -// static String DEFAULT_FORMAT = "json" + /** + * Macth mapping rule by controller action + * @param controllerName + * @param action + * @return + */ + private MappingRulesEntry matchByAction(String controllerName, String action) { + if (!action) return null + return rules.get(controllerName).find { it.action == action } + } - Map rules = new TreeMap() + /** + * Match mapping rule by path params + * @param controllerName + * @param urlParams + * @param format + * @return + */ + private MappingRulesEntry matchByURLParams(String controllerName, List urlParams, String format = "") { + if (urlParams) { + def params = urlParams.collect { it.name } + def matches = rules.get(controllerName)?.findAll { matchAllParams(it.path, params) } + if (matches) { + if (matches.size() > 1) { + // Find out if format has been set + matches = matches.findAll { it.format == format } + } + return matches[0] + } + return null + } + } - public void addRule(String controllerName, String actioName, String path, String verb, String defaultFormat) { - String key = (controllerName + "." + actioName).toUpperCase() - key = key.replace("CONTROLLER", "") + protected String camelUpperCase(String stringToSplit) { + if (!stringToSplit) return "" + String result = stringToSplit[0].toLowerCase() + for (int i = 1; i < stringToSplit.size(); i++) { + def car = stringToSplit[i] + if (car != car.toUpperCase()) { + result = result + car + } else { + result = result + car.toUpperCase() + } + } - String shortPath = path -// if(shortPath.startsWith(FIRSTCHARPATH)) { -// shortPath = shortPath.substring(FIRSTCHARPATH.size()) -// } - //shortPath = shortPath.replace("{format}",defaultFormat) + return result.trim() + } - rules.put(key, new MappingRulesEntry(path: shortPath, verb: verb)) + /** + * Check if rule path match all given params. + * Reserved tokens like {action} or {format} are filtered. + * + * @param path + * @param params + * @return + */ + protected boolean matchAllParams(String path, List params) { + def filtered = ['action', 'format'] + def pattern = /\{\w*}/ + def pathParams = (path =~ pattern).findAll { !(param(it) in filtered) }.collect { param(it) } + return params.sort() == pathParams.sort() } - public MappingRulesEntry getRule(String controllerName, String actionName) { - String key = (controllerName + "." + actionName).toUpperCase() - key = key.replace("CONTROLLER", "") - rules.get(key) + private String param(String tokenParam) { + String param = tokenParam.replaceFirst(/\{/, '') + param = param.replaceFirst(/}/, '') + return param } + @Override + public String toString() { + return "MappingRules{" + + "rules=" + rules + + '}'; + } } class MappingRulesEntry { - public String path - public String verb + + String path + String verb + String action + String format public String toString() { - return "path = $path , verb = $verb" + return "path = $path , verb = $verb , action = $action , format = $format" } } diff --git a/test/unit/org/restapidoc/utils/MappingRulesSpec.groovy b/test/unit/org/restapidoc/utils/MappingRulesSpec.groovy new file mode 100644 index 0000000..47af4ac --- /dev/null +++ b/test/unit/org/restapidoc/utils/MappingRulesSpec.groovy @@ -0,0 +1,92 @@ +package org.restapidoc.utils + +import org.restapidoc.pojo.RestApiParamDoc +import spock.lang.Specification + + +/** + * Created by lguerin on 04/06/15. + */ +class MappingRulesSpec extends Specification { + + MappingRules mappingRules + + def setup() { + mappingRules = new MappingRules() + } + + def "Camel upper case controller name"() { + expect: + expected == mappingRules.camelUpperCase(controller) + + where: + controller | expected + "BookController" | "bookController" + "MyBookController" | "myBookController" + "" | "" + } + + def "Does path match all params"() { + expect: + match == mappingRules.matchAllParams(path, params) + + where: + match | params | path + true | ["bookId"] | "/api/custom/{bookId}/{action}" + false | ["bookId", "authorId"] | "/api/custom/{bookId}/{action}" + true | ["bookId", "authorId"] | "/api/custom/{bookId}/by/{authorId}/{action}" + false | ["bookId"] | "/api/custom/{bookId}/by/{authorId}/{action}" + true | ["bookId", "authorId"] | "/api/custom/{bookId}/by/{authorId}/{action}.{format}" + } + + def "Match rules by action name"() { + given: + mappingRules.addRule "bookController", "save", "/api/book.{format}", "POST", "json" + mappingRules.addRule "bookController", "show", "/api/book/{id}.{format}", "GET", "json" + mappingRules.addRule "bookController", "update", "/api/book/{id}.{format}", "PUT", "json" + mappingRules.addRule "bookController", "delete", "/api/book/{id}.{format}", "DELETE", "json" + mappingRules.addRule "bookController", "listByAuthor", "/api/author/{id}/book.{format}", "GET", "json" + + expect: + path == mappingRules.matchRule(controller, action, "json")?.getPath() + + where: + controller | action | path + "bookController" | "show" | "/api/book/{id}.{format}" + "bookController" | "listByAuthor" | "/api/author/{id}/book.{format}" + "bookController" | "XXX" | null + "XXX" | "" | null + } + + def "Match rules by URL params"() { + given: + mappingRules.addRule "restCustomController", "", "/api/custom/{bookId}/chapter/{chapterId2}/{action}.{format}", "*", "json" + mappingRules.addRule "restCustomController", "", "/api/custom/{bookId}/{action}.{format}", "*", "json" + mappingRules.addRule "restCustomController", "", "/api/custom/{bookId}/{action}.{format}", "DELETE", "json" + List urlParams = [] + urlParams.add(new RestApiParamDoc(name: "bookId")) + + when: + def match = mappingRules.matchRule("restCustomController", "", urlParams, "json") + + then: + match != null + match.path == "/api/custom/{bookId}/{action}.{format}" + + when: 'We have duplicate paths for the same controller with a different verb' + mappingRules.addRule "restCustomController", "", "/api/custom/{bookId}/{action}.{format}", "PUT", "json" + match = mappingRules.matchRule("restCustomController", "", urlParams, "json") + + then: 'Get the first path who match' + match != null + match.path == "/api/custom/{bookId}/{action}.{format}" + + when: 'We have many paths that match given params for the same controller but with a different format' + mappingRules.addRule "restCustomController", "", "/api/custom/xml/{bookId}/{action}.{format}", "PUT", "xml" + match = mappingRules.matchRule("restCustomController", "", urlParams, "xml") + + then: 'Get the path whith the right format' + match != null + match.path == "/api/custom/xml/{bookId}/{action}.{format}" + } +} \ No newline at end of file