wtf

package module
v0.2.2 Latest Latest
Warning

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

Go to latest
Published: Jan 7, 2021 License: MIT Imports: 4 Imported by: 2

README

WTF Dial GitHub release test deploy

This project provides a real-time dashboard for teams to view how f-cked up they currently are. Each team member provides input to specify the level at which they feel the team is currently messed up. These values range from 0% (meaning team feels there are no WTF situations) to 100% (meaning the members feel the team is completely f-cked).

The idea for this came from Peter Bourgon's tweets.

How to use this repository

This repository was built to help others learn how to build a fully functioning Go application. It can be used in several ways:

  1. As a reference—the code is well documented. Honestly, too documented for most projects but the goal here is to be as clear as possible for anyone reading the code.

  2. As a walkthrough—companion blog posts will be added to the Go Beyond web site that walk through the various parts of the application and explain the design choices. You can find the initial blog post here: https://www.gobeyond.dev/wtf-dial/

  3. Ask questions in the GitHub Discussions board.

You can also see the project structure overview below to get a quick overview of the application structure.

Project structure

The wtf project organizes code with the following approach:

  1. Application domain types go in the root—User, UserService, Dial, etc.
  2. Implementations of the application domain go in subpackages—sqlite, http, etc.
  3. Everything is tied together in the cmd subpackages—cmd/wtf & cmd/wtfd.
Application domain

The application domain is the collection of types which define what your application does without defining how it does it. For example, if you were to describe what WTF Dial does to a non-technical person, you would describe it in terms of Users and Dials.

We also include interfaces for managing our application domain data types which are used as contracts for the underlying implementations. For example, we define a wtf.DialService interface for CRUD (Create/Read/Update/Delete) actions and SQLite does the actual implementation.

This allows all packages to share a common understanding of what each service does. We can swap out implementations, or more importantly, we can layer implementations on top of one another. We could, for example, add a Redis caching layer on top of our database layer without having the two layers know about one another as long as they both implement the same common interface.

Implementation subpackages

Most subpackages are used as an adapter between our application domain and the technology that we're using to implement the domain. For example, sqlite.DialService implements the wtf.DialService using SQLite.

The subpackages generally should not know about one another and should communicate in terms of the application domain.

These are separated out into the following packages:

  • http—Implements services over HTTP transport layer.
  • inmem—Implements in-memory event listener service & subscriptions.
  • sqlite—Implements services on SQLite storage layer.

There is also a mock package which implements simple mocks for each of the application domain interfaces. This allows each subpackage's unit tests to share a common set of mocks so layers can be tested in isolation.

Binary packages

The implementation subpackages are loosely coupled so they need to be wired together by another package to actually make working software. That's the job of the cmd subpackages which produce the final binary.

There are two binaries:

  • wtfd—the WTF server
  • wtf—the client CLI application

Each of these binaries collect the services together in different ways depending on the use case.

The wtfd server binary creates a sqlite storage layer and adds the http transport layer on top. The wtf client binary doesn't have a storage layer. It only needs the client side http transport layer.

The cmd packages are ultimately the interface between the application domain and the operator. That means that configuration types & CLI flags should live in these packages.

Other packages

A few smaller packages don't fall into the organization listed above:

  • csv—implements a csv.DialEncoder for encoding a list of Dial objects to a writer using the CSV format.
  • http/html-groups together HTML templates used by the http package.

Development

You can build wtf locally by cloning the repository and installing the ego templating library.

Then run:

$ make 
$ go install ./cmd/...

The wtfd server uses GitHub for authentication so you'll need to create a new GitHub OAuth App.

Next, you'll need to setup a configuration file in ~/wtfd.conf:

[github]
client-id     = "00000000000000000000"
client-secret = "0000000000000000000000000000000000000000"

[http]
addr      = ":3000"
block-key = "0000000000000000000000000000000000000000000000000000000000000000"
hash-key  = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"

Replace the GitHub client-id & client-secret with the values from the GitHub OAuth application you registered.

The [http] section can be left as-is for a local environment. The key fields need random hex values for generating secure cookies but all zeros is ok for local testing.

Finally, run the wtfd server and open the web site at http://localhost:3000:

$ $GOPATH/bin/wtfd
Storybook

The wtf-storybook binary allows you to test UI views with prepopulated data. This can make it easier to quickly test certain scenarios without needing to set up your backend database.

To run storybook, simply build it and run it:

$ go install ./cmd/wtf-storybook
$ wtf-storybook
Listening on http://localhost:3001

To add a new view, add an entry to the routes variable:

var routes = []*Route{
	// Show dial listing when user has no dials.
	{
		Name: "Dial listing with data",
		Path: "/dials-with-no-data",
		Renderer: &html.DialIndexTemplate{
			Dials: []*wtf.Dial{},
		},
	},
}

Then navigate to https://localhost:3001 and you'll see it displayed in the list.

SQLite

By default, the SQLite tests run against in-memory databases. However, you can specify the -dump flag for the tests to write data out to temporary files. This works best when running against a single test.

$ go test -run=MyTest -dump ./sqlite
DUMP=/tmp/sy9j7nks0zq2vr4s_nswrx8h0000gn/T/375403844/db

You can then inspect that database using the sqlite3 CLI to see its contents.

Contributing

This application is built for educational purposes so additional functionality will likely be rejected. Please feel free to submit an issue if you're interested in seeing something added. Please do not simply submit a pull request.

Documentation

Index

Constants

View Source
const (
	ECONFLICT       = "conflict"
	EINTERNAL       = "internal"
	EINVALID        = "invalid"
	ENOTFOUND       = "not_found"
	ENOTIMPLEMENTED = "not_implemented"
	EUNAUTHORIZED   = "unauthorized"
)

Application error codes.

NOTE: These are meant to be generic and they map well to HTTP error codes. Different applications can have very different error code requirements so these should be expanded as needed (or introduce subcodes).

View Source
const (
	EventTypeDialValueChanged           = "dial:value_changed"
	EventTypeDialMembershipValueChanged = "dial_membership:value_changed"
)

Event type constants.

View Source
const (
	AuthSourceGitHub = "github"
)

Authentication providers. Currently we only support GitHub but any OAuth provider could be supported.

View Source
const (
	DialMembershipSortByUpdatedAtDesc = "updated_at_desc"
)

Dial membership sort options. Only specific sorting options are supported.

View Source
const (
	MaxDialNameLen = 100
)

Dial constants.

Variables

View Source
var (
	Version string
	Commit  string
)

Build version & commit SHA.

View Source
var ReportError = func(ctx context.Context, err error, args ...interface{}) {}

ReportError notifies an external service of errors. No-op by default.

View Source
var ReportPanic = func(err interface{}) {}

ReportPanic notifies an external service of panics. No-op by default.

Functions

func CanDeleteDialMembership

func CanDeleteDialMembership(ctx context.Context, membership *DialMembership) bool

CanDeleteDialMembership returns true if the current user can delete membership.

func CanEditDial

func CanEditDial(ctx context.Context, dial *Dial) bool

CanEditDial returns true if the current user can edit the dial. Only the dial owner can edit the dial.

func CanEditDialMembership

func CanEditDialMembership(ctx context.Context, membership *DialMembership) bool

CanEditDialMembership returns true if the current user can edit membership.

func ErrorCode

func ErrorCode(err error) string

ErrorCode unwraps an application error and returns its code. Non-application errors always return EINTERNAL.

func ErrorMessage

func ErrorMessage(err error) string

ErrorMessage unwraps an application error and returns its message. Non-application errors always return "Internal error".

func FlashFromContext

func FlashFromContext(ctx context.Context) string

FlashFromContext returns the flash value for the current request.

func NewContextWithFlash

