trails

package module
v0.5.4 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2022 License: MIT Imports: 6 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

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

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 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 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 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
example/second-example
second-example shows off a simple trails app that utilizes completely custom application code and defaults a user provides additional configuration to.
second-example shows off a simple trails app that utilizes completely custom application code and defaults a user provides additional configuration to.
example/start-here
start-here provides a toy example use of Trails' http stack, focusing on the basics of:
start-here provides a toy example use of Trails' http stack, focusing on the basics of:
example/third-example
third-example provides a more "robust" use of authentication and unauthentication, highlighting trails' flexibility to work with an application's own implementation of important interfaces describing the currentUser concept at the heart of trails.
third-example provides a more "robust" use of authentication and unauthentication, highlighting trails' flexibility to work with an application's own implementation of important interfaces describing the currentUser concept at the heart of trails.
keyring
Package keyring defines how keys in a *http.Request.Context should behave and a way for storing and retrieving those keys for wider use in the application.
Package keyring defines how keys in a *http.Request.Context should behave and a way for storing and retrieving those keys for wider use in the application.
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 and custom configuration.
Package ranger initializes and manages a trails app with sane defaults and custom configuration.

Jump to

Keyboard shortcuts

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