From 7a812663039ce8ad5d2c26642f9aff83608dadab Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 6 Dec 2023 06:05:52 +0100 Subject: [PATCH 1/3] Discovery: API for client and server --- discovery/api/v1/generated.go | 269 +++++++++++++++++++++++++++++++ discovery/api/v1/wrapper.go | 16 ++ discovery/api/v1/wrapper_test.go | 46 ++++++ discovery/interface.go | 15 +- discovery/mock.go | 4 +- discovery/module.go | 45 +++++- discovery/module_test.go | 35 +++- discovery/test.go | 2 + docs/_static/discovery/v1.yaml | 69 ++++++++ 9 files changed, 489 insertions(+), 12 deletions(-) diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go index 67aaee659c..f3bd75a7ca 100644 --- a/discovery/api/v1/generated.go +++ b/discovery/api/v1/generated.go @@ -22,11 +22,28 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// SearchResult defines model for SearchResult. +type SearchResult struct { + // Fields Input descriptor IDs and their mapped values that from the Verifiable Credential. + Fields map[string]interface{} `json:"fields"` + + // Id The ID of the Verifiable Presentation. + Id string `json:"id"` + + // Vp Verifiable Presentation + Vp VerifiablePresentation `json:"vp"` +} + // GetPresentationsParams defines parameters for GetPresentations. type GetPresentationsParams struct { Tag *string `form:"tag,omitempty" json:"tag,omitempty"` } +// SearchPresentationsParams defines parameters for SearchPresentations. +type SearchPresentationsParams struct { + Query map[string]string `form:"query" json:"query"` +} + // RegisterPresentationJSONRequestBody defines body for RegisterPresentation for application/json ContentType. type RegisterPresentationJSONRequestBody = VerifiablePresentation @@ -110,6 +127,9 @@ type ClientInterface interface { 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) + + // SearchPresentations request + SearchPresentations(ctx context.Context, serviceID string, params *SearchPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) GetPresentations(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -148,6 +168,18 @@ func (c *Client) RegisterPresentation(ctx context.Context, serviceID string, bod return c.Client.Do(req) } +func (c *Client) SearchPresentations(ctx context.Context, serviceID string, params *SearchPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSearchPresentationsRequest(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) +} + // NewGetPresentationsRequest generates requests for GetPresentations func NewGetPresentationsRequest(server string, serviceID string, params *GetPresentationsParams) (*http.Request, error) { var err error @@ -251,6 +283,58 @@ func NewRegisterPresentationRequestWithBody(server string, serviceID string, con return req, nil } +// NewSearchPresentationsRequest generates requests for SearchPresentations +func NewSearchPresentationsRequest(server string, serviceID string, params *SearchPresentationsParams) (*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/search", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "query", runtime.ParamLocationQuery, params.Query); 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 +} + 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 { @@ -301,6 +385,9 @@ type ClientWithResponsesInterface interface { 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) + + // SearchPresentationsWithResponse request + SearchPresentationsWithResponse(ctx context.Context, serviceID string, params *SearchPresentationsParams, reqEditors ...RequestEditorFn) (*SearchPresentationsResponse, error) } type GetPresentationsResponse struct { @@ -376,6 +463,38 @@ func (r RegisterPresentationResponse) StatusCode() int { return 0 } +type SearchPresentationsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]SearchResult + 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 SearchPresentationsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SearchPresentationsResponse) 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...) @@ -402,6 +521,15 @@ func (c *ClientWithResponses) RegisterPresentationWithResponse(ctx context.Conte return ParseRegisterPresentationResponse(rsp) } +// SearchPresentationsWithResponse request returning *SearchPresentationsResponse +func (c *ClientWithResponses) SearchPresentationsWithResponse(ctx context.Context, serviceID string, params *SearchPresentationsParams, reqEditors ...RequestEditorFn) (*SearchPresentationsResponse, error) { + rsp, err := c.SearchPresentations(ctx, serviceID, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseSearchPresentationsResponse(rsp) +} + // ParseGetPresentationsResponse parses an HTTP response from a GetPresentationsWithResponse call func ParseGetPresentationsResponse(rsp *http.Response) (*GetPresentationsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -495,6 +623,48 @@ func ParseRegisterPresentationResponse(rsp *http.Response) (*RegisterPresentatio return response, nil } +// ParseSearchPresentationsResponse parses an HTTP response from a SearchPresentationsWithResponse call +func ParseSearchPresentationsResponse(rsp *http.Response) (*SearchPresentationsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SearchPresentationsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []SearchResult + 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 +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Retrieves the presentations of a discovery service. @@ -503,6 +673,9 @@ type ServerInterface interface { // Register a presentation on the discovery service. // (POST /discovery/{serviceID}) RegisterPresentation(ctx echo.Context, serviceID string) error + // Searches for presentations registered on the discovery service. + // (GET /discovery/{serviceID}/search) + SearchPresentations(ctx echo.Context, serviceID string, params SearchPresentationsParams) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -555,6 +728,33 @@ func (w *ServerInterfaceWrapper) RegisterPresentation(ctx echo.Context) error { return err } +// SearchPresentations converts echo context to params. +func (w *ServerInterfaceWrapper) SearchPresentations(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 SearchPresentationsParams + // ------------- Required query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, true, "query", ctx.QueryParams(), ¶ms.Query) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter query: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.SearchPresentations(ctx, serviceID, params) + 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 @@ -585,6 +785,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/discovery/:serviceID", wrapper.GetPresentations) router.POST(baseURL+"/discovery/:serviceID", wrapper.RegisterPresentation) + router.GET(baseURL+"/discovery/:serviceID/search", wrapper.SearchPresentations) } @@ -683,6 +884,45 @@ func (response RegisterPresentationdefaultApplicationProblemPlusJSONResponse) Vi return json.NewEncoder(w).Encode(response.Body) } +type SearchPresentationsRequestObject struct { + ServiceID string `json:"serviceID"` + Params SearchPresentationsParams +} + +type SearchPresentationsResponseObject interface { + VisitSearchPresentationsResponse(w http.ResponseWriter) error +} + +type SearchPresentations200JSONResponse []SearchResult + +func (response SearchPresentations200JSONResponse) VisitSearchPresentationsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SearchPresentationsdefaultApplicationProblemPlusJSONResponse 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 SearchPresentationsdefaultApplicationProblemPlusJSONResponse) VisitSearchPresentationsResponse(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. @@ -691,6 +931,9 @@ type StrictServerInterface interface { // Register a presentation on the discovery service. // (POST /discovery/{serviceID}) RegisterPresentation(ctx context.Context, request RegisterPresentationRequestObject) (RegisterPresentationResponseObject, error) + // Searches for presentations registered on the discovery service. + // (GET /discovery/{serviceID}/search) + SearchPresentations(ctx context.Context, request SearchPresentationsRequestObject) (SearchPresentationsResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -761,3 +1004,29 @@ func (sh *strictHandler) RegisterPresentation(ctx echo.Context, serviceID string } return nil } + +// SearchPresentations operation middleware +func (sh *strictHandler) SearchPresentations(ctx echo.Context, serviceID string, params SearchPresentationsParams) error { + var request SearchPresentationsRequestObject + + request.ServiceID = serviceID + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.SearchPresentations(ctx.Request().Context(), request.(SearchPresentationsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SearchPresentations") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(SearchPresentationsResponseObject); ok { + return validResponse.VisitSearchPresentationsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/discovery/api/v1/wrapper.go b/discovery/api/v1/wrapper.go index 93f58cf394..73edc41213 100644 --- a/discovery/api/v1/wrapper.go +++ b/discovery/api/v1/wrapper.go @@ -83,3 +83,19 @@ func (w *Wrapper) RegisterPresentation(_ context.Context, request RegisterPresen } return RegisterPresentation201Response{}, nil } + +func (w *Wrapper) SearchPresentations(_ context.Context, request SearchPresentationsRequestObject) (SearchPresentationsResponseObject, error) { + searchResults, err := w.Client.Search(request.ServiceID, request.Params.Query) + if err != nil { + return nil, err + } + var result []SearchResult + for _, searchResult := range searchResults { + result = append(result, SearchResult{ + Vp: searchResult.Presentation, + Id: searchResult.Presentation.ID.String(), + Fields: searchResult.Fields, + }) + } + return SearchPresentations200JSONResponse(result), nil +} diff --git a/discovery/api/v1/wrapper_test.go b/discovery/api/v1/wrapper_test.go index df838920f0..0888e9c81a 100644 --- a/discovery/api/v1/wrapper_test.go +++ b/discovery/api/v1/wrapper_test.go @@ -20,8 +20,10 @@ package v1 import ( "errors" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/discovery" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -116,6 +118,50 @@ func TestWrapper_ResolveStatusCode(t *testing.T) { } } +func TestWrapper_SearchPresentations(t *testing.T) { + query := map[string]string{ + "foo": "bar", + } + id, _ := ssi.ParseURI("did:nuts:foo#1") + vp := vc.VerifiablePresentation{ + ID: id, + VerifiableCredential: []vc.VerifiableCredential{credential.ValidNutsOrganizationCredential(t)}, + } + t.Run("ok", func(t *testing.T) { + test := newMockContext(t) + results := []discovery.SearchResult{ + { + Presentation: vp, + Fields: nil, + }, + } + test.client.EXPECT().Search(serviceID, query).Return(results, nil) + + response, err := test.wrapper.SearchPresentations(nil, SearchPresentationsRequestObject{ + ServiceID: serviceID, + Params: SearchPresentationsParams{Query: query}, + }) + + assert.NoError(t, err) + assert.IsType(t, SearchPresentations200JSONResponse{}, response) + actual := response.(SearchPresentations200JSONResponse) + require.Len(t, actual, 1) + assert.Equal(t, vp, actual[0].Vp) + assert.Equal(t, vp.ID.String(), actual[0].Id) + }) + t.Run("error", func(t *testing.T) { + test := newMockContext(t) + test.client.EXPECT().Search(serviceID, query).Return(nil, discovery.ErrServiceNotFound) + + _, err := test.wrapper.SearchPresentations(nil, SearchPresentationsRequestObject{ + ServiceID: serviceID, + Params: SearchPresentationsParams{Query: query}, + }) + + assert.ErrorIs(t, err, discovery.ErrServiceNotFound) + }) +} + type mockContext struct { ctrl *gomock.Controller server *discovery.MockServer diff --git a/discovery/interface.go b/discovery/interface.go index 0e78892306..67c3c5048f 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -27,7 +27,6 @@ import ( ) // Tag is value that references a point in the list. -// It is used by clients to request new entries since their last query. // It is opaque for clients: they should not try to interpret it. // The server who issued the tag can interpret it as Lamport timestamp. type Tag string @@ -94,5 +93,17 @@ type Server interface { // Client defines the API for Discovery Clients. type Client interface { - Search(serviceID string, query map[string]string) ([]vc.VerifiablePresentation, error) + // Search searches for presentations which credential(s) match the given query. + // Query parameters are formatted as simple JSON paths, e.g. "issuer" or "credentialSubject.name". + Search(serviceID string, query map[string]string) ([]SearchResult, error) +} + +// SearchResult is a single result of a search operation. +type SearchResult struct { + // Presentation is the Verifiable Presentation that was matched. + Presentation vc.VerifiablePresentation `json:"vp"` + // Fields is a map of Input Descriptor Constraint Fields from the Discovery Service's Presentation Definition. + // The keys are the Input Descriptor IDs mapped to the values from the credential(s) inside the Presentation. + // It only includes constraint fields that have an ID. + Fields map[string]interface{} `json:"fields"` } diff --git a/discovery/mock.go b/discovery/mock.go index 391979c563..4e3c21c67c 100644 --- a/discovery/mock.go +++ b/discovery/mock.go @@ -93,10 +93,10 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { } // Search mocks base method. -func (m *MockClient) Search(serviceID string, query map[string]string) ([]vc.VerifiablePresentation, error) { +func (m *MockClient) Search(serviceID string, query map[string]string) ([]SearchResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Search", serviceID, query) - ret0, _ := ret[0].([]vc.VerifiablePresentation) + ret0, _ := ret[0].([]SearchResult) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/discovery/module.go b/discovery/module.go index 87fb5ed53c..a30b776b73 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -24,6 +24,7 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/discovery/log" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -57,6 +58,7 @@ var _ core.Injectable = &Module{} var _ core.Runnable = &Module{} var _ core.Configurable = &Module{} var _ Server = &Module{} +var _ Client = &Module{} var retractionPresentationType = ssi.MustParseURI("RetractedVerifiablePresentation") @@ -74,7 +76,7 @@ type Module struct { storageInstance storage.Engine store *sqlStore serverDefinitions map[string]ServiceDefinition - services map[string]ServiceDefinition + allDefinitions map[string]ServiceDefinition vcrInstance vcr.VCR } @@ -83,7 +85,7 @@ func (m *Module) Configure(_ core.ServerConfig) error { return nil } var err error - m.services, err = loadDefinitions(m.config.Definitions.Directory) + m.allDefinitions, err = loadDefinitions(m.config.Definitions.Directory) if err != nil { return err } @@ -91,7 +93,7 @@ func (m *Module) Configure(_ core.ServerConfig) error { // Get the definitions that are enabled for this server serverDefinitions := make(map[string]ServiceDefinition) for _, definitionID := range m.config.Server.DefinitionIDs { - if definition, exists := m.services[definitionID]; !exists { + if definition, exists := m.allDefinitions[definitionID]; !exists { return fmt.Errorf("service definition '%s' not found", definitionID) } else { serverDefinitions[definitionID] = definition @@ -104,7 +106,7 @@ func (m *Module) Configure(_ core.ServerConfig) error { func (m *Module) Start() error { var err error - m.store, err = newSQLStore(m.storageInstance.GetSQLDatabase(), m.services, m.serverDefinitions) + m.store, err = newSQLStore(m.storageInstance.GetSQLDatabase(), m.allDefinitions, m.serverDefinitions) if err != nil { return err } @@ -258,6 +260,41 @@ func loadDefinitions(directory string) (map[string]ServiceDefinition, error) { return result, nil } +func (m *Module) Search(serviceID string, query map[string]string) ([]SearchResult, error) { + service, exists := m.allDefinitions[serviceID] + if !exists { + return nil, ErrServiceNotFound + } + matchingVPs, err := m.store.search(serviceID, query) + if err != nil { + return nil, err + } + var result []SearchResult + for _, matchingVP := range matchingVPs { + // Match credentials to Presentation Definition, to resolve map with InputDescriptorId -> CredentialValue + submissionVCs, inputDescriptorMappingObjects, err := service.PresentationDefinition.Match(matchingVP.VerifiableCredential) + var fields map[string]interface{} + if err != nil { + log.Logger().Infof("Search() is unable to build submission for VP '%s': %s", matchingVP.ID, err) + } else { + credentialMap := make(map[string]vc.VerifiableCredential) + for i := 0; i < len(inputDescriptorMappingObjects); i++ { + credentialMap[inputDescriptorMappingObjects[i].Id] = submissionVCs[i] + } + fields, err = service.PresentationDefinition.ResolveConstraintsFields(credentialMap) + if err != nil { + log.Logger().Infof("Search() is unable to resolve Input Descriptor Constraints Fields map for VP '%s': %s", matchingVP.ID, err) + } + } + + result = append(result, SearchResult{ + Presentation: matchingVP, + Fields: fields, + }) + } + return result, nil +} + // validateAudience checks if the given audience of the presentation matches the service ID. func validateAudience(service ServiceDefinition, audience []string) error { for _, audienceID := range audience { diff --git a/discovery/module_test.go b/discovery/module_test.go index 3b54d56700..8367c9574c 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -19,6 +19,7 @@ package discovery import ( + "encoding/json" "errors" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/vc" @@ -74,9 +75,9 @@ func Test_Module_Add(t *testing.T) { }) t.Run("valid for too long", func(t *testing.T) { m, _ := setupModule(t, storageEngine) - def := m.services[testServiceID] + def := m.allDefinitions[testServiceID] def.PresentationMaxValidity = 1 - m.services[testServiceID] = def + m.allDefinitions[testServiceID] = def m.serverDefinitions[testServiceID] = def err := m.Add(testServiceID, vpAlice) @@ -239,9 +240,9 @@ func setupModule(t *testing.T, storageInstance storage.Engine) (*Module, *verifi mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes() m := New(storageInstance, mockVCR) require.NoError(t, m.Configure(core.ServerConfig{})) - m.services = testDefinitions() + m.allDefinitions = testDefinitions() m.serverDefinitions = map[string]ServiceDefinition{ - testServiceID: m.services[testServiceID], + testServiceID: m.allDefinitions[testServiceID], } require.NoError(t, m.Start()) return m, mockVerifier @@ -286,3 +287,29 @@ func TestModule_Configure(t *testing.T) { assert.ErrorContains(t, err, "unable to read definitions directory 'test/non_existent'") }) } + +func TestModule_Search(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Run("ok", func(t *testing.T) { + m, _ := setupModule(t, storageEngine) + require.NoError(t, m.store.add(testServiceID, vpAlice, nil)) + results, err := m.Search(testServiceID, map[string]string{ + "credentialSubject.id": aliceDID.String(), + }) + assert.NoError(t, err) + expectedJSON, _ := json.Marshal([]SearchResult{ + { + Presentation: vpAlice, + Fields: map[string]interface{}{"issuer_field": authorityDID}, + }, + }) + actualJSON, _ := json.Marshal(results) + assert.JSONEq(t, string(expectedJSON), string(actualJSON)) + }) + t.Run("unknown service ID", func(t *testing.T) { + m, _ := setupModule(t, storageEngine) + _, err := m.Search("unknown", nil) + assert.ErrorIs(t, err, ErrServiceNotFound) + }) +} diff --git a/discovery/test.go b/discovery/test.go index a97ba1ea7d..8be0a792e3 100644 --- a/discovery/test.go +++ b/discovery/test.go @@ -50,6 +50,7 @@ var testServiceID = "usecase_v1" func testDefinitions() map[string]ServiceDefinition { issuerPattern := "did:example:*" + issuerFieldID := "issuer_field" return map[string]ServiceDefinition{ testServiceID: { ID: testServiceID, @@ -61,6 +62,7 @@ func testDefinitions() map[string]ServiceDefinition { Constraints: &pe.Constraints{ Fields: []pe.Field{ { + Id: &issuerFieldID, Path: []string{"$.issuer"}, Filter: &pe.Filter{ Type: "string", diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index 2d376315b6..8c12ee59ea 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -71,6 +71,60 @@ 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 + required: true + 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: + $ref: "#/components/schemas/SearchResult" + default: + $ref: "../common/error_response.yaml" components: schemas: VerifiablePresentation: @@ -87,6 +141,21 @@ components: type: array items: $ref: "#/components/schemas/VerifiablePresentation" + SearchResult: + type: object + required: + - id + - vp + - fields + properties: + id: + type: string + description: The ID of the Verifiable Presentation. + vp: + $ref: "#/components/schemas/VerifiablePresentation" + fields: + type: object + description: Input descriptor IDs and their mapped values that from the Verifiable Credential. securitySchemes: jwtBearerAuth: type: http From 430b6cb715c01dddc27b0fd940570fdd3a877334 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 9 Jan 2024 10:59:01 +0100 Subject: [PATCH 2/3] revert --- discovery/interface.go | 1 + 1 file changed, 1 insertion(+) diff --git a/discovery/interface.go b/discovery/interface.go index 67c3c5048f..218810ed2b 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -27,6 +27,7 @@ import ( ) // Tag is value that references a point in the list. +// It is used by clients to request new entries since their last query. // It is opaque for clients: they should not try to interpret it. // The server who issued the tag can interpret it as Lamport timestamp. type Tag string From 3d6185bc17f15addd90b609cc0558ed84a52d358 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 9 Jan 2024 11:30:15 +0100 Subject: [PATCH 3/3] add note, pr feedback --- docs/_static/discovery/v1.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index 8c12ee59ea..a3a913edbb 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -94,6 +94,8 @@ paths: description: | An API of the discovery client that searches for presentations on the discovery service, whose credentials match the given query parameter. + It queries the client's local copy of the Discovery Service which is periodically synchronized with the Discovery Server. + This means new registrations might not immediately show up, depending on the client refresh interval. 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).