func NewContextWithFlash(ctx context.Context, v string) context.Context

NewContextWithFlash returns a new context with the given flash value.

func NewContextWithUser

func NewContextWithUser(ctx context.Context, user *User) context.Context

NewContextWithUser returns a new context with the given user.

func UserIDFromContext

func UserIDFromContext(ctx context.Context) int

UserIDFromContext is a helper function that returns the ID of the current logged in user. Returns zero if no user is logged in.

Types

type Auth

type Auth struct {
	ID int `json:"id"`

	// User can have one or more methods of authentication.
	// However, only one per source is allowed per user.
	UserID int   `json:"userID"`
	User   *User `json:"user"`

	// The authentication source & the source provider's user ID.
	// Source can only be "github" currently.
	Source   string `json:"source"`
	SourceID string `json:"sourceID"`

	// OAuth fields returned from the authentication provider.
	// GitHub does not use refresh tokens but the field exists for future providers.
	AccessToken  string     `json:"-"`
	RefreshToken string     `json:"-"`
	Expiry       *time.Time `json:"-"`

	// Timestamps of creation & last update.
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

Auth represents a set of OAuth credentials. These are linked to a User so a single user could authenticate through multiple providers.

The authentication system links users by email address, however, some GitHub users don't provide their email publicly so we may not be able to link them by email address. It's a moot point, however, as we only support GitHub as an OAuth provider.

func (*Auth) AvatarURL

func (a *Auth) AvatarURL(size int) string

AvatarURL returns a URL to the avatar image hosted by the authentication source. Returns an empty string if the authentication source is invalid.

func (*Auth) Validate

func (a *Auth) Validate() error

Validate returns an error if any fields are invalid on the Auth object. This can be called by the SQLite implementation to do some basic checks.

type AuthFilter

type AuthFilter struct {
	// Filtering fields.
	ID       *int    `json:"id"`
	UserID   *int    `json:"userID"`
	Source   *string `json:"source"`
	SourceID *string `json:"sourceID"`

	// Restricts results to a subset of the total range.
	// Can be used for pagination.
	Offset int `json:"offset"`
	Limit  int `json:"limit"`
}

AuthFilter represents a filter accepted by FindAuths().

type AuthService

type AuthService interface {
	// Looks up an authentication object by ID along with the associated user.
	// Returns ENOTFOUND if ID does not exist.
	FindAuthByID(ctx context.Context, id int) (*Auth, error)

	// Retrieves authentication objects based on a filter. Also returns the
	// total number of objects that match the filter. This may differ from the
	// returned object count if the Limit field is set.
	FindAuths(ctx context.Context, filter AuthFilter) ([]*Auth, int, error)

	// Creates a new authentication object If a User is attached to auth, then
	// the auth object is linked to an existing user. Otherwise a new user
	// object is created.
	//
	// On success, the auth.ID is set to the new authentication ID.
	CreateAuth(ctx context.Context, auth *Auth) error

	// Permanently deletes an authentication object from the system by ID.
	// The parent user object is not removed.
	DeleteAuth(ctx context.Context, id int) error
}

AuthService represents a service for managing auths.

type Dial

type Dial struct {
	ID int `json:"id"`

	// Owner of the dial. Only the owner may delete the dial.
	UserID int   `json:"userID"`
	User   *User `json:"user"`

	// Human-readable name of the dial.
	Name string `json:"name"`

	// Code used to share the dial with other users.
	// It allows the creation of a shareable link without explicitly inviting users.
	InviteCode string `json:"inviteCode,omitempty"`

	// Aggregate WTF level for the dial. This is a computed field based on the
	// average value of each member's WTF level.
	Value int `json:"value"`

	// Timestamps for dial creation & last update.
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`

	// List of associated members and their contributing WTF level.
	// This is only set when returning a single dial.
	Memberships []*DialMembership `json:"memberships,omitempty"`
}

Dial represents an aggregate WTF level. They are used to roll up the WTF levels of multiple members and show an average WTF level.

A dial is created by a user and can only be edited & deleted by the user who created it. Members can be added by sharing an invite link and accepting the invitation.

The WTF level for the dial will immediately change when a member's WTF level changes and the change will be announced to all other members in real-time.

See the EventService for more information about notifications.

func (*Dial) MembershipByUserID

func (d *Dial) MembershipByUserID(userID int) *DialMembership

MembershipByUserID returns the membership attached to the dial for a given user. Returns nil if user is not associated with the dial or if memberships is unset.

func (*Dial) Validate

func (d *Dial) Validate() error

Validate returns an error if dial has invalid fields. Only performs basic validation.

type DialFilter

type DialFilter struct {
	// Filtering fields.
	ID         *int    `json:"id"`
	InviteCode *string `json:"inviteCode"`

	// Restrict to subset of range.
	Offset int `json:"offset"`
	Limit  int `json:"limit"`
}

DialFilter represents a filter used by FindDials().

type DialMembership

type DialMembership struct {
	ID int `json:"id"`

	// Parent dial. This dial's WTF level updates when a membership updates.
	DialID int   `json:"dialID"`
	Dial   *Dial `json:"dial"`

	// Owner of the membership. Only this user can update the membership.
	UserID int   `json:"userID"`
	User   *User `json:"user"`

	// Current WTF level for the user for this dial.
	// Updating this value will cause the parent dial's WTF level to be recomputed.
	Value int `json:"value"`

	// Timestamps for membership creation & last update.
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

DialMembership represents a contributor to a Dial. Each membership is aggregated to determine the total WTF value of the parent dial.

All members can view all other member's values in the dial. However, only the membership owner can edit the membership value.

func (*DialMembership) Validate

func (m *DialMembership) Validate() error

Validate returns an error if membership fields are invalid. Only performs basic validation.

type DialMembershipFilter

type DialMembershipFilter struct {
	ID     *int `json:"id"`
	DialID *int `json:"dialID"`
	UserID *int `json:"userID"`

	// Restricts to a subset of the results.
	Offset int `json:"offset"`
	Limit  int `json:"limit"`

	// Sorting option for results.
	SortBy string `json:"sortBy"`
}

DialMembershipFilter represents a filter used by FindDialMemberships().

type DialMembershipService

type DialMembershipService interface {
	// Retrieves a membership by ID along with the associated dial & user.
	// Returns ENOTFOUND if membership does exist or user does not have
	// permission to view it.
	FindDialMembershipByID(ctx context.Context, id int) (*DialMembership, error)

	// Retrieves a list of matching memberships based on filter. Only returns
	// memberships that belong to dials that the current user is a member of.
	// Also returns a count of total matching memberships which may different if
	// "Limit" is specified on the filter.
	FindDialMemberships(ctx context.Context, filter DialMembershipFilter) ([]*DialMembership, int, error)

	// Creates a new membership on a dial for the current user. Returns
	// EUNAUTHORIZED if there is no current user logged in.
	CreateDialMembership(ctx context.Context, membership *DialMembership) error

	// Updates the value of a membership. Only the owner of the membership can
	// update the value. Returns EUNAUTHORIZED if user is not the owner. Returns
	// ENOTFOUND if the membership does not exist.
	UpdateDialMembership(ctx context.Context, id int, upd DialMembershipUpdate) (*DialMembership, error)

	// Permanently deletes a membership by ID. Only the membership owner and
	// the parent dial's owner can delete a membership.
	DeleteDialMembership(ctx context.Context, id int) error
}

DialMembershipService represents a service for managing dial memberships.

type DialMembershipUpdate

type DialMembershipUpdate struct {
	Value *int `json:"value"`
}

DialMembershipUpdate represents a set of fields to update on a membership.

type DialMembershipValueChangedPayload

type DialMembershipValueChangedPayload struct {
	ID    int `json:"id"`
	Value int `json:"value"`
}

DialMembershipValueChangedPayload represents the payload for an Event object with a type of EventTypeDialMembershipValueChanged.

type DialService

type DialService interface {
	// Retrieves a single dial by ID along with associated memberships. Only
	// the dial owner & members can see a dial. Returns ENOTFOUND if dial does
	// not exist or user does not have permission to view it.
	FindDialByID(ctx context.Context, id int) (*Dial, error)

	// Retrieves a list of dials based on a filter. Only returns dials that
	// the user owns or is a member of. Also returns a count of total matching
	// dials which may different from the number of returned dials if the
	// "Limit" field is set.
	FindDials(ctx context.Context, filter DialFilter) ([]*Dial, int, error)

	// Creates a new dial and assigns the current user as the owner.
	// The owner will automatically be added as a member of the new dial.
	CreateDial(ctx context.Context, dial *Dial) error

	// Updates an existing dial by ID. Only the dial owner can update a dial.
	// Returns the new dial state even if there was an error during update.
	//
	// Returns ENOTFOUND if dial does not exist. Returns EUNAUTHORIZED if user
	// is not the dial owner.
	UpdateDial(ctx context.Context, id int, upd DialUpdate) (*Dial, error)

	// Permanently removes a dial by ID. Only the dial owner may delete a dial.
	// Returns ENOTFOUND if dial does not exist. Returns EUNAUTHORIZED if user
	// is not the dial owner.
	DeleteDial(ctx context.Context, id int) error

	// Sets the value of the user's membership in a dial. This works the same
	// as calling UpdateDialMembership() although it doesn't require that the
	// user know their membership ID. Only the dial ID.
	//
	// Returns ENOTFOUND if the membership does not exist.
	SetDialMembershipValue(ctx context.Context, dialID, value int) error

	// AverageDialValueReport returns a report of the average dial value across
	// all dials that the user is a member of. Average values are computed
	// between start & end time and are slotted into given intervals. The
	// minimum interval size is one minute.
	AverageDialValueReport(ctx context.Context, start, end time.Time, interval time.Duration) (*DialValueReport, error)
}

DialService represents a service for managing dials.

type DialUpdate

type DialUpdate struct {
	Name *string `json:"name"`
}

DialUpdate represents a set of fields to update on a dial.

type DialValueChangedPayload

type DialValueChangedPayload struct {
	ID    int `json:"id"`
	Value int `json:"value"`
}

DialValueChangedPayload represents the payload for an Event object with a type of EventTypeDialValueChanged.

type DialValueRecord

type DialValueRecord struct {
	Value     int       `json:"value"`
	Timestamp time.Time `json:"timestamp"`
}

DialValueRecord represents an average dial value at a given point in time for the DialValueReport.

func (*DialValueRecord) GoString

func (r *DialValueRecord) GoString() string

GoString prints a more easily readable representation for debugging. The timestamp field is represented as an RFC 3339 string instead of a pointer.

type DialValueReport

type DialValueReport struct {
	Records []*DialValueRecord
}

DialValueReport represents a report generated by AverageDialValueReport(). Each record represents the average value within an interval of time.

type Error

type Error struct {
	// Machine-readable error code.
	Code string

	// Human-readable error message.
	Message string
}

Error represents an application-specific error. Application errors can be unwrapped by the caller to extract out the code & message.

Any non-application error (such as a disk error) should be reported as an EINTERNAL error and the human user should only see "Internal error" as the message. These low-level internal error details should only be logged and reported to the operator of the application (not the end user).

func Errorf

func Errorf(code string, format string, args ...interface{}) *Error

Errorf is a helper function to return an Error with a given code and formatted message.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface. Not used by the application otherwise.

type Event

type Event struct {
	// Specifies the type of event that is occurring.
	Type string `json:"type"`

	// The actual data from the event. See related payload types below.
	Payload interface{} `json:"payload"`
}

Event represents an event that occurs in the system. Currently there are only events for changes to a dial value or membership value. These events are eventually propagated out to connected users via WebSockets whenever changes occur so that the UI can update in real-time.

type EventService

type EventService interface {
	// Publishes an event to a user's event listeners.
	// If the user is not currently subscribed then this is a no-op.
	PublishEvent(userID int, event Event)

	// Creates a subscription for the current user's events.
	// Caller must call Subscription.Close() when done with the subscription.
	Subscribe(ctx context.Context) (Subscription, error)
}

EventService represents a service for managing event dispatch and event listeners (aka subscriptions).

Events are user-centric in this implementation although a more generic implementation may use a topic-centic model (e.g. "dial_value_changed(id=1)"). The application has frequent reconnects so it's more efficient to subscribe for a single user instead of resubscribing to all their related topics.

func NopEventService

func NopEventService() EventService

NopEventService returns an event service that does nothing.

type Subscription

type Subscription interface {
	// Event stream for all user's event.
	C() <-chan Event

	// Closes the event stream channel and disconnects from the event service.
	Close() error
}

Subscription represents a stream of events for a single user.

type User

type User struct {
	ID int `json:"id"`

	// User's preferred name & email.
	Name  string `json:"name"`
	Email string `json:"email"`

	// Randomly generated API key for use with the CLI.
	APIKey string `json:"-"`

	// Timestamps for user creation & last update.
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`

	// List of associated OAuth authentication objects.
	// Currently only GitHub is supported so there should only be a maximum of one.
	Auths []*Auth `json:"auths"`
}

User represents a user in the system. Users are typically created via OAuth using the AuthService but users can also be created directly for testing.

func UserFromContext

func UserFromContext(ctx context.Context) *User

UserFromContext returns the current logged in user.

func (*User) AvatarURL

func (u *User) AvatarURL(size int) string

AvatarURL returns a URL to the avatar image for the user. This loops over all auth providers to find the first available avatar. Currently only GitHub is supported. Returns blank string if no avatar URL available.

func (*User) Validate

func (u *User) Validate() error

Validate returns an error if the user contains invalid fields. This only performs basic validation.

type UserFilter

type UserFilter struct {
	// Filtering fields.
	ID     *int    `json:"id"`
	Email  *string `json:"email"`
	APIKey *string `json:"apiKey"`

	// Restrict to subset of results.
	Offset int `json:"offset"`
	Limit  int `json:"limit"`
}

UserFilter represents a filter passed to FindUsers().

type UserService

type UserService interface {
	// Retrieves a user by ID along with their associated auth objects.
	// Returns ENOTFOUND if user does not exist.
	FindUserByID(ctx context.Context, id int) (*User, error)

	// Retrieves a list of users by filter. Also returns total count of matching
	// users which may differ from returned results if filter.Limit is specified.
	FindUsers(ctx context.Context, filter UserFilter) ([]*User, int, error)

	// Creates a new user. This is only used for testing since users are typically
	// created during the OAuth creation process in AuthService.CreateAuth().
	CreateUser(ctx context.Context, user *User) error

	// Updates a user object. Returns EUNAUTHORIZED if current user is not
	// the user that is being updated. Returns ENOTFOUND if user does not exist.
	UpdateUser(ctx context.Context, id int, upd UserUpdate) (*User, error)

	// Permanently deletes a user and all owned dials. Returns EUNAUTHORIZED
	// if current user is not the user being deleted. Returns ENOTFOUND if
	// user does not exist.
	DeleteUser(ctx context.Context, id int) error
}

UserService represents a service for managing users.

type UserUpdate

type UserUpdate struct {
	Name  *string `json:"name"`
	Email *string `json:"email"`
}

UserUpdate represents a set of fields to be updated via UpdateUser().

Directories

Path Synopsis
cmd
wtf

Jump to

Keyboard shortcuts

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