Skip to content

Commit

Permalink
Setup an initial Heroku HTTP Server (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
CGA1123 authored Sep 27, 2021
1 parent 8c60590 commit 47b245c
Show file tree
Hide file tree
Showing 11 changed files with 287 additions and 3 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
dist/
bin/
5 changes: 4 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ issues:
exclude-use-default: false

linters:
disable:
- deadcode

enable:
- misspell
- bodyclose
- deadcode
- unused
- gofmt
- goimports
- gosimple
Expand Down
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: bin/slugcmplr server
4 changes: 4 additions & 0 deletions bin/go-post-compile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# bin/go-post-compile is used by the heroku/go buildpack as a post-compile hook
#!/bin/bash

go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/[email protected]
Binary file added bin/slugcmplr
Binary file not shown.
21 changes: 21 additions & 0 deletions cmd/slugcmplr/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func Cmd() *cobra.Command {
releaseCmd,
imageCmd,
versionCmd,
serverCmd,
}
for _, cmd := range cmds {
rootCmd.AddCommand(cmd(verbose))
Expand Down Expand Up @@ -186,3 +187,23 @@ func (o *stdOutputter) ErrOrStderr() io.Writer {

return o.Err
}

func requireEnv(names ...string) (map[string]string, error) {
result := map[string]string{}
missing := []string{}

for _, name := range names {
v, ok := os.LookupEnv(name)
if !ok {
missing = append(missing, name)
} else {
result[name] = v
}
}

if len(missing) > 0 {
return map[string]string{}, fmt.Errorf("variables not set: %v", missing)
}

return result, nil
}
58 changes: 58 additions & 0 deletions cmd/slugcmplr/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"fmt"
"os"

"github.com/cga1123/slugcmplr"
"github.com/spf13/cobra"
)

func serverCmd(verbose bool) *cobra.Command {
cmd := &cobra.Command{
Use: "server",
Short: "start a slugmcplr server",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
output := outputterFromCmd(cmd, verbose)

if err := defaultEnv(); err != nil {
return fmt.Errorf("failed to set env defaults: %w", err)
}

env, err := requireEnv(
"PORT",
"SLUGCMPLR_ENV",
)
if err != nil {
return fmt.Errorf("error fetching environment: %w", err)
}

s := &slugcmplr.ServerCmd{
Port: env["PORT"],
Environment: env["SLUGCMPLR_ENV"],
}

return s.Execute(cmd.Context(), output)
},
}

return cmd
}

func defaultEnv() error {
defaults := map[string]string{
"SLUGCMPLR_ENV": "development",
}
for k, v := range defaults {
if _, ok := os.LookupEnv(k); ok {
continue
}

if err := os.Setenv(k, v); err != nil {
return err
}
}

return nil
}
11 changes: 10 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// +heroku goVersion go1.17
// +heroku install ./cmd/...

module github.com/cga1123/slugcmplr

go 1.17
Expand All @@ -6,17 +9,22 @@ require (
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d
github.com/bmatcuk/doublestar/v4 v4.0.2
github.com/go-git/go-git/v5 v5.4.2
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/heroku/heroku-go/v5 v5.3.0
github.com/otiai10/copy v1.6.0
github.com/spf13/cobra v1.2.1
github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942
)

require (
github.com/Microsoft/go-winio v0.4.16 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
github.com/cenkalti/backoff v2.1.1+incompatible // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.3.1 // indirect
github.com/google/go-cmp v0.5.6 // indirect
Expand All @@ -28,12 +36,13 @@ require (
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/pborman/uuid v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 // indirect
github.com/xanzy/ssh-agent v0.3.0 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
Expand Down Expand Up @@ -176,6 +178,10 @@ github.com/google/uuid v1.1.4/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
Expand Down
137 changes: 137 additions & 0 deletions server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package slugcmplr

import (
"context"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)

const (
production = "production"
development = "development"
test = "test"
)

// ServerCmd wraps up all the information required to start a slugcmplr HTTP
// server.
type ServerCmd struct {
// Port is the port to listen to, the server will bind to the 0.0.0.0
// interface by default.
Port string

// Environment contains the current deployment environment, e.g. "production"
Environment string
}

// Execute starts a slugcmplr server, blocking untile a SIGTERM/SIGINT is
// received.
func (s *ServerCmd) Execute(ctx context.Context, out Outputter) error {
return runServer(ctx, out, s.Port, s.Router())
}

// Router builds a *mux.Router for slugcmplr.
func (s *ServerCmd) Router() *mux.Router {
r := mux.NewRouter()

r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://imgs.xkcd.com/comics/compiling.png", http.StatusFound)
})

return r
}

