powertools-lambda-java icon indicating copy to clipboard operation
powertools-lambda-java copied to clipboard

RFC: support for multi-endpoint HTTP handler Lambda functions

Open dnnrly opened this issue 3 months ago • 9 comments

Key information

  • RFC PR: (leave this empty)
  • Related issue(s), if known:
  • Area: HTTP binding
  • Meet tenets: Yes

Summary

One paragraph explanation of the feature.

This RFC proposes a new module that will allow developers to easily implement lightweight, multi-endpoint ALB and API Gateway lambdas in Java with minimal (or no) additional dependencies outside of the Powertools and the JRE. This approach provides an alternative to using more heavy weight frameworks based around Spring or JaxRS, reducing GC and classloader burden to allow maximum cold start performance.

Motivation

Why are we doing this? What use cases does it support? What is the expected outcome?

Here's the value statement I think we should work towards:

As a developer

I would like to implement my HTTP microservice in Java on AWS Lambda

So that I can take advantage of the serverless platform without having to adopt an entirely new technology stack

We have been using a lightweight framework to achieve this goal rather successfully for the past couple of years. We have gone all-in on AWS lambda and have been able to solve many of the problems associated with implementing Java lambdas as we gained experience. We would like to contribute some of the technology that has enabled to do this so successfully.

To be more specific about what problem this solves, this RFC proposes a solution for lambdas that:

  • Handle several HTTP endpoints or are generally RESTful in a structured and maintainable way
  • Optimised to use the minimum additional dependencies
  • Not use reflection that could increase cold start times

Many of the techniques and approaches used in this proposal come from this article: https://medium.com/capital-one-tech/aws-lambda-java-tutorial-best-practices-to-lower-cold-starts-capital-one-dc1d8806118

This proposal does not make Java more performant than using other runtimes for your lambda, but it does prevent the framework being a performance burden.

Proposal

This is the bulk of the RFC.

Explain the design in enough detail for somebody familiar with Powertools for AWS Lambda (Java) to understand it, and for somebody familiar with the implementation to implement it.

This should get into specifics and corner-cases, and include examples of how the feature is used. Any new terminology should be defined here.

The design of this proposal is significantly different from the approach taken in Spring and JaxRS. It borrows concepts and idioms from other languages, Go in particular. Despite this it is still possible to use patterns familiar to Java developers such as 3-tier or Domain Driven Design. Examples of this will be shown

API examples

Basic route binding

A simple example of how URL path and HTTP method can be used to bind to a specific handler
public class ExampleRestfulController {

  private final Router router;

  {
    /*
     This router demonstrates some of the features you would expect to use when creating a RESTful
     resource.  They are all using the same root but differentiated by looking at the HTTP method
     and the `id` path parameter. These individual routes are also named so that you can filter
     log events based on that rather than having to use a regular expression on the path in
     combination with method.
    */
    final Router resourcesRouter =
        new Router(
            Route.bind("/", methodOf("POST")).to(this::createResource),
            Route.bind("/(?<id>.+)", methodOf("GET")).to(this::getResource),
            Route.bind("/(?<id>.+)", methodOf("DELETE")).to(this::deleteResource),
            Route.fallback().to(this::invalidMethodFallthrough));

    router =
        new Router(
            Route.HEALTH,
            Route.bind("/resources(?<sub>/.*)?")
                .to(subRouteHandler("sub", resourcesRouter)));
  }

  private AlbResponse createResource(Request request) {
    return AlbResponse.builder().withStatus(Status.CREATED).withBody("created something").build();
  }

  private AlbResponse getResource(Request request) {
    return AlbResponse.builder()
        .withStatus(Status.OK)
        .withBody("got resource with ID " + request.getPathParameters().get("id"))
        .build();
  }

  private AlbResponse deleteResource(Request request) {
    return AlbResponse.builder()
        .withStatusCode(204)
        .withBody("deleted resource with ID " + request.getPathParameters().get("id"))
        .build();
  }

  private AlbResponse invalidMethodFallthrough(Request request) {
    return AlbResponse.builder().withStatus(Status.METHOD_NOT_ALLOWED).build();
  }
}

