README
¶
This draft proposes an API for composing HTTP responses through functional options. It focuses on the resp
package and not other packages in trails/http
.
Current State
HTTP responses are highly flexible and context-dependent. However, the std libs http
pkg is too "low-level". We end up with repeated code, duplicated boilerplate, and unintentional bugs from dev errors.
In second-child
& college-try
, convenience methods standardizing common workflows for HTTP responses were created to solve that first problem. There are only a handful, so generally easy to memorize. They are:
- JSON responses:
respondJSON(w http.ResponseWriter, r *http.Request, status int, data interface{})
:- responds with JSON-encoded data and the passed in status code
- Html responses that employ
buildVueResponse
to structure the passed in data forvue.tmpl
:respondUnauthenticated(w http.ResponseWriter, r *http.Request, templateToRender string, data interface{})
:- renders the passed in template using the passed in data by first wrapping it in the
unauthenticated_base.tmpl
- renders the passed in template using the passed in data by first wrapping it in the
respondAuthenticated(w http.ResponseWriter, r *http.Request, templateToRender string, data interface{})
:- renders the passed in template used the passed in data by first wrapping it in the
authenticated_base.tmpl
- renders the passed in template used the passed in data by first wrapping it in the
- Redirect responses:
redirectWithCustomError(w http.ResponseWriter, r *http.Request, err error, data map[string]interface{}, url, flashType, flashMessage string)
:- redirects to the specified url after logging the error and setting the specified flash in the session.
redirectWithGenericError(w http.ResponseWriter, r *http.Request, err error, data map[string]interface{}, url string)
:- calls
redirectWithCustomError
withflashType
error.
- calls
redirectOnSuccess(w http.ResponseWriter, r *http.Request, url, flashMessage string)
:- redirects to the specified url after setting a success flash on the session.
Problem
There are a few instances where we call http.Redirect
directly, but, otherwise, these methods solved all HTTP response needs in the previous two projects. The multi-purpose nature of these functions, nevertheless, leads to an ambiguous API interface. Consider: nil
is a reasonible error
to pass to redirectWithGenericError
. What happens when one does so? To answer that question, one must review the actual function body or documentation (if available).
As well, consider our lock-in into using the unauthenticated_base
and authenticated_base
templates in those projects. Thus far, there is no known use case for not needing these, but the fetters exist. The template render workflow is not trivial and needing to duplicate it simply to support an alternative render path would be a chore rife with potential error.
Solution
A Responder
provides an application-level configuration for how to respond to HTTP requests. With each HTTP response, a Responder
applies fields configured at an application-level, requisite parts of the *http.Request
being responded to, and all functional options to the http.ResponseWriter
provided by the handler.
Example
Let's jump straight into an example showing both short and long forms. In the below example, before redirecting, an error is logged and a flash error is set in the user's session:
// note . import to simplify below example, open question whether this should be the pattern to replicate
import . "github.com/xy-planning-network/trails/resp"
type Handler struct {
// other fields
Responder
}
func (h *Handler) myHandler(w http.ResponseWriter, r *http.Request) {
// some work that declares the diverse identifiers passed into the functional options
// short-form
Redirect(w, r, Url(GetLoginURL), GenericErr(err))
// long-form
Redirect(
w, r,
User(cu),
Err(err),
Url(GetLoginURL),
Code(http.StatusInternalServerError),
Flash(FlashError, ErrorMessage),
)
}
Explanation
The first, short-form uses the convenience method GenericErr
. This way, we need not manually set the user, error, status code, and flash on the response since that functional option does it all for us. The long-form spells out what those options are to get a better look at what we can do with this library.
Under the hood, Redirect
initializes and builds up a *Response
object that stores data passed in from functional options and then, finally, sends a redirect response. For example, Url(u string)
parses the URL in u
and assigns to *Response.url
to ensure a valid URL can be redirected to.
Options that require calling a previous functional option will throw an error
if this requirement has not been met. Each Responder
method leverages a loop to attempt to heal these situations by calling those options throwing errors (in the same order they were passed to the method) again until all issues are resolved or a set of options are left over that require remediation by the caller. If it is kicked off, a warning is logged, allowing a developer to repair the situation before it lands in a production environment.
Notably, all of these functional options can validly be called outside Responder.Do
. Accordingly, error statuses can be inspected by the caller and handled on an option-by-option basis, if so desired. This would be a more advanced usage that ought to be kept in mind while developing this package.
Proposed Response Methods:
These enumerate the ways in which a handler can respond to a request.
Err() // Wrapper around http.Error as a failsafe
Html() // Render HTML templates
Json() // Serialize JSON data specified through functional opts
Redirect() // Redirect URL specified through functional opts
Proposed Functional Options:
These enumerate the ways in which a response method can be configured. I refer the reader to code comments for explanation of the diverse functions.
Authed()
Unauthed()
Code(c int)
Data(d map[string]interface{})
Err(e error)
Flash(class, msg string)
GenericErr(e error)
Params(key, val string)
Props(p map[string]interface{})
Success(msg string)
Tmpls(ts ...string)
User(u domain.User)
Url(u string)
Warn(msg string)
Vue(entry string)
RespondErr(e error)
Trade-offs
- Different order of functional options do not necessarily create the same result. The intention of options aggregating others is to provide conveniences eliminating the need to think through that ordering. But, when those fall short of a use case, a dev may experience a lack of clarity anticipating what the response they compose looks like.
- This new approach contends with a philosophy of having "one way to do things". Calls for an HTTP response could end up looking different (say, different order of functional options) even if producing the exact same result.
- Potentially complicated initialization program: need a
template.Parser
passed into aresp.Responder
Next Steps
- General approach to templates
- Unit tests
- Response methods can return errors; call
(*Responder).Err
instead? - Flash messages
- Create a
(*Responder).Raw
that writes binary data: leverageencoding.BinaryMarshaler
? Some user-provided function? - Should
(*Responder).Err
wraphttp.Error
or instead wrap(*Responder).Redirect
sending back to a root URL? - How to enable an application-wide
initialProps
map that also requires request-dependent (using*http.Request.Context
) values to be populated? -
In it's current draft, it is possible to code out an invalidRespond
by forgetting to include a terminal method that writes to thehttp.ResponseWriter
. This can be solved by having the appropriate methods stored on the underlying*Response
object and calling it after processing all the other options, if it exists, erroring if it does not. An alternative approach is defined below.
Potential changes
❌ Remove needless execution
Compare these two respond
calls:
Do(w, r, Err(e), Props(p), Unauthed(), Tmpls(someParsedTmpl), Html())
Do(w, r, err(e), Props(p), Unauthed, Tmpls(someParsedTmpl), Html)
The second instance omits actually calling functional options that need no initialization. The question here, then, is whether the mixture of, alternatively, executed and referenced functions leads to a confusing API surface. While the compiler and IDE would obviate a developer composing a Do
incorrectly, it may simply look like magic why some options are executed while others are not. Referring to the function body or documentation to clarify that difference is an extra, unnecessary step in the development experience.
Rejected because:
Keeping all functional options as closures simplifies the mental model required to use and develop on the API.
✅ Terminal response as methods on Responder
instead of standalone functions
As mentioned in the trade-offs, one must include the final function that actually writes to the http.ResponseWriter
for that action to occur. Those functions could be proper methods, however.
Compare:
// Note on-the-fly initialization of Responder for clarity's sake; normally would be initialized already
(Responder{}).Do(w, r, Err(e), Props(p), Unauthed(), Tmpls(someParsedTmpl), Html())
(Responder{}).Html(w, r, Err(e), Props(p), Unauthed(), Tmpls(someParsedTmpl))
Instead of a generic Do
function that merely controls building the *Response
and hopes that it's a valid object, Html
, in the above example, would perform those duties and then actually render the templates. At the moment, this would mean three response functions: Html
, Redirect
& Json
replacing Do
.
Adopted because:
Html
, Json
and Redirect
as resp.Fn
terminal options breaks outside what a resp.Fn
does: mutate a *resp.Response
.
Requiring a dev to pick the kind of response they are composing from the outset may help keep it on rails.
Limited to 3 options at the moment, so does not require keeping many methods top of mind.
Use cases
Make explicit a couple of wins, here:
- Instead of needing to log and then execute the response, the err method will take care of logging
- Instead of multiple functions available for the same kind of response, which risk splintering behavior
- Eliminates cruft from calls - i.e., passing in
nil
for parameters not needed in a specific response
http/api
By and large all OK responses in http/api
would go from this:
h.respondJSON(w, r, http.StatusOK, data)
to:
h.Json(w, r, Data(data))
and all not OK statuses require logging would move from:
h.Logger.Error(err.Error(), logCtx)
h.respondJSON(w, r, http.StatusInternalServerError)
to:
h.Json(w, r, Err(err))
Swap out Err(err)
for Code(http.StatusInternalServerError
if no error needs logging.
http/web
Rendering a static page that leverages a base layout template would go from:
h.respondUnauthenticated(w, r, "tmpl/unauthenticated/incident.tmpl", nil)
to:
Html(w, r, Unauthed(), Tmpls("incident.tmpl"))
Rendering a particular page, with a Vue app, would go from:
h.respondAuthenticated(w, r, "tmpl/vue/app.tmpl", h.buildVueResponse("GetDashboard", nil))
to:
Html(w, r, Authed(), Vue("GetDashboard"))
Redirects - especially since these commonly imply error logging and setting flashes - are straightforward as well:
h.redirectWithGenericError(w, r, err, nil, cu.HomePath())
to:
Redirect(w, r, Err(err), Url(cu.HomePath()))
Documentation
¶
Overview ¶
Package resp provides a high-level API for responding to HTTP requests.
The core of the package revolves around the interplay between a Responder, which ought to be configured for broad contexts (i.e., application-wide), and a Response, which lives at the http.Handler level.
Within a handler, a Responder constructs a Response and finally executes a response in one of these ways: - rendering HTML templates - rendering JSON data - redirecting - writing error messages/codes
Responders and Responses draw upon configuration happening through functional options. To avoid needless error throwing, both may silently ignore options that are irrelevant or would put them in invalid states. In some cases, a Responder may emit logs warning of incorrect usage, enabling a developer to remediate these mistakes within their workflow.
However, some incorrect use cannot be fixed and all of the forms of response made available by a Responder (e.g., Html, Json, Redirect) can return meaningful errors.
The Responder is responsible for providing any data a Response may need to do its work correctly. Notably, a Responder contains data on default templates, keys used for an *http.Request.Context, the web app's root URL and so forth. ResponderOptFns carry out configuring a Responder and are unlikely to be used within a handler. Instead, it is expected these feature in a web app's router setup steps.
Index ¶
- Variables
- func WithAdditionalScriptsTemplate(fp string) func(*Responder)
- func WithAuthTemplate(fp string) func(*Responder)
- func WithContactErrMsg(msg string) func(*Responder)
- func WithErrTemplate(fp string) func(*Responder)
- func WithLogger(log logger.Logger) func(*Responder)
- func WithParser(p *template.Parser) func(*Responder)
- func WithRootUrl(u string) func(*Responder)
- func WithUnauthTemplate(fp string) func(*Responder)
- func WithVueScriptsTemplate(fp string) func(*Responder)
- func WithVueTemplate(fp string) func(*Responder)
- type Fn
- func Authed() Fn
- func Code(c int) Fn
- func CurrentUser(u any) Fn
- func Data(d any) Fn
- func Err(e error) Fn
- func Flash(flash session.Flash) Fn
- func GenericErr(e error) Fn
- func Params(pairs map[string]string) Fn
- func Success(msg string) Fn
- func Tmpls(fps ...string) Fn
- func ToRoot() Fn
- func Toolbox(toolbox trails.Toolbox) Fn
- func Unauthed() Fn
- func Url(u string) Fn
- func Vue(entry string) Fn
- func Warn(msg string) Fn
- type Responder
- func (doer Responder) CurrentUser(ctx context.Context) (any, error)
- func (doer *Responder) Err(w http.ResponseWriter, r *http.Request, err error, opts ...Fn)
- func (doer *Responder) Html(w http.ResponseWriter, r *http.Request, opts ...Fn) error
- func (doer *Responder) Json(w http.ResponseWriter, r *http.Request, opts ...Fn) error
- func (doer *Responder) Redirect(w http.ResponseWriter, r *http.Request, opts ...Fn) error
- func (doer Responder) Session(ctx context.Context) (session.Session, error)
- type ResponderOptFn
- type Response
Constants ¶
This section is empty.
Variables ¶
Functions ¶
func WithAdditionalScriptsTemplate ¶ added in v0.6.1
WithAdditionalScriptsTemplate sets the template identified by the filepath to use for rendering alongside Authed and Unauthed templates.
Authed and Unauthed requires this option.
func WithAuthTemplate ¶
WithAuthTemplate sets the template identified by the filepath to use for rendering when a user is authenticated.
Authed requires this option.
func WithContactErrMsg ¶
WithContactErrMsg sets the error message to use for error Flashes.
We recommend using session.ContactUsErr as a template.
func WithErrTemplate ¶ added in v0.6.0
WithErrTemplate sets the template identified by the filepath to use for rendering when an unexpected, unhandled error occurs while
func WithLogger ¶
WithLogger sets the provided implementation of Logger in order to log all statements through it.
If no Logger is provided through this option, a defaultLogger will be configured.
func WithParser ¶
WithParser sets the provided implementation of template.Parser to use for parsing HTML templates.
func WithRootUrl ¶
WithRootUrl sets the provided URL after parsing it into a *url.URL to use for rendering and redirecting
NOTE: If u fails parsing by url.ParseRequestURI, the root URL becomes https://example.com
func WithUnauthTemplate ¶
WithUnauthTemplate sets the template identified by the filepath to use for rendering when a user is not authenticated.
Unauthed requires this option.
func WithVueScriptsTemplate ¶ added in v0.6.1
WithVueTemplate sets the template identified by the filepath to use for rendering additional scripts within a Vue client application.
Vue requires this option.
func WithVueTemplate ¶
WithVueTemplate sets the template identified by the filepath to use for rendering a Vue client application.
Vue requires this option.
Types ¶
type Fn ¶
A Fn is a functional option that mutates the state of the Response.
func Authed ¶
func Authed() Fn
Authed prepends all templates with the base authenticated template and adds resp.user from the session.
If no user can be retrieved from the session, it is assumed a user is not logged in and returns ErrNoUser.
If WithAuthTemplate was not called setting up the Responder, ErrBadConfig returns.
func CurrentUser ¶ added in v0.4.1
CurrentUser stores the user in the *Response.
Used with Responder.Html and Responder.Json. When used with Json, the user is assigned to the "currentUser" key.
func Data ¶
Data stores the provided value for writing to the client.
Used with Responder.Html and Responder.Json.
func GenericErr ¶
GenericErr combines Err() and Flash() to log the passed in error and set a generic error flash in the session using either the string set by WithContactErrMsg or session.DefaultErrMsg.
func Params ¶ added in v0.3.15
Params adds the query parameters to the response's URL. Params appends to rather than overwrite other query parameters.
Used with Responder.Redirect.
func Success ¶
Success sets the status OK to http.StatusOK and sets a session.FlashSuccess flash in the session with the passed in msg.
Used with Responder.Html.
func Toolbox ¶ added in v0.6.1
Toolbox includes the toolbox in the data to be rendered. Toolbox should be called after Data. Toolbox only supports including the provided toolbox in the data if it is map[string]any.
Multiple calls to Toolbox results in merging the trails.Tools together.
func Unauthed ¶
func Unauthed() Fn
Unauthed prepends all templates with the base unauthenticated template. If the first template is the base authenticated template, this overwrites it.
If WithUnauthTemplate was not called setting up the Responder, ErrBadConfig returns.
func Url ¶
Url parses raw the URL string and sets it in the *Response if successful.
Used with Responder.Redirect.
func Vue ¶
Vue structures the provided data alongside default values according to a default schema.
Here's the schema:
{ "entry": entry, "props": { "initialProps": { "baseURL": d.rootUrl, "currentUser": r.user, }, ...key-value pairs set by Data ...key-value pairs set using trails.AppPropsKey }, ...key-value pairs set by Data }
Calls to Data are merged into the required schema in the following way.
At it's simplest, for example, Data(map[string]any{"myProp": "Hello, World"}), will produce:
{ "entry": entry, "props": { "myProp": "Hello, World", "initialProps": { "baseURL": d.rootUrl, "currentUser": r.user, } } }
If the type passed into Data is not map[string]any, Data(myStruct{}), the value is placed under another "props" key, producing:
{ "entry": entry, "props": { "props": myStruct{}, "initialProps": { "baseURL": d.rootUrl, "currentUser": r.user, }, } }
Finally, if values need to be present to template rendering under a specific key, and properties need to be passed in as well, include a map[string]any the "initialProps" key and the two maps will be merged.
Here's how that's done:
data := map[string]any{ "keyForMyTmpl": true, "props": map[string]any{ "myProp": "Hello, World" }, }
Html(Data(data), Vue(entry))
will produce:
{ "entry": entry, "keyForMyTmpl": true "props: { "myProp": "Hello, World", "initialProps": { "baseURL": d.rootUrl, "currentUser": r.user, }, }, }
It is not required to set any keys for pulling additional values out of the *http.Request.Context.
type Responder ¶
type Responder struct {
// contains filtered or unexported fields
}
Responder maintains reusable pieces for responding to HTTP requests. It exposes many common methods for writing structured data as an HTTP response. These are the forms of response Responder can execute:
Html Json Redirect
Most oftentimes, setting up a single instance of a Responder suffices for an application. Meaning, one needs only application-wide configuration of how HTTP responses should look. Our suggestion does not exclude creating diverse Responders for non-overlapping segments of an application.
When handling a specific HTTP request, calling code supplies additional data, structure, and so forth through Fn functions. While one can create functions of the same type, the Responder and Response structs do not expose much - if anything - to interact with.
func NewResponder ¶
func NewResponder(opts ...ResponderOptFn) *Responder
NewResponder constructs a *Responder using the ResponderOptFns passed in.
TODO(dlk): make setting root url required arg? + cannot redirect in err state w/o
func (Responder) CurrentUser ¶
CurrentUser retrieves the user set in the context.
If WithUserSessionKey was not called setting up the Responder or the context.Context has no value for that key, ErrNotFound returns.
func (*Responder) Err ¶
Err wraps http.Error(), logging the error causing the failure state.
Use in exceptional circumstances when no Redirect or Html can occur.
func (*Responder) Html ¶
Html composes together HTML templates set in *Responder and configured by Authed, Unauthed, Tmpls and other such calls.
func (*Responder) Json ¶
Json responds with data in JSON format, collating it from User(), Data() and setting appropriate headers.
When standard 2xx codes are supplied, the JSON schema will look like this:
{ "currentUser": {}, "data": {} }
Otherwise, "currentUser" is elided.
User() calls populate "currentUser" Data() calls populate "data"
func (*Responder) Redirect ¶
Redirect calls http.Redirect, given Url() set the redirect destination. If Url() is not passed in opts, then ToRoot() sets the redirect destination.
The default response status code is 302.
If Code() set the status code to something other than standard redirect 3xx statuses, Redirect overwrites the status code with an appropriate 3xx status code.
type ResponderOptFn ¶
type ResponderOptFn func(*Responder)
A ResponderOptFn mutates the provided *Responder in some way. A ResponderOptFn is used when constructing a new Responder.