Skip to content

Commit

Permalink
feat: impl Problem Details based on RFC 7807
Browse files Browse the repository at this point in the history
  • Loading branch information
josestg committed Oct 15, 2023
0 parents commit 7364c93
Show file tree
Hide file tree
Showing 11 changed files with 863 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Lint and Test

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ '1.21.x' ]

steps:
- uses: actions/checkout@v3
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}

- name: Display Go version
run: go version

- name: Prepare Tooling
run: make tools

- name: Run Linter
run: make lint

- name: Run Test
run: make test
24 changes: 24 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
go.work

# Editor
.idea
37 changes: 37 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/make -f

# Choosing the shell
# - [docs](https://www.gnu.org/software/make/manual/html_node/Choosing-the-Shell.html)
SHELL =/bin/bash


# The default target is to prepare the development environment.
all: tools lint

.PHONY: lint
lint: hack/go-lint.sh
@chmod +x hack/go-lint.sh
@echo "Running linter."
@hack/go-lint.sh
@echo "Linter done."

.PHONY: test
test: hack/go-unittest.sh
@chmod +x hack/go-unittest.sh
@echo "Running unit tests."
@hack/go-unittest.sh
@echo "Unit tests done."


# Install all development tools, these tools are used by pre-commit hook.
tools: hack/install-tools.sh
@echo "Installing tools"
@hack/install-tools.sh
@echo "Tools installed"


# Enable pre-commit hook.
setup-pre-commit:
@echo "Setting up pre-commit hook"
@cp -f hack/pre-commit.sh .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
160 changes: 160 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Problem Details

This package implements [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) (Problem Details for HTTP APIs) for Go.
It provides an idiomatic way to use RFC 7807 in Go and offers both JSON and XML writers.

## Installation

```bash
go get github.com/josestg/problemdetail
```

## Examples

### Problem Details as an Error

```go
const (
TypOutOfCredit = "https://example.com/probs/out-of-credit"
TypProductNotFound = "https://example.com/probs/product-not-found"
)

func service() error {
// do something...

// simulate 30% error rate for each type of error.
n := rand.Intn(9)
if n < 3 {
return problemdetail.New(TypOutOfCredit,
problemdetail.WithValidateLevel(problemdetail.LStandard),
problemdetail.WithTitle("You do not have enough credit."),
problemdetail.WithDetail("Your current balance is 30, but that costs 50."),
)
}

if n < 6 {
return problemdetail.New(TypProductNotFound,
problemdetail.WithValidateLevel(problemdetail.LStandard),
problemdetail.WithTitle("The product was not found."),
problemdetail.WithDetail("The product you requested was not found in the system."),
)
}

return errors.New("unknown error")
}

// handler is a sample handler for HTTP server.
// you can make this as a centralized error handler middleware.
func handler(w http.ResponseWriter, _ *http.Request) {
err := service()
if err != nil {
// read the error as problemdetail.ProblemDetailer.
var pd problemdetail.ProblemDetailer
if !errors.As(err, &pd) {
// untyped error for generic error handling.
untyped := problemdetail.New(
problemdetail.Untyped,
problemdetail.WithValidateLevel(problemdetail.LStandard),
)
_ = problemdetail.WriteJSON(w, untyped, http.StatusInternalServerError)
return
}

// typed error for specific error handling.
switch pd.Kind() {
case TypOutOfCredit:
_ = problemdetail.WriteJSON(w, pd, http.StatusForbidden)
// or problemdetail.WriteXML(w, pd, http.StatusForbidden) for XML
case TypProductNotFound:
_ = problemdetail.WriteJSON(w, pd, http.StatusNotFound)
}
return
}
w.WriteHeader(http.StatusOK)
}

func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
```

### Problem Details with Extensions

```go
const (
TypOutOfCredit = "https://example.com/probs/out-of-credit"
TypProductNotFound = "https://example.com/probs/product-not-found"
)

// BalanceProblemDetail is a sample problem detail with extension by embedding ProblemDetail.
type BalanceProblemDetail struct {
*problemdetail.ProblemDetail
Balance int64 `json:"balance" xml:"balance"`
Accounts []string `json:"accounts" xml:"accounts"`
}

func service() error {
// do something...

// simulate 30% error rate for each type of error.
n := rand.Intn(9)
if n < 3 {
pd := problemdetail.New(TypOutOfCredit,
problemdetail.WithValidateLevel(problemdetail.LStandard),
problemdetail.WithTitle("You do not have enough credit."),
problemdetail.WithDetail("Your current balance is 30, but that costs 50."),
)
return &BalanceProblemDetail{
ProblemDetail: pd,
Balance: 30,
Accounts: []string{"/account/12345", "/account/67890"},
}
}

if n < 6 {
return problemdetail.New(TypProductNotFound,
problemdetail.WithValidateLevel(problemdetail.LStandard),
problemdetail.WithTitle("The product was not found."),
problemdetail.WithDetail("The product you requested was not found in the system."),
)
}

return errors.New("unknown error")
}

// handler is a sample handler for HTTP server.
// you can make this as a centralized error handler middleware.
func handler(w http.ResponseWriter, _ *http.Request) {
err := service()
if err != nil {
// read the error as problemdetail.ProblemDetailer.
var pd problemdetail.ProblemDetailer
if !errors.As(err, &pd) {
// untyped error for generic error handling.
untyped := problemdetail.New(
problemdetail.Untyped,
problemdetail.WithValidateLevel(problemdetail.LStandard),
)
_ = problemdetail.WriteJSON(w, untyped, http.StatusInternalServerError)
return
}

// typed error for specific error handling.
switch pd.Kind() {
case TypOutOfCredit:
_ = problemdetail.WriteJSON(w, pd, http.StatusForbidden)
// or problemdetail.WriteXML(w, pd, http.StatusForbidden) for XML
case TypProductNotFound:
_ = problemdetail.WriteJSON(w, pd, http.StatusNotFound)
}
return
}
w.WriteHeader(http.StatusOK)
}

func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
```
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/josestg/problemdetail

go 1.21.3
31 changes: 31 additions & 0 deletions hack/go-lint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash

# ensure the execution will stop if any command fails (returns non-zero value)
set -e -o pipefail

echo "execute go vet"
go vet ./...

if ! command -v staticcheck > /dev/null; then
echo "staticcheck not installed or available in the PATH" >&2
exit 1
else
echo "execute staticcheck"
staticcheck ./...
fi

if ! command -v govulncheck > /dev/null; then
echo "govulncheck not installed or available in the PATH" >&2
exit 1
else
echo "execute govulncheck"
govulncheck ./...
fi

if ! command -v gosec > /dev/null; then
echo "gosec not installed or available in the PATH" >&2
exit 1
else
echo "execute gosec, will take a while. please be patient!"
gosec -exclude-generated -quiet ./...
fi
7 changes: 7 additions & 0 deletions hack/go-unittest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash

# ensure the execution will stop if any command fails (returns non-zero value)
set -e -o pipefail

echo "execute go test"
go test -race -short -timeout 60s ./...
36 changes: 36 additions & 0 deletions hack/install-tools.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash

# ensure the execution will stop if any command fails (returns non-zero value)
set -e -o pipefail

# install staticcheck if not installed yet.
if ! command -v staticcheck > /dev/null; then
echo "Installing staticcheck"
go install honnef.co/go/tools/cmd/staticcheck@latest
else
echo "staticcheck already installed"
fi

# install goimports if not installed yet.
if ! command -v goimports > /dev/null; then
echo "Installing goimports"
go install golang.org/x/tools/cmd/goimports@latest
else
echo "goimports already installed"
fi

# install gosec if not installed yet.
if ! command -v gosec > /dev/null; then
echo "Installing gosec"
curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b "$(go env GOPATH)"/bin
else
echo "gosec already installed"
fi

# install govulncheck if not installed yet.
if ! command -v govulncheck > /dev/null; then
echo "Installing govulncheck"
go install golang.org/x/vuln/cmd/govulncheck@latest
else
echo "govulncheck already installed"
fi
42 changes: 42 additions & 0 deletions hack/pre-commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# ensure the execution will stop if any command fails (returns non-zero value)
set -e -o pipefail

echo "check if go.mod needs to be updated"
# Check if go.mod needs to be updated.
if go mod tidy -v 2>&1 | grep -q 'updates to go.mod needed'; then
echo "please run go mod tidy and commit the changes"
exit 1
fi

## this will retrieve all of the .go files that have been
## changed since the last commit
go_staged_files=$(git diff --cached --diff-filter=ACM --name-only -- '*.go')


if [[ $go_staged_files == "" ]]; then
# when there are no staged go files, we can skip the rest of the checks.
echo "no go files in staged changes. skipping gofmt, goimports, go vet and staticcheck"
else
if ! command -v gofmt &> /dev/null ; then
echo "gofmt not installed or available in the PATH" >&2
exit 1
fi


if ! command -v goimports &> /dev/null ; then
echo "goimports not installed or available in the PATH" >&2
exit 1
fi

for file in $go_staged_files; do
printf "[gofmt, goimports] %s\n" "$file"
goimports -l -w "$file"
gofmt -l -w "$file"
git add "$file"
done
fi

make lint
make test
Loading

0 comments on commit 7364c93

Please sign in to comment.