143 lines
5.0 KiB
Go
143 lines
5.0 KiB
Go
package result_test
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.djmil.dev/go/template/pkg/result"
|
|
)
|
|
|
|
// The steps declared here are the ones worth naming: each is reused across
|
|
// examples, or carries enough logic that inlining it would bury the pipeline in
|
|
// the middle of the call. At the call site the name is then the documentation,
|
|
// so the chain reads as a sentence. Steps used exactly once and small enough to
|
|
// read at a glance stay inside the example that uses them.
|
|
|
|
// defaultPort is the last resort when no source yields a usable port.
|
|
const defaultPort = 3000
|
|
|
|
// unprivileged reports whether p sits outside the reserved port range.
|
|
func unprivileged(p int) bool { return p >= 1024 }
|
|
|
|
// rejectPrivileged fails a port inside the reserved range, naming the offender.
|
|
// Filter can name it too, but only given a binding to read Value from; mid-chain
|
|
// there is none, which is why this is written as an AndThen step.
|
|
func rejectPrivileged(p int) result.Expect[int] {
|
|
if unprivileged(p) {
|
|
return result.Ok(p)
|
|
}
|
|
return result.Err[int](fmt.Errorf("port %d is privileged", p))
|
|
}
|
|
|
|
// listenAddr renders a port as the address the server should bind to.
|
|
func listenAddr(p int) string { return fmt.Sprintf("localhost:%d", p) }
|
|
|
|
// isLocal reports whether addr points at the loopback host.
|
|
func isLocal(addr string) bool { return strings.HasPrefix(addr, "localhost") }
|
|
|
|
// describeAsPortFailure says what the program was trying to achieve when a port
|
|
// could not be resolved, keeping the original cause in the errors.Is chain.
|
|
func describeAsPortFailure(err error) error {
|
|
return fmt.Errorf("resolve listen port: %w", err)
|
|
}
|
|
|
|
// listenAddrOf hides a type crossing behind a name. parsePort yields an
|
|
// Expect[int] and an address is a string, so the crossing has to be the
|
|
// package-level result.Map — but only in here. Callers receive an
|
|
// Expect[string] they can keep chaining on, and MapErr layers on context that
|
|
// result.Map alone cannot, since it carries the error verbatim.
|
|
func listenAddrOf(raw string) result.Expect[string] {
|
|
return result.Map(parsePort(raw), listenAddr).MapErr(describeAsPortFailure)
|
|
}
|
|
|
|
// Example_flow shows the point of the combinators: a computation that can fail
|
|
// at two different steps reads as one expression, with no `if err != nil`
|
|
// between them. Nothing inspects the error along the way — a failure simply
|
|
// skips the rest of the chain until UnwrapOr supplies a value.
|
|
func Example_flow() {
|
|
for _, raw := range []string{"8080", "80", "not-a-number"} {
|
|
port := parsePort(raw).
|
|
Filter(unprivileged, "privileged port").
|
|
UnwrapOr(defaultPort)
|
|
|
|
fmt.Println(raw, "->", port)
|
|
}
|
|
// Output:
|
|
// 8080 -> 8080
|
|
// 80 -> 3000
|
|
// not-a-number -> 3000
|
|
}
|
|
|
|
// Example_flowFallback shows Or expressing a precedence order over several
|
|
// config sources: the first that succeeds wins, and the errors of the sources
|
|
// it displaces are discarded.
|
|
func Example_flowFallback() {
|
|
var (
|
|
fromFlag = result.Failf[int]("no --port given")
|
|
fromEnv = parsePort("9000")
|
|
fromFile = parsePort("8080")
|
|
)
|
|
|
|
port := fromFlag.Or(fromEnv).Or(fromFile).UnwrapOr(defaultPort)
|
|
|
|
fmt.Println(port)
|
|
// Output:
|
|
// 9000
|
|
}
|
|
|
|
// Example_flowTypeChange shows where a chain has to break. listenAddr turns an
|
|
// int into a string, and Go cannot express that as a method, so the crossing is
|
|
// written as result.Map — and the chain resumes on its result.
|
|
func Example_flowTypeChange() {
|
|
addr := result.Map(parsePort("8080"), listenAddr).
|
|
Filter(isLocal, "not a local address")
|
|
|
|
fmt.Println(addr.UnwrapOr("<unset>"))
|
|
// Output:
|
|
// localhost:8080
|
|
}
|
|
|
|
// Example_flowNamedCrossing shows the alternative to breaking a chain at a type
|
|
// change: name the crossing instead. Compare Example_flowTypeChange, which calls
|
|
// result.Map at the call site — here the call site is one unbroken chain, and
|
|
// the failure arrives carrying the context listenAddrOf added.
|
|
func Example_flowNamedCrossing() {
|
|
for _, raw := range []string{"8080", "not-a-number"} {
|
|
addr := listenAddrOf(raw).
|
|
Filter(isLocal, "not a local address").
|
|
UnwrapOr("<unset>")
|
|
|
|
fmt.Println(raw, "->", addr)
|
|
}
|
|
|
|
failed := listenAddrOf("nope")
|
|
fmt.Println("context added:", strings.HasPrefix(failed.Err().Error(), "resolve listen port:"))
|
|
// Output:
|
|
// 8080 -> localhost:8080
|
|
// not-a-number -> <unset>
|
|
// context added: true
|
|
}
|
|
|
|
// Example_flowBoundary shows the failure path. Nothing in the chain handles the
|
|
// error: it propagates untouched from the step that produced it, collects
|
|
// context on the way past, and only surfaces where the chain ends — here at
|
|
// Expect, which result.Run turns back into an ordinary Go error.
|
|
//
|
|
// AndThen carries the rejection rather than Filter, because the message names
|
|
// the value it rejected and the chain offers no binding to read it from.
|
|
func Example_flowBoundary() {
|
|
err := result.Run(func() {
|
|
port := parsePort("80").
|
|
AndThen(rejectPrivileged).
|
|
MapErr(describeAsPortFailure).
|
|
Expect("start server")
|
|
|
|
fmt.Println("listening on", port)
|
|
})
|
|
|
|
fmt.Println("failed:", err)
|
|
// Output:
|
|
// failed: start server
|
|
// resolve listen port: port 80 is privileged
|
|
}
|