Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Http endpoint base class with request context access #18

Merged
merged 3 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import akka.javasdk.annotations.JWT;
import akka.javasdk.annotations.http.HttpEndpoint;
import akka.javasdk.annotations.http.Get;
import akka.javasdk.http.RequestContext;
import akka.javasdk.http.AbstractHttpEndpoint;

import java.util.concurrent.CompletionStage;
import static java.util.concurrent.CompletableFuture.completedStage;
Expand All @@ -19,17 +19,12 @@
// tag::bearer-token[]
@HttpEndpoint("/hello")
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN, bearerTokenIssuers = "my-issuer-123", staticClaims = { @JWT.StaticClaim(claim = "sub", pattern = "my-subject-123")})
public class HelloJwtEndpoint {
public class HelloJwtEndpoint extends AbstractHttpEndpoint {
// end::bearer-token[]

RequestContext context;
public HelloJwtEndpoint(RequestContext context){
this.context = context;
}

@Get("/")
public CompletionStage<String> helloWorld() {
var claims = context.getJwtClaims();
var claims = requestContext().getJwtClaims();
var issuer = claims.issuer().get();
var sub = claims.subject().get();
return completedStage("issuer: " + issuer + ", subject: " + sub);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
@HttpEndpoint("/missingjwt")
public class MissingJwtEndpoint {

// Note: leaving this with injected request context rather than extend AbstractHttpEndpoint to keep
// some test coverage
RequestContext context;
public MissingJwtEndpoint(RequestContext context){
this.context = context;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
* <li>{@link io.opentelemetry.api.trace.Span}</li>
* <li>Custom types provided by a {@link akka.javasdk.DependencyProvider} from the service setup</li>
* </ul>
* <p>If the annotated class extends {@link akka.javasdk.http.AbstractHttpEndpoint} the request context
* is available without constructor injection.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2021-2024 Lightbend Inc. <https://www.lightbend.com>
*/

package akka.javasdk.http;

import akka.annotation.InternalApi;

/**
* Optional base class for HTTP endpoints giving access to a request context without additional constructor parameters
*/
abstract public class AbstractHttpEndpoint {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could call it RequestContextSupport.

And maybe better if we make this a interface with default methods. As such users can use it as a mixin instead.

And have some other base classes on their own.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a field so interface does not work.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes, that's true.


volatile private RequestContext context;

/**
* INTERNAL API
*
* @hidden
*/
@InternalApi
final public void _internalSetRequestContext(RequestContext context) {
this.context = context;
}

/**
* Always available from request handling methods, not available from the constructor.
*/
protected final RequestContext requestContext() {
if (context == null) {
throw new IllegalStateException("The request context can only be accessed from the request handling methods of the endpoint.");
}
return context;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
import akka.javasdk.Tracing;

/**
* Not for user extension, can be injected as constructor parameter into HTTP endpoint components
* Not for user extension, can be injected as constructor parameter into HTTP endpoint components or
* accessible from {@link AbstractHttpEndpoint#requestContext()} if the endpoint class extends
* `AbstractHttpEndpoint`.
*/
@DoNotInherit
public interface RequestContext extends Context {
Expand Down
38 changes: 22 additions & 16 deletions akka-javasdk/src/main/scala/akka/javasdk/impl/SdkRunner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import akka.javasdk.view.View
import akka.javasdk.workflow.Workflow
import akka.javasdk.workflow.WorkflowContext
import akka.javasdk.JwtClaims
import akka.javasdk.http.AbstractHttpEndpoint
import akka.javasdk.Tracing
import akka.javasdk.impl.http.JwtClaimsImpl
import akka.javasdk.impl.telemetry.SpanTracingImpl
Expand Down Expand Up @@ -560,25 +561,30 @@ private final class Sdk(

private def httpEndpointFactory[E](httpEndpointClass: Class[E]): HttpEndpointConstructionContext => E = {
(context: HttpEndpointConstructionContext) =>
wiredInstance(httpEndpointClass) {
lazy val requestContext = new RequestContext {
override def getPrincipals: Principals =
PrincipalsImpl(context.principal.source, context.principal.service)

override def getJwtClaims: JwtClaims =
context.jwt match {
case Some(jwtClaims) => new JwtClaimsImpl(jwtClaims)
case None =>
throw new RuntimeException(
"There are no JWT claims defined but trying accessing the JWT claims. The class or the method needs to be annotated with @JWT.")
}

override def tracing(): Tracing = new SpanTracingImpl(context.openTelemetrySpan, sdkTracerFactory)
}
val instance = wiredInstance(httpEndpointClass) {
sideEffectingComponentInjects(context.openTelemetrySpan).orElse {
case p if p == classOf[RequestContext] =>
new RequestContext {
override def getPrincipals: Principals =
PrincipalsImpl(context.principal.source, context.principal.service)

override def getJwtClaims: JwtClaims =
context.jwt match {
case Some(jwtClaims) => new JwtClaimsImpl(jwtClaims)
case None =>
throw new RuntimeException(
"There are no JWT claims defined but trying accessing the JWT claims. The class or the method needs to be annotated with @JWT.")
}

override def tracing(): Tracing = new SpanTracingImpl(context.openTelemetrySpan, sdkTracerFactory)
}
case p if p == classOf[RequestContext] => requestContext
}
}
instance match {
case withBaseClass: AbstractHttpEndpoint => withBaseClass._internalSetRequestContext(requestContext)
case _ =>
}
instance
}

private def wiredInstance[T](clz: Class[T])(partial: PartialFunction[Class[_], Any]): T = {
Expand Down
9 changes: 3 additions & 6 deletions docs/src/modules/java/pages/access-control.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -185,19 +185,16 @@ Note that in local development, the services don’t actually authenticate with

== Programmatically accessing principals

The current principal associated with a request can be accessed through the `RequestContext`. The `RequestContext`
can be injected in the endpoint constructor.
The current principal associated with a request can be accessed through the `RequestContext`.

NOTE: Endpoints are stateless and each request is served by a new Endpoint instance. Therefore, the
injected `RequestContext` is always a new instance and is associated with the request currently being handled.
NOTE: Endpoints are stateless and each request is served by a new Endpoint instance. Therefore, the `RequestContext` is always a new instance and is associated with the request currently being handled.

[source, java, indent=0]
.{sample-base-url}/doc-snippets/src/main/java/com/example/acl/UserEndpoint.java[UserEndpoint.java]
----
include::example$doc-snippets/src/main/java/com/example/acl/UserEndpoint.java[tags=endpoint-class;request-context]
----

<1> Inject `RequestContext` on your endpoint constructor to gain access to the request specific context.
<1> Let your endpoint extend link:_attachments/api/akka/javasdk/http/AbstractHttpEndpoint.html[AbstractHttpEndpoint] to get access to the request specific `RequestContext` through `requestContext()`.

You can access the current Principals through method `RequestContext.getPrincipals()`

Expand Down
6 changes: 3 additions & 3 deletions docs/src/modules/java/pages/auth-with-jwts.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,15 @@ include::example$endpoint-jwt/src/main/java/hellojwt/api/HelloJwtEndpoint.java[t
----

The token extracted from the bearer token must have one of the two issuers defined in the annotation.
Akka will place the claims from the validated token in the link:_attachments/api/akka/javasdk/http/RequestContext.html[RequestContext], so you can access them from your service via `getJwtClaims()`. The `RequestContext` can be injected into the endpoint constructor, so you can retrieve the JWT claims like this:
Akka will place the claims from the validated token in the link:_attachments/api/akka/javasdk/http/RequestContext.html[RequestContext], so you can access them from your service via `getJwtClaims()`. The `RequestContext` is accessed by letting the endpoint extend link:_attachments/api/akka/javasdk/http/AbstractHttpEndpoint.html[AbstractHttpEndpoint] which provides the method `requestContext()`, so you can retrieve the JWT claims like this:

[source, java, indent=0]
.{sample-base-url}/endpoint-jwt/src/main/java/hellojwt/api/HelloJwtEndpoint.java[HelloJwtEndpoint.java]
----
include::example$endpoint-jwt/src/main/java/hellojwt/api/HelloJwtEndpoint.java[tag=accessing-claims]
----
<1> Access the claims in the request. Note that while the `get()` is generally a bad practice, here we know the claims must be present given the `@JWT` configuration.

<1> Access the claims from the request context.
<2> Note that while calling `Optional#get()` is generally a bad practice, here we know the claims must be present given the `@JWT` configuration.


== Running locally with JWT support
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import akka.javasdk.annotations.http.Get;
import akka.javasdk.annotations.http.HttpEndpoint;
import akka.javasdk.annotations.http.Post;
import akka.javasdk.http.RequestContext;
import akka.javasdk.http.AbstractHttpEndpoint;

/*
// tag::class-level-acl[]
Expand All @@ -20,32 +20,25 @@
*/
// tag::endpoint-class[]
@HttpEndpoint("/user")
public class UserEndpoint {
public class UserEndpoint extends AbstractHttpEndpoint { // <1>
// ...
// end::endpoint-class[]

// tag::request-context[]
final private RequestContext requestContext;

public UserEndpoint(RequestContext requestContext) { // <1>
this.requestContext = requestContext;
}
// end::request-context[]

public record CreateUser(String username, String email) { }

// tag::checking-principals[]
@Get
public String checkingPrincipals() {
if (requestContext.getPrincipals().isInternet()) {
var principals = requestContext().getPrincipals();
if (principals.isInternet()) {
return "accessed from the Internet";
} else if (requestContext.getPrincipals().isSelf()) {
} else if (principals.isSelf()) {
return "accessed from Self (internal call from current service)";
} else if (requestContext.getPrincipals().isBackoffice()) {
} else if (principals.isBackoffice()) {
return "accessed from Backoffice API";
} else {
return "accessed from another service: " +
requestContext.getPrincipals().getLocalService();
principals.getLocalService();
}
}
// end::checking-principals[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import akka.javasdk.annotations.http.HttpEndpoint;
// end::basic-endpoint[]
import akka.javasdk.annotations.http.Post;
import akka.javasdk.http.AbstractHttpEndpoint;
import akka.javasdk.http.HttpException;
import akka.javasdk.http.HttpResponses;
import akka.stream.Materializer;
Expand All @@ -28,7 +29,7 @@
@HttpEndpoint("/example") // <1>
@Acl(allow = @Acl.Matcher(principal = Acl.Principal.ALL)) // <2>
// tag::lower-level-request[]
public class ExampleEndpoint {
public class ExampleEndpoint extends AbstractHttpEndpoint {

// end::basic-endpoint[]
private final Materializer materializer;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.jwt;

import akka.javasdk.annotations.Acl;
import akka.javasdk.annotations.http.HttpEndpoint;
import akka.javasdk.http.AbstractHttpEndpoint;
import akka.javasdk.annotations.JWT;

// tag::bearer-token[]
@HttpEndpoint("/example-jwt") // <1>
@Acl(allow = @Acl.Matcher(principal = Acl.Principal.ALL)) // <2>
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
bearerTokenIssuers = "my-issuer") // <1>
public class HelloJwtEndpoint extends AbstractHttpEndpoint {

public String message(String msg) {
//..
// end::bearer-token[]
return "ok! Claims: " + String.join(",", requestContext().getJwtClaims().allClaimNames());
// tag::bearer-token[]
}

@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
bearerTokenIssuers = "my-other-issuer")
public String messageWithIssuer(String msg) { // <3>
//..
// end::bearer-token[]
return "ok! Claims: " + String.join(",", requestContext().getJwtClaims().allClaimNames());
// tag::bearer-token[]
}
}
// end::bearer-token[]
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import akka.javasdk.annotations.http.Get;
// tag::accessing-claims[]
import akka.javasdk.http.RequestContext;
import akka.javasdk.http.AbstractHttpEndpoint;

// end::accessing-claims[]

Expand All @@ -24,16 +24,9 @@
@HttpEndpoint("/hello")
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN, bearerTokenIssuers = "my-issuer") // <1>
// tag::accessing-claims[]
public class HelloJwtEndpoint {
public class HelloJwtEndpoint extends AbstractHttpEndpoint {
// end::bearer-token[]
// end::accessing-claims[]
// tag::accessing-claims[]

RequestContext context;
public HelloJwtEndpoint(RequestContext context){
this.context = context;
}
// end::accessing-claims[]

@Get("/")
public CompletionStage<String> hello() {
Expand All @@ -47,9 +40,9 @@ public CompletionStage<String> hello() {
// end::multiple-bearer-token-issuers[]
@Get("/claims")
public CompletionStage<String> helloClaims() {
var claims = context.getJwtClaims();
var issuer = claims.issuer().get(); // <1>
var sub = claims.subject().get(); // <1>
var claims = requestContext().getJwtClaims(); // <1>
var issuer = claims.issuer().get(); // <2>
var sub = claims.subject().get(); // <2>
return completedStage("issuer: " + issuer + ", subject: " + sub);
}
// tag::bearer-token[]
Expand Down
Loading