Designing HTTP Endpoints

An Endpoint is a component that creates an externally accessible API. Endpoints are how you expose your services to the outside world. Endpoints can have different protocols and, initially, support HTTP.

HTTP Endpoint components make it possible to conveniently define such APIs accepting and responding in JSON, or dropping down to lower level APIs for ultimate flexibility in what types of data is accepted and returned.

Basics

To define an HTTP Endpoint component, create a public class and annotate it with @HttpEndpoint("/path-prefix").

Each public method on the endpoint that is annotated with method @Get, @Post, @Put, @Patch or @Delete will be handling incoming requests matching the /path-prefix and the method-specific path used as value defined for the path annotation.

The most basic example:

import akka.javasdk.annotations.Acl;
import akka.javasdk.annotations.http.Get;
import akka.javasdk.annotations.http.HttpEndpoint;

@HttpEndpoint("/example") (1)
@Acl(allow = @Acl.Matcher(principal = Acl.Principal.ALL)) (2)
public class ExampleEndpoint {


  @Get("/hello") (3)
  public String hello() {
    return "Hello World"; (4)
  }
1 Common path prefix for all methods in the same class /example.
2 ACL configuration allowing any client to access the endpoint.
3 GET endpoint path is combined with the prefix and becomes available at /example/hello
4 Return value, is turned into an 200 Ok response, with content type text/plain and the specified string as body.
Without an ACL annotation no client is allowed to access the endpoint. For more details on how ACLs can be configured, see Access Control Lists (ACLs)

Path parameters

The path can also contain one or more parameters, which are extracted and passed to the method:

  @Get("/hello/{name}") (1)
  public String hello(String name) { (2)
    return "Hello " + name;
  }

  @Get("/hello/{name}/{age}") (3)
  public String hello(String name, int age) { (4)
    return "Hello " + name + "! You are " + age + " years old";
  }
1 Path parameter name in expression.
2 Method parameter named as the one in the expression
3 When there are multiple parameters
4 The method must accept all the same names in the same order as in the path expression.

Path parameter can be of types String, int, long, boolean, float, double, short and char as well as their java.lang class counterparts.

Request body

To accept an HTTP JSON body, specify a parameter that is a class that Jackson can deserialize:

  public record GreetingRequest(String name, int age) {} (1)

  @Post("/hello")
  public String hello(GreetingRequest greetingRequest) { (2)
    return "Hello " + greetingRequest.name + "! " +
        "You are " + greetingRequest.age + " years old";
  }

  @Post("/hello/{number}") (3)
  public String hello(int number, GreetingRequest greetingRequest) { (4)
    return number + " Hello " + greetingRequest.name + "! " +
        "You are " + greetingRequest.age + " years old";
  }
1 A class that Jackson can serialize and deserialize to JSON
2 A parameter of the request body type
3 When combining request body with path variables
4 The body must come last in the parameter list

Additionally, the request body parameter can be of the following types:

  • String for any request with a text content type, the body decoded into a string

  • java.util.List<T> where T is a type Jackson can deserialize, accepts a JSON array.

  • akka.http.javadsl.model.HttpEntity.Strict for the entire request body as bytes together with the content type for arbitrary payload handling.

  • akka.http.javadsl.model.HttpRequest for a low level, streaming representation of the entire request including headers. See Low level requests below for more details

Response body

To return response with JSON, the return value can be a class that Jackson can serialize:

  public record MyResponse(String name, int age) {}

  @Get("/hello-response/{name}/{age}")
  public MyResponse helloJson(String name, int age) {
    return new MyResponse(name, age); (1)
  }
1 Returning an object that Jackson can serialize into JSON

In addition to an object that can be turned to JSON, a request handler can return the following:

  • null or void to return an empty body.

  • String to return a UTF-8 encoded text/plain HTTP response.

  • CompletionStage<T> to respond based on an asynchronous result.

    • When the completion stage is completed with a T it is turned into a response.

    • If it is instead failed, the failure leads to an error response according to the error handling explained in error responses.

  • akka.http.javadsl.model.HttpResponse for complete control over the response, see Low level responses below

Error responses

The HTTP protocol has several status codes to signal that something went wrong with a request, for example HTTP 400 Bad request to signal that the incoming request was not valid.

Responding with an error can be done by throwing one of the exceptions available through static factory methods in akka.javasdk.http.HttpException.

  @Get("/hello-code/{name}/{age}")
  public String helloWithValidation(String name, int age) {
    if (age > 130)
      throw HttpException.badRequest("It is unlikely that you are " + age + " years old");  (1)
    else
      return " Hello " + name + "!";  (2)
  }
1 Throw one of the exceptions created through factory methods provided by HttpException to respond with a HTTP error
2 Return non-error

In addition to the special `HttpException`s, exceptions are handled like this:

