From c7c855028531b8650265d34972fb8c038d3005f9 Mon Sep 17 00:00:00 2001 From: Istvan Meszaros Date: Thu, 3 Sep 2020 12:11:02 +0200 Subject: [PATCH 1/3] Parameter lookup feature for script and MVC routes. --- jooby/src/main/java/io/jooby/Context.java | 51 ++++++ jooby/src/main/java/io/jooby/ParamLookup.java | 146 ++++++++++++++++++ jooby/src/main/java/io/jooby/ParamSource.java | 63 ++++++++ .../main/java/io/jooby/annotations/Param.java | 43 ++++++ .../io/jooby/internal/ParamLookupImpl.java | 35 +++++ .../main/java/io/jooby/apt/Annotations.java | 36 +++-- .../jooby/internal/apt/ParamDefinition.java | 8 + .../java/io/jooby/internal/apt/ParamKind.java | 25 ++- .../internal/apt/asm/ParamLookupWriter.java | 71 +++++++++ .../source/ParamSourceCheckerContext.java | 22 +++ .../java/source/RouteWithParamLookup.java | 72 +++++++++ .../test/java/tests/ModuleCompilerTest.java | 89 ++++++++++- .../src/test/java/io/jooby/LookupTest.java | 106 +++++++++++++ 13 files changed, 752 insertions(+), 15 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/ParamLookup.java create mode 100644 jooby/src/main/java/io/jooby/ParamSource.java create mode 100644 jooby/src/main/java/io/jooby/annotations/Param.java create mode 100644 jooby/src/main/java/io/jooby/internal/ParamLookupImpl.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/asm/ParamLookupWriter.java create mode 100644 modules/jooby-apt/src/test/java/source/ParamSourceCheckerContext.java create mode 100644 modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java create mode 100644 modules/jooby-test/src/test/java/io/jooby/LookupTest.java diff --git a/jooby/src/main/java/io/jooby/Context.java b/jooby/src/main/java/io/jooby/Context.java index bc01c134e5..2a4937a857 100644 --- a/jooby/src/main/java/io/jooby/Context.java +++ b/jooby/src/main/java/io/jooby/Context.java @@ -5,6 +5,7 @@ */ package io.jooby; +import io.jooby.internal.ParamLookupImpl; import io.jooby.internal.ReadOnlyContext; import io.jooby.internal.WebSocketSender; @@ -22,6 +23,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Locale; @@ -715,6 +717,55 @@ public interface Context extends Registry { */ @Nonnull FileUpload file(@Nonnull String name); + /* ********************************************************************************************** + * Parameter Lookup + * ********************************************************************************************** + */ + + /** + * Searches for a parameter in the specified sources, in the specified + * order, returning the first non-missing {@link Value}, or a 'missing' + * {@link Value} if none found. + *

+ * At least one {@link ParamSource} must be specified. + * + * @param name The name of the parameter. + * @param sources Sources to search in. + * @return The first non-missing {@link Value} or a {@link Value} representing + * a missing value if none found. + * @throws IllegalArgumentException If no {@link ParamSource}s are specified. + */ + default Value lookup(String name, ParamSource... sources) { + if (sources.length == 0) { + throw new IllegalArgumentException("No parameter sources were specified."); + } + + return Arrays.stream(sources) + .map(source -> source.provider.apply(this, name)) + .filter(value -> !value.isMissing()) + .findFirst() + .orElseGet(() -> Value.missing(name)); + } + + /** + * Returns a {@link ParamLookup} instance which is a fluent interface covering + * the functionality of the {@link #lookup(String, ParamSource...)} method. + * + *

{@code
+   *  Value foo = ctx.lookup()
+   *    .inQuery()
+   *    .inPath()
+   *    .get("foo");
+   * }
+ * + * @return A {@link ParamLookup} instance. + * @see ParamLookup + * @see #lookup(String, ParamSource...) + */ + default ParamLookup lookup() { + return new ParamLookupImpl(this); + } + /* ********************************************************************************************** * Request Body * ********************************************************************************************** diff --git a/jooby/src/main/java/io/jooby/ParamLookup.java b/jooby/src/main/java/io/jooby/ParamLookup.java new file mode 100644 index 0000000000..8d63a2f23b --- /dev/null +++ b/jooby/src/main/java/io/jooby/ParamLookup.java @@ -0,0 +1,146 @@ +/** + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.util.Optional; + +/** + * Fluent interface allowing to conveniently search context parameters + * in multiple sources. + * + *
{@code
+ *  Value foo = ctx.lookup()
+ *    .inQuery()
+ *    .inPath()
+ *    .get("foo");
+ * }
+ * + * @see Context#lookup() + * @see Context#lookup(String, ParamSource...) + */ +public interface ParamLookup { + + /** + * Adds the specified source to the search locations. + * + * @param source The source to add. + * @return This instance. + */ + Stage in(ParamSource source); + + /** + * Adds the path parameters to the search locations. + * + * @return This instance. + */ + default Stage inPath() { + return in(ParamSource.PATH); + } + + /** + * Adds the header parameters to the search locations. + * + * @return This instance. + */ + default Stage inHeader() { + return in(ParamSource.HEADER); + } + + /** + * Adds the cookie parameters to the search locations. + * + * @return This instance. + */ + default Stage inCookie() { + return in(ParamSource.COOKIE); + } + + /** + * Adds the flash parameters to the search locations. + * + * @return This instance. + */ + default Stage inFlash() { + return in(ParamSource.FLASH); + } + + /** + * Adds the session parameters to the search locations. + * + * @return This instance. + */ + default Stage inSession() { + return in(ParamSource.SESSION); + } + + /** + * Adds the query parameters to the search locations. + * + * @return This instance. + */ + default Stage inQuery() { + return in(ParamSource.QUERY); + } + + /** + * Adds the form parameters to the search locations. + * + * @return This instance. + */ + default Stage inForm() { + return in(ParamSource.FORM); + } + + /** + * Adds the multipart parameters to the search locations. + * + * @return This instance. + */ + default Stage inMultipart() { + return in(ParamSource.MULTIPART); + } + + /** + * Fluent interface allowing to conveniently search context parameters + * in multiple sources. + * + *
{@code
+   *  Value foo = ctx.lookup()
+   *    .inQuery()
+   *    .inPath()
+   *    .get("foo");
+   * }
+ * + * @see Context#lookup() + * @see Context#lookup(String, ParamSource...) + */ + interface Stage extends ParamLookup { + + /** + * Searches for a parameter in the specified sources, in the specified + * order, returning the first non-missing {@link Value}, or a 'missing' + * {@link Value} if none found. + * + * @param name The name of the parameter. + * @return The first non-missing {@link Value} or a {@link Value} representing + * a missing value if none found. + */ + Value get(String name); + + /** + * Wraps the result of {@link #get(String)} in an {@link Optional} if the + * value is a {@link ValueNode} or returns an empty {@link Optional} + * otherwise. + * + * @param name The name of the parameter. + * @return An {@link Optional} wrapping the result of {@link #get(String)} + */ + default Optional getNode(String name) { + return Optional.of(get(name)) + .map(v -> v instanceof ValueNode ? (ValueNode) v : null); + } + } +} diff --git a/jooby/src/main/java/io/jooby/ParamSource.java b/jooby/src/main/java/io/jooby/ParamSource.java new file mode 100644 index 0000000000..51b99522b9 --- /dev/null +++ b/jooby/src/main/java/io/jooby/ParamSource.java @@ -0,0 +1,63 @@ +/** + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.util.function.BiFunction; + +/** + * List of possible parameter sources supported by {@link Context#lookup(String, ParamSource...)}. + * + * @see io.jooby.annotations.Param + * @see Context#lookup(String, ParamSource...) + */ +public enum ParamSource { + + /** + * Source equivalent to {@link Context#path(String)}. + */ + PATH(Context::path), + + /** + * Source equivalent to {@link Context#header(String)}. + */ + HEADER(Context::header), + + /** + * Source equivalent to {@link Context#cookie(String)}. + */ + COOKIE(Context::cookie), + + /** + * Source equivalent to {@link Context#flash(String)}. + */ + FLASH(Context::flash), + + /** + * Source equivalent to {@link Context#session(String)}. + */ + SESSION(Context::session), + + /** + * Source equivalent to {@link Context#query(String)}. + */ + QUERY(Context::query), + + /** + * Source equivalent to {@link Context#form(String)}. + */ + FORM(Context::form), + + /** + * Source equivalent to {@link Context#multipart(String)}. + */ + MULTIPART(Context::multipart); + + final BiFunction provider; + + ParamSource(BiFunction provider) { + this.provider = provider; + } +} diff --git a/jooby/src/main/java/io/jooby/annotations/Param.java b/jooby/src/main/java/io/jooby/annotations/Param.java new file mode 100644 index 0000000000..5f5de5374d --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotations/Param.java @@ -0,0 +1,43 @@ +/** + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotations; + +import io.jooby.ParamSource; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Allow access to a parameter from MVC route method and from multiple sources. + *

+ * The order of {@link ParamSource}s defines the search priority. + * + *

{@code
+ *  public String search(@Param(sources = { ParamSource.QUERY, ParamSource.PATH }) String q) {
+ *    ...
+ *  }
+ * }
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Param { + + /** + * Parameter name. + * + * @return Parameter name. + */ + String value() default ""; + + /** + * Parameter sources to search in, the order defines the search priority. + * + * @return Parameter sources to search in. + */ + ParamSource[] sources(); +} diff --git a/jooby/src/main/java/io/jooby/internal/ParamLookupImpl.java b/jooby/src/main/java/io/jooby/internal/ParamLookupImpl.java new file mode 100644 index 0000000000..9dd9051dd7 --- /dev/null +++ b/jooby/src/main/java/io/jooby/internal/ParamLookupImpl.java @@ -0,0 +1,35 @@ +/** + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal; + +import io.jooby.Context; +import io.jooby.ParamLookup; +import io.jooby.ParamSource; +import io.jooby.Value; + +import java.util.ArrayList; +import java.util.List; + +public class ParamLookupImpl implements ParamLookup.Stage { + + private final Context context; + private final List chain = new ArrayList<>(); + + public ParamLookupImpl(Context context) { + this.context = context; + } + + @Override + public Stage in(ParamSource source) { + chain.add(source); + return this; + } + + @Override + public Value get(String name) { + return context.lookup(name, chain.toArray(new ParamSource[0])); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java b/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java index 2d73a721d5..66ed5e7b6e 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java @@ -19,6 +19,7 @@ import io.jooby.annotations.PATCH; import io.jooby.annotations.POST; import io.jooby.annotations.PUT; +import io.jooby.annotations.Param; import io.jooby.annotations.Path; import io.jooby.annotations.PathParam; import io.jooby.annotations.Produces; @@ -134,6 +135,13 @@ public interface Annotations { javax.ws.rs.FormParam.class.getName() ))); + /** + * Parameter lookup. + */ + Set PARAM_LOOKUP = unmodifiableSet(new LinkedHashSet<>(asList( + Param.class.getName() + ))); + /** * Produces parameters. */ @@ -166,25 +174,33 @@ public interface Annotations { * @return List of values. */ static @Nonnull List attribute(@Nonnull AnnotationMirror mirror, @Nonnull String name) { - Function cleanValue = arg -> { - if (arg instanceof AnnotationValue) { - return ((AnnotationValue) arg).getValue().toString(); - } - return arg.toString(); - }; + return attribute(mirror, name, v -> v.getValue().toString()); + } + + /** + * Get an annotation value. + * + * @param mirror Annotation. + * @param name Attribute name. + * @param mapper Mapper function. + * @param Return type. + * @return List of values. + */ + static @Nonnull List attribute(@Nonnull AnnotationMirror mirror, + @Nonnull String name, @Nonnull Function mapper) { for (Map.Entry entry : mirror .getElementValues().entrySet()) { if (entry.getKey().getSimpleName().toString().equals(name)) { Object value = entry.getValue().getValue(); if (value instanceof List) { - List values = (List) value; - return (List) values.stream() - .map(cleanValue) + List values = (List) value; + return values.stream() + .map(mapper) .filter(Objects::nonNull) .collect(Collectors.toList()); } - String singleValue = cleanValue.apply(value); + T singleValue = mapper.apply(entry.getValue()); return singleValue == null ? Collections.emptyList() : Collections.singletonList(singleValue); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java index a3c8b6013f..502b618f6e 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java @@ -135,6 +135,14 @@ public boolean isSimpleType() { return false; } + public String[] sources() { + return annotations(parameter.getAnnotationMirrors(), this.kind.annotations()).stream() + .flatMap(it -> Annotations.attribute(it, "sources", v -> + ((VariableElement) v.getValue()).getSimpleName().toString()).stream()) + + .toArray(String[]::new); + } + private Class[] builtinTypes() { return new Class[]{ String.class, diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java index edf5071df9..5b74db2a97 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java @@ -8,12 +8,14 @@ import io.jooby.Context; import io.jooby.Formdata; import io.jooby.Multipart; +import io.jooby.ParamSource; import io.jooby.apt.Annotations; -import io.jooby.internal.apt.asm.ContextParamWriter; -import io.jooby.internal.apt.asm.NamedParamWriter; import io.jooby.internal.apt.asm.BodyWriter; +import io.jooby.internal.apt.asm.ContextParamWriter; import io.jooby.internal.apt.asm.FileUploadWriter; +import io.jooby.internal.apt.asm.NamedParamWriter; import io.jooby.internal.apt.asm.ObjectTypeWriter; +import io.jooby.internal.apt.asm.ParamLookupWriter; import io.jooby.internal.apt.asm.ParamWriter; import javax.lang.model.type.TypeMirror; @@ -182,6 +184,19 @@ public enum ParamKind { return new NamedParamWriter(); } }, + PARAM_LOOKUP { + @Override public Set annotations() { + return Annotations.PARAM_LOOKUP; + } + + @Override public Method singleValue(ParamDefinition param) throws NoSuchMethodException { + return Context.class.getDeclaredMethod("lookup", String.class, ParamSource[].class); + } + + @Override public ParamWriter newWriter() { + return new ParamLookupWriter(); + } + }, ROUTE_PARAM { @Override public Set annotations() { @@ -211,10 +226,12 @@ public ParamWriter newWriter() { throw new UnsupportedOperationException(); } - public abstract Method valueObject(ParamDefinition param) throws NoSuchMethodException; + public Method valueObject(ParamDefinition param) throws NoSuchMethodException { + throw new UnsupportedOperationException("No value object method for: '" + param + "'"); + } public Method singleValue(ParamDefinition param) throws NoSuchMethodException { - throw new UnsupportedOperationException("No value method for: '" + param + "'"); + throw new UnsupportedOperationException("No single value method for: '" + param + "'"); } public static ParamKind forTypeInjection(ParamDefinition param) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/asm/ParamLookupWriter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/asm/ParamLookupWriter.java new file mode 100644 index 0000000000..4cc93836be --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/asm/ParamLookupWriter.java @@ -0,0 +1,71 @@ +/** + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt.asm; + +import io.jooby.ParamSource; +import io.jooby.internal.apt.ParamDefinition; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Type; + +import java.lang.reflect.Method; +import java.util.Map; + +import static org.objectweb.asm.Opcodes.AASTORE; +import static org.objectweb.asm.Opcodes.ANEWARRAY; +import static org.objectweb.asm.Opcodes.BIPUSH; +import static org.objectweb.asm.Opcodes.DUP; +import static org.objectweb.asm.Opcodes.GETSTATIC; +import static org.objectweb.asm.Opcodes.ICONST_0; +import static org.objectweb.asm.Opcodes.ICONST_1; +import static org.objectweb.asm.Opcodes.ICONST_2; +import static org.objectweb.asm.Opcodes.ICONST_3; +import static org.objectweb.asm.Opcodes.ICONST_4; +import static org.objectweb.asm.Opcodes.ICONST_5; +import static org.objectweb.asm.Opcodes.INVOKEINTERFACE; +import static org.objectweb.asm.Type.getMethodDescriptor; + +public class ParamLookupWriter extends ValueWriter { + + @Override + public void accept(ClassWriter writer, String handlerInternalName, MethodVisitor visitor, + ParamDefinition parameter, Map registry) throws Exception { + + String parameterName = parameter.getHttpName(); + String[] sources = parameter.sources(); + Method paramMethod = parameter.getSingleValue(); + String internalName = Type.getType(ParamSource.class).getInternalName(); + String descriptor = Type.getDescriptor(ParamSource.class); + + visitor.visitLdcInsn(parameterName); + pushInt(sources.length, visitor); + visitor.visitTypeInsn(ANEWARRAY, internalName); + + for (int i = 0, n = sources.length; i < n; ++i) { + visitor.visitInsn(DUP); + pushInt(i, visitor); + visitor.visitFieldInsn(GETSTATIC, internalName, sources[i], descriptor); + visitor.visitInsn(AASTORE); + } + + visitor.visitMethodInsn(INVOKEINTERFACE, CTX.getInternalName(), paramMethod.getName(), + getMethodDescriptor(paramMethod), true); + + super.accept(writer, handlerInternalName, visitor, parameter, registry); + } + + private void pushInt(int value, MethodVisitor visitor) { + switch (value) { + case 0: visitor.visitInsn(ICONST_0); break; + case 1: visitor.visitInsn(ICONST_1); break; + case 2: visitor.visitInsn(ICONST_2); break; + case 3: visitor.visitInsn(ICONST_3); break; + case 4: visitor.visitInsn(ICONST_4); break; + case 5: visitor.visitInsn(ICONST_5); break; + default: visitor.visitIntInsn(BIPUSH, value); + } + } +} diff --git a/modules/jooby-apt/src/test/java/source/ParamSourceCheckerContext.java b/modules/jooby-apt/src/test/java/source/ParamSourceCheckerContext.java new file mode 100644 index 0000000000..19ee4439ad --- /dev/null +++ b/modules/jooby-apt/src/test/java/source/ParamSourceCheckerContext.java @@ -0,0 +1,22 @@ +package source; + +import io.jooby.MockContext; +import io.jooby.ParamSource; +import io.jooby.Value; + +import java.util.function.Consumer; + +public class ParamSourceCheckerContext extends MockContext { + + private final Consumer onLookup; + + public ParamSourceCheckerContext(Consumer onLookup) { + this.onLookup = onLookup; + } + + @Override + public Value lookup(String name, ParamSource... sources) { + onLookup.accept(sources); + return super.lookup(name, sources); + } +} diff --git a/modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java b/modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java new file mode 100644 index 0000000000..6d7eca68b6 --- /dev/null +++ b/modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java @@ -0,0 +1,72 @@ +package source; + +import io.jooby.annotations.GET; +import io.jooby.annotations.Param; + +import javax.annotation.Nullable; + +import static io.jooby.ParamSource.COOKIE; +import static io.jooby.ParamSource.FLASH; +import static io.jooby.ParamSource.FORM; +import static io.jooby.ParamSource.HEADER; +import static io.jooby.ParamSource.PATH; +import static io.jooby.ParamSource.QUERY; +import static io.jooby.ParamSource.SESSION; + +public class RouteWithParamLookup { + + @GET("/lookup/no-sources") + public String lookupNoSources(@Param(sources = {}) int myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/source-num-1") + public String lookupSourceNum1(@Nullable @Param(sources = { PATH }) String myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/source-num-2") + public String lookupSourceNum2(@Nullable @Param(sources = { PATH, HEADER }) String myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/source-num-3") + public String lookupSourceNum3(@Nullable @Param(sources = { PATH, HEADER, COOKIE }) String myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/source-num-4") + public String lookupSourceNum4(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH }) String myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/source-num-5") + public String lookupSourceNum5(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH, SESSION }) String myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/source-num-6") + public String lookupSourceNum6(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH, SESSION, QUERY }) String myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/source-num-6plus") + public String lookupSourceNum6plus(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH, SESSION, QUERY, FORM }) String myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/query-path/{myParam}") + public String lookupQueryPath(@Param(sources = { QUERY, PATH }) int myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/path-query/{myParam}") + public String lookupPathQuery(@Param(sources = { PATH, QUERY }) int myParam) { + return String.valueOf(myParam); + } + + @GET("/lookup/missing") + public String lookupMissing(@Param(sources = { PATH, QUERY }) String myParam) { + return String.valueOf(myParam); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/ModuleCompilerTest.java b/modules/jooby-apt/src/test/java/tests/ModuleCompilerTest.java index d1c7b44a30..dc48c01191 100644 --- a/modules/jooby-apt/src/test/java/tests/ModuleCompilerTest.java +++ b/modules/jooby-apt/src/test/java/tests/ModuleCompilerTest.java @@ -1,7 +1,9 @@ package tests; import io.jooby.Context; +import io.jooby.MockContext; import io.jooby.MockRouter; +import io.jooby.ParamSource; import io.jooby.Route; import io.jooby.StatusCode; import io.jooby.apt.MvcModuleCompilerRunner; @@ -12,10 +14,12 @@ import source.JavaBeanParam; import source.MinRoute; import source.NoPathRoute; +import source.ParamSourceCheckerContext; import source.PrimitiveReturnType; import source.RouteAttributes; import source.RouteDispatch; import source.RouteWithMimeTypes; +import source.RouteWithParamLookup; import source.Routes; import source.VoidRoute; @@ -23,10 +27,18 @@ import java.util.List; import java.util.Map; +import static io.jooby.ParamSource.COOKIE; +import static io.jooby.ParamSource.FLASH; +import static io.jooby.ParamSource.FORM; +import static io.jooby.ParamSource.HEADER; +import static io.jooby.ParamSource.PATH; +import static io.jooby.ParamSource.QUERY; +import static io.jooby.ParamSource.SESSION; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class ModuleCompilerTest { @@ -97,6 +109,81 @@ public void routesWithMimeTypes() throws Exception { }); } + @Test + public void routesWithParamLookup() throws Exception { + new MvcModuleCompilerRunner(new RouteWithParamLookup()) + .module(app -> { + MockRouter router = new MockRouter(app); + + Throwable t = assertThrows(IllegalArgumentException.class, () -> router.get("/lookup/no-sources").value()); + + assertEquals(t.getMessage(), "No parameter sources were specified."); + + { + MockContext context = new ParamSourceCheckerContext(sources -> assertArrayEquals(sources, new ParamSource[] { + PATH + })); + router.get("/lookup/source-num-1", context).value(); + } + + { + MockContext context = new ParamSourceCheckerContext(sources -> assertArrayEquals(sources, new ParamSource[] { + PATH, HEADER + })); + router.get("/lookup/source-num-2", context).value(); + } + + { + MockContext context = new ParamSourceCheckerContext(sources -> assertArrayEquals(sources, new ParamSource[] { + PATH, HEADER, COOKIE + })); + router.get("/lookup/source-num-3", context).value(); + } + + { + MockContext context = new ParamSourceCheckerContext(sources -> assertArrayEquals(sources, new ParamSource[] { + PATH, HEADER, COOKIE, FLASH + })); + router.get("/lookup/source-num-4", context).value(); + } + + { + MockContext context = new ParamSourceCheckerContext(sources -> assertArrayEquals(sources, new ParamSource[] { + PATH, HEADER, COOKIE, FLASH, SESSION + })); + router.get("/lookup/source-num-5", context).value(); + } + + { + MockContext context = new ParamSourceCheckerContext(sources -> assertArrayEquals(sources, new ParamSource[] { + PATH, HEADER, COOKIE, FLASH, SESSION, QUERY + })); + router.get("/lookup/source-num-6", context).value(); + } + + { + MockContext context = new ParamSourceCheckerContext(sources -> assertArrayEquals(sources, new ParamSource[] { + PATH, HEADER, COOKIE, FLASH, SESSION, QUERY, FORM + })); + router.get("/lookup/source-num-6plus", context).value(); + } + + { + MockContext context = new MockContext(); + context.setQueryString("?myParam=69"); + assertEquals("69", router.get("/lookup/query-path/42", context).value()); + } + + { + MockContext context = new MockContext(); + context.setQueryString("?myParam=69"); + assertEquals("42", router.get("/lookup/path-query/42", context).value()); + } + + assertEquals("null", router.get("/lookup/missing").value()); + }); + } + @Test public void voidRoutes() throws Exception { new MvcModuleCompilerRunner(new VoidRoute()) diff --git a/modules/jooby-test/src/test/java/io/jooby/LookupTest.java b/modules/jooby-test/src/test/java/io/jooby/LookupTest.java new file mode 100644 index 0000000000..47d841db9d --- /dev/null +++ b/modules/jooby-test/src/test/java/io/jooby/LookupTest.java @@ -0,0 +1,106 @@ +package io.jooby; + +import io.jooby.exception.MissingValueException; +import io.jooby.internal.FormdataNode; +import io.jooby.internal.MultipartNode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +import static io.jooby.ParamSource.COOKIE; +import static io.jooby.ParamSource.FLASH; +import static io.jooby.ParamSource.FORM; +import static io.jooby.ParamSource.HEADER; +import static io.jooby.ParamSource.MULTIPART; +import static io.jooby.ParamSource.PATH; +import static io.jooby.ParamSource.QUERY; +import static io.jooby.ParamSource.SESSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class LookupTest { + + private MockContext context; + + @BeforeEach + public void makeContext() { + context = new MockContext(); + + Map pathMap = new HashMap<>(); + pathMap.put("foo", "path-bar"); + context.setPathMap(pathMap); + + context.setRequestHeader("foo", "header-bar"); + + Map cookieMap = new HashMap<>(); + cookieMap.put("foo", "cookie-bar"); + context.setCookieMap(cookieMap); + + context.setFlashAttribute("foo", "flash-bar"); + + MockSession session = new MockSession(); + session.put("foo", "session-bar"); + context.setSession(session); + + context.setQueryString("?foo=query-bar"); + + FormdataNode formdataNode = new FormdataNode(context); + formdataNode.put("foo", "form-bar"); + context.setForm(formdataNode); + + Multipart multipart = new MultipartNode(context); + multipart.put("foo", "multipart-bar"); + context.setMultipart(multipart); + } + + @Test + public void testNoSources() { + Throwable t = Assertions.assertThrows(IllegalArgumentException.class, this::test); + assertEquals(t.getMessage(), "No parameter sources were specified."); + } + + @Test + public void testPriority() { + test(PATH, HEADER, COOKIE, FLASH, SESSION, QUERY, FORM, MULTIPART); + test(HEADER, COOKIE, FLASH, SESSION, QUERY, FORM, MULTIPART, PATH); + test(COOKIE, FLASH, SESSION, QUERY, FORM, MULTIPART, PATH, HEADER); + test(FLASH, SESSION, QUERY, FORM, MULTIPART, PATH, HEADER, COOKIE); + test(SESSION, QUERY, FORM, MULTIPART, PATH, HEADER, COOKIE, FLASH); + test(QUERY, FORM, MULTIPART, PATH, HEADER, COOKIE, FLASH, SESSION); + test(FORM, MULTIPART, PATH, HEADER, COOKIE, FLASH, SESSION, QUERY); + test(MULTIPART, PATH, HEADER, COOKIE, FLASH, SESSION, QUERY, FORM); + + test(PATH, l -> l.inPath().inHeader().inCookie().inFlash().inSession().inQuery().inForm().inMultipart()); + test(HEADER, l -> l.inHeader().inCookie().inFlash().inSession().inQuery().inForm().inMultipart().inPath()); + test(COOKIE, l -> l.inCookie().inFlash().inSession().inQuery().inForm().inMultipart().inPath().inHeader()); + test(FLASH, l -> l.inFlash().inSession().inQuery().inForm().inMultipart().inPath().inHeader().inCookie()); + test(SESSION, l -> l.inSession().inQuery().inForm().inMultipart().inPath().inHeader().inCookie().inFlash()); + test(QUERY, l -> l.inQuery().inForm().inMultipart().inPath().inHeader().inCookie().inFlash().inSession()); + test(FORM, l -> l.inForm().inMultipart().inPath().inHeader().inCookie().inFlash().inSession().inQuery()); + test(MULTIPART, l -> l.inMultipart().inPath().inHeader().inCookie().inFlash().inSession().inQuery().inForm()); + } + + @Test + public void testMissingValue() { + assertThrows(MissingValueException.class, + () -> new MockContext().lookup("foo", PATH, HEADER, COOKIE, FLASH, SESSION, QUERY, FORM, MULTIPART).value()); + + assertThrows(MissingValueException.class, + () -> new MockContext().lookup().inPath().inHeader().inCookie().inFlash().inSession().inQuery().inForm().inMultipart().get("foo").value()); + } + + private void test(ParamSource... sources) { + String value = context.lookup("foo", sources).value(); + assertEquals(sources[0].name().toLowerCase() + "-bar", value); + } + + private void test(ParamSource firstSource, Function lookup) { + String value = lookup.apply(context.lookup()).get("foo").value(); + assertEquals(firstSource.name().toLowerCase() + "-bar", value); + } +} From 1e8f86d5757da9e4e00eb71c278da21c1704bda1 Mon Sep 17 00:00:00 2001 From: Istvan Meszaros Date: Thu, 3 Sep 2020 17:12:33 +0200 Subject: [PATCH 2/3] Change requests. --- .../main/java/io/jooby/annotations/Param.java | 6 ++--- .../main/java/io/jooby/apt/Annotations.java | 4 ++-- .../jooby/internal/apt/ParamDefinition.java | 4 ++-- .../java/io/jooby/internal/apt/ParamKind.java | 8 +++++++ .../java/source/RouteWithParamLookup.java | 22 +++++++++---------- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/jooby/src/main/java/io/jooby/annotations/Param.java b/jooby/src/main/java/io/jooby/annotations/Param.java index 5f5de5374d..2a6a69580c 100644 --- a/jooby/src/main/java/io/jooby/annotations/Param.java +++ b/jooby/src/main/java/io/jooby/annotations/Param.java @@ -18,7 +18,7 @@ * The order of {@link ParamSource}s defines the search priority. * *
{@code
- *  public String search(@Param(sources = { ParamSource.QUERY, ParamSource.PATH }) String q) {
+ *  public String search(@Param({ ParamSource.QUERY, ParamSource.PATH }) String q) {
  *    ...
  *  }
  * }
@@ -32,12 +32,12 @@ * * @return Parameter name. */ - String value() default ""; + String name() default ""; /** * Parameter sources to search in, the order defines the search priority. * * @return Parameter sources to search in. */ - ParamSource[] sources(); + ParamSource[] value(); } diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java b/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java index 66ed5e7b6e..314a9f2a7a 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/Annotations.java @@ -138,9 +138,9 @@ public interface Annotations { /** * Parameter lookup. */ - Set PARAM_LOOKUP = unmodifiableSet(new LinkedHashSet<>(asList( + Set PARAM_LOOKUP = unmodifiableSet(Collections.singleton( Param.class.getName() - ))); + )); /** * Produces parameters. diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java index 502b618f6e..462d24db4d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamDefinition.java @@ -137,7 +137,7 @@ public boolean isSimpleType() { public String[] sources() { return annotations(parameter.getAnnotationMirrors(), this.kind.annotations()).stream() - .flatMap(it -> Annotations.attribute(it, "sources", v -> + .flatMap(it -> Annotations.attribute(it, "value", v -> ((VariableElement) v.getValue()).getSimpleName().toString()).stream()) .toArray(String[]::new); @@ -311,7 +311,7 @@ private List annotations(List anno private String parameterName(VariableElement parameter, Set types) { return annotations(parameter.getAnnotationMirrors(), types).stream() - .flatMap(it -> Annotations.attribute(it, "value").stream()) + .flatMap(it -> Annotations.attribute(it, kind.httpNameMemberName()).stream()) .findFirst() .orElse(parameter.getSimpleName().toString()); } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java index 5b74db2a97..26fd852b3a 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParamKind.java @@ -196,6 +196,10 @@ public enum ParamKind { @Override public ParamWriter newWriter() { return new ParamLookupWriter(); } + + @Override public String httpNameMemberName() { + return "name"; + } }, ROUTE_PARAM { @@ -234,6 +238,10 @@ public Method singleValue(ParamDefinition param) throws NoSuchMethodException { throw new UnsupportedOperationException("No single value method for: '" + param + "'"); } + public String httpNameMemberName() { + return "value"; + } + public static ParamKind forTypeInjection(ParamDefinition param) { TypeMirror type = param.isOptional() ? param.getType().getArguments().get(0).getRawType() : diff --git a/modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java b/modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java index 6d7eca68b6..7402c9974b 100644 --- a/modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java +++ b/modules/jooby-apt/src/test/java/source/RouteWithParamLookup.java @@ -16,57 +16,57 @@ public class RouteWithParamLookup { @GET("/lookup/no-sources") - public String lookupNoSources(@Param(sources = {}) int myParam) { + public String lookupNoSources(@Param({}) int myParam) { return String.valueOf(myParam); } @GET("/lookup/source-num-1") - public String lookupSourceNum1(@Nullable @Param(sources = { PATH }) String myParam) { + public String lookupSourceNum1(@Nullable @Param({ PATH }) String myParam) { return String.valueOf(myParam); } @GET("/lookup/source-num-2") - public String lookupSourceNum2(@Nullable @Param(sources = { PATH, HEADER }) String myParam) { + public String lookupSourceNum2(@Nullable @Param({ PATH, HEADER }) String myParam) { return String.valueOf(myParam); } @GET("/lookup/source-num-3") - public String lookupSourceNum3(@Nullable @Param(sources = { PATH, HEADER, COOKIE }) String myParam) { + public String lookupSourceNum3(@Nullable @Param({ PATH, HEADER, COOKIE }) String myParam) { return String.valueOf(myParam); } @GET("/lookup/source-num-4") - public String lookupSourceNum4(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH }) String myParam) { + public String lookupSourceNum4(@Nullable @Param({ PATH, HEADER, COOKIE, FLASH }) String myParam) { return String.valueOf(myParam); } @GET("/lookup/source-num-5") - public String lookupSourceNum5(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH, SESSION }) String myParam) { + public String lookupSourceNum5(@Nullable @Param({ PATH, HEADER, COOKIE, FLASH, SESSION }) String myParam) { return String.valueOf(myParam); } @GET("/lookup/source-num-6") - public String lookupSourceNum6(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH, SESSION, QUERY }) String myParam) { + public String lookupSourceNum6(@Nullable @Param({ PATH, HEADER, COOKIE, FLASH, SESSION, QUERY }) String myParam) { return String.valueOf(myParam); } @GET("/lookup/source-num-6plus") - public String lookupSourceNum6plus(@Nullable @Param(sources = { PATH, HEADER, COOKIE, FLASH, SESSION, QUERY, FORM }) String myParam) { + public String lookupSourceNum6plus(@Nullable @Param({ PATH, HEADER, COOKIE, FLASH, SESSION, QUERY, FORM }) String myParam) { return String.valueOf(myParam); } @GET("/lookup/query-path/{myParam}") - public String lookupQueryPath(@Param(sources = { QUERY, PATH }) int myParam) { + public String lookupQueryPath(@Param({ QUERY, PATH }) int myParam) { return String.valueOf(myParam); } @GET("/lookup/path-query/{myParam}") - public String lookupPathQuery(@Param(sources = { PATH, QUERY }) int myParam) { + public String lookupPathQuery(@Param({ PATH, QUERY }) int myParam) { return String.valueOf(myParam); } @GET("/lookup/missing") - public String lookupMissing(@Param(sources = { PATH, QUERY }) String myParam) { + public String lookupMissing(@Param({ PATH, QUERY }) String myParam) { return String.valueOf(myParam); } } From 2c9f6658116ee79cbb3b0a8166d594a83c445f31 Mon Sep 17 00:00:00 2001 From: Istvan Meszaros Date: Thu, 3 Sep 2020 18:11:11 +0200 Subject: [PATCH 3/3] Javadocs for parameter lookup. --- docs/asciidoc/context.adoc | 42 ++++++++++++++++++++++++++++++++++++-- docs/asciidoc/mvc-api.adoc | 33 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/asciidoc/context.adoc b/docs/asciidoc/context.adoc index 212a24970e..d51d24251d 100644 --- a/docs/asciidoc/context.adoc +++ b/docs/asciidoc/context.adoc @@ -44,12 +44,15 @@ registry while the request it belongs to is being processed. === Parameters -There are several parameter types: `header`, `cookie`, `path`, `query`, `form` and `multipart`. -All them share an unified/type-safe API for accessing and manipulating their values. +There are several parameter types: `header`, `cookie`, `path`, `query`, `form`, `multipart`, +`session` and `flash`. All them share a unified/type-safe API for accessing and manipulating their values. We are going to describe them briefly in the next sections, then go into specific features of the <>. +There is also a <> feature by which you can access a parameter from any combination +of the above types with well-defined priority. + ==== Header HTTP headers allow the client and the server to pass additional information with the request or the @@ -568,6 +571,41 @@ Flash attributes are implemented using an `HTTP Cookie`. To customize the cookie install(FlashScope(Cookie("myflash").setHttpOnly(true))) ---- +==== Parameter Lookup + +You can search for parameters in multiple sources with an explicitly defined priority using the +javadoc:Context[lookup] or javadoc:Context[lookup, java.lang.String, ParamSource...] method: + +.Java +[source,java,role="primary"] +---- +get("/{foo}", ctx -> { + String foo = ctx.lookup("foo", ParamSource.QUERY, ParamSource.PATH).value(); + return "foo is: " + foo; +}); + +get("/{foo}", ctx -> { + String foo = ctx.lookup().inQuery().inPath().get("foo").value(); + return "foo is: " + foo; +}); +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +get("/{foo}") { ctx -> + val foo = ctx.lookup("foo", ParamSource.QUERY, ParamSource.PATH).value() + "foo is: $foo" +} + +get("/{foo}") { ctx -> + val foo = ctx.lookup().inQuery().inPath()["foo"].value() + "foo is: $foo" +} +---- + +In case of a request like `/bar?foo=baz`, `foo is: baz` will be returned since the query parameter +takes precedence over the path parameter. include::value-api.adoc[] diff --git a/docs/asciidoc/mvc-api.adoc b/docs/asciidoc/mvc-api.adoc index 6dc8c03908..4424f1b995 100644 --- a/docs/asciidoc/mvc-api.adoc +++ b/docs/asciidoc/mvc-api.adoc @@ -220,6 +220,9 @@ MVC routes (Singleton vs Non-Singleton routes). HTTP parameter provision is available via `*Param` annotations. +There is also a javadoc:annotations.Param[] annotation which allows to retrieve parameters from +<>. + ==== Header Provisioning of headers is available via javadoc:annotations.HeaderParam[] annotation: @@ -600,6 +603,36 @@ class MyController { <1> All context attributes must be set as arguments. They must be declared as `Map` +==== Multiple Sources + +You can use the javadoc:annotations.Param[] annotation to search for a parameter in multiple sources. +The sources and their precedence can be specified as follows: + +.Multiple Sources +[source, java, role = "primary"] +---- +public class FooController { + + @GET("/{foo}") + public String multipleSources(@Param({ QUERY, PATH }) String foo) { + return "foo is: " + foo; + } +} +---- + +.Kotlin +[source, kotlin, role = "secondary"] +---- +class FooController { + + @GET("/{foo}") + fun multipleSources(@Param(QUERY, PATH) foo: String) = "foo is: $foo" +} +---- + +In case of a request like `/bar?foo=baz`, `foo is: baz` will be returned since the query parameter +takes precedence over the path parameter. + === Responses ==== Status Code