Skip to content

Commit

Permalink
feat(problems): improve the problems responses and controls
Browse files Browse the repository at this point in the history
  • Loading branch information
ConsoleTVs committed Oct 7, 2024
1 parent 4caac61 commit 9a78f7b
Show file tree
Hide file tree
Showing 4 changed files with 252 additions and 32 deletions.
1 change: 1 addition & 0 deletions helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
)

// JSON decodes the given request payload into `T`
func JSON[T any](request *http.Request) (T, error) {
result := *new(T)

Expand Down
78 changes: 46 additions & 32 deletions problem.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import (
"fmt"
"maps"
"net/http"
"strings"

"github.com/studiolambda/akumu/utils"
)

// Problem represents a problem details for HTTP APIs.
Expand All @@ -27,12 +28,24 @@ type ProblemControls struct {
DefaultType ProblemControlsResolver[string]
DefaultTitle ProblemControlsResolver[string]
DefaultInstance ProblemControlsResolver[string]
Response ProblemControlsResolver[Builder]
}

// ProblemsKey is the context key where the
// problem controls are stored in the request.
type ProblemsKey struct{}

func defaultProblemControls() ProblemControls {
return ProblemControls{
Lowercase: defaultProblemControlsLowercase,
DefaultStatus: defaultProblemControlsStatus,
DefaultType: defaultProblemControlsType,
DefaultTitle: defaultProblemControlsTitle,
DefaultInstance: defaultProblemControlsInstance,
Response: defaultProblemControlsResponse,
}
}

// Problems return the [ProblemControls] used to determine
// how [Problem] respond to http requests.
func Problems(request *http.Request) (ProblemControls, bool) {
Expand All @@ -43,34 +56,51 @@ func Problems(request *http.Request) (ProblemControls, bool) {
return controls, ok
}

func defaultProblemControllerLowercase(problem Problem, request *http.Request) bool {
func defaultProblemControlsLowercase(problem Problem, request *http.Request) bool {
return true
}

func defaultProblemControllerStatus(problem Problem, request *http.Request) int {
func defaultProblemControlsStatus(problem Problem, request *http.Request) int {
return http.StatusInternalServerError
}

func defaultProblemControllerType(problem Problem, request *http.Request) string {
func defaultProblemControlsType(problem Problem, request *http.Request) string {
return "about:blank"
}

func defaultProblemControllerTitle(problem Problem, request *http.Request) string {
func defaultProblemControlsTitle(problem Problem, request *http.Request) string {
return http.StatusText(problem.Status)
}

func defaultProblemControllerInstance(problem Problem, request *http.Request) string {
func defaultProblemControlsInstance(problem Problem, request *http.Request) string {
return request.URL.String()
}

func NewProblemControls() ProblemControls {
return ProblemControls{
Lowercase: defaultProblemControllerLowercase,
DefaultStatus: defaultProblemControllerStatus,
DefaultType: defaultProblemControllerType,
DefaultTitle: defaultProblemControllerTitle,
DefaultInstance: defaultProblemControllerInstance,
func defaultProblemControlsResponse(problem Problem, request *http.Request) Builder {
responses := map[string]Builder{
"application/problem+json": Response(problem.Status).
JSON(problem).
Header("Content-Type", "application/problem+json"),
"application/json": Response(problem.Status).
JSON(problem).
Header("Content-Type", "application/problem+json"),
"text/html": Response(problem.Status).
HTML(fmt.Sprintf(
`<style>.akumu.titlecase{text-transform:capitalize;}.akumu.uppercase-first::first-letter{text-transform:uppercase;}</style><h1 class="akumu titlecase">%s &mdash; %d</h1><h2 class="akumu uppercase-first">%s &mdash; %s</h2><a href=\"%s\">%s</a>`,
problem.Title, problem.Status, problem.Detail, problem.Instance, problem.Type, problem.Type,
)),
}

accept := utils.ParseAccept(request)

for _, media := range accept.Order() {
if response, ok := responses[media]; ok {
return response
}
}

return Response(problem.Status).
Text(fmt.Sprintf("%s\n\n%s", problem.Title, problem.Detail))
}

// NewProblem creates a new [Problem] from
Expand Down Expand Up @@ -185,7 +215,7 @@ func (problem Problem) controls(request *http.Request) ProblemControls {
return controls
}

return NewProblemControls()
return defaultProblemControls()
}

func (problem Problem) defaulted(request *http.Request) Problem {
Expand Down Expand Up @@ -218,23 +248,7 @@ func (problem Problem) defaulted(request *http.Request) Problem {
// Respond implements [Responder] interface to implement
// how a problem responds to an http request.
func (problem Problem) Respond(request *http.Request) Builder {
problem = problem.defaulted(request)

if strings.Contains(request.Header.Get("Accept"), "application/problem+json") || strings.Contains(request.Header.Get("Accept"), "application/json") {
return Response(problem.Status).
JSON(problem).
Header("Content-Type", "application/problem+json")
}

// todo: disabled due to improvement schedule
// if strings.Contains(request.Header.Get("Accept"), "text/html") {
// return Response(problem.Status).
// HTML(fmt.Sprintf(
// `<style>.akumu.titlecase{text-transform:capitalize;}.akumu.uppercase-first::first-letter{text-transform:uppercase;}</style><h1 class="akumu titlecase">%s &mdash; %d</h1><h2 class="akumu uppercase-first">%s &mdash; %s</h2><a href=\"%s\">%s</a>`,
// problem.Title, problem.Status, problem.Detail, problem.Instance, problem.Type, problem.Type,
// ))
// }
controls := problem.controls(request)

return Response(problem.Status).
Text(fmt.Sprintf("%s\n\n%s", problem.Title, problem.Detail))
return controls.Response(problem.defaulted(request), request)
}
103 changes: 103 additions & 0 deletions utils/accept.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package utils

import (
"mime"
"net/http"
"sort"
"strconv"
"strings"
)

type acceptPair struct {
media string
quality float64
}

type Accept struct {
values []acceptPair
}

func ParseAccept(request *http.Request) Accept {
accept := Accept{
values: make([]acceptPair, 0),
}

for _, header := range request.Header.Values("Accept") {
for _, line := range strings.Split(header, ",") {
media, parameters, err := mime.ParseMediaType(line)

if err != nil {
continue
}

quality := 1.0

if param, ok := parameters["q"]; ok {
if q, err := strconv.ParseFloat(param, 64); err == nil {
quality = q
}
}

accept.values = append(accept.values, acceptPair{
media: media,
quality: quality,
})
}
}

return accept
}

func (accept Accept) find(media string) (acceptPair, bool) {
for _, pair := range accept.values {
if media == pair.media {
return pair, true
}

// Test for wildcard in media type
if strings.Contains(media, "/*") {
// Compare only the first part.
if trimmed := strings.TrimSuffix(media, "/*"); strings.HasPrefix(pair.media, trimmed) {
return pair, true
}
}

// Test for wildcard in accept media type
if strings.Contains(pair.media, "/*") {
// Compare only the first part.
if trimmed := strings.TrimSuffix(pair.media, "/*"); strings.HasPrefix(media, trimmed) {
return pair, true
}
}
}

return acceptPair{}, false
}

func (accept Accept) Accepts(media string) bool {
_, found := accept.find(media)

return found
}

func (accept Accept) Quality(media string) float64 {
if pair, found := accept.find(media); found {
return pair.quality
}

return 0
}

func (accept Accept) Order() []string {
keys := make([]string, len(accept.values))

for i, pair := range accept.values {
keys[i] = pair.media
}

sort.SliceStable(keys, func(i, j int) bool {
return accept.values[i].quality > accept.values[j].quality
})

return keys
}
102 changes: 102 additions & 0 deletions utils/accept_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package utils_test

import (
"net/http"
"testing"

"github.com/studiolambda/akumu/utils"
)

func TestAcceptAccepts(t *testing.T) {
request, err := http.NewRequest("GET", "/", nil)

if err != nil {
t.Fatalf("failed to create request: %s", err)
}

request.Header.Add("Accept", "application/json, text/*")

accept := utils.ParseAccept(request)

if expected := "application/json"; !accept.Accepts(expected) {
t.Fatalf("failed to accept media type: %s", expected)
}

if expected := "text/html"; !accept.Accepts(expected) {
t.Fatalf("failed to accept media type: %s", expected)
}

if expected := "text/*"; !accept.Accepts(expected) {
t.Fatalf("failed to accept media type: %s", expected)
}

if expected := "foo/bar"; accept.Accepts(expected) {
t.Fatalf("failed to not accept media type: %s", expected)
}
}

func TestAcceptAcceptsWithMultipleHeaderValues(t *testing.T) {
request, err := http.NewRequest("GET", "/", nil)

if err != nil {
t.Fatalf("failed to create request: %s", err)
}

request.Header.Add("Accept", "application/json")
request.Header.Add("Accept", "text/*")

accept := utils.ParseAccept(request)

if expected := "application/json"; !accept.Accepts(expected) {
t.Fatalf("failed to accept media type: %s", expected)
}

if expected := "text/html"; !accept.Accepts(expected) {
t.Fatalf("failed to accept media type: %s", expected)
}

if expected := "text/*"; !accept.Accepts(expected) {
t.Fatalf("failed to accept media type: %s", expected)
}

if expected := "foo/bar"; accept.Accepts(expected) {
t.Fatalf("failed to not accept media type: %s", expected)
}
}

func TestAcceptOrder(t *testing.T) {
request, err := http.NewRequest("GET", "/", nil)

if err != nil {
t.Fatalf("failed to create request: %s", err)
}

request.Header.Add("Accept", "application/json, text/*, foo/bar;q=0.3, another/*;q=0.4, bar/baz;q=0.5")

accept := utils.ParseAccept(request)
order := accept.Order()

if expected := 5; expected != len(order) {
t.Fatalf("failed order len: %d, expected %d", len(order), expected)
}

if expected := "application/json"; expected != order[0] {
t.Fatalf("failed order element: %s, expected %s", order[0], expected)
}

if expected := "text/*"; expected != order[1] {
t.Fatalf("failed order element: %s, expected %s", order[1], expected)
}

if expected := "bar/baz"; expected != order[2] {
t.Fatalf("failed order element: %s, expected %s", order[2], expected)
}

if expected := "another/*"; expected != order[3] {
t.Fatalf("failed order element: %s, expected %s", order[3], expected)
}

if expected := "foo/bar"; expected != order[4] {
t.Fatalf("failed order element: %s, expected %s", order[4], expected)
}
}

0 comments on commit 9a78f7b

Please sign in to comment.