Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion broker/lms/lms_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,26 @@ type CheckedOutItem struct {
DueDate *time.Time
}

// LookupUserOptions controls which user data an LMS lookup should validate and return.
type LookupUserOptions struct {
ValidatePatronProfile bool
IncludePatronInfo bool
}

// LookupUserResult contains the canonical user identifier and optional patron details.
type LookupUserResult struct {
UserID string
GivenName string
Surname string
EmailAddresses []string
}

// LmsAdapter is an interface defining methods for interacting with a Library Management System (LMS)
// https://github.com/openlibraryenvironment/mod-rs/blob/master/service/src/main/groovy/org/olf/rs/lms/HostLMSActions.groovy
type LmsAdapter interface {
SetLogFunc(logFunc ncipclient.NcipLogFunc)

LookupUser(patron string, validatePatronProfile bool) (userId string, err error)
LookupUser(patron string, options LookupUserOptions) (LookupUserResult, error)

// Operations without a response payload return performed=false when skipped.
// Errors never confirm progress; explicit manual confirmations do.
Expand Down
4 changes: 2 additions & 2 deletions broker/lms/lms_adapter_manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ type LmsAdapterManual struct {
func (l *LmsAdapterManual) SetLogFunc(logFunc ncipclient.NcipLogFunc) {
}

func (l *LmsAdapterManual) LookupUser(patron string, validatePatronProfile bool) (string, error) {
return patron, nil
func (l *LmsAdapterManual) LookupUser(patron string, options LookupUserOptions) (LookupUserResult, error) {
return LookupUserResult{UserID: patron}, nil
}

// AcceptItem skips requester LMS item creation in manual workflows.
Expand Down
110 changes: 82 additions & 28 deletions broker/lms/lms_adapter_ncip.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@ import (
"encoding/xml"
"errors"
"fmt"
"github.com/indexdata/crosslink/broker/profiles"
"net/http"
"strings"

"github.com/indexdata/crosslink/broker/ncipclient"
"github.com/indexdata/crosslink/broker/profiles"
dirapi "github.com/indexdata/crosslink/directory/api"
"github.com/indexdata/crosslink/ncip"
)

type NcipUserElement string

const (
NCIPUserId string = "User Id"
NCIPUserPrivilege string = "User Privilege"
NCIPItemBarcode string = "Item Barcode"
NCIPUserId string = "User Id"
NCIPUserPrivilege string = "User Privilege"
NCIPNameInformation string = "Name Information"
NCIPUserAddressInformation string = "User Address Information"
NCIPItemBarcode string = "Item Barcode"
)

type NcipItemElement string
Expand Down Expand Up @@ -83,26 +85,26 @@ func (l *LmsAdapterNcip) SetLogFunc(logFunc ncipclient.NcipLogFunc) {
l.ncipClient.SetLogFunc(logFunc)
}

func (l *LmsAdapterNcip) LookupUser(patron string, validatePatronProfile bool) (string, error) {
func (l *LmsAdapterNcip) LookupUser(patron string, options LookupUserOptions) (LookupUserResult, error) {
if l.config.LookupUserEnabled != nil && !*l.config.LookupUserEnabled {
return patron, nil // could even be empty
return LookupUserResult{UserID: patron}, nil // could even be empty
}
if patron == "" {
return "", fmt.Errorf("empty patron identifier")
return LookupUserResult{}, fmt.Errorf("empty patron identifier")
}
// first try to check if patron is actually user Id
arg := ncip.LookupUser{
UserId: &ncip.UserId{UserIdentifierValue: patron},
UserElementType: l.getUserElements(false, validatePatronProfile),
UserElementType: l.getUserElements(false, options),
}
response, err := l.ncipClient.LookupUser(arg)
if err == nil {
if validatePatronProfile {
if options.ValidatePatronProfile {
if err = l.validatePatronProfile(response); err != nil {
return "", err
return LookupUserResult{}, err
}
}
return patron, nil
return lookupUserResult(patron, response, options.IncludePatronInfo), nil
}
// then try by user username
// a better solution would be that the LookupUser had type argument (eg barcode or PIN)
Expand All @@ -114,39 +116,91 @@ func (l *LmsAdapterNcip) LookupUser(patron string, validatePatronProfile bool) (
})
arg = ncip.LookupUser{
AuthenticationInput: authenticationInput,
UserElementType: l.getUserElements(true, validatePatronProfile),
UserElementType: l.getUserElements(true, options),
}
response, err = l.ncipClient.LookupUser(arg)
if err != nil {
return "", err
return LookupUserResult{}, err
}
if validatePatronProfile {
if options.ValidatePatronProfile {
if err = l.validatePatronProfile(response); err != nil {
return "", err
return LookupUserResult{}, err
}
}
userID := ""
if response != nil && response.UserOptionalFields != nil && len(response.UserOptionalFields.UserId) != 0 {
return response.UserOptionalFields.UserId[0].UserIdentifierValue, nil
userID = response.UserOptionalFields.UserId[0].UserIdentifierValue
} else if response != nil && response.UserId != nil {
userID = response.UserId.UserIdentifierValue
}
if userID == "" {
return LookupUserResult{}, fmt.Errorf("missing User ID in LookupUser response")
}
return lookupUserResult(userID, response, options.IncludePatronInfo), nil
}

func (l *LmsAdapterNcip) getUserElements(userID bool, options LookupUserOptions) []ncip.SchemeValuePair {
var elements []ncip.SchemeValuePair
if userID || (options.ValidatePatronProfile && l.config.PatronProfiles != nil && len(*l.config.PatronProfiles) > 0) {
elements = append(elements, ncip.SchemeValuePair{Text: NCIPUserId})
}
if options.ValidatePatronProfile && l.config.PatronProfiles != nil && len(*l.config.PatronProfiles) > 0 {
elements = append(elements, ncip.SchemeValuePair{Text: NCIPUserPrivilege})
}
if response != nil && response.UserId != nil {
return response.UserId.UserIdentifierValue, nil
if options.IncludePatronInfo {
elements = append(elements,
ncip.SchemeValuePair{Text: NCIPNameInformation},
ncip.SchemeValuePair{Text: NCIPUserAddressInformation},
)
}
return "", fmt.Errorf("missing User ID in LookupUser response")
return elements
}

func (l *LmsAdapterNcip) getUserElements(userId bool, validatePatronProfile bool) []ncip.SchemeValuePair {
if validatePatronProfile && l.config.PatronProfiles != nil && len(*l.config.PatronProfiles) > 0 {
return []ncip.SchemeValuePair{
{Text: NCIPUserId},
{Text: NCIPUserPrivilege},
func lookupUserResult(userID string, response *ncip.LookupUserResponse, includePatronInfo bool) LookupUserResult {
result := LookupUserResult{UserID: userID}
if !includePatronInfo || response == nil || response.UserOptionalFields == nil {
return result
}

optional := response.UserOptionalFields
if optional.NameInformation != nil && optional.NameInformation.PersonalNameInformation != nil {
name := optional.NameInformation.PersonalNameInformation.StructuredPersonalUserName
if name != nil {
result.GivenName = strings.TrimSpace(name.GivenName)
result.Surname = strings.TrimSpace(name.Surname)
}
}
if userId {
return []ncip.SchemeValuePair{
{Text: NCIPUserId},

seen := make(map[string]struct{})
for _, address := range optional.UserAddressInformation {
email := ncipEmailAddress(address.ElectronicAddress)
if email == "" {
continue
}
key := strings.ToLower(email)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result.EmailAddresses = append(result.EmailAddresses, email)
}
return nil
return result
}

func ncipEmailAddress(address *ncip.ElectronicAddress) string {
if address == nil {
return ""
}
data := strings.TrimSpace(address.ElectronicAddressData)
if data == "" {
return ""
}
addressType := strings.ToLower(strings.TrimSpace(address.ElectronicAddressType.Text))
// Match common NCIP/FOLIO email type variants such as "mailto", "Email", and "electronic mail address".
if strings.Contains(addressType, "mail") {
return data
}
return ""
}

func (l *LmsAdapterNcip) validatePatronProfile(response *ncip.LookupUserResponse) error {
Expand Down
100 changes: 72 additions & 28 deletions broker/lms/lms_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,80 +51,81 @@ func TestLookupUser(t *testing.T) {
ncipClient: mock,
config: config,
}
_, err := ad.LookupUser("", true)
validateOptions := LookupUserOptions{ValidatePatronProfile: true}
_, err := ad.LookupUser("", validateOptions)
assert.Error(t, err)
assert.Equal(t, "empty patron identifier", err.Error())

userId, err := ad.LookupUser("testuser", true)
result, err := ad.LookupUser("testuser", validateOptions)
assert.NoError(t, err)
assert.Equal(t, "testuser", userId)
assert.Equal(t, "testuser", result.UserID)
request := mock.(*ncipClientMock).lastRequest.(ncip.LookupUser)
assert.Equal(t, []ncip.SchemeValuePair{{Text: NCIPUserId}, {Text: NCIPUserPrivilege}}, request.UserElementType)

userId, err = ad.LookupUser("staff-profile", true)
result, err = ad.LookupUser("staff-profile", validateOptions)
assert.NoError(t, err)
assert.Equal(t, "staff-profile", userId)
assert.Equal(t, "staff-profile", result.UserID)

_, err = ad.LookupUser("blocked-profile", true)
_, err = ad.LookupUser("blocked-profile", validateOptions)
assert.EqualError(t, err, `patron profile with code "BLOCKED" and name "Blocked patrons" is not eligible to create ILL requests`)
var ineligibleErr *PatronProfileIneligibleError
if assert.ErrorAs(t, err, &ineligibleErr) {
assert.Equal(t, "BLOCKED", ineligibleErr.ProfileCode)
assert.Equal(t, "Blocked patrons", ineligibleErr.ProfileName)
}

_, err = ad.LookupUser("blocked user", true)
_, err = ad.LookupUser("blocked user", validateOptions)
assert.EqualError(t, err, `patron profile with code "BLOCKED" and name "Blocked patrons" is not eligible to create ILL requests`)
request = mock.(*ncipClientMock).lastRequest.(ncip.LookupUser)
assert.Equal(t, []ncip.SchemeValuePair{{Text: NCIPUserId}, {Text: NCIPUserPrivilege}}, request.UserElementType)

userId, err = ad.LookupUser("blocked-profile", false)
result, err = ad.LookupUser("blocked-profile", LookupUserOptions{})
assert.NoError(t, err)
assert.Equal(t, "blocked-profile", userId)
assert.Equal(t, "blocked-profile", result.UserID)
request = mock.(*ncipClientMock).lastRequest.(ncip.LookupUser)
assert.Empty(t, request.UserElementType)

userId, err = ad.LookupUser("blocked user", false)
result, err = ad.LookupUser("blocked user", LookupUserOptions{})
assert.NoError(t, err)
assert.Equal(t, "blocked-user-id", userId)
assert.Equal(t, "blocked-user-id", result.UserID)
request = mock.(*ncipClientMock).lastRequest.(ncip.LookupUser)
assert.Equal(t, []ncip.SchemeValuePair{{Text: NCIPUserId}}, request.UserElementType)

_, err = ad.LookupUser("bad user", true)
_, err = ad.LookupUser("bad user", validateOptions)
assert.Error(t, err)
assert.Equal(t, "unknown user name", err.Error())

_, err = ad.LookupUser("problem user", true)
_, err = ad.LookupUser("problem user", validateOptions)
var ncipErr *ncipclient.NcipError
assert.ErrorAs(t, err, &ncipErr)
assert.Equal(t, string(ncip.UnknownUser), ncipErr.Problem.ProblemType.Text)
assert.Equal(t, "patron was not found", ncipErr.Problem.ProblemDetail)

userId, err = ad.LookupUser("pass", true)
result, err = ad.LookupUser("pass", validateOptions)
assert.NoError(t, err)
assert.Equal(t, "pass", userId)
assert.Equal(t, "pass", result.UserID)

_, err = ad.LookupUser("missing data", true)
_, err = ad.LookupUser("missing data", validateOptions)
assert.Error(t, err)
assert.Equal(t, "missing User ID in LookupUser response", err.Error())

userId, err = ad.LookupUser("good user", true)
result, err = ad.LookupUser("good user", validateOptions)
assert.NoError(t, err)
assert.Equal(t, "user124", userId)
assert.Equal(t, "user124", result.UserID)

userId, err = ad.LookupUser("other user", true)
result, err = ad.LookupUser("other user", validateOptions)
assert.NoError(t, err)
assert.Equal(t, "user123", userId)
assert.Equal(t, "user123", result.UserID)

b = false
userId, err = ad.LookupUser("", true)
result, err = ad.LookupUser("", validateOptions)
assert.NoError(t, err)
assert.Equal(t, "", userId)
assert.Equal(t, "", result.UserID)

mock.(*ncipClientMock).lastRequest = nil
userId, err = ad.LookupUser("anyuser", true)
result, err = ad.LookupUser("anyuser", validateOptions)
assert.NoError(t, err)
assert.Equal(t, "anyuser", userId)
assert.Equal(t, "anyuser", result.UserID)
assert.Nil(t, mock.(*ncipClientMock).lastRequest) // not called
}

Expand Down Expand Up @@ -165,29 +166,47 @@ func TestLookupUserElements(t *testing.T) {
config: dirapi.LmsConfig{PatronProfiles: test.profiles},
}

_, err := adapter.LookupUser("testuser", true)
_, err := adapter.LookupUser("testuser", LookupUserOptions{ValidatePatronProfile: true})
assert.NoError(t, err)
directRequest := mock.lastRequest.(ncip.LookupUser)
assert.Equal(t, test.directElements, directRequest.UserElementType)

_, err = adapter.LookupUser("other user", true)
_, err = adapter.LookupUser("other user", LookupUserOptions{ValidatePatronProfile: true})
assert.NoError(t, err)
fallbackRequest := mock.lastRequest.(ncip.LookupUser)
assert.Equal(t, test.fallbackElements, fallbackRequest.UserElementType)

_, err = adapter.LookupUser("testuser", false)
_, err = adapter.LookupUser("testuser", LookupUserOptions{})
assert.NoError(t, err)
directRequest = mock.lastRequest.(ncip.LookupUser)
assert.Empty(t, directRequest.UserElementType)

_, err = adapter.LookupUser("other user", false)
_, err = adapter.LookupUser("other user", LookupUserOptions{})
assert.NoError(t, err)
fallbackRequest = mock.lastRequest.(ncip.LookupUser)
assert.Equal(t, userIDElement, fallbackRequest.UserElementType)
})
}
}

func TestLookupUserPatronInfo(t *testing.T) {
mock := new(ncipClientMock)
adapter := &LmsAdapterNcip{ncipClient: mock}

result, err := adapter.LookupUser("patron-info", LookupUserOptions{IncludePatronInfo: true})

assert.NoError(t, err)
assert.Equal(t, "patron-info", result.UserID)
assert.Equal(t, "Jane", result.GivenName)
assert.Equal(t, "Doe", result.Surname)
assert.Equal(t, []string{"jane@example.org"}, result.EmailAddresses)
request := mock.lastRequest.(ncip.LookupUser)
assert.Equal(t, []ncip.SchemeValuePair{
{Text: NCIPNameInformation},
{Text: NCIPUserAddressInformation},
}, request.UserElementType)
}

func TestPatronProfile(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -758,6 +777,31 @@ func (n *ncipClientMock) SetLogFunc(logFunc ncipclient.NcipLogFunc) {
func (n *ncipClientMock) LookupUser(lookup ncip.LookupUser) (*ncip.LookupUserResponse, error) {
n.lastRequest = lookup
if lookup.UserId != nil {
if lookup.UserId.UserIdentifierValue == "patron-info" {
return &ncip.LookupUserResponse{
UserId: &ncip.UserId{UserIdentifierValue: "patron-info"},
UserOptionalFields: &ncip.UserOptionalFields{
NameInformation: &ncip.NameInformation{
PersonalNameInformation: &ncip.PersonalNameInformation{
StructuredPersonalUserName: &ncip.StructuredPersonalUserName{
GivenName: " Jane ",
Surname: " Doe ",
},
},
},
UserAddressInformation: []ncip.UserAddressInformation{
{ElectronicAddress: &ncip.ElectronicAddress{
ElectronicAddressType: ncip.SchemeValuePair{Text: "mailto"},
ElectronicAddressData: " jane@example.org ",
}},
{ElectronicAddress: &ncip.ElectronicAddress{
ElectronicAddressType: ncip.SchemeValuePair{Text: "TEL"},
ElectronicAddressData: "+1 555 0100",
}},
},
},
}, nil
}
if lookup.UserId.UserIdentifierValue == "staff-profile" {
return lookupUserResponseWithProfile(lookup.UserId.UserIdentifierValue, "PROFILE", "STAFF", "Staff"), nil
}
Expand Down
Loading
Loading