Skip to content

Latest commit

 

History

History
1716 lines (1240 loc) · 41.4 KB

File metadata and controls

1716 lines (1240 loc) · 41.4 KB
id http-model
title HTTP Model

zio-http-model is a pure, zero-dependency HTTP data model for building HTTP clients and servers. It provides immutable types representing requests, responses, headers, URLs, paths, query parameters, and all HTTP primitives.

The module is designed as a pure data layer:

  • Zero effects: no streaming, no I/O, no mutable state (except monotonic lazy-parse caches in Headers)
  • Zero ZIO dependency: uses zio.blocks.chunk.Chunk, not zio.Chunk
  • Single encoding contract: Path and QueryParams store values decoded internally, encode only on output
  • Cross-platform: JVM and Scala.js support
  • Cross-version: Scala 2.13 and 3.x support
package zio.http

// Core request/response types
final case class Request(method: Method, url: URL, headers: Headers, body: Body, version: Version)
final case class Response(status: Status, headers: Headers, body: Body, version: Version)

// URL structure
final case class URL(scheme: Option[Scheme], host: Option[String], port: Option[Int],
                     path: Path, queryParams: QueryParams, fragment: Option[String])

final case class Path(segments: Chunk[String], hasLeadingSlash: Boolean, trailingSlash: Boolean)
final class QueryParams private[http] (...)

// HTTP primitives
sealed abstract class Method(val name: String, val ordinal: Int)
opaque type Status = Int  // Scala 3
sealed abstract class Version(val major: Int, val minor: Int)
sealed trait Scheme

// Headers and body
final class Headers private[http] (...)
sealed trait Header
final class Body private (val data: Chunk[Byte], val contentType: ContentType)

// Supporting types
final case class ContentType(mediaType: MediaType, boundary: Option[Boundary], charset: Option[Charset])
final case class ResponseCookie(...), RequestCookie(name: String, value: String)
final case class Form(entries: Chunk[(String, String)])

Motivation

HTTP libraries often couple protocol concerns with effects and streaming, making it difficult to:

  • Share data structures across client and server implementations
  • Serialize requests/responses for caching or testing
  • Work with HTTP primitives without committing to a specific effect system

zio-http-model solves this by providing pure data types that:

  • Represent all HTTP concepts as immutable values
  • Encode only on output (decode on input, store decoded)
  • Parse incrementally with lazy caching (Headers parses typed headers on first access and caches the result)
  • Work with any effect system or none at all

Installation

Add the following to your build.sbt:

libraryDependencies += "dev.zio" %% "zio-http-model" % "<version>"

For cross-platform projects (Scala.js):

libraryDependencies += "dev.zio" %%% "zio-http-model" % "<version>"

Supported Scala versions: 2.13.x and 3.x

Quick Start

Creating a request with query parameters:

import zio.http._

val url = URL.parse("https://api.example.com/users?active=true").toOption.get
val request = Request.get(url)

val withHeader = request.addHeader("authorization", "Bearer token123")

Creating a JSON response:

import zio.http._

val jsonBody = Body.fromString("""{"message":"ok"}""", Charset.UTF8)
val response = Response(
  status = Status.Ok,
  body = jsonBody,
  headers = Headers("content-type" -> "application/json")
)

Method

Method represents standard HTTP methods as case objects:

sealed abstract class Method(val name: String, val ordinal: Int)

Predefined Methods

import zio.http.Method

val get     = Method.GET
val post    = Method.POST
val put     = Method.PUT
val delete  = Method.DELETE
val patch   = Method.PATCH
val head    = Method.HEAD
val options = Method.OPTIONS
val trace   = Method.TRACE
val connect = Method.CONNECT

Parsing

import zio.http.Method

Method.fromString("GET")    // Some(Method.GET)
Method.fromString("POST")   // Some(Method.POST)
Method.fromString("CUSTOM") // None (unknown method)

Rendering

import zio.http.Method

Method.render(Method.GET)  // "GET"
Method.GET.name            // "GET"
Method.GET.toString        // "GET"

Status

Status is an opaque type alias for Int in Scala 3 (AnyVal wrapper in Scala 2.13), providing zero-allocation status codes with predefined constants.

opaque type Status = Int  // Scala 3

Predefined Status Codes

Status codes are organized by category:

import zio.http.Status

// 1xx Informational
Status.Continue           // 100
Status.SwitchingProtocols // 101

// 2xx Success
Status.Ok                 // 200
Status.Created            // 201
Status.NoContent          // 204

// 3xx Redirection
Status.MovedPermanently   // 301
Status.Found              // 302
Status.SeeOther           // 303
Status.NotModified        // 304

// 4xx Client Errors
Status.BadRequest         // 400
Status.Unauthorized       // 401
Status.Forbidden          // 403
Status.NotFound           // 404

// 5xx Server Errors
Status.InternalServerError // 500
Status.BadGateway          // 502
Status.ServiceUnavailable  // 503

Creating Status Codes

import zio.http.Status

val custom = Status(418)          // I'm a teapot
val ok     = Status.fromInt(200)  // Status.Ok

