Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: create an example of an API server using fault #35

Merged
merged 4 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
1 change: 1 addition & 0 deletions examples/api/.tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
golang 1.21.1
55 changes: 55 additions & 0 deletions examples/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# API Example

This is an example project to demonstrate the use of Fault in an API context. This fake API expose a single endpoint
`/users/{id}`. Asking for user 999 will generate an error by design. Asking for 123 will return a user info payload.
Asking for anything else will return a "NotFound" response.

## Running

From the repository root, using golang 1.21+, run (because the slog package is needed).

```bash
go run ./examples/api/main.go
```

Then you can run the following curl commands
```bash
curl http://localhost:3333/users/999 # wil return a 500 error
curl http://localhost:3333/users/321 # will return not found
curl http://localhost:3333/users/123 # will return a user in JSON
```

Running each curl should produce the following logs
```
2023/09/09 11:05:06 ERROR
db error: connection lost
examples/api/main.go:53
Could not get user
examples/api/main.go:54
<ftag>
examples/api/main.go:54
http_method=GET user_id=999 request_id=It73FDo3WC-000005 request_path=/users/999 remote_ip=127.0.0.1:52246 protocol=HTTP/1.1 error="Could not get user: db error: connection lost"
2023/09/09 11:05:06 INFO API Request request_id=It73FDo3WC-000005 request_path=/users/999 remote_ip=127.0.0.1:52246 protocol=HTTP/1.1 http_method=GET status=500 latency=96.25µs

2023/09/09 11:05:25 ERROR
db error: user id[321] not found
examples/api/main.go:62
User not found
examples/api/main.go:63
<ftag>
examples/api/main.go:63
http_method=GET user_id=321 request_path=/users/321 remote_ip=127.0.0.1:52246 request_id=It73FDo3WC-000006 protocol=HTTP/1.1 error="User not found: db error: user id[321] not found"
2023/09/09 11:05:25 INFO API Request protocol=HTTP/1.1 http_method=GET request_path=/users/321 remote_ip=127.0.0.1:52246 request_id=It73FDo3WC-000006 status=404 latency=65.306µs

2023/09/09 11:06:28 INFO API Request protocol=HTTP/1.1 request_id=It73FDo3WC-000008 http_method=GET request_path=/users/123 remote_ip=127.0.0.1:52426 status=200 latency=46.562µs

```

## How

There is a couple of things we're trying to demonstrate here:

1. We use fault's flatten method to produce the equivalent of a stacktrace that we display with the error
2. We use fault's `ftag` to "tag" the errors and infer the http status code from that
3. We use fault's `fctx` to add http context fields, but also controller based values, like in this example, the `userId`
4. We use fault's user-friendly error messages while building the user-facing message we return as part of the http response. (check the curl responses)
11 changes: 11 additions & 0 deletions examples/api/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module github.com/Southclaws/fault/examples/api

go 1.21

require (
github.com/Southclaws/fault v0.6.1
github.com/go-chi/chi/v5 v5.0.8
github.com/go-chi/render v1.0.2
)

require github.com/ajg/form v1.5.1 // indirect
8 changes: 8 additions & 0 deletions examples/api/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/Southclaws/fault v0.6.1 h1:bv/Zph2LxpeEB5HlujnmV7rfAmNHHnNikofi+rGf5RI=
github.com/Southclaws/fault v0.6.1/go.mod h1:VUVkAWutC59SL16s6FTqf3I6I2z77RmnaW5XRz4bLOE=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg=
github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
92 changes: 92 additions & 0 deletions examples/api/http/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package http

import (
"context"
"fmt"
"github.com/Southclaws/fault/fctx"
"github.com/Southclaws/fault/fmsg"
"github.com/Southclaws/fault/ftag"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"log/slog"
"net/http"
"time"
)

