trails

package module
v0.10.17 Latest Latest
Warning

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

Go to latest
Published: Dec 17, 2024 License: MIT Imports: 13 Imported by: 2

README ΒΆ

Go on the Trails

MIT license Go Reference

What's Trails

Trails unifies the patterns and solutions XY Planning Network developed to power a handful of web applications. We at XYPN prefer the slower method of walking the trails and staying closer to the dirt over something speedier on the road. Nevertheless, Trails has opinions and removes boilerplate when it can. Trails will be in v0 for the foreseeable future.

Trails provides libraries for quickly building web applications that have standard, well-defined web application needs, such as managing user sessions or routing based on user authorization. It defines the concepts needed for solving those problems through interfaces and provides default implementations of those so development can begin immediately.

So, what's in here?

ranger/

A trails app is set managed and guided by a *ranger.Ranger. A *ranger.Ranger composes the different tools trails makes available and provides opinionated defaults. It is as simple as:

package main

import (
        "github.com/xy-planning-network/trails/resp"
        "github.com/xy-planning-network/trails/ranger"
)

type handler struct {
        *resp.Responder
}

func (h *handler) GetHelloWorld(w http.ResponseWriter, r *http.Request) {
        h.Raw(w, r, Data("Hello, World!"))
}

func main() {
        rng := ranger.New()

        h := &handler{rng.Responder}

        rng.Handle(router.Route{Method: http.MethodGet, Path: "/", Handler: h})
}
http/

It may be trails' Ranger is too opinionated for your use case. Very well, trails pushes each and every element of a web app into its own module. These can be used on their own as a toolkit, rather than a framework.

In http/ we find trails' web server powered by a router, middleware stack, HTML template rendering, user session management and a high-level declarative API for crafting HTTP responses. Let's get this setup!

http/router

The first thing Trails does is initialize an HTTP router:

package main

import (
        "net/http"

        "github.com/xy-planning-network/trails/http/router"
)

func main() {
        r := router.NewRouter("DEVELOPMENT")
        r.Handle(router.Path{Path: "/", Method: http.MethodGet, Handler: getRoot}) // this and other functions in other examples would be defined elsewhere πŸ˜…
        http.ListenAndServe(":3000", r)
}

Not too useful, yet, just one route at / to direct requests to. But, this shows Trails' router implements http.Handler! We don't want to stray too far away from the standard library.

Let's get a few more routes in there.

Trails' router encourages registering routes in logically similar groups.

package main

import (
        "net/http"

        "github.com/xy-planning-network/trails/http/router"
)

func main() {
        r := router.NewRouter("DEVELOPMENT")
        base := []router.Route{
                {Path: "/login", Method: http.MethodGet, Handler: getLogin},
                {Path: "/logoff", Method: http.MethodGet, Handler: getLogoff},
                {Path: "/password/reset", Method: http.MethodGet, Handler: getPasswordReset},
                {Path: "/password/reset", Method: http.MethodPost, Handler: resetPassword},
        }
        r.HandleRoutes(base)

        http.ListenAndServe(":3000", r)
}

πŸŽ‰ We did it! Our Trails app serves up 4 distinct routes. πŸŽ‰

It is often the case that many routes for a web server share identical middleware stacks, which aid in directing, redirecting, or adding contextual information to a request. It is also often the case that small errors can lead to registering a route incorrectly, thereby unintentionally exposing a resource or not collecting data necessary for actually handling a request.

The example above does not utilize any middleware, which can be quickly rectified by using Trails' middleware library:

package main

import (
        "github.com/xy-planning-network/trails/http/middleware"
        "github.com/xy-planning-network/trails/http/router"
)

func main() {
        r := router.NewRouter("DEVELOPMENT")
        r.OnEveryRequest(middleware.InjectIPAddress())

        policies := []router.Route{
                {Path: "/terms", Method: http.MethodGet, Handler: getTerms},
                {Path: "/privacy-policy", Method: http.MethodGet, Handler: getPrivacyPolicy},
        }
        r.HandleRoutes(policies)

        base := []router.Route{
                {Path: "/login", Method: http.MethodGet, Handler: getLogin},
                {Path: "/logoff", Method: http.MethodGet, Handler: getLogoff},
                {Path: "/password/reset", Method: http.MethodGet, Handler: getPasswordReset},
                {Path: "/password/reset", Method: http.MethodPost, Handler: resetPassword},
        }
        r.HandleRoutes(
                base,
                middleware.LogRequest(logger.DefaultLogger()),
        )
}

