Construyendo APIs REST con Smithy4s

#scala#smithy4s#rest-api#http4s

Smithy4s es una herramienta de generación de código que crea código Scala de servidor y cliente desde especificaciones Smithy. Funciona con http4s y te da seguridad de tipos en tu API.

¿Por qué Smithy4s?

Cuando construyes APIs de forma tradicional, a menudo necesitas:

  • Escribir mucho código repetitivo para serialización
  • Mantener servidor, cliente y documentación sincronizados manualmente
  • Lidiar con errores en tiempo de ejecución cuando la API cambia

Smithy4s soluciona esto con:

  • Generación de código: Crea código de servidor y cliente desde una definición
  • Seguridad de tipos: El compilador detecta errores antes de ejecutar
  • Soporte de protocolos: Funciona con REST JSON y otros formatos
  • Buena integración: Funciona bien con http4s y Cats Effect

Comenzando

Configuración del Proyecto

Agrega el plugin Smithy4s a tu project/plugins.sbt:

addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.15")

Habilita el plugin en build.sbt:

lazy val root = project
  .in(file("."))
  .enablePlugins(Smithy4sCodegenPlugin)
  .settings(
    name := "smithy4s-demo",
    scalaVersion := "3.3.1",
    libraryDependencies ++= Seq(
      "com.disneystreaming.smithy4s" %% "smithy4s-http4s" % "0.18.15",
      "org.http4s" %% "http4s-ember-server" % "0.23.23",
      "org.http4s" %% "http4s-ember-client" % "0.23.23"
    )
  )

Define tu API

Crea una especificación Smithy en src/main/smithy/bookstore.smithy:

$version: "2"

namespace demo.bookstore

use alloy#simpleRestJson

@simpleRestJson
service BookstoreService {
    operations: [GetBook, ListBooks, CreateBook]
}

@readonly
@http(method: "GET", uri: "/books/{isbn}")
operation GetBook {
    input := {
        @required
        @httpLabel
        isbn: String
    }
    output := {
        @required
        book: Book
    }
    errors: [BookNotFound]
}

@readonly
@http(method: "GET", uri: "/books")
operation ListBooks {
    output := {
        @required
        books: Books
    }
}

@http(method: "POST", uri: "/books")
operation CreateBook {
    input := {
        @required
        book: Book
    }
    output := {
        @required
        book: Book
    }
}

structure Book {
    @required
    isbn: String
    @required
    title: String
    @required
    author: String
    year: Integer
}

list Books {
    member: Book
}

@error("client")
@httpError(404)
structure BookNotFound {
    @required
    message: String
}

Implementa el Servicio

La generación de código crea un trait BookstoreService[F[_]] que implementas:

import cats.effect.*
import cats.syntax.all.*
import demo.bookstore.*

class BookstoreServiceImpl[F[_]: Sync] extends BookstoreService[F] {
  private var books = Map(
    "978-0134685991" -> Book(
      isbn = "978-0134685991",
      title = "Effective Java",
      author = "Joshua Bloch",
      year = Some(2018)
    )
  )

  def getBook(isbn: String): F[GetBookOutput] =
    books.get(isbn) match {
      case Some(book) => GetBookOutput(book).pure[F]
      case None => 
        Sync[F].raiseError(
          BookNotFound(s"Book with ISBN $isbn not found")
        )
    }

  def listBooks(): F[ListBooksOutput] =
    ListBooksOutput(books.values.toList).pure[F]

  def createBook(book: Book): F[CreateBookOutput] = 
    Sync[F].delay {
      books = books + (book.isbn -> book)
      CreateBookOutput(book)
    }
}

Crea el Servidor

import cats.effect.*
import com.comcast.ip4s.*
import org.http4s.ember.server.EmberServerBuilder
import smithy4s.http4s.SimpleRestJsonBuilder

object Main extends IOApp.Simple {
  def run: IO[Unit] = {
    val bookstoreImpl = new BookstoreServiceImpl[IO]
    
    SimpleRestJsonBuilder
      .routes(bookstoreImpl)
      .resource
      .flatMap { routes =>
        EmberServerBuilder
          .default[IO]
          .withHost(host"0.0.0.0")
          .withPort(port"9000")
          .withHttpApp(routes.orNotFound)
          .build
      }
      .use(_ => IO.println("Servidor iniciado en puerto 9000") *> IO.never)
  }
}

Usa el Cliente Generado

Smithy4s también genera un cliente con seguridad de tipos:

import cats.effect.*
import org.http4s.ember.client.EmberClientBuilder
import smithy4s.http4s.SimpleRestJsonBuilder

object ClientDemo extends IOApp.Simple {
  def run: IO[Unit] = 
    EmberClientBuilder.default[IO].build.use { httpClient =>
      SimpleRestJsonBuilder(BookstoreService)
        .client(httpClient)
        .uri(uri"http://localhost:9000")
        .resource
        .use { client =>
          for {
            // Listar todos los libros
            allBooks <- client.listBooks()
            _ <- IO.println(s"Libros: ${allBooks.books}")
            
            // Obtener un libro específico
            book <- client.getBook("978-0134685991")
            _ <- IO.println(s"Libro: ${book.book}")
            
            // Crear un nuevo libro
            newBook = Book(
              isbn = "978-1617295027",
              title = "Functional Programming in Scala",
              author = "Paul Chiusano",
              year = Some(2014)
            )
            created <- client.createBook(newBook)
            _ <- IO.println(s"Creado: ${created.book}")
          } yield ()
        }
    }
}

Beneficios Principales

Única Fuente de Verdad: Tu especificación Smithy define tanto el servidor como el cliente.

Seguridad de Tipos: El compilador detecta violaciones de contrato en tiempo de compilación.

Documentación: Puedes generar especificaciones OpenAPI desde tus definiciones Smithy.

Flexibilidad de Protocolo: Soporta REST JSON, protocolos AWS, o formatos personalizados.

Compatible con Scala 3: Soporte completo para Scala 3 con buena inferencia de tipos.

Características Avanzadas

Protocolos Personalizados

Smithy4s soporta protocolos personalizados más allá de REST JSON. Puedes definir tus propios formatos de serialización.

Integración de Middleware

Como Smithy4s genera rutas http4s, puedes usar middleware estándar de http4s:

import org.http4s.server.middleware.*

val routesWithMiddleware = Logger.httpRoutes(true, true)(routes)

Manejo de Errores

Los errores definidos en Smithy se convierten en excepciones tipadas que el framework maneja automáticamente, devolviendo códigos de estado HTTP apropiados.

Recursos

Conclusión

Smithy4s trae desarrollo de APIs contract-first a Scala con buena seguridad de tipos y experiencia del desarrollador. Al definir tu API una vez en Smithy, obtienes stubs de servidor, clientes y documentación que se mantienen sincronizados automáticamente.

La combinación de Smithy4s con http4s y Cats Effect te da una forma sólida de construir APIs REST en Scala.