|
- package capstan
-
- import (
- "context"
- "net/http"
- "strconv"
- "strings"
- "time"
-
- ce "git.destrealm.org/go/capstan/errors"
- "git.destrealm.org/go/capstan/render"
- "git.destrealm.org/go/capstan/status"
- "git.destrealm.org/go/errors"
- "git.destrealm.org/go/logging"
- "github.com/gorilla/websocket"
- )
-
- // Route descriptor ultimately used for constructing chi routes. This
- // encapsulates the actual user code handler and passes in the appropriate
- // arguments.
- type Route struct {
- router *Router
-
- special string
-
- // Name of this handler. This is a symbolic name
- Name string
-
- // Path derived from route descriptor.
- Path string
-
- // CapPath contains the original Capstan-formatted path for this route.
- CapPath string
-
- // BasePath as indicated by the router. This is used by the URL builder but
- // may provide informational context to controllers indicating that this
- // route isn't mounted at the site root.
- BasePath string
-
- // CapBasePath is similar to BasePath with the exception that it contains
- // Capstan-formatted path information.
- CapBasePath string
-
- // MandatoryNoSlash strips any trailing slash from the route and creates a
- // single route with no trailing slash. This enforces the absense of a
- // slash. No redirection may occur from routes with a trailing slash to
- // routes without a slash when flagged with this feature.
- //
- // Mandatory absense of a slash is enforced when the route is defined with a
- // trailing exclamation point, e.g. "/route!". Such routes may also include
- // a trailing slash to ensure the meaning is clearer (e.g. "not slash" or
- // "/route/!").
- MandatoryNoSlash bool
-
- // MandatorySlash is the precise opposite of MandatoryNoSlash: Routes are
- // required to terminate with a trailing slash and no redirection may occur
- // from routes without a slash.
- //
- // Routes with a mandatory slash must terminate with a trailing slash, e.g.
- // "/route/".
- MandatorySlash bool
-
- // OptionalSlash creates two separate routing table entries: One with a
- // trailing slash and one without. If the route ends with a slash, this is
- // considered the route's default state, and requests to this route without
- // a trailing slash will be redirected to a route of the same name with the
- // slash appended. Likewise, the converse is true.
- //
- // Optional slashes may be delineated with a terminating question mark, e.g.
- // "/route/?" for routes that will redirect to a trailing slash or "/route?"
- // for those that do not. The question mark itself is optional for routes
- // defined without a trailing slash and may be omitted, e.g., "/route" and
- // "/route?" define the same behavior. The question mark is advisable to
- // make clear the developer's intention, and may be required in some
- // contexts (such as defining the behavior of an index route).
- //
- // Note: A trailing slash, e.g. "/route/" indicates that the route must
- // terminate with a *mandatory* slash. Hence, a terminating ? is required
- // when defining a default redirection state from "/route" to "/route/",
- // e.g. "/route/?".
- //
- // This section will be clarified in the documentation.
- OptionalSlash bool
-
- // SlashIsDefault indicates that the default route state is to terminate
- // with a slash. The value of this flag is only applicable if OptionalSlash
- // is true.
- SlashIsDefault bool
-
- // RouteTimeout indicates this route should enforce a timeout of the
- // specified duration, in seconds. If ForceTimeout is true, the handler will
- // exit after RouteTimeout seconds. If ForceTimeout is false, then timeout
- // handling must be managed by the controller (and will be ignored
- // otherwise).
- RouteTimeout int
-
- // ForceTimeout will forcibly exit the handler after RouteTimeout seconds or
- // do nothing until the controller endpoint managed by this route exists of
- // its own volition.
- ForceTimeout bool
-
- // Paths for multiple routes that may be handled by the same controller.
- Paths map[string][]string
-
- // ContentType is a convenience mechanism for setting the content type of
- // the route if it is one of text/plain, text/json, or application/json. You
- // should use this instead of setting the headers directly.
- //
- // This value is mostly intended for API endpoints that generate JSON
- // output.
- ContentType string
-
- // Headers used as route defaults.
- Headers http.Header
-
- // Prefix to prepend to the handler's name to limit naming collisions when
- // reverse-mapping URLs.
- Prefix string
-
- // Suffix to append to the handler's name. This will typically be the
- // request method but may be overridden here. If this value isn't set, the
- // request method is used instead.
- Suffix string
-
- // ParamTypes maps parameter names to their type for use by *Param() context
- // functions.
- ParamTypes map[string]string
-
- // Method associated with this route.
- Method string
-
- // Middleware for this route.
- //
- // TODO: Eventually support redeclaration of middleware types such that it
- // accepts a Context. This will require also wrapping external middleware,
- // such as that which ships with chi and others.
- Middleware []func(http.Handler) http.Handler
-
- // Endpoint function.
- Endpoint Endpoint
-
- // Controller reference.
- Controller Controller
-
- // Upgrader for websocket connections.
- Upgrader *websocket.Upgrader
-
- // BeforeResponse call. If defined by the router, this function will be
- // called before any response is issued. If `error` contains a 400- or
- // 500-level error, the response is aborted.
- //
- // This will be nil unless defined by the controller.
- BeforeResponse []func(Context, *Route) error
-
- // AfterResponse call. If defined by the router, this function will be
- // called after every response made to this route. If `error` contains a
- // 400- or 500-level error, its value is returned instead of the value
- // returned by the route's handler.
- //
- // AfterResponse will also receive the error returned by Route.Endpoint.
- //
- // This will be nil unless defined by the controller.
- AfterResponse []func(Context, error) error
-
- // Renderer defines a controller-specific renderer to use for this handler.
- // If this is nil, the global renderer will be used instead.
- Renderer render.Renderer
-
- // Context generation function. This is set by the router.
- contextMaker ContextMaker
- }
-
- // Copy and return a new instance of the current route.
- //
- // Beware: Non-value types are only copied as pointers to their original values.
- // This means that maps local to the Route struct are not copied by value.
- func (rt *Route) Copy() *Route {
- return &Route{
- router: rt.router,
- Name: rt.Name,
- Path: rt.Path,
- CapPath: rt.CapPath,
- BasePath: rt.BasePath,
- MandatoryNoSlash: rt.MandatoryNoSlash,
- MandatorySlash: rt.MandatorySlash,
- OptionalSlash: rt.OptionalSlash,
- SlashIsDefault: rt.SlashIsDefault,
- RouteTimeout: rt.RouteTimeout,
- ForceTimeout: rt.ForceTimeout,
- Paths: rt.Paths,
- ContentType: rt.ContentType,
- Headers: rt.Headers,
- Prefix: rt.Prefix,
- Suffix: rt.Suffix,
- ParamTypes: rt.ParamTypes,
- Method: rt.Method,
- Controller: rt.Controller,
- Middleware: rt.Middleware,
- Endpoint: rt.Endpoint,
- BeforeResponse: rt.BeforeResponse,
- AfterResponse: rt.AfterResponse,
- Renderer: rt.Renderer,
- }
- }
-
- func (rt *Route) ForName() string {
- name := rt.Name
- if rt.Prefix != "" {
- name = rt.Prefix + "." + name
- }
-
- if rt.Suffix == "" {
- if rt.special != "" {
- rt.Suffix = rt.special
- } else {
- rt.Suffix = rt.Method
- }
- }
-
- return name + ":" + strings.ToLower(rt.Suffix)
- }
-
- func (rt *Route) FullPath() string {
- if rt.BasePath == "" || rt.BasePath == "/" {
- return rt.Path
- }
- if rt.BasePath[len(rt.BasePath)-1:] == "/" {
- return rt.BasePath[:len(rt.BasePath)-1] + rt.Path
- }
- return rt.BasePath + rt.Path
- }
-
- // ServeHTTP provides an HTTP handler implementation that makes routes
- // compatible with Go's default HTTP server.
- //
- // This function is somewhat messy and handles the entirety of Capstan's request
- // handling logic. Refactoring is welcome.
- func (rt *Route) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- var err error
-
- ctx := rt.contextMaker(w, r, rt)
-
- if rt.RouteTimeout > 0 {
- ct, cancel := context.WithTimeout(r.Context(),
- time.Second*time.Duration(rt.RouteTimeout))
- defer cancel()
- r = r.WithContext(ct)
- }
-
- // Copy default headers for group.
- if len(rt.Headers) > 0 {
- for k, v := range rt.Headers {
- for _, s := range v {
- w.Header().Add(k, s)
- }
- }
- }
-
- // BeforeResponse acts vaguely like middleware with the exception each
- // handler is isolated from the next and receives a Context instance.
- // BeforeResponse handlers can interrupt the chain by returning an error
- // containing a 4xx-level code or higher.
- if len(rt.BeforeResponse) > 0 {
- for _, fn := range rt.BeforeResponse {
- if err = fn(ctx, rt); err != nil {
- code := errors.Guarantee(err).Code()
- if code >= 400 {
- ctx.SetError(err)
- ctx.SetCode(code)
- goto ERRTYPES
- }
- break
- }
- }
- }
-
- // We don't handle templated errors here, because the only upgrader we
- // currently support is for websocket connections. JSON responses are fine.
- if rt.Upgrader != nil {
- ws, err := rt.Upgrader.Upgrade(w, r, nil)
- if err != nil {
- w.Header().Add("upgrade", "websocket")
- err = status.Code(426)
- ctx.ContentType(ApplicationJSON)
- goto ERRTYPES
- }
- if ws == nil {
- w.Header().Add("upgrade", "websocket")
- err = status.Code(426)
- ctx.ContentType(ApplicationJSON)
- goto ERRTYPES
- } else {
- ctx.SetWebSocket(ws)
- defer ws.Close()
- }
- }
-
- // Previous errors (above) should already be handled with the exception of
- // redirects. If we're already redirecting, ignore the new endpoint content.
- //
- // TODO: Cleanup.
- if redirect, ok := err.(ce.Redirector); ok && redirect.Redirect(rt.ForName()) {
- goto ERRTYPES
- }
-
- // Handle endpoint.
- err = rt.Endpoint(ctx)
-
- // AfterResponse handlers act akin to a post-request middleware that can
- // perform additional processing. AfterResponse handlers CANNOT change the
- // request body being sent to the client, but they can interrupt the control
- // flow by returning redirect codes.
- //
- // AfterResponse handlers are intended to perform any post-processing
- // required immediately following a response. Though it may be possible,
- // they are not intended to manipulate the result itself.
- if err == nil && len(rt.AfterResponse) > 0 {
- for _, fn := range rt.AfterResponse {
- err = fn(ctx, err)
- switch err.(type) {
- case *ce.Redirect, *ce.Internal:
- continue
- case error:
- goto ERRTYPES
- }
- }
- }
-
- ERRTYPES:
- logger := logging.MustInheritLogger("web.errors", "web")
-
- switch err.(type) {
- case Context:
- if ctx.Code() >= 400 {
- rt.router.HTTPError(ctx)
- if e := ctx.HasError(); e != nil {
- logger.Warning(e)
- }
- }
- case *ce.Redirect:
- if e, ok := err.(*ce.Redirect); ok {
- w.Header().Add("Location", e.URL)
- if e.Code != 0 {
- w.WriteHeader(e.Code)
- } else {
- w.WriteHeader(301)
- }
- }
- case *ce.Internal:
- if e, ok := err.(*ce.Internal); ok {
- if e.Redirect(rt.ForName()) {
- // TODO: Should handle parameters and "external" options.
- builder := rt.router.urls.For(e.Controller)
- builder.Params(e.Params)
- s := builder.Encode()
- w.Header().Add("Location", s)
- w.WriteHeader(301)
- }
- }
- case error:
- code := errors.Guarantee(err).Code()
- if code == 0 {
- code = 500
- }
- ctx.SetCode(code)
-
- if ctx.Code() >= 400 {
- rt.router.HTTPError(ctx)
- }
- w.WriteHeader(ctx.Code())
- default:
- if err == nil || ctx.Code() == 0 {
- ctx.SetCode(200)
- }
- w.WriteHeader(ctx.Code())
- }
- }
-
- func (rt *Route) WriteError(ctx Context, err error) {
-
- if strings.HasPrefix(rt.ContentType, "application/json") || strings.HasPrefix(rt.ContentType, "text/json") {
- ctx.Response().Header().Set("Content-Type", rt.ContentType+"; charset=utf-8")
- ctx.Response().WriteHeader(errors.Guarantee(err).Code())
- ctx.Response().Write([]byte(`{"error":"` + err.Error() + `"}`))
- } else {
- ctx.Response().WriteHeader(errors.Guarantee(err).Code())
- ctx.Response().Write([]byte(`<!doctype html>
- <title>` + err.Error() + `</title>
- <body>
- <h1>` + strconv.Itoa(errors.Guarantee(err).Code()) + ": " + err.Error() + `</h1>
- <p>Error while processing your request.</p>
- </body>`))
- }
- }
-
- func responseType(contentType string) ContentType {
- if strings.HasPrefix(contentType, "application/json") {
- return ApplicationJSON
- }
- if strings.HasPrefix(contentType, "text/json") {
- return ApplicationJSON
- }
- if strings.HasPrefix(contentType, "text/plain") {
- return TextPlain
- }
- return TextHTML
- }
|