diff --git a/pkg/document/webresolver/mocks/orbresolver.gen.go b/pkg/document/webresolver/mocks/orbresolver.gen.go new file mode 100644 index 000000000..b6cf63aa6 --- /dev/null +++ b/pkg/document/webresolver/mocks/orbresolver.gen.go @@ -0,0 +1,113 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package mocks + +import ( + "sync" + + "github.com/trustbloc/sidetree-core-go/pkg/document" +) + +type OrbResolver struct { + ResolveDocumentStub func(string) (*document.ResolutionResult, error) + resolveDocumentMutex sync.RWMutex + resolveDocumentArgsForCall []struct { + arg1 string + } + resolveDocumentReturns struct { + result1 *document.ResolutionResult + result2 error + } + resolveDocumentReturnsOnCall map[int]struct { + result1 *document.ResolutionResult + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *OrbResolver) ResolveDocument(arg1 string) (*document.ResolutionResult, error) { + fake.resolveDocumentMutex.Lock() + ret, specificReturn := fake.resolveDocumentReturnsOnCall[len(fake.resolveDocumentArgsForCall)] + fake.resolveDocumentArgsForCall = append(fake.resolveDocumentArgsForCall, struct { + arg1 string + }{arg1}) + fake.recordInvocation("ResolveDocument", []interface{}{arg1}) + fake.resolveDocumentMutex.Unlock() + if fake.ResolveDocumentStub != nil { + return fake.ResolveDocumentStub(arg1) + } + if specificReturn { + return ret.result1, ret.result2 + } + fakeReturns := fake.resolveDocumentReturns + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *OrbResolver) ResolveDocumentCallCount() int { + fake.resolveDocumentMutex.RLock() + defer fake.resolveDocumentMutex.RUnlock() + return len(fake.resolveDocumentArgsForCall) +} + +func (fake *OrbResolver) ResolveDocumentCalls(stub func(string) (*document.ResolutionResult, error)) { + fake.resolveDocumentMutex.Lock() + defer fake.resolveDocumentMutex.Unlock() + fake.ResolveDocumentStub = stub +} + +func (fake *OrbResolver) ResolveDocumentArgsForCall(i int) string { + fake.resolveDocumentMutex.RLock() + defer fake.resolveDocumentMutex.RUnlock() + argsForCall := fake.resolveDocumentArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *OrbResolver) ResolveDocumentReturns(result1 *document.ResolutionResult, result2 error) { + fake.resolveDocumentMutex.Lock() + defer fake.resolveDocumentMutex.Unlock() + fake.ResolveDocumentStub = nil + fake.resolveDocumentReturns = struct { + result1 *document.ResolutionResult + result2 error + }{result1, result2} +} + +func (fake *OrbResolver) ResolveDocumentReturnsOnCall(i int, result1 *document.ResolutionResult, result2 error) { + fake.resolveDocumentMutex.Lock() + defer fake.resolveDocumentMutex.Unlock() + fake.ResolveDocumentStub = nil + if fake.resolveDocumentReturnsOnCall == nil { + fake.resolveDocumentReturnsOnCall = make(map[int]struct { + result1 *document.ResolutionResult + result2 error + }) + } + fake.resolveDocumentReturnsOnCall[i] = struct { + result1 *document.ResolutionResult + result2 error + }{result1, result2} +} + +func (fake *OrbResolver) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.resolveDocumentMutex.RLock() + defer fake.resolveDocumentMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *OrbResolver) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} diff --git a/pkg/document/webresolver/resolvehandler.go b/pkg/document/webresolver/resolvehandler.go new file mode 100644 index 000000000..3f715ead5 --- /dev/null +++ b/pkg/document/webresolver/resolvehandler.go @@ -0,0 +1,132 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package webresolver + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/trustbloc/edge-core/pkg/log" + "github.com/trustbloc/sidetree-core-go/pkg/document" + "github.com/trustbloc/sidetree-core-go/pkg/docutil" +) + +var logger = log.New("did-web-resolver") + +// ErrDocumentNotFound is document not found error. +var ErrDocumentNotFound = fmt.Errorf("document not found") + +// ResolveHandler resolves generic documents. +type ResolveHandler struct { + orbResolver orbResolver + orbPrefix string + orbUnpublishedDIDLabel string + + domain *url.URL + + metrics metricsProvider +} + +// Orb resolver resolves Orb documents. +type orbResolver interface { + ResolveDocument(id string) (*document.ResolutionResult, error) +} + +type metricsProvider interface { + WebDocumentResolveTime(duration time.Duration) +} + +// NewResolveHandler returns a new document resolve handler. +func NewResolveHandler(domain *url.URL, orbPrefix, orbUnpublishedLabel string, resolver orbResolver, + metrics metricsProvider) *ResolveHandler { + rh := &ResolveHandler{ + domain: domain, + orbPrefix: orbPrefix, + orbResolver: resolver, + metrics: metrics, + orbUnpublishedDIDLabel: orbUnpublishedLabel, + } + + return rh +} + +// ResolveDocument resolves a document. +func (r *ResolveHandler) ResolveDocument(id string) (*document.ResolutionResult, error) { + startTime := time.Now() + + defer func() { + r.metrics.WebDocumentResolveTime(time.Since(startTime)) + }() + + unpublishedOrbDID := r.orbPrefix + docutil.NamespaceDelimiter + + r.orbUnpublishedDIDLabel + docutil.NamespaceDelimiter + id + + localResponse, err := r.orbResolver.ResolveDocument(unpublishedOrbDID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return nil, ErrDocumentNotFound + } + + return nil, fmt.Errorf("failed to resolve document for id[%s]: %w", id, err) + } + + alsoKnownAs := getAlsoKnownAs(localResponse.Document) + + webDID := fmt.Sprintf("did:web:%s:identity:%s", r.domain.Host, id) + + if !contains(alsoKnownAs, webDID) { + // TODO: is this legit error message (should we allow it even if it is not in also known as) + return nil, fmt.Errorf("id[%s] not found in alsoKnownAs", webDID) + } + + didWebDoc, err := transformToDIDWeb(id, localResponse.Document) + if err != nil { + return nil, err + } + + logger.Debugf("resolved id: %s", id) + + return &document.ResolutionResult{Document: didWebDoc}, nil +} + +func transformToDIDWeb(id string, doc document.Document) (document.Document, error) { + docBytes, err := doc.Bytes() + if err != nil { + return nil, fmt.Errorf("failed to marshal document for id[%s]: %w", id, err) + } + + // replace all occurrences of did:orb ID with did:web ID + didWebDocStr := strings.ReplaceAll(string(docBytes), doc.ID(), id) + + var didWebDoc document.Document + + err = json.Unmarshal([]byte(didWebDocStr), &didWebDoc) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal document for id[%s]: %w", id, err) + } + + return didWebDoc, nil +} + +func getAlsoKnownAs(doc document.Document) []string { + didDoc := document.DidDocumentFromJSONLDObject(doc) + + return didDoc.AlsoKnownAs() +} + +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + + return false +} diff --git a/pkg/document/webresolver/resolvehandler_test.go b/pkg/document/webresolver/resolvehandler_test.go new file mode 100644 index 000000000..0e20dbd95 --- /dev/null +++ b/pkg/document/webresolver/resolvehandler_test.go @@ -0,0 +1,119 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package webresolver + +//go:generate counterfeiter -o ./mocks/orbresolver.gen.go --fake-name OrbResolver . orbResolver + +import ( + "encoding/json" + "fmt" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trustbloc/sidetree-core-go/pkg/document" + + "github.com/trustbloc/orb/pkg/document/webresolver/mocks" + orbmocks "github.com/trustbloc/orb/pkg/mocks" +) + +const ( + orbPrefix = "did:orb" + orbUnpublishedLabel = "uAAA" + + testDomain = "https://example.com" + testSuffix = "suffix" +) + +func TestResolveHandler_Resolve(t *testing.T) { + testDomainURL, err := url.Parse(testDomain) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + testDoc, err := getTestDoc() + require.NoError(t, err) + + orbResolver := &mocks.OrbResolver{} + orbResolver.ResolveDocumentReturns(&document.ResolutionResult{Document: testDoc}, nil) + + handler := NewResolveHandler(testDomainURL, + orbPrefix, orbUnpublishedLabel, orbResolver, + &orbmocks.MetricsProvider{}) + + response, err := handler.ResolveDocument(testSuffix) + require.NoError(t, err) + require.NotNil(t, response) + }) + + t.Run("error - domain not in alsoKnownAs", func(t *testing.T) { + testDoc, err := getTestDoc() + require.NoError(t, err) + + orbResolver := &mocks.OrbResolver{} + orbResolver.ResolveDocumentReturns(&document.ResolutionResult{Document: testDoc}, nil) + + otherDomainURL, err := url.Parse("https://other.com") + require.NoError(t, err) + + handler := NewResolveHandler(otherDomainURL, + orbPrefix, orbUnpublishedLabel, orbResolver, + &orbmocks.MetricsProvider{}) + + response, err := handler.ResolveDocument("suffix") + require.Error(t, err) + require.Nil(t, response) + require.Contains(t, err.Error(), "id[did:web:other.com:identity:suffix] not found in alsoKnownAs") + }) + + t.Run("error - orb resolver error", func(t *testing.T) { + orbResolver := &mocks.OrbResolver{} + orbResolver.ResolveDocumentReturns(nil, fmt.Errorf("orb resolver error")) + + handler := NewResolveHandler(testDomainURL, + orbPrefix, orbUnpublishedLabel, orbResolver, + &orbmocks.MetricsProvider{}) + + response, err := handler.ResolveDocument(testSuffix) + require.Error(t, err) + require.Nil(t, response) + require.Contains(t, err.Error(), "orb resolver error") + }) + + t.Run("error - orb resolver not found error", func(t *testing.T) { + orbResolver := &mocks.OrbResolver{} + orbResolver.ResolveDocumentReturns(nil, fmt.Errorf("not found")) + + handler := NewResolveHandler(testDomainURL, + orbPrefix, orbUnpublishedLabel, orbResolver, + &orbmocks.MetricsProvider{}) + + response, err := handler.ResolveDocument(testSuffix) + require.Error(t, err) + require.Nil(t, response) + require.Contains(t, err.Error(), "document not found") + }) +} + +func getTestDoc() (document.Document, error) { + didDoc := make(document.Document) + didDoc["alsoKnownAs"] = []string{"did:web:example.com:identity:suffix"} + didDoc["id"] = "did:orb:cid:suffix" + + didDocBytes, err := didDoc.Bytes() + if err != nil { + return nil, err + } + + var expected document.Document + + err = json.Unmarshal(didDocBytes, &expected) + if err != nil { + return nil, err + } + + return expected, nil +} diff --git a/pkg/mocks/metricsprovider.go b/pkg/mocks/metricsprovider.go index eb98d56c6..2364889e3 100644 --- a/pkg/mocks/metricsprovider.go +++ b/pkg/mocks/metricsprovider.go @@ -122,6 +122,10 @@ func (m *MetricsProvider) DocumentCreateUpdateTime(value time.Duration) { func (m *MetricsProvider) DocumentResolveTime(value time.Duration) { } +// WebDocumentResolveTime records the time it takes the REST handler to resolve a web document. +func (m *MetricsProvider) WebDocumentResolveTime(value time.Duration) { +} + // OutboxIncrementActivityCount increments the number of activities of the given type posted to the outbox. func (m *MetricsProvider) OutboxIncrementActivityCount(activityType string) { }