Status Code Operations

import zio.http.Status

val status = Status.Ok

status.code              // 200
status.text              // "OK"
status.isSuccess         // true (2xx)
status.isInformational   // false (1xx)
status.isRedirection     // false (3xx)
status.isClientError     // false (4xx)
status.isServerError     // false (5xx)
status.isError           // false (4xx or 5xx)

Version

Version represents HTTP protocol versions:

sealed abstract class Version(val major: Int, val minor: Int)

Predefined Versions

import zio.http.Version

val v10 = Version.`HTTP/1.0`
val v11 = Version.`HTTP/1.1`
val v20 = Version.`HTTP/2.0`
val v30 = Version.`HTTP/3.0`

Parsing and Rendering

import zio.http.Version

Version.fromString("HTTP/1.1") // Some(Version.`HTTP/1.1`)
Version.fromString("HTTP/2")   // Some(Version.`HTTP/2.0`)
Version.fromString("HTTP/3")   // Some(Version.`HTTP/3.0`)

Version.render(Version.`HTTP/1.1`) // "HTTP/1.1"
Version.`HTTP/2.0`.text            // "HTTP/2.0"

Scheme

Scheme represents URL schemes with support for HTTP, HTTPS, WebSocket (WS, WSS), and custom schemes:

sealed trait Scheme {
  def text: String
  def defaultPort: Option[Int]
  def isSecure: Boolean
  def isWebSocket: Boolean
}

Predefined Schemes

import zio.http.Scheme

val http  = Scheme.HTTP   // http://, port 80
val https = Scheme.HTTPS  // https://, port 443
val ws    = Scheme.WS     // ws://, port 80
val wss   = Scheme.WSS    // wss://, port 443

Scheme Properties

import zio.http.Scheme

Scheme.HTTPS.isSecure      // true
Scheme.WS.isWebSocket      // true
Scheme.HTTP.defaultPort    // Some(80)

Custom Schemes

import zio.http.Scheme

val custom = Scheme.Custom("git+ssh")
custom.text         // "git+ssh"
custom.defaultPort  // None

Parsing

import zio.http.Scheme

Scheme.fromString("https")    // Scheme.HTTPS
Scheme.fromString("wss")      // Scheme.WSS
Scheme.fromString("custom")   // Scheme.Custom("custom")

Charset

Charset represents character encodings with JVM-only conversion to java.nio.charset.Charset:

sealed abstract class Charset(val name: String)

Predefined Charsets

import zio.http.Charset

val utf8      = Charset.UTF8       // "UTF-8"
val ascii     = Charset.ASCII      // "US-ASCII"
val iso88591  = Charset.ISO_8859_1 // "ISO-8859-1"
val utf16     = Charset.UTF16      // "UTF-16"
val utf16be   = Charset.UTF16BE    // "UTF-16BE"
val utf16le   = Charset.UTF16LE    // "UTF-16LE"

Parsing

import zio.http.Charset

Charset.fromString("UTF-8")      // Some(Charset.UTF8)
Charset.fromString("utf8")       // Some(Charset.UTF8) (case-insensitive)
Charset.fromString("ISO-8859-1") // Some(Charset.ISO_8859_1)
Charset.fromString("LATIN1")     // Some(Charset.ISO_8859_1) (alias)

Boundary

Boundary represents multipart form-data boundaries:

import zio.http.Boundary

val boundary = Boundary("----WebKitFormBoundary7MA4YWxkTrZu0gW")
boundary.value    // "----WebKitFormBoundary7MA4YWxkTrZu0gW"
boundary.toString // "----WebKitFormBoundary7MA4YWxkTrZu0gW"

Generating Boundaries

import zio.http.Boundary

val generated = Boundary.generate  // random 24-character alphanumeric string

PercentEncoder

PercentEncoder provides RFC 3986 percent-encoding for URL components. Each URL component has different encoding rules:

import zio.http.PercentEncoder
import zio.http.PercentEncoder.ComponentType

val segment = PercentEncoder.encode("hello world", ComponentType.PathSegment)
// "hello%20world"

val queryKey = PercentEncoder.encode("filter[name]", ComponentType.QueryKey)
// "filter%5Bname%5D"

val decoded = PercentEncoder.decode("hello%20world")
// "hello world"

Component Types

The encoder recognizes these component types:

  • PathSegment: path segments between /
  • QueryKey: query parameter names
  • QueryValue: query parameter values
  • Fragment: fragment identifiers after #
  • UserInfo: userinfo in authority

Each type has specific rules for which characters must be percent-encoded.

ContentType

ContentType combines a media type with optional charset and boundary parameters:

import zio.http.{ContentType, Charset, Boundary}
import zio.blocks.mediatype.MediaTypes

val json = ContentType(MediaTypes.application.`json`)

val htmlUtf8 = ContentType(
  mediaType = MediaTypes.text.`html`,
  charset = Some(Charset.UTF8)
)

val multipart = ContentType(
  mediaType = MediaTypes.multipart.`form-data`,
  boundary = Some(Boundary("----boundary"))
)

