A suite of Swift packages for HTML templating, HTTP client networking, and HTTP server hosting. Built on functional programming principles using FP: every public API uses Reader for dependency injection, Result for error handling, DeferredTask / Publisher (Combine) for async work, and FunctionWrapper for composable function types. No force-unwraps, no fatalError, no silent failures.
Platforms: macOS 13+, iOS 16+, tvOS 16+, watchOS 9+
- HTMLTemplating — file-based HTML template engine with
{{}}directives - NetworkClient — composable HTTP client built on
URLSessionand Combine - NetworkServer — embedded NIO-backed HTTP server with a typed functional routing DSL
// Package.swift
.package(url: "https://github.com/luizmb/NetworkTools.git", branch: "main")Add individual products to your targets as needed:
.product(name: "HTMLTemplating", package: "NetworkTools"),
.product(name: "NetworkClient", package: "NetworkTools"),
.product(name: "NetworkServer", package: "NetworkTools"),A lightweight template engine that resolves {{variables}}, loops, conditionals, and includes. The fragment directory is threaded as a Reader environment — no stored properties, no singletons.
public struct HTMLEnvironment {
// Resolves a filename to a URL, or nil if not found.
// All files use the .template extension: find("row.template") for fragments,
// find("index.template") for top-level templates.
public let find: (String) -> URL?
// Reads a URL's contents, returning an error on failure.
public let readFile: (URL) -> Result<String, Error>
// Designated init — supply both for fully custom behaviour.
public init(find: @escaping (String) -> URL?, readFile: @escaping (URL) -> Result<String, Error>)
// Direct filesystem: appends the filename to path, reads via String(contentsOf:encoding:).
public static func live(path: String) -> Self
// Bundle-based: calls Bundle.url(forResource:withExtension:"template"),
// reads via String(contentsOf:encoding:).
public static func live(bundle: Bundle) -> Self
// Testing: find returns a synthetic URL, readFile always succeeds with the given string.
public static func mockSuccess(contents: String) -> Self
// Testing: find returns a synthetic URL, readFile always fails with the given error.
public static func mockFailure(error: Error) -> Self
}All IO flows through the environment. Neither render nor loadTemplate performs IO directly.
// A template context: keys map to strings, booleans, or lists of sub-contexts.
public typealias Context = [String: TemplateValue]
public indirect enum TemplateValue {
case string(String)
case bool(Bool)
case list([Context])
}
public enum TemplateError: Error {
case notFound(String) // template/fragment file not found
case readError(String, Error) // I/O error reading the file
}func render(_ template: String, _ context: Context) -> Reader<HTMLEnvironment, Result<String, TemplateError>>The entry point. Returns a Reader that must be run with an HTMLEnvironment to produce a Result<String, TemplateError>.
let env = HTMLEnvironment.live(path: "/path/to/templates")
let result = render("Hello, {{name}}!", ["name": .string("World")])
.runReader(env)
// result == .success("Hello, World!")Replaced with the string value of the key, or an empty string if the key is missing or is a list.
let template = "<title>{{title}}</title><p>{{body}}</p>"
let ctx: Context = [
"title": .string("My Page"),
"body": .string("Welcome!"),
]
let html = try render(template, ctx).runReader(env).get()
// "<title>My Page</title><p>Welcome!</p>"Booleans render as "true" or "false":
render("Active: {{active}}", ["active": .bool(true)]).runReader(env)
// .success("Active: true")Renders a fragment file once per item in a list, giving each iteration its own isolated sub-context.
// fragments/row.html.template:
// <li>{{name}} — {{score}}</li>
let ctx: Context = [
"players": .list([
["name": .string("Alice"), "score": .string("42")],
["name": .string("Bob"), "score": .string("37")],
])
]
let template = "<ul>{{#each players row}}</ul>"
let html = try render(template, ctx).runReader(env).get()
// "<ul><li>Alice — 42</li><li>Bob — 37</li></ul>"An empty list produces no output; a missing key is silently skipped.
Renders a fragment if the key is truthy (non-empty string, true, or non-empty list).
// fragments/badge.html.template:
// <span class="admin">Admin</span>
let template = "<p>{{username}}{{#if isAdmin badge}}</p>"
let ctx: Context = [
"username": .string("alice"),
"isAdmin": .bool(true),
]
let html = try render(template, ctx).runReader(env).get()
// "<p>alice<span class=\"admin\">Admin</span></p>"Falsy values (empty string, false, empty list, missing key) produce no output.
Inserts another fragment file inline, passing the current context through.
// fragments/header.html.template:
// <header><h1>{{siteName}}</h1></header>
// fragments/footer.html.template:
// <footer>© {{year}}</footer>
let template = """
{{#include header}}
<main>{{content}}</main>
{{#include footer}}
"""
let ctx: Context = [
"siteName": .string("My App"),
"content": .string("<p>Hello</p>"),
"year": .string("2025"),
]
let html = try render(template, ctx).runReader(env).get()Includes compose freely — a fragment can itself contain #include, #each, and #if directives. Errors propagate outward through the Result.
esc("<script>alert('xss')</script>")
// "<script>alert('xss')</script>"
escAttr(#"say "hello""#)
// "say "hello""esc escapes &, <, >. escAttr additionally escapes " for use inside HTML attributes.
loadTemplate reads a named .template file via the environment and returns a Reader just like render. Compose them with flatMap so the environment is injected once:
let ctx: Context = ["title": .string("Home"), "body": .string("<p>Hello</p>")]
// >>- is the ReaderTResult bind: threads the Result error automatically.
let page = loadTemplate("index") >>- { source in render(source, ctx) }
// Direct filesystem:
let html = page.runReader(.live(path: "/app/templates"))
// Bundle:
let html = page.runReader(.live(bundle: .main))
// Testing — no filesystem, no bundle:
let html = page.runReader(.mockSuccess(contents: "<p>{{body}}</p>"))Because render returns a Reader, you can swap the environment entirely without touching the template logic:
let pageReader: Reader<HTMLEnvironment, Result<String, TemplateError>> =
render("{{#include layout}}", [
"title": .string("Dashboard"),
"content": .string(bodyHTML),
])
// Production: filesystem-based.
let prodHTML = pageReader.runReader(.live(path: "/app/templates"))
// Or bundle-based:
// let prodHTML = pageReader.runReader(.live(bundle: .main))
// Testing: every fragment load succeeds with a fixed string.
let testHTML = pageReader.runReader(.mockSuccess(contents: "<html>{{content}}</html>"))
// Testing: every fragment load fails — verifies error propagation.
let failHTML = pageReader.runReader(.mockFailure(error: URLError(.fileDoesNotExist)))All IO is fully contained in the environment — render never touches the filesystem directly.
Shared building blocks used by all three packages. The most broadly useful surface is the codec layer and its Combine extensions.
Convert<Input, Output, Failure> is a FunctionWrapper around (Input) -> Result<Output, Failure>. It is a reusable, composable fallible conversion as a first-class value, supporting the full Functor / Applicative / Monad hierarchy.
Concrete typealiases pin the type parameters for common uses:
// Data -> Result<D, DecodingError>
public typealias DataDecoder<D: Decodable> = Convert<Data, D, DecodingError>
// I -> Result<Data, EncodingError>
public typealias DataEncoder<I: Encodable> = Convert<I, Data, EncodingError>
// [String: String] -> Result<D, DecodingError> (used by NetworkServer routing)
public typealias DictionaryDecoder<D: Decodable> = Convert<[String: String], D, DecodingError>let userDecoder: DataDecoder<User> = JSONDecoder().dataDecoder(for: User.self)
let albumEncoder: DataEncoder<Album> = JSONEncoder().dataEncoder(for: Album.self)
// Call directly:
let result: Result<User, DecodingError> = userDecoder(jsonData)
let encoded: Result<Data, EncodingError> = albumEncoder(myAlbum)Protocols satisfied by JSONDecoder / JSONEncoder (and any custom codec). They let you inject the codec as a dependency rather than constructing it inline.
func makeDecoder() -> DataDecoderFactory {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
return d
}Static factory that lifts a Data value into a typed decoded publisher, for use inside flatMap chains:
// AnyPublisher<User, DecodingError>
let pub: AnyPublisher<User, DecodingError> = .decoding(jsonData, using: JSONDecoder())
// With a pre-built DataDecoder:
let pub2 = AnyPublisher<User, DecodingError>.decoding(jsonData, using: userDecoder)Instance methods that encode the publisher's Encodable output to Data.
// Upstream failure type == EncodingError — no mapping needed:
let dataPublisher: AnyPublisher<Data, EncodingError> =
someAlbumPublisher.encode(using: JSONEncoder())
// Upstream failure type is different — lift EncodingError with mapError:
let dataPublisher2: AnyPublisher<Data, MyError> =
someAlbumPublisher.encode(using: JSONEncoder(), mapError: MyError.encoding)
// Both overloads also accept a pre-built DataEncoder<Output>:
someAlbumPublisher.encode(using: albumEncoder)
someAlbumPublisher.encode(using: albumEncoder, mapError: MyError.encoding)contramap maps over the input type, adapting a Convert<Input, Output, Failure> to accept a different input by pre-processing it before the conversion runs:
let userDecoder: DataDecoder<User> = JSONDecoder().dataDecoder(for: User.self)
// Adapt to accept String instead of Data:
let stringDecoder: Convert<String, User, DecodingError> =
userDecoder.contramap { Data($0.utf8) }Two extension overloads convert a Combine publisher into a DeferredTask, bridging into async/await contexts:
// Infallible publisher — DeferredTask<Output>:
let task: DeferredTask<Int> = someIntPublisher.asDeferredTask()
// Failable publisher — DeferredTask<Result<Output, Failure>>:
let task: DeferredTask<Result<User, HTTPError>> = somePublisher.asDeferredTask()A composable HTTP client with two parallel APIs: RequestPublisher<A> (Combine) and NetworkTask<A> (async/await). Both model the same abstraction — a URLRequest environment threaded through a typed result pipeline — using different concurrency primitives.
Note:
RequestPublisherand its operators require Combine (macOS 10.15+, iOS 13+, tvOS 13+, watchOS 6+).NetworkTaskandURLSession.taskRequesteruseDeferredTaskand are available on all supported platforms without Combine.
// The primary type: a reusable function from URLRequest to a typed response publisher.
public struct RequestPublisher<A>: FunctionWrapper
// Alias for the raw response pair before status/decoding.
public typealias Requester = RequestPublisher<(Data, HTTPURLResponse)>
// A reusable fallible conversion function: Input -> Result<Output, Failure>.
public struct Convert<Input, Output, Failure: Error>: FunctionWrapper
// Specialised typealiases from Core:
public typealias DataDecoder<D: Decodable> = Convert<Data, D, DecodingError>
public typealias DataEncoder<E: Encodable> = Convert<E, Data, EncodingError>
public enum HTTPError: Error {
case network(Error) // URLSession-level failure
case badStatus(Int, Data) // non-2xx response (code + raw body for diagnostics)
case decoding(Error) // JSON decoding failure
}URLSession gains a .requester property that lifts dataTaskPublisher into a Requester:
let requester: Requester = URLSession.shared.requesterCall it with any URLRequest to get a publisher:
// swiftlint:disable:next force_unwrapping
let url = URL(string: "https://api.example.com/users/1")!
let publisher: AnyPublisher<(Data, HTTPURLResponse), HTTPError> =
requester(URLRequest(url: url))struct User: Decodable {
let id: Int
let name: String
}
// Build a reusable typed pipeline; run it with a URLRequest when needed.
let getUser: RequestPublisher<User> =
URLSession.shared.requester
.validateStatusCode()
.decode(using: JSONDecoder())
// swiftlint:disable:next force_unwrapping
let request = URLRequest(url: URL(string: "https://api.example.com/users/1")!)
getUser(request)
.sink(
receiveCompletion: { print("done:", $0) },
receiveValue: { print("user:", $0.name) }
)DataDecoder<D> is Convert<Data, D, DecodingError>. A JSONDecoder produces one via dataDecoder(for:):
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let userDecoder: DataDecoder<User> = decoder.dataDecoder(for: User.self)
// Use directly:
let result: Result<User, DecodingError> = userDecoder(jsonData)
// Post-process with map to extract a field:
let nameDecoder: DataDecoder<String> = userDecoder.map(\.name)DataDecoder (and Convert in general) supports the full Functor / Applicative / Monad hierarchy.
map transforms the decoded output of a successful publisher:
// Get just the name from a user endpoint.
let namePublisher: RequestPublisher<String> =
URLSession.shared.requester
.validateStatusCode()
.decode(using: userDecoder)
.map(\.name)
// Using the <£> (fmap) operator — function on the left:
let namePublisher2: RequestPublisher<String> =
\.name <£> URLSession.shared.requester
.validateStatusCode()
.decode(using: userDecoder)apply (or <*>) runs two RequestPublishers against the same URLRequest and zips their results:
struct Dashboard { let user: User; let stats: Stats }
// Lift the constructor into a publisher, then apply each field independently.
let dashboardPublisher: RequestPublisher<Dashboard> =
RequestPublisher.pure(curry(Dashboard.init))
<*> URLSession.shared.requester.validateStatusCode().decode(using: userDecoder)
<*> URLSession.shared.requester.validateStatusCode().decode(using: statsDecoder)flatMap (or >>-) threads the same base URLRequest through a dependent chain:
// Fetch a user, then fetch their team using the team ID from the first response.
let userAndTeam: RequestPublisher<(User, Team)> =
URLSession.shared.requester
.validateStatusCode()
.decode(using: userDecoder)
>>- { user in
// swiftlint:disable:next force_unwrapping
let teamURL = URL(string: "https://api.example.com/teams/\(user.teamId)")!
return URLSession.shared.requester
.validateStatusCode()
.decode(using: teamDecoder)
.map { (user, $0) }
}Kleisli composition (>=>) sequences functions of the form (A) -> RequestPublisher<B>:
let fetchUser: (Int) -> RequestPublisher<User> = { id in ... }
let fetchTeam: (User) -> RequestPublisher<Team> = { user in ... }
let fetchProject: (Team) -> RequestPublisher<Project> = { team in ... }
// Compose into a single (Int) -> RequestPublisher<Project>:
let fetchProjectForUser: (Int) -> RequestPublisher<Project> =
fetchUser >=> fetchTeam >=> fetchProject
// swiftlint:disable:next force_unwrapping
fetchProjectForUser(42)(URLRequest(url: URL(string: "https://api.example.com")!))
.sink(receiveCompletion: { _ in }, receiveValue: { print($0) })let resilient: RequestPublisher<User> =
URLSession.shared.requester
.validateStatusCode()
.decode(using: userDecoder)
.flatMapError { _ in RequestPublisher.pure(User.guest) }| Operator | Meaning |
|---|---|
f <£> r |
fmap: apply f to the output of r |
r <&> f |
flipped fmap |
r £> v |
replace output with constant v |
f <*> r |
apply: function-in-publisher applied to value-in-publisher |
a *> b |
sequence, keep right |
a <* b |
sequence, keep left |
r >>- f |
bind: flatMap (r then f) |
f -<< r |
flipped bind |
f >=> g |
Kleisli left-to-right composition |
g <=< f |
Kleisli right-to-left composition |
NetworkTask<A> is the async/await counterpart to RequestPublisher<A>. It is a ZIO<URLRequest, A, HTTPError> — a FunctionWrapper around (URLRequest) -> DeferredTask<Result<A, HTTPError>>. No Combine required.
// NetworkTask<A> = ZIO<URLRequest, A, HTTPError>
// = (URLRequest) -> DeferredTask<Result<A, HTTPError>>
public typealias NetworkTask<A: Sendable> = ZIO<URLRequest, A, HTTPError>
// Raw HTTP response pair — counterpart to Requester
public typealias TaskRequester = NetworkTask<(Data, HTTPURLResponse)>URLSession gains a .taskRequester property parallel to .requester:
let taskRequester: TaskRequester = URLSession.shared.taskRequesterThe same pipeline steps are available via validateStatusCode() and decode(using:):
struct User: Decodable, Sendable { let id: Int; let name: String }
let getUser: NetworkTask<User> =
URLSession.shared.taskRequester
.validateStatusCode()
.decode(using: JSONDecoder())
let request = URLRequest(url: URL(string: "https://api.example.com/users/1")!)
let result: Result<User, HTTPError> = await getUser(request).run()Because NetworkTask is a ZIO (FunctionWrapper), it participates in the same Functor / Monad / Applicative hierarchy as RequestPublisher through the FP operators.
An embedded HTTP server backed by SwiftNIO. Routes are built by Kleisli-composing (>=>) a series of lifting functions, then wrapping the result with when(…). Routers are values — they combine with <|>, adapt their environment with contramap, and are injected into the server via Reader.
get(path, params:, query:) — GET route entry point
post / put / patch / delete — other HTTP verbs; same signature
>=> ignoreBody() — no body; imposes no Decodable constraint
or decodeBody() — decode body as B using env's DataDecoderFactory (Env: HasDataDecoderFactory)
>=> .response { req in … } — lift closure into Effect<TypedRequest, Env, Response, ResponseError>
when(chain) — wrap into Router<DefaultEnv>
when(chain, injecting: T.self) — wrap into Router<T> for custom environments
Multiple routers combine with <|>. The operator tries the left router first and falls through to the right only on a 404 (unmatched route). Query-param errors (400) and body-decode errors (400) stop immediately without trying the next router.
The environment is injected once at startup via runReader. Every >=> step and every .response closure is pure — no side effects, no env access — until startServer(…).runReader(env) is called.
// A route pattern with typed URL and query parameter structs.
// Use Empty for parameter groups that require no decoding.
public struct Route<URLParams: Decodable, QueryParams: Decodable>: Sendable
// A first-class router value. Its handle property is a Reader — inject the
// environment once at startup via handle.runReader(env) to get the request handler.
public struct Router<Env: Sendable>
// A fully decoded, typed request passed to every handler.
public struct TypedRequest<URLParams, QueryParams, Body> {
public let urlParams: URLParams
public let queryParams: QueryParams
public let body: Body
public let raw: Request // original NIO request (method, uri, path, body, queryParams)
}
// Typed error value; the response status code, headers, and body are all fields.
public struct ResponseError: Error {
public let status: HTTPResponseStatus
public let headers: [(String, String)]
public let body: Data
}
// Sentinel for any type parameter that carries no data.
public struct Empty: Codable, Sendable {
public static let value: Empty
}// startServer returns a Reader — inject the environment when running.
// This call blocks until the server shuts down (or fails to bind).
let result: Result<Void, Error> = startServer(port: 8080, router: myRouter).runReader(myEnv)
// Run on a background thread to avoid blocking the caller:
Thread.detachNewThread {
_ = startServer(port: 8080, router: myRouter).runReader(myEnv)
}let router = when(
get("/ping") >=> ignoreBody() >=> .response { _ in .html("pong") }
)
Thread.detachNewThread {
_ = startServer(port: 8080, router: router).runReader(DefaultEnv())
}Declare a Decodable struct whose property names match the :placeholder names in the route pattern. URLParamsDecoder maps path segments to struct fields using their string values.
struct AlbumID: Decodable { let id: String }
let router = when(
get("/albums/:id", params: AlbumID.self)
>=> ignoreBody()
>=> .response { req in .html("Album: \(req.urlParams.id)") }
)Multiple parameters work the same way — one struct field per placeholder:
struct PhotoPath: Decodable {
let albumId: String
let photoId: String
}
let router = when(
get("/albums/:albumId/photos/:photoId", params: PhotoPath.self)
>=> ignoreBody()
>=> .response { req in .html("Album \(req.urlParams.albumId), photo \(req.urlParams.photoId)") }
)Typed URL params participate in routing. If a :placeholder cannot be decoded into the declared Swift type (e.g., a field typed as Int when the path segment is "beach"), the route returns 404 and the next router is tried. This lets the same URL shape be handled by different typed routes:
struct NumericID: Decodable { let id: Int }
struct StringSlug: Decodable { let id: String }
// GET /albums/123 → matched by Int route → "Numeric album: 123"
// GET /albums/jazz → falls through (404) → matched by String route → "Album slug: jazz"
let router =
when(get("/albums/:id", params: NumericID.self) >=> ignoreBody()
>=> .response { req in .html("Numeric album: \(req.urlParams.id)") })
<|> when(get("/albums/:id", params: StringSlug.self) >=> ignoreBody()
>=> .response { req in .html("Album slug: \(req.urlParams.id)") })Declare a Decodable struct whose fields match the query-string keys. Optional fields are accepted even when the key is absent; required fields that are missing cause a 400 response.
struct Pagination: Decodable {
let page: Int?
let limit: Int?
}
// GET /items?page=2&limit=20
let router = when(
get("/items", query: Pagination.self)
>=> ignoreBody()
>=> .response { req in
let page = req.queryParams.page ?? 1
let limit = req.queryParams.limit ?? 10
return .plainText("page=\(page) limit=\(limit)")
}
)Use .json(_:encoder:) — pass the value and any DataEncoderFactory (e.g. a JSONEncoder). The factory is typically injected from a Reader environment or captured from an outer scope:
struct Album: Codable {
let id: Int
let title: String
}
let router = when(
get("/albums/1")
>=> ignoreBody()
>=> .response { _ in .json(Album(id: 1, title: "Kind of Blue"), encoder: JSONEncoder()) }
)Pre-configure a JSONEncoder and reuse it across handlers:
let prettyEncoder: JSONEncoder = {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
e.keyEncodingStrategy = .convertToSnakeCase
return e
}()
// Use directly in any handler:
.json(myValue, encoder: prettyEncoder)
.json(myValue, encoder: prettyEncoder, status: .created)Use decodeBody(using:) as the middle step in the Kleisli chain. The lens extracts the decoder from the environment, making the dependency explicit at the call site and letting different endpoints use different decoding strategies. A decode failure returns 400 before the handler is called.
struct CreateAlbum: Decodable { let title: String }
struct Album: Codable { let id: Int; let title: String }
struct AppEnv: HasDictionaryDecoderFactory, Sendable {
let decoder: JSONDecoder
var dictionaryDecoderFactory: DictionaryDecoderFactory { DefaultEnv().dictionaryDecoderFactory }
}
let router = when(
post("/albums")
>=> decodeBody(using: \.decoder)
>=> .response { (req: TypedRequest<Empty, Empty, CreateAlbum>) in
.json(Album(id: nextID(), title: req.body.title), encoder: JSONEncoder(), status: .created)
},
injecting: AppEnv.self
)The lens can also target a pre-built DataDecoder<B> stored on the environment (avoids re-building the decoder per request), or a DataDecoderFactory property for when the type must be inferred:
// DataDecoder<B> stored directly — zero allocation per request
>=> decodeBody(using: \.albumDecoder) // where albumDecoder: DataDecoder<CreateAlbum>
// DataDecoderFactory stored on env — type B inferred from context
>=> decodeBody(using: \.jsonDecoder) // where jsonDecoder: JSONDecoder (or any DataDecoderFactory)Combine URL params, query params, and a body in one route:
struct AlbumID: Decodable { let id: Int }
struct Format: Decodable { let format: String? }
struct CreatePhoto: Decodable { let caption: String; let data: String }
let router = when(
post("/albums/:id/photos", params: AlbumID.self, query: Format.self)
>=> decodeBody(using: \.decoder)
>=> .response { (req: TypedRequest<AlbumID, Format, CreatePhoto>) in
.html("Uploaded to album \(req.urlParams.id) as \(req.queryParams.format ?? "jpeg")")
},
injecting: AppEnv.self
)<|> is the ordered-choice operator for routers. It tries the left side first; it falls through to the right only when the left returns 404.
struct Env: HasDictionaryDecoderFactory, Sendable {
let decoder: JSONDecoder
var dictionaryDecoderFactory: DictionaryDecoderFactory { DefaultEnv().dictionaryDecoderFactory }
}
let router: Router<Env> =
when(get("/ping") >=> ignoreBody() >=> .response { _ in .html("pong") }, injecting: Env.self)
<|> when(get("/health") >=> ignoreBody() >=> .response { _ in .html("ok") }, injecting: Env.self)
<|> when(
post("/echo")
>=> decodeBody(using: \.decoder)
>=> .response { (req: TypedRequest<Empty, Empty, [String: String]>) in
.json(req.body, encoder: JSONEncoder())
},
injecting: Env.self
)Router.empty is the identity — a router that always returns 404:
let empty = Router<DefaultEnv>.empty
// empty.handle.runReader(DefaultEnv())(request) always yields .failure(.notFound).response is a static factory on Effect that lifts any of several closure shapes into an Effect<TypedRequest<U,Q,B>, Env, Response, ResponseError>, which composes directly with the preceding >=> chain. Swift infers the Effect type from context, so the Effect. prefix is not needed:
// Sync failable — returns Result<Response, ResponseError>
.response { req -> Result<Response, ResponseError> in
guard req.urlParams.id > 0 else { return .badRequest("id must be positive") }
return .json(myAlbum, encoder: JSONEncoder())
}
// Async failable — returns DeferredTask<Result<Response, ResponseError>>
.response { req in
DeferredTask {
guard let album = await db.fetchAlbum(req.urlParams.id) else { return .notFound }
return .json(album, encoder: JSONEncoder())
}
}
// Combine env-independent — encoder inline; mapError lifts EncodingError into ResponseError
.response { req in
fetchAlbumPublisher(req.urlParams.id) // AnyPublisher<Album, ResponseError>
.encode(using: JSONEncoder(), mapError: { ResponseError.serverError($0.localizedDescription) })
.map { Response(status: .ok, headers: [("Content-Type", "application/json")], body: $0) }
.eraseToAnyPublisher()
}
// Combine env-dependent — encoder comes from the environment
.response { req, env in
fetchAlbumPublisher(req.urlParams.id) // AnyPublisher<Album, ResponseError>
.encode(using: env.encoder, mapError: { ResponseError.serverError($0.localizedDescription) })
.map { Response(status: .ok, headers: [("Content-Type", "application/json")], body: $0) }
.eraseToAnyPublisher()
}When the handler needs values from the server's environment (database connections, config, auth tokens), return a Reader from the closure. Custom environments must conform to HasDictionaryDecoderFactory for route parameter decoding — delegate to DefaultEnv for the default implementation:
struct AppEnv: HasDictionaryDecoderFactory, Sendable {
let db: Database
let config: Config
var dictionaryDecoderFactory: DictionaryDecoderFactory { DefaultEnv().dictionaryDecoderFactory }
}
struct AlbumID: Decodable { let id: Int }
let router: Router<AppEnv> = when(
get("/albums/:id", params: AlbumID.self)
>=> ignoreBody()
>=> .response { req, env in
DeferredTask {
guard let album = await env.db.fetchAlbum(id: req.urlParams.id) else {
return .notFound
}
return .json(album, encoder: env.encoder)
}
},
injecting: AppEnv.self
)
// Inject the environment at startup, not at route definition time.
startServer(port: 8080, router: router).runReader(AppEnv(db: db, config: config))For synchronous env access:
struct ConfigEnv: HasDictionaryDecoderFactory, Sendable {
let greeting: String
var dictionaryDecoderFactory: DictionaryDecoderFactory { DefaultEnv().dictionaryDecoderFactory }
}
let router: Router<ConfigEnv> = when(
get("/hello")
>=> ignoreBody()
>=> .response { _, env in .html(env.greeting) },
injecting: ConfigEnv.self
)
startServer(port: 8080, router: router).runReader(ConfigEnv(greeting: "Hi there!"))contramap adapts a Router<SmallEnv> to work inside a larger environment by providing a function (World) -> SmallEnv. This lets you build modular routers that know only about their own slice of the environment, then assemble them at the top level:
// Each sub-env must conform to HasDictionaryDecoderFactory.
struct AppEnv: HasDictionaryDecoderFactory, Sendable {
let auth: AuthEnv
let data: DataEnv
var dictionaryDecoderFactory: DictionaryDecoderFactory { DefaultEnv().dictionaryDecoderFactory }
}
// Each sub-module declares only what it needs (also conforming to HasDictionaryDecoderFactory).
let authRouter: Router<AuthEnv> = /* login/logout routes */
let dataRouter: Router<DataEnv> = /* resource routes */
// Combine at the app level, mapping each router to its env slice.
let appRouter: Router<AppEnv> =
authRouter.contramap(\.auth)
<|> dataRouter.contramap(\.data)
startServer(port: 8080, router: appRouter).runReader(AppEnv(auth: authEnv, data: dataEnv))Handlers return Result<Response, ResponseError>. Static factories let you construct these with leading-dot syntax:
// Success
.json(album, encoder: JSONEncoder()) // 200 application/json
.json(album, encoder: JSONEncoder(), status: .created) // 201
.html("<h1>Hello</h1>") // 200 text/html
.html("<b>bad</b>", status: .badRequest)
.plainText("OK")
.raw(pdfData) // application/octet-stream
.image(jpegData) // image/jpeg
.image(pngData, mimeType: "image/png")
// Failure
.notFound // 404
.badRequest("missing 'title'") // 400
.serverError("db unavailable") // 500ResponseError.notFound // 404 text/plain "Not Found"
ResponseError.badRequest("missing 'title'") // 400 text/plain
ResponseError.serverError("db unavailable") // 500 text/plain
// Custom status/headers/body
ResponseError(status: .unauthorized, headers: [("WWW-Authenticate", "Bearer")], body: Data())
// Encoded body — factory mirrors the Result factories but returns ResponseError directly
ResponseError.json(Payload(code: 42), encoder: JSONEncoder(), status: .unprocessableEntity)
ResponseError.html("<b>error</b>", status: .badRequest)import Foundation
import NIOHTTP1
import NetworkServer
// MARK: - Models
struct Album: Codable, Sendable {
let id: Int
let title: String
let year: Int
}
struct CreateAlbum: Decodable { let title: String; let year: Int }
// MARK: - Environment
struct AppEnv: HasDictionaryDecoderFactory, Sendable {
var albums: [Album]
let decoder: JSONDecoder
let encoder: DataEncoderFactory
var dictionaryDecoderFactory: DictionaryDecoderFactory { DefaultEnv().dictionaryDecoderFactory }
static let live = AppEnv(
albums: [
Album(id: 1, title: "Kind of Blue", year: 1959),
Album(id: 2, title: "A Love Supreme", year: 1964),
Album(id: 3, title: "In a Silent Way", year: 1969),
],
decoder: JSONDecoder(),
encoder: JSONEncoder()
)
}
// MARK: - Route params
struct AlbumID: Decodable { let id: Int }
struct YearQuery: Decodable { let year: Int? }
// MARK: - Router
let router: Router<AppEnv> =
// GET /albums — list all, optionally filtered by ?year=
when(
get("/albums", query: YearQuery.self)
>=> ignoreBody()
>=> .response { req, env in
let albums = req.queryParams.year.map { y in env.albums.filter { $0.year == y } }
?? env.albums
return .json(albums, encoder: env.encoder)
},
injecting: AppEnv.self
)
// GET /albums/:id — fetch one album by integer ID
<|> when(
get("/albums/:id", params: AlbumID.self)
>=> ignoreBody()
>=> .response { req, env in
guard let album = env.albums.first(where: { $0.id == req.urlParams.id }) else {
return .notFound
}
return .json(album, encoder: env.encoder)
},
injecting: AppEnv.self
)
// POST /albums — create a new album from a JSON body
<|> when(
post("/albums")
>=> decodeBody(using: \.decoder)
>=> .response { (req: TypedRequest<Empty, Empty, CreateAlbum>, env: AppEnv) in
let newAlbum = Album(id: env.albums.count + 1, title: req.body.title, year: req.body.year)
return .json(newAlbum, encoder: env.encoder, status: .created)
},
injecting: AppEnv.self
)
// MARK: - Start
Thread.detachNewThread {
_ = startServer(port: 8080, router: router).runReader(.live)
}NetworkServer and HTMLTemplating compose naturally — run the Reader from render inside an env-aware handler:
struct WebEnv: HasDictionaryDecoderFactory, Sendable {
let templates: HTMLEnvironment
let db: Database
var dictionaryDecoderFactory: DictionaryDecoderFactory { DefaultEnv().dictionaryDecoderFactory }
}
let router: Router<WebEnv> = when(
get("/")
>=> ignoreBody()
>=> .response { _, env in
let ctx: Context = [
"title": .string("Albums"),
"albums": .list(env.db.allAlbums().map { ["title": .string($0.title)] }),
]
switch render("{{#include page}}", ctx).runReader(env.templates) {
case .success(let html): return .html(html)
case .failure(let err): return .serverError(String(describing: err))
}
},
injecting: WebEnv.self
)
startServer(port: 8080, router: router).runReader(
WebEnv(templates: .live(path: "/app/templates"), db: myDB)
)All three packages follow the same functional conventions via FP:
Readerfor dependency injection (template environment, server environment, request threading). Noinitinjection, no stored globals.Resultinstead ofthrowsat all public API boundaries. Errors are values.DeferredTaskfor async work in the server (lazy, nothing runs until.run()is called).Publisher(Combine) for the HTTP client (composable, cancellable, backpressure-aware).FunctionWrapperfor any(A) -> Bthat should be composable —RequestPublisher,DataDecoder,DataEncoderall conform.- Alternative (
<|>) for router composition — tries left then right (only 404 falls through), identity isRouter.empty. - No crashing operations — no force-unwrap, no
fatalError, notry!. All failure paths returnResultor publisher errors.