// nolint:unused
func loggingHandler(out io.Writer) func(n http.Handler) http.Handler {
return func(n http.Handler) http.Handler {
return handlers.LoggingHandler(out, n)
}
}

// timeoutHandler will set a timeout on the request from having read the
// headers until writing the full response body.
//
// For most requests this should be 30s or less. Heroku will close any
// connection that has not started writing responses within 30s.
//
// See: https://devcenter.heroku.com/articles/http-routing#timeouts
//
// nolint:unused
func timeoutHandler(t time.Duration) func(http.Handler) http.Handler {
return func(n http.Handler) http.Handler {
return http.TimeoutHandler(n, t, http.StatusText(http.StatusServiceUnavailable))
}
}

func runServer(ctx context.Context, out Outputter, port string, r *mux.Router) error {
r.Use(
loggingHandler(out.OutOrStdout()),
)

// Default Handler 404s
r.PathPrefix("/").HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
},
)

server := &http.Server{
Addr: "0.0.0.0:" + port,

// ReadTimeout sets a timeout from connection open until fully
// request-body read. Mitigating slow client attacks.
ReadTimeout: time.Second * 10,
Handler: r,
}

errorC := make(chan error, 1)
shutdownC := make(chan os.Signal, 1)

go func(errC chan<- error) {
errC <- server.ListenAndServe()
}(errorC)

signal.Notify(shutdownC, syscall.SIGINT, syscall.SIGTERM)

select {
case err := <-errorC:
if err != nil && err != http.ErrServerClosed {
return err
}

return nil
case <-shutdownC:
return shutdown(server)
case <-ctx.Done():
return shutdown(server)
}
}

func shutdown(server *http.Server) error {
// Heroku Dynos are given 30s to shutdown gracefully.
ctx, cancel := context.WithTimeout(context.Background(), 29*time.Second)
defer cancel()

return server.Shutdown(ctx)
}

// nolint:unused
func (s *ServerCmd) inProduction() bool {
return s.Environment == production
}

// nolint:unused
func (s *ServerCmd) inDevelopment() bool {
return s.Environment == development
}

// nolint:unused
func (s *ServerCmd) inTest() bool {
return s.Environment == test
}
46 changes: 46 additions & 0 deletions server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package slugcmplr_test

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/cga1123/slugcmplr"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)

func testHandler(router *mux.Router, r *http.Request) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()

router.ServeHTTP(recorder, r)

return recorder
}

func TestServer_Root(t *testing.T) {
t.Parallel()

s := &slugcmplr.ServerCmd{Environment: "test"}
req, err := http.NewRequest("GET", "/", nil)
assert.NoError(t, err, "Request should be built successfully")

res := testHandler(s.Router(), req).Result()
defer res.Body.Close() // nolint:errcheck

assert.Equal(t, 302, res.StatusCode, "Response should be a redirect")
assert.Equal(t, "https://imgs.xkcd.com/comics/compiling.png", res.Header.Get("Location"))
}

func TestServer_BadPath(t *testing.T) {
t.Parallel()

s := &slugcmplr.ServerCmd{Environment: "test"}
req, err := http.NewRequest("GET", "/not-a-reasonable-path", nil)
assert.NoError(t, err, "Request should be built successfully")

res := testHandler(s.Router(), req).Result()
defer res.Body.Close() // nolint:errcheck

assert.Equal(t, 404, res.StatusCode, "Response should be a not found")
}

0 comments on commit 47b245c

Please sign in to comment.