Parsing

import zio.http.ContentType

ContentType.parse("application/json")
// Right(ContentType(MediaType("application", "json")))

ContentType.parse("text/html; charset=utf-8")
// Right(ContentType(..., charset = Some(Charset.UTF8)))

ContentType.parse("multipart/form-data; boundary=abc123")
// Right(ContentType(..., boundary = Some(Boundary("abc123"))))

ContentType.parse("")
// Left("Invalid content type: cannot be empty")

Rendering

import zio.http.{ContentType, Charset}
import zio.blocks.mediatype.MediaTypes

val ct = ContentType(
  MediaTypes.text.`plain`,
  charset = Some(Charset.UTF8)
)

ct.render  // "text/plain; charset=UTF-8"

Predefined Content Types

import zio.http.ContentType

val json   = ContentType.`application/json`
val plain  = ContentType.`text/plain`
val html   = ContentType.`text/html`
val binary = ContentType.`application/octet-stream`

Path

Path represents URL paths with decoded segments stored internally:

final case class Path(
  segments: Chunk[String],
  hasLeadingSlash: Boolean,
  trailingSlash: Boolean
)

Paths use a single encoding contract: decode on input, store decoded, encode on output.

Creating Paths

import zio.http.Path

val empty = Path.empty                    // ""
val root  = Path.root                     // "/"
val users = Path("/users")                // segments: ["users"], leading slash: true
val api   = Path("api/v1/users/")         // segments: ["api", "v1", "users"], trailing slash: true

Parsing Encoded Paths

Path.fromEncoded decodes percent-encoded segments:

import zio.http.Path

val path = Path.fromEncoded("/hello%20world/foo%2Fbar")
// Path(Chunk("hello world", "foo/bar"), hasLeadingSlash = true, trailingSlash = false)

path.segments(0)  // "hello world" (decoded)
path.segments(1)  // "foo/bar" (decoded)

Building Paths

import zio.http.Path

val base = Path("/api")
val extended = base / "users" / "123"  // "/api/users/123"

val combined = Path("/api") ++ Path("v1/users")  // "/api/v1/users"

Encoding Paths

import zio.http.Path
import zio.blocks.chunk.Chunk

val path = Path(Chunk("hello world", "foo/bar"), hasLeadingSlash = true, trailingSlash = false)

path.encode  // "/hello%20world/foo%2Fbar" (percent-encoded)
path.render  // "/hello world/foo/bar" (decoded for display)

Path Properties

import zio.http.Path

val path = Path("/api/v1/users/")

path.isEmpty           // false
path.nonEmpty          // true
path.length            // 3 (number of segments)
path.hasLeadingSlash   // true
path.trailingSlash     // true

Path Navigation

import zio.http.Path

val path = Path("/api/v1/users")

// Slash manipulation
path.addLeadingSlash     // same (already has one)
path.dropLeadingSlash    // "api/v1/users" (relative)
path.addTrailingSlash    // "/api/v1/users/"
path.dropTrailingSlash   // same (already no trailing slash)

// Inspecting
path.isRoot              // false (root is "/" with no segments)
Path.root.isRoot         // true

// Prefix checking
path.startsWith(Path("api/v1"))  // true

// Slicing
path.drop(1)       // "/v1/users" (drop first segment)
path.take(2)       // "/api/v1" (take first 2 segments)
path.dropRight(1)  // "/api/v1" (drop last segment)
path.initial       // "/api/v1" (all but last)
path.last          // Some("users")
path.reverse       // "/users/v1/api"

QueryParams

QueryParams stores query parameters with multiple values per key:

final class QueryParams private[http] (
  private val keys: Array[String],
  private val vals: Array[Chunk[String]],
  val size: Int
)

Like Path, query parameters store decoded values internally and encode only on output.

Creating QueryParams

import zio.http.QueryParams

val empty = QueryParams.empty

val params = QueryParams(
  "name" -> "Alice",
  "age" -> "30",
  "active" -> "true"
)

Parsing Encoded Query Strings

import zio.http.QueryParams

val params = QueryParams.fromEncoded("name=Alice%20Smith&age=30&active=true")

params.getFirst("name")    // Some("Alice Smith") (decoded)
params.getFirst("age")     // Some("30")
params.getFirst("active")  // Some("true")

Accessing Values

import zio.http.QueryParams

val params = QueryParams(
  "color" -> "red",
  "color" -> "blue",
  "size" -> "large"
)

params.get("color")       // Some(Chunk("red", "blue"))
params.getFirst("color")  // Some("red")
params.getFirst("size")   // Some("large")
params.getFirst("other")  // None
params.has("color")       // true

Modifying QueryParams

import zio.http.QueryParams

val params = QueryParams("a" -> "1", "b" -> "2")

val added = params.add("c", "3")         // adds "c=3"
val set   = params.set("a", "100")       // replaces all "a" values
val removed = params.remove("b")         // removes all "b" entries

Encoding

import zio.http.QueryParams

val params = QueryParams(
  "name" -> "Alice Smith",
  "filter[status]" -> "active"
)

params.encode  // "name=Alice%20Smith&filter%5Bstatus%5D=active"

Converting to List

import zio.http.QueryParams

val params = QueryParams("a" -> "1", "a" -> "2", "b" -> "3")
params.toList  // List(("a", "1"), ("a", "2"), ("b", "3"))

URL

URL combines scheme, host, port, path, query parameters, and fragment:

final case class URL(
  scheme: Option[Scheme],
  host: Option[String],
  port: Option[Int],
  path: Path,
  queryParams: QueryParams,
  fragment: Option[String]
)

Parsing URLs

import zio.http.URL

val absolute = URL.parse("https://api.example.com:8080/users?active=true#results")
// Right(URL(
//   scheme = Some(Scheme.HTTPS),
//   host = Some("api.example.com"),
//   port = Some(8080),
//   path = Path("/users"),
//   queryParams = QueryParams("active" -> "true"),
//   fragment = Some("results")
// ))

val relative = URL.parse("/api/users?page=2")
// Right(URL(
//   scheme = None,
//   host = None,
//   port = None,
//   path = Path("/api/users"),
//   queryParams = QueryParams("page" -> "2"),
//   fragment = None
// ))

The parser handles:

  • IPv6 hosts in brackets: http://[::1]:8080/
  • Userinfo: https://user:pass@example.com/ (userinfo is skipped)
  • Relative URLs: /path?query
  • Fragment identifiers: #section

Building URLs

import zio.http.{URL, Path, Scheme}

val base = URL.root  // http://localhost/

val extended = base / "api" / "users"  // adds path segments

val withQuery = extended ?? ("active", "true") ?? ("page", "1")
// http://localhost/api/users?active=true&page=1

URL from Path

import zio.http.{URL, Path}

val path = Path("/api/users")
val url = URL.fromPath(path)  // relative URL with just path

Encoding URLs

import zio.http.URL

val url = URL.parse("https://example.com/hello world?name=Alice Smith").toOption.get

url.encode  // "https://example.com/hello%20world?name=Alice%20Smith"
url.toString  // same as encode

URL Properties

import zio.http.URL

val absolute = URL.parse("https://example.com/").toOption.get
val relative = URL.parse("/api/users").toOption.get

absolute.isAbsolute  // true (has scheme)
relative.isRelative  // true (no scheme)

URL Transformation

import zio.http.{URL, Path, Scheme, QueryParams}

val url = URL.parse("https://api.example.com:8080/users?page=1").toOption.get

// Setting components
url.host("other.com")           // changes host
url.port(9090)                  // changes port
url.scheme(Scheme.HTTP)          // changes scheme
url.path(Path("/v2/users"))      // replaces path
url.fragment("top")              // sets fragment

// Adding paths
url.addPath("123")             // appends segment: /users/123
url.addPath(Path("v2/items"))  // appends multi-segment path

// Query manipulation
url.addQueryParams(QueryParams("sort" -> "name"))
url.updateQueryParams(_.remove("page"))

// Slash manipulation (delegates to path)
url.addLeadingSlash
url.dropTrailingSlash

// Derived properties
url.hostPort   // Some("api.example.com:8080")
url.relative   // strips scheme/host/port, keeps path and query

Header

Header is a trait representing typed HTTP headers:

trait Header {
  def headerName: String
  def renderedValue: String
}

Each header type has a companion object implementing Header.Typed[H] for parsing and rendering.

Predefined Header Types

import zio.http.{Header => _, *}
import zio.http.headers

val contentType   = headers.ContentType
val accept        = headers.Accept
val authorization = headers.Authorization
val host          = headers.Host
val userAgent     = headers.UserAgent
val cacheControl  = headers.CacheControl
val contentLength = headers.ContentLength
val location      = headers.Location
val setCookie     = headers.SetCookieHeader
val cookie        = headers.CookieHeader

Creating Typed Headers

import zio.http.{Header => _, ContentType, Charset, *}
import zio.http.headers
import zio.blocks.mediatype.MediaTypes

val ct = headers.ContentType(
  ContentType(MediaTypes.application.`json`, charset = Some(Charset.UTF8))
)

val auth = headers.Authorization.Bearer("token123")

val host = headers.Host("api.example.com", Some(8080))

Parsing Headers

import zio.http.{Header => _, *}
import zio.http.headers

headers.ContentType.parse("application/json; charset=utf-8")
// Right(headers.ContentType(...))

headers.Host.parse("example.com:443")
// Right(headers.Host("example.com", Some(443)))

headers.ContentLength.parse("1024")
// Right(headers.ContentLength(1024))

headers.ContentLength.parse("-1")
// Left("Invalid content-length: -1")

Custom Headers

import zio.http.Header

val custom = Header.Custom("x-request-id", "abc-123")
custom.headerName     // "x-request-id"
custom.renderedValue  // "abc-123"

Headers

Headers is a flat array-based collection with lazy monotonic parsing:

final class Headers private[http] (
  private val names: Array[String],
  private val rawValues: Array[String],
  private val parsed: Array[AnyRef],  // null -> unparsed, value -> cached
  val size: Int
)

When you call get[H], the header is parsed once and cached. Subsequent calls return the cached result.

Creating Headers

import zio.http.Headers

val empty = Headers.empty

val headers = Headers(
  "content-type" -> "application/json",
  "authorization" -> "Bearer token",
  "x-request-id" -> "abc-123"
)

Getting Typed Headers

import zio.http.{Headers, *}
import zio.http.{headers => h}

val headers = Headers(
  "content-type" -> "application/json",
  "content-length" -> "1024"
)

val ct = headers.get(h.ContentType)
// Some(h.ContentType(...)) (parsed and cached)

val cl = headers.get(h.ContentLength)
// Some(h.ContentLength(1024)) (parsed and cached)

val auth = headers.get(h.Authorization)
// None (not present)

Getting Raw Values

import zio.http.Headers

val headers = Headers("x-custom" -> "value")

headers.rawGet("x-custom")  // Some("value")
headers.rawGet("missing")   // None

Getting All Headers of a Type

Some headers can appear multiple times (like Set-Cookie):

import zio.http.{Headers, *}
import zio.http.{headers => h}

val headers = Headers(
  "set-cookie" -> "session=abc",
  "set-cookie" -> "preference=dark"
)

val cookies = headers.getAll(h.SetCookieHeader)
// Chunk(h.SetCookieHeader(...), h.SetCookieHeader(...))

Modifying Headers

import zio.http.Headers

val headers = Headers("a" -> "1", "b" -> "2")

val added = headers.add("c", "3")        // adds "c: 3"
val set   = headers.set("a", "100")      // replaces all "a" values
val removed = headers.remove("b")        // removes all "b" entries
val has = headers.has("a")               // true

Converting to List

import zio.http.Headers

val headers = Headers("a" -> "1", "b" -> "2")
headers.toList  // List(("a", "1"), ("b", "2"))

Combining Headers

import zio.http.Headers

val auth = Headers("authorization" -> "Bearer token")
val cors = Headers("access-control-allow-origin" -> "*")

val combined = auth ++ cors  // Headers(authorization: ..., access-control-allow-origin: ...)

combined.contains("authorization")  // true (alias for `has`)
combined.toChunk                    // Chunk(("authorization", ...), ("access-control-allow-origin", ...))

Body

Body wraps a materialized Chunk[Byte] with a content type:

final class Body private (
  val data: Chunk[Byte],
  val contentType: ContentType
)

Creating Bodies

Body.empty provides an empty body with default application/octet-stream content type:

import zio.http.Body

val empty = Body.empty
// Body(data = Chunk.empty, contentType = application/octet-stream)

Body.fromString creates a body with text/plain content type:

import zio.http.{Body, Charset}

val fromString = Body.fromString("Hello, World!", Charset.UTF8)
// Content-Type: text/plain; charset=UTF-8

Body.fromArray creates a body with default application/octet-stream content type:

import zio.http.Body

val fromBytes = Body.fromArray(Array[Byte](1, 2, 3))
// Content-Type: application/octet-stream

Body.fromChunk creates a body from a Chunk[Byte] with optional content type:

import zio.http.{Body, ContentType}
import zio.blocks.chunk.Chunk
import zio.blocks.mediatype.MediaTypes

val chunk = Chunk[Byte](1, 2, 3, 4, 5)
val body = Body.fromChunk(chunk)
// Content-Type: application/octet-stream (default)

val jsonBody = Body.fromChunk(chunk, ContentType(MediaTypes.application.`json`))
// Content-Type: application/json

Reading Bodies

Body provides direct access to data and content type:

import zio.http.{Body, Charset}

val body = Body.fromString("Hello!", Charset.UTF8)

body.length           // 6
body.isEmpty          // false
body.nonEmpty         // true
body.asString()       // "Hello!" (UTF-8 default)
body.asString(Charset.ASCII)  // "Hello!" (explicit charset)
body.data             // Chunk[Byte](72, 101, 108, 108, 111, 33)
body.contentType      // ContentType(text/plain; charset=UTF-8)

Cookie

Cookies are split into RequestCookie and ResponseCookie with different structures:

final case class RequestCookie(name: String, value: String)

final case class ResponseCookie(
  name: String,
  value: String,
  domain: Option[String],
  path: Option[Path],
  maxAge: Option[Long],
  isSecure: Boolean,
  isHttpOnly: Boolean,
  sameSite: Option[SameSite]
)

SameSite

import zio.http.SameSite

val strict = SameSite.Strict
val lax    = SameSite.Lax
val none   = SameSite.None_  // underscore avoids conflict with scala.None

Parsing Request Cookies

import zio.http.Cookie

val cookies = Cookie.parseRequest("session=abc123; preference=dark")
// Chunk(RequestCookie("session", "abc123"), RequestCookie("preference", "dark"))

Parsing Response Cookies

import zio.http.Cookie

val cookie = Cookie.parseResponse("session=abc; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Strict")
// Right(ResponseCookie(
//   name = "session",
//   value = "abc",
//   domain = Some("example.com"),
//   path = Some(Path("/")),
//   isSecure = true,
//   isHttpOnly = true,
//   sameSite = Some(SameSite.Strict)
// ))

Rendering Cookies

import zio.http.{Cookie, RequestCookie, ResponseCookie, SameSite, Path}
import zio.blocks.chunk.Chunk

val requestCookies = Chunk(
  RequestCookie("session", "abc"),
  RequestCookie("theme", "dark")
)
Cookie.renderRequest(requestCookies)
// "session=abc; theme=dark"

val responseCookie = ResponseCookie(
  name = "session",
  value = "xyz",
  domain = Some("example.com"),
  path = Some(Path("/")),
  maxAge = Some(3600),
  isSecure = true,
  isHttpOnly = true,
  sameSite = Some(SameSite.Strict)
)
Cookie.renderResponse(responseCookie)
// "session=xyz; Domain=example.com; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Strict"

Form

Form represents URL-encoded form data:

final case class Form(entries: Chunk[(String, String)])

Creating Forms

import zio.http.Form

val empty = Form.empty

val form = Form(
  "username" -> "alice",
  "password" -> "secret",
  "remember" -> "true"
)

Accessing Form Data

import zio.http.Form

val form = Form(
  "tag" -> "scala",
  "tag" -> "functional",
  "page" -> "1"
)

form.get("tag")      // Some("scala") (first value)
form.getAll("tag")   // Chunk("scala", "functional")
form.get("page")     // Some("1")
form.get("missing")  // None

Modifying Forms

import zio.http.Form

val form = Form("a" -> "1")
val added = form.add("b", "2")  // Form(("a", "1"), ("b", "2"))

Encoding and Parsing

import zio.http.Form

val form = Form(
  "name" -> "Alice Smith",
  "email" -> "alice@example.com"
)

val encoded = form.encode
// "name=Alice%20Smith&email=alice%40example.com"

val parsed = Form.fromString(encoded)
// Form with decoded entries

FormField

FormField is a sealed trait for multipart form fields, supporting simple key-value pairs, text parts with optional metadata, and binary parts:

import zio.http._
import zio.blocks.chunk.Chunk
import zio.blocks.mediatype.MediaTypes

// Simple key-value field
val simple = FormField.Simple("username", "alice")

// Text field with optional content type and filename
val text = FormField.Text(
  name = "bio",
  value = "Hello world",
  contentType = Some(ContentType(MediaTypes.text.`plain`)),
  filename = None
)

// Binary field with content type and optional filename
val binary = FormField.Binary(
  name = "avatar",
  data = Chunk.fromArray(Array[Byte](1, 2, 3)),
  contentType = ContentType(MediaTypes.image.`png`),
  filename = Some("avatar.png")
)

// All variants share a common `name` accessor
simple.name  // "username"
text.name    // "bio"
binary.name  // "avatar"

Request

Request combines all HTTP request components:

final case class Request(
  method: Method,
  url: URL,
  headers: Headers,
  body: Body,
  version: Version
)

Creating Requests

import zio.http._

val url = URL.parse("https://api.example.com/users").toOption.get
val getRequest = Request.get(url)

val jsonBody = Body.fromString("""{"name":"Alice"}""", Charset.UTF8)
val postRequest = Request.post(url, jsonBody)

Request Factory Methods

import zio.http._

val url = URL.parse("https://api.example.com/resource/1").toOption.get
val body = Body.fromString("""{"name":"updated"}""")

Request.get(url)              // GET with empty body
Request.post(url, body)       // POST with body
Request.put(url, body)        // PUT with body
Request.patch(url, body)      // PATCH with body
Request.delete(url)           // DELETE with empty body
Request.head(url)             // HEAD with empty body
Request.options(url)          // OPTIONS with empty body

Modifying Requests

All modification methods return a new Request—the original is unchanged:

import zio.http._

val request = Request.get(URL.parse("https://api.example.com/users").toOption.get)

// Adding/modifying headers
request.addHeader("Accept", "application/json")
request.addHeaders(Headers("X-A" -> "1", "X-B" -> "2"))
request.setHeader("Accept", "text/html")       // replaces existing
request.removeHeader("Accept")

// Replacing components
request.body(Body.fromString("data"))
request.url(URL.parse("/other").toOption.get)
request.method(Method.POST)
request.version(Version.`HTTP/2.0`)
// Functional updates
request.updateHeaders(_.add("X-Custom", "value"))
request.updateUrl(_ / "123")   // appends path segment

Full control:

import zio.http._

val url = URL.parse("https://api.example.com/users").toOption.get
val body = Body.fromString("""{"name":"Alice"}""", Charset.UTF8)

val request = Request(
  method = Method.POST,
  url = url,
  headers = Headers(
    "content-type" -> "application/json",
    "authorization" -> "Bearer token123"
  ),
  body = body,
  version = Version.`HTTP/1.1`
)

Accessing Request Data

import zio.http._
import zio.http.{headers => h}

val request = Request.get(URL.parse("/api/users?page=1").toOption.get)

request.path         // Path("/api/users")
request.queryParams  // QueryParams("page" -> "1")
request.contentType  // Option[ContentType]
request.header(h.Authorization)  // Option[h.Authorization]

Response

Response represents HTTP responses:

final case class Response(
  status: Status,
  headers: Headers,
  body: Body,
  version: Version
)

Creating Responses

import zio.http._

val ok = Response.ok  // 200 OK, empty body

val notFound = Response.notFound  // 404 Not Found, empty body

Response Factory Methods

import zio.http._

// Status-only responses
Response.ok                    // 200
Response.notFound              // 404
Response.badRequest            // 400
Response.unauthorized          // 401
Response.forbidden             // 403
Response.internalServerError   // 500
Response.serviceUnavailable    // 503

// Responses with bodies
Response.text("Hello, World!")    // 200 with text/plain body
Response.json("""{'ok':true}""")  // 200 with application/json in headers and body
// Redirects
Response.redirect("/new-location")                     // 307 Temporary Redirect
Response.redirect("/new-location", isPermanent = true) // 308 Permanent Redirect
Response.seeOther("/other")                            // 303 See Other

Note that Response.json creates bodies with application/json content-type on the Body itself, not just in the headers.

Modifying Responses

import zio.http._

val response = Response.ok

// Adding/modifying headers
response.addHeader("X-Custom", "value")
response.setHeader("X-Custom", "new-value")
response.removeHeader("X-Custom")

// Replacing components
response.body(Body.fromString("data"))
response.status(Status.Created)
response.version(Version.`HTTP/2.0`)
// Functional update
response.updateHeaders(_.add("X-Request-Id", "abc"))

// Add a Set-Cookie header
response.addCookie(ResponseCookie("session", "abc123"))

Full control:

import zio.http._

val jsonBody = Body.fromString("""{"message":"created"}""", Charset.UTF8)

val response = Response(
  status = Status.Created,
  headers = Headers(
    "content-type" -> "application/json",
    "location" -> "/users/123"
  ),
  body = jsonBody,
  version = Version.`HTTP/1.1`
)

Accessing Response Data

import zio.http._
import zio.http.{headers => h}

val response = Response.ok

response.status.code                      // 200
response.status.isSuccess                 // true
response.contentType                      // Option[ContentType]
response.header(h.ContentType)       // Option[h.ContentType]
response.header(h.Location)          // Option[h.Location]

Advanced Usage

Building a Complete HTTP Exchange

import zio.http._

// Build request
val url = URL.parse("https://api.example.com/users").toOption.get
val requestBody = Body.fromString("""{"name":"Alice","age":30}""", Charset.UTF8)

val request = Request(
  method = Method.POST,
  url = url,
  headers = Headers(
    "content-type" -> "application/json",
    "authorization" -> "Bearer abc123",
    "user-agent" -> "MyClient/1.0"
  ),
  body = requestBody,
  version = Version.`HTTP/1.1`
)

// Build response
val responseBody = Body.fromString("""{"id":123,"name":"Alice","age":30}""", Charset.UTF8)

val response = Response(
  status = Status.Created,
  headers = Headers(
    "content-type" -> "application/json",
    "location" -> "/users/123"
  ),
  body = responseBody,
  version = Version.`HTTP/1.1`
)

URL Building with Fluent API

import zio.http._

val url = URL.parse("https://api.example.com").toOption.get

val extended = (url / "v1" / "users" / "123") ?? ("include", "profile") ?? ("include", "posts")

extended.encode
// "https://api.example.com/v1/users/123?include=profile&include=posts"

Typed Header Access

import zio.http._
import zio.http.{headers => h}

val headers = Headers(
  "content-type" -> "application/json; charset=utf-8",
  "content-length" -> "1024",
  "authorization" -> "Bearer token"
)

// Type-safe header access with parsing
val ct = headers.get(h.ContentType)
ct.map(_.value.charset)  // Some(Some(Charset.UTF8))

val cl = headers.get(h.ContentLength)
cl.map(_.length)  // Some(1024)

// Raw access
headers.rawGet("authorization")  // Some("Bearer token")

Cookie Management

import zio.http._
import zio.blocks.chunk.Chunk

// Parse cookies from request header
val cookieHeader = "session=abc; theme=dark"
val requestCookies = Cookie.parseRequest(cookieHeader)

// Create response with Set-Cookie headers
val sessionCookie = ResponseCookie(
  name = "session",
  value = "xyz123",
  path = Some(Path("/")),
  maxAge = Some(3600),
  isSecure = true,
  isHttpOnly = true,
  sameSite = Some(SameSite.Strict)
)

val response = Response(
  status = Status.Ok,
  headers = Headers(
    "set-cookie" -> Cookie.renderResponse(sessionCookie)
  ),
  body = Body.empty
)

Form Submission

import zio.http._

val form = Form(
  "username" -> "alice",
  "password" -> "secret",
  "remember" -> "true"
)

val formBody = Body.fromString(form.encode, Charset.UTF8)

val request = Request(
  method = Method.POST,
  url = URL.parse("/login").toOption.get,
  headers = Headers(
    "content-type" -> "application/x-www-form-urlencoded"
  ),
  body = formBody,
  version = Version.`HTTP/1.1`
)

Design Principles

Single Encoding Contract

Path and QueryParams store decoded values internally. Encoding happens only at output boundaries:

  • Path.fromEncoded(s) decodes, stores decoded segments
  • Path.encode encodes segments for transmission
  • QueryParams.fromEncoded(s) decodes, stores decoded key-value pairs
  • QueryParams.encode encodes for transmission

This eliminates double-encoding bugs and clarifies responsibilities.

Lazy Header Parsing

Headers stores raw string values and parses typed headers on first access. Parsed results are cached in a parallel Array[AnyRef] for O(1) subsequent lookups. This design:

  • Avoids parsing headers that are never accessed
  • Avoids re-parsing the same header multiple times
  • Supports unknown/custom headers without parsing failures

No Streaming

Bodies are fully materialized Chunk[Byte]. Streaming is left to higher-level HTTP libraries that compose with this data model. This keeps the model simple and effect-free.

Zero ZIO Dependency

The module uses zio.blocks.chunk.Chunk instead of zio.Chunk, making it usable in any Scala project without ZIO.

Schema-Based Typed Access (zio-http-model-schema)

The zio-http-model-schema module provides schema-based extraction of query parameters and headers with automatic decoding and validation.

Installation

Add the following to your build.sbt:

libraryDependencies += "dev.zio" %% "zio-blocks-http-model-schema" % "<version>"

For cross-platform projects (Scala.js):

libraryDependencies += "dev.zio" %%% "zio-blocks-http-model-schema" % "<version>"

Imports

Import the schema module to enable extension methods:

import zio.http.schema._
import zio.blocks.schema.Schema

Query Parameter Extraction

QueryParams gains schema-based extraction methods via implicit conversions:

import zio.http.{QueryParams, URL}
import zio.http.schema._
import zio.blocks.schema.Schema

val url = URL.parse("/api/users?page=2&tag=scala&tag=fp").toOption.get
val params = url.queryParams

// Extract single value with automatic decoding
params.query[Int]("page")
// Right(2)

// Extract all values for a key
params.queryAll[String]("tag")
// Right(Chunk("scala", "fp"))

// Extract with default fallback
params.queryOrElse[Int]("limit", 10)
// 10 - uses default since "limit" not present

Header Extraction

Headers gains schema-based extraction methods:

import zio.http.{Headers, Request, URL}
import zio.http.schema._
import zio.blocks.schema.Schema

val request = Request.get(URL.parse("/").toOption.get)
  .addHeader("x-page", "5")
  .addHeader("x-tag", "scala")
  .addHeader("x-tag", "functional")

val headers = request.headers

// Extract single header value
headers.header[Int]("x-page")
// Right(5)

// Extract all header values
headers.headerAll[String]("x-tag")
// Right(Chunk("scala", "functional"))

// Extract with default fallback
headers.headerOrElse[Int]("x-limit", 100)
// 100

Request and Response Extensions

Request and Response gain schema-based extraction methods that delegate to their query parameters and headers:

import zio.http.{Request, Response, URL}
import zio.http.schema._
import zio.blocks.schema.Schema

// Request query parameter extraction
val request = Request.get(URL.parse("/search?q=zio&limit=20").toOption.get)

request.query[String]("q")
// Right("zio")

request.query[Int]("limit")
// Right(20)

// Response header extraction via schema
val response = Response.ok.addHeader("x-correlation-id", "abc-123")

val responseOps = new ResponseSchemaOps(response)
responseOps.header[String]("x-correlation-id")

Error Handling

Schema-based extraction returns Either[QueryParamError, A] or Either[HeaderError, A] for explicit error handling:

import zio.http.{QueryParams, URL}
import zio.http.schema._
import zio.blocks.schema.Schema

val params = QueryParams("name" -> "Alice", "age" -> "invalid")

params.query[String]("name") match {
  case Right(name) => println(s"Name: $name")
  case Left(QueryParamError.Missing(key)) => println(s"Missing key: $key")
  case Left(QueryParamError.Malformed(key, value, cause)) =>
    println(s"Failed to parse $key=$value: $cause")
}

params.query[Int]("age") match {
  case Right(age) => println(s"Age: $age")
  case Left(QueryParamError.Missing(key)) => println(s"Missing key: $key")
  case Left(QueryParamError.Malformed(key, value, cause)) =>
    println(s"Failed to parse $key=$value: $cause")
}

Supported Types

The schema module provides built-in Schema instances for common types. Any type with a Schema[T] can be extracted:

  • Primitives: String, Int, Long, Boolean, Double, Float, Short, Byte, Char
  • Big Numbers: BigInt, BigDecimal
  • UUID: java.util.UUID

For custom types, define a Schema[T] instance using schema derivation or manual construction.