Skip to content

RFC: support for multi-endpoint HTTP handler Lambda functions #1612

Open
@dnnrly

Description

@dnnrly

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status

    Ideas

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions