Skip to content

Instantly share code, notes, and snippets.

@adamw
Created October 12, 2017 12:36
Show Gist options
  • Save adamw/6248904a39eb3fedb89d3c9a1752ba10 to your computer and use it in GitHub Desktop.
Save adamw/6248904a39eb3fedb89d3c9a1752ba10 to your computer and use it in GitHub Desktop.
// Hello.java
public class Hello {
public String helloWorld() {
return "Hello World";
}
}
// HelloEndpoints.java
public class HelloEndpoints {
private Endpoint helloWorldEndpoint(Hello hello) {
return Endpoint
.withPath("/hello")
.method(GET)
.produces(MediaType.TEXT_PLAIN)
.invoke(hello::helloWorld);
}
public List<Endpoint> endpoints(Hello hello) {
return Arrays.asList(helloWorldEndpoint(hello), ...);
}
}
@markkolich
Copy link

@adamw I landed here after reading https://blog.softwaremill.com/the-case-against-annotations-4b2fb170ed67. Nice writeup, very insightful, thanks for putting that together!

In this gist you present a really simple alternative to an annotation driven route/endpoint definition. Translating your example to annotation land, one would probably end up with something like this:

@RequestMapping(path = "/hello", method = GET)

Applying this to a slightly more complicated ("real world") example, what if the endpoint looks something like PUT:/hello/{world}/{foo}? Suddenly you have a need to extract and pass a PUT request body, and loosely typed Strings for path params {world} and {foo} into hello::helloWorld.

How would that look without annotations?

In other words, the helloWorld() method in this case will probably look something like:

/* PUT:/hello/{world}/{foo} */
public String helloWorld(String world, String foo, ByteBuffer requestBody) {
  // ...
} 

In your proposal, how do values for requestBody, world, and foo make their way into this method? Where does that mapping/magic happen? Would you expect the implementer to write a method for invoke that parses out these values and passes them into the corresponding handler method?

return Endpoint
  .withPath("/hello/{world}/{foo}")
  .method(PUT)
  .produces(MediaType.APPLICATION_JSON)
  .invoke((request) -> {
    // .... magic here to extract `world`, `foo` and the request body from the request?
    return helloWorld(world, foo, requestBody);
  });

I suppose I can buy that approach, but it somewhat looks like that's just moving the problem around: you're left writing that "mapping" code over and over again instead of allowing a toolkit to do it for you.

Would you object to something like this instead?...

public String helloWorld(
  @Path("world") String world,
  @Path("foo") String foo,
  @RequestBody ByteBuffer requestBody) {
  // ...
} 

In other words, annotate the method arguments with clear identifiers as to where those values are "expected" to come from, keeping the Endpoint invocation a clean .invoke(hello::helloWorld). I'm not in favor of all annotations, but I think it's reasonable to use some annotations to allow a toolkit to extract/parse those mappings for you where applicable.

@adamw
Copy link
Author

adamw commented Dec 28, 2018

Thanks for the comments!

First of all I think it's worth noting that with or without annotations, the approach is basically the same:

  1. we describe the endpoints. In one approach, we might use the restricted language of annotations (@Path, @RequestMapping etc.). In another, we use a "normal" language, such as Scala, Java or Kotlin. From a high level, the result in both cases is the same: a data structure containing information about your endpoints
  2. we create an interpreter, which turns the description into an actual endpoint. With annotations, this is done by e.g. Spring, first using runtime reflection to read the description, and then exposing the endpoint based on that information. Without annotations, you have to explicitly pass the data structure you created to the interpreter.

There might be many interpreters, both when using annotations and when not. For example, you can expose endpoints based on annotations, or generate documentation. Without annotations, you can do the same; as someone pointed out, that's what Tapir is aspiring to do.

The big advantage of the "normal code" approach is that you have much more flexibility when creating the endpoint description, unlike being unnecessarily constrained with the limitation of annotations. However, annotations do have the advantage of being able to reference fields or methods - something that is not directly possible in Java (but is possible in Scala with shapeless/macros). But then again, you might not have to reference fields/methods in the first place - depend on how you define the API to create the descriptions.

Summing up: I wouldn't use annotations for creating endpoint descriptions. Normal code is much more flexible, allows operating on values in the base language (just like any other value) and can be interpreted to an actual endpoint in the same way as with the annotations approach. Annotations do have their usages, but mainly for compile time (for example, apart from things such as @SuppressWarnings, in Scala they are often useful for directing compile-time code generation via macros).

@adamw
Copy link
Author

adamw commented Dec 28, 2018

Btw. as for the specific example with extracting path parameters, I think a Java API could be sth like this:

return Endpoint
  .withPath("hello")
  .withPathSegment(Parsers.string)
  .withPathSegment(Parsers.integer)
  .withBody(Parsers.json)
  .method(PUT)
  .produces(MediaType.APPLICATION_JSON)
  .invoke(Logic::helloWorld);

Here, the first invocation of withPathSegment would be on Endpoint0, and yield an Endpoint1<String> (an endpoint, which requires a single string parameter - in this case, read from the path, but could be read from the query as well).

The second would be on Endpoint1<String> and yield an Endpoint2<String, Integer>, etc. Then, the invoke method can be properly typed to expect a method reference with the right signature.

That's of course just a sketch, and would require the library author to define a number of EndpointN classes (or maybe auto-generate them?). In Scala, you have have a single Endpoint class which can accumulate the input/output parameters with some combinators (again, see Tapir for details).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment