From a3d81409fc919d38434c470e7563e3bd7fc6a4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sun, 24 Nov 2024 21:51:46 +0100 Subject: [PATCH] feat(docs+core): documented and changed how server errors work --- builder.go | 272 +++++++++++++++++++++++++++++++++++------ errors.go | 67 ++++++++-- errors_test.go | 37 ++++++ handler.go | 19 ++- helpers.go | 5 + hooks.go | 19 ++- middleware.go | 10 ++ middleware/logger.go | 14 ++- middleware/problems.go | 3 + middleware/recover.go | 21 +++- problem.go | 78 +++++++++++- responder.go | 9 ++ router.go | 129 ++++++++++++++++++- testing.go | 9 ++ utils.go | 2 + utils/accept.go | 40 +++++- 16 files changed, 661 insertions(+), 73 deletions(-) create mode 100644 errors_test.go diff --git a/builder.go b/builder.go index 4bcb6eb..d2d45f2 100644 --- a/builder.go +++ b/builder.go @@ -8,22 +8,103 @@ import ( "net/http" ) +// BuilderHandler type is used to define how a request +// should be responded based on the given [Builder]. +// +// This function is what ultimately gets called whenever +// akumu needs to send a response. type BuilderHandler func(http.ResponseWriter, *http.Request, Builder) +// Builder is the type used to build and handle an +// incoming http request's response. That means that +// it contains the necesary utilities to handle the +// [http.Request]. +// +// While the internals of this type are private, there's +// a lot of building methods attached to it, making it +// possible to quickly build Builder types. +// +// One should most likely not build a request from scratch +// and instead use the [Response] and [Failed] functions +// to create a new [Builder]. +// +// The default [BuilderHandler] is [DefaultResponderHandler]. type Builder struct { + + // handler is the main response handler that will be executed + // whenever there's the need to resolve this builder into + // an actual http response. That means, writing the given result + // of the builder into an [http.ResponseWriter]. handler BuilderHandler - status int + + // status is the http status code that the builder currently holds. + // + // Usually, the handler determines what to do with but, but the most + // likely outcome is for it to be the actual response http code. + status int + + // headers stores a [http.Header] of the actual http response. + // + // Although the handler determines what to do with it, the most + // likely outcome is for it to be the actual response http headers. headers http.Header - body io.Reader - err error - stream <-chan []byte - writer func(writer http.ResponseWriter) + + // err stores if the current builder is expected to fail. This is + // useful as it contains the specific error that should be "thrown". + // + // There's specific behaviour defined in the [DefaultResponderHandler] + // for handling errors that also implement [Responder]. + // + // The [DefaultResponderHandler] has a specific priority, given there's also + // the body, err, stream and writer possibilities. + // + // The handler determines what to do with it. + err error + + // body is an [io.Reader] that will most likely be used as the response + // body. That means that it will be read and written to the actual + // [http.ResponseWriter]. + // + // The [DefaultResponderHandler] has a specific priority, given there's also + // the body, err, stream and writer possibilities. + // + // The handler determines what to do with it. + body io.Reader + + // stream is a channel that can be used to directly stream parts of the + // response, effectively using HTTP streaming using [http.ResponseWriter]. + // + // The [DefaultResponderHandler] has a specific priority, given there's also + // the body, err, stream and writer possibilities. + // + // The handler determines what to do with it. + stream <-chan []byte + + // writer is a custom function that can be used directly to manipulate + // the actual response that's sent. This is useful for custom streaming + // or file downloads. + // + // The [DefaultResponderHandler] has a specific priority, given there's also + // the body, err, stream and writer possibilities. + // + // The handler determines what to do with it. + writer func(writer http.ResponseWriter) } var ( + // ErrWriterRequiresFlusher is an error that determines that + // the given response writer needs a flusher in order to push + // changes to the [http.ResponseWriter]. This should most likely + // not happen due [http.ResponseWriter] already implementing [http.Flusher]. ErrWriterRequiresFlusher = errors.New("response writer requires a flusher") ) +// writeHeaders writes the given response headers by calling +// [http.ResponseWriter]'s `WriteHeader` method. +// +// This function also returns true if the response status code is +// between [500, 599], making it possible to know if there was +// a server error. func writeHeaders(writer http.ResponseWriter, builder Builder) bool { for key, values := range builder.headers { for _, value := range values { @@ -36,6 +117,23 @@ func writeHeaders(writer http.ResponseWriter, builder Builder) bool { return builder.status >= 500 && builder.status < 600 } +// DefaultResponderHandler is the default handler that's used in +// a [Builder]. This handler does most of the things expected +// for akumu to handle http responses, although it can be customized +// if needed. +// +// By default, this handler does make use of the [OnErrorHook] that is +// found in the request's context, on the [OnErrorKey] key if server error happens. +// +// By default, this handler does handle the [Builder] in the following order of priority: +// 1. errors +// 2. writer +// 3. body +// 4. stream +// 5. default (no body) +// +// This means that if a [Builder] contain more than one possible response type, only the +// first one defined, following the order above, will be executed. func DefaultResponderHandler(writer http.ResponseWriter, request *http.Request, builder Builder) { onError, hasOnError := request.Context().Value(OnErrorKey{}).(OnErrorHook) @@ -47,15 +145,13 @@ func DefaultResponderHandler(writer http.ResponseWriter, request *http.Request, } if builder.writer != nil { - if writeHeaders(writer, builder) { - if hasOnError { - onError(ServerError{ - Code: builder.status, - URL: request.URL.String(), - Text: http.StatusText(builder.status), - Kind: ServerErrorWriter, - }) + if writeHeaders(writer, builder) && hasOnError { + serverErr := ErrServer{ + Code: builder.status, + Request: request, } + + onError(errors.Join(serverErr, ErrServerWriter)) } builder.writer(writer) @@ -73,16 +169,13 @@ func DefaultResponderHandler(writer http.ResponseWriter, request *http.Request, return } - if writeHeaders(writer, builder) { - if hasOnError { - onError(ServerError{ - Code: builder.status, - URL: request.URL.String(), - Text: http.StatusText(builder.status), - Kind: ServerErrorBody, - Body: string(body), - }) + if writeHeaders(writer, builder) && hasOnError { + serverErr := ErrServer{ + Code: builder.status, + Request: request, } + + onError(errors.Join(serverErr, ErrServerBody)) } writer.Write(body) @@ -101,15 +194,13 @@ func DefaultResponderHandler(writer http.ResponseWriter, request *http.Request, return } - if writeHeaders(writer, builder) { - if hasOnError { - onError(ServerError{ - Code: builder.status, - URL: request.URL.String(), - Text: http.StatusText(builder.status), - Kind: ServerErrorStream, - }) + if writeHeaders(writer, builder) && hasOnError { + serverErr := ErrServer{ + Code: builder.status, + Request: request, } + + onError(errors.Join(serverErr, ErrServerStream)) } flusher.Flush() @@ -130,18 +221,21 @@ func DefaultResponderHandler(writer http.ResponseWriter, request *http.Request, } } - if writeHeaders(writer, builder) { - if hasOnError { - onError(ServerError{ - Code: builder.status, - URL: request.URL.String(), - Text: http.StatusText(builder.status), - Kind: ServerErrorDefault, - }) + if writeHeaders(writer, builder) && hasOnError { + serverErr := ErrServer{ + Code: builder.status, + Request: request, } + + onError(errors.Join(serverErr, ErrServerDefault)) } } +// Response is used to create a builder with the given +// HTTP status. +// +// This starts a new builder that can directly be returned +// in a [Handler], as a [Builder] implements the error interface. func Response(status int) Builder { return Builder{ handler: DefaultResponderHandler, @@ -152,27 +246,58 @@ func Response(status int) Builder { } } +// Failed is used to create a builder with the given +// error as the main factor. It should only be used +// by either custom errors that modify responses (such +// as the [Problem] errors) or errors that are often +// server errors (such as database errors) that are +// controled but out of the nature of the request. +// +// This is basically an alias for a [Response] with +// a status code [http.StatusInternalServerError] and +// a failed error. +// +// This starts a new builder that can directly be returned +// in a [Handler], as a [Builder] implements the error interface. func Failed(err error) Builder { return Response(http.StatusInternalServerError). Failed(err) } +// Error implements the error interface for any [Builder], +// making it possible to be used in a [Handler]. func (builder Builder) Error() string { return http.StatusText(builder.status) } +// Status sets the HTTP status of the [Builder] into +// the desired one, leading the the response http status code. func (builder Builder) Status(status int) Builder { builder.status = status return builder } +// Headers sets the given HTTP headers to the builder. +// +// This is an override operation and does not merge previous +// headers. func (builder Builder) Headers(headers http.Header) Builder { builder.headers = headers return builder } +// Header sets a new header to the [Builder]. +// +// This overrides any previous headers with the same key. +// +// Because of immutability, this method creates a copy +// of the headers first, so previous [Builder] instances +// are not affected. +// +// You may look into [Builder.AppendHeader] if you prefer +// to append rather than override. func (builder Builder) Header(key, value string) Builder { builder.headers = builder.headers.Clone() builder.headers.Set(key, value) @@ -180,6 +305,16 @@ func (builder Builder) Header(key, value string) Builder { return builder } +// AppendHeader appends a new header to the [Builder]. +// +// This does not overrides any previous headers with the same key. +// +// Because of immutability, this method creates a copy +// of the headers first, so previous [Builder] instances +// are not affected. +// +// You may look into [Builder.Header] if you prefer +// to override rather than append. func (builder Builder) AppendHeader(key, value string) Builder { builder.headers = builder.headers.Clone() builder.headers.Add(key, value) @@ -187,16 +322,30 @@ func (builder Builder) AppendHeader(key, value string) Builder { return builder } +// Body sets the [Builder]'s body reader to a new +// [bytes.Reader] with the given []byte. +// +// If you already have a reader use the [Builder.BodyReader]. func (builder Builder) Body(body []byte) Builder { return builder.BodyReader(bytes.NewReader(body)) } +// Body sets the [Builder]'s body reader. +// +// If you don't have a reader use the [Builder.Body]. func (builder Builder) BodyReader(body io.Reader) Builder { builder.body = body return builder } +// Stream sets the [Builder] to stream from the given channel. +// +// The streaming will end as soon as the channel is closed or +// whenever the request's context is canceled. +// +// It also sets the aproppiate request headers to stream, most +// notably, the Cache-Control to `no-cache` and Connection to `keep-alive`. func (builder Builder) Stream(stream <-chan []byte) Builder { builder.stream = stream @@ -205,12 +354,23 @@ func (builder Builder) Stream(stream <-chan []byte) Builder { Header("Connection", "keep-alive") } +// Stream sets the [Builder] to stream from the given channel. +// +// The streaming will end as soon as the channel is closed or +// whenever the request's context is canceled. +// +// A part from the [Stream] headers, this method additionally +// sets the Content-Type to `text/event-stream`. func (builder Builder) SSE(stream <-chan []byte) Builder { return builder. Header("Content-Type", "text/event-stream"). Stream(stream) } +// Cookie sets a new [http.Cookie] to the [Builder]'s response. +// +// This effectively appends a new "Set-Cookie" header with the +// cokkies' value. func (builder Builder) Cookie(cookie http.Cookie) Builder { if c := cookie.String(); c != "" { return builder.AppendHeader("Set-Cookie", c) @@ -219,24 +379,42 @@ func (builder Builder) Cookie(cookie http.Cookie) Builder { return builder } +// Failed indicates that the current [Builder] is +// intended to fail with the given error. +// +// The status code is not changed, but you may use +// [Builder.Status] or use [Failed] directly. func (builder Builder) Failed(err error) Builder { builder.err = err return builder } +// Text sets the response body text to the given +// string and also makes sure the Content-Type +// is set to "text/plain". func (builder Builder) Text(body string) Builder { return builder. Header("Content-Type", "text/plain"). Body([]byte(body)) } +// HTML sets the response body text to the given +// string and also makes sure the Content-Type +// is set to "text/html". func (builder Builder) HTML(html string) Builder { return builder. Header("Content-Type", "text/html"). Body([]byte(html)) } +// JSON encodes the given body variable into the +// request's body and also makes sure the Content-Type +// is set to "application/json". +// +// If the encoding fails, the [Builder] is set to a +// status of [http.StatusInternalServerError] with the +// failed error. func (builder Builder) JSON(body any) Builder { buffer := &bytes.Buffer{} @@ -251,32 +429,50 @@ func (builder Builder) JSON(body any) Builder { BodyReader(buffer) } +// BodyWriter marks the [Builder] as a custom writer function +// making the handler execute the logic passed here when a response +// is written. +// +// This is useful when you need a lot of control over how a response +// is written to the actual [http.ResponseWriter] such as for file downloads. func (builder Builder) BodyWriter(writer func(writer http.ResponseWriter)) Builder { builder.writer = writer return builder } +// Handler sets a custom [BuilderHandler] into the current [Builder]. +// +// This is not likely something that is needed as the [DefaultResponderHandler] +// takes care of most needed things already. func (builder Builder) Handler(handler BuilderHandler) Builder { builder.handler = handler return builder } +// Handle executes the given [Builder] with the given response writer and request. +// +// It internally passes it to the [Builder]'s Handler. func (builder Builder) Handle(response http.ResponseWriter, request *http.Request) { builder.handler(response, request, builder) } +// Respond implements [Responder] interface on a [Builder]. func (builder Builder) Respond(request *http.Request) Builder { return builder } +// WithoutError is used to remove any errors from the current [Builder]. func (builder Builder) WithoutError() Builder { builder.err = nil return builder } +// Merge merges another builder with this one. +// +// The [Builder] passed as a parameter takes precedence. func (builder Builder) Merge(other Builder) Builder { if other.status != 0 { builder.status = other.status diff --git a/errors.go b/errors.go index c0660b7..7e0f9b6 100644 --- a/errors.go +++ b/errors.go @@ -1,28 +1,69 @@ package akumu -import "fmt" +import ( + "errors" + "fmt" + "net/http" +) -type ServerErrorKind string +// ErrServer is the base error that akumu +// will return whenever there's any error +// in the >=500 - <600 range. +type ErrServer struct { -type ServerError struct { + // Code determines the response's status code. + // This is used to understand what response + // was sent due to that particular error. Code int - URL string - Text string - Kind ServerErrorKind - Body string + + // Request stores the actual http request that + // failed to execute. This is very useful to + // extract information such as URL or headers + // to understand more about what caused this error. + Request *http.Request } var ( - ServerErrorWriter ServerErrorKind = "writer" - ServerErrorBody ServerErrorKind = "body" - ServerErrorStream ServerErrorKind = "stream" - ServerErrorDefault ServerErrorKind = "default" + // ErrServerWriter determines that the + // server error comes from executing logic + // around [Builder.BodyWriter] or derivates + // that use this method. + ErrServerWriter = errors.New("builder is a body writer") + + // ErrServerBody determines that the + // server error comes from executing logic + // around [Builder.BodyReader] or derivates + // that use this method. + ErrServerBody = errors.New("builder is a body reader") + + // ErrServerStream determines that the + // server error comes from executing logic + // around [Builder.Stream] or derivates + // that use this method. + ErrServerStream = errors.New("builder is a body stream") + + // ErrServerDefault determines that the + // server error comes from executing logic + // around having no body, stream nor writer + // involved in delivering a response. + ErrServerDefault = errors.New("builder is a default no body") ) -func (err ServerError) Error() string { +// Error implements the error interface +// for a server error. +func (err ErrServer) Error() string { return fmt.Sprintf( "%d: %s", err.Code, - err.Text, + http.StatusText(err.Code), ) } + +// Is determines if the given target error +// is an [ErrServer]. This is used when dealing +// with errors using [errors.Is] and [errors.As]. +func (err ErrServer) Is(target error) bool { + _, ok := target.(ErrServer) + + return ok +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..220123f --- /dev/null +++ b/errors_test.go @@ -0,0 +1,37 @@ +package akumu_test + +import ( + "errors" + "net/http" + "testing" + + "github.com/studiolambda/akumu" +) + +func TestServerErrors(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/foo/bar", nil) + + if err != nil { + t.Fatalf("unable to build http request: %s", err) + } + + serverErr := akumu.ErrServer{ + Code: http.StatusInternalServerError, + Request: req, + } + + if !errors.Is(serverErr, akumu.ErrServer{}) { + t.Fatalf("server error cannot be resolved to its error type") + } + + serr := akumu.ErrServer{} + ok := errors.As(serverErr, &serr) + + if !ok { + t.Fatalf("server error cannot be resolved to its error type") + } + + if serr.Code != serverErr.Code { + t.Fatalf("resolved server error code is different from original") + } +} diff --git a/handler.go b/handler.go index 32e4f61..a803583 100644 --- a/handler.go +++ b/handler.go @@ -4,8 +4,14 @@ import ( "net/http" ) +// Handler is the akumu's equivalent of the [http.Handler]. +// +// Is is the function that can take care of a request and +// handle a correct response for it. type Handler func(*http.Request) error +// handleResponder is a helper that will handle specific [Responder] +// responses. It also takes care of any parent [Builder]. func handleResponder(writer http.ResponseWriter, request *http.Request, parent *Builder, responder Responder) { if parent != nil { parent. @@ -20,6 +26,8 @@ func handleResponder(writer http.ResponseWriter, request *http.Request, parent * Handle(writer, request) } +// handleNoError is called whenever there's a response that does not +// contain any error. For example, returning `nil` in a handler. func handleNoError(writer http.ResponseWriter, request *http.Request, parent *Builder) { if parent != nil { parent.Handle(writer, request) @@ -30,6 +38,7 @@ func handleNoError(writer http.ResponseWriter, request *http.Request, parent *Bu Response(http.StatusOK).Handle(writer, request) } +// handle takes care of responding to a given request. func handle(writer http.ResponseWriter, request *http.Request, err error, parent *Builder) { if err == nil { handleNoError(writer, request, parent) @@ -57,16 +66,22 @@ func handle(writer http.ResponseWriter, request *http.Request, err error, parent Handle(writer, request) } +// ServeHTTP implements the [http.Handler] interface to have +// compatibility with the http package. func (handler Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { handle(writer, request, handler(request), nil) } +// HandlerFunc transforms the [Handler] into an [http.HandlerFunc]. func (handler Handler) HandlerFunc() http.HandlerFunc { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { handler.ServeHTTP(writer, request) }) } -func HandlerFunc(handler func(*http.Request) error) http.HandlerFunc { - return Handler(handler).HandlerFunc() +// HandlerFunc transforms a [Handler] into an [http.HandlerFunc]. +// +// This function is a simple alias of [Handler.HandlerFunc]. +func HandlerFunc(handler Handler) http.HandlerFunc { + return handler.HandlerFunc() } diff --git a/helpers.go b/helpers.go index deb34af..bb98e1d 100644 --- a/helpers.go +++ b/helpers.go @@ -6,6 +6,11 @@ import ( ) // JSON decodes the given request payload into `T` +// +// This is very usefull for cases where you want +// to quickly take care of decoding JSON payloads into +// specific types. It automatically disallows unknown +// fields and uses [json.Decoder] with the [http.Request.Body]. func JSON[T any](request *http.Request) (T, error) { result := *new(T) diff --git a/hooks.go b/hooks.go index ec092ee..0f9dc19 100644 --- a/hooks.go +++ b/hooks.go @@ -1,5 +1,22 @@ package akumu +// OnErrorKey is used in the [http.Request]'s context +// to store the hook or callback that will run whenever +// a server error is found. type OnErrorKey struct{} -type OnErrorHook func(ServerError) +// OnErrorHook stores the current handler that will take +// care of handling a server error in case it's found. +// +// The `error` is always a [ErrServer] and it's joined with +// either of those: +// - [ErrServerWriter] +// - [ErrServerBody] +// - [ErrServerStream] +// - [ErrServerDefault] +// +// To "extend" the hook, you can make use of composition +// to also call the previous function in the new one, creating +// a chain of handlers. Keep in mind you will need to check +// for `nil` if that was the case. +type OnErrorHook func(error) diff --git a/middleware.go b/middleware.go index 13dba63..1419d8d 100644 --- a/middleware.go +++ b/middleware.go @@ -2,4 +2,14 @@ package akumu import "net/http" +// Middleware is just an alias for a function that +// takes a handler and returns another handler. +// +// Use this in places where the common pattern +// of middlewares is needed. +// +// To maintain compatibility with the ecosystem, +// the handlers used by middlewares are [http.Handler] +// instead of akumu's [Handler]. Use the [Builder.Handle] +// to help when dealing with akumu in middlewares. type Middleware = func(http.Handler) http.Handler diff --git a/middleware/logger.go b/middleware/logger.go index 5f7ee92..1369ff9 100644 --- a/middleware/logger.go +++ b/middleware/logger.go @@ -8,24 +8,31 @@ import ( "github.com/studiolambda/akumu" ) +// Logger middleware sets a [slog.Logger] instance +// as the logger for any http requests. func Logger(logger *slog.Logger) akumu.Middleware { return func(handler http.Handler) http.Handler { return LoggerWith(handler, logger) } } +// LoggerDefault middleware sets the [slog.Default] instance +// as the logger for any http requests. func LoggerDefault() akumu.Middleware { return func(handler http.Handler) http.Handler { return LoggerWith(handler, slog.Default()) } } +// LoggerWith middleware sets a [slog.Logger] instance +// as the logger for any http requests but this time accepting +// the handler as a parameter. func LoggerWith(handler http.Handler, logger *slog.Logger) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { parent, hasParent := request.Context().Value(akumu.OnErrorKey{}).(akumu.OnErrorHook) handler.ServeHTTP(writer, request.WithContext( - context.WithValue(request.Context(), akumu.OnErrorKey{}, func(err akumu.ServerError) { + context.WithValue(request.Context(), akumu.OnErrorKey{}, func(err akumu.ErrServer) { if hasParent && parent != nil { parent(err) } @@ -34,9 +41,8 @@ func LoggerWith(handler http.Handler, logger *slog.Logger) http.Handler { request.Context(), "server error", "code", err.Code, - "text", err.Text, - "url", err.URL, - "kind", err.Kind, + "text", http.StatusText(err.Code), + "url", err.Request.URL, ) }), )) diff --git a/middleware/problems.go b/middleware/problems.go index 29f1958..7073305 100644 --- a/middleware/problems.go +++ b/middleware/problems.go @@ -7,6 +7,9 @@ import ( "github.com/studiolambda/akumu" ) +// Problems sets the given [akumu.ProblemControls] to the [http.Request]. +// +// The [akumu.Problem] will respect those controls when a problem is handled. func Problems(controls akumu.ProblemControls) akumu.Middleware { return func(handler http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/middleware/recover.go b/middleware/recover.go index 6100b2d..89f3bb9 100644 --- a/middleware/recover.go +++ b/middleware/recover.go @@ -9,9 +9,24 @@ import ( ) var ( - ErrUnexpected = errors.New("an unexpected error occurred") + // ErrRecoverUnexpectedError is the default error that's passed to + // the recover response when the error cannot be determined from the + // given recover()'s value. + ErrRecoverUnexpectedError = errors.New("an unexpected error occurred") ) +// Recover recovers any panics during a [akumu.Handler] execution. +// +// If the recover() value is of type error, this is directly passed to +// the [akumu.Failed] method. +// +// If it's of type string, a new error is created +// with that string. +// +// If it implements [fmt.Stringer] then that string +// will be used. +// +// If none matches, [ErrRecoverUnexpectedError] is returned. func Recover() akumu.Middleware { return func(handler http.Handler) http.Handler { return RecoverWith(handler, func(value any) error { @@ -24,11 +39,13 @@ func Recover() akumu.Middleware { return errors.New(err.String()) } - return ErrUnexpected + return ErrRecoverUnexpectedError }) } } +// RecoverWith allows a handler to decide what to do with the recover() value, +// allowing to customize the error that [akumu.Failed] receives. func RecoverWith(handler http.Handler, handle func(value any) error) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { defer func() { diff --git a/problem.go b/problem.go index ab0d622..d31d7e6 100644 --- a/problem.go +++ b/problem.go @@ -27,10 +27,80 @@ type Problem struct { // to serialize and de-serialize properties. additional map[string]any - Type string - Title string - Detail string - Status int + // Type member is a JSON string containing a URI reference + // that identifies the problem type. + // + // Consumers MUST use the Type URI (after resolution, + // if necessary) as the problem type's primary identifier. + // + // When this member is not present, its value is assumed + // to be "about:blank". + Type string + + // Title member is a string containing a short, + // human-readable summary of the problem type. + // + // It SHOULD NOT change from occurrence to occurrence of + // the problem, except for localization (e.g., using proactive + // content negotiation. + // + // The Title string is advisory and is included only for users + // who are unaware of and cannot discover the semantics of the + // type URI (e.g., during offline log analysis). + Title string + + // Detail member is a JSON string containing a human-readable + // explanation specific to this occurrence of the problem. + // + // The Detail string, if present, ought to focus on helping + // the client correct the problem, rather than giving debugging information. + // + // Consumers SHOULD NOT parse the Detail member for information. + // + // Extensions are more suitable and less error-prone ways to obtain + // such information. + Detail string + + // The Status member is a JSON number indicating the HTTP status + // code generated by the origin server for this occurrence of the problem. + // + // The "status" member, if present, is only advisory; it conveys the HTTP + // status code used for the convenience of the consumer. + // + // Generators MUST use the same status code in the actual HTTP response, + // to assure that generic HTTP software that does not understand this format + // still behaves correctly. + // + // Consumers can use the status member to determine what the original status + // code used by the generator was when it has been changed + // (e.g., by an intermediary or cache) and when a message's content is + // persisted without HTTP information. Generic HTTP software will still + // use the HTTP status code. + Status int + + // The "instance" member is a JSON string containing a URI reference that + // identifies the specific occurrence of the problem. + // + // When the "instance" URI is dereferenceable, the problem details object + // can be fetched from it. It might also return information about the problem + // occurrence in other formats through use of proactive content negotiation. + // + // When the "instance" URI is not dereferenceable, it serves as a unique identifier + // for the problem occurrence that may be of significance to the server but is + // opaque to the client. + // + // When "instance" contains a relative URI, it is resolved relative to the document's + // base URI. However, using relative URIs can cause confusion, and they might not + // be handled correctly by all implementations. + // + // For example, if the two resources "https://api.example.org/foo/bar/123" + // and "https://api.example.org/widget/456" both respond with an "instance" equal + // to the relative URI reference "example-instance", when resolved they will + // identify different resources ("https://api.example.org/foo/bar/example-instance" + // and "https://api.example.org/widget/example-instance", respectively). + // + // As a result, it is RECOMMENDED that absolute URIs be used in "instance" when possible, + // and that when relative URIs are used, they include the full path (e.g., "/instances/123"). Instance string } diff --git a/responder.go b/responder.go index a1489b4..675ed69 100644 --- a/responder.go +++ b/responder.go @@ -2,6 +2,15 @@ package akumu import "net/http" +// Responder interface defines a common way for types +// to be able to respond to an HTTP request in a custom way. +// +// For example, this is used by the [Problem] type to define +// a custom response. +// +// Keep in mind that because it's often necesary to return this +// in a [Handler], it's likely needed to implement the error +// interface as well. type Responder interface { Respond(request *http.Request) Builder } diff --git a/router.go b/router.go index f64a1b1..1137ab8 100644 --- a/router.go +++ b/router.go @@ -9,13 +9,45 @@ import ( "strings" ) +// Router is the structure that handles +// http routing in an akumu application. +// +// This router is completly optional and +// uses [http.ServeMux] under the hood +// to register all the routes. +// +// It also handles some patterns automatically, +// such as {$}, that is appended on each route +// automatically, regardless of the pattern. type Router struct { - native *http.ServeMux - pattern string - parent *Router + + // native stores the actual [http.ServeMux] + // that's used internally to register the routes. + native *http.ServeMux + + // pattern stores the current pattern that will be + // used as a prefix to all the route registrations + // on this router. This pattern is already joined with + // the parent router's pattern if any. + pattern string + + // parent stores the parent [Router] if any. This is + // used to correctly resolve the [http.ServeMux] to + // use by sub-routers so that they all register the + // routes to the same [http.ServeMux]. + parent *Router + + // middlewares stores the actual middlewares that will + // be applied to any route registration on the current + // router. It already contains all the middlewares of + // the parent's [Router] if any. middlewares []Middleware } +// NewRouter creates a new [Router] instance and +// automatically creates all the needed components +// such as the middleware list or the native +// [http.ServeMux] that's used under the hood. func NewRouter() *Router { return &Router{ native: http.NewServeMux(), @@ -25,6 +57,15 @@ func NewRouter() *Router { } } +// Group uses the given pattern to automatically +// mount a sub-router that has that pattern as a +// prefix. +// +// This means that any route registered with the +// sub-router will also have the given pattern suffixed. +// +// Keep in mind this can be nested as well, meaning that +// many sub-routers may be grouped, creating complex patterns. func (router *Router) Group(pattern string, subrouter func(*Router)) { subrouter(&Router{ native: nil, // parent's native will be used @@ -34,6 +75,14 @@ func (router *Router) Group(pattern string, subrouter func(*Router)) { }) } +// With does create a new sub-router that automatically applies +// the given middlewares. +// +// This is very usefull when used to inline some middlewares to +// specific routes. +// +// In constrast to [Router.Use] method, it does create a new +// sub-router instead of modifying the current router. func (router *Router) With(middlewares ...Middleware) *Router { return &Router{ native: nil, // parent's native will be used @@ -43,6 +92,10 @@ func (router *Router) With(middlewares ...Middleware) *Router { } } +// mux returns the native [http.ServeMux] that is used +// internally by the router. This exists because sub-routers +// must use the same [http.ServeMux] and therefore, there's +// some recursivity involved to get the same [http.ServeMux]. func (router *Router) mux() *http.ServeMux { if router.parent != nil { return router.parent.mux() @@ -51,6 +104,10 @@ func (router *Router) mux() *http.ServeMux { return router.native } +// wrap makes an [http.Handler] wrapped by the current routers' +// middlewares. This means that the resulting [http.Handler] is +// the same as first calling the router middlewares and then the +// provided [http.Handler]. func (router *Router) wrap(handler http.Handler) http.Handler { for _, middleware := range router.middlewares { handler = middleware(handler) @@ -59,10 +116,32 @@ func (router *Router) wrap(handler http.Handler) http.Handler { return handler } +// Use appends to the current router the given middlewares. +// +// Subsequent route registrations will be wrapped with any previous +// middlewares that the router had defined, plus the new ones +// that are registered after this call. +// +// In constrats with the [Router.With] method, this one does modify +// the current router instead of returning a new sub-router. func (router *Router) Use(middlewares ...Middleware) { router.middlewares = append(router.middlewares, middlewares...) } +// Method registers a new handler to the router with the given +// method and pattern. This is usefull if you need to dynamically +// register a route to the router using a string as the method. +// +// Typically, the method string should be one of the following: +// - [http.MethodGet] +// - [http.MethodHead] +// - [http.MethodPost] +// - [http.MethodPut] +// - [http.MethodPatch] +// - [http.MethodDelete] +// - [http.MethodConnect] +// - [http.MethodOptions] +// - [http.MethodTrace] func (router *Router) Method(method string, pattern string, handler Handler) { pattern = path.Join(router.pattern, pattern) @@ -75,48 +154,74 @@ func (router *Router) Method(method string, pattern string, handler Handler) { Handle(fmt.Sprintf("%s %s{$}", method, pattern), router.wrap(handler)) } +// Get registers a new handler to the router using [Router.Method] +// and using the [http.MethodGet] as the method parameter. func (router *Router) Get(pattern string, handler Handler) { router.Method(http.MethodGet, pattern, handler) } +// Head registers a new handler to the router using [Router.Method] +// and using the [http.MethodHead] as the method parameter. func (router *Router) Head(pattern string, handler Handler) { router.Method(http.MethodHead, pattern, handler) } +// Post registers a new handler to the router using [Router.Method] +// and using the [http.MethodPost] as the method parameter. func (router *Router) Post(pattern string, handler Handler) { router.Method(http.MethodPost, pattern, handler) } +// Put registers a new handler to the router using [Router.Method] +// and using the [http.MethodPut] as the method parameter. func (router *Router) Put(pattern string, handler Handler) { router.Method(http.MethodPut, pattern, handler) } +// Patch registers a new handler to the router using [Router.Method] +// and using the [http.MethodPatch] as the method parameter. func (router *Router) Patch(pattern string, handler Handler) { router.Method(http.MethodPatch, pattern, handler) } +// Delete registers a new handler to the router using [Router.Method] +// and using the [http.MethodDelete] as the method parameter. func (router *Router) Delete(pattern string, handler Handler) { router.Method(http.MethodDelete, pattern, handler) } +// Connect registers a new handler to the router using [Router.Method] +// and using the [http.MethodConnect] as the method parameter. func (router *Router) Connect(pattern string, handler Handler) { router.Method(http.MethodConnect, pattern, handler) } +// Options registers a new handler to the router using [Router.Method] +// and using the [http.MethodOptions] as the method parameter. func (router *Router) Options(pattern string, handler Handler) { router.Method(http.MethodOptions, pattern, handler) } +// Trace registers a new handler to the router using [Router.Method] +// and using the [http.MethodTrace] as the method parameter. func (router *Router) Trace(pattern string, handler Handler) { router.Method(http.MethodTrace, pattern, handler) } +// ServeHTTP is the method that will make the router implement +// the [http.Handler] interface, making it possible to be used +// as a handler in places like [http.Server]. func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { router. native. ServeHTTP(writer, request) } +// Has reports whether the given pattern is registered in the router +// with the given method. +// +// Alternatively, check out the [Router.Matches] to use an [http.Request] +// as the parameter. func (router *Router) Has(method string, pattern string) bool { if request, err := http.NewRequest(method, pattern, nil); err == nil { return router.Matches(request) @@ -125,12 +230,21 @@ func (router *Router) Has(method string, pattern string) bool { return false } +// Matches reports whether the given [http.Request] match any registered +// route in the router. +// +// This means that, given the request method and the +// URL, a [Handler] can be resolved. func (router *Router) Matches(request *http.Request) bool { _, ok := router.HandlerMatch(request) return ok } +// Handler returns the [Handler] that matches the given method and pattern. +// The second return value determines if the [Handler] was found or not. +// +// For matching against an [http.Request]use the [Router.HandlerMatch] method. func (router *Router) Handler(method string, pattern string) (Handler, bool) { if request, err := http.NewRequest(method, pattern, nil); err == nil { return router.HandlerMatch(request) @@ -139,6 +253,10 @@ func (router *Router) Handler(method string, pattern string) (Handler, bool) { return nil, false } +// HandlerMatch returns the [Handler] that matches the given [http.Request]. +// The second return value determines if the [Handler] was found or not. +// +// For matching against a method and a pattern, use the [Router.Handler] method. func (router *Router) HandlerMatch(request *http.Request) (Handler, bool) { if handler, pattern := router.native.Handler(request); pattern != "" { if handler, ok := handler.(Handler); ok { @@ -149,6 +267,11 @@ func (router *Router) HandlerMatch(request *http.Request) (Handler, bool) { return nil, false } +// Record returns a [httptest.ResponseRecorder] that can be used to inspect what +// the given http request would have returned as a response. +// +// This method is a shortcut of calling [RecordHandler] with the router as the +// [Handler] and the given request. func (router *Router) Record(request *http.Request) *httptest.ResponseRecorder { return RecordHandler(router, request) } diff --git a/testing.go b/testing.go index 3b1792e..46e0e5b 100644 --- a/testing.go +++ b/testing.go @@ -5,14 +5,23 @@ import ( "net/http/httptest" ) +// RecordServer records what a given [http.Server] would give as a reponse to a [http.Request]. +// +// The response is recorded using a [httptest.ResponseRecorder]. func RecordServer(server *http.Server, request *http.Request) *httptest.ResponseRecorder { return RecordHandler(server.Handler, request) } +// Record records what a given [Handler] would give as a reponse to a [http.Request]. +// +// The response is recorded using a [httptest.ResponseRecorder]. func Record(handler Handler, request *http.Request) *httptest.ResponseRecorder { return RecordHandler(handler, request) } +// Record records what a given [http.Handler] would give as a reponse to a [http.Request]. +// +// The response is recorded using a [httptest.ResponseRecorder]. func RecordHandler(handler http.Handler, request *http.Request) *httptest.ResponseRecorder { recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) diff --git a/utils.go b/utils.go index c041e64..18932a3 100644 --- a/utils.go +++ b/utils.go @@ -2,6 +2,8 @@ package akumu import "unicode" +// lowercase makes the input string a lowercased +// string using [unicode.ToLower] on each character. func lowercase(str string) string { result := make([]byte, len(str)) diff --git a/utils/accept.go b/utils/accept.go index 79e1cc4..d5c7526 100644 --- a/utils/accept.go +++ b/utils/accept.go @@ -3,20 +3,26 @@ package utils import ( "mime" "net/http" - "sort" + "slices" "strconv" "strings" ) +// acceptPair is a simple structure that +// holds the media and quality of an accept value. type acceptPair struct { media string quality float64 } +// Accept is a type designed to help working +// with header values found in the "Accept" header. type Accept struct { values []acceptPair } +// ParseAccept creates a new [Accept] based on a +// [http.Request] by parsing its headers. func ParseAccept(request *http.Request) Accept { accept := Accept{ values: make([]acceptPair, 0), @@ -48,6 +54,10 @@ func ParseAccept(request *http.Request) Accept { return accept } +// find looks for a given media in the accept header and +// returns its [acceptPair] if found. +// +// The second return value is true when is found, and false otherwise. func (accept Accept) find(media string) (acceptPair, bool) { for _, pair := range accept.values { if media == pair.media { @@ -74,12 +84,18 @@ func (accept Accept) find(media string) (acceptPair, bool) { return acceptPair{}, false } +// Accepts reports whether the given media +// is found in the accept headers. func (accept Accept) Accepts(media string) bool { _, found := accept.find(media) return found } +// Quality returns the quality of the given media +// found in the accept headers. +// +// Returns 0 if not found. func (accept Accept) Quality(media string) float64 { if pair, found := accept.find(media); found { return pair.quality @@ -88,16 +104,28 @@ func (accept Accept) Quality(media string) float64 { return 0 } +// Order creates an ordered slice that contains the +// actual acceptance order based on the accept quality. func (accept Accept) Order() []string { + values := slices.Clone(accept.values) + + slices.SortFunc(values, func(a, b acceptPair) int { + if a.quality > b.quality { + return -1 + } + + if a.quality < b.quality { + return 1 + } + + return 0 + }) + keys := make([]string, len(accept.values)) - for i, pair := range accept.values { + for i, pair := range values { keys[i] = pair.media } - sort.SliceStable(keys, func(i, j int) bool { - return accept.values[i].quality > accept.values[j].quality - }) - return keys }