Hi, I am familiarizing myself with the OpenAPI plugin API, notably using annotations and may need some nudge/guidance on how to use the annotation API for non-static and wrapped Handler endpoints.
Inspired by the tutorial and documentation snippets, I crafted a MVP using a Handler that is defined via a final static (case 1), final-only (case 2) and one with a custom/wrapped Handler implementation that internally branches depending on the context's 'Accept' header:
// [..] standard imports [..]
public class Test {
private static final String ENDPOINT_TEST1 = "/test1";
private static final String ENDPOINT_TEST2 = "/test2";
private static final String ENDPOINT_TEST3 = "/test3";
public Test() {
Javalin instance = RestServer.getInstance();
instance.routes(() -> {
get(ENDPOINT_TEST1, serveTestPage1);
get(ENDPOINT_TEST2, serveTestPage2);
get(ENDPOINT_TEST3, serveTestPage3);
});
}
@OpenApi(description = "GET endpoint test V1", operationId = "serveTestPage", summary = "to serve",
tags = { "Test" }, path = ENDPOINT_TEST1, method = HttpMethod.GET,
responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) })
private static final Handler serveTestPage1 = ctx -> { // works OK
ctx.result("serveTestPage1 path = " + ctx.path());
// [serving request based on ctx]
};
@OpenApi(description = "GET endpoint test V2", operationId = "serveTestPage2", summary = "to serve",
tags = { "Test" }, path = ENDPOINT_TEST2, method = HttpMethod.GET,
responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) })
private final Handler serveTestPage2 = ctx -> { // note: w/o 'static' modifier
ctx.result("serveTestPage2 path = " + ctx.path());
// [serving request based on ctx]
};
@OpenApi(description = "GET endpoint test V3", operationId = "serveTestPage3", summary = "to serve",
tags = { "Test" }, path = ENDPOINT_TEST3, method = HttpMethod.GET,
responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) })
private final Handler serveTestPage3 = new CombinedHandler(ctx -> { // note: wrapped Handler implementation
ctx.result("serveTestPage3 path = " + ctx.path());
// [serving request based on ctx] serving BINARY/JSON/YML based on user 'Accept' header request
});
/**
* little helper class to allow regular GET and SSE initialization via the same end-point
*/
public class CombinedHandler implements Handler {
private final Handler getHandler;
public CombinedHandler(Handler getHandler) {
this.getHandler = getHandler; // end-user/service specific Handler code
}
@Override
public void handle(Context ctx) throws Exception {
// if (MimeType.EVENT_STREAM.toString().equals(ctx.header(Header.ACCEPT))) {
if (MimeType.EVENT_STREAM.equals(RestServer.getRequestedMimeProtocol(ctx)) || ctx.queryParam("sse") != null) {
// [..] handle SSE initialisation requests
ctx.result("handle SSE init process path = " + ctx.path());
return;
}
getHandler.handle(ctx);
}
}
}
All endpoints serve the correct responses. However, while the endpoint swagger documentation seems to work for the standard case 1 (doc shows up in 'Test' category', it fails and triggers the default
"A default response was added to the documentation of [..]"
action (and ending up in the 'default' category) for endpoints ENDPOINT_TEST2 (not static) and ENDPOINT_TEST3 (wrapped/branching custom Handler implementation).
What am I missing? Have I moved outside the boundary of what the annotation API allows?
Any hint would be much appreciated!
Many thanks in advance.
I'm not super familiar with this, and I'm not sure I'm understanding the results you're getting. I had assumed the annotations applied to the functions (serveTestPage1) and not the handlers they returned, so I'm a little surprised that the documentation would work at all?
Maybe I'm simply not familiar with the syntax though. Is this Java?
@MrThreepwood, yes this is Java (or at least I hope it is :smirk:). Since the handler aspect of the code compiles and executes as expected, this IMHO seems to point to an OpenAPI plugin reflection/annotation parsing feature.
From a reflection point-of-view, the static keyword or the @OpenApiannotation for that matter are a priori just another decoration of the member field variable. In the above example/use-case these are attached to a given member field (ie. here serveTestPage[1,3]). The member variables themselves are functional interfaces of type Handler. Thus from a reflection reading point-of-view, there shouldn't be any issues.
However, since I am not an expert on the OpenAPI design and/or the corresponding Javalin plugin, I cannot discern (yet) whether this is a parsing feature, the desired behaviour or bug.
Maybe there is an another internal reason why non-static or other Handler-derived functional interfaces are not supported this way. Would be nice if they were, but my initial gut feeling was, that perhaps I was misusing the API and/or missed another flag or similar.
@TobiasWalle and/or @chsfleury, could you maybe help with this issue.
After some debugging, I may have narrowed it down to the fact that above case 2 and 3 are 'anonymous non-static functional lambdas' which seem to be omitted in the handling functions
https://github.com/tipsy/javalin/blob/314e453a733449d5862e415ce9b0b96318a67e87/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt#L94-L107
and subsequently
https://github.com/tipsy/javalin/blob/314e453a733449d5862e415ce9b0b96318a67e87/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt#L67-L83
Could this be the cause for the handler working correctly within Javalin but the OpenAPI plugin not finding/associating the annotations with the handlers?
Many thanks in advance!
After some debugging, I may have narrowed it down to the fact that above case 2 and 3 are 'anonymous non-static functional lambdas' which seem to be omitted in the handling functions
the when expression chooses the else case ?
or handlerReflection.isJavaAnonymousLambda -> null // Cannot be parsed
Sorry, it's been a while since I've used Java and I was expecting method references as I don't think I've ever seen this particular setup before. It does look like in Java it has to actually be an actual handler instance or method reference. You're annotating a variable, but you're not passing that variable to get(), you're passing the value of the variable. And the values are not annotated.
I'm not sure how the function could possibly pull documentation off of it in that case as there is no annotations on the things you're actually passing. If instead of variables those were functions that met the handler interface and you then referenced them, I would expect that to work (though perhaps that's what you're attempting to avoid?)
@MrThreepwood, you most certainly can annotate variables and fields in particular.
For example, add the following simple snippets to the constructor:
for (Field field : Test.this.getClass().getDeclaredFields()) {
OpenApi openDoc = ExtractDocumentationKt.getOpenApiAnnotation(field);
if (openDoc != null) {
System.err.println("field = " + field.getName() + " openDoc = " + openDoc);
}
}
System.err.println("serveTestPage1 isJavaAnonymousLambda = " + ReflectionUtilKt.isJavaAnonymousLambda(serveTestPage1));
System.err.println("serveTestPage2 isJavaAnonymousLambda = " + ReflectionUtilKt.isJavaAnonymousLambda(serveTestPage2));
System.err.println("serveTestPage3 isJavaAnonymousLambda = " + ReflectionUtilKt.isJavaAnonymousLambda(serveTestPage3));
and you'll get the following output and correct @OpenAPI annotations for the handler:
field = serveTestPage1 openDoc = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V1", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test1", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
field = serveTestPage2 openDoc = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V2", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test2", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage2", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
field = serveTestPage3 openDoc = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V3", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test3", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage3", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
serveTestPage1 isJavaAnonymousLambda = true
serveTestPage2 isJavaAnonymousLambda = true
serveTestPage3 isJavaAnonymousLambda = false
Note the custom content of the @OpenAPI annotation.
What puzzles me, is that it works for the first case, but not the second (the static keyword being the only difference, and also does not work for the third, although it'ss a regular class -- albeit wrapped -- implementation.
My hypothesis is -- if there isn't an obvious OpenAPI syntax error that I missed -- that these possible paths haven't been implemented in the plugin. The question is whether this was done intentionally by the plugin author (for a non-documented reason) or whether this is possible a bug/missing feature in the plugin.
Yes, you can annotate variables and fields, what I'm saying is that you are not passing the variable or field.
public Test() {
Javalin instance = RestServer.getInstance();
instance.routes(() -> {
get(ENDPOINT_TEST1, serveTestPage1);
get(ENDPOINT_TEST2, serveTestPage2);
get(ENDPOINT_TEST3, serveTestPage3);
});
}
If you look at the above, you aren't passing your class, and you aren't passing a reference to your fields. When calling get it doesn't pass serveTestPage1, it passes the VALUE of serveTestPage1. If we wrote get as
get(String url, Handler handler,) { handler = new SomeHandler()}
Your variable would not change in your class. There is no way to access the field that was storing the value of the handler from the get function, so how would they access the annotations?
It's possible the static one is kind of working because Java does something clever with static final variables that are set equal to a lambda (at that point it's essentially just a function) when it compiles, instead changing variable calls to method references.
@MrThreepWood, that's partially true.
Yes, Java passes variables by VALUE. However, besides primitives, most of these values are references to objects and thus retain most of their class information/annotation properties and relationships to other classes within the JVM which can be accessed via reflection (N.B. the 'serveTestPage...' in the above example are actually fields, see also code further below). Since this is a JVM functionality, this should apriori be the same for Java, Kotlin, or any other JVM-based language for that matter. The ClassGraph module scanner that is used in the OpenApi plugin and Javalin's own ReflectionUtil helper class make use of this very fact.
TL;DR: this seems to be a bug/missing feature or is at least inconsistent with the OpenAPI plugin documentation w.r.t. the annotation API. Help from a Kotlin-affine soul would be very much appreciated.
Essentially, the OpenApi extractOpenApiDoc(final HandlerMetaInfo handlerMetaInfo) {..} function below would need to be merged/integrated into the ExtractDocumentation class. Glory, honour, and a prize are waiting. :grin:
more details regarding the original question: the more I delved into the code, the more it seems that the annotation-based OpenApi documentation tooling as outlined in the tutorials isn't complete. At least for Handler methods that are defined via lambdas and/or via Handler-derived classes seems to be missing. For example, it even fails for the near-verbatim example snippets taken from the OpenApi documentation:
click to expand tutorial source code
import org.eclipse.jetty.server.Authentication.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.plugin.openapi.OpenApiOptions;
import io.javalin.plugin.openapi.OpenApiPlugin;
import io.javalin.plugin.openapi.annotations.OpenApi;
import io.javalin.plugin.openapi.annotations.OpenApiRequestBody;
import io.javalin.plugin.openapi.annotations.OpenApiContent;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
import io.javalin.plugin.openapi.ui.SwaggerOptions;
import io.swagger.v3.oas.models.info.Info;
public class AnnotationDemo {
private static final Logger LOGGER = LoggerFactory.getLogger(AnnotationDemo.class);
public AnnotationDemo() {
LOGGER.atInfo().log("initialised");
}
@OpenApi(description = "custom descrption", operationId = "createUser", summary = "custom summary", requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = User.class)), responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(from = User.class)) })
public void createUser(Context ctx) {
// ...
System.err.println("called createUser - ctx " + ctx.path());
}
private static OpenApiOptions getOpenApiOptions() {
Info applicationInfo = new Info().version("1.0").description("My Application");
return new OpenApiOptions(applicationInfo).path("/swagger-docs").swagger(new SwaggerOptions("/swagger"));
}
public static void main(String[] args) {
Javalin app = Javalin.create(config -> {
config.registerPlugin(new OpenApiPlugin(getOpenApiOptions()));
}).start(8080);
AnnotationDemo userController = new AnnotationDemo();
app.post("/users", userController::createUser);
}
}
click to expand tutorial output
[main] INFO org.eclipse.jetty.util.log - Logging initialized @425ms to org.eclipse.jetty.util.log.Slf4jLog
[main] INFO io.javalin.Javalin -
__ __ _
/ /____ _ _ __ ____ _ / /(_)____
__ / // __ `/| | / // __ `// // // __ \
/ /_/ // /_/ / | |/ // /_/ // // // / / /
\____/ \__,_/ |___/ \__,_//_//_//_/ /_/
https://javalin.io/documentation
[main] INFO io.javalin.Javalin - Starting Javalin ...
[main] INFO io.javalin.Javalin - Listening on http://localhost:8080/
[main] INFO io.javalin.Javalin - Javalin started in 174ms \o/
[main] INFO <package name>.AnnotationDemo - initialised
[qtp1725008249-17] WARN io.javalin.Javalin - Unfortunately it is not possible to match the @OpenApi annotations to the handler in <package name>.AnnotationDemo. Please add the `path` and the `method` information to the annotation, so the handler can be matched.
[qtp1725008249-17] WARN io.javalin.Javalin - Unfortunately it is not possible to match the @OpenApi annotations to the handler in <package name>.AnnotationDemo. Please add the `path` and the `method` information to the annotation, so the handler can be matched.
[qtp1725008249-17] WARN io.javalin.Javalin - Unfortunately it is not possible to match the @OpenApi annotations to the handler in <package name>.AnnotationDemo. Please add the `path` and the `method` information to the annotation, so the handler can be matched.
[qtp1725008249-17] WARN io.javalin.plugin.openapi.JavalinOpenApi - A default response was added to the documentation of POST /users
The corresponding Swagger UI snapshot:

What I gathered so far:
Handler methods via the @OpenApi annotation, it seems that one must also assign the path annotation parameter in order that the plugin can match the OpenApi meta information to the handler. Otherwise, one gets rewarded with a runtime reminder like: Unfortunately, it is not possible to match the @OpenApi annotations to the handler in <canonical class name>. Please add thepathand themethodinformation to the annotation, so the handler can be matched.Handler methods that are declared via static fields (as done in many of the Javalin examples) seem to work -- probably since in that case Field::get(obj).equals(obj) evaluates to true and the lambda being globally the same reference to a member function.@OpenApi annotations. Actually, most of the annotation-based examples (including those based on method references) boil down to lambdas. I updated the above sample to illustrate the basics of how this could be done for a given handler from any other class than the one where these are actually defined. The system print-outs are for debugging/illustration purposes and should, of course, be removed for production code:
import static io.javalin.apibuilder.ApiBuilder.get;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import io.javalin.Javalin;
import io.javalin.core.event.HandlerMetaInfo;
import io.javalin.core.util.Header;
import io.javalin.core.util.ReflectionUtilKt;
import io.javalin.http.Context;
import io.javalin.http.Handler;
import io.javalin.plugin.openapi.annotations.HttpMethod;
import io.javalin.plugin.openapi.annotations.OpenApi;
import io.javalin.plugin.openapi.annotations.OpenApiContent;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
public class Test {
private static final String ENDPOINT_TEST1 = "/test1";
private static final String ENDPOINT_TEST2 = "/test2";
private static final String ENDPOINT_TEST3 = "/test3";
private static final String ENDPOINT_TEST4 = "/test4";
private static final String ENDPOINT_TEST5 = "/test5";
public void register() {
Javalin instance = RestServer.getInstance();
// https://stackoverflow.com/questions/3021548/annotate-anonymous-inner-class
instance.events(evt -> evt.handlerAdded(handlerMetaInfo -> {
System.err.println("");
System.err.println("### handler added : " + handlerMetaInfo + " - handler = " + handlerMetaInfo.getHandler() + " type " + handlerMetaInfo.getHandler().getClass());
final boolean isAnonymousLambda = ReflectionUtilKt.isJavaAnonymousLambda(handlerMetaInfo.getHandler());
System.err.println("### handler isJavaAnonymousLambda = " + isAnonymousLambda);
System.err.println("### handler OpenApi docs = " + extractOpenApiDoc(handlerMetaInfo));
Object handler = handlerMetaInfo.getHandler();
if (handler == Test.serveTestPage1) {
System.err.println("### known handler 1 added");
} else if (handler == this.serveTestPage2) {
System.err.println("### known handler 2 added");
} else if (handler == this.serveTestPage3) {
System.err.println("### known handler 3 added");
} else {
System.err.println("### handler is one of the two declared methods");
}
}));
instance.routes(() -> {
get(ENDPOINT_TEST1, serveTestPage1);
get(ENDPOINT_TEST2, serveTestPage2);
get(ENDPOINT_TEST3, serveTestPage3);
get(ENDPOINT_TEST4, this::serveTestPage4);
get(ENDPOINT_TEST5, this::serveTestPage5);
});
}
private OpenApi extractOpenApiDoc(final HandlerMetaInfo handlerMetaInfo) {
if (!(handlerMetaInfo.getHandler() instanceof Handler)) {
// alternatively: throw exception - chose null since this affects only functionally less criticial meta information
return null;
}
final Handler handler = (Handler) handlerMetaInfo.getHandler();
final Class<?> clazz = handler.getClass();
final String canonicalName = clazz.getCanonicalName();
final int lambdaOffset = canonicalName.indexOf("$$Lambda$");
final boolean isLambdaHandler = ReflectionUtilKt.isJavaAnonymousLambda(handler) && lambdaOffset > 0;
System.err.println("--handler class = " + canonicalName);
final Class<?> declaringClass;
if (isLambdaHandler) {
// lambda-type field declaration
// a little bit dirty but still practical way of getting declaring class
try {
final String declaringClassName = clazz.getCanonicalName().substring(0, lambdaOffset);
declaringClass = Class.forName(declaringClassName);
} catch (ClassNotFoundException e) {
// naughty exception -> log warning that we cannot find OpenApi annotation
return null;
}
// a possible cleaner way of getting declaring class
// see: https://stackoverflow.com/questions/3021548/annotate-anonymous-inner-class
// and: https://checkerframework.org/jsr308/specification/java-annotation-design.html
// or notably: https://stackoverflow.com/questions/34589435/get-the-enclosing-class-of-a-java-lambda-expression
// Class<?> declaringClass = LambdaUtils.handlerMethod(handler).getDeclaringClass();
} else {
// non-lambda field declaration
// simple way
declaringClass = clazz.getEnclosingClass();
System.err.println("non-lambda = " + Arrays.toString(clazz.getAnnotations()));
}
for (Field field : declaringClass.getDeclaredFields()) {
if (field.getAnnotation(OpenApi.class) == null) {
continue;
}
OpenApi openApi = field.getAnnotation(OpenApi.class);
// alt/optional: matcher for for static field handlers
try {
if (field.get(handler).equals(handler)) {
System.err.println("--field member variable matched by reference");
return openApi;
}
} catch (IllegalArgumentException | IllegalAccessException e) {
// not important here
}
if (isIncompatibleHandler(openApi, handlerMetaInfo)) {
continue;
}
System.err.println("--field member variable matched through OpenApi meta data");
return openApi;
}
// check if it's a method
for (Method method : declaringClass.getDeclaredMethods()) {
if (method.getAnnotation(OpenApi.class) == null) {
continue;
}
OpenApi openApi = method.getAnnotation(OpenApi.class);
if (isIncompatibleHandler(openApi, handlerMetaInfo)) {
continue;
}
System.err.println("--field member variable matched through OpenApi meta data");
return openApi;
}
// couldn't find OpenApi annotation
return null;
}
private boolean isIncompatibleHandler(OpenApi openApi, final HandlerMetaInfo handlerMetaInfo) {
// N.B. needs a more thorough comparison (null, isBlank, HandlerType vs. HttpMethod types, etc.)
return !handlerMetaInfo.getHttpMethod().toString().contentEquals(openApi.method().toString()) || !handlerMetaInfo.getPath().equals(openApi.path());
}
@OpenApi(description = "GET endpoint test V1", operationId = "serveTestPage1", summary = "to serve #1", tags = { "Test" }, path = ENDPOINT_TEST1, method = HttpMethod.GET, responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) })
private static final Handler serveTestPage1 = ctx -> {
ctx.result("serveTestPage1 path = " + ctx.path());
// [serving request based on ctx]
};
@OpenApi(description = "GET endpoint test V2", operationId = "serveTestPage2", summary = "to serve #2", tags = { "Test" }, path = ENDPOINT_TEST2, method = HttpMethod.GET, responses = @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")))
private final Handler serveTestPage2 = ctx -> {
ctx.result("serveTestPage2 path = " + ctx.path());
// [serving request based on ctx]
};
@OpenApi(description = "GET endpoint test V3", operationId = "serveTestPage3", summary = "to serve #3", tags = { "Test" }, path = ENDPOINT_TEST3, method = HttpMethod.GET, responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) })
private final Handler serveTestPage3 = new CombinedHandler(ctx -> {
ctx.result("serveTestPage3 path = " + ctx.path());
// [serving request based on ctx] serving BINARY/JSON/YML based on user 'Accept'
// request
});
@OpenApi(description = "GET endpoint test V4", operationId = "serveTestPage4", summary = "to serve #4", tags = { "Test" }, path = ENDPOINT_TEST4, method = HttpMethod.GET, responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) })
public void serveTestPage4(Context ctx) {
ctx.result("serveTestPage4 path = " + ctx.path());
// [serving request based on ctx]
}
@OpenApi(description = "GET endpoint test V5", operationId = "serveTestPage5", summary = "to serve #5", tags = { "Test" }, path = ENDPOINT_TEST5, method = HttpMethod.GET, responses = { @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) })
public void serveTestPage5(Context ctx) {
ctx.result("serveTestPage5 path = " + ctx.path());
// [serving request based on ctx]
}
/**
* little helper class to allow regular GET and SSE initialisation through via
* the same end-point
*/
public static class CombinedHandler implements Handler {
private final Handler getHandler;
public CombinedHandler(Handler getHandler) {
this.getHandler = getHandler;
}
@Override
public void handle(Context ctx) throws Exception {
if (MimeType.EVENT_STREAM.toString().equals(ctx.header(Header.ACCEPT))) {
//if (MimeType.EVENT_STREAM.equals(RestServer.getRequestedMimeProtocol(ctx)) || ctx.queryParam("sse") != null) {
// [..] handle SSE initialisation requests
ctx.result("handle SSE init process path = " + ctx.path());
return;
}
getHandler.handle(ctx);
}
}
}
click to expand to see the example output
[JavaFX Application Thread] INFO io.javalin.Javalin - Starting Javalin ...
[JavaFX Application Thread] INFO io.javalin.Javalin - Listening on http://0:8080/
[JavaFX Application Thread] INFO io.javalin.Javalin - Listening on https://0:8443/
[JavaFX Application Thread] INFO io.javalin.Javalin - Javalin started in 339ms \o/
### handler added : HandlerMetaInfo(httpMethod=GET, path=/test1, handler=<example package>.Test$$Lambda$487/0x00000001004eb440@28b194e6, roles=[]) - handler = <example package>.Test$$Lambda$487/0x00000001004eb440@28b194e6 type class <example package>.Test$$Lambda$487/0x00000001004eb440
### handler isJavaAnonymousLambda = true
--handler class = <example package>.Test$$Lambbut da$487/0x00000001004eb440
--field member variable matched by reference
### handler OpenApi docs = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve #1", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V1", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test1", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage1", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
### known handler 1 added
### handler added : HandlerMetaInfo(httpMethod=GET, path=/test2, handler=<example package>.Test$$Lambda$488/0x00000001004eb840@211f5cb1, roles=[]) - handler = <example package>.Test$$Lambda$488/0x00000001004eb840@211f5cb1 type class <example package>.Test$$Lambda$488/0x00000001004eb840
### handler isJavaAnonymousLambda = true
--handler class = <example package>.Test$$Lambda$488/0x00000001004eb840
--field member variable matched through OpenApi meta data
### handler OpenApi docs = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve #2", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V2", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test2", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage2", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
### known handler 2 added
### handler added : HandlerMetaInfo(httpMethod=GET, path=/test3, handler=<example package>.Test$CombinedHandler@17c741b0, roles=[]) - handler = <example package>.Test$CombinedHandler@17c741b0 type class <example package>.Test$CombinedHandler
### handler isJavaAnonymousLambda = false
--handler class = <example package>.Test.CombinedHandler
non-lambda = []
--field member variable matched through OpenApi meta data
### handler OpenApi docs = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve #3", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V3", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test3", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage3", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
### known handler 3 added
### handler added : HandlerMetaInfo(httpMethod=GET, path=/test4, handler=<example package>.Test$$Lambda$500/0x00000001004ee840@10f28250, roles=[]) - handler = <example package>.Test$$Lambda$500/0x00000001004ee840@10f28250 type class <example package>.Test$$Lambda$500/0x00000001004ee840
### handler isJavaAnonymousLambda = true
--handler class </p = <example package>.Test$$Lambda$500/0x00000001004ee840
--field member variable matched through OpenApi meta data
### handler OpenApi docs = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve #4", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V4", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test4", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage4", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
### handler is one of the two declared methods
### handler added : HandlerMetaInfo(httpMethod=GET, path=/test5, handler=<example package>.Test$$Lambda$501/0x00000001004eec40@558e6f84, roles=[]) - handler = <example package>.Test$$Lambda$501/0x00000001004eec40@558e6f84 type class <example package>.Test$$Lambda$501/0x00000001004eec40
### handler isJavaAnonymousLambda = true
--handler class = <example package>.Test$$Lambda$501/0x00000001004eec40
--field member variable matched through OpenApi meta data
### handler OpenApi docs = @io.javalin.plugin.openapi.annotations.OpenApi(summary="to serve #5", headers={}, pathParams={}, method=GET, queryParams={}, formParams={}, deprecated=false, description="GET endpoint test V5", composedRequestBody=@io.javalin.plugin.openapi.annotations.OpenApiComposedRequestBody(description="-- This string represents a null value and shouldn't be used --", anyOf={}, oneOf={}, contentType="AUTODETECT - Will be replaced later", required=false), cookies={}, tags={"Test"}, path="/test5", security={}, requestBody=@io.javalin.plugin.openapi.annotations.OpenApiRequestBody(description="-- This string represents a null value and shouldn't be used --", required=false, content={}), operationId="serveTestPage5", responses={@io.javalin.plugin.openapi.annotations.OpenApiResponse(description="-- This string represents a null value and shouldn't be used --", content={@io.javalin.plugin.openapi.annotations.OpenApiContent(type="text/html", isArray=false, from=io.javalin.plugin.openapi.annotations.NULL_CLASS.class)}, status="200")}, ignore=false, fileUploads={})
### handler is one of the two declared methods
Thus, it's possible, but simply not implemented in the OpenApi plugin. Unfortunately, I am not fluent enough in Kotlin to transfer the above code and extend the Javalin library (struggling with my Eclipse IDE to compile Javalin from scratch).
Any help and/or bug fixing this issue would be much appreciated.
Thus, it's possible, but simply not implemented in the OpenApi plugin.
@RalphSteinhagen Very nice work on investigating this! I'm a bit busier than usual these days, so I can't implement the solution myself, but this will make it a lot easier for whoever steps up.
Any help and/or bug fixing this issue would be much appreciated.
@sealedtx, you did a lot of good work OpenAPI plugin before, could you have a look at this?
@RalphSteinhagen I can give you some advice you probably don't want to hear: IntelliJ makes working with Kotlin a lot easier. You can get the free community edition and set it to use Eclipse keybindings, which should make the transition easier.
@RalphSteinhagen I can give you some advice you probably don't want to hear: IntelliJ [..]
@tipsy don't worry, your advice is valid and well taken. I am not an Eclipse evangelist nor particularly fond of it. We use it for more pragmatic/organizational reasons, as some of my colleagues made a blood-curdling oath upon it and refuse to support anything outside that realm.
My co-conspirators and I are working mostly in Java, C/C++, and some increasingly also in Python. Eclipse with its tooling seemed to initially nicely bridge that gap albeit as 'a jack of all trades and master of none'. We already started exploring alternatives such as CLion and others...
Am glad that Javalin is well maintained and that it has gathered a healthy open-source community around it. :+1:
Hi @tipsy, I reviewed this issue and dived into source code. After debug I faced with a strange situation which I don't know how to explain, because I am not an expert in Java reflection. As I noticed the reason why non-static lambda field is ignored is inside ReflectionUtil.lambdaField where ReflectionUtil.javaFieldName is called:
https://github.com/tipsy/javalin/blob/1872e25a266432e0156aceb4b32d4c12c197b187/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt#L55-L60
I have two fields in my test class:
public static final Handler testStatic = ctx -> {
ctx.result("testStatic path");
};
public final Handler testNonStatic = ctx -> {
ctx.result("testNonStatic path");
};
Here inside javaFieldName - Field.get(it) works for static lambda field, but non-static throws IllegalArgumentException:
https://github.com/tipsy/javalin/blob/1872e25a266432e0156aceb4b32d4c12c197b187/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt#L11
Can not set final io.javalin.http.Handler field Test.testNonStatic to Test$$Lambda$15/0x0000000100098040
I think the key for solving this problem - is the reason why javaFieldName for non-static lambda fields cannot be retrieved from declaredFields. Need advice from someone with more expertise in Java reflection.
@sealedtx a big thank you for looking into this. :+1:
I presume the it.get(it) == this issue you describe is related to what I mentioned above
[..] in that case Field::get(obj).equals(obj) evaluates to true [..]
The object reference in get(it) works only because 'it' is static (ie. globally defined) and ignored according to the docs at Field::get(Object). For all others, it in get(it) would need to be a reference to the actual class instance (ie. not the Class<?> object) where the field variable is declared and which is not available via the Handler interface API since the Javalin::get function receives only the reference to the field/lambda but not the declaring class (which is OK for most cases, I guess).
The best strategy I could come up with while keeping the same API is to get the generic Class<?> name from the field reference's class name string (ie. split the reference name between the $$Lambda, then search for the class via reflection using its canonical name, and finally to parse the declared @OpenApi annotations for matching path and HttpMethod. Since the OpenAPI description is mostly by class-field/method and rarely differentiated by class instance, this should work.
The relevant snippets from the `extractOpenApiDoc(..)' listed above are:
final Handler handler = (Handler) handlerMetaInfo.getHandler();
final Class<?> clazz = handler.getClass();
final int lambdaOffset = clazz.getCanonicalName().indexOf("$$Lambda$"); // N.B. check if this is the same for lambdas in Kotlin
final boolean isLambdaHandler = lambdaOffset > 0;
final Class<?> declaringClass;
if (isLambdaHandler) {
// a little bit dirty but still practical way of getting declaring class
try {
final String declaringClassName = clazz.getCanonicalName().substring(0, lambdaOffset);
declaringClass = Class.forName(declaringClassName);
} catch (ClassNotFoundException notimportantexception) {
// naughty exception -> log as warning that we cannot find OpenApi annotation
return null;
}
} else {
// non-lambda field declaration - the simple way
declaringClass = clazz.getEnclosingClass();
}
// [..]
// handle parsing 'declaringClass' for either
// matching declared fields' annotations if it's a lambda (regardless of static or non-static), or
// matching declared methods' annotations if the Handler is a method (all other cases).
// [..]
I hope that helps. Not a simple one-liner but works for all lambdas and class methods regardless of whether they are static or not.
Thanks again!
That's essentially what I was getting at. Since your annotation isn't on the thing that's being passed, and the thing that's being passed can't directly reference the annotation, you're now just looking for any open api annotation that matches your path. At that point, why bother annotating the variable that stores the lambda? You could put it anywhere. This also doesn't work if the lambda being bassed doesn't originate in the class the annotation is stored in since the variable you're annotating could store a lambda generated anywhere (though I don't know why you'd do that).
This actually begins to a look a lot like annotation based routing. You're functionally declaring the route handler with javalin's get, but then expecting the documentation to be found for that same function being passed by looking for an annotation that specifies the path and method. At that point it would make a lot more sense to me to bind the routes by passing the entire Controller and inspecting all annotations.
I would argue that the methodReferenceOfNonStaticJavaHandler is highly counter intuitive and unexpected as well. It's unfortunate that Java doesn't keep function annotations (or even function name) when passing by reference, but it doesn't and I wouldn't expect this library to attempt to bypass that.
It makes more sense to me to expect people to use the DocumentedHandler implementation to work around the limitations of Java.
That said, I normally don't do much Javalin development in Java anymore, so perhaps others don't consider this as counterintuitive.
@MrThreepwood usually annotations are defined close to where the object of interest is being defined. For most annotations, this is even a mandatory condition.
I personally found these OpenAPI annotations appealing since through this the meta-information is defined close to where the relevant Handler code they document is and not -- for example -- split when refactoring occurs. This minimizes a lot of boiler-plate code as well as programming errors especially for large and/or long-lived projects where developers tend to e.g. "forget" where classes, methods, fields, and their corresponding meta-information are defined and that these need to be synchronized.
Regardless of whether it is a lambda or not, the present OpenAPI plugin seems to implement this very mechanism on purpose, albeit for methods only (ie. searching through the Class<?> definition by reflection) and not for fields. Comparing to how handler methods are defined in most of the other Javalin examples/tutorials (ie. by field references) this is a bit inconsistent.
Thus, the question is whether the OpenApi plugin annotation interface should either apply to all type of Handler definition paradigms (via methods, fields, regardless of if static/non-static) or be dropped because it apparently only works for Handler definitions (with reflection browsing) and static Handler fields (perhaps more or less by accident) paradigms. At least there is one aborted hook in the code that made me believe that the original author was aware of this and -- for some unknown reason -- did not follow-up on this.
@tipsy as the father of Javalin and @TobiasWalle as the original OpenAPI plugin author may I request your views, your original intent on this, and whether the above as merit?
Many thanks in advance and I am sorry for the trouble to everybody involved.
Thus, the question is whether the OpenApi plugin annotation interface should either apply to all type of Handler definition paradigms (via methods, fields, regardless of if static/non-static) ...
@tipsy ... may I request your views, your original intent on this, and whether the above as merit?
I definitely want the annotations to work for any type of Handler, no matter if it's static or non-static. I would consider this a bug.
The issue here isn't whether or not it's static really, the issue is that the annotation isn't applied to the handler at all in the case of a variable reference.
I was surprised to learn that method references in Java also don't actually retain a link to the method they're referencing, meaning that realistically annotating a method for this purpose is no different than annotating a random field, but it's still very strange to expect that annotating some random item on a class should somehow be read by a function that's not passed that class.
The only way to implement this is to:
I could see why someone might expect that a method reference would pass it's annotations, and why the annotation would work on it (after all, the method IS the handler and you are annotating it). This isn't how Java works apparently and I would argue that it's current implementation is a little bit insane.
However, in the case of the lambda variable being passed, it's not even the handler that's being annotated, it's a random variable.
It really doesn't seem like there's any sane way to implement this. I think if this is going to be implemented it should be very well documented.