From 27f2bb6ac3de5c7c1da1e163d80c33c1c3fde275 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Fri, 15 Dec 2023 15:08:34 +0100 Subject: [PATCH] Discovery: implement server API (#2659) --- cmd/root.go | 2 + codegen/configs/discovery_v1.yaml | 10 + discovery/api/v1/generated.go | 772 ++++++++++++++++++++++++++++++ discovery/api/v1/types.go | 24 + discovery/api/v1/wrapper.go | 84 ++++ discovery/api/v1/wrapper_test.go | 136 ++++++ discovery/interface.go | 2 +- discovery/module.go | 55 ++- discovery/module_test.go | 34 +- docs/_static/discovery/v1.yaml | 63 --- docs/pages/integrating/api.rst | 2 + makefile | 1 + 12 files changed, 1090 insertions(+), 95 deletions(-) create mode 100644 codegen/configs/discovery_v1.yaml create mode 100644 discovery/api/v1/generated.go create mode 100644 discovery/api/v1/types.go create mode 100644 discovery/api/v1/wrapper.go create mode 100644 discovery/api/v1/wrapper_test.go diff --git a/cmd/root.go b/cmd/root.go index c9d1d9eda2..e33ce818fc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,6 +44,7 @@ import ( didmanAPI "github.com/nuts-foundation/nuts-node/didman/api/v1" didmanCmd "github.com/nuts-foundation/nuts-node/didman/cmd" "github.com/nuts-foundation/nuts-node/discovery" + discoveryAPI "github.com/nuts-foundation/nuts-node/discovery/api/v1" discoveryCmd "github.com/nuts-foundation/nuts-node/discovery/cmd" "github.com/nuts-foundation/nuts-node/events" eventsCmd "github.com/nuts-foundation/nuts-node/events/cmd" @@ -221,6 +222,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, storageInstance)) system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance}) system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance}) + system.RegisterRoutes(&discoveryAPI.Wrapper{Server: discoveryInstance}) // Register engines // without dependencies diff --git a/codegen/configs/discovery_v1.yaml b/codegen/configs/discovery_v1.yaml new file mode 100644 index 0000000000..ffa5d4adbb --- /dev/null +++ b/codegen/configs/discovery_v1.yaml @@ -0,0 +1,10 @@ +package: v1 +generate: + echo-server: true + client: true + models: true + strict-server: true +output-options: + skip-prune: true + exclude-schemas: + - VerifiablePresentation diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go new file mode 100644 index 0000000000..aac9117245 --- /dev/null +++ b/discovery/api/v1/generated.go @@ -0,0 +1,772 @@ +// Package v1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.0.0 DO NOT EDIT. +package v1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" + strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" +) + +const ( + JwtBearerAuthScopes = "jwtBearerAuth.Scopes" +) + +// GetPresentationsParams defines parameters for GetPresentations. +type GetPresentationsParams struct { + Tag *string `form:"tag,omitempty" json:"tag,omitempty"` +} + +// RegisterPresentationJSONRequestBody defines body for RegisterPresentation for application/json ContentType. +type RegisterPresentationJSONRequestBody = VerifiablePresentation + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // GetPresentations request + GetPresentations(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RegisterPresentationWithBody request with any body + RegisterPresentationWithBody(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + RegisterPresentation(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) GetPresentations(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPresentationsRequest(c.Server, serviceID, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterPresentationWithBody(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterPresentationRequestWithBody(c.Server, serviceID, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterPresentation(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterPresentationRequest(c.Server, serviceID, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetPresentationsRequest generates requests for GetPresentations +func NewGetPresentationsRequest(server string, serviceID string, params *GetPresentationsParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, serviceID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/discovery/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Tag != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "tag", runtime.ParamLocationQuery, *params.Tag); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRegisterPresentationRequest calls the generic RegisterPresentation builder with application/json body +func NewRegisterPresentationRequest(server string, serviceID string, body RegisterPresentationJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRegisterPresentationRequestWithBody(server, serviceID, "application/json", bodyReader) +} + +// NewRegisterPresentationRequestWithBody generates requests for RegisterPresentation with any type of body +func NewRegisterPresentationRequestWithBody(server string, serviceID string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, serviceID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/discovery/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // GetPresentationsWithResponse request + GetPresentationsWithResponse(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*GetPresentationsResponse, error) + + // RegisterPresentationWithBodyWithResponse request with any body + RegisterPresentationWithBodyWithResponse(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) + + RegisterPresentationWithResponse(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) +} + +type GetPresentationsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + Entries []VerifiablePresentation `json:"entries"` + Tag string `json:"tag"` + } + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r GetPresentationsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetPresentationsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type RegisterPresentationResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSON400 *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r RegisterPresentationResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RegisterPresentationResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// GetPresentationsWithResponse request returning *GetPresentationsResponse +func (c *ClientWithResponses) GetPresentationsWithResponse(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*GetPresentationsResponse, error) { + rsp, err := c.GetPresentations(ctx, serviceID, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPresentationsResponse(rsp) +} + +// RegisterPresentationWithBodyWithResponse request with arbitrary body returning *RegisterPresentationResponse +func (c *ClientWithResponses) RegisterPresentationWithBodyWithResponse(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) { + rsp, err := c.RegisterPresentationWithBody(ctx, serviceID, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterPresentationResponse(rsp) +} + +func (c *ClientWithResponses) RegisterPresentationWithResponse(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) { + rsp, err := c.RegisterPresentation(ctx, serviceID, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterPresentationResponse(rsp) +} + +// ParseGetPresentationsResponse parses an HTTP response from a GetPresentationsWithResponse call +func ParseGetPresentationsResponse(rsp *http.Response) (*GetPresentationsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetPresentationsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Entries []VerifiablePresentation `json:"entries"` + Tag string `json:"tag"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRegisterPresentationResponse parses an HTTP response from a RegisterPresentationWithResponse call +func ParseRegisterPresentationResponse(rsp *http.Response) (*RegisterPresentationResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RegisterPresentationResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Retrieves the presentations of a discovery service. + // (GET /discovery/{serviceID}) + GetPresentations(ctx echo.Context, serviceID string, params GetPresentationsParams) error + // Register a presentation on the discovery service. + // (POST /discovery/{serviceID}) + RegisterPresentation(ctx echo.Context, serviceID string) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// GetPresentations converts echo context to params. +func (w *ServerInterfaceWrapper) GetPresentations(ctx echo.Context) error { + var err error + // ------------- Path parameter "serviceID" ------------- + var serviceID string + + err = runtime.BindStyledParameterWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, ctx.Param("serviceID"), &serviceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter serviceID: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetPresentationsParams + // ------------- Optional query parameter "tag" ------------- + + err = runtime.BindQueryParameter("form", true, false, "tag", ctx.QueryParams(), ¶ms.Tag) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tag: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetPresentations(ctx, serviceID, params) + return err +} + +// RegisterPresentation converts echo context to params. +func (w *ServerInterfaceWrapper) RegisterPresentation(ctx echo.Context) error { + var err error + // ------------- Path parameter "serviceID" ------------- + var serviceID string + + err = runtime.BindStyledParameterWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, ctx.Param("serviceID"), &serviceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter serviceID: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.RegisterPresentation(ctx, serviceID) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/discovery/:serviceID", wrapper.GetPresentations) + router.POST(baseURL+"/discovery/:serviceID", wrapper.RegisterPresentation) + +} + +type GetPresentationsRequestObject struct { + ServiceID string `json:"serviceID"` + Params GetPresentationsParams +} + +type GetPresentationsResponseObject interface { + VisitGetPresentationsResponse(w http.ResponseWriter) error +} + +type GetPresentations200JSONResponse struct { + Entries []VerifiablePresentation `json:"entries"` + Tag string `json:"tag"` +} + +func (response GetPresentations200JSONResponse) VisitGetPresentationsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetPresentationsdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response GetPresentationsdefaultApplicationProblemPlusJSONResponse) VisitGetPresentationsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type RegisterPresentationRequestObject struct { + ServiceID string `json:"serviceID"` + Body *RegisterPresentationJSONRequestBody +} + +type RegisterPresentationResponseObject interface { + VisitRegisterPresentationResponse(w http.ResponseWriter) error +} + +type RegisterPresentation201Response struct { +} + +func (response RegisterPresentation201Response) VisitRegisterPresentationResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type RegisterPresentation400ApplicationProblemPlusJSONResponse struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` +} + +func (response RegisterPresentation400ApplicationProblemPlusJSONResponse) VisitRegisterPresentationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type RegisterPresentationdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response RegisterPresentationdefaultApplicationProblemPlusJSONResponse) VisitRegisterPresentationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Retrieves the presentations of a discovery service. + // (GET /discovery/{serviceID}) + GetPresentations(ctx context.Context, request GetPresentationsRequestObject) (GetPresentationsResponseObject, error) + // Register a presentation on the discovery service. + // (POST /discovery/{serviceID}) + RegisterPresentation(ctx context.Context, request RegisterPresentationRequestObject) (RegisterPresentationResponseObject, error) +} + +type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc +type StrictMiddlewareFunc = strictecho.StrictEchoMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetPresentations operation middleware +func (sh *strictHandler) GetPresentations(ctx echo.Context, serviceID string, params GetPresentationsParams) error { + var request GetPresentationsRequestObject + + request.ServiceID = serviceID + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetPresentations(ctx.Request().Context(), request.(GetPresentationsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetPresentations") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetPresentationsResponseObject); ok { + return validResponse.VisitGetPresentationsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// RegisterPresentation operation middleware +func (sh *strictHandler) RegisterPresentation(ctx echo.Context, serviceID string) error { + var request RegisterPresentationRequestObject + + request.ServiceID = serviceID + + var body RegisterPresentationJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.RegisterPresentation(ctx.Request().Context(), request.(RegisterPresentationRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RegisterPresentation") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(RegisterPresentationResponseObject); ok { + return validResponse.VisitRegisterPresentationResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/discovery/api/v1/types.go b/discovery/api/v1/types.go new file mode 100644 index 0000000000..7a9ff02004 --- /dev/null +++ b/discovery/api/v1/types.go @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package v1 + +import "github.com/nuts-foundation/go-did/vc" + +// VerifiablePresentation is a type alias for the VerifiablePresentation from the go-did library. +type VerifiablePresentation = vc.VerifiablePresentation diff --git a/discovery/api/v1/wrapper.go b/discovery/api/v1/wrapper.go new file mode 100644 index 0000000000..375a1939e3 --- /dev/null +++ b/discovery/api/v1/wrapper.go @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package v1 + +import ( + "context" + "errors" + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/discovery" + "net/http" +) + +var _ StrictServerInterface = (*Wrapper)(nil) +var _ core.ErrorStatusCodeResolver = (*Wrapper)(nil) + +type Wrapper struct { + Server discovery.Server + Client discovery.Client +} + +func (w *Wrapper) ResolveStatusCode(err error) int { + switch { + case errors.Is(err, discovery.ErrServerModeDisabled): + return http.StatusBadRequest + case errors.Is(err, discovery.ErrInvalidPresentation): + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } +} + +func (w *Wrapper) Routes(router core.EchoRouter) { + RegisterHandlers(router, NewStrictHandler(w, []StrictMiddlewareFunc{ + func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { + return func(ctx echo.Context, request interface{}) (response interface{}, err error) { + ctx.Set(core.OperationIDContextKey, operationID) + ctx.Set(core.ModuleNameContextKey, discovery.ModuleName) + ctx.Set(core.StatusCodeResolverContextKey, w) + return f(ctx, request) + } + }, + })) +} + +func (w *Wrapper) GetPresentations(_ context.Context, request GetPresentationsRequestObject) (GetPresentationsResponseObject, error) { + var tag *discovery.Tag + if request.Params.Tag != nil { + tag = new(discovery.Tag) + *tag = discovery.Tag(*request.Params.Tag) + } + presentations, newTag, err := w.Server.Get(request.ServiceID, tag) + if err != nil { + return nil, err + } + return GetPresentations200JSONResponse{ + Entries: presentations, + Tag: string(*newTag), + }, nil +} + +func (w *Wrapper) RegisterPresentation(_ context.Context, request RegisterPresentationRequestObject) (RegisterPresentationResponseObject, error) { + err := w.Server.Add(request.ServiceID, *request.Body) + if err != nil { + return nil, err + } + return RegisterPresentation201Response{}, nil +} diff --git a/discovery/api/v1/wrapper_test.go b/discovery/api/v1/wrapper_test.go new file mode 100644 index 0000000000..df838920f0 --- /dev/null +++ b/discovery/api/v1/wrapper_test.go @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package v1 + +import ( + "errors" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/discovery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "net/http" + "testing" +) + +const serviceID = "wonderland" + +func TestWrapper_GetPresentations(t *testing.T) { + t.Run("no tag", func(t *testing.T) { + latestTag := discovery.Tag("latest") + test := newMockContext(t) + presentations := []vc.VerifiablePresentation{} + test.server.EXPECT().Get(serviceID, nil).Return(presentations, &latestTag, nil) + + response, err := test.wrapper.GetPresentations(nil, GetPresentationsRequestObject{ServiceID: serviceID}) + + require.NoError(t, err) + require.IsType(t, GetPresentations200JSONResponse{}, response) + assert.Equal(t, latestTag, discovery.Tag(response.(GetPresentations200JSONResponse).Tag)) + assert.Equal(t, presentations, response.(GetPresentations200JSONResponse).Entries) + }) + t.Run("with tag", func(t *testing.T) { + givenTag := discovery.Tag("given") + latestTag := discovery.Tag("latest") + test := newMockContext(t) + presentations := []vc.VerifiablePresentation{} + test.server.EXPECT().Get(serviceID, &givenTag).Return(presentations, &latestTag, nil) + + response, err := test.wrapper.GetPresentations(nil, GetPresentationsRequestObject{ + ServiceID: serviceID, + Params: GetPresentationsParams{ + Tag: (*string)(&givenTag), + }, + }) + + require.NoError(t, err) + require.IsType(t, GetPresentations200JSONResponse{}, response) + assert.Equal(t, latestTag, discovery.Tag(response.(GetPresentations200JSONResponse).Tag)) + assert.Equal(t, presentations, response.(GetPresentations200JSONResponse).Entries) + }) + t.Run("error", func(t *testing.T) { + test := newMockContext(t) + test.server.EXPECT().Get(serviceID, nil).Return(nil, nil, errors.New("foo")) + + _, err := test.wrapper.GetPresentations(nil, GetPresentationsRequestObject{ServiceID: serviceID}) + + assert.Error(t, err) + }) +} + +func TestWrapper_RegisterPresentation(t *testing.T) { + t.Run("ok", func(t *testing.T) { + test := newMockContext(t) + presentation := vc.VerifiablePresentation{} + test.server.EXPECT().Add(serviceID, presentation).Return(nil) + + response, err := test.wrapper.RegisterPresentation(nil, RegisterPresentationRequestObject{ + ServiceID: serviceID, + Body: &presentation, + }) + + assert.NoError(t, err) + assert.IsType(t, RegisterPresentation201Response{}, response) + }) + t.Run("error", func(t *testing.T) { + test := newMockContext(t) + presentation := vc.VerifiablePresentation{} + test.server.EXPECT().Add(serviceID, presentation).Return(discovery.ErrInvalidPresentation) + + _, err := test.wrapper.RegisterPresentation(nil, RegisterPresentationRequestObject{ + ServiceID: serviceID, + Body: &presentation, + }) + + assert.ErrorIs(t, err, discovery.ErrInvalidPresentation) + }) +} + +func TestWrapper_ResolveStatusCode(t *testing.T) { + expected := map[error]int{ + discovery.ErrServerModeDisabled: http.StatusBadRequest, + discovery.ErrInvalidPresentation: http.StatusBadRequest, + errors.New("foo"): http.StatusInternalServerError, + } + wrapper := Wrapper{} + for err, expectedCode := range expected { + t.Run(err.Error(), func(t *testing.T) { + assert.Equal(t, expectedCode, wrapper.ResolveStatusCode(err)) + }) + } +} + +type mockContext struct { + ctrl *gomock.Controller + server *discovery.MockServer + client *discovery.MockClient + wrapper Wrapper +} + +func newMockContext(t *testing.T) mockContext { + ctrl := gomock.NewController(t) + server := discovery.NewMockServer(ctrl) + client := discovery.NewMockClient(ctrl) + return mockContext{ + ctrl: ctrl, + server: server, + client: client, + wrapper: Wrapper{Server: server, Client: client}, + } +} diff --git a/discovery/interface.go b/discovery/interface.go index d308eb91a3..0e78892306 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -86,7 +86,7 @@ var ErrPresentationAlreadyExists = errors.New("presentation already exists") // Server defines the API for Discovery Servers. type Server interface { // Add registers a presentation on the given Discovery Service. - // If the presentation is not valid or it does not conform to the Service ServiceDefinition, it returns an error. + // If the presentation is not valid, or it does not conform to the Service ServiceDefinition, it returns an error. Add(serviceID string, presentation vc.VerifiablePresentation) error // Get retrieves the presentations for the given service, starting at the given timestamp. Get(serviceID string, startAt *Tag) ([]vc.VerifiablePresentation, *Tag, error) diff --git a/discovery/module.go b/discovery/module.go index 3685e7025c..87fb5ed53c 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -35,8 +35,24 @@ import ( const ModuleName = "Discovery" +// ErrServerModeDisabled is returned when a client invokes a Discovery Server (Add or Get) operation on the node, +// for a Discovery Service which it doesn't serve. var ErrServerModeDisabled = errors.New("node is not a discovery server for this service") +// ErrInvalidPresentation is returned when a client tries to register a Verifiable Presentation that is invalid. +var ErrInvalidPresentation = errors.New("presentation is invalid for registration") + +var ( + errUnsupportedPresentationFormat = errors.New("only JWT presentations are supported") + errPresentationWithoutID = errors.New("presentation does not have an ID") + errPresentationWithoutExpiration = errors.New("presentation does not have an expiration") + errPresentationValidityExceedsCredentials = errors.New("presentation is valid longer than the credential(s) it contains") + errPresentationDoesNotFulfillDefinition = errors.New("presentation does not fulfill Presentation ServiceDefinition") + errRetractionReferencesUnknownPresentation = errors.New("retraction presentation refers to a non-existing presentation") + errRetractionContainsCredentials = errors.New("retraction presentation must not contain credentials") + errInvalidRetractionJTIClaim = errors.New("invalid/missing 'retract_jti' claim for retraction presentation") +) + var _ core.Injectable = &Module{} var _ core.Runnable = &Module{} var _ core.Configurable = &Module{} @@ -107,6 +123,8 @@ func (m *Module) Config() interface{} { return &m.config } +// Add registers a presentation on the given Discovery Service. +// See interface.go for more information. func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) error { // First, simple sanity checks definition, isServer := m.serverDefinitions[serviceID] @@ -114,10 +132,10 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e return ErrServerModeDisabled } if presentation.Format() != vc.JWTPresentationProofFormat { - return errors.New("only JWT presentations are supported") + return errors.Join(ErrInvalidPresentation, errUnsupportedPresentationFormat) } if presentation.ID == nil { - return errors.New("presentation does not have an ID") + return errors.Join(ErrInvalidPresentation, errPresentationWithoutID) } // Make sure the presentation is intended for this service if err := validateAudience(definition, presentation.JWT().Audience()); err != nil { @@ -125,11 +143,11 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e } expiration := presentation.JWT().Expiration() if expiration.IsZero() { - return errors.New("presentation does not have an expiration") + return errors.Join(ErrInvalidPresentation, errPresentationWithoutExpiration) } // VPs should not be valid for too long, as that would prevent the server from pruning them. if int(expiration.Sub(time.Now()).Seconds()) > definition.PresentationMaxValidity { - return fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second) + return errors.Join(ErrInvalidPresentation, fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second)) } // Check if the presentation already exists credentialSubjectID, err := credential.PresentationSigner(presentation) @@ -141,7 +159,7 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e return err } if exists { - return ErrPresentationAlreadyExists + return errors.Join(ErrInvalidPresentation, ErrPresentationAlreadyExists) } // Depending on the presentation type, we need to validate different properties before storing it. if presentation.IsType(retractionPresentationType) { @@ -150,12 +168,12 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e err = m.validateRegistration(definition, presentation) } if err != nil { - return err + return errors.Join(ErrInvalidPresentation, err) } // Check signature of presentation and contained credential(s) _, err = m.vcrInstance.Verifier().VerifyVP(presentation, true, true, nil) if err != nil { - return fmt.Errorf("presentation verification failed: %w", err) + return errors.Join(ErrInvalidPresentation, fmt.Errorf("presentation verification failed: %w", err)) } return m.store.add(definition.ID, presentation, nil) } @@ -165,7 +183,7 @@ func (m *Module) validateRegistration(definition ServiceDefinition, presentation expiration := presentation.JWT().Expiration() for _, cred := range presentation.VerifiableCredential { if cred.ExpirationDate != nil && expiration.After(*cred.ExpirationDate) { - return fmt.Errorf("presentation is valid longer than the credential(s) it contains") + return errPresentationValidityExceedsCredentials } } // VP must fulfill the PEX Presentation ServiceDefinition @@ -175,7 +193,7 @@ func (m *Module) validateRegistration(definition ServiceDefinition, presentation return err } if len(creds) != len(presentation.VerifiableCredential) { - return errors.New("presentation does not fulfill Presentation ServiceDefinition") + return errPresentationDoesNotFulfillDefinition } return nil } @@ -184,29 +202,28 @@ func (m *Module) validateRetraction(serviceID string, presentation vc.Verifiable // Presentation might be a retraction (deletion of an earlier credentialRecord) must contain no credentials, and refer to the VP being retracted by ID. // If those conditions aren't met, we don't need to register the retraction. if len(presentation.VerifiableCredential) > 0 { - return errors.New("retraction presentation must not contain credentials") + return errRetractionContainsCredentials } // Check that the retraction refers to an existing presentation. // If not, it might've already been removed due to expiry or superseded by a newer presentation. - var retractJTIString string - if retractJTIRaw, ok := presentation.JWT().Get("retract_jti"); !ok { - return errors.New("retraction presentation does not contain 'retract_jti' claim") - } else { - if retractJTIString, ok = retractJTIRaw.(string); !ok { - return errors.New("retraction presentation 'retract_jti' claim is not a string") - } + retractJTIRaw, _ := presentation.JWT().Get("retract_jti") + retractJTI, ok := retractJTIRaw.(string) + if !ok || retractJTI == "" { + return errInvalidRetractionJTIClaim } signerDID, _ := credential.PresentationSigner(presentation) // checked before - exists, err := m.store.exists(serviceID, signerDID.String(), retractJTIString) + exists, err := m.store.exists(serviceID, signerDID.String(), retractJTI) if err != nil { return err } if !exists { - return errors.New("retraction presentation refers to a non-existing presentation") + return errRetractionReferencesUnknownPresentation } return nil } +// Get retrieves the presentations for the given service, starting at the given tag. +// See interface.go for more information. func (m *Module) Get(serviceID string, tag *Tag) ([]vc.VerifiablePresentation, *Tag, error) { if _, exists := m.serverDefinitions[serviceID]; !exists { return nil, nil, ErrServerModeDisabled diff --git a/discovery/module_test.go b/discovery/module_test.go index 6c32c28e6b..3b54d56700 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -56,7 +56,7 @@ func Test_Module_Add(t *testing.T) { presentationVerifier.EXPECT().VerifyVP(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("failed")) err := m.Add(testServiceID, vpAlice) - require.EqualError(t, err, "presentation verification failed: failed") + require.EqualError(t, err, "presentation is invalid for registration\npresentation verification failed: failed") _, tag, err := m.Get(testServiceID, nil) require.NoError(t, err) @@ -70,7 +70,7 @@ func Test_Module_Add(t *testing.T) { err := m.Add(testServiceID, vpAlice) assert.NoError(t, err) err = m.Add(testServiceID, vpAlice) - assert.EqualError(t, err, "presentation already exists") + assert.ErrorIs(t, err, ErrPresentationAlreadyExists) }) t.Run("valid for too long", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -80,7 +80,7 @@ func Test_Module_Add(t *testing.T) { m.serverDefinitions[testServiceID] = def err := m.Add(testServiceID, vpAlice) - assert.EqualError(t, err, "presentation is valid for too long (max 1s)") + assert.EqualError(t, err, "presentation is invalid for registration\npresentation is valid for too long (max 1s)") }) t.Run("no expiration", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -88,7 +88,7 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} delete(claims, "exp") })) - assert.EqualError(t, err, "presentation does not have an expiration") + assert.ErrorIs(t, err, errPresentationWithoutExpiration) }) t.Run("presentation does not contain an ID", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -98,12 +98,12 @@ func Test_Module_Add(t *testing.T) { delete(claims, "jti") }, vcAlice) err := m.Add(testServiceID, vpWithoutID) - assert.EqualError(t, err, "presentation does not have an ID") + assert.ErrorIs(t, err, errPresentationWithoutID) }) t.Run("not a JWT", func(t *testing.T) { m, _ := setupModule(t, storageEngine) err := m.Add(testServiceID, vc.VerifiablePresentation{}) - assert.EqualError(t, err, "only JWT presentations are supported") + assert.ErrorIs(t, err, errUnsupportedPresentationFormat) }) t.Run("registration", func(t *testing.T) { @@ -129,7 +129,7 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }, vcAlice) err := m.Add(testServiceID, vpAlice) - assert.EqualError(t, err, "presentation is valid longer than the credential(s) it contains") + assert.ErrorIs(t, err, errPresentationValidityExceedsCredentials) }) t.Run("not conform to Presentation Definition", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -163,7 +163,7 @@ func Test_Module_Add(t *testing.T) { t.Run("non-existent presentation", func(t *testing.T) { m, _ := setupModule(t, storageEngine) err := m.Add(testServiceID, vpAliceRetract) - assert.EqualError(t, err, "retraction presentation refers to a non-existing presentation") + assert.ErrorIs(t, err, errRetractionReferencesUnknownPresentation) }) t.Run("must not contain credentials", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -172,7 +172,7 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }, vcAlice) err := m.Add(testServiceID, vp) - assert.EqualError(t, err, "retraction presentation must not contain credentials") + assert.ErrorIs(t, err, errRetractionContainsCredentials) }) t.Run("missing 'retract_jti' claim", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -181,9 +181,9 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }) err := m.Add(testServiceID, vp) - assert.EqualError(t, err, "retraction presentation does not contain 'retract_jti' claim") + assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) }) - t.Run("'retract_jti' claim in not a string", func(t *testing.T) { + t.Run("'retract_jti' claim is not a string", func(t *testing.T) { m, _ := setupModule(t, storageEngine) vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { vp.Type = append(vp.Type, retractionPresentationType) @@ -191,7 +191,17 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }) err := m.Add(testServiceID, vp) - assert.EqualError(t, err, "retraction presentation 'retract_jti' claim is not a string") + assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) + }) + t.Run("'retract_jti' claim is an empty string", func(t *testing.T) { + m, _ := setupModule(t, storageEngine) + vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + claims["retract_jti"] = "" + claims[jwt.AudienceKey] = []string{testServiceID} + }) + err := m.Add(testServiceID, vp) + assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) }) }) } diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index 84be706c9e..062502ef56 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -81,69 +81,6 @@ paths: $ref: "../common/error_response.yaml" default: $ref: "../common/error_response.yaml" - /discovery/{serviceID}/search: - parameters: - - name: serviceID - in: path - required: true - schema: - type: string - # Way to specify dynamic query parameters - # See https://stackoverflow.com/questions/49582559/how-to-document-dynamic-query-parameter-names-in-openapi-swagger - - in: query - name: query - schema: - type: object - additionalProperties: - type: string - style: form - explode: true - get: - summary: Searches for presentations registered on the discovery service. - description: | - An API of the discovery client that searches for presentations on the discovery service, - whose credentials match the given query parameter. - The query parameters are interpreted as JSON path expressions, evaluated on the verifiable credentials. - The following features and limitations apply: - - only simple child-selectors are supported (so no arrays selectors, script expressions etc). - - only JSON string values can be matched, no numbers, booleans, etc. - - wildcard (*) are supported at the start and end of the value - - a single wildcard (*) means: match any (non-nil) value - - matching is case-insensitive - - expressions must not include the '$.' prefix, which is added by the API. - - all expressions must match a single credential, for the credential to be included in the result. - - if there are multiple credentials in the presentation, the presentation is included in the result if any of the credentials match. - - Valid examples: - - `credentialSubject.givenName=John` - - `credentialSubject.organization.city=Arnhem` - - `credentialSubject.organization.name=Hospital*` - - `credentialSubject.organization.name=*clinic` - - `issuer=did:web:example.com` - operationId: searchPresentations - tags: - - discovery - responses: - "200": - description: Search results are returned, if any. - content: - application/json: - schema: - type: array - items: - type: object - required: - - id - - credential - properties: - id: - type: string - description: The ID of the Verifiable Presentation. - credential: - type: object - description: The Verifiable Credential that matched the query. - default: - $ref: "../common/error_response.yaml" components: schemas: VerifiablePresentation: diff --git a/docs/pages/integrating/api.rst b/docs/pages/integrating/api.rst index c59379ba43..a8d7f84d18 100644 --- a/docs/pages/integrating/api.rst +++ b/docs/pages/integrating/api.rst @@ -7,6 +7,7 @@ Below you can discover the Nuts Node APIs and download their OpenAPI specificati - Common: `SSI types <../../_static/common/ssi_types.yaml>`_, `Default Error <../../_static/common/error_response.yaml>`_ - `DID Manager <../../_static/didman/v1.yaml>`_ +- `Discovery Service <../../_static/discovery/v1.yaml>`_ - `Crypto <../../_static/crypto/v1.yaml>`_ - `Verifiable Credential Registry (v2) <../../_static/vcr/vcr_v2.yaml>`_ - `Verifiable Data Registry <../../_static/vdr/v1.yaml>`_ @@ -30,6 +31,7 @@ Below you can discover the Nuts Node APIs and download their OpenAPI specificati "dom_id": "#swagger-ui", urls: [ {url: "../../_static/didman/v1.yaml", name: "DID Manager"}, + {url: "../../_static/discovery/v1.yaml", name: "Discovery Service"}, {url: "../../_static/crypto/v1.yaml", name: "Crypto"}, {url: "../../_static/vcr/vcr_v2.yaml", name: "Verifiable Credential Registry (v2)"}, {url: "../../_static/vdr/v1.yaml", name: "Verifiable Data Registry"}, diff --git a/makefile b/makefile index 4932e9302a..1365b36384 100644 --- a/makefile +++ b/makefile @@ -70,6 +70,7 @@ gen-api: oapi-codegen --config codegen/configs/auth_employeeid.yaml auth/services/selfsigned/web/spec.yaml | gofmt > auth/services/selfsigned/web/generated.go oapi-codegen --config codegen/configs/auth_iam.yaml docs/_static/auth/iam.yaml | gofmt > auth/api/iam/generated.go oapi-codegen --config codegen/configs/didman_v1.yaml docs/_static/didman/v1.yaml | gofmt > didman/api/v1/generated.go + oapi-codegen --config codegen/configs/discovery_v1.yaml docs/_static/discovery/v1.yaml | gofmt > discovery/api/v1/generated.go oapi-codegen --config codegen/configs/crypto_store_client.yaml https://raw.githubusercontent.com/nuts-foundation/secret-store-api/main/nuts-storage-api-v1.yaml | gofmt > crypto/storage/external/generated.go oapi-codegen --config codegen/configs/policy_client_v1.yaml docs/_static/policy/v1.yaml | gofmt > policy/api/v1/client/generated.go