Skip to content
Open
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
96 changes: 95 additions & 1 deletion guide/guide/.js/src/main/assets/pages/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,24 @@ trait MyRestApi { ... }
object MyRestApi extends CirceRestApiCompanion[MyRestApi]
```

Instead of hand-writing one convenience base class per companion shape, you can use
`RestApisWithCustomImplicits`, which packages **all** the companion shapes at once, pre-bound to your
implicits bundle. Create a single object parameterized by your implicits type and use its inner
companions:

```scala
object CirceRestApis extends RestApisWithCustomImplicits[CirceRestImplicits.type](CirceRestImplicits)

trait MyRestApi { ... }
object MyRestApi extends CirceRestApis.ApiCompanion[MyRestApi] // client + server + OpenAPI
```

Its inner companions mirror the standard ones: `ApiCompanion` (client + server + OpenAPI),
`NoDocApiCompanion` (no OpenAPI), `ServerApiCompanion` (server only) and `ClientApiCompanion`
(client only). For the data types used by such APIs there is an analogous `ApiDataWithCustomImplicits`
which provides `ApiDataCompanion`, `ApiSealedCaseCompanion` and other data-type companions bound to your
implicits - the custom-implicits counterpart of [`RestDataCompanion`](#restdatacompanion).

**WARNING**: if you also generate [OpenAPI documents](#generating-openapi-30-specifications) for your
REST API, then along from custom serialization you must provide customized instances of
[`RestSchema`](#restschema-typeclass) that will adequately describe your new serialization format.
Expand Down Expand Up @@ -883,7 +901,11 @@ of `AsTask[MyFavoriteIOMonad]` (for server side) and `FromTask[MyFavoriteIOMonad
Just like when [providing serialization for third party type](#providing-serialization-for-third-party-type),
you should put these implicits into a trait and inject them into REST API trait's companion object.

Udash repository contains an [example implementation of Monix Task support in its test sources](https://github.com/UdashFramework/udash-core/blob/master/rest/src/test/scala/io/udash/rest/monix/MonixRestImplicits.scala).
Note that `DefaultRestImplicits` already supports both Monix `Task` and `Future` out of the box - `Task`
is the effect Udash REST uses internally, so no extra implicits are needed for it. To integrate a
different effect type `F[_]`, provide implicit instances of `RawRest.AsTask[F]` (used on the server side)
and `RawRest.FromTask[F]` (used on the client side) in your implicits trait and inject them through the
companion object, exactly as with custom serialization.

## API evolution

Expand Down Expand Up @@ -917,6 +939,78 @@ Conversely, changes that would break your API include:
- Adding non-`@Path` parameters without giving them default value
- Changing order of `@Path` parameters

## Contextual REST APIs

Sometimes the server-side implementation of a REST method needs access to some *request-scoped context*
that must **not** appear in the client-facing interface - a typical example is the authenticated user,
extracted from a session cookie or token by a lower layer (e.g. a servlet filter). Udash REST supports
this through *contextual* APIs.

The building block is `WithCtx[Ctx, R]` - simply a wrapper over a `Ctx => R` function. Contextual method
results use the alias `CtxTask[T] = WithCtx[Ctx, Task[T]]`, i.e. a `Task` that still awaits the context.

Contextual companions are parameterized with an implicits bundle, just like the
[custom-serialization companions](#plugging-in-entirely-custom-serialization), so you get custom
serialization at the same time. In the examples below `MyImplicits` could add serialization for your own
types; here it is simply `DefaultRestImplicits`:

```scala
object MyImplicits extends DefaultRestImplicits
```

### Server-only contextual APIs

If the API is consumed only by external clients (not written in Udash REST), use
`ContextualServerRestApis`, which bakes in both the implicits bundle and the context type:

```scala
case class UserContext(userId: String)
object MyCtxApis extends ContextualServerRestApis[MyImplicits.type, UserContext](MyImplicits)

trait UserApi {
@GET def profile(id: String): MyCtxApis.CtxTask[String]
}
object UserApi extends MyCtxApis.ServerApiCompanion[UserApi]

class UserApiImpl extends UserApi {
def profile(id: String): MyCtxApis.CtxTask[String] =
MyCtxApis.CtxTask { ctx => Task.now(s"profile $id requested by ${ctx.userId}") }
}
```

The `CtxTask { ctx => ... }` builder gives the implementation access to the context. The context is
supplied (as an implicit) by the backend when the implementation is turned into a raw REST handler, so it
never appears on the wire. Use `ServerApiImplCompanion` instead of `ServerApiCompanion` if your API has
its methods implemented directly in a class, without a separate trait.

### Contextual APIs shared between server and client

If the same API trait is used on both sides (typical for inter-service communication), use
`ContextualServerAndClientRestApis`. The API trait is parameterized with the context type; the client
uses `NoCtx`:

```scala
object MyRestApis extends ContextualServerAndClientRestApis[MyImplicits.type](MyImplicits)

trait UserApi[Ctx] extends MyRestApis.Api[Ctx] {
@GET def profile(id: String): CtxTask[String]
}
object UserApi extends MyRestApis.ApiCompanion[UserApi]

// server side - context is available in the implementation
class UserApiImpl extends UserApi[UserContext] {
def profile(id: String): CtxTask[String] =
CtxTask { ctx => Task.now(s"profile $id requested by ${ctx.userId}") }
}

// client side - no context; UserApi.Client is an alias for UserApi[NoCtx]
val client: UserApi.Client = ??? // e.g. JettyRestClient[UserApi.Client](...)
val result: Task[String] = client.profile("42").result
```

On the client, a method returns a `CtxTask[String]` over `NoCtx`; calling `.result` resolves the empty
context implicitly and yields the `Task` that performs the request.

## Implementing backends

Core REST framework has Servlet based server implementation and `sttp` based client implementation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.udash
package rest

import io.udash.rest.raw.RawRest
import monix.eval.Task
import monix.execution.Scheduler
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers

// Contextual API used on both sides: parameterized by context, client fixes it to NoCtx.
trait GreetSharedApi[Ctx] extends CtxSharedRestApis.Api[Ctx] {
@GET def greet(@Query tag: Tag): CtxTask[String]
}
object GreetSharedApi extends CtxSharedRestApis.ApiCompanion[GreetSharedApi]

class GreetSharedApiImpl extends GreetSharedApi[UserCtx] {
def greet(tag: Tag): CtxTask[String] = CtxTask { ctx => Task.now(s"${ctx.user}:${tag.value}") }
}

// Same, but without OpenAPI generation.
trait GreetSharedNoDocApi[Ctx] extends CtxSharedRestApis.Api[Ctx] {
@GET def greet(@Query tag: Tag): CtxTask[String]
}
object GreetSharedNoDocApi extends CtxSharedRestApis.NoDocApiCompanion[GreetSharedNoDocApi]

class GreetSharedNoDocApiImpl extends GreetSharedNoDocApi[UserCtx] {
def greet(tag: Tag): CtxTask[String] = CtxTask { ctx => Task.now(s"nodoc-${ctx.user}:${tag.value}") }
}

class ContextualServerAndClientRestApiTest extends AnyFunSuite with ScalaFutures with Matchers {
implicit def scheduler: Scheduler = Scheduler.global

test("server (with context) and client (NoCtx) round-trip over the custom Tag type") {
implicit val ctx: UserCtx = UserCtx("bob")
val serverHandle: RawRest.HandleRequest =
RawRest.asHandleRequest[GreetSharedApi[UserCtx]](new GreetSharedApiImpl)

// client type is GreetSharedApi[NoCtx]
val client: GreetSharedApi.Client = RawRest.fromHandleRequest[GreetSharedApi.Client](serverHandle)

// .result resolves the NoCtx context implicitly, then the task fires the request through serverHandle
client.greet(Tag("hi")).result.runToFuture.futureValue shouldBe "bob:hi"
}

test("NoDocApiCompanion: same round-trip without OpenAPI generation") {
implicit val ctx: UserCtx = UserCtx("bob")
val serverHandle: RawRest.HandleRequest =
RawRest.asHandleRequest[GreetSharedNoDocApi[UserCtx]](new GreetSharedNoDocApiImpl)

val client: GreetSharedNoDocApi.Client =
RawRest.fromHandleRequest[GreetSharedNoDocApi.Client](serverHandle)

client.greet(Tag("hi")).result.runToFuture.futureValue shouldBe "nodoc-bob:hi"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.udash
package rest

import io.udash.rest.raw._
import monix.eval.Task
import monix.execution.Scheduler
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers

// Server-only contextual API defined as a trait + separate implementation.
trait CtxGreetApi {
@GET def greet(@Query tag: Tag): CtxRestApis.CtxTask[String]
}
object CtxGreetApi extends CtxRestApis.ServerApiCompanion[CtxGreetApi]

class CtxGreetApiImpl extends CtxGreetApi {
def greet(tag: Tag): CtxRestApis.CtxTask[String] =
CtxRestApis.CtxTask { ctx => Task.now(s"${ctx.user}:${tag.value}") }
}

// Server-only contextual API defined directly as an implementation class (no separate trait).
class CtxPingImpl {
@GET def ping(@Query tag: Tag): CtxRestApis.CtxTask[String] =
CtxRestApis.CtxTask { ctx => Task.now(s"pong-${ctx.user}-${tag.value}") }
}
object CtxPingImpl extends CtxRestApis.ServerApiImplCompanion[CtxPingImpl]

// Server-only contextual API without OpenAPI generation.
trait CtxGreetNoDocApi {
@GET def greet(@Query tag: Tag): CtxRestApis.CtxTask[String]
}
object CtxGreetNoDocApi extends CtxRestApis.ServerNoDocApiCompanion[CtxGreetNoDocApi]

class CtxGreetNoDocApiImpl extends CtxGreetNoDocApi {
def greet(tag: Tag): CtxRestApis.CtxTask[String] =
CtxRestApis.CtxTask { ctx => Task.now(s"nodoc-${ctx.user}:${tag.value}") }
}

class ContextualServerRestApiTest extends AnyFunSuite with ScalaFutures with Matchers {
implicit def scheduler: Scheduler = Scheduler.global
implicit val ctx: UserCtx = UserCtx("alice")

private def getTag(handle: RawRest.HandleRequest, path: String, tagQuery: String): RestResponse =
handle(RestRequest(
HttpMethod.GET,
RestParameters(List(PlainValue(path)), query = Mapping.create("tag" -> PlainValue(tagQuery))),
HttpBody.Empty,
)).runToFuture.futureValue

test("ServerApiCompanion: injected Tag serialization is used and context is applied") {
val handle = RawRest.asHandleRequest[CtxGreetApi](new CtxGreetApiImpl)
val resp = getTag(handle, "greet", "tag:hello")
resp.code shouldBe 200
resp.body.textualContentOpt.get shouldBe "\"alice:hello\""
}

test("ServerApiImplCompanion: same behavior for a bare implementation class") {
val handle = RawRest.asHandleRequest[CtxPingImpl](new CtxPingImpl)
val resp = getTag(handle, "ping", "tag:world")
resp.code shouldBe 200
resp.body.textualContentOpt.get shouldBe "\"pong-alice-world\""
}

test("ServerNoDocApiCompanion: same behavior without OpenAPI generation") {
val handle = RawRest.asHandleRequest[CtxGreetNoDocApi](new CtxGreetNoDocApiImpl)
val resp = getTag(handle, "greet", "tag:hello")
resp.code shouldBe 200
resp.body.textualContentOpt.get shouldBe "\"nodoc-alice:hello\""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package io.udash
package rest

import com.avsystem.commons.serialization.json.JsonStringOutput
import io.udash.rest.openapi.Info
import io.udash.rest.raw.{PlainValue, RawRest, RestRequest}
import monix.eval.Task
import monix.execution.Scheduler
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers

// Non-contextual API whose companion is derived through the custom-implicits bundle.
trait EchoApi {
@GET def echo(@Query tag: Tag): Task[String]
}
object EchoApi extends CustomRestApis.ApiCompanion[EchoApi]

class EchoApiImpl extends EchoApi {
def echo(tag: Tag): Task[String] = Task.now(s"got:${tag.value}")
}

// Same, but without OpenAPI generation.
trait EchoNoDocApi {
@GET def echo(@Query tag: Tag): Task[String]
}
object EchoNoDocApi extends CustomRestApis.NoDocApiCompanion[EchoNoDocApi]

class EchoNoDocApiImpl extends EchoNoDocApi {
def echo(tag: Tag): Task[String] = Task.now(s"nodoc:${tag.value}")
}

class CustomImplicitsRestApiTest extends AnyFunSuite with ScalaFutures with Matchers {
implicit def scheduler: Scheduler = Scheduler.global

test("round-trips using the injected Tag serialization on both sides") {
@volatile var lastRequest: RestRequest = null
val serverHandle: RawRest.HandleRequest = { req =>
lastRequest = req
RawRest.asHandleRequest[EchoApi](new EchoApiImpl).apply(req)
}
val client: EchoApi = RawRest.fromHandleRequest[EchoApi](serverHandle)

// server-side decode ("got:") and successful result prove the injected AsReal[PlainValue, Tag] was used
client.echo(Tag("hi")).runToFuture.futureValue shouldBe "got:hi"

// client-side encode: the outgoing query uses the custom `tag:` format => injected AsRaw was collected
val queryValue = lastRequest.parameters.query.entries.collectFirst {
case (k, PlainValue(v)) if k == "tag" => v
}
queryValue shouldBe Some("tag:hi")
}

test("OpenAPI is generated from the injected Tag schema") {
// `object EchoApi extends CustomRestApis.ApiCompanion[EchoApi]` already requires a RestSchema[Tag]
// from the injected bundle to compile; here we just confirm the document renders.
val openapi = EchoApi.openapiMetadata.openapi(Info("Echo", "1.0"))
val json = JsonStringOutput.writePretty(openapi)
json should include("/echo")
json should include("tag")
}

test("NoDocApiCompanion round-trips (client + server, no OpenAPI)") {
@volatile var lastRequest: RestRequest = null
val serverHandle: RawRest.HandleRequest = { req =>
lastRequest = req
RawRest.asHandleRequest[EchoNoDocApi](new EchoNoDocApiImpl).apply(req)
}
val client: EchoNoDocApi = RawRest.fromHandleRequest[EchoNoDocApi](serverHandle)

client.echo(Tag("hi")).runToFuture.futureValue shouldBe "nodoc:hi"

lastRequest.parameters.query.entries.collectFirst {
case (k, PlainValue(v)) if k == "tag" => v
} shouldBe Some("tag:hi")
}
}
52 changes: 52 additions & 0 deletions rest/.jvm/src/test/scala/io/udash/rest/WithCtxTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package io.udash
package rest

import io.udash.rest.WithCtx.NoCtx
import monix.eval.Task
import monix.execution.Scheduler
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers

class WithCtxTest extends AnyFunSuite with ScalaFutures with Matchers {
implicit def scheduler: Scheduler = Scheduler.global

private val CtxTask = CtxTaskCompanion[UserCtx]
private val alice = UserCtx("alice")

test("WithCtx.apply and result compute the value for a context") {
val wc = WithCtx[UserCtx, String](_.user.toUpperCase)
wc(alice) shouldBe "ALICE"
wc.result(alice) shouldBe "ALICE"
}

test("CtxTaskCompanion factory methods") {
CtxTask.now(1).apply(alice).runToFuture.futureValue shouldBe 1
CtxTask.eval(2).apply(alice).runToFuture.futureValue shouldBe 2
CtxTask.sync(_.user).apply(alice).runToFuture.futureValue shouldBe "alice"
CtxTask(ctx => Task.now(ctx.user + "!")).apply(alice).runToFuture.futureValue shouldBe "alice!"
CtxTask.readCtx.apply(alice).runToFuture.futureValue shouldBe alice
CtxTask.readCtx(_.user).apply(alice).runToFuture.futureValue shouldBe "alice"
CtxTask.unit.apply(alice).runToFuture.futureValue shouldBe (())
CtxTask.defer(CtxTask.now(3)).apply(alice).runToFuture.futureValue shouldBe 3
CtxTask.raiseError[Int](new RuntimeException("boom"))
.apply(alice).runToFuture.failed.futureValue.getMessage shouldBe "boom"
}

test("CtxTaskOps.map and flatMap thread the same context through") {
val base = CtxTask.readCtx(_.user) // yields ctx.user
base.map(_.length).apply(alice).runToFuture.futureValue shouldBe 5
base.flatMap(u => CtxTask.now(u + "-x")).apply(alice).runToFuture.futureValue shouldBe "alice-x"
}

test("CtxTaskOps.onErrorRecover and failed") {
val failing = CtxTask.raiseError[Int](new RuntimeException("nope"))
failing.onErrorRecover { case _ => 42 }.apply(alice).runToFuture.futureValue shouldBe 42
failing.failed.apply(alice).runToFuture.futureValue.getMessage shouldBe "nope"
}

test("NoCtx makes result resolvable from the implicit scope") {
val wc = WithCtx[NoCtx, Int](_ => 7)
wc.result shouldBe 7 // uses the implicit NoCtx.noContext
}
}
Loading
Loading