Filters

In this section, we will describe how we can add filters that allow us to perform operations on the request object before it is passed to the handler. I've called them filters here but we can decide if there's a more appropriate name for this.

A simple example of how we can add filters to a binding.
Filter aSingleFilter = new YourFilter();
List<Filter> manyFilters = asList(new AnotherFilter(), new FilterWithParameters(42));

Router router =
    new Router(Route.HEALTH, Route.bind("/path")
        .to(aSingleFilter)
        .to(manyFilters)
        .to((handler, request) -> {
            // Do something inline
            return handler.apply(request);
        })
        .thenHandler(this::mainHandler));
Here is an example of a filter you might want perform some common logging.
public class RequestLogger implements Filter {

  private final YourStructuredLogger log;

  public RequestLogger(Logger log) {
    this.log = log;
    log.info("isColdStart", "true");
  }

  @Override
  public AlbResponse apply(RouteHandler handler, Request request) {
    log.info("path", request.getPath());

    String method = request.getHttpMethod();
    if (!Objects.equals(method, "")) {
      log.info("http_method", method);
    }
    logHeaderIfPresent(request, "content-type");
    logHeaderIfPresent(request, "accept");

    try {
      AlbResponse response = handler.apply(request);
      log.info("status_code", String.valueOf(response.getStatusCode()));
      return response;
    } catch (Exception ex) {
      log.error("Unhandled exception", ex);
      throw ex;
    }
  }
Here is an example of how you could do request validation inside of a filter.
Filter validator = (next, request) -> {
  if (request.getBody() != "") {
    return next.apply(request);
  } else {
    return AlbResponse.builder().withStatusCode(400).withBody("you must have a request body").build();
  }
};
Here is how you might implement a simple (or complex) exception mapper using filters.
public class ExceptionMapping {

  private final Router router =
      new Router(
          Route.HEALTH,
          Route.bind("/resources/123", methodOf("GET"))
              .to(this::mapException)
              .then(this::accessSubResource),
          Route.bind("/resources/(?<id>.+)", methodOf("GET"))
              .to(this::mapException)
              .then(this::accessMissingResource),
          Route.bind("/resources", methodOf("POST"))
              .to(this::mapException)
              .then(this::processRequestObject),
          Route.bind("/some-gateway", methodOf("GET"))
              .to(this::mapException)
              .then(this::callDownstream));

  /** This is a really simple example of how exception mapping can be done using forwarders. */
  private AlbResponse mapException(RouteHandler handler, Request request) {
    AlbResponse response;

    try {
      response = handler.apply(request);
    } catch (RequestValidationException ex) {
      response = AlbResponse.builder().withStatus(Status.BAD_REQUEST).build();
    } catch (NotFoundException ex) {
      response = AlbResponse.builder().withStatus(Status.NOT_FOUND).build();
    } catch (BadDownstreamDependencyException ex) {
      response = AlbResponse.builder().withStatus(Status.BAD_GATEWAY).build();
    } catch (Exception e) {
      response = AlbResponse.builder().withStatus(Status.INTERNAL_SERVER_ERROR).build();
    }

    return response;
  }

  private AlbResponse callDownstream(Request request) {
    throw new BadDownstreamDependencyException();
  }

  private AlbResponse processRequestObject(Request request) {
    throw new RequestValidationException();
  }

  private AlbResponse accessMissingResource(Request request) {
    throw new NotFoundException();
  }

  private AlbResponse accessSubResource(Request request) {
    return AlbResponse.builder().withStatus(Status.OK).withBody("Hello!").build();
  }

  private static class BadDownstreamDependencyException extends RuntimeException {}

  private static class RequestValidationException extends RuntimeException {}

  private static class NotFoundException extends RuntimeException {}
}

Dependency Injection

Dependency injection is outside of the scope of this RFC - but this approach is compatible with compile-time DI solutions like Dagger2. Our experience has shown that you retain a high degree of control over what code is executed and the number classes being managed by the class loader, allowing you to manage your cold start phase very well.

Drawbacks

Why should we not do this?

Do we need additional dependencies? Impact performance/package size?

The style of API used here will not be familiar to a lot of Java developers. This may add some cognitive burden to those that would like to adopt this module in their solution.

Rationale and alternatives