// Adding requestID in the fault context for logging purpose
func DecorateRequestMetadata(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rid := middleware.GetReqID(ctx)
ctx = fctx.WithMeta(ctx, "request_id", rid)
ctx = fctx.WithMeta(ctx, "http_method", r.Method)
ctx = fctx.WithMeta(ctx, "request_path", r.URL.Path)
ctx = fctx.WithMeta(ctx, "remote_ip", r.RemoteAddr)
ctx = fctx.WithMeta(ctx, "protocol", r.Proto)
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}

// Adding any path variable in the fault context for logging purpose
func PathVariableAsFCtx(pathVarName, fctxName string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if value := chi.URLParam(r, pathVarName); value != "" {
r = r.WithContext(fctx.WithMeta(r.Context(), fctxName, value))
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}

func fctxToSlog(ctx context.Context) []any {
faultFields := fctx.GetMeta(ctx)
fields := make([]any, 0, len(faultFields))
for k, v := range faultFields {
fields = append(fields, slog.String(k, v))
}
return fields
}

func LoggerRequest(logger *slog.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
t1 := time.Now()
defer func() {
fields := fctxToSlog(r.Context())
fields = append(fields, slog.Int("status", ww.Status()))
fields = append(fields, slog.Duration("latency", time.Since(t1)))
logger.Info("API Request", fields...)
}()
next.ServeHTTP(ww, r)
}
return http.HandlerFunc(fn)
}

}

func RespondWithError(
logger *slog.Logger,
err error,
w http.ResponseWriter,
r *http.Request,
) {
tag := ftag.Get(err)
matdurand marked this conversation as resolved.
Show resolved Hide resolved

attrs := fctxToSlog(r.Context())
errStr := fmt.Sprintf("%+v", err)
attrs = append(attrs, slog.String("error", err.Error()))
logger.Error("\n"+errStr, attrs...)

// Using tags to determine http status based on the error
if tag == ftag.NotFound {
http.Error(w, fmsg.GetIssue(err), 404)
return
}

// Default to internal server error
http.Error(w, http.StatusText(500), 500)
}
86 changes: 86 additions & 0 deletions examples/api/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"fmt"
"github.com/Southclaws/fault"
apihttp "github.com/Southclaws/fault/examples/api/http"
"github.com/Southclaws/fault/fmsg"
"github.com/Southclaws/fault/ftag"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"log/slog"
"net/http"
)

var ExistingUserID = "123"
var ErrorUserID = "999"
var logger *slog.Logger

func init() {
logger = slog.Default()
render.Respond = func(w http.ResponseWriter, r *http.Request, v interface{}) {
if _, ok := v.(error); ok {

// We change the response to not reveal the actual error message,
// instead we can transform the message something more friendly or mapped
// to some code / language, etc.
render.DefaultResponder(w, r, render.M{"status": "error"})
return
}

render.DefaultResponder(w, r, v)
}
}

type User struct {
ID string `json:"id"`
Name string `json:"name"`
}

func GetUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
if userID == ExistingUserID {
user := User{
ID: userID,
Name: "Bob",
}
render.JSON(w, r, user)
return
}

if userID == ErrorUserID {
err := fault.New("db error: connection lost")
err = fault.Wrap(err,
fmsg.WithDesc("Could not get user", "An error occured while getting the user. Try again later"),
ftag.With(ftag.Internal),
)
apihttp.RespondWithError(logger, err, w, r)
return
}

err := fault.New(fmt.Sprintf("db error: user id[%s] not found", userID))
err = fault.Wrap(err,
fmsg.WithDesc("User not found", "Cannot find the requested user"),
ftag.With(ftag.NotFound))
apihttp.RespondWithError(logger, err, w, r)
}

func main() {

r := chi.NewRouter()

r.Use(middleware.RequestID)
r.Use(apihttp.DecorateRequestMetadata)
r.Use(middleware.Recoverer)
r.Use(render.SetContentType(render.ContentTypeJSON))
r.Use(apihttp.LoggerRequest(logger))

r.Route("/users/{userID}", func(r chi.Router) {
r.Use(apihttp.PathVariableAsFCtx("userID", "user_id"))
r.Get("/", GetUser)
})

fmt.Printf("Listening on :3333 ...\n")
http.ListenAndServe(":3333", r)
}