Skip to content

Commit

Permalink
Documentation of Endpoint API (#2741)
Browse files Browse the repository at this point in the history
* update path codec.

* rendering path codecs.

* attaching docs.

* attaching examples.

* decoding and formatting path codecs.

* endpoint overview.

* endpoint with error example.

* describing input and outputs.

* describing errors.

* transform methods.

* openapi documentation.

* sfix.

* description of a book.

* description annotation.

* sfix.

* remove extra layers.

* generate endpoint from openapi.

* fix basePath for code generator.

* generating cli app from endpoints.

* update example path.

* fmt.
  • Loading branch information
khajavi authored Apr 18, 2024
1 parent 30531f1 commit e6b61c4
Show file tree
Hide file tree
Showing 11 changed files with 886 additions and 9 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ libraryDependencies ++= Seq(
"dev.zio" %% "zio-metrics-connectors-prometheus" % "2.3.1"
)
)
.dependsOn(zioHttpJVM, zioHttpCli)
.dependsOn(zioHttpJVM, zioHttpCli, zioHttpGen)

lazy val zioHttpGen = (project in file("zio-http-gen"))
.settings(stdSettings("zio-http-gen"))
Expand Down
518 changes: 518 additions & 0 deletions docs/dsl/endpoint.md

Large diffs are not rendered by default.

67 changes: 66 additions & 1 deletion docs/dsl/path_codec.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ title: PathCodec

`PathCodec[A]` represents a codec for paths of type `A`, comprising segments where each segment can be a literal, an integer, a long, a string, a UUID, or the trailing path.

The three basic operations that `PathCodec` supports are:

