Documentation
¶
Overview ¶
Package ferryhttp loads configuration from an HTTP request into a Go struct: its query parameters, or its header fields.
b, err := ferry.Bind[Filter](ferryhttp.NewQuerySource()) // once, at start-up ... f, err := b.Load(ferryhttp.WithQuery(r.Context(), r.URL.Query())) // per request
A server binds once and loads through the binding on every request: the type a handler reads does not change between requests, and only the values do. ferry.Load takes the same source and is the shape for a one-off, such as a URL parsed in a script.
The request arrives in the context, not in the source ¶
A source is built once, at start-up, and is safe to share across every goroutine net/http runs a handler in. The values it reads belong to one request, so they travel in the context instead: WithQuery puts a request's query parameters there and WithHeaders puts its header fields there. A load whose context carries neither is refused before anything is read, because a handler that forgot the call would otherwise see every field reported missing.
Parameter names come from the tags ¶
Each part of a field's address contributes its own text, and nested fields are joined. A field tagged host inside one tagged db reads db.host as a query parameter and Db-Host as a header field, because the join is "." for one and "-" for the other. Header names are matched the way net/http spells them, which is case-insensitively, so x-request-id and X-Request-Id are one field. Widen either join with Separator when two fields want one name; that collision fails the load before anything is read and names both fields.
A repeated name is a sequence ¶
?tags=a&tags=b fills a []string with two elements, in the order the request carried them, and so do two X-Tags: header lines. One occurrence fills a one-element slice. The same values also read as tags.0=a&tags.1=b, and a request that uses both spellings for one position is refused rather than resolved.
A name occurring more than once is a sequence and nothing else, so reading it into a plain string field is refused while that field is read. Nothing quietly takes the first value.
Which of the two a name gets is decided by the field it is read into and never by the request: ?limits.rps=1&limits.rps=2 is two elements into a map[string][]string and a refusal into a map[string]string.
Set but empty is not the same as absent ¶
?x= loads as the empty string, and x not being in the query at all is a different observation: a field tagged required is satisfied by ?token= and fails when token is absent.
A header field cannot hold every string ¶
A header value may not contain a control character other than a tab, and leading and trailing spaces and tabs do not survive the wire. Query parameters have no such limit: any byte sequence survives, in a name and in a value alike. Nothing else about a value's type survives either trip, because both planes hold text and neither carries type information of its own.
A plane can carry payloads instead of text ¶
A []byte field takes the bytes of the text that arrived, which is what a request holding text means. BytesAs says the plane carries payloads instead, and how they are spelled:
src := ferryhttp.NewHeaderSource(ferryhttp.BytesAs(
ferry.With(ferryhttp.Base64(), ferryhttp.Gzip(), ferryhttp.MaxSize(4<<10))))
Base64 is the spelling and Gzip and MaxSize are payload steps stacked under it. The step written last is closest to the payload and runs first on the way out, so that source caps the payload, compresses it and spells the result as base64, and a load undoes exactly that. MaxSize refuses in both directions, and the outbound refusal happens before anything is written.
A spelling is a fact about the whole plane, because a request carries no type information for a driver to consult. Declare one and every value this source reads is a payload, so a string or an int field over the same source is then a value the field cannot take: give the fields that are not payloads a source of their own.
There is no way to write back ¶
This package loads only. Nothing in it implements ferry.Sink, so ferry.Dump with it does not compile rather than failing at run time. Building an outbound request is a different job, and it belongs to a package written for it.
Values are attacker-supplied, and names are safe to print ¶
Everything this package reads came off the wire. A refusal from it names the parameter or field it is about and never quotes what that name held, so an error may be logged and returned without leaking a token in a query string or an Authorization header. A parameter name minted by a map is part of the address and does appear.
The design records behind these decisions are in docs/adr/.
Example ¶
Example loads a small annotated struct out of one request's query parameters.
The source is built once and is safe to keep in a handler's closure: it holds no request, and the values travel in the context instead. A real handler passes r.Context() and r.URL.Query(); this one parses a query string so that the example is self-contained and runs the same everywhere.
package main
import (
"context"
"fmt"
"net/url"
"github.com/onhotpath/ferry"
ferryhttp "github.com/onhotpath/ferry/driver/http"
)
// Filter is the schema the query example loads, and the names its tags carry are
// the query parameters the driver looks for.
type Filter struct {
Q string `ferry:"q,required"`
Tags []string `ferry:"tags"`
Limit int `ferry:"limit,default=25"`
}
func main() {
src := ferryhttp.NewQuerySource() // once, at start-up
values, err := url.ParseQuery("q=ferry&tags=go&tags=config")
if err != nil {
fmt.Println(err)
return
}
f, err := ferry.Load[Filter](ferryhttp.WithQuery(context.Background(), values), src)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", f)
}
Output: {Q:ferry Tags:[go config] Limit:25}
Example (Headers) ¶
Example_headers loads out of one request's header fields.
Field names are matched the way net/http spells them, which is case-insensitively, so the tag may be written in whatever case reads best.
package main
import (
"context"
"fmt"
"net/http"
"github.com/onhotpath/ferry"
ferryhttp "github.com/onhotpath/ferry/driver/http"
)
// Tenant is the schema the header example loads. A hyphen is how a header
// nests, so x-tenant and its two fields are X-Tenant-Id and X-Tenant-Region.
type Tenant struct {
ID string `ferry:"id,required"`
Region string `ferry:"region,default=eu-west-1"`
}
func main() {
src := ferryhttp.NewHeaderSource()
h := http.Header{}
h.Set("X-Tenant-Id", "acme")
type request struct {
Tenant Tenant `ferry:"x-tenant"`
}
req, err := ferry.Load[request](ferryhttp.WithHeaders(context.Background(), h), src)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", req.Tenant)
}
Output: {ID:acme Region:eu-west-1}
Example (Middleware) ¶
Example_middleware wraps a handler so that every request arrives with its query parameters already loaded into a struct.
The binding is built once, outside the handler, and holds the compiled schema and the names the source computed for it. Each request loads through it with its own query parameters in the context, so nothing per request recomputes what the type already settled.
The second request is the failure path: q is required and is not there, so the wrapped handler is never reached and the refusal carries the address of the parameter it is about rather than a zero value nobody asked for.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/onhotpath/ferry"
ferryhttp "github.com/onhotpath/ferry/driver/http"
)
// Filter is the schema the query example loads, and the names its tags carry are
// the query parameters the driver looks for.
type Filter struct {
Q string `ferry:"q,required"`
Tags []string `ferry:"tags"`
Limit int `ferry:"limit,default=25"`
}
// filterKey is the middleware's own key for the loaded value, unexported and of
// its own type so that nothing else can read or overwrite what it put there.
type filterKey struct{}
// filterFrom reads back what the middleware loaded. A handler reached through it
// always has one, because a request whose filter did not load never gets there.
func filterFrom(ctx context.Context) Filter {
f, _ := ctx.Value(filterKey{}).(Filter)
return f
}
// withFilter is the middleware: it loads a Filter out of every request's query
// parameters and hands it to next in the context.
//
// The binding is built once, by the caller, and this closes over it. Each
// request supplies its own query parameters instead, so the per-request work is
// the load and nothing else.
func withFilter(b *ferry.Binding[Filter], next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, err := b.Load(ferryhttp.WithQuery(r.Context(), r.URL.Query()))
if err != nil {
refuse(w, err)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), filterKey{}, f)))
})
}
// refuse answers a request the load would not build a value for, naming the
// parameter it was about and never quoting what that parameter held.
func refuse(w http.ResponseWriter, err error) {
var located *ferry.Error
if errors.As(err, &located) {
http.Error(w, "bad request at "+located.Address().String(), http.StatusBadRequest)
return
}
http.Error(w, "bad request", http.StatusBadRequest)
}
func main() {
b, err := ferry.Bind[Filter](ferryhttp.NewQuerySource()) // once, at start-up
if err != nil {
fmt.Println(err)
return
}
h := withFilter(b, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%+v", filterFrom(r.Context()))
}))
for _, target := range []string{"/search?q=ferry&tags=go&tags=config", "/search?tags=go"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, nil))
fmt.Println(w.Code, strings.TrimSpace(w.Body.String()))
}
}
Output: 200 {Q:ferry Tags:[go config] Limit:25} 400 bad request at /q
Example (NoRequestInTheContext) ¶
Example_noRequestInTheContext shows the refusal a handler that forgot ferryhttp.WithQuery gets.
It is refused before anything is read, because a load answered from nothing would report every field missing and a required field would fail for a request that supplied it.
package main
import (
"context"
"errors"
"fmt"
"github.com/onhotpath/ferry"
ferryhttp "github.com/onhotpath/ferry/driver/http"
)
// Filter is the schema the query example loads, and the names its tags carry are
// the query parameters the driver looks for.
type Filter struct {
Q string `ferry:"q,required"`
Tags []string `ferry:"tags"`
Limit int `ferry:"limit,default=25"`
}
func main() {
_, err := ferry.Load[Filter](context.Background(), ferryhttp.NewQuerySource())
fmt.Println(errors.Is(err, ferryhttp.ErrNoQuery))
fmt.Println(errors.Is(err, ferry.ErrPlane))
}
Output: true true
Example (RepeatedName) ¶
Example_repeatedName shows what a parameter occurring more than once means.
It is a sequence and never a value that happened to arrive twice, so reading it into a string field is refused rather than silently taking the first.
The two things a handler answering 400 needs are printed here: which parameter it is about, and which refusal it is. The message says the rest, and it never quotes what the parameter held.
package main
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/onhotpath/ferry"
ferryhttp "github.com/onhotpath/ferry/driver/http"
)
func main() {
type page struct {
Sort string `ferry:"sort"`
}
values, err := url.ParseQuery("sort=name&sort=age")
if err != nil {
fmt.Println(err)
return
}
_, err = ferry.Load[page](ferryhttp.WithQuery(context.Background(), values), ferryhttp.NewQuerySource())
var located *ferry.Error
if errors.As(err, &located) {
fmt.Println(located.Address())
}
fmt.Println(errors.Is(err, ferryhttp.ErrRepeated))
}
Output: /sort true
Index ¶
- Constants
- Variables
- func Base64() ferry.Spelling[[]byte, string]
- func Gzip() ferry.Transform[[]byte]
- func MaxSize(n int) ferry.Transform[[]byte]
- func WithHeaders(ctx context.Context, h http.Header) context.Context
- func WithQuery(ctx context.Context, v url.Values) context.Context
- type HeaderOption
- type Option
- type QueryOption
- type Source
Examples ¶
Constants ¶
const HeaderSeparator = "-"
HeaderSeparator is the string nested fields are joined with in a header field name when no Separator is given.
It is what every multi-word field name in the IANA registry already uses: X-Forwarded-For and X-Forwarded-Proto are the registry's own spelling of a nested x-forwarded object.
const QuerySeparator = "."
QuerySeparator is the string nested fields are joined with in a query parameter name when no Separator is given.
A query parameter name may hold any byte, so nothing forces this choice and it is the spelling most APIs already use for a nested field.
Variables ¶
var ErrIllegalName = errors.New("http: this cannot be named in a request")
ErrIllegalName reports an address this driver cannot name in a request at all.
A query parameter name is any byte sequence, so only two addresses reach it there: one with an empty part, and the root address of a schema whose root is a single value, which carries no part to be named by and is refused until RootParam or RootField names it. A header field name is a token, so a name holding a byte no field name may hold reaches it as well, and a root name held to that grammar is one of them. A tagged field is refused before anything is read, and a map key that mints such a name is refused as it is minted.
It wraps ferry.ErrPlane, and it stays reachable under ferry's wrapper, so errors.Is answers for it on what ferry.Load returned.
var ErrNoHeaders = errors.New("http: no header fields in the context")
ErrNoHeaders reports a load through NewHeaderSource whose context carries no header fields, which means WithHeaders was not called or was called with a nil http.Header. It is ErrNoQuery's counterpart and is refused at the same moment for the same reason.
var ErrNoQuery = errors.New("http: no query parameters in the context")
ErrNoQuery reports a load through NewQuerySource whose context carries no query parameters, which means WithQuery was not called or was called with a nil url.Values. A nil one carries nothing a request could have supplied, so it is the same absence and is refused as one; an allocated url.Values holding no parameters is a request that carries none, and loads.
It is the handler's own defect and not the request's: a load that answered from nothing instead would report every field missing, and a required field would fail for a request that supplied it. So it is refused before anything is read.
It wraps ferry.ErrPlane and stays reachable under ferry's wrapper, so errors.Is answers for it on what ferry.Load returned.
var ErrOption = errors.New("http: unusable driver option")
ErrOption reports a driver option this source cannot be built with: a separator that is empty, one holding a byte a header field name may not, or a BytesAs with no spelling in it.
NewQuerySource and NewHeaderSource take options and return no error, so this lands at the first moment the driver is asked for anything, which is before any request is looked at. It wraps ferry.ErrPlane and stays reachable under ferry's wrapper, so errors.Is answers for it on what ferry.Load returned.
var ErrRepeated = errors.New("http: this name occurs more than once")
ErrRepeated reports a name occurring more than once, read into a field that takes a single value.
A name occurring more than once is a sequence: ?tags=a&tags=b is two elements, not one value that happens to have arrived twice. Reading it into a string field would have to discard one of them, so it is refused instead, and the refusal names the field. Change the field to a slice, or reject the request.
It arrives while the field is being read, carries that field's address, and says how many times the name occurred. A request with two such names reports both, one failure per name.
It wraps ferry.ErrPlane and stays reachable under ferry's wrapper.
var ErrTwoSpellings = errors.New("http: this name carries a sequence in two spellings at once")
ErrTwoSpellings reports one sequence position spelled two ways in one request.
A sequence reads either from a repeated name or from index-suffixed names, and ?tags=a&tags=b&tags.0=z uses both for position 0, so one of the two values would be lost. Only an overlap is refused: ?tags=a&tags=b&tags.2=z extends the sequence rather than contradicting it, and loads as three elements.
A request claiming several positions twice is one refusal and not several, and it names the lowest of them, so the same request always reads the same way.
It wraps ferry.ErrPlane and stays reachable under ferry's wrapper.
Functions ¶
func Base64 ¶
Base64 spells a byte payload as base64 text, which is how a []byte field is carried by a plane that holds nothing but text.
src := ferryhttp.NewHeaderSource(ferryhttp.BytesAs(ferryhttp.Base64()))
It reads and writes standard base64 with padding, which is what a header field and a query parameter both survive unchanged. A value spelled in none of it is refused rather than half-decoded, and the refusal names the offset the decoder stopped at and never the text: everything this plane holds came off the wire, so a message quoting it is a token in a log.
Stack payload steps under it with ferry.With:
ferry.With(ferryhttp.Base64(), ferryhttp.Gzip(), ferryhttp.MaxSize(4<<10))
Hand the result to BytesAs, which is where this plane's spelling is declared and where the sharp edge of declaring one is written down.
func Gzip ¶
Gzip compresses a payload on the way out and decompresses it on the way in.
ferry.With(ferryhttp.Base64(), ferryhttp.Gzip(), ferryhttp.MaxSize(4<<10))
It is a payload step and not a spelling, so it runs under whichever spelling carries the bytes: written as above, a dump caps the payload, compresses it and spells the result as base64, and a load undoes exactly that.
Data the plane held that is not a gzip stream, or is one that was cut short, is refused rather than returned half-read.
The sharp edge is on the way in, and it is the reason a size step belongs in the same stack: a compressed payload expands before anything under it sees the result, so a small request can decompress into a large allocation, and MaxSize refuses it only once it is already in memory. Bound what reaches this plane at the server - net/http's own header limit, or http.MaxBytesReader - rather than here.
func MaxSize ¶
MaxSize refuses a payload larger than n bytes, in both directions.
ferry.With(ferryhttp.Base64(), ferryhttp.Gzip(), ferryhttp.MaxSize(4<<10))
It is a payload step and not a spelling, and it is the one that refuses on the way out as well as on the way in: a payload past the budget fails before anything is written, which is where a failure that can be known without touching the plane belongs. On the way in it is the last step to run, so the size it holds is the size of the payload a field is about to be given rather than the size of what arrived on the wire.
The bytes it counts are the bytes at its own position in the stack. Written as above it caps the payload itself, on both sides of the compression; written as ferry.With(ferryhttp.Base64(), ferryhttp.MaxSize(4<<10), ferryhttp.Gzip()) it caps the compressed form instead.
A refusal names both sizes and nothing else, because a size is structure rather than something the plane supplied.
func WithHeaders ¶
WithHeaders returns a context carrying one request's header fields, which is how a load through NewHeaderSource reaches them.
t, err := ferry.Load[Tenant](ferryhttp.WithHeaders(r.Context(), r.Header), src)
The fields are read and never written to. A load whose context did not come through this call, or came through it carrying a nil http.Header, is refused with ErrNoHeaders.
func WithQuery ¶
WithQuery returns a context carrying one request's query parameters, which is how a load through NewQuerySource reaches them.
f, err := ferry.Load[Filter](ferryhttp.WithQuery(r.Context(), r.URL.Query()), src)
The values are read and never written to, so passing r.URL.Query() directly is safe even though it is a fresh map on every call.
A load whose context did not come through this call is refused with ErrNoQuery rather than answered from nothing, and so is one that came through it carrying a nil url.Values.
Types ¶
type HeaderOption ¶
type HeaderOption interface {
// contains filtered or unexported methods
}
HeaderOption is what NewHeaderSource takes.
Every Option is one, and RootField is a HeaderOption and not an Option, for the reason RootParam is a QueryOption. Nothing outside this package can implement this interface.
func RootField ¶
func RootField(name string) HeaderOption
RootField names the header field a schema whose root is a single value is read from.
src := ferryhttp.NewHeaderSource(ferryhttp.RootField("X-Request-Id"))
id, err := ferry.Load[string](ferryhttp.WithHeaders(r.Context(), r.Header), src)
It is RootParam on the header plane, and it is a HeaderOption the same way that one is a QueryOption: NewQuerySource does not take it.
The name is held to what a header field name is - a non-empty run of letters, digits and !#$%&'*+-.^_`|~ - and it is canonicalised the way net/http canonicalises every field name, so RootField("x-request-id") reads the field a request carries as X-Request-Id.
type Option ¶
type Option interface {
QueryOption
HeaderOption
}
Option configures a Source on either plane, so one may be given to NewQuerySource and to NewHeaderSource alike. The set is closed at two: Separator and BytesAs.
The two options that name the root address are not here, because there is one per plane and they are not interchangeable: RootParam is a QueryOption, RootField a HeaderOption, and a source handed the other plane's does not compile.
func BytesAs ¶
BytesAs says this plane carries byte payloads, and how they are spelled.
src := ferryhttp.NewHeaderSource(ferryhttp.BytesAs(
ferry.With(ferryhttp.Base64(), ferryhttp.Gzip(), ferryhttp.MaxSize(4<<10))))
Without it every value this plane holds is text, which a []byte field takes as the bytes of that text. With it every value is a payload spelled the way the spelling says, so a request carrying base64 fills a []byte field with what the base64 decoded to, and a value the spelling has no reading for fails the load at the parameter or field it is about.
The sharp edge is that this is a fact about the whole plane and not about one field, because a request carries no type information for a driver to consult. Every value is read as a payload once this is declared, so a string, an int or a duration field over the same source is then a value the field cannot take: declare it for a source whose every value is a payload, and read the fields that are not through a source of their own.
Build the spelling with Base64, stack payload steps under it with ferry.With, and write your own by implementing ferry.Spelling over a string carrier.
Example ¶
ExampleBytesAs loads a compressed certificate out of one request's query parameters.
The spelling is declared once, with the source, and it is a fact about the whole plane: every value this source reads is a payload spelled that way. The steps under it run innermost first on the way out, so what a client sends is the base64 of the gzip of a payload the size cap already passed, and the load undoes exactly that.
package main
import (
"context"
"fmt"
"net/url"
"github.com/onhotpath/ferry"
ferryhttp "github.com/onhotpath/ferry/driver/http"
)
// Certificate is the schema the payload example loads, and its one field is a
// payload rather than text.
type Certificate struct {
Cert []byte `ferry:"cert"`
}
func main() {
spelling := ferry.With(ferryhttp.Base64(), ferryhttp.Gzip(), ferryhttp.MaxSize(4<<10))
src := ferryhttp.NewQuerySource(ferryhttp.BytesAs(spelling))
// What a client would have put in the query string.
spelled, err := spelling.Render([]byte("-----BEGIN CERTIFICATE-----"))
if err != nil {
fmt.Println(err)
return
}
ctx := ferryhttp.WithQuery(context.Background(), url.Values{"cert": {spelled}})
c, err := ferry.Load[Certificate](ctx, src)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%s\n", c.Cert)
}
Output: -----BEGIN CERTIFICATE-----
func Separator ¶
Separator sets the string nested fields are joined with.
src := ferryhttp.NewQuerySource(ferryhttp.Separator("..")) // db.host reads db..host
It defaults to QuerySeparator for a query source and HeaderSeparator for a header source, and it is the way out when two fields want one name and neither can be renamed: at ".." the fields db.host and the nested db/host stay apart. No separator is safe for every schema, because a field name may contain the separator itself, so whatever is chosen, two fields the join would collapse are still refused before any request is looked at.
For a header source it must be a non-empty run of the bytes a field name may hold: letters, digits and !#$%&'*+-.^_`|~. For a query source it must only be non-empty.
type QueryOption ¶
type QueryOption interface {
// contains filtered or unexported methods
}
QueryOption is what NewQuerySource takes.
Every Option is one, so an option that means the same thing on both planes goes to either constructor. RootParam is a QueryOption and not an Option, because it names one half of a request. Nothing outside this package can implement this interface.
func RootParam ¶
func RootParam(name string) QueryOption
RootParam names the query parameter a schema whose root is a single value is read from.
src := ferryhttp.NewQuerySource(ferryhttp.RootParam("q"))
q, err := ferry.Load[string](ferryhttp.WithQuery(r.Context(), r.URL.Query()), src)
Such a schema has one address, the root, and that address carries no part for this driver to name it by, so without this option it is refused before any request is looked at. It says nothing about any other schema: every address with a part of its own is named by that part as before.
The name is one query parameter and it may not be empty. It is a QueryOption rather than an Option, so NewHeaderSource does not take it: RootField is the header plane's, and giving one plane's to the other is a compile error rather than a root read out of the wrong half of the request.
Example ¶
ExampleRootParam loads a whole schema out of one parameter.
The type resolves to a single value rather than to a struct, so the schema has one address, the root, and that address carries no part for this driver to name it by. The option is what names it, and without one the load is refused before the request is looked at.
package main
import (
"context"
"fmt"
"net/url"
"github.com/onhotpath/ferry"
ferryhttp "github.com/onhotpath/ferry/driver/http"
)
func main() {
src := ferryhttp.NewQuerySource(ferryhttp.RootParam("q"))
ctx := ferryhttp.WithQuery(context.Background(), url.Values{"q": {"ferry"}})
q, err := ferry.Load[string](ctx, src)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(q)
}
Output: ferry
type Source ¶
type Source struct {
// contains filtered or unexported fields
}
Source is one request's query parameters or header fields as a ferry plane, read side.
src := ferryhttp.NewQuerySource() // once, at start-up f, err := ferry.Load[Filter](ferryhttp.WithQuery(r.Context(), r.URL.Query()), src)
It carries no request, which is what makes one Source serve every handler: the values arrive in the context, per load, through WithQuery or WithHeaders. Build one with NewQuerySource or NewHeaderSource; the zero Source has no plane to read and refuses rather than guessing.
A Source is safe for use from many goroutines, which is what net/http running every handler in a goroutine of its own requires. The names it computes for a type are computed once and never written to afterwards, and everything one load needs of its own is allocated when that load starts.
There is no ferryhttp.Sink beside it. This package loads only, so ferry.Dump through it is a compile error at the call site rather than a failure at run time.
func NewHeaderSource ¶
func NewHeaderSource(opts ...HeaderOption) *Source
NewHeaderSource builds a Source over a request's header fields.
src := ferryhttp.NewHeaderSource() t, err := ferry.Load[Tenant](ferryhttp.WithHeaders(r.Context(), r.Header), src)
With no options it joins nested fields with HeaderSeparator. Change that with Separator, and name the field a schema whose root is a single value reads from with RootField.
func NewQuerySource ¶
func NewQuerySource(opts ...QueryOption) *Source
NewQuerySource builds a Source over a request's query parameters.
src := ferryhttp.NewQuerySource() f, err := ferry.Load[Filter](ferryhttp.WithQuery(r.Context(), r.URL.Query()), src)
With no options it joins nested fields with QuerySeparator. Change that with Separator, and name the parameter a schema whose root is a single value reads from with RootParam.
The two constructors take two option types because the option that names the root is one plane's and not the other's. Everything else is an Option and goes to either.
func (*Source) Bind ¶
Bind computes this schema's parameter or field names and checks them, and it is where a schema this plane cannot hold is refused.
Two things are checked, before any request is looked at: that every field has a name on this plane at all, and that no two fields render to the same name. A schema failing either is refused here, in one error naming every offending field along with the one it collided with.
It does no I/O and does not look at the context, so it succeeds whether or not a request has been supplied. A load with no request in its context is refused when that load starts, which is the first moment the absence is visible.