Skip to content

Commit

Permalink
Add HTTP error wrapper (#46)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ainmosni authored Aug 13, 2024
1 parent 23136cf commit ab50ab8
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 67 deletions.
62 changes: 41 additions & 21 deletions dsp/catalog_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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{
Expand All @@ -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 {
Expand Down
24 changes: 10 additions & 14 deletions dsp/common_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
86 changes: 86 additions & 0 deletions dsp/error_wrapper.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 3 additions & 3 deletions dsp/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
8 changes: 0 additions & 8 deletions dsp/shared/catalog_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions dsp/shared/common_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
11 changes: 0 additions & 11 deletions dsp/shared/contract_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
10 changes: 0 additions & 10 deletions dsp/shared/transfer_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
8 changes: 8 additions & 0 deletions dsp/shared/validation_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}})
}

0 comments on commit ab50ab8

Please sign in to comment.