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>
whereT
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
orvoid
to return an empty body. -
String
to return a UTF-8 encodedtext/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 a400 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;
private static final Logger logger = LoggerFactory.getLogger(ShoppingCartEndpoint.class);
public ShoppingCartEndpoint(ComponentClient componentClient) { (2)
this.componentClient = componentClient;
}
@Get("/{cartId}") (3)
public CompletionStage<ShoppingCart> get(String cartId) {
logger.info("Get cart id={}", cartId);
return componentClient.forEventSourcedEntity(cartId) (4)
.method(ShoppingCartEntity::getCart)
.invokeAsync(); (5)
}
@Put("/{cartId}/item") (6)
public CompletionStage<HttpResponse> addItem(String cartId, ShoppingCart.LineItem item) {
logger.info("Adding item to cart id={} item={}", cartId, 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:
@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 |