diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go index e07f868a00..34076c2ab2 100644 --- a/discovery/api/v1/generated.go +++ b/discovery/api/v1/generated.go @@ -22,6 +22,18 @@ 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]string `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"` @@ -455,15 +467,9 @@ func (r RegisterPresentationResponse) StatusCode() int { } type SearchPresentationsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]struct { - // Credential The Verifiable Credential that matched the query. - Credential map[string]interface{} `json:"credential"` - - // Id The ID of the Verifiable Presentation. - Id string `json:"id"` - } + 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"` @@ -638,13 +644,7 @@ func ParseSearchPresentationsResponse(rsp *http.Response) (*SearchPresentationsR switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []struct { - // Credential The Verifiable Credential that matched the query. - Credential map[string]interface{} `json:"credential"` - - // Id The ID of the Verifiable Presentation. - Id string `json:"id"` - } + var dest []SearchResult if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -902,13 +902,7 @@ type SearchPresentationsResponseObject interface { VisitSearchPresentationsResponse(w http.ResponseWriter) error } -type SearchPresentations200JSONResponse []struct { - // Credential The Verifiable Credential that matched the query. - Credential map[string]interface{} `json:"credential"` - - // Id The ID of the Verifiable Presentation. - Id string `json:"id"` -} +type SearchPresentations200JSONResponse []SearchResult func (response SearchPresentations200JSONResponse) VisitSearchPresentationsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/discovery/api/v1/wapper.go b/discovery/api/v1/wapper.go index 55fb6d6bea..c7c9e94976 100644 --- a/discovery/api/v1/wapper.go +++ b/discovery/api/v1/wapper.go @@ -32,6 +32,7 @@ var _ core.ErrorStatusCodeResolver = (*Wrapper)(nil) type Wrapper struct { Server discovery.Server + Client discovery.Client } func (w *Wrapper) ResolveStatusCode(err error) int { @@ -84,6 +85,17 @@ func (w *Wrapper) RegisterPresentation(_ context.Context, request RegisterPresen } func (w *Wrapper) SearchPresentations(_ context.Context, request SearchPresentationsRequestObject) (SearchPresentationsResponseObject, error) { - // TODO: Do we need this from the start on, or are we hooking up VCR.SearchVCs to Discovery.Search()? - panic("implement me") + 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.VP, + Id: searchResult.VP.ID.String(), + Fields: searchResult.Fields, + }) + } + return SearchPresentations200JSONResponse(result), nil } diff --git a/discovery/api/v1/wapper_test.go b/discovery/api/v1/wapper_test.go index 83257a0bb0..6c70b0707e 100644 --- a/discovery/api/v1/wapper_test.go +++ b/discovery/api/v1/wapper_test.go @@ -19,8 +19,10 @@ package v1 import ( + 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" @@ -64,22 +66,6 @@ func TestWrapper_GetPresentations(t *testing.T) { }) } -type mockContext struct { - ctrl *gomock.Controller - server *discovery.MockServer - wrapper Wrapper -} - -func newMockContext(t *testing.T) mockContext { - ctrl := gomock.NewController(t) - server := discovery.NewMockServer(ctrl) - return mockContext{ - ctrl: ctrl, - server: server, - wrapper: Wrapper{server}, - } -} - func TestWrapper_RegisterPresentation(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newMockContext(t) @@ -107,3 +93,66 @@ func TestWrapper_RegisterPresentation(t *testing.T) { assert.ErrorIs(t, err, discovery.ErrInvalidPresentation) }) } + +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{ + { + VP: 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 + 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 0e78892306..9d026befae 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -94,5 +94,10 @@ type Server interface { // Client defines the API for Discovery Clients. type Client interface { - Search(serviceID string, query map[string]string) ([]vc.VerifiablePresentation, error) + Search(serviceID string, query map[string]string) ([]SearchResult, error) +} + +type SearchResult struct { + VP vc.VerifiablePresentation + Fields map[string]string } diff --git a/discovery/mock.go b/discovery/mock.go index ade7b84915..9b1320c756 100644 --- a/discovery/mock.go +++ b/discovery/mock.go @@ -92,10 +92,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 bf38c9bcf0..e4e298f4da 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -57,6 +57,7 @@ var _ core.Injectable = &Module{} var _ core.Runnable = &Module{} var _ core.Configurable = &Module{} var _ Server = &Module{} +var _ Client = &Module{} var retractionPresentationType = ssi.MustParseURI("RetractedVerifiablePresentation") @@ -253,3 +254,22 @@ func loadDefinitions(directory string) (map[string]ServiceDefinition, error) { } return result, nil } + +func (m *Module) Search(serviceID string, query map[string]string) ([]SearchResult, error) { + if _, exists := m.serverDefinitions[serviceID]; !exists { + return nil, ErrServerModeDisabled + } + matchingVPs, err := m.store.search(serviceID, query) + if err != nil { + return nil, err + } + var result []SearchResult + for _, matchingVP := range matchingVPs { + result = append(result, SearchResult{ + VP: matchingVP, + // TODO: TokenIntrospection is also missing map[InputDescriptorId]CredentialValue ? + //Fields: matchingVP.Fields, + }) + } + return result, nil +} diff --git a/discovery/module_test.go b/discovery/module_test.go index 47aad7f3b1..6858245377 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -264,3 +264,27 @@ 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) + assert.Equal(t, []SearchResult{ + { + VP: vpAlice, + Fields: nil, + }, + }, results) + }) + t.Run("not a client for this service ID", func(t *testing.T) { + m, _ := setupModule(t, storageEngine) + _, err := m.Search("other", nil) + assert.ErrorIs(t, err, ErrServerModeDisabled) + }) +} diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index f677b3f782..76eeecf636 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -132,23 +132,31 @@ paths: 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. + $ref: "#/components/schemas/SearchResult" default: $ref: "../common/error_response.yaml" components: schemas: VerifiablePresentation: $ref: "../common/ssi_types.yaml#/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. + additionalProperties: + type: string + description: The value of the field that matched the query. securitySchemes: jwtBearerAuth: type: http