-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 2eb053b
Showing
26 changed files
with
1,432 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
package main | ||
|
||
import ( | ||
"fmt" | ||
"net/http" | ||
) | ||
|
||
func (app *application) logError(r *http.Request, err error) { | ||
app.logger.Error(err, "properties", 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) methodNotAllowedResponse(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 due 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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
package main | ||
|
||
import ( | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/url" | ||
"strconv" | ||
"strings" | ||
|
||
"github.com/julienschmidt/httprouter" | ||
"greenlight.chipa.me/internal/validator" | ||
) | ||
|
||
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 parameter") | ||
} | ||
|
||
return id, nil | ||
} | ||
|
||
type envelope map[string]any | ||
|
||
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 incorrect JSON type for field %q", unmarshalTypeError.Field) | ||
} | ||
return fmt.Errorf("body contains incorrect JSON type (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 an integer value") | ||
return defaultValue | ||
} | ||
|
||
return i | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"database/sql" | ||
"flag" | ||
"os" | ||
"time" | ||
|
||
"github.com/charmbracelet/log" | ||
|
||
_ "github.com/lib/pq" | ||
"greenlight.chipa.me/internal/data" | ||
) | ||
|
||
const version = "1.0.0" | ||
|
||
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 | ||
} | ||
} | ||
|
||
type application struct { | ||
config config | ||
logger *log.Logger | ||
models data.Models | ||
} | ||
|
||
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", os.Getenv("GREENLIGHT_DB_DSN"), "PostgreSQL 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 connection 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.Parse() | ||
|
||
logger := log.NewWithOptions(os.Stderr, log.Options{ | ||
Level: log.DebugLevel, | ||
Formatter: log.JSONFormatter, | ||
ReportCaller: true, | ||
ReportTimestamp: true, | ||
}) | ||
|
||
db, err := openDB(cfg) | ||
if err != nil { | ||
logger.Fatal(err) | ||
} | ||
defer db.Close() | ||
logger.Info("database connection pool established") | ||
|
||
app := &application{ | ||
config: cfg, | ||
logger: logger, | ||
models: data.NewModels(db), | ||
} | ||
|
||
err = app.serve() | ||
if err != nil { | ||
logger.Fatal(err) | ||
} | ||
} | ||
func openDB(cfg config) (*sql.DB, error) { | ||
db, err := sql.Open("postgres", cfg.db.dsn) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
db.SetMaxOpenConns(cfg.db.maxOpenConns) | ||
db.SetMaxIdleConns(cfg.db.maxIdleConns) | ||
duration, err := time.ParseDuration(cfg.db.maxIdleTime) | ||
if err != nil { | ||
return nil, err | ||
} | ||
db.SetConnMaxIdleTime(duration) | ||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
defer cancel() | ||
err = db.PingContext(ctx) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return db, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
package main | ||
|
||
import ( | ||
"fmt" | ||
"net" | ||
"net/http" | ||
"sync" | ||
"time" | ||
|
||
"golang.org/x/time/rate" | ||
) | ||
|
||
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 client struct { | ||
limiter *rate.Limiter | ||
lastSeen time.Time | ||
} | ||
|
||
var ( | ||
mu sync.Mutex | ||
clients = make(map[string]*client) | ||
) | ||
|
||
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, _, err := net.SplitHostPort(r.RemoteAddr) | ||
if err != nil { | ||
app.serverErrorResponse(w, r, err) | ||
return | ||
} | ||
|
||
mu.Lock() | ||
if _, found := clients[ip]; !found { | ||
clients[ip] = &client{ | ||
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) | ||
}) | ||
} |
Oops, something went wrong.