Resolve mail exchange folders to their real path in the hierarchy (#752)

## Description

Creates and uses a `ContainerResolver` interface to fetch container paths for items of different categories (when other resolvers are implemented). If the resolver is not available or fails to resolve a folder, defaults to the old implementation of using the folder's ID as its path.

## Type of change

Please check the type of change your PR introduces:
- [x] 🌻 Feature
- [ ] 🐛 Bugfix
- [ ] 🗺️ Documentation
- [ ] 🤖 Test
- [ ] 🐹 Trivial/Minor

## Issue(s)
<!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. -->

* #456 

## Test Plan

<!-- How will this be tested prior to merging.-->

- [ ] 💪 Manual
- [x]  Unit test
- [ ] 💚 E2E
This commit is contained in:
ashmrtn 2022-09-02 16:18:49 -07:00 committed by GitHub
parent a1d3503436
commit d707c22205
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 176 additions and 3 deletions

View File

@ -158,7 +158,34 @@ func (suite *ExchangeIteratorSuite) TestIterativeFunctions() {
nil) nil)
iterateError := pageIterator.Iterate(callbackFunc) iterateError := pageIterator.Iterate(callbackFunc)
require.NoError(t, iterateError) assert.NoError(t, iterateError)
assert.NoError(t, errs)
// TODO(ashmrtn): Only check Exchange Mail folder names right now because
// other resolvers aren't implemented. Once they are we can expand these
// checks, potentially by breaking things out into separate tests per
// category.
if !test.scope.IncludesCategory(selectors.ExchangeMail) {
return
}
expectedFolderNames := map[string]struct{}{
"Inbox": {},
"Sent Items": {},
"Deleted Items": {},
}
for _, c := range collections {
// TODO(ashmrtn): Update these checks when collections support path.Path.
require.Greater(t, len(c.FullPath()), 4)
folder := c.FullPath()[4]
if _, ok := expectedFolderNames[folder]; ok {
delete(expectedFolderNames, folder)
}
}
assert.Empty(t, expectedFolderNames)
}) })
} }
} }

View File

@ -3,6 +3,7 @@ package exchange
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -13,6 +14,8 @@ import (
"github.com/alcionai/corso/pkg/selectors" "github.com/alcionai/corso/pkg/selectors"
) )
var errNilResolver = errors.New("nil resolver")
// descendable represents objects that implement msgraph-sdk-go/models.entityable // descendable represents objects that implement msgraph-sdk-go/models.entityable
// and have the concept of a "parent folder". // and have the concept of a "parent folder".
type descendable interface { type descendable interface {
@ -37,6 +40,71 @@ type GraphIterateFunc func(
statusUpdater support.StatusUpdater, statusUpdater support.StatusUpdater,
) func(any) bool ) func(any) bool
// maybeGetAndPopulateFolderResolver gets a folder resolver if one is available for
// this category of data. If one is not available, returns nil so that other
// logic in the caller can complete as long as they check if the resolver is not
// nil. If an error occurs populating the resolver, returns an error.
func maybeGetAndPopulateFolderResolver(
ctx context.Context,
qp graph.QueryParams,
category path.CategoryType,
) (graph.ContainerResolver, error) {
var res graph.ContainerResolver
switch category {
case path.EmailCategory:
service, err := createService(qp.Credentials, qp.FailFast)
if err != nil {
return nil, err
}
res = &mailFolderCache{
userID: qp.User,
gs: service,
}
default:
return nil, nil
}
if err := res.Populate(ctx); err != nil {
return nil, errors.Wrap(err, "populating directory resolver")
}
return res, nil
}
func resolveCollectionPath(
ctx context.Context,
resolver graph.ContainerResolver,
tenantID, user, folderID string,
category path.CategoryType,
) ([]string, error) {
if resolver == nil {
// Allows caller to default to old-style path.
return nil, errors.WithStack(errNilResolver)
}
p, err := resolver.IDToPath(ctx, folderID)
if err != nil {
return nil, errors.Wrap(err, "resolving folder ID")
}
fullPath, err := p.ToDataLayerExchangePathForCategory(
tenantID,
user,
category,
false,
)
if err != nil {
return nil, errors.Wrap(err, "converting to canonical path")
}
// TODO(ashmrtn): This can return the path directly when Collections take
// path.Path.
return strings.Split(fullPath.String(), "/"), nil
}
// IterateSelectAllDescendablesForCollection utility function for // IterateSelectAllDescendablesForCollection utility function for
// Iterating through MessagesCollectionResponse or ContactsCollectionResponse, // Iterating through MessagesCollectionResponse or ContactsCollectionResponse,
// objects belonging to any folder are // objects belonging to any folder are
@ -52,6 +120,7 @@ func IterateSelectAllDescendablesForCollections(
isCategorySet bool isCategorySet bool
collectionType optionIdentifier collectionType optionIdentifier
category path.CategoryType category path.CategoryType
resolver graph.ContainerResolver
) )
return func(pageItem any) bool { return func(pageItem any) bool {
@ -67,6 +136,16 @@ func IterateSelectAllDescendablesForCollections(
category = path.ContactsCategory category = path.ContactsCategory
} }
if r, err := maybeGetAndPopulateFolderResolver(ctx, qp, category); err != nil {
errs = support.WrapAndAppend(
"getting folder resolver for category "+category.String(),
err,
errs,
)
} else {
resolver = r
}
isCategorySet = true isCategorySet = true
} }
@ -75,9 +154,29 @@ func IterateSelectAllDescendablesForCollections(
errs = support.WrapAndAppendf(qp.User, errors.New("descendable conversion failure"), errs) errs = support.WrapAndAppendf(qp.User, errors.New("descendable conversion failure"), errs)
return true return true
} }
// Saving to messages to list. Indexed by folder // Saving to messages to list. Indexed by folder
directory := *entry.GetParentFolderId() directory := *entry.GetParentFolderId()
dirPath := []string{qp.Credentials.TenantID, qp.User, category.String(), directory}
if _, ok = collections[directory]; !ok { if _, ok = collections[directory]; !ok {
newPath, err := resolveCollectionPath(
ctx,
resolver,
qp.Credentials.TenantID,
qp.User,
directory,
category,
)
if err != nil {
if !errors.Is(err, errNilResolver) {
errs = support.WrapAndAppend("", err, errs)
}
} else {
dirPath = newPath
}
service, err := createService(qp.Credentials, qp.FailFast) service, err := createService(qp.Credentials, qp.FailFast)
if err != nil { if err != nil {
errs = support.WrapAndAppend(qp.User, err, errs) errs = support.WrapAndAppend(qp.User, err, errs)
@ -86,7 +185,7 @@ func IterateSelectAllDescendablesForCollections(
edc := NewCollection( edc := NewCollection(
qp.User, qp.User,
[]string{qp.Credentials.TenantID, qp.User, category.String(), directory}, dirPath,
collectionType, collectionType,
service, service,
statusUpdater, statusUpdater,
@ -239,6 +338,15 @@ func IterateFilterFolderDirectoriesForCollections(
err error err error
) )
resolver, err := maybeGetAndPopulateFolderResolver(ctx, qp, path.EmailCategory)
if err != nil {
errs = support.WrapAndAppend(
"getting folder resolver for category email",
err,
errs,
)
}
return func(folderItem any) bool { return func(folderItem any) bool {
folder, ok := folderItem.(displayable) folder, ok := folderItem.(displayable)
if !ok { if !ok {
@ -260,6 +368,29 @@ func IterateFilterFolderDirectoriesForCollections(
} }
directory := *folder.GetId() directory := *folder.GetId()
dirPath := []string{
qp.Credentials.TenantID,
qp.User,
path.EmailCategory.String(),
directory,
}
p, err := resolveCollectionPath(
ctx,
resolver,
qp.Credentials.TenantID,
qp.User,
directory,
path.EmailCategory,
)
if err != nil {
if !errors.Is(err, errNilResolver) {
errs = support.WrapAndAppend("", err, errs)
}
} else {
dirPath = p
}
service, err = createService(qp.Credentials, qp.FailFast) service, err = createService(qp.Credentials, qp.FailFast)
if err != nil { if err != nil {
@ -277,7 +408,7 @@ func IterateFilterFolderDirectoriesForCollections(
temp := NewCollection( temp := NewCollection(
qp.User, qp.User,
[]string{qp.Credentials.TenantID, qp.User, path.EmailCategory.String(), directory}, dirPath,
messages, messages,
service, service,
statusUpdater, statusUpdater,

View File

@ -1,8 +1,11 @@
package graph package graph
import ( import (
"context"
msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
"github.com/alcionai/corso/internal/path"
"github.com/alcionai/corso/pkg/account" "github.com/alcionai/corso/pkg/account"
"github.com/alcionai/corso/pkg/selectors" "github.com/alcionai/corso/pkg/selectors"
) )
@ -24,3 +27,15 @@ type Service interface {
// ErrPolicy returns if the service is implementing a Fast-Fail policy or not // ErrPolicy returns if the service is implementing a Fast-Fail policy or not
ErrPolicy() bool ErrPolicy() bool
} }
// ContainerResolver houses functions for getting information about containers
// from remote APIs (i.e. resolve folder paths with Graph API). Resolvers may
// cache information about containers.
type ContainerResolver interface {
// IDToPath takes an m365 container ID and converts it to a hierarchical path
// to that container. The path has a similar format to paths on the local
// file system.
IDToPath(ctx context.Context, m365ID string) (*path.Builder, error)
// Populate performs any setup logic the resolver may need.
Populate(context.Context) error
}