  • What other designs have been considered? Why not them?
  • What is the impact of not doing this?

Unresolved questions

Optional, stash area for topics that need further development e.g. TBD

dnnrly avatar Mar 19 '24 16:03 dnnrly

Morning @dnnrly ! Thanks heaps for this - it is clear you have spent time thinking about this. There is prior art here too - powertools for python supports something similar .

Full disclosure - we are focusing at the moment on getting V2 #1522 into a good state, targeting H1 this year. This is certainly something we'd be interested in in the future, though, and would have a natural home in V2, as we're reluctant to add net new features to V1.

scottgerring avatar Mar 20 '24 06:03 scottgerring

Sure, that makes sense. I'm in no rush to include this - happy to contribute whenever it makes the most sense. It probably offers this RFC a chance to breathe a little bit and gives us time to get the right kind of feedback.

dnnrly avatar Mar 20 '24 08:03 dnnrly

absolutely - it would be great to get other views on this pop up in the coming weeks !

scottgerring avatar Mar 20 '24 09:03 scottgerring

Thank you @dnnrly for this very well detailed RFC. As Scott mentioned, there is an equivalent in python, and this is a feature we were thinking about when we looked at the feature parity with Python. To be transparent, it was definitely not our number one priority as it's a complex one and also very competitive (vs Spring & other fwks). But I like the idea to have something lighter and the first thoughts you put in this RFC.

About this, can we have pre-filters and post-filters (for example to add CORS headers or other data transformation) ?

Also, it would be great to see what has been done in Python, not to replicate but to get inspiration and maybe complete the feature scope.

jeromevdl avatar Mar 20 '24 12:03 jeromevdl

The filters in this implementation aren't pre- or post-, you can write the code around the call to next(...) however it makes sense. The internal implementation we have includes a library of filters that we can use in our routers. Some do validation, others logging, and even some mutate the request before it gets to the handler. A lot of the behaviour implemented in the Python version could easily be implemented as filters that you just add to your chain.

As for the comparison with other frameworks, our experience with Spring and JaxRS performance was very poor when we tried it. The cold-start times were quite high - beyond our budget for the APIs that we have implemented. It's not really meant to compete with those technologies, just give you options if you want to host your API using lambdas.

I totally understand that it's not a priority. I raised this RFC as we have had a stable implementation for a couple of years now and it has become an important part of how we deliver our APIs and it seemed a shame not to share our experience with the wider development community. 😄

dnnrly avatar Mar 20 '24 13:03 dnnrly

Thank you very much for your contribution here. Let's sleep on this a bit. v2 is around the corner, then it makes sense to have another look at it.

jeromevdl avatar Mar 20 '24 14:03 jeromevdl

Thinking out loud @dnnrly - what would be the downside of providing a powertools implementation of JAX-RS? I don't think it should be inherently slow in and of itself (see e.g. Quarkus). On the other hand from my own perspective I quite like this style of API modelling you've done here - it feels more "modern" to me, and lines up with the way we're using builder-style interfaces in V2 e.g.

batch V2 API

https://github.com/aws-powertools/powertools-lambda-java/blob/adbb7bfc0a2b32ba5e83b377d91897c89281c2ef/docs/utilities/batch.md?plain=1#L533-L545

scottgerring avatar Mar 26 '24 07:03 scottgerring

@scottgerring remember our conversation on jax-rs: https://github.com/aws-powertools/powertools-lambda-java/issues/1103

jeromevdl avatar Mar 26 '24 08:03 jeromevdl

@scottgerring remember our conversation on jax-rs: #1103

In retrospect I think my suggestion to use quarkus or spring-boot is a bit heavy-handed. I see the value in providing something lightweight like both yourself and @dnnrly are suggesting! I am not sure about the performance impact of it; it feels like quarkus with resteasy-reactive mitigates this somehow (compile time weaving ? 🤷 ).

Ultimately I am hoping to prompt new discussion on options !

scottgerring avatar Mar 26 '24 09:03 scottgerring