We've added middlewares in two places in two different ways.

First, we use Router.OnEveryRequest to set a middleware that grabs the originating request's IP address on every single Route. Next, we include a middleware that logs the request when we also register or base routes. This logger will run only when a request matches one of those base routes.

Let's start getting fancy 🍸.

In our Trails app, we don't want our users who've already logged in to access neither the login page or password reset page - they should only be able to reset their password from a settings page. Furthermore, only authenticated users should be able to access the logoff endpoint. We can use Trails baked-in support for authentication to reorganize our routing:

package main

import (
        "github.com/xy-planning-network/trails/http/middleware"
        "github.com/xy-planning-network/trails/http/router"
)

func main() {
        env := "DEVELOPMENT"

        sessionstore := session.NewStoreService(env, "ABCD", "ABCD") // Read more about me in http/session

        r := router.NewRouter(env)
        r.OnEveryRequest(
                middleware.InjectIPAddress(),
                middleware.InjectSession(sessionstore, πŸ—), // πŸ—: read more about managing keys used for a *http.Request.Context in http/ctx
        )

        policies := []router.Route{
                {Path: "/terms", Method: http.MethodGet, Handler: getTerms},
                {Path: "/privacy-policy", Method: http.MethodGet, Handler: getPrivacyPolicy},
        }
        r.HandleRoutes(policies)

        unauthed := []router.Route{
                {Path: "/login", Method: http.MethodGet, Handler: getLogin},
                {Path: "/password/reset", Method: http.MethodGet, Handler: getPasswordReset},
                {Path: "/password/reset", Method: http.MethodPost, Handler: resetPassword},
        }
        r.UnauthedRoutes(πŸ—, unauthed)

        authed := []router.Route{
                {Path: "/logoff", Method: http.MethodGet, Handler: getLogoff},
                {Path: "/settings", Method: http.MethodGet, Handler: getSettings},
                {Path: "/settings", Method: http.MethodPut, Handler: updateSettings},
        }
        r.AuthedRoutes(πŸ—, "/login", "/logoff", authed)
}

Organizing routes around middleware stacks, especially those relating to authentication and authorization, can aid in eliminating subtle bugs.

http/resp

Given the Router directed a request correctly, Trails provides a high-level API for crafting responses in an HTTP handler. An HTTP handler uses a Responder to join together application-wide configuration and handler-specific needs. This standardizes responses across the web app enabling clients to rely on the HTTP headers, status codes, data schemas, etc. coming from Trails. We initialize a Responder using functional options and make that available to all our handlers:

package main

import (
	"embed"
	"net/http"

        "github.com/xy-planning-network/trails/http/resp"
	"github.com/xy-planning-network/trails/http/template"
)

//go:embed *.tmpl
var files embed.FS

type handler struct {
        *resp.Responder
}

func (h *handler) getLogin(w http.ResponseWriter, r *http.Request) {
        h.Html(w, r, resp.Tmpl("root.tmpl"))
}

func main() {
        p := template.NewParser(files) // Read more about me in http/template
        d := resp.NewResponder(resp.WithParser(p))
        h := &handler{d}
        r := router.NewRouter("DEVELOPMENT")
        r.Handle(router.Route{Path: "/", Method: http.MethodGet, Handler: r.getLogin})
}

Let's elide over the use of embed and trails/http/template for now in order to focus on this line in our handler:

h.Html(w, r, resp.Tmpl("root.tmpl"))

With the *resp.Responder embedded in our handler, we can utilize it's Html method to render HTML templates and respond with that data. Using a resp.Fn, we set the template to render. If that template needs some additional values, we can provide those with resp.Data:

func (h *handler) getLogin(w http.ResponseWriter, r *http.Request) {
        hello := map[string]any{"welcomeMsg": "Hello, World!"}
        err := h.Html(w, r, resp.Tmpl("root.tmpl"), resp.Data(hello))
        if err != nil {
                h.Err(w, r, err)
                return
        }
}

These resp.Fn functional options are highly flexible. Some are generic - such as resp.Code. Some compose together multiple options - such as resp.GenericErr. Even more, some are specialized - such as resp.Props - for apps leveraging the full suite of features available in Trails.

