resp

package
v0.8.8-dev Latest Latest
Warning

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

Go to latest
Published: Aug 3, 2023 License: MIT Imports: 15 Imported by: 2

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:

  1. JSON responses:
    • respondJSON(w http.ResponseWriter, r *http.Request, status int, data interface{}):
      • responds with JSON-encoded data and the passed in status code
  2. Html responses that employ buildVueResponse to structure the passed in data for vue.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
    • 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
  3. 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 with flashType error.
    • 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

  1. 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.
  2. 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.
  3. Potentially complicated initialization program: need a template.Parser passed into a resp.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: leverage encoding.BinaryMarshaler? Some user-provided function?
  • Should (*Responder).Err wrap http.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 invalid Respond by forgetting to include a terminal method that writes to the http.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

Constants

This section is empty.

Variables

View Source
var (
	ErrBadConfig   = errors.New("improperly configured")
	ErrDone        = errors.New("request ctx done")
	ErrInvalid     = errors.New("invalid")
	ErrMissingData = errors.New("missing data")
	ErrNotFound    = errors.New("not found")
	ErrNoUser      = errors.New("no user")
)

Functions

func WithAdditionalScriptsTemplate added in v0.6.1

func WithAdditionalScriptsTemplate(fp string) func(*Responder)

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

func WithAuthTemplate(fp string) func(*Responder)

WithAuthTemplate sets the template identified by the filepath to use for rendering when a user is authenticated.

Authed requires this option.

func WithContactErrMsg

func WithContactErrMsg(msg string) func(*Responder)

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

func WithErrTemplate(fp string) func(*Responder)

WithErrTemplate sets the template identified by the filepath to use for rendering when an unexpected, unhandled error occurs while

func WithLogger

func WithLogger(log logger.Logger) func(*Responder)

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

func WithParser(p *template.Parser) func(*Responder)

WithParser sets the provided implementation of template.Parser to use for parsing HTML templates.

func WithRootUrl

func WithRootUrl(u string) func(*Responder)

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

func WithUnauthTemplate(fp string) func(*Responder)

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

func WithVueScriptsTemplate(fp string) func(*Responder)

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

func WithVueTemplate(fp string) func(*Responder)

WithVueTemplate sets the template identified by the filepath to use for rendering a Vue client application.

Vue requires this option.

Types

type Fn

type Fn func(Responder, *Response) error

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 Code

func Code(c int) Fn

Code sets the response status code.

func CurrentUser added in v0.4.1

func CurrentUser(u any) Fn

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

func Data(d any) Fn

Data stores the provided value for writing to the client.

Used with Responder.Html and Responder.Json.

func Err

func Err(e error) Fn

Err sets the status code http.StatusInternalServerError and logs the error.

func Flash

func Flash(flash session.Flash) Fn

Flash sets a flash message in the session with the passed in class and msg.

func GenericErr

func GenericErr(e error) Fn

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

func Params(pairs map[string]string) Fn

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

func Success(msg string) Fn

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 Tmpls

func Tmpls(fps ...string) Fn

Tmpls appends to the templates to be rendered.

Used with Responder.Html.

func ToRoot

func ToRoot() Fn

ToRoot calls URL with the Responder's default, root URL.

func Toolbox added in v0.6.1

func Toolbox(toolbox trails.Toolbox) Fn

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

func Url(u string) Fn

Url parses raw the URL string and sets it in the *Response if successful.

Used with Responder.Redirect.

func Vue

func Vue(entry string) Fn

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.

func Warn

func Warn(msg string) Fn

Warn sets a flash warning in the session and logs the warning.

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

func (doer Responder) CurrentUser(ctx context.Context) (any, error)

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

func (doer *Responder) Err(w http.ResponseWriter, r *http.Request, err error, opts ...Fn)

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

func (doer *Responder) Html(w http.ResponseWriter, r *http.Request, opts ...Fn) error

Html composes together HTML templates set in *Responder and configured by Authed, Unauthed, Tmpls and other such calls.

func (*Responder) Json

func (doer *Responder) Json(w http.ResponseWriter, r *http.Request, opts ...Fn) error

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

func (doer *Responder) Redirect(w http.ResponseWriter, r *http.Request, opts ...Fn) error

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.

func (Responder) Session

func (doer Responder) Session(ctx context.Context) (session.Session, error)

Session retrieves the session set in the context as a session.Session.

If WithSessionKey was not called setting up the Responder or the context.Context has no value for that key, ErrNotFound returns.

type ResponderOptFn

type ResponderOptFn func(*Responder)

A ResponderOptFn mutates the provided *Responder in some way. A ResponderOptFn is used when constructing a new Responder.

type Response

type Response struct {
	// contains filtered or unexported fields
}

A Response is the internal object a Responder response method builds while applying all functional options.

Jump to

Keyboard shortcuts

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