From ab50ab85596189ee6c71c869ecc7b292588cabb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Tue, 13 Aug 2024 12:08:46 +0200 Subject: [PATCH] Add HTTP error wrapper (#46) * Add HTTP error wrapper This adds a HTTP error wrapper for handlers, and an error interface to use it. This is meant to make http error handling less frustrating. * Rename based on feedback --- dsp/catalog_handlers.go | 62 +++++++++++++++-------- dsp/common_handlers.go | 24 ++++----- dsp/error_wrapper.go | 86 ++++++++++++++++++++++++++++++++ dsp/routing.go | 6 +-- dsp/shared/catalog_types.go | 8 --- dsp/shared/common_types.go | 11 ++++ dsp/shared/contract_types.go | 11 ---- dsp/shared/transfer_types.go | 10 ---- dsp/shared/validation_helpers.go | 8 +++ 9 files changed, 159 insertions(+), 67 deletions(-) create mode 100644 dsp/error_wrapper.go diff --git a/dsp/catalog_handlers.go b/dsp/catalog_handlers.go index 352e0c1..8fd7457 100644 --- a/dsp/catalog_handlers.go +++ b/dsp/catalog_handlers.go @@ -20,8 +20,6 @@ import ( "time" "github.com/go-dataspace/run-dsp/dsp/shared" - "github.com/go-dataspace/run-dsp/internal/constants" - "github.com/go-dataspace/run-dsp/jsonld" "github.com/go-dataspace/run-dsp/logging" providerv1 "github.com/go-dataspace/run-dsrpc/gen/go/dsp/v1alpha1" "github.com/google/uuid" @@ -35,20 +33,46 @@ var dataService = shared.DataService{ EndpointURL: "https://insert-url-here.dsp/", } -func (ch *dspHandlers) catalogRequestHandler(w http.ResponseWriter, req *http.Request) { +// CatalogError implements HTTPError for catalog requests. +type CatalogError struct { + status int + dspCode string + reason string + err string +} + +func (ce CatalogError) Error() string { return ce.err } +func (ce CatalogError) StatusCode() int { return ce.status } +func (ce CatalogError) ErrorType() string { return "dspace:CatalogError" } +func (ce CatalogError) DSPCode() string { return ce.dspCode } +func (ce CatalogError) Description() []shared.Multilanguage { return []shared.Multilanguage{} } +func (ce CatalogError) ProviderPID() string { return "" } +func (ce CatalogError) ConsumerPID() string { return "" } + +func (ce CatalogError) Reason() []shared.Multilanguage { + return []shared.Multilanguage{{Value: ce.reason, Language: "en"}} +} + +func catalogError(err string, statusCode int, dspCode string, reason string) CatalogError { + return CatalogError{ + status: statusCode, + dspCode: dspCode, + reason: reason, + err: err, + } +} + +func (ch *dspHandlers) catalogRequestHandler(w http.ResponseWriter, req *http.Request) error { logger := logging.Extract(req.Context()) catalogReq, err := shared.DecodeValid[shared.CatalogRequestMessage](req) if err != nil { - logger.Error("Non validating catalog request", "err", err) - returnError(w, http.StatusBadRequest, "Request did not validate") - return + return catalogError("request did not validate", http.StatusBadRequest, "400", "Invalid request") } logger.Debug("Got catalog request", "req", catalogReq) // As the filter option is undefined, we will not fill anything resp, err := ch.provider.GetCatalogue(req.Context(), &providerv1.GetCatalogueRequest{}) if err != nil { - grpcErrorHandler(w, logger, err) - return + return grpcErrorHandler(err) } err = shared.EncodeValid(w, req, http.StatusOK, shared.CatalogAcknowledgement{ @@ -62,47 +86,43 @@ func (ch *dspHandlers) catalogRequestHandler(w http.ResponseWriter, req *http.Re }, }, }, - Context: jsonld.NewRootContext([]jsonld.ContextEntry{{ID: constants.DSPContext}}), + Context: shared.GetDSPContext(), Datasets: processProviderCatalogue(resp.GetDatasets(), dataService), Service: []shared.DataService{dataService}, }) if err != nil { - logger.Error("failed to serve catalog", "err", err) + logger.Error("failed to serve catalog after accepting", "err", err) } + return nil } -func (ch *dspHandlers) datasetRequestHandler(w http.ResponseWriter, req *http.Request) { +func (ch *dspHandlers) datasetRequestHandler(w http.ResponseWriter, req *http.Request) error { paramID := req.PathValue("id") if paramID == "" { - returnError(w, http.StatusBadRequest, "No ID given in path") - return + return catalogError("no ID in path", http.StatusBadRequest, "400", "No ID given in path") } ctx, logger := logging.InjectLabels(req.Context(), "paramID", paramID) id, err := uuid.Parse(paramID) if err != nil { - logger.Error("Misformed uuid in path", "err", err) - returnError(w, http.StatusBadRequest, "Invalid ID") - return + return catalogError(err.Error(), http.StatusBadRequest, "400", "Invalid dataset ID") } datasetReq, err := shared.DecodeValid[shared.DatasetRequestMessage](req) if err != nil { - logger.Error("Non validating dataset request", "err", err) - returnError(w, http.StatusBadRequest, "Request did not validate") - return + return catalogError(err.Error(), http.StatusBadRequest, "400", "Invalid dataset request") } logger.Debug("Got dataset request", "req", datasetReq) resp, err := ch.provider.GetDataset(ctx, &providerv1.GetDatasetRequest{ DatasetId: id.String(), }) if err != nil { - grpcErrorHandler(w, logger, err) - return + return grpcErrorHandler(err) } err = shared.EncodeValid(w, req, http.StatusOK, processProviderDataset(resp.GetDataset(), dataService)) if err != nil { logger.Error("failed to serve dataset", "err", err) } + return nil } func processProviderDataset(pds *providerv1.Dataset, service shared.DataService) shared.Dataset { diff --git a/dsp/common_handlers.go b/dsp/common_handlers.go index 2043998..bc69228 100644 --- a/dsp/common_handlers.go +++ b/dsp/common_handlers.go @@ -17,14 +17,12 @@ package dsp import ( "encoding/json" "fmt" - "log/slog" "net/http" "net/url" "github.com/go-dataspace/run-dsp/dsp/shared" "github.com/go-dataspace/run-dsp/dsp/statemachine" "github.com/go-dataspace/run-dsp/internal/constants" - "github.com/go-dataspace/run-dsp/jsonld" providerv1 "github.com/go-dataspace/run-dsrpc/gen/go/dsp/v1alpha1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -66,25 +64,24 @@ func routeNotImplemented(w http.ResponseWriter, req *http.Request) { returnError(w, http.StatusNotImplemented, fmt.Sprintf("%s %s has not been implemented", method, path)) } -func grpcErrorHandler(w http.ResponseWriter, l *slog.Logger, err error) { - l.Error("Got GRPC error", "err", err) +func grpcErrorHandler(err error) CatalogError { switch status.Code(err) { //nolint:exhaustive case codes.Unauthenticated: - returnContent(w, http.StatusForbidden, "not authenticated") + return catalogError(err.Error(), http.StatusForbidden, "403", "Not authenticated") case codes.PermissionDenied: - returnContent(w, http.StatusUnauthorized, "permission denied") + return catalogError(err.Error(), http.StatusUnauthorized, "401", "Permission denied") case codes.InvalidArgument: - returnContent(w, http.StatusBadRequest, "invalid argument") + return catalogError(err.Error(), http.StatusBadRequest, "400", "Invalid argument") case codes.NotFound: - returnContent(w, http.StatusNotFound, "not found") + return catalogError(err.Error(), http.StatusNotFound, "404", "Not found") default: - returnContent(w, http.StatusInternalServerError, "err") + return catalogError(err.Error(), http.StatusInternalServerError, "500", "Internal server error") } } -func dspaceVersionHandler(w http.ResponseWriter, req *http.Request) { +func dspaceVersionHandler(w http.ResponseWriter, req *http.Request) error { vResp := shared.VersionResponse{ - Context: jsonld.NewRootContext([]jsonld.ContextEntry{{ID: constants.DSPContext}}), + Context: shared.GetDSPContext(), ProtocolVersions: []shared.ProtocolVersion{ { Version: constants.DSPVersion, @@ -94,11 +91,10 @@ func dspaceVersionHandler(w http.ResponseWriter, req *http.Request) { } data, err := shared.ValidateAndMarshal(req.Context(), vResp) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, errorString("Error while trying to fetch dataspace versions")) - return + return err } w.WriteHeader(http.StatusOK) fmt.Fprint(w, string(data)) + return nil } diff --git a/dsp/error_wrapper.go b/dsp/error_wrapper.go new file mode 100644 index 0000000..ea158e4 --- /dev/null +++ b/dsp/error_wrapper.go @@ -0,0 +1,86 @@ +// Copyright 2024 go-dataspace +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dsp + +import ( + "errors" + "net/http" + + "github.com/go-dataspace/run-dsp/dsp/shared" + "github.com/go-dataspace/run-dsp/logging" +) + +// HTTPReturnError is an interface for a dataspace protocol error, containing all the information +// needed to return a sane dataspace error over HTTP. +type HTTPReturnError interface { + error + StatusCode() int + ErrorType() string + DSPCode() string + Reason() []shared.Multilanguage + Description() []shared.Multilanguage + ProviderPID() string + ConsumerPID() string +} + +// WrapHandlerWithError wraps a http handler that returns an error into a more generic http.Handler. +// It will handle the error like it's supposed to be an http error. If the function returns a normal +// error, it will return a 500 with a generic error message. If the error conforms to a HTTPError, +// it will use the information to format a proper HTTP dataspace error. +func WrapHandlerWithError(h func(w http.ResponseWriter, r *http.Request) error) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := h(w, r) + if err != nil { + logger := logging.Extract(r.Context()) + logger.Error("HTTP handler returned error", "err", err.Error()) + + var httpError HTTPReturnError + if errors.As(err, &httpError) { + handleHTTPError(w, r, httpError) + return + } + + // If a normal error we just return a generic 500 with a generic error. + if err := shared.EncodeValid(w, r, http.StatusInternalServerError, shared.DSPError{ + Context: shared.GetDSPContext(), + Type: "dspace:UnknownError", + Code: "INTERNAL", + Reason: []shared.Multilanguage{ + { + Value: "Internal Server Errror", + Language: "en", + }, + }, + }); err != nil { + logger.Error("Error while encoding generic error", "err", err) + } + } + }) +} + +func handleHTTPError(w http.ResponseWriter, r *http.Request, err HTTPReturnError) { + dErr := shared.DSPError{ + Context: shared.GetDSPContext(), + Type: err.ErrorType(), + ProviderPID: err.ProviderPID(), + ConsumerPID: err.ConsumerPID(), + Code: err.DSPCode(), + Reason: err.Reason(), + Description: err.Description(), + } + if err := shared.EncodeValid(w, r, err.StatusCode(), dErr); err != nil { + logging.Extract(r.Context()).Error("Error while encoding HTTP Error", "err", err) + } +} diff --git a/dsp/routing.go b/dsp/routing.go index b87ee3e..3cec35b 100644 --- a/dsp/routing.go +++ b/dsp/routing.go @@ -27,7 +27,7 @@ import ( func GetWellKnownRoutes() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /dspace-version", dspaceVersionHandler) + mux.Handle("GET /dspace-version", WrapHandlerWithError(dspaceVersionHandler)) // This is an optional proof endpoint for protected datasets. mux.HandleFunc("GET /dspace-trust", routeNotImplemented) return mux @@ -43,8 +43,8 @@ func GetDSPRoutes( ch := dspHandlers{provider: provider, store: store, reconciler: reconciler, selfURL: selfURL} // Catalog endpoints - mux.HandleFunc("POST /catalog/request", ch.catalogRequestHandler) - mux.HandleFunc("GET /catalog/datasets/{id}", ch.datasetRequestHandler) + mux.Handle("POST /catalog/request", WrapHandlerWithError(ch.catalogRequestHandler)) + mux.Handle("GET /catalog/datasets/{id}", WrapHandlerWithError(ch.datasetRequestHandler)) // Contract negotiation endpoints mux.HandleFunc("GET /negotiations/{providerPID}", ch.providerContractStateHandler) diff --git a/dsp/shared/catalog_types.go b/dsp/shared/catalog_types.go index 7ec108c..5c47cdc 100644 --- a/dsp/shared/catalog_types.go +++ b/dsp/shared/catalog_types.go @@ -45,14 +45,6 @@ type CatalogAcknowledgement struct { Homepage string `json:"foaf:homepage,omitempty"` } -// CatalogError is a standardised error for catalog requests. -type CatalogError struct { - Context jsonld.Context `json:"@context"` - Type string `json:"@type" validate:"required,eq=dspace:CatalogError"` - Code string `json:"dspace:code"` - Reason []map[string]any `json:"dspace:reason"` -} - // Dataset is a DCAT dataset. type Dataset struct { Resource diff --git a/dsp/shared/common_types.go b/dsp/shared/common_types.go index 0010d59..4cc4f35 100644 --- a/dsp/shared/common_types.go +++ b/dsp/shared/common_types.go @@ -45,3 +45,14 @@ type PublishInfo struct { URL string Token string } + +// DSPError is an amalgamation of all DSP errors combined into one. +type DSPError struct { + Context jsonld.Context `json:"@context"` + Type string `json:"@type"` + ProviderPID string `json:"dspace:providerPid,omitempty"` + ConsumerPID string `json:"dspace:consumerPid,omitempty"` + Code string `json:"dspace:code,omitempty"` + Reason []Multilanguage `json:"dspace:reason,omitempty"` + Description []Multilanguage `json:"dct:description,omitempty"` +} diff --git a/dsp/shared/contract_types.go b/dsp/shared/contract_types.go index 7b1faa8..d386b2d 100644 --- a/dsp/shared/contract_types.go +++ b/dsp/shared/contract_types.go @@ -84,14 +84,3 @@ type ContractNegotiation struct { ConsumerPID string `json:"dspace:consumerPid" validate:"required"` State string `json:"dspace:state" validate:"required"` } - -// ContractNegotiationError is a response to show the state of the contract negotiation. -type ContractNegotiationError struct { - Context jsonld.Context `json:"@context,omitempty"` - Type string `json:"@type,omitempty" validate:"required,eq=dspace:ContractNegotiationError"` - ProviderPID string `json:"dspace:providerPid,omitempty" validate:"required"` - ConsumerPID string `json:"dspace:consumerPid,omitempty" validate:"required"` - Code string `json:"dspace:code,omitempty"` - Reason []map[string]any `json:"dspace:reason,omitempty"` - Description []Multilanguage `json:"dct:description,omitempty"` -} diff --git a/dsp/shared/transfer_types.go b/dsp/shared/transfer_types.go index aaa6aad..929f4f1 100644 --- a/dsp/shared/transfer_types.go +++ b/dsp/shared/transfer_types.go @@ -75,16 +75,6 @@ type TransferProcess struct { State string `json:"dspace:state" validate:"required,transfer_state"` } -// TransferError signals the suspension of a datatransfer. -type TransferError struct { - Context jsonld.Context `json:"@context,omitempty"` - Type string `json:"@type,omitempty" validate:"required,eq=dspace:TransferError"` - ProviderPID string `json:"dspace:providerPid,omitempty" validate:"required"` - ConsumerPID string `json:"dspace:consumerPid,omitempty" validate:"required"` - Code string `json:"code,omitempty"` - Reason []map[string]any `json:"reason,omitempty"` -} - // DataAddress represents a dataspace data address. type DataAddress struct { Type string `json:"@type,omitempty" validate:"required,eq=dspace:DataAddress"` diff --git a/dsp/shared/validation_helpers.go b/dsp/shared/validation_helpers.go index b7b901e..562cc65 100644 --- a/dsp/shared/validation_helpers.go +++ b/dsp/shared/validation_helpers.go @@ -22,6 +22,8 @@ import ( "net/http" "slices" + "github.com/go-dataspace/run-dsp/internal/constants" + "github.com/go-dataspace/run-dsp/jsonld" "github.com/go-dataspace/run-dsp/logging" "github.com/go-dataspace/run-dsp/odrl" "github.com/go-playground/validator/v10" @@ -148,3 +150,9 @@ func RegisterValidators(v *validator.Validate) error { } return odrl.RegisterValidators(v) } + +// GetDSPContext returns the DSP context. +// TODO: Replace all the hardcoded ones with this function. +func GetDSPContext() jsonld.Context { + return jsonld.NewRootContext([]jsonld.ContextEntry{{ID: constants.DSPContext}}) +}