Notably, a Responder concludes the lifecycle of an HTTP request by writing a response in one of these ways:

  • Err
    • *Responder.Err provides a backstop for malformed calls to *Responder methods by wrapping std lib's http.Error.
  • Html
    • *Responder.Html renders templates written in Go's html/template syntax.
  • Json
    • *Responder.Json renders data in JSON format.
  • Redirect
    • *Responder.Redirect redirects a request to another endpoint, a wrapper around http.Redirect.

Trees

Trails integrates with XYPN's open-source Vue component library, Trees, in two quick steps. Setup is as simple as defining the path to your base Vue template, passing in the path to your base Vue template using resp.WithVueTemplate, and include that template with resp.Vue when using resp.(*Responder).Html.

What needs to be done?

  • Database connection
  • Database migrations
  • Routing
  • Middlewares
  • Response handling
  • Session management
  • Form scaffolding
  • Vue 3 integrations
  • Logging
  • Authentication/Authorization
  • Parsing + sending emails

HELP πŸ”₯πŸ”₯πŸ”₯

  • My web server just keeps send 200s and nothing else!
    • All examples have been tested (minus bugs!) and so use the convenience of not checking the error a *http.Responder method may return. When in doubt, start handling those errors. Instead of:
        func myHandler(w http.ResponseWriter, r *http.Request) {
          Html(w, r, Tmpls("my-root.tmpl"))
        }
      
      try
        func myHandler(w http.ResponseWriter, r *http.Request) {
          if err := Html(w, r, Tmpls("my-root.tmpl")); err != nil {
            Err(w, r, err)
          }
        }
      

Pioneers

Below are "pioneers" who make our work easier and deserve more credit than just an import in the go.mod:

Documentation ΒΆ

Index ΒΆ

Constants ΒΆ

View Source
const (
	LogKindKey = "kind"
	LogMaskVal = "xxxxxx"
)

Variables ΒΆ

View Source
var (
	ErrBadConfig   = errors.New("bad config")
	ErrMissingData = errors.New("missing data")
	ErrNotExist    = errors.New("not exist")
	ErrNotValid    = errors.New("invalid")
)
View Source
var (
	AppLogKind    = slog.StringValue("app")
	HTTPLogKind   = slog.StringValue("http")
	WorkerLogKind = slog.StringValue("worker")

	// MaskedLogValue is a convenience [golang.org/x/exp/slog.Value]
	// to be used in implementations of [golang.org/x/exp/slog.LogValuer]
	// to hide sensitive data from log messages.
	MaskedLogValue = slog.StringValue(LogMaskVal)
)

Functions ΒΆ

func EnvVarOrBool ΒΆ added in v0.6.0

func EnvVarOrBool(key string, def bool) bool

EnvVarOrBool gets the environment variable for the provided key and returns whether it matches "true" or "false" (after lower casing it) or the default value.

func EnvVarOrDuration ΒΆ added in v0.6.0

func EnvVarOrDuration(key string, def time.Duration) time.Duration

EnvVarOrDuration gets the environment variable for the provided key, parses it into a time.Duration, or, returns the default time.Duration.

func EnvVarOrInt ΒΆ added in v0.6.1

func EnvVarOrInt(key string, def int) int

EnvVarOrInt gets the environment variable for the provided key, creates an int from the retrieved value, or returns the provided default if the value is not a valid int.

func EnvVarOrLogLevel ΒΆ added in v0.6.0

func EnvVarOrLogLevel(key string, def slog.Level) slog.Level

EnvVarOrLogLevel gets the environment variable for the provided key, creates a log/slog.Level from the retrieved value, or returns the provided default log/slog.Level.

func EnvVarOrString ΒΆ added in v0.6.0

func EnvVarOrString(key, def string) string

EnvVarOrString gets the environment variable for the provided key or the provided default string.

func EnvVarOrURL ΒΆ added in v0.6.0

func EnvVarOrURL(key, def string) *url.URL

EnvVarOrURL gets the environment variable for the provided or the provided default *url.URL.

func NewAppPropsContext ΒΆ added in v0.10.16

func NewAppPropsContext(ctx context.Context, props AppProps) context.Context

NewAppPropsContext adds props to ctx, returning the resulting context. If props have already been added to ctx, it's key-value pairs are added to existing ones. If any keys collide, those in props overwrite previous values.

func NewLogLevel ΒΆ added in v0.7.0

func NewLogLevel(val string) slog.Level

NewLogLevel translates val into a golang.org/x/exp/slog.Level

Types ΒΆ

type AccessState ΒΆ

type AccessState string

AccessState is a string representation of the broadest, general access an entity such as an Account or a User has to a trails application.

const (
	AccessGranted     AccessState = "granted"
	AccessInvited     AccessState = "invited"
	AccessRevoked     AccessState = "revoked"
	AccessVerifyEmail AccessState = "verify-email"
)

func (AccessState) String ΒΆ

func (as AccessState) String() string

String stringifies the AccessState.

String implements fmt.Stringer.

type Account ΒΆ

type Account struct {
	Model
	AccessState    AccessState `json:"accessState"`
	AccountOwnerID uint        `json:"accountOwnerId"`

	// Associations
	AccountOwner *User  `json:"accountOwner,omitempty"`
	Users        []User `json:"users,omitempty"`
}

An Account is a way many Users access a trails application and can be related to one another.

An Account has many Users. An Account has one User designated as the owner of the Account.

type AppProps ΒΆ added in v0.10.16

type AppProps map[string]any

An AppProps passes data from the server to the client as a set of props needed for general application state. The data is passed around in a context.Context and rendered as JSON. The data is expected to be marshaled into Vue/JS props.

NB: Data not representable by JSON will create errors; review encoding/json.Marshaler.

func AppPropsFromContext ΒΆ added in v0.10.16

func AppPropsFromContext(ctx context.Context) AppProps

AppPropsFromContext retrieves an AppProps in ctx. If not already set, it initializes a new AppProps.

type DeletedTime ΒΆ

type DeletedTime struct {
	sql.NullTime
}

DeletedTime is a nullable timestamp marking a record as soft deleted.

func (DeletedTime) DeleteClauses ΒΆ

func (DeletedTime) DeleteClauses(f *schema.Field) []clause.Interface

Implements GORM-specific interfaces for modifying queries when DeletedTime is valid cf.: - https://github.com/go-gorm/gorm/blob/8dde09e0becd383bc24c7bd7d17e5600644667a8/soft_delete.go

func (DeletedTime) IsDeleted ΒΆ

func (dt DeletedTime) IsDeleted() bool

IsDeleted asserts whether the record is soft deleted.

func (DeletedTime) QueryClauses ΒΆ

func (DeletedTime) QueryClauses(f *schema.Field) []clause.Interface

func (DeletedTime) UpdateClauses ΒΆ

func (DeletedTime) UpdateClauses(f *schema.Field) []clause.Interface

type Environment ΒΆ added in v0.6.0

type Environment string

An Environment is a different context in which a trails app operates.

const (
	Demo        Environment = "DEMO"
	Development Environment = "DEVELOPMENT"
	Production  Environment = "PRODUCTION"
	Review      Environment = "REVIEW"
	Staging     Environment = "STAGING"
	Testing     Environment = "TESTING"
)

func EnvVarOrEnv ΒΆ added in v0.6.0

func EnvVarOrEnv(key string, def Environment) Environment

EnvVarOrEnv gets the environment variable for the provided key, casts it into an Environment, or returns the provided default Environment if key is not a valid Environment.

func (Environment) CanUseServiceStub ΒΆ added in v0.6.0

func (e Environment) CanUseServiceStub() bool

CanUseServiceStub asserts whether the Environment allows for setting up with stubbed out services, for those services that support stubbing.

func (Environment) IsDemo ΒΆ added in v0.6.0

func (e Environment) IsDemo() bool

func (Environment) IsDevelopment ΒΆ added in v0.6.0

func (e Environment) IsDevelopment() bool

func (Environment) IsProduction ΒΆ added in v0.6.0

func (e Environment) IsProduction() bool

func (Environment) IsReview ΒΆ added in v0.6.0

func (e Environment) IsReview() bool

func (Environment) IsStaging ΒΆ added in v0.6.0

func (e Environment) IsStaging() bool

func (Environment) IsTesting ΒΆ added in v0.6.0

func (e Environment) IsTesting() bool

func (Environment) String ΒΆ added in v0.6.0

func (e Environment) String() string

func (Environment) ToolboxEnabled ΒΆ added in v0.6.0

func (e Environment) ToolboxEnabled() bool

ToolboxEnabled asserts whether the Environment enables the client-side toolbox.

