Skip to content

Commit

Permalink
First Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
cachesdev committed Sep 11, 2024
0 parents commit 2eb053b
Show file tree
Hide file tree
Showing 26 changed files with 1,432 additions and 0 deletions.
57 changes: 57 additions & 0 deletions cmd/api/errors.go
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)
}
20 changes: 20 additions & 0 deletions cmd/api/healthcheck.go
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)
}
}
123 changes: 123 additions & 0 deletions cmd/api/helpers.go
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
}
102 changes: 102 additions & 0 deletions cmd/api/main.go
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
}
77 changes: 77 additions & 0 deletions cmd/api/middleware.go
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)
})
}
Loading

0 comments on commit 2eb053b

Please sign in to comment.