- **decode**: converting a path into a value of type `A`.
- **format**: converting a value of type `A` into a path.
- **++ or /**: combining two `PathCodec` values to create a new `PathCodec` that matches both paths, so the resulting of the decoding operation will be a tuple of the two values.

So we can think of `PathCodec` as the following simplified trait:

```scala
trait PathCodec[A] {
def /[B](that: PathCodec[B]): PathCodec[(A, B)]

def decode(path: Path): Either[String, A]
def format(value: A): : Either[String, Path]
}
```

## Building PathCodecs

The `PathCodec` data type offers several predefined codecs for common types:
Expand All @@ -20,7 +37,7 @@ The `PathCodec` data type offers several predefined codecs for common types:

Complex `PathCodecs` can be constructed by combining them using the `/` operator:

```scala mdoc:compile-only
```scala mdoc:silent
import zio.http.codec.PathCodec
import PathCodec._

Expand All @@ -29,6 +46,54 @@ val pathCodec = empty / "users" / int("user-id") / "posts" / string("post-id")

By combining `PathCodec` values, the resulting `PathCodec` type reflects the types of the path segments it matches. In the provided example, the type of `pathCodec` is `(Int, String)` because it matches a path with two segments of type `Int` and `String`, respectively.

## Decoding and Formatting PathCodecs

To decode a path into a value of type `A`, we can use the `PathCodec#decode` method:

```scala mdoc
import zio.http._

pathCodec.decode(Path("users/123/posts/abc"))
```

To format (encode) a value of type `A` into a path, we can use the `PathCodec#format` method:

```scala mdoc
pathCodec.format((123, "abc"))
```

## Rendering PathCodecs

If we render the previous `PathCodec` to a string using `PathCodec#render` or `PathCodec#toString`, we get the following result:

```scala mdoc
pathCodec.render

pathCodec.toString
```

## Attaching Documentation to PathCodecs

The `PathCodec#??` operator, takes a `Doc` and annotate the `PathCodec` with it. It is useful for generating developer-friendly documentation for the API:

```scala mdoc
import zio.http.codec._

val users = PathCodec.literal("users") ?? (Doc.p("Managing users including CRUD operations"))
```

When generating OpenAPI documentation, these annotations will be used to generate the API documentation.

## Attaching Examples to PathCodecs

Similarly to attaching documentation, we can attach examples to `PathCodec` using the `PathCodec#example` operator:

```scala mdoc
import zio.http.codec._

val userId = PathCodec.int("user-id") ?? (Doc.p("The user id")) example ("user-id", 123)
```

## Using Value Objects with PathCodecs

Other than the common `PathCodec` constructors, it's also possible to transform a `PathCodec` into a more specific data type using the `transform` method.
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/concrete-entity.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ sidebar_label: "Concrete Entity"
```scala mdoc:passthrough
import utils._

printSource("zio-http-example/src/main/scala/example/CliExamples.scala")
printSource("zio-http-example/src/main/scala/example/endpoint/CliExamples.scala")
```
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const sidebars = {
"dsl/handler",
"dsl/headers",
"dsl/body",
"dsl/endpoint",
"dsl/form",
"dsl/cookies",
"dsl/flash",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package example.endpoint

import zio._

import zio.schema.annotation.description
import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.PathCodec._
import zio.http.codec._
import zio.http.endpoint._
import zio.http.endpoint.openapi._

object BooksEndpointExample extends ZIOAppDefault {
case class Book(
@description("Title of the book")
title: String,
@description("List of the authors of the book")
authors: List[String],
)
object Book {
implicit val schema: Schema[Book] = DeriveSchema.gen
}

object BookRepo {
val book1 = Book("Programming in Scala", List("Martin Odersky", "Lex Spoon", "Bill Venners", "Frank Sommers"))
val book2 = Book("Zionomicon", List("John A. De Goes", "Adam Fraser"))
val book3 = Book("Effect-Oriented Programming", List("Bill Frasure", "Bruce Eckel", "James Ward"))
def find(q: String): List[Book] = {
if (q.toLowerCase == "scala") List(book1, book2, book3)
else if (q.toLowerCase == "zio") List(book2, book3)
else List.empty
}
}

val endpoint =
Endpoint((RoutePattern.GET / "books") ?? Doc.p("Route for querying books"))
.query(
QueryCodec.queryTo[String]("q").examples(("example1", "scala"), ("example2", "zio")) ?? Doc.p(
"Query parameter for searching books",
),
)
.out[List[Book]](Doc.p("List of books matching the query")) ?? Doc.p(
"Endpoint to query books based on a search query",
)

val booksRoute = endpoint.implement(handler((query: String) => BookRepo.find(query)))
val openAPI = OpenAPIGen.fromEndpoints(title = "Library API", version = "1.0", endpoint)
val swaggerRoutes = SwaggerUI.routes("docs" / "openapi", openAPI)
val routes = Routes(booksRoute) ++ swaggerRoutes

def run = Server.serve(routes.toHttpApp).provide(Server.default)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package example
package example.endpoint

import zio._
import zio.cli._
Expand All @@ -13,9 +13,8 @@ import zio.http.endpoint.cli._
import zio.http.endpoint.{Endpoint, EndpointExecutor}

trait TestCliEndpoints {
import zio.http.codec.PathCodec._

import HttpCodec._
import zio.http.codec.PathCodec._
final case class User(
@description("The unique identifier of the User")
id: Int,
Expand Down Expand Up @@ -82,8 +81,8 @@ object TestCliApp extends zio.cli.ZIOCliDefault with TestCliEndpoints {
object TestCliServer extends zio.ZIOAppDefault with TestCliEndpoints {
val getUserRoute =
getUser.implement {
Handler.fromFunction { case (id, _) =>
User(id, "Juanito", Some("[email protected]"))
Handler.fromFunctionZIO { case (id, _) =>
ZIO.succeed(User(id, "Juanito", Some("[email protected]"))).debug("Hello")
}
}

Expand All @@ -101,7 +100,7 @@ object TestCliServer extends zio.ZIOAppDefault with TestCliEndpoints {
}
}

val routes = Routes(getUserRoute, getUserPostsRoute, createUserRoute)
val routes = Routes(getUserRoute, getUserPostsRoute, createUserRoute) @@ Middleware.debug

val run = Server.serve(routes.toHttpApp).provide(Server.default)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package example.endpoint

import zio._

import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.PathCodec
import zio.http.endpoint.Endpoint
import zio.http.endpoint.EndpointMiddleware.None

object EndpointWithError extends ZIOAppDefault {

case class Book(title: String, authors: List[String])

object Book {
implicit val schema: Schema[Book] = DeriveSchema.gen
}
case class NotFoundError(error: String, message: String)

object NotFoundError {
implicit val schema: Schema[NotFoundError] = DeriveSchema.gen
}

object BookRepo {
def find(id: Int): ZIO[Any, String, Book] = {
if (id == 1)
ZIO.succeed(Book("Zionomicon", List("John A. De Goes", "Adam Fraser")))
else
ZIO.fail("Not found")
}
}

val endpoint: Endpoint[Int, Int, NotFoundError, Book, None] =
Endpoint(RoutePattern.GET / "books" / PathCodec.int("id"))
.out[Book]
.outError[NotFoundError](Status.NotFound)

val getBookHandler: Handler[Any, NotFoundError, Int, Book] =
handler { (id: Int) =>
BookRepo
.find(id)
.mapError(err => NotFoundError(err, "The requested book was not found. Please try using a different ID."))
}

val app = endpoint.implement(getBookHandler).toHttpApp @@ Middleware.debug

def run = Server.serve(app).provide(Server.default)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package example.endpoint

import zio._

import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.{HeaderCodec, PathCodec}
import zio.http.endpoint.Endpoint
import zio.http.endpoint.EndpointMiddleware.None

object EndpointWithMultipleErrorsUsingEither extends ZIOAppDefault {

case class Book(title: String, authors: List[String])

object Book {
implicit val schema: Schema[Book] = DeriveSchema.gen
}

case class BookNotFound(message: String, bookId: Int)

object BookNotFound {
implicit val schema: Schema[BookNotFound] = DeriveSchema.gen
}

case class AuthenticationError(message: String, userId: Int)

object AuthenticationError {
implicit val schema: Schema[AuthenticationError] = DeriveSchema.gen
}

object BookRepo {
def find(id: Int): ZIO[Any, BookNotFound, Book] = {
if (id == 1)
ZIO.succeed(Book("Zionomicon", List("John A. De Goes", "Adam Fraser")))
else
ZIO.fail(BookNotFound("The requested book was not found.", id))
}
}

val endpoint: Endpoint[Int, (Int, Header.Authorization), Either[BookNotFound, AuthenticationError], Book, None] =
Endpoint(RoutePattern.GET / "books" / PathCodec.int("id"))
.header(HeaderCodec.authorization)
.out[Book]
.outError[BookNotFound](Status.NotFound)
.outError[AuthenticationError](Status.Unauthorized)

def isUserAuthorized(authHeader: Header.Authorization) = false

val getBookHandler
: Handler[Any, Either[BookNotFound, AuthenticationError], (RuntimeFlags, Header.Authorization), Book] =
handler { (id: Int, authHeader: Header.Authorization) =>
if (isUserAuthorized(authHeader))
BookRepo.find(id).mapError(Left(_))
else
ZIO.fail(Right(AuthenticationError("User is not authenticated", 123)))
}

val app = endpoint.implement(getBookHandler).toHttpApp @@ Middleware.debug

def run = Server.serve(app).provide(Server.default)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package example.endpoint

import zio._

import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.{HeaderCodec, HttpCodec, PathCodec}
import zio.http.endpoint.Endpoint
import zio.http.endpoint.EndpointMiddleware.None

object EndpointWithMultipleUnifiedErrors extends ZIOAppDefault {

case class Book(title: String, authors: List[String])

object Book {
implicit val schema: Schema[Book] = DeriveSchema.gen
}

abstract class AppError(message: String)

case class BookNotFound(message: String, bookId: Int) extends AppError(message)

object BookNotFound {
implicit val schema: Schema[BookNotFound] = DeriveSchema.gen
}

case class AuthenticationError(message: String, userId: Int) extends AppError(message)

object AuthenticationError {
implicit val schema: Schema[AuthenticationError] = DeriveSchema.gen
}

object BookRepo {
def find(id: Int): ZIO[Any, BookNotFound, Book] = {
if (id == 1)
ZIO.succeed(Book("Zionomicon", List("John A. De Goes", "Adam Fraser")))
else
ZIO.fail(BookNotFound("The requested book was not found.", id))
}
}

val endpoint: Endpoint[Int, (Int, Header.Authorization), AppError, Book, None] =
Endpoint(RoutePattern.GET / "books" / PathCodec.int("id"))
.header(HeaderCodec.authorization)
.out[Book]
.outErrors[AppError](
HttpCodec.error[BookNotFound](Status.NotFound),
HttpCodec.error[AuthenticationError](Status.Unauthorized),
)

def isUserAuthorized(authHeader: Header.Authorization) = false

val getBookHandler: Handler[Any, AppError, (Int, Header.Authorization), Book] =
handler { (id: Int, authHeader: Header.Authorization) =>
if (isUserAuthorized(authHeader))
BookRepo.find(id)
else
ZIO.fail(AuthenticationError("User is not authenticated", 123))
}

val app = endpoint.implement(getBookHandler).toHttpApp @@ Middleware.debug

def run = Server.serve(app).provide(Server.default)
}
Loading

0 comments on commit e6b61c4

Please sign in to comment.