func (Environment) Valid ΒΆ added in v0.6.0

func (e Environment) Valid() error

type Key ΒΆ added in v0.6.1

type Key string
const (

	// CurrentUserKey stashes the currentUser for a session.
	CurrentUserKey Key = "CurrentUserKey"

	// IpAddrKey stashes the IP address of an HTTP request being handled by trails.
	IpAddrKey Key = "IpAddrKey"

	// RequestIDKey stashes a unique UUID for each HTTP request.
	RequestIDKey Key = "RequestIDKey"

	// SessionKey stashes the session associated with an HTTP request.
	SessionKey Key = "SessionKey"

	// SessionIDKey stashes a unique UUID for each session.
	SessionIDKey Key = "SessionIDKey"
)

func (Key) String ΒΆ added in v0.6.1

func (k Key) String() string

String formats the stringified key with additional contextual information

type Model ΒΆ

type Model struct {
	ID        uint        `json:"id"`
	CreatedAt time.Time   `json:"createdAt"`
	UpdatedAt time.Time   `json:"updatedAt"`
	DeletedAt DeletedTime `json:"deletedAt"`
}

A Model is the essential data points for primary ID-based models in a trails application, indicating when a record was created, last updated and soft deleted.

type Tool ΒΆ added in v0.6.1

type Tool struct {
	Actions []ToolAction `json:"actions"`
	Title   string       `json:"title"`
}

A Tool is a set of actions grouped under a category. A Tool may pertain to a part of the domain, grouping actions touching similar models.

func (Tool) Render ΒΆ added in v0.6.1

func (t Tool) Render() bool

Render asserts whether the Tool should be rendered.

type ToolAction ΒΆ added in v0.6.1

type ToolAction struct {
	Name string `json:"name"`
	URL  string `json:"url"`
}

A ToolAction is a specific link the end user can follow to execute the named action.

type Toolbox ΒΆ added in v0.6.1

type Toolbox []Tool

A Toolbox is a set of Tools exposed to the end user in certain environments, notably, not in Production. Generally, these are administrative tools that simplify demonstrating features which would otherwise require actions taken in many steps.

func (Toolbox) Filter ΒΆ added in v0.6.1

func (t Toolbox) Filter() Toolbox

Filter returns a Toolbox after removing all Tools that cannot be rendered. If none can be rendered, Filter returns a zero-value Toolbox.

type User ΒΆ

type User struct {
	Model
	AccessState AccessState `json:"accessState"`
	AccountID   uint        `json:"accountId"`
	Email       string      `json:"email"`
	ExternalID  uuid.UUID   `json:"externalId"`
	Password    []byte      `json:"-"`

	// Associations
	Account *Account `json:"account,omitempty"`
}

A User is the core entity that interacts with a trails application.

An agent's HTTP requests are authenticated first by a specific request with email & password data matching credentials stored on a DB record for a User. Upon a match, a session is created and stored. Further requests are authenticated by referencing that session.

A User has one Account.

func (User) GetID ΒΆ

func (u User) GetID() uint

func (User) HasAccess ΒΆ

func (u User) HasAccess() bool

HasAccess asserts whether the User's properties give it general access to the trails application.

func (User) HomePath ΒΆ

func (u User) HomePath() string

HomePath returns the relative URL path designated as the default resource in the trails applicaiton they can access.

Directories ΒΆ

Path Synopsis
http
middleware
The middleware package defines what a middleware is in trails and a set of basic middlewares.
The middleware package defines what a middleware is in trails and a set of basic middlewares.
resp
Package resp provides a high-level API for responding to HTTP requests.
Package resp provides a high-level API for responding to HTTP requests.
router
Package router defines what an HTTP server is and a default implementation of it.
Package router defines what an HTTP server is and a default implementation of it.
template/templatetest
Package templatetest exposes a mock fs.FS that implements basic file operations.
Package templatetest exposes a mock fs.FS that implements basic file operations.
Package logger provides logging functionality to a trails app by defining the required behavior in Logger and providing an implementation of it with TrailsLogger.
Package logger provides logging functionality to a trails app by defining the required behavior in Logger and providing an implementation of it with TrailsLogger.
Package postgres manages our database connection.
Package postgres manages our database connection.
Package ranger initializes and manages a trails app with sane defaults.
Package ranger initializes and manages a trails app with sane defaults.

Jump to

Keyboard shortcuts

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