generated from example/golang-server-template
Initial commit
This commit is contained in:
commit
b01f991861
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bin/
|
||||
vendor/
|
||||
.envrc
|
||||
53
Makefile
Normal file
53
Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# ==================================================================================== #
|
||||
# HELPERS
|
||||
# ==================================================================================== #
|
||||
## help: print this help message
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo 'Usage:'
|
||||
@sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /'
|
||||
.PHONY: confirm
|
||||
confirm:
|
||||
@echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ]
|
||||
# ==================================================================================== #
|
||||
# DEVELOPMENT
|
||||
# ==================================================================================== #
|
||||
## run/api: run the cmd/api application
|
||||
.PHONY: run/api
|
||||
run/api:
|
||||
go run ./cmd/api
|
||||
|
||||
# ==================================================================================== #
|
||||
# QUALITY CONTROL
|
||||
# ==================================================================================== #
|
||||
## audit: tidy dependencies and format, vet and test all code
|
||||
.PHONY: audit
|
||||
audit: vendor
|
||||
@echo 'Tidying and verifying module dependencies...'
|
||||
go mod tidy
|
||||
go mod verify
|
||||
@echo 'Formatting code...'
|
||||
go fmt ./...
|
||||
@echo 'Vetting code...'
|
||||
go vet ./...
|
||||
@echo 'Running tests...'
|
||||
go test -race -vet=off ./...
|
||||
|
||||
## vendor: tidy and vendor dependencies
|
||||
.PHONY: vendor
|
||||
vendor:
|
||||
@echo 'Tidying and verifying module dependencies...'
|
||||
go mod tidy
|
||||
go mod verify
|
||||
@echo 'Vendoring dependencies...'
|
||||
go mod vendor
|
||||
|
||||
# ==================================================================================== #
|
||||
# BUILD
|
||||
# ==================================================================================== #
|
||||
|
||||
## build/api: build the cmd/api application
|
||||
.PHONY: build/api
|
||||
build/api:
|
||||
go build -ldflags='-s' -o ./bin/api ./cmd/api
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags='-s' -o ./bin/linux_amd64/api ./cmd/api
|
||||
27
README.md
Normal file
27
README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
## 项目结构
|
||||
|
||||
```yaml
|
||||
- bin # 可编译的二进制文件, 用于部署到服务器
|
||||
- cmd
|
||||
- api # 业务代码, 处理请求,权限
|
||||
- main.go
|
||||
- internel # 辅助代码, 处理数据库, 数据校验, 发邮件等
|
||||
- migrations # sql迁移文件
|
||||
- remote # 配置文件和启动脚本
|
||||
- go.mod # 项目依赖, 版本号, 模块路径
|
||||
- Makefile # 自动化处理任务, 审核代码, 生成二进制文件, 执行sql迁移
|
||||
```
|
||||
|
||||
## hello world
|
||||
|
||||
```go
|
||||
package main
|
||||
impot "fmt"
|
||||
func main() {
|
||||
fmt.Println("hello world!")
|
||||
}
|
||||
```
|
||||
执行代码
|
||||
```bash
|
||||
$ go run ./cmd/api # hello world!
|
||||
```
|
||||
25
cmd/api/context.go
Normal file
25
cmd/api/context.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"greenlight.alexedwards.net/internal/data"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey = contextKey("user")
|
||||
|
||||
func (app *application) contextSetUser(r *http.Request, user *data.User) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func (app *application) contextGetUser(r *http.Request) *data.User {
|
||||
user, ok := r.Context().Value(userContextKey).(*data.User)
|
||||
if !ok {
|
||||
panic("missing user value in request context")
|
||||
}
|
||||
return user
|
||||
}
|
||||
82
cmd/api/errors.go
Normal file
82
cmd/api/errors.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (app *application) logError(r *http.Request, err error) {
|
||||
app.logger.PrintError(err, map[string]string{
|
||||
"request_method": r.Method,
|
||||
"request_url": r.URL.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) {
|
||||
env := envelope{"error": message}
|
||||
err := app.writeJSON(w, status, env, nil)
|
||||
if err != nil {
|
||||
app.logError(r, err)
|
||||
w.WriteHeader(500)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
app.logError(r, err)
|
||||
message := "the server encountered a problem and could not process your request"
|
||||
app.errorResponse(w, r, http.StatusInternalServerError, message)
|
||||
}
|
||||
|
||||
func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "the requested resource could not be found"
|
||||
app.errorResponse(w, r, http.StatusNotFound, message)
|
||||
}
|
||||
|
||||
func (app *application) methodNotAllowResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := fmt.Sprintf("the %s method is not supported for this resource", r.Method)
|
||||
app.errorResponse(w, r, http.StatusMethodNotAllowed, message)
|
||||
}
|
||||
|
||||
func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
app.errorResponse(w, r, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
|
||||
app.errorResponse(w, r, http.StatusUnprocessableEntity, errors)
|
||||
}
|
||||
|
||||
func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "unable to update the record duto to an edit conflict, please try again"
|
||||
app.errorResponse(w, r, http.StatusConflict, message)
|
||||
}
|
||||
|
||||
func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "rate limit exceeded"
|
||||
app.errorResponse(w, r, http.StatusTooManyRequests, message)
|
||||
}
|
||||
|
||||
func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "invalid authentication credentials"
|
||||
app.errorResponse(w, r, http.StatusUnauthorized, message)
|
||||
}
|
||||
|
||||
func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
message := "invalid or missing authentication token"
|
||||
app.errorResponse(w, r, http.StatusUnauthorized, message)
|
||||
}
|
||||
|
||||
func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "you must be authenticated to access this resource"
|
||||
app.errorResponse(w, r, http.StatusUnauthorized, message)
|
||||
}
|
||||
|
||||
func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "you do not have the necessary permissions to access this resource"
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
}
|
||||
|
||||
func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "your account does not have the necessary permissions to access this resource"
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
}
|
||||
20
cmd/api/healthcheck.go
Normal file
20
cmd/api/healthcheck.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
env := envelope{
|
||||
"status": "available",
|
||||
"system_info": map[string]string{
|
||||
"environment": app.config.env,
|
||||
"version": version,
|
||||
},
|
||||
}
|
||||
err := app.writeJSON(w, http.StatusOK, env, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
121
cmd/api/helpers.go
Normal file
121
cmd/api/helpers.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"greenlight.alexedwards.net/internal/validator"
|
||||
)
|
||||
|
||||
type envelope map[string]any
|
||||
|
||||
func (app *application) readIDParam(r *http.Request) (int64, error) {
|
||||
params := httprouter.ParamsFromContext(r.Context())
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
if err != nil || id < 1 {
|
||||
return 0, errors.New("invalid id paramerer")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
|
||||
js, err := json.MarshalIndent(data, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
js = append(js, '\n')
|
||||
for key, value := range headers {
|
||||
w.Header()[key] = value
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(js)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
|
||||
maxBytes := 1_048_576
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(dst)
|
||||
if err != nil {
|
||||
var syntaxError *json.SyntaxError
|
||||
var unmarshalTypeError *json.UnmarshalTypeError
|
||||
var invalidUnmarshalError *json.InvalidUnmarshalError
|
||||
var maxBytesError *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return errors.New("body contains badly-formed JSON")
|
||||
case errors.As(err, &unmarshalTypeError):
|
||||
if unmarshalTypeError.Field != "" {
|
||||
return fmt.Errorf("body contains an invalid value for the %q field (at character %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
|
||||
}
|
||||
return fmt.Errorf("body contains an invalid value (at character %d)", unmarshalTypeError.Offset)
|
||||
case errors.Is(err, io.EOF):
|
||||
return errors.New("body must not be empty")
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
return fmt.Errorf("body contains unknown key %s", fieldName)
|
||||
case errors.As(err, &maxBytesError):
|
||||
return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit)
|
||||
case errors.As(err, &invalidUnmarshalError):
|
||||
panic(err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = dec.Decode(&struct{}{})
|
||||
if err != io.EOF {
|
||||
return errors.New("body must only contain a single JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (app *application) readString(qs url.Values, key string, defaultValue string) string {
|
||||
s := qs.Get(key)
|
||||
if s == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return s
|
||||
}
|
||||
func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string {
|
||||
csv := qs.Get(key)
|
||||
if csv == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return strings.Split(csv, ",")
|
||||
}
|
||||
func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
|
||||
s := qs.Get(key)
|
||||
if s == "" {
|
||||
return defaultValue
|
||||
}
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
v.AddError(key, "must be a integer value")
|
||||
return defaultValue
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (app *application) background(fn func()) {
|
||||
app.wg.Add(1)
|
||||
go func() {
|
||||
defer app.wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
app.logger.PrintError(fmt.Errorf("%s", err), nil)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
110
cmd/api/main.go
Normal file
110
cmd/api/main.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"greenlight.alexedwards.net/internal/data"
|
||||
"greenlight.alexedwards.net/internal/jsonlog"
|
||||
"greenlight.alexedwards.net/internal/vcs"
|
||||
)
|
||||
|
||||
var (
|
||||
version = vcs.Version()
|
||||
)
|
||||
|
||||
type config struct {
|
||||
port int
|
||||
env string
|
||||
db struct {
|
||||
dsn string
|
||||
maxOpenConns int
|
||||
maxIdleConns int
|
||||
maxIdleTime string
|
||||
}
|
||||
limiter struct {
|
||||
rps float64
|
||||
burst int
|
||||
enabled bool
|
||||
}
|
||||
smtp struct {
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
sender string
|
||||
}
|
||||
cors struct {
|
||||
trustedOrigins []string
|
||||
}
|
||||
}
|
||||
|
||||
type application struct {
|
||||
config config
|
||||
logger *jsonlog.Logger
|
||||
models data.Models
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func main() {
|
||||
var cfg config
|
||||
flag.IntVar(&cfg.port, "port", 4000, "API server port")
|
||||
flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")
|
||||
flag.StringVar(&cfg.db.dsn, "db-dsn", "", "PostgresSQL DSN")
|
||||
flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
|
||||
flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
|
||||
flag.StringVar(&cfg.db.maxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max idle time")
|
||||
|
||||
flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second")
|
||||
flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
|
||||
flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")
|
||||
|
||||
flag.StringVar(&cfg.smtp.host, "smtp-host", "smtp.mailtrap.io", "SMTP server host")
|
||||
flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP server port")
|
||||
flag.StringVar(&cfg.smtp.username, "smtp-username", "ebe83d2e524f7d", "SMTP server username")
|
||||
flag.StringVar(&cfg.smtp.password, "smtp-password", "2a46c462463a5f", "SMTP server password")
|
||||
flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.alexedwards.not>", "SMTP sender email address")
|
||||
|
||||
flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error {
|
||||
cfg.cors.trustedOrigins = strings.Fields(val)
|
||||
return nil
|
||||
})
|
||||
|
||||
displayVersion := flag.Bool("version", false, "Display version and exit")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *displayVersion {
|
||||
fmt.Printf("Version: \t%s\n", version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
|
||||
|
||||
logger.PrintInfo("database connection pool established", nil)
|
||||
|
||||
expvar.NewString("version").Set(version)
|
||||
expvar.Publish("goroutines", expvar.Func(func() any {
|
||||
return runtime.NumGoroutine()
|
||||
}))
|
||||
expvar.Publish("timestamp", expvar.Func(func() any {
|
||||
return time.Now().Unix()
|
||||
}))
|
||||
|
||||
app := &application{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
err := app.serve()
|
||||
if err != nil {
|
||||
logger.PrintFatal(err, nil)
|
||||
}
|
||||
}
|
||||
170
cmd/api/middleware.go
Normal file
170
cmd/api/middleware.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/felixge/httpsnoop"
|
||||
"github.com/tomasen/realip"
|
||||
"golang.org/x/time/rate"
|
||||
"greenlight.alexedwards.net/internal/data"
|
||||
)
|
||||
|
||||
func (app *application) recoverPanic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
w.Header().Set("Connection", "close")
|
||||
app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) rateLimit(next http.Handler) http.Handler {
|
||||
type clinet struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
clients = make(map[string]*clinet)
|
||||
)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
mu.Lock()
|
||||
for ip, client := range clients {
|
||||
if time.Since(client.lastSeen) > 3*time.Minute {
|
||||
delete(clients, ip)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if app.config.limiter.enabled {
|
||||
ip := realip.FromRequest(r)
|
||||
mu.Lock()
|
||||
if _, found := clients[ip]; !found {
|
||||
clients[ip] = &clinet{
|
||||
limiter: rate.NewLimiter(rate.Limit(app.config.limiter.rps), app.config.limiter.burst),
|
||||
}
|
||||
}
|
||||
clients[ip].lastSeen = time.Now()
|
||||
if !clients[ip].limiter.Allow() {
|
||||
mu.Unlock()
|
||||
app.rateLimitExceededResponse(w, r)
|
||||
return
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Vary", "Authorization")
|
||||
authorizationHeader := r.Header.Get("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
r = app.contextSetUser(r, data.AnonymousUser)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
headerParts := strings.Split(authorizationHeader, " ")
|
||||
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
|
||||
app.invalidAuthenticationTokenResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := headerParts[1]
|
||||
fmt.Println("LOG: TOKEN", "TODO: DEAL WITH TOKEN", token)
|
||||
// r = app.contextSetUser(r, user)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) requiredAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := app.contextGetUser(r)
|
||||
if user.IsAnonymous() {
|
||||
app.authenticationRequiredResponse(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) requiredActivatedUser(next http.HandlerFunc) http.HandlerFunc {
|
||||
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := app.contextGetUser(r)
|
||||
if !user.Activated {
|
||||
app.inactiveAccountResponse(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
return app.requiredAuthenticatedUser(fn)
|
||||
}
|
||||
|
||||
func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
user := app.contextGetUser(r)
|
||||
fmt.Println("LOG: PERMISSION", "TODO: DEAL WITH PERMISSION", user)
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
return app.requiredActivatedUser(fn)
|
||||
}
|
||||
|
||||
func (app *application) enableCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Add("Vary", "Access-Control-Request-Method")
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && len(app.config.cors.trustedOrigins) != 0 {
|
||||
for i := range app.config.cors.trustedOrigins {
|
||||
if origin == app.config.cors.trustedOrigins[i] {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) metrics(next http.Handler) http.Handler {
|
||||
totalRequestsReceived := expvar.NewInt("total_requests_received")
|
||||
totalResponseSent := expvar.NewInt("total_response_sent")
|
||||
totalProcessingTimeMicroseconds := expvar.NewInt("total_processing_time_microseconds")
|
||||
totalResponsesSentByStatus := expvar.NewMap("total_responses_sent_by_status")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
totalRequestsReceived.Add(1)
|
||||
metrics := httpsnoop.CaptureMetrics(next, w, r)
|
||||
totalResponseSent.Add(1)
|
||||
totalProcessingTimeMicroseconds.Add(metrics.Duration.Microseconds())
|
||||
totalResponsesSentByStatus.Add(strconv.Itoa(metrics.Code), 1)
|
||||
})
|
||||
}
|
||||
27
cmd/api/routes.go
Normal file
27
cmd/api/routes.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
func (app *application) routes() http.Handler {
|
||||
router := httprouter.New()
|
||||
|
||||
router.NotFound = http.HandlerFunc(app.notFoundResponse)
|
||||
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowResponse)
|
||||
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
|
||||
router.Handler(http.MethodGet, "/debug/vars", expvar.Handler())
|
||||
return app.metrics(
|
||||
app.recoverPanic(
|
||||
app.enableCORS(
|
||||
app.rateLimit(
|
||||
app.authenticate(router),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
}
|
||||
60
cmd/api/server.go
Normal file
60
cmd/api/server.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (app *application) serve() error {
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", app.config.port),
|
||||
Handler: app.routes(),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
shutdownError := make(chan error)
|
||||
|
||||
go func() {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
s := <-quit
|
||||
app.logger.PrintInfo("shutting down server", map[string]string{
|
||||
"signal": s.String(),
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
err := srv.Shutdown(ctx)
|
||||
if err != nil {
|
||||
shutdownError <- err
|
||||
}
|
||||
app.logger.PrintInfo("completing background tasks", map[string]string{
|
||||
"addr": srv.Addr,
|
||||
})
|
||||
app.wg.Wait()
|
||||
shutdownError <- nil
|
||||
}()
|
||||
app.logger.PrintInfo("starting server", map[string]string{
|
||||
"addr": srv.Addr,
|
||||
"env": app.config.env,
|
||||
})
|
||||
err := srv.ListenAndServe()
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
err = <-shutdownError
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.logger.PrintInfo("stopped server", map[string]string{
|
||||
"addr": srv.Addr,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
53
cmd/examples/cors/preflight/main.go
Normal file
53
cmd/examples/cors/preflight/main.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Simple CORS</h1>
|
||||
<div id="output"></div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch("http://localhost:4000/v1/tokens/authentication", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "alice@example.com",
|
||||
password: "pa55word",
|
||||
})
|
||||
}).then(
|
||||
function(response) {
|
||||
response.text().then(function(text) {
|
||||
document.getElementById('output').innerHTML = text
|
||||
})
|
||||
},
|
||||
function(err) {
|
||||
document.getElementById('output').innerHTML = err
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":9000", "Server address")
|
||||
flag.Parse()
|
||||
log.Printf("Starting server on %s", *addr)
|
||||
err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(html))
|
||||
}))
|
||||
log.Fatal(err)
|
||||
}
|
||||
44
cmd/examples/cors/simple/main.go
Normal file
44
cmd/examples/cors/simple/main.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Simple CORS</h1>
|
||||
<div id="output"></div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch("http://localhost:4000/v1/healthcheck").then(
|
||||
function(response) {
|
||||
response.text().then(function(text) {
|
||||
document.getElementById('output').innerHTML = text
|
||||
})
|
||||
},
|
||||
function(err) {
|
||||
document.getElementById('output').innerHTML = err
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":9000", "Server address")
|
||||
flag.Parse()
|
||||
log.Printf("Starting server on %s", *addr)
|
||||
err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(html))
|
||||
}))
|
||||
log.Fatal(err)
|
||||
}
|
||||
11
go.mod
Normal file
11
go.mod
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module greenlight.alexedwards.net
|
||||
|
||||
go 1.22.3
|
||||
|
||||
require (
|
||||
github.com/felixge/httpsnoop v1.0.4
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/lib/pq v1.10.2
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
|
||||
golang.org/x/time v0.5.0
|
||||
)
|
||||
10
go.sum
Normal file
10
go.sum
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
67
internal/data/filters.go
Normal file
67
internal/data/filters.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"greenlight.alexedwards.net/internal/validator"
|
||||
)
|
||||
|
||||
type Filters struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Sort string
|
||||
SortSafelist []string
|
||||
}
|
||||
type Metadata struct {
|
||||
CurrentPage int `json:"current_page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
FirstPage int `json:"first_page,omitempty"`
|
||||
LastPage int `json:"last_page,omitempty"`
|
||||
TotalRecords int `json:"total_records,omitempty"`
|
||||
}
|
||||
|
||||
func ValidateFilters(v *validator.Validator, f Filters) {
|
||||
v.Check(f.Page > 0, "page", "must be greater than zero")
|
||||
v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10,000,000")
|
||||
v.Check(f.PageSize > 0, "page_size", "must be greater than zero")
|
||||
v.Check(f.Page <= 100, "page_size", "must be a maximum of 100")
|
||||
v.Check(validator.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value")
|
||||
}
|
||||
|
||||
func (f Filters) limit() int {
|
||||
return f.PageSize
|
||||
}
|
||||
|
||||
func (f Filters) offset() int {
|
||||
return (f.Page - 1) * f.PageSize
|
||||
}
|
||||
|
||||
func calculateMetadata(totalRecords, page, pageSize int) Metadata {
|
||||
if totalRecords == 0 {
|
||||
return Metadata{}
|
||||
}
|
||||
return Metadata{
|
||||
CurrentPage: page,
|
||||
PageSize: pageSize,
|
||||
FirstPage: 1,
|
||||
LastPage: int(math.Ceil(float64(totalRecords) / float64(pageSize))),
|
||||
TotalRecords: totalRecords,
|
||||
}
|
||||
}
|
||||
|
||||
func (f Filters) sortColumn() string {
|
||||
for _, safeValue := range f.SortSafelist {
|
||||
if f.Sort == safeValue {
|
||||
return strings.TrimPrefix(f.Sort, "-")
|
||||
}
|
||||
}
|
||||
panic("unsafe sort parameter: " + f.Sort)
|
||||
}
|
||||
|
||||
func (f Filters) sortDirection() string {
|
||||
if strings.HasPrefix(f.Sort, "-") {
|
||||
return "DESC"
|
||||
}
|
||||
return "ASC"
|
||||
}
|
||||
22
internal/data/models.go
Normal file
22
internal/data/models.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRecordNotFound = errors.New("record not found")
|
||||
ErrEditConflict = errors.New("edit conflict")
|
||||
)
|
||||
|
||||
type Models struct {
|
||||
}
|
||||
|
||||
func NewModels(db *sql.DB) Models {
|
||||
return Models{}
|
||||
}
|
||||
|
||||
func NewMockModels() Models {
|
||||
return Models{}
|
||||
}
|
||||
31
internal/data/users.go
Normal file
31
internal/data/users.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateEmail = errors.New("duplicate email")
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateAt time.Time `json:"create_at"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password password `json:"-"`
|
||||
Activated bool `json:"activated"`
|
||||
Version int `json:"-"`
|
||||
}
|
||||
|
||||
var AnonymousUser = &User{}
|
||||
|
||||
type password struct {
|
||||
plaintext *string
|
||||
hash []byte
|
||||
}
|
||||
|
||||
func (u *User) IsAnonymous() bool {
|
||||
return u == AnonymousUser
|
||||
}
|
||||
92
internal/jsonlog/jsonlog.go
Normal file
92
internal/jsonlog/jsonlog.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package jsonlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Level int8
|
||||
|
||||
const (
|
||||
LevelInfo Level = iota
|
||||
LevelError
|
||||
LevelFatal
|
||||
LevelOff
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelInfo:
|
||||
return "INFO"
|
||||
case LevelError:
|
||||
return "ERROR"
|
||||
case LevelFatal:
|
||||
return "FATAL"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
out io.Writer
|
||||
minLevel Level
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(out io.Writer, minLevel Level) *Logger {
|
||||
return &Logger{
|
||||
out: out,
|
||||
minLevel: minLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) PrintInfo(message string, properties map[string]string) {
|
||||
l.print(LevelInfo, message, properties)
|
||||
}
|
||||
|
||||
func (l *Logger) PrintError(err error, properties map[string]string) {
|
||||
l.print(LevelError, err.Error(), properties)
|
||||
}
|
||||
|
||||
func (l *Logger) PrintFatal(err error, properties map[string]string) {
|
||||
l.print(LevelFatal, err.Error(), properties)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func (l *Logger) print(level Level, message string, properties map[string]string) (int, error) {
|
||||
if level < l.minLevel {
|
||||
return 0, nil
|
||||
}
|
||||
aux := struct {
|
||||
Level string `json:"level"`
|
||||
TIme string `json:"time"`
|
||||
Message string `json:"message"`
|
||||
Properties map[string]string `json:"properties,omitempty"`
|
||||
Trace string `json:"trace,omitempty"`
|
||||
}{
|
||||
Level: level.String(),
|
||||
TIme: time.Now().UTC().Format(time.RFC3339),
|
||||
Message: message,
|
||||
Properties: properties,
|
||||
}
|
||||
|
||||
if level >= LevelError {
|
||||
aux.Trace = string(debug.Stack())
|
||||
}
|
||||
var line []byte
|
||||
line, err := json.Marshal(aux)
|
||||
if err != nil {
|
||||
line = []byte(LevelError.String() + ": unable to marshal log message:" + err.Error())
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.out.Write(append(line, '\n'))
|
||||
}
|
||||
|
||||
func (l *Logger) Write(message []byte) (n int, err error) {
|
||||
return l.print(LevelError, string(message), nil)
|
||||
}
|
||||
53
internal/validator/validator.go
Normal file
53
internal/validator/validator.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package validator
|
||||
|
||||
import "regexp"
|
||||
|
||||
var (
|
||||
EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
Errors map[string]string
|
||||
}
|
||||
|
||||
func New() *Validator {
|
||||
return &Validator{Errors: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (v *Validator) Valid() bool {
|
||||
return len(v.Errors) == 0
|
||||
}
|
||||
|
||||
func (v *Validator) AddError(key, message string) {
|
||||
if _, exists := v.Errors[key]; !exists {
|
||||
v.Errors[key] = message
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) Check(ok bool, key, message string) {
|
||||
if !ok {
|
||||
v.AddError(key, message)
|
||||
}
|
||||
}
|
||||
|
||||
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
|
||||
for i := range permittedValues {
|
||||
if value == permittedValues[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Matches(value string, rx *regexp.Regexp) bool {
|
||||
return rx.MatchString(value)
|
||||
}
|
||||
|
||||
func Unique[T comparable](values []T) bool {
|
||||
uniqueValues := make(map[T]bool)
|
||||
for _, value := range values {
|
||||
uniqueValues[value] = true
|
||||
}
|
||||
|
||||
return len(values) == len(uniqueValues)
|
||||
}
|
||||
31
internal/vcs/vcs.go
Normal file
31
internal/vcs/vcs.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package vcs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func Version() string {
|
||||
var revision string
|
||||
var modified bool
|
||||
var time string
|
||||
bi, ok := debug.ReadBuildInfo()
|
||||
if ok {
|
||||
for _, s := range bi.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.time":
|
||||
time = s.Value
|
||||
case "vcs.revision":
|
||||
revision = s.Value
|
||||
case "vcs.modified":
|
||||
if s.Value == "true" {
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if modified {
|
||||
return fmt.Sprintf("%s-%s-dirty", time, revision)
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", time, revision)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user