edadma / apion   0.0.7

MIT License Website GitHub

A type-safe HTTP server framework for Scala.js that combines Express-style ergonomics with Scala's powerful type system

Scala versions: 3.x
Scala.js versions: 1.x

Fluxus Logo

Apion

A lightweight, Express-inspired API server framework for Scala.js that provides a familiar developer experience while leveraging Scala's type safety and immutability.

Release Maven Central Release Date Last Commit License: ISC Scala.js: 1.17.0

Key Features

  • Express-like chainable API with type safety
  • Pure functions and immutable types
  • Unified handler/middleware system
  • JWT authentication with role-based access control
  • Body parsing for JSON and form data
  • Type-safe request/response handling
  • Comprehensive error handling
  • Static file serving
  • Request compression
  • CORS and security headers

Installation

Add to your build.sbt:

libraryDependencies += "io.github.edadma" %%% "apion" % "0.0.6"

Quick Start

import io.github.edadma.apion._
import zio.json._

case class User(name: String, email: String) derives JsonEncoder, JsonDecoder

@main
def run(): Unit =
  Server()
    .use(LoggingMiddleware())
    .use(CorsMiddleware())
    .get("/hello", _ => "Hello World!".asText)
    .post(
      "/users",
       _.json[User].flatMap {
          case Some(user) => user.asJson(201)
          case _          => "Invalid user data".asText(400)
       },
    )
    .listen(3000) { println("Server running at http://localhost:3000") }

Test the server using curl:

# Test the hello endpoint
curl http://localhost:3000/hello

# Create a new user
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]"}'

Expected responses:

# GET /hello response:
Hello World!

# POST /users response (status 201):
{"name":"Alice","email":"[email protected]"}

Core Concepts

Handlers

All request processors (middleware, routes, error handlers) share a unified type:

type Handler = Request => Future[Result]

sealed trait Result
case class Continue(request: Request) extends Result   // Continue processing
case class Complete(response: Response) extends Result // End with response
case class Fail(error: ServerError) extends Result     // Propagate error  
case object Skip extends Result                        // Try next route

Middleware

Middleware can modify requests, generate responses, or handle errors:

// Authentication middleware
val auth = AuthMiddleware(AuthMiddleware.Config(
  secretKey = "your-secret-key",
  requireAuth = true,
  excludePaths = Set("/public"),
  audience = Some("your-app"),
  issuer = "your-service"
))

// Cookie middleware
val cookies = CookieMiddleware(CookieMiddleware.Options(
  secret = Some("cookie-secret"),
  parseJSON = true
))

// Security headers
val security = SecurityMiddleware(SecurityMiddleware.Options(
  contentSecurityPolicy = true,
  frameguard = true,
  xssFilter = true
))

server.use(auth).use(cookies).use(security)

Routing

Supports path parameters, nested routes, and route grouping:

// Path parameters
server.get("/users/:id", request => {
  val userId = request.params("id")
  getUserById(userId).asJson
})

// Route grouping
val apiRouter = Router()
  .use(authMiddleware)
  .get("/users", listUsers)
  .post("/users", createUser)

server.use("/api", apiRouter)

Request Processing

Access request data with type safety:

def handler(request: Request): Future[Result] = {
  // Access components
  val path = request.path
  val method = request.method
  val headers = request.headers
  val params = request.params
  val query = request.query
  
  // Get typed body from context
  request.json[User].flatMap {
    case Some(user) => // Handle user data
    case _ => request.failValidation("Invalid body")
  }
}

Response Building

Convenient response creation:

// JSON responses
data.asJson                      // 200 OK
data.asJson(201)                 // Created
ErrorResponse("msg").asJson(400) // Bad Request

// Text responses
"Hello".asText                   // 200 OK
"Created".asText(201)            // Created

// Common responses
NotFound                         // 404 Not Found
BadRequest                       // 400 Bad Request
ServerError                      // 500 Internal Error

Error Handling

Type-safe error propagation:

sealed trait ServerError extends RuntimeException
case class ValidationError(msg: String) extends ServerError
case class AuthError(msg: String) extends ServerError
case class NotFoundError(msg: String) extends ServerError

// In handlers
request.failValidation("Invalid input")
request.failAuth("Unauthorized")
request.failNotFound("Not found")

Additional Features

Static Files

server.use(StaticMiddleware("public", StaticMiddleware.Options(
  index = true,          // Serve index.html for directories
  dotfiles = "ignore",   // How to handle dotfiles
  etag = true,           // Enable ETag generation
  maxAge = 3600,         // Cache max-age in seconds
  redirect = true,       // Redirect directories to trailing slash
  fallthrough = true     // Continue to next handler if file not found
)))

Cookie Handling

server
  .use(CookieMiddleware(CookieMiddleware.Options(
    secret = Some("cookie-secret"),
    parseJSON = true
  )))
  .get("/set-cookie", request => 
    Future.successful(Complete(
      Response.text("Cookie set")
        .withCookie(
          name = "session",
          value = "abc123",
          maxAge = Some(3600),
          httpOnly = true,
          secure = true
        )
    ))
  )
  .get("/read-cookie", request => {
    request.cookie("session") match {
      case Some(value) => s"Cookie value: $value".asText
      case None => "No cookie found".asText(404)
    }
  })

Compression

server.use(CompressionMiddleware(CompressionMiddleware.Options(
  // Compression filter options
  level = 6,          // compression level 0-9
  threshold = 1024,   // minimum size in bytes to compress
  memLevel = 8,       // memory usage level 1-9
  windowBits = 15,    // window size 9-15

  // Brotli-specific options
  brotliQuality = 11,      // compression quality 0-11
  brotliBlockSize = 4096,  // block size 16-24

  // Filter options
  filter = _ => true,  // function to determine if response should be compressed

  // Which encodings to support/prefer (in order of preference)
  encodings = List("br", "gzip", "deflate")
)))

Body Parsing

// JSON parsing with type-safe handling
case class User(name: String, email: String) derives JsonEncoder, JsonDecoder

server
  .post("/users", request => {
    request.json[User].flatMap {
      case Some(userData) =>
        // Body has been parsed as type User
        userData.asJson(201)
      case _ =>
        "Invalid request body".asText(400)
    }
  })

// URL-encoded form data parsing
server
  .post("/form", request => {
    request.form.flatMap {
      case Some(formData) =>
        // Access form fields
        val name = formData.getOrElse("name", "")
        formData.asJson
      case _ =>
        "Invalid form data".asText(400)
    }
  })

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Write your changes
  4. Add appropriate tests:
    • Unit tests for pure functions and utilities
    • Integration tests for middleware, request handling chains, and complex features
    • Consider both kinds when changes affect multiple areas
  5. Verify all tests pass with sbt test
  6. Push to the branch
  7. Create a Pull Request with:
    • Description of changes
    • Summary of tests added
    • Any necessary documentation updates

See apion/src/test/scala/io/github/edadma/apion for examples of:

  • Unit tests: JWTTests.scala shows testing pure JWT functionality
  • Integration tests: AuthIntegrationTests.scala shows testing middleware behavior in a running server

License

This project is licensed under the ISC License.