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..218810ed2b 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -94,5 +94,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..a3a913edbb 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -71,6 +71,62 @@ 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. + 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). + - 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 +143,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