  • IllegalArgumentException is turned into a 400 Bad request

  • Any other exception is turned into a 500 Internal server error.

    • In production the error is logged together with a correlation id and the response message only includes the correlation id to not leak service internals to an untrusted client.

    • In local development and integration tests the full exception is returned as response body.

Interacting with other components

The most common use case for endpoints is to interact with other components in a service. This is done through the akka.javasdk.client.ComponentClient. If the constructor of the endpoint class has a parameter of this type, it will be injected by the SDK.

@Acl(allow = @Acl.Matcher(principal = Acl.Principal.INTERNET))
@HttpEndpoint("/carts") (1)
public class ShoppingCartEndpoint {

  private final ComponentClient componentClient;

  public ShoppingCartEndpoint(ComponentClient componentClient) { (2)
    this.componentClient = componentClient;
  }


  @Get("/{cartId}") (3)
  public CompletionStage<ShoppingCart> get(String cartId) {
    return componentClient.forEventSourcedEntity(cartId) (4)
        .method(ShoppingCartEntity::getCart)
        .invokeAsync(); (5)
  }


  @Put("/{cartId}/item") (6)
  public CompletionStage<HttpResponse> addItem(String cartId, ShoppingCart.LineItem item) {
    return componentClient.forEventSourcedEntity(cartId)
      .method(ShoppingCartEntity::addItem)
      .invokeAsync(item)
      .thenApply(__ -> HttpResponses.ok()); (7)
  }
1 Common path prefix for all methods in the same class /carts.
2 Accept the ComponentClient and keep it in a field.
3 GET endpoint path is combined with a path parameter name, e.g. /carts/123.
4 The component client can be used to interact with other components.
5 Result of a request to a component is a CompletionStage<T>, it can be returned directly to let Akka serialize it.
6 Use path parameter {cartId} in combination with request body ShoppingCart.LineItem.
7 Result of request mapped to a more suitable response, in this case, 200 Ok with an empty body.

For more details see Component and service calls

Interacting with other HTTP services

It is also possible to interact with other services over HTTP. This is done through the akka.javasdk.http.HttpClientProvider.

When the other service is also an Akka service deployed in the same project, it can be looked up via the deployed name of the service:

TODO: this sample is a bit too synthetic to be good, we accept a request and pass it on, and then we don’t look at the response body at all

@HttpEndpoint("/customer")
public class CustomerRegistryEndpoint {

  private final Logger log = LoggerFactory.getLogger(getClass());
  private final HttpClient httpClient;
  private final ComponentClient componentClient;

  public record Address(String street, String city) { }

  public record CreateCustomerRequest(String email, String name, Address address) { }


  public CustomerRegistryEndpoint(HttpClientProvider webClientProvider, (1)
                                  ComponentClient componentClient) {
    this.httpClient = webClientProvider.httpClientFor("customer-registry"); (2)
    this.componentClient = componentClient;
  }

  @Post("/{id}")
  public CompletionStage<HttpResponse> create(String id, CreateCustomerRequest createRequest) {
    log.info("Delegating customer creation to upstream service: {}", createRequest);
    if (id == null || id.isBlank())
      throw HttpException.badRequest("No id specified");

    // make call to customer-registry service
    return
      httpClient.POST("/customer/" + id) (3)
        .withRequestBody(createRequest)
        .invokeAsync() (4)
        .thenApply(response -> { (5)
          if (response.httpResponse().status() == StatusCodes.CREATED) {
            return HttpResponses.created();
          } else {
            throw new RuntimeException("Delegate call to create upstream customer failed, response status: " + response.httpResponse().status());
          }
        });
  }
1 Accept the HttpClientProvider
2 Use it to create a client for the service customer-registry
3 Issue an HTTP POST request to the service
4 The result of a request is either CompletionStage<T> with the result
5 Once the response arrives, turn it into our own response.
If you’re looking to test this locally, you will likely need to run the 2 services with different ports. For more details, consult Running multiple services.

It is also possible to interact with arbitrary non-Akka services using the HttpClientProvider, for such use, pass a string with https://example.com or http://example.com instead of a service name.

For more details see Component and service calls

Advanced HTTP requests and responses

For more control over the request and responses it is also possible to use the more low-level Akka HTTP model APIs.

Low level responses

Returning akka.http.javadsl.model.HttpResponse makes it possible to do more flexible and advanced responses.

For example, it allows returning custom headers, custom response body encodings and even streaming responses.

As a convenience akka.javasdk.http.HttpResponses provides factories for common response scenarios without having to reach for the Akka HTTP model APIs directly:

  record HelloResponse(String greeting) {}

  @Get("/hello-low-level-response/{name}/{age}")
  public HttpResponse lowLevelResponseHello(String name, int age) { (1)
    if (age > 130)
      return HttpResponses.badRequest("It is unlikely that you are " + age + " years old");  (2)
    else
      return HttpResponses.ok(new HelloResponse("Hello " + name + "!"));  (3)
  }
1 Declare the return type as akka.http.javadsl.model.HttpResponse
2 Return a bad request response
3 Return an ok response, you can still use arbitrary objects and get them serialized to JSON

akka.javasdk.http.HttpResponses provides convenient factories for common response message types without having to reach for the Akka HTTP model APIs directly:

  record HelloResponse(String greeting) {}

  @Get("/hello-low-level-response/{name}/{age}")
  public HttpResponse lowLevelResponseHello(String name, int age) { (1)
    if (age > 130)
      return HttpResponses.badRequest("It is unlikely that you are " + age + " years old");  (2)
    else
      return HttpResponses.ok(new HelloResponse("Hello " + name + "!"));  (3)
  }
1 Declare the return type as akka.http.javadsl.model.HttpResponse
2 Return a bad request response
3 Return an ok response

Dropping all the way down to the Akka HTTP API:

  @Get("/hello-lower-level-response/{name}/{age}")
  public HttpResponse lowerLevelResponseHello(String name, int age) {
    if (age > 130)
      return HttpResponse.create()
          .withStatus(StatusCodes.BAD_REQUEST)
          .withEntity("It is unlikely that you are " + age + " years old");
    else {
      try {
        var jsonBytes = JsonSupport.encodeToAkkaByteString(new HelloResponse("Hello " + name + "!")); (1)
        return HttpResponse.create() (2)
            .withEntity(ContentTypes.APPLICATION_JSON, jsonBytes); (3)
      } catch (JsonProcessingException e) {
        throw new RuntimeException("Could not serialize response to JSON", e);
      }
    }
  }
1 At this level there is no convenience, the response object must manually be rendered into JSON bytes
2 The response returned by HttpResponse.create is 200 Ok
3 Pass the response body bytes and the ContentType to describe what they contain

Low level requests

Accepting HttpEntity.Strict will collect all request entity bytes into memory for processing (up to 8Mb), for example to handle uploads of a custom media type:

  private final static ContentType IMAGE_JPEG = ContentTypes.create(MediaTypes.IMAGE_JPEG);
  @Post("/post-image/{name}")
  public String lowLevelRequestHello(String name, HttpEntity.Strict strictRequestBody) {
    if (!strictRequestBody.getContentType().equals(IMAGE_JPEG)) (1)
      throw HttpException.badRequest("This service only accepts " + IMAGE_JPEG);
    else {
      return "Got " + strictRequestBody.getData().size() + " bytes for image name " + name;  (2)
    }
  }
1 HttpEntity.Strict gives access to the request body content type
2 as well as the actual bytes, in a akka.util.ByteString

Accepting akka.http.javadsl.model.HttpRequest makes it possible to do more flexible and advanced request handling but at the cost of quite a bit more complex request handling.

This way of handling requests should only be used for advanced use cases when there is no other option.

In such a method it is paramount that the streaming request body is always handled, for example by discarding it or collecting it all into memory, if not it will stall the incoming HTTP connection.

Handling the streaming request will require a akka.stream.Materializer, to get access to a materializer, define a constructor parameter of this type to have it injected by the SDK.

public class ExampleEndpoint {

  private final Materializer materializer;

  public ExampleEndpoint(Materializer materializer) { (1)
    this.materializer = materializer;
  }

  @Get("/hello-lower-level-request/{name}")
  public CompletionStage<String> lowerLevelRequestHello(String name, HttpRequest request) {
    if (request.getHeader("X-my-special-header").isEmpty()) {
      return request.discardEntityBytes(materializer).completionStage().thenApply(__ -> { (2)
        throw HttpException.forbidden("Missing the special header");
      });
    } else {
      return request.entity().toStrict(1000, materializer).thenApply(strictRequestBody ->  (3)
        " Hello " + name + "! " +
            "We got your " + strictRequestBody.getData().size() + " bytes " +
            "of type " + strictRequestBody.getContentType()
      );
    }
  }
1 Accept the materializer and keep it in a field
2 Make sure to discard the request body when failing
3 Or collect the bytes into memory