ferryhttp

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 12, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

ferry/http

Load an HTTP request's query parameters or header fields into a Go struct.

go get github.com/onhotpath/ferry/driver/http

The package is ferryhttp, because http is a name every caller already has taken.

In a server: bind once, load per request

ferry.Bind compiles the type and hands the source the names it asks for, once. Everything a request supplies of its own arrives in the context instead, so a handler holds the binding and loads through it.

Here that is a middleware, which is the shape most servers already have a place for. It is not exported by this package: it is thirty lines a caller writes for the value their own handlers want.

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 Example_middleware() {
	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
}

That is Example_middleware in example_test.go, imports aside, so go test compiles and runs it. 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 /q rather than a zero value nobody asked for.

One load, with no binding held

ferry.Load takes the same source and does the bind again on each call, which is the shape for a one-off: a URL parsed in a script, a test, a single request outside a serving loop.

func Example() {
	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}
}

That is the Example in the same file. It parses a query string only to be self-contained; in a handler the two calls are r.Context() and r.URL.Query().

Parameter names come from the tags. Each part contributes its own text and nested fields are joined with ., so a field tagged host inside one tagged db reads db.host. Nothing is folded: a query parameter name is any byte sequence, so the name is exactly what the tag spelled.

Slices and maps read what is already there. ?tags=a&tags=b fills a []string, and so does ?tags.0=a&tags.1=b. ?limits.rps=50 fills a map[string]string under the key rps.

Headers, with the same source shape

b, err := ferry.Bind[Tenant](ferryhttp.NewHeaderSource()) // once, at start-up
t, err := b.Load(ferryhttp.WithHeaders(r.Context(), r.Header))

Field names join with - instead of ., and are matched case-insensitively, the way net/http spells them. Headers nest, and the hyphen is how: X-Forwarded-For and X-Forwarded-Proto are the registry's own spelling of a nested x-forwarded object, and this driver reads them as one.

type Forwarded struct {
	For   string `ferry:"for"`
	Proto string `ferry:"proto"`
}

type Request struct {
	Forwarded Forwarded `ferry:"x-forwarded"`
}

A repeated field line is a sequence, exactly as a repeated parameter is: two Accept-Encoding: lines fill a []string with two elements. A single line holding a comma-separated list is one value, because net/http does not split it and neither does this driver.

The context is the only way in

A Source holds no request. It is built once and shared, which is what net/http running every handler in a goroutine of its own requires, and the request travels in the context instead.

A load whose context never went through WithQuery or WithHeaders, or went through one of them carrying a nil url.Values or http.Header, is refused before anything is read:

ferry: opening the plane: plane error: http: no query parameters in the context: put it there with ferryhttp.WithQuery or ferryhttp.WithHeaders

That is a bug in the handler and not in the request, so it is loud. A driver that answered from nothing instead would report every field missing, and a required field would fail for a request that supplied it.

errors.Is(err, ferryhttp.ErrNoQuery) distinguishes it from a malformed request, which is the difference between a 500 and a 400.

A repeated name is a sequence, and never the first value

?sort=name&sort=age into a string field is refused:

ferry: /sort: the driver failed: plane error: http: this name occurs more than once: it occurs 2 times, and this field takes one value

Nothing quietly takes the first. Change the field to a []string, or answer 400 and name the parameter.

The refusal arrives while that field is read, so it carries the field's address and a required field pointed at a repeated name reports one failure rather than two. Which reading a name gets is decided by what the field is, not by what the request holds: ?tags=a&tags=b is two elements into a []string and a refusal into a string, and the same is true one level down, where ?limits.rps=1&limits.rps=2 is a refusal into a map[string]string and two elements into a map[string][]string. A request with two such parameters reports both, one failure per parameter, each carrying its own address.

The positions are the request's own order, because position n is the nth value the name carries.

One position cannot be spelled two ways

?tags=a&tags=b and ?tags.0=a&tags.1=b are two spellings of one sequence, and both load. A request using both for the same position is refused rather than resolved:

