From 085c43da4563f3350c7256f3fe080ba2fb808047 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Tue, 9 Jan 2024 10:27:36 +0100 Subject: [PATCH] Discovery: HTTP client for communicating with Discovery Service (#2711) * Discovery: HTTP client for communicating with Discovery Service * import cycle * shared models package * pr feedback * pr feedback --- codegen/configs/discovery_v1.yaml | 1 + discovery/api/v1/client/http.go | 93 +++++++++++++++++++ discovery/api/v1/client/http_test.go | 118 ++++++++++++++++++++++++ discovery/api/v1/client/interface.go | 35 +++++++ discovery/api/v1/client/mock.go | 71 ++++++++++++++ discovery/api/v1/generated.go | 19 +--- discovery/api/v1/model/types.go | 27 ++++++ discovery/api/v1/types.go | 8 +- discovery/api/v1/wrapper.go | 1 + docs/_static/discovery/v1.yaml | 24 ++--- makefile | 1 + test/http/handler.go | 3 + vcr/verifier/signature_verifier.go | 18 ++++ vcr/verifier/signature_verifier_test.go | 18 ++++ 14 files changed, 411 insertions(+), 26 deletions(-) create mode 100644 discovery/api/v1/client/http.go create mode 100644 discovery/api/v1/client/http_test.go create mode 100644 discovery/api/v1/client/interface.go create mode 100644 discovery/api/v1/client/mock.go create mode 100644 discovery/api/v1/model/types.go diff --git a/codegen/configs/discovery_v1.yaml b/codegen/configs/discovery_v1.yaml index ffa5d4adbb..f0fba7b580 100644 --- a/codegen/configs/discovery_v1.yaml +++ b/codegen/configs/discovery_v1.yaml @@ -8,3 +8,4 @@ output-options: skip-prune: true exclude-schemas: - VerifiablePresentation + - PresentationsResponse diff --git a/discovery/api/v1/client/http.go b/discovery/api/v1/client/http.go new file mode 100644 index 0000000000..357e5f04a3 --- /dev/null +++ b/discovery/api/v1/client/http.go @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024 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 client + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/discovery/api/v1/model" + "io" + "net/http" + "net/url" + "time" +) + +// New creates a new DefaultHTTPClient. +func New(strictMode bool, timeout time.Duration, tlsConfig *tls.Config) *DefaultHTTPClient { + return &DefaultHTTPClient{ + client: core.NewStrictHTTPClient(strictMode, timeout, tlsConfig), + } +} + +var _ HTTPClient = &DefaultHTTPClient{} + +// DefaultHTTPClient implements HTTPClient using HTTP. +type DefaultHTTPClient struct { + client core.HTTPRequestDoer +} + +func (h DefaultHTTPClient) Register(ctx context.Context, serviceEndpointURL string, presentation vc.VerifiablePresentation) error { + requestBody, _ := json.Marshal(presentation) + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceEndpointURL, bytes.NewReader(requestBody)) + if err != nil { + return err + } + httpRequest.Header.Set("Content-Type", "application/json") + httpResponse, err := h.client.Do(httpRequest) + if err != nil { + return fmt.Errorf("failed to invoke remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + } + defer httpResponse.Body.Close() + if err := core.TestResponseCode(201, httpResponse); err != nil { + return fmt.Errorf("non-OK response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + } + return nil +} + +func (h DefaultHTTPClient) Get(ctx context.Context, serviceEndpointURL string, tag *string) ([]vc.VerifiablePresentation, *string, error) { + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceEndpointURL, nil) + if tag != nil { + httpRequest.URL.RawQuery = url.Values{"tag": []string{*tag}}.Encode() + } + if err != nil { + return nil, nil, err + } + httpResponse, err := h.client.Do(httpRequest) + if err != nil { + return nil, nil, fmt.Errorf("failed to invoke remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + } + defer httpResponse.Body.Close() + if err := core.TestResponseCode(200, httpResponse); err != nil { + return nil, nil, fmt.Errorf("non-OK response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + } + responseData, err := io.ReadAll(httpResponse.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + } + var result model.PresentationsResponse + if err := json.Unmarshal(responseData, &result); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + } + return result.Entries, &result.Tag, nil +} diff --git a/discovery/api/v1/client/http_test.go b/discovery/api/v1/client/http_test.go new file mode 100644 index 0000000000..98cee0da1d --- /dev/null +++ b/discovery/api/v1/client/http_test.go @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2024 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 client + +import ( + "context" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" + testHTTP "github.com/nuts-foundation/nuts-node/test/http" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestHTTPInvoker_Register(t *testing.T) { + vp := vc.VerifiablePresentation{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + } + vpData, _ := vp.MarshalJSON() + t.Run("ok", func(t *testing.T) { + handler := &testHTTP.Handler{StatusCode: http.StatusCreated} + server := httptest.NewServer(handler) + client := New(false, time.Minute, server.TLS) + + err := client.Register(context.Background(), server.URL, vp) + + assert.NoError(t, err) + assert.Equal(t, http.MethodPost, handler.Request.Method) + assert.Equal(t, "application/json", handler.Request.Header.Get("Content-Type")) + assert.Equal(t, vpData, handler.RequestData) + }) + t.Run("non-ok", func(t *testing.T) { + server := httptest.NewServer(&testHTTP.Handler{StatusCode: http.StatusInternalServerError}) + client := New(false, time.Minute, server.TLS) + + err := client.Register(context.Background(), server.URL, vp) + + assert.ErrorContains(t, err, "non-OK response from remote Discovery Service") + }) +} + +func TestHTTPInvoker_Get(t *testing.T) { + vp := vc.VerifiablePresentation{ + Context: []ssi.URI{ssi.MustParseURI("https://www.w3.org/2018/credentials/v1")}, + } + const clientTag = "client-tag" + const serverTag = "server-tag" + t.Run("no tag from client", func(t *testing.T) { + handler := &testHTTP.Handler{StatusCode: http.StatusOK} + handler.ResponseData = map[string]interface{}{ + "entries": []interface{}{vp}, + "tag": serverTag, + } + server := httptest.NewServer(handler) + client := New(false, time.Minute, server.TLS) + + presentations, tag, err := client.Get(context.Background(), server.URL, nil) + + assert.NoError(t, err) + assert.Len(t, presentations, 1) + assert.Empty(t, handler.RequestQuery.Get("tag")) + assert.Equal(t, serverTag, *tag) + }) + t.Run("tag provided by client", func(t *testing.T) { + handler := &testHTTP.Handler{StatusCode: http.StatusOK} + handler.ResponseData = map[string]interface{}{ + "entries": []interface{}{vp}, + "tag": serverTag, + } + server := httptest.NewServer(handler) + client := New(false, time.Minute, server.TLS) + + inputTag := clientTag + presentations, tag, err := client.Get(context.Background(), server.URL, &inputTag) + + assert.NoError(t, err) + assert.Len(t, presentations, 1) + assert.Equal(t, clientTag, handler.RequestQuery.Get("tag")) + assert.Equal(t, serverTag, *tag) + }) + t.Run("server returns invalid status code", func(t *testing.T) { + handler := &testHTTP.Handler{StatusCode: http.StatusInternalServerError} + server := httptest.NewServer(handler) + client := New(false, time.Minute, server.TLS) + + _, _, err := client.Get(context.Background(), server.URL, nil) + + assert.ErrorContains(t, err, "non-OK response from remote Discovery Service") + }) + t.Run("server does not return JSON", func(t *testing.T) { + handler := &testHTTP.Handler{StatusCode: http.StatusOK} + handler.ResponseData = "not json" + server := httptest.NewServer(handler) + client := New(false, time.Minute, server.TLS) + + _, _, err := client.Get(context.Background(), server.URL, nil) + + assert.ErrorContains(t, err, "failed to unmarshal response from remote Discovery Service") + }) +} diff --git a/discovery/api/v1/client/interface.go b/discovery/api/v1/client/interface.go new file mode 100644 index 0000000000..1ad0842f2c --- /dev/null +++ b/discovery/api/v1/client/interface.go @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 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 client + +import ( + "context" + "github.com/nuts-foundation/go-did/vc" +) + +// HTTPClient is the interface for the client that invokes the remote Discovery Service. +type HTTPClient interface { + // Register registers a Verifiable Presentation on the remote Discovery Service. + Register(ctx context.Context, serviceEndpointURL string, presentation vc.VerifiablePresentation) error + + // Get retrieves Verifiable Presentations from the remote Discovery Service, that were added since the given tag. + // If the call succeeds it returns the Verifiable Presentations and the tag that was returned by the server. + // If tag is nil, all Verifiable Presentations are retrieved. + Get(ctx context.Context, serviceEndpointURL string, tag *string) ([]vc.VerifiablePresentation, *string, error) +} diff --git a/discovery/api/v1/client/mock.go b/discovery/api/v1/client/mock.go new file mode 100644 index 0000000000..05119ff9bb --- /dev/null +++ b/discovery/api/v1/client/mock.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: discovery/api/v1/client/interface.go +// +// Generated by this command: +// +// mockgen -destination=discovery/api/v1/client/mock.go -package=client -source=discovery/api/v1/client/interface.go +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + vc "github.com/nuts-foundation/go-did/vc" + gomock "go.uber.org/mock/gomock" +) + +// MockHTTPClient is a mock of HTTPClient interface. +type MockHTTPClient struct { + ctrl *gomock.Controller + recorder *MockHTTPClientMockRecorder +} + +// MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient. +type MockHTTPClientMockRecorder struct { + mock *MockHTTPClient +} + +// NewMockHTTPClient creates a new mock instance. +func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient { + mock := &MockHTTPClient{ctrl: ctrl} + mock.recorder = &MockHTTPClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockHTTPClient) Get(ctx context.Context, serviceEndpointURL string, tag *string) ([]vc.VerifiablePresentation, *string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, serviceEndpointURL, tag) + ret0, _ := ret[0].([]vc.VerifiablePresentation) + ret1, _ := ret[1].(*string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Get indicates an expected call of Get. +func (mr *MockHTTPClientMockRecorder) Get(ctx, serviceEndpointURL, tag any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockHTTPClient)(nil).Get), ctx, serviceEndpointURL, tag) +} + +// Register mocks base method. +func (m *MockHTTPClient) Register(ctx context.Context, serviceEndpointURL string, presentation vc.VerifiablePresentation) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Register", ctx, serviceEndpointURL, presentation) + ret0, _ := ret[0].(error) + return ret0 +} + +// Register indicates an expected call of Register. +func (mr *MockHTTPClientMockRecorder) Register(ctx, serviceEndpointURL, presentation any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Register", reflect.TypeOf((*MockHTTPClient)(nil).Register), ctx, serviceEndpointURL, presentation) +} diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go index aac9117245..67aaee659c 100644 --- a/discovery/api/v1/generated.go +++ b/discovery/api/v1/generated.go @@ -304,12 +304,9 @@ type ClientWithResponsesInterface interface { } type GetPresentationsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *struct { - Entries []VerifiablePresentation `json:"entries"` - Tag string `json:"tag"` - } + Body []byte + HTTPResponse *http.Response + JSON200 *PresentationsResponse ApplicationproblemJSONDefault *struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -420,10 +417,7 @@ func ParseGetPresentationsResponse(rsp *http.Response) (*GetPresentationsRespons switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest struct { - Entries []VerifiablePresentation `json:"entries"` - Tag string `json:"tag"` - } + var dest PresentationsResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -603,10 +597,7 @@ type GetPresentationsResponseObject interface { VisitGetPresentationsResponse(w http.ResponseWriter) error } -type GetPresentations200JSONResponse struct { - Entries []VerifiablePresentation `json:"entries"` - Tag string `json:"tag"` -} +type GetPresentations200JSONResponse PresentationsResponse func (response GetPresentations200JSONResponse) VisitGetPresentationsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/discovery/api/v1/model/types.go b/discovery/api/v1/model/types.go new file mode 100644 index 0000000000..2d9a512ad0 --- /dev/null +++ b/discovery/api/v1/model/types.go @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 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 model + +import "github.com/nuts-foundation/go-did/vc" + +// PresentationsResponse is the response for the GetPresentations endpoint. +type PresentationsResponse struct { + Entries []vc.VerifiablePresentation `json:"entries"` + Tag string `json:"tag"` +} diff --git a/discovery/api/v1/types.go b/discovery/api/v1/types.go index 7a9ff02004..d719c5d646 100644 --- a/discovery/api/v1/types.go +++ b/discovery/api/v1/types.go @@ -18,7 +18,13 @@ package v1 -import "github.com/nuts-foundation/go-did/vc" +import ( + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/discovery/api/v1/model" +) // VerifiablePresentation is a type alias for the VerifiablePresentation from the go-did library. type VerifiablePresentation = vc.VerifiablePresentation + +// PresentationsResponse is a type alias +type PresentationsResponse = model.PresentationsResponse diff --git a/discovery/api/v1/wrapper.go b/discovery/api/v1/wrapper.go index 375a1939e3..93f58cf394 100644 --- a/discovery/api/v1/wrapper.go +++ b/discovery/api/v1/wrapper.go @@ -62,6 +62,7 @@ func (w *Wrapper) Routes(router core.EchoRouter) { func (w *Wrapper) GetPresentations(_ context.Context, request GetPresentationsRequestObject) (GetPresentationsResponseObject, error) { var tag *discovery.Tag if request.Params.Tag != nil { + // *string to *Tag tag = new(discovery.Tag) *tag = discovery.Tag(*request.Params.Tag) } diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index 062502ef56..2d376315b6 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -38,17 +38,7 @@ paths: content: application/json: schema: - type: object - required: - - tag - - entries - properties: - tag: - type: string - entries: - type: array - items: - $ref: "#/components/schemas/VerifiablePresentation" + $ref: "#/components/schemas/PresentationsResponse" default: $ref: "../common/error_response.yaml" post: @@ -85,6 +75,18 @@ components: schemas: VerifiablePresentation: $ref: "../common/ssi_types.yaml#/components/schemas/VerifiablePresentation" + PresentationsResponse: + type: object + required: + - tag + - entries + properties: + tag: + type: string + entries: + type: array + items: + $ref: "#/components/schemas/VerifiablePresentation" securitySchemes: jwtBearerAuth: type: http diff --git a/makefile b/makefile index 0c3317d69c..7d9c9e5b9e 100644 --- a/makefile +++ b/makefile @@ -20,6 +20,7 @@ gen-mocks: mockgen -destination=crypto/storage/spi/mock.go -package spi -source=crypto/storage/spi/interface.go mockgen -destination=didman/mock.go -package=didman -source=didman/types.go mockgen -destination=discovery/mock.go -package=discovery -source=discovery/interface.go + mockgen -destination=discovery/api/v1/client/mock.go -package=client -source=discovery/api/v1/client/interface.go mockgen -destination=events/events_mock.go -package=events -source=events/interface.go Event mockgen -destination=events/mock.go -package=events -source=events/conn.go Conn ConnectionPool mockgen -destination=http/echo_mock.go -package=http -source=http/echo.go -imports echo=github.com/labstack/echo/v4 diff --git a/test/http/handler.go b/test/http/handler.go index 6edd9f3b27..59814aba33 100644 --- a/test/http/handler.go +++ b/test/http/handler.go @@ -20,6 +20,7 @@ import ( "encoding/json" "io" "net/http" + "net/url" ) // Handler is a custom http handler useful in testing. @@ -31,6 +32,7 @@ import ( type Handler struct { Request *http.Request RequestHeaders http.Header + RequestQuery url.Values StatusCode int RequestData []byte ResponseData interface{} @@ -41,6 +43,7 @@ func (h *Handler) ServeHTTP(writer http.ResponseWriter, req *http.Request) { h.Request = req h.RequestData, _ = io.ReadAll(req.Body) h.RequestHeaders = req.Header.Clone() + h.RequestQuery = req.URL.Query() var bytes []byte if s, ok := h.ResponseData.(string); ok { diff --git a/vcr/verifier/signature_verifier.go b/vcr/verifier/signature_verifier.go index 3ca21a8a2a..7822db896c 100644 --- a/vcr/verifier/signature_verifier.go +++ b/vcr/verifier/signature_verifier.go @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 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 verifier import ( diff --git a/vcr/verifier/signature_verifier_test.go b/vcr/verifier/signature_verifier_test.go index 6169923315..1307148c65 100644 --- a/vcr/verifier/signature_verifier_test.go +++ b/vcr/verifier/signature_verifier_test.go @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 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 verifier import (