From ec9e3c38bccf20ee21c520a46b936734cc50d56d Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Wed, 22 Feb 2023 11:55:01 +0100 Subject: [PATCH] Added functionality to use a SkipInertia annotation for controllers and/or actions that should not be handled by the InertiaInterceptor. --- gradle.properties | 2 +- .../plugin/inertia/InertiaInterceptor.groovy | 29 ++++-- .../annotation/AnnotationExcluder.java | 67 +++++++++++++ .../inertia/annotation/SkipInertia.java | 12 +++ .../annotation/AnnotationExcluderSpec.groovy | 98 +++++++++++++++++++ 5 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 src/main/java/grails/plugin/inertia/annotation/AnnotationExcluder.java create mode 100644 src/main/java/grails/plugin/inertia/annotation/SkipInertia.java create mode 100644 src/test/groovy/grails/plugin/inertia/annotation/AnnotationExcluderSpec.groovy diff --git a/gradle.properties b/gradle.properties index 3c8caf4..560f05d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ #Thu, 09 Feb 2023 08:09:33 +0000 -version=1.1.1-SNAPSHOT +version=1.2.0-SNAPSHOT grailsVersion=5.3.2 grailsGradlePluginVersion=5.3.0 groovyVersion=3.0.14 diff --git a/grails-app/controllers/grails/plugin/inertia/InertiaInterceptor.groovy b/grails-app/controllers/grails/plugin/inertia/InertiaInterceptor.groovy index c803a9d..34c7e2e 100644 --- a/grails-app/controllers/grails/plugin/inertia/InertiaInterceptor.groovy +++ b/grails-app/controllers/grails/plugin/inertia/InertiaInterceptor.groovy @@ -1,21 +1,23 @@ package grails.plugin.inertia import grails.config.Config +import grails.core.GrailsApplication +import grails.core.support.GrailsApplicationAware import grails.core.support.GrailsConfigurationAware +import grails.plugin.inertia.annotation.AnnotationExcluder +import grails.plugin.inertia.annotation.SkipInertia import grails.util.Environment import grails.util.Holders import groovy.json.JsonSlurper import groovy.transform.CompileStatic +import io.micronaut.http.HttpStatus -import static grails.web.http.HttpHeaders.VARY -import static javax.servlet.http.HttpServletResponse.SC_CONFLICT -import static javax.servlet.http.HttpServletResponse.SC_MOVED_TEMPORARILY -import static javax.servlet.http.HttpServletResponse.SC_SEE_OTHER -import static Inertia.INERTIA_ATTRIBUTE_VERSION import static Inertia.INERTIA_ATTRIBUTE_MANIFEST +import static Inertia.INERTIA_ATTRIBUTE_VERSION +import static grails.web.http.HttpHeaders.VARY @CompileStatic -class InertiaInterceptor implements GrailsConfigurationAware { +class InertiaInterceptor implements GrailsConfigurationAware, GrailsApplicationAware { String manifestLocation String manifestHash = 'not yet calculated' @@ -28,7 +30,14 @@ class InertiaInterceptor implements GrailsConfigurationAware { private static final String CONTENT_TYPE_JSON = 'application/json;charset=utf-8' private static final String CONTENT_TYPE_HTML = 'text/html;charset=utf-8' - InertiaInterceptor() { match controller: '*' } + InertiaInterceptor() { + match controller: '*' + } + + @Override + void setGrailsApplication(GrailsApplication grailsApplication) { + AnnotationExcluder.excludeAnnotations(this, grailsApplication, SkipInertia) + } boolean before() { @@ -42,7 +51,7 @@ class InertiaInterceptor implements GrailsConfigurationAware { if(isInertiaRequest && isGetRequest && manifestShouldBeUsed && isAssetsOutOfDate) { log.debug 'Inertia asset version has changed, notifying Inertia client and aborting request processing to force full inertiaPage reload!' header Inertia.INERTIA_HEADER_LOCATION, webRequest.currentRequest.forwardURI - render status: SC_CONFLICT + render status: HttpStatus.CONFLICT.code return false } @@ -53,7 +62,7 @@ class InertiaInterceptor implements GrailsConfigurationAware { // Changes the status code during redirects, ensuring they are made as // GET requests, preventing "MethodNotAllowedHttpException" errors. - if (methodNotAllowedShouldBePrevented) response.status = SC_SEE_OTHER + if (methodNotAllowedShouldBePrevented) response.status = HttpStatus.SEE_OTHER.code // Add the Javascript Manifest when not in Development Environment // In Development Environment a node server should be started to serve the javascript files (npm run serve) @@ -86,7 +95,7 @@ class InertiaInterceptor implements GrailsConfigurationAware { boolean getManifestShouldBeUsed() { Environment.current != Environment.DEVELOPMENT } boolean getIsGetRequest() { 'GET' == request.method } - boolean getMethodNotAllowedShouldBePrevented() { isInertiaRequest && response.status == SC_MOVED_TEMPORARILY && request.method in ['PUT', 'PATCH', 'DELETE'] } + boolean getMethodNotAllowedShouldBePrevented() { isInertiaRequest && response.status == HttpStatus.FOUND.code && request.method in ['PUT', 'PATCH', 'DELETE'] } boolean getIsInertiaHtmlView() { modelAndView?.viewName == Inertia.INERTIA_VIEW_HTML } boolean getIsInertiaRequest() { request.getHeader(INERTIA_HEADER_NAME) == INERTIA_HEADER_VALUE } boolean getIsAssetsCurrent() { diff --git a/src/main/java/grails/plugin/inertia/annotation/AnnotationExcluder.java b/src/main/java/grails/plugin/inertia/annotation/AnnotationExcluder.java new file mode 100644 index 0000000..a84a0ec --- /dev/null +++ b/src/main/java/grails/plugin/inertia/annotation/AnnotationExcluder.java @@ -0,0 +1,67 @@ +package grails.plugin.inertia.annotation; + +import grails.artefact.Interceptor; +import grails.core.GrailsApplication; +import grails.core.GrailsClass; +import grails.core.GrailsControllerClass; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Objects; + +public class AnnotationExcluder { + + private static final Logger log = LoggerFactory.getLogger(AnnotationExcluder.class); + + /** + * Excludes all Controllers and/or Actions that have the provided annotation applied to them from + * triggering the provided interceptor. + * @param interceptor The interceptor to exclude the matches for + * @param grailsApplication The current grails application + * @param annotation The annotation to search for + */ + public static void excludeAnnotations(final Interceptor interceptor, final GrailsApplication grailsApplication, final Class annotation) { + final GrailsClass[] controllers = grailsApplication.getArtefacts("Controller"); + + for (GrailsClass controller : controllers) { + + final String controllerName = controller.getLogicalPropertyName(); + final Class controllerClazz = controller.getClazz(); + final Object namespace = getControllerNamespace(controllerClazz); + final Annotation classAnnotation = controllerClazz.getAnnotation(annotation); + + if (classAnnotation != null) { + handleAction("*", namespace, controllerName, interceptor); + } else{ + Arrays.stream(controllerClazz.getMethods()) + .filter(method -> method.getAnnotation(annotation) != null && Modifier.isPublic(method.getModifiers())) + .forEach(methodAction -> handleAction(methodAction.getName(), namespace, controllerName, interceptor)); + + Arrays.stream(controllerClazz.getDeclaredFields()) + .filter( field -> field.getAnnotation(annotation) != null ) + .forEach( fieldAction -> handleAction(fieldAction.getName(), namespace, controllerName, interceptor)); + } + } + } + + private static void handleAction(String actionName, Object namespace, String controllerName, Interceptor interceptor) { + if(log.isDebugEnabled()) log.debug("Excluding namespace: {}, controller: {}, action: {} from interceptor: {}", namespace, controllerName, actionName, interceptor.getClass().getName()); + LinkedHashMap args = new LinkedHashMap<>(); + args.put("namespace", namespace); + args.put("controller", controllerName); + args.put("action", actionName); + interceptor.getMatchers().forEach(matcher -> matcher.except(args)); + } + + private static Object getControllerNamespace(Class controllerClazz) { + return Arrays.stream(controllerClazz.getDeclaredFields()) + .filter(field -> Objects.equals(GrailsControllerClass.NAMESPACE_PROPERTY, field.getName()) && Modifier.isStatic(field.getModifiers())) + .findFirst() + .map(field -> { try { return field.get(null); } catch (IllegalAccessException e) { return null; } }) + .orElse(null); + } +} diff --git a/src/main/java/grails/plugin/inertia/annotation/SkipInertia.java b/src/main/java/grails/plugin/inertia/annotation/SkipInertia.java new file mode 100644 index 0000000..f0b72e3 --- /dev/null +++ b/src/main/java/grails/plugin/inertia/annotation/SkipInertia.java @@ -0,0 +1,12 @@ +package grails.plugin.inertia.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE, ElementType.FIELD}) +public @interface SkipInertia { + String value() default ""; +} diff --git a/src/test/groovy/grails/plugin/inertia/annotation/AnnotationExcluderSpec.groovy b/src/test/groovy/grails/plugin/inertia/annotation/AnnotationExcluderSpec.groovy new file mode 100644 index 0000000..1491e1d --- /dev/null +++ b/src/test/groovy/grails/plugin/inertia/annotation/AnnotationExcluderSpec.groovy @@ -0,0 +1,98 @@ +package grails.plugin.inertia.annotation + +import grails.artefact.Interceptor +import grails.testing.web.interceptor.InterceptorUnitTest +import grails.web.Controller +import spock.lang.Specification + +import java.lang.annotation.ElementType +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +class AnnotationExcluderSpec extends Specification implements InterceptorUnitTest { + + def 'it excludes controller classes with annotation'() { + given: + grailsApplication.addArtefact('Controller', SkipClassController) + + when: + withRequest(controller: 'skipClass', action: 'action') + + then: + interceptor.doesMatch() + + when: + AnnotationExcluder.excludeAnnotations(interceptor, grailsApplication, SkipAnnotation) + + then: + !interceptor.doesMatch() + } + + def 'it excludes controller method actions with annotation'() { + given: + grailsApplication.addArtefact('Controller', SkipMethodActionController) + + when: + withRequest(controller: 'skipMethodAction', action: 'action') + + then: + interceptor.doesMatch() + + when: + AnnotationExcluder.excludeAnnotations(interceptor, grailsApplication, SkipAnnotation) + + then: + !interceptor.doesMatch() + } + + def 'it excludes controller closure actions with annotation'() { + given: + grailsApplication.addArtefact('Controller', SkipClosureActionController) + + when: + withRequest(controller: 'skipClosureAction', action: 'action') + + then: + interceptor.doesMatch() + + when: + AnnotationExcluder.excludeAnnotations(interceptor, grailsApplication, SkipAnnotation) + + then: + !interceptor.doesMatch() + } +} + +@Controller +@SkipAnnotation +class SkipClassController { + + @SuppressWarnings('unused') + def action() {} +} + +@Controller +class SkipMethodActionController { + + @SkipAnnotation + @SuppressWarnings('unused') + def action() {} +} + +@Controller +class SkipClosureActionController { + @SkipAnnotation + @SuppressWarnings('unused') + def action = {} +} + +class AllMatchingInterceptor implements Interceptor { + AllMatchingInterceptor() { matchAll() } +} + +@Retention(RetentionPolicy.RUNTIME) +@Target([ElementType.METHOD, ElementType.TYPE, ElementType.FIELD]) +@interface SkipAnnotation { + String value() default ""; +}