ferry: /tags: the driver failed: plane error: http: this name carries a sequence in two spellings at once: position 0 is spelled both by the repetition and by a name of its own, so one of the two values would be lost

The realistic way to hit it is a form with a hidden tags.0 beside a checkbox group named tags.

Only an overlap is refused. ?tags=a&tags=b&tags.2=c extends the sequence and loads as three elements, because position 2 is not claimed twice.

A request claiming more than one position twice is one refusal rather than several, and it names the lowest of them. The same request reads the same way every run, which is what lets a test assert on the line.

Set but empty is not the same as absent

?x= loads as the empty string. x not being in the query at all is a different observation, and required can tell them apart: ferry:"token,required" is satisfied by ?token= and fails when token is absent.

A whole schema can be one value

?q=ferry read into a string is an ordinary handler, and the schema is the string rather than a struct holding one. Such a schema has one address, the root, and the root carries no part for this driver to name it by, so an option names it:

func ExampleRootParam() {
	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
}

ferryhttp.RootField is the header plane's, held to the grammar a field name has and canonicalised like every other field name: RootField("x-request-id") reads X-Request-Id. Without the option the load is refused before the request is looked at, and the refusal names the option that lifts it.

One plane's option given to the other plane's source does not compile, which is why there are two names rather than one. RootParam returns a ferryhttp.QueryOption and RootField a ferryhttp.HeaderOption, while Separator and BytesAs return a ferryhttp.Option, which is both and goes to either constructor.

Two fields cannot share a name

Nesting joins with ., so a field tagged db.host and a nested db/host both want db.host. When that happens the load fails immediately, before anything is read, and names both:

ferry: /db.host: query gives this and /db/host the same name, "db.host", so one of the two would be lost

Rename one of the fields, or widen the join: ferryhttp.Separator("..") keeps db..host and db.host apart. The same option exists on the header source, where the default join is -.

What a header value cannot hold

A header field value may not contain a control character other than a tab, and a leading or trailing space or tab does not survive the wire. Both are properties of HTTP, measured here against a real net/http client and server rather than read off the specification, and they are the one thing the header plane can carry less of than the query plane. A query parameter has no such limit: any byte sequence survives percent-encoding, in a name and in a value alike.

Neither plane carries type information of its own. Everything is text, and a bool or an int field is parsed out of that text by ferry rather than by the plane.

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. ferryhttp.BytesAs says the plane carries payloads instead, and how they are spelled:

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-----

ferryhttp.Base64 is the spelling, and ferryhttp.Gzip and ferryhttp.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. A load undoes exactly that, and ferryhttp.MaxSize refuses in both directions - on the way out before anything is written, and on the way in once the payload is decompressed.

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 that source reads is a payload, so a string or an int field over it is then a value the field cannot take. Give the fields that are not payloads a source of their own.

Refusals never quote what the request held

Everything this driver reads came off the wire. A refusal names the parameter or field it is about and never the value, so an error may be logged and returned without leaking a token out of a query string or an Authorization header. A parameter name minted by a map is part of the address and does appear, because ferry cannot name the address without it.

There is no way to write back

This package loads only, so ferry.Dump through it does not compile rather than failing at run time. Building an outbound request is a different job and belongs to a package written for it.

More

The package documentation is the reference for every option above, and the design records behind them are in docs/adr/.

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

Examples

Constants

View Source
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.

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func Base64() ferry.Spelling[[]byte, string]

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

func Gzip() ferry.Transform[[]byte]

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

func MaxSize(n int) ferry.Transform[[]byte]

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

func WithHeaders(ctx context.Context, h http.Header) context.Context

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

func WithQuery(ctx context.Context, v url.Values) context.Context

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

func BytesAs(s ferry.Spelling[[]byte, string]) Option

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

func Separator(sep string) Option

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

func (s *Source) Bind(addrs *ferry.AddressSet) (ferry.OpenFunc, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL