gosaas

package module
v0.0.0-...-89c1cf6 Latest Latest
Warning

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

Go to latest
Published: Jul 12, 2022 License: MIT Imports: 28 Imported by: 0

README

This project become StaticBackend and active development is done there.

Build a SaaS app in Go

gosaas Documentation CircleCI Go Report Card Maintainability

In September 2018 I published a book named Build a SaaS app in Go. This project is the transformation of what the book teaches into a library that can be used to quickly build a web app / SaaS and focusing on your core product instead of common SaaS components.

This is under development and API will change.

Migrating to PostgreSQL at the moment.

Usage quick example

You can create your main package and copy the docker-compose.yml. You'll need Redis and PostgreSQL for the library to work.

package main

import (
	"net/http"
	"github.com/dstpierre/gosaas"
	"github.com/dstpierre/gosaas/model"
)

func main() {
	routes := make(map[string]*gosaas.Route)
	routes["test"] = &gosaas.Route{
		Logger:      true,
		MinimumRole: model.RolePublic,
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			gosaas.Respond(w, r, http.StatusOK, "hello world!")
		}),
	}

	mux := gosaas.NewServer(routes)
	http.ListenAndServe(":8080", mux)
}

Than start the docker containers and your app:

$> docker-compose up
$> go run main.go

Than you request localhost:8080:

$> curl http://localhost:8080/test
"hello? world!"

Table of content

Installation

go get github.com/dstpierre/gosaas

What's included

The following aspects are covered by this library:

  • Web server capable of serving HTML templates, static files. Also JSON for an API.
  • Easy helper functions for parsing and encoding type<->JSON.
  • Routing logic in your own code.
  • Middlewares: logging, authentication, rate limiting and throttling.
  • User authentication and authorization using multiple ways to pass a token and a simple role based authorization.
  • Database agnostic data layer. Currently handling PostgreSQL.
  • User management, billing (per account or per user) and webhooks management. [in dev]
  • Simple queue (using Redis) and Pub/Sub for queuing tasks.
  • Cron-like scheduling for recurring tasks.

The in dev part means that those parts needs some refactoring compare to what was built in the book. The vast majority of the code is there and working, but it's not "library" friendly at the moment.

Quickstart

Here's some quick tips to get you up and running.

Defining routes

You only need to pass the top-level routes that gosaas needs to handle via a map[string]*gosaas.Route.

For example, if you have the following routes in your web application:

/task, /task/mine, /task/done, /ping

You would pass the following map to gosaas's NewServer function:

routes := make(map[string]*gosaas.Route)
routes["task"] = &gosaas.Route{
	Logger: true,
	WithDB: true,
	handler: task,
	...
}
routes["ping"] = &gosaas.Route(
	Logger: true,
	Handler: ping,
)

Where task and ping are types that implement http's ServeHTTP function, for instance:

type Task struct{}

func (t *Task) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// you handle the rest of the routing logic in your own code
	var head string
	head, r.URL.Path = gosaas.ShiftPath(r.URL.Path)
	if head =="/" {
		t.list(w, r)
	} else if head == "mine" {
		t.mine(w, r)
	}
	...
}

You may define Task in its own package or inside your main package.

Each route can opt-in to include specific middleware, here's the list:

// Route represents a web handler with optional middlewares.
type Route struct {
	// middleware
	WithDB           bool // Adds the database connection to the request Context
	Logger           bool // Writes to the stdout request information
	EnforceRateLimit bool // Enforce the default rate and throttling limits

	// authorization
	MinimumRole model.Roles // Indicates the minimum role to access this route

	Handler http.Handler // The handler that will be executed
}

This is how you would handle parameterized route /task/detail/id-goes-here:

func (t *Task) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	var head string
	head, r.URL.Path = gosaas.ShiftPath(r.URL.Path)
	if head == "detail" {
		t.detail(w, r)
	}
}

func (t *Task) detail(w http.ResponseWriter, r *http.Request) {
	id, _ := gosaas.ShiftPath(r.URL.Path)
	// id = "id-goes-here
	// and now you may call the database and passing this id (probably with the AccountID and UserID)
	// from the Auth value of the request Context
}
Database

Before 2019/03/17 there's were a MongoDB implementation which has been removed to only support database/sql. PostgreSQL is currently the only supported driver.

The data package exposes a DB type that have a Connection field pointing to the database.

Before calling http.ListenAndServe you have to initialize the DB field of the Server type:

db := &data.DB{}

if err := db.Open(*dn, *ds); err != nil {
	log.Fatal("unable to connect to the database:", err)
}

mux.DB = db

Where *dn and *ds are flags containing "postgres" and "user=postgres password=postgres dbname=postgres sslmode=disable" for example, respectively which are the driver name and the datasource connection string.

This is an example of what your main function could be:

func main() {
	dn := flag.String("driver", "postgres", "name of the database driver to use, only postgres is supported at the moment")
	ds := flag.String("datasource", "", "database connection string")
	q := flag.Bool("queue", false, "set as queue pub/sub subscriber and task executor")
	e := flag.String("env", "dev", "set the current environment [dev|staging|prod]")
	flag.Parse()

	if len(*dn) == 0 || len(*ds) == 0 {
		flag.Usage()
		return
	}

	routes := make(map[string]*gosaas.Route)
	routes["test"] = &gosaas.Route{
		Logger:      true,
		MinimumRole: model.RolePublic,
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			gosaas.Respond(w, r, http.StatusOK, "hello? Worker!")
		}),
	}

	mux := gosaas.NewServer(routes)

	// open the database connection
	db := &data.DB{}

	if err := db.Open(*dn, *ds); err != nil {
		log.Fatal("unable to connect to the database:", err)
	}

	mux.DB = db

	isDev := false
	if *e == "dev" {
		isDev = true
	}

	// Set as pub/sub subscriber for the queue executor if q is true
	executors := make(map[queue.TaskID]queue.TaskExecutor)
	// if you have custom task executor you may fill this map with your own implementation 
	// of queue.taskExecutor interface
	cache.New(*q, isDev, executors)

	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Println(err)
	}

}
Responding to requests

The gosaas package exposes two useful functions:

Respond: used to return JSON:

gosaas.Respond(w, r, http.StatusOK, oneTask)

ServePage: used to return HTML from templates:

gosaas.ServePage(w, r, "template.html", data)
JSON parsing

There a helper function called gosaas.ParseBody that handles the JSON decoding into types. This is a typical http handler:

func (t Type) do(w http.ResponseWriter, r *http.Request) {
	var oneTask MyTask
	if err := gosaas.ParseBody(r.Body, &oneTask); err != nil {
		gosaas.Respond(w, r, http.StatusBadRequest, err)
		return
	}
	...
}
Context

You'll most certainly need to get a reference back to the database and the currently logged in user. This is done via the request Context.

func (t Type) list(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	db := ctx.Value(gosaas.ContextDatabase).(*data.DB)
	auth := ctx.Value(ContextAuth).(Auth)

	// you may use the db.Connection in your own data implementation
	tasks := Tasks{DB: db.Connection}
	list, err := tasks.List(auth.AccountID, auth.UserID)
	if err != nil {
		gosaas.Respond(w, r, http.StatusInternalServerError, err)
		return
	}
	Respond(w, r, http.StatusOK, list)
}

More documentation

You can find a more detailed documentation here: https://dominicstpierre.com/gosaas/

Please ask any questions here or on Twitter @dominicstpierre.

Status and contributing

I'm currently trying to reach a v1 and planning to use this in production with my next SaaS.

If you'd like to contribute I'd be more than happy to discuss, post an issue and feel free to explain what you'd like to add/change/remove.

Here's some aspect that are still a bit rough:

  • Not enough tests.
  • Redis is required and cannot be changed easily, it's also coupled with the queue package.
  • The controller for managing account/user is not done yet.
  • The billing controller will need to be glued.
  • The controllers package should be inside an internal package.
  • Still not sure if the way the data package is written that it is idiomatic / easy to understand.
  • There's no way to have granularity in the authorization, i.e. if /task require model.RoleUser /task/delete cannot have model.RoleAdmin as MinimumRole.

Running the tests

At this moment the tests uses the mem data implementation so you need to run the tests using the mem tag as follow:

$> go test -tags mem ./...

Credits

Thanks to the following packages:

Licence

MIT

Documentation

Overview

Package gosaas contains helper functions, middlewares, user management and billing functionalities commonly used in typical Software as a Service web application.

The primary goal of this library is to handle repetitive components letting you focus on the core part of your project.

You use the NewServer function to get a working server MUX. You need to pass the top level routes to the NewServer function to get the initial routing working.

For instance if your web application handles the following routes:

/task
/task/mine
/task/done
/ping
/ping/stat

You only pass the "task" and "ping" routes to the server. Anything after the top-level will be handled by your code. You will be interested in ShiftPath, Respond, ParseBody and ServePage functions to get started.

The most important aspect of a route is the Handler field which corresponds to the code to execute. The Handler is a standard http.Handler meaning that your code will need to implement the ServeHTTP function.

The remaining fields for a route control if specific middlewares are part of the request life-cycle or not. For instance, the Logger flag will output request information to stdout when enabled.

Index

Examples

Constants

View Source
const (
	// ContextOriginalPath holds the original requested URL.
	ContextOriginalPath key = iota
	// ContextRequestStart holds the request start time.
	ContextRequestStart
	// ContextDatabase holds a reference to a data.DB database connection and services.
	ContextDatabase
	// ContextAuth holds the authenticated user account id and user id.
	ContextAuth
	// ContextMinimumRole holds the minimum role to access this resource.
	ContextMinimumRole
	// ContextRequestID unique ID for the request.
	ContextRequestID
	// ContextRequestDump holds the request data dump.
	ContextRequestDump
	// ContextLanguage holds the request language.
	ContextLanguage
	// ContextContentIsJSON indicates if the request Content-Type is application/json
	ContextContentIsJSON
)

Variables

This section is empty.

Functions

func Authenticator

func Authenticator(next http.Handler) http.Handler

Authenticator middleware used to authenticate requests.

There are 4 ways to authenticate a request: 1. Via an HTTP header named X-API-KEY. 2. Via a querystring parameter named "key=token". 3. Via a cookie named X-API-KEY. 4. Via basic authentication.

For routes with MinimumRole set as model.RolePublic there's no authentication performed.

func Cors

func Cors(next http.Handler) http.Handler

Cors enables calls via remote origin to handle external JavaScript calls mainly.

func ExtractLimitAndOffset

func ExtractLimitAndOffset(r *http.Request) (limit int, offset int)

BUG(dom): This needs more thinking...

func Language

func Language(next http.Handler) http.Handler

Language is a middleware handling the language cookie named "lng".Language

This is used in HTML templates and Go code when using the Translate function. You need to create a language files inside a directory named languagepack (i.e. en.json, fr.json).

func Logger

func Logger(next http.Handler) http.Handler

Logger is a middleware that log requests information to stdout.

If the request failed with a status code >= 300, a dump of the request will be saved into the cache store. You can investigate and replay the request in a development environment using this tool https://github.com/dstpierre/httpreplay.

func ParseBody

func ParseBody(body io.ReadCloser, result interface{}) error

ParseBody parses the request JSON body into a struct.ParseBody

Example usage:

func handler(w http.ResponseWriter, r *http.Request) {
	var task Task
	if err := gosaas.ParseBody(r.Body, &task); err != nil {
		gosaas.Respond(w, r, http.StatusBadRequest, err)
		return
	}
}

func RateLimiter

func RateLimiter(next http.Handler) http.Handler

RateLimiter is a middleware used to prevent too many call in short time span. If the maximum allowed requests per-user is reached it will return a StatusTooManyRequests error.

For clarity if maximum is reached a "Retry-After" HTTP header with the time in second the user will need to wait before sending another request.

func Respond

func Respond(w http.ResponseWriter, r *http.Request, status int, data interface{}) error

Respond return an strruct with specific status as JSON.

If data is an error it will be wrapped in a generic JSON object:

{
	"status": 401,
	"error": "the result of data.Error()"
}

Example usage:

func handler(w http.ResponseWriter, r *http.Request) {
	task := Task{ID: 123, Name: "My Task", Done: false}
	gosaas.Respond(w, r, http.StatusOK, task)
}

func SendWebhook

func SendWebhook(wh data.WebhookServices, event string, data interface{})

SendWebhook posts data to all subscribers of an event.

func ServePage

func ServePage(w http.ResponseWriter, r *http.Request, name string, data interface{})

ServePage will render and respond with an HTML template.ServePage

HTML templates should be saved into a directory named templates.ServePage

Example usage:

func handler(w http.ResponseWriter, r *http.Request) {
	data := HomePage{Title: "Hello world!"}
	gosaas.ServePage(w, r, "index.html", data)
}

func SetStripeKey

func SetStripeKey(key string)

SetStripeKey sets the Stripe Key.

func ShiftPath

func ShiftPath(p string) (head, tail string)

ShiftPath splits the request URL head and tail.

This is useful to perform routing inside the ServeHTTP function.ShiftPath

Example usage:

package yourapp

import (
	"net/http"
	"github.com/dstpierre/gosaas"
)

func main() {
	routes := make(map[string]*gosaas.Route)
	routes["speak"] = &gosaas.Route{Handler: speak{}}
	mux := gosaas.NewServer(routes)
	http.ListenAndServe(":8080", mux)
}

type speak struct{}
func (s speak) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	var head string
	head, r.URL.Path = gosaas.ShiftPath(r.URL.Path)
	if head == "loud" {
		s.scream(w, r)
	} else {
		s.whisper(w, r)
	}
}

func Throttler

func Throttler(next http.Handler) http.Handler

Throttler is a middleware used to throttle and apply rate limit to requests.

This is currently set to 9999 calls limit per day. If the limit is reached the middleware returns an error with the code StatusTooManyRequests.

func Translate

func Translate(lng, key string) template.HTML

Translate finds a key in a language pack file (saved in directory named languagepack) and return the value as template.HTML so it's safe to use HTML inside the language pack file.Translate

The language pack file are simple JSON file named lng.json like en.json:

{
	"lang": "en",
	"keys": [
		{"key": "landing-title", "value": "Welcome to my site"}
	]
}

func Translatef

func Translatef(lng, key string, a ...interface{}) string

Translatef finds a translation key and substitute the formatting parameters.

Types

type Auth

type Auth struct {
	AccountID int64
	UserID    int64
	Email     string
	Role      model.Roles
}

Auth represents an authenticated user.

type Billing

type Billing struct {
	DB *data.DB
}

Billing handles everything related to the billing requests

func (Billing) Convert

func (b Billing) Convert(bc BillingNewCustomer) error

Convert upgrades an existing trial account to a paid account.

func (Billing) Overview

func (b Billing) Overview(accountID int64) (*BillingOverview, error)

func (Billing) ServeHTTP

func (b Billing) ServeHTTP(w http.ResponseWriter, r *http.Request)

func (Billing) Start

func (b Billing) Start(bc BillingNewCustomer) error

type BillingCardData

type BillingCardData struct {
	ID         string `json:"id"`
	Name       string `json:"name"`
	Number     string `json:"number"`
	Month      string `json:"month"`
	Year       string `json:"year"`
	CVC        string `json:"cvc"`
	Brand      string `json:"brand"`
	Expiration string `json:"expiration"`
}

BillingCardData represents a Stripe credit card

type BillingNewCustomer

type BillingNewCustomer struct {
	AccountID   int64
	Email       string
	Plan        string
	StripeToken string
	Coupon      string
	IsPerSeat   bool
	IsYearly    bool
	TrialDays   int
	Quantity    int
}

BillingNewCustomer represents data sent to api for creating a new customer

type BillingOverview

type BillingOverview struct {
	Account        *model.Account    `json:"account"`
	StripeID       string            `json:"stripeId"`
	Plan           string            `json:"plan"`
	IsYearly       bool              `json:"isYearly"`
	IsNew          bool              `json:"isNew"`
	Cards          []BillingCardData `json:"cards"`
	CostForNewUser int               `json:"costForNewUser"`
	CurrentPlan    *data.BillingPlan `json:"currentPlan"`
	Seats          int               `json:"seats"`
	Logins         []model.User      `json:"logins"`
	NextInvoice    *stripe.Invoice   `json:"nextInvoice"`
}

BillingOverview represents if an account is a paid customer or not

type DeleteSubscription

type DeleteSubscription struct {
	Event string `json:"event"`
	URL   string `json:"url"`
}

type Notification

type Notification struct {
	Title     template.HTML
	Message   template.HTML
	IsSuccess bool
	IsError   bool
	IsWarning bool
}

Notification can be used to display alert to the user in an HTML template.

type Route

type Route struct {
	// middleware
	WithDB           bool
	Logger           bool
	EnforceRateLimit bool
	AllowCrossOrigin bool

	// authorization
	MinimumRole model.Roles

	Handler http.Handler
}

Route represents a web handler with optional middlewares.

func NewError

func NewError(err error, statusCode int) *Route

NewError returns a new route that simply Respond with the error and status code.

type Server

type Server struct {
	DB              *data.DB
	Logger          func(http.Handler) http.Handler
	Authenticator   func(http.Handler) http.Handler
	Throttler       func(http.Handler) http.Handler
	RateLimiter     func(http.Handler) http.Handler
	Cors            func(http.Handler) http.Handler
	StaticDirectory string
	Routes          map[string]*Route
}

Server is the starting point of the backend.

Responsible for routing requests to handlers.

func NewServer

func NewServer(routes map[string]*Route) *Server

NewServer returns a production server with all available middlewares. Only the top level routes needs to be passed as parameter.

There's three built-in implementations:

1. users: for user management (signup, signin, authentication, get detail, etc).

2. billing: for a fully functional billing process (converting from free to paid, changing plan, get invoices, etc).

3. webhooks: for allowing users to subscribe to events (you may trigger webhook via gosaas.SendWebhook).

To override default inplementation you simply have to supply your own like so:

routes := make(map[string]*gosaas.Route)
routes["billing"] = &gosaas.Route{Handler: billing.Route}

This would use your own billing implementation instead of the one supplied by gosaas.

Example
routes := make(map[string]*Route)
routes["task"] = &Route{
	Logger:      true,           // enable logging
	MinimumRole: model.RoleFree, // make sure only free user and up can access this route
	Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// normally you would use a custom type that implement ServerHTTP
		// and handle all sub-level route for this top-level route.
		Respond(w, r, http.StatusOK, "list of tasks....")
	}),
}

mux := NewServer(routes)
if err := http.ListenAndServe(":8080", mux); err != nil {
	log.Fatal(err)
}
Output:

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP is where the top level routes get matched with the map[string]*gosaas.Route received from the call to NewServer. Middleware are applied based on the found route properties.

If no route can be found an error is returned.

Static files are served from the "/public/" directory by default. To change this you may set the StaticDirectory after creating the server like this:

mux := gosaas.NewServer(routes)
mux.StaticDirectory = "/files/"

type StripeWebhook

type StripeWebhook struct {
	Event stripe.Event `json:"event"`
}

StripeWebhook is used to grab data sent by Stripe for a webhook

type User

type User struct{}

User handles everything related to the /user requests

func (User) ServeHTTP

func (u User) ServeHTTP(w http.ResponseWriter, r *http.Request)

type ViewData

type ViewData struct {
	Language string
	Role     model.Roles
	Alert    *Notification
	Data     interface{}
}

ViewData is the base data needed for all pages to render.

It will automatically get the user's language, role and if there's an alert to display. You can view this a a wrapper around what you would have sent to the page being redered.

func CreateViewData

func CreateViewData(ctx context.Context, alert *Notification, data interface{}) ViewData

CreateViewData wraps the data into a ViewData type where the language, role and notification will be automatically added along side the data.

type Webhook

type Webhook struct{}

Webhook handles everything related to the /webhooks requests

POST /webhooks -> subscribe to events GET /webhooks -> get the list of subscriptions for current user POST /webhooks/unsub -> remove a subscription

func (Webhook) ServeHTTP

func (wh Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request)

type WebhookData

type WebhookData struct {
	ID   string            `json:"id"`
	Type string            `json:"type"`
	Data WebhookDataObject `json:"data"`
}

WebhookData used when stripe webhook is call

type WebhookDataObject

type WebhookDataObject struct {
	Object WebhookDataObjectData `json:"object"`
}

WebhookDataObject is the container for the object received

type WebhookDataObjectData

type WebhookDataObjectData struct {
	ID           string `json:"id"`
	Customer     string `json:"customer"`
	Subscription string `json:"subscription"`
	Closed       bool   `json:"closed"`
}

WebhookDataObjectData is the object being sent by stripe

Notes

Bugs

  • This needs more thinking...

Directories

Path Synopsis
internal

Jump to

Keyboard shortcuts

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