Skip to content

zio-blocks-http: Pure, zero-dependency HTTP data model #1113

@987Nabil

Description

@987Nabil

Motivation

ZIO HTTP currently mixes pure HTTP data types with runtime/server concerns (ZIO effects, Netty interop, streaming, codec logic). This creates several problems:

  1. Circular dependenciesStatus.toResponse creates a Response, Response.fromThrowable depends on Status, server factories live on data types
  2. Non-portablejava.nio.charset.Charset in public API, java.util.LinkedHashMap backing QueryParams, Netty AsciiString via CharSequence leaking into Headers
  3. Recurring bugs from design ambiguity — URL encoding has produced 6 bugs in the last year because Path has no clear contract on whether segments are stored encoded or decoded
  4. Allocation overheadHeaders uses a Concat tree that allocates on every ++, Status is a sealed trait with case objects instead of an unboxed int, Body wraps everything in ZIO effects even for fully materialized data

We propose a new zio-blocks-http module: a pure, immutable, zero-ZIO-dependency HTTP data model optimized for extreme runtime performance. It depends only on other zio-blocks modules (chunk, mediatype). No streaming (will come when blocks Streams is ready). No server/client runtime assumptions — no Netty, no specific HTTP server dependency.

Design Principles

  • Pure data — no effects, no streaming, no mutable state (except monotonic lazy-parse caches in Headers)
  • Zero ZIO dependency — collections use blocks Chunk, not zio.Chunk
  • Single encoding contract — Path segments always stored decoded, encoding happens only at serialization
  • Typed headers with lazy parsing — keep zio-http's Header sealed trait hierarchy for type safety, but parse lazily and cache
  • Server-runtime agnostic — no design choices assume Netty or any specific server. Adapters belong in zio-http.
  • Scala 3 first, Scala 2 compatible — use opaque types where beneficial (Status), with AnyVal fallbacks

Components

# Type Internal Representation Key Design Decisions
1 Method Sealed trait enum No ANY (routing wildcard, not HTTP). No ++. No / operator. Add ordinal: Int for array-indexed dispatch.
2 Status Opaque type over Int (Scala 3); AnyVal (Scala 2) Zero allocation. Status.Ok, Status.NotFound, etc. as plain vals — no cache needed. inline extension methods for isSuccess, isClientError, etc. No toResponse.
3 Version Simple enum: Http1_0, Http1_1, Http2, Http3 No Default (that's config, not protocol).
4 Scheme Simple enum: HTTP, HTTPS, WS, WSS Optional Custom(String) case for extensibility.
5 Path Chunk[String] of decoded segments + trailing-slash flag Single encoding contract: segments always stored decoded. Path("/users/john doe") from plain string. Path.fromEncoded("/users/john%20doe") from wire format. encode for output. One shared PercentEncoder, RFC 3986 compliant with per-component encoding rules.
6 QueryParams Parallel arrays: keys: Array[String], values: Array[Chunk[String]], size: Int No Java collections. No LinkedHashMap, no java.util.List. Immutable. Encoding via shared PercentEncoder. QueryParamsBuilder (mutable) for construction. Option semantics are NOT in QueryParams — that's the endpoint codec layer.
7 URL scheme, host, port, path, queryParams, fragment Relative vs absolute = presence/absence of scheme. No Location sealed trait. URL parsing as static method.
8 Header Sealed trait with typed subtypes (ContentType, Accept, Authorization, etc.) Keep zio-http's Header type hierarchy for type-safe access. Each HeaderType has parse(String): Either[String, H] and render(H): String — nothing else. No fromHeaders, no toHeaders, no StringSchemaCodec. Pure String → A and A → String.
9 Headers Parallel arrays: names[], values[], parsed[], size Flat, not a concat-tree. See Headers design detail below.
10 Body Array[Byte] + optional ContentType No effects. No Task, ZIO, ZStream. Always materialized. empty, fromArray, fromChunk, fromString. Streaming support will come with blocks Streams.
11 ContentType mediaType: MediaType + optional Boundary + optional Charset Uses MediaType from zio-blocks-mediatype.
12 Charset Own enum: UTF8, ASCII, ISO_8859_1, UTF16, UTF16BE, UTF16LE No java.nio.charset.Charset in public API. Portable to Scala.js. toJava conversion on JVM.
13 Request method, url, headers, body, version No remoteAddress, no remoteCertificate (transport concerns). No HeaderOps/QueryOps mixins. Immutable.
14 Response status, headers, body, version No mutable encoded cache. No fromSocketApp, fromThrowable, fromServerSentEvents. Immutable.
15 Cookie RequestCookie(name, value) and ResponseCookie(name, value, domain, path, maxAge, ...) Pure data. Encoding/decoding as standalone functions, not methods on the data type.
16 Form Chunk[(String, String)] URL-encoded form data. No multipart streaming.
17 Boundary Value wrapper over String Multipart boundary identifier.

Headers Design Detail

The key design is typed headers with lazy parsing and caching:

┌───────────────────────────────────────────────────────┐
│ Headers                                               │
│                                                       │
│  names:  [ "content-type",  "accept",  "x-custom"  ] │ ← pre-lowercased
│  values: [ "application/json", "text/*", "foo"      ] │ ← raw wire value
│  parsed: [ ContentType(…),    null,      null       ] │ ← lazy, cached
│  size:   3                                            │
└───────────────────────────────────────────────────────┘
  • parsed[] starts all-null. Populated on first typed access. Monotonic writes (null → value), safe under races (deterministic parsing = same result if double-parsed).
  • headers.get(Header.ContentType) → scans names[], checks parsed[i], parses if null, caches, returns typed value.
  • headers.rawGet("content-type") → scans names[], returns values[i], zero parsing.
  • headers.getAll(Header.SetCookie) → returns all matching entries. Multiple entries with the same name = multiple array slots — multi-value headers are naturally supported per RFC 9110 §5.2–5.3.
  • headers.add(name, value) / headers.set(name, value)add appends (multi-value), set replaces all existing.
  • Server runtimes use HeadersBuilder (mutable) to construct from wire data, then .build() into immutable Headers.

Design Issues in zio-http This Addresses

Based on analysis of open/closed issues in zio/zio-http:

Bug Class zio-http Issues Root Cause How This Fixes It
URL double/missing encoding zio/zio-http#3887, zio/zio-http#3935, zio/zio-http#3625, zio/zio-http#3624, zio/zio-http#3646 Path has no contract on encoded vs decoded. Multiple independent encode/decode implementations. Single contract: segments stored decoded. One PercentEncoder, RFC 3986 per-component rules.
Multi-value header loss zio/zio-http#3590 Headers.Concat tree + getUnsafe returns first match only. Flat array — getAll naturally returns all matches.
Header performance zio/zio-http#1063 Concat tree traversal, Iterator allocation on encode. Flat array, indexed access, HeadersBuilder for zero-copy construction.
Missing headers / race zio/zio-http#3395 Mutable var encoded on Response + lazy tree iteration. Immutable. No mutable fields on Response.
Cookie replacement zio/zio-http#3438 addCookie removes then re-adds on Concat tree — non-atomic. Append-only flat array.
QueryParams Java collections zio/zio-http#3420 java.util.LinkedHashMap merge logic. Purpose-built parallel arrays. No Java collections.
Optional query double-wrap zio/zio-http#3448, zio/zio-http#3491, zio/zio-http#2942 StringSchemaCodec wrapping + HttpCodec.Fallback nesting overflow. QueryParams is raw data — no Option semantics baked in.
Non-portable Charset zio/zio-http#3650 java.nio.charset.Charset in public API. Own Charset enum with JVM conversion.
Basic auth colon bug zio/zio-http#3193 split(":") with length == 2 check in HeaderType. Clean parse/render functions per HeaderType.

Module Dependencies

zio-blocks-chunk          (zero deps)
zio-blocks-mediatype      (zero deps)

zio-blocks-http           (depends: chunk, mediatype)

zio-http                  (depends: zio-blocks-http, + ZIO + server runtime)

Non-Goals (Explicit Exclusions)

  • No streaming body — will come with blocks Streams
  • No server/client runtime — no Handler, Route, Server, Client
  • No ZIO dependency — no Task, ZIO, Chunk (from zio), ZStream
  • No endpoint/codec — separate zio-blocks-endpoint module (tracked in separate issue)
  • No OpenAPI generation — stays in zio-http
  • No middlewareResponse.Patch, HandlerAspect etc. stay in zio-http
  • No WebSocket — server-runtime concern
  • No multipart streaming — depends on streaming support

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions