Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AgencyHoldings endpoint #187

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
249 changes: 249 additions & 0 deletions src/datasources/agencyHoldings.datasource.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import config from "../config";
import isEmpty from "lodash/isEmpty";
import { getFirstMatch } from "../utils/utils";

const {
url: holdingsServiceUrl,
prefix: holdingsServicePrefix,
ttl: holdingsServiceTtl,
} = config.datasources.holdingsservice;

/**
* @typedef {("AVAILABLE_NOW"|"AVAILABLE_LATER"|"AVAILABLE_UNKNOWN"|"ERRORS")} AgencyHoldingsFilterType
* @type {Readonly<{AVAILABLE_NOW: string, AVAILABLE_LATER: string, AVAILABLE_UNKNOWN: string, ERRORS: string}>}
*/
// Equivalent use in schema
export const AgencyHoldingsFilterEnum = Object.freeze({
AVAILABLE_NOW: "AVAILABLE_NOW",
AVAILABLE_LATER: "AVAILABLE_LATER",
AVAILABLE_UNKNOWN: "AVAILABLE_UNKNOWN",
ERRORS: "ERRORS",
});

function checkSingleAvailability(expectedDelivery) {
return getFirstMatch(true, AgencyHoldingsFilterEnum.AVAILABLE_UNKNOWN, [
[
new Date(expectedDelivery).toDateString() === new Date().toDateString(),
AgencyHoldingsFilterEnum.AVAILABLE_NOW,
],
[
expectedDelivery &&
!isEmpty(expectedDelivery) &&
new Date(expectedDelivery).toDateString() !== new Date().toDateString(),
AgencyHoldingsFilterEnum.AVAILABLE_LATER,
],
]);
}

function checkAvailability(singleRes) {
return singleRes.holdingsItem
.map((item) => checkSingleAvailability(item.expectedDelivery))
?.sort((a, b) => {
function getMatcherArray(matcherValue) {
return [
[matcherValue === AgencyHoldingsFilterEnum.AVAILABLE_NOW, 0],
[matcherValue === AgencyHoldingsFilterEnum.AVAILABLE_LATER, 1],
[matcherValue === AgencyHoldingsFilterEnum.AVAILABLE_UNKNOWN, 2],
];
}
const aValue = getFirstMatch(
true,
getMatcherArray(a).length,
getMatcherArray(a)
);
const bValue = getFirstMatch(
true,
getMatcherArray(b).length,
getMatcherArray(b)
);

return aValue - bValue;
})?.[0];
}

function parseDetailedHoldingsResponse(responderDetailed) {
return responderDetailed?.map((res) => {
return {
agencyId: res.responderId,
availability: checkAvailability(res),
};
});
}

async function fetchDetailedHoldings(context, lookupRecord) {
if (isEmpty(lookupRecord)) {
return {
body: { responderDetailed: [], error: [], trackingId: "betabib" },
};
}

return await context.fetch(`${holdingsServiceUrl}detailed-holdings`, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
lookupRecord: lookupRecord,
trackingId: "betabib",
}),
});
}

async function progressiveLoad({
context,
resultCount = 10,
stepSizeBeforeSetup = resultCount + Math.ceil(resultCount * 0.25),
lookupRecord: lookupRecordBeforeSetup,
filter,
}) {
const lookupRecordBeforeSetupLength = lookupRecordBeforeSetup.length;
const agencyIdsWithFilteredHoldings = new Set();

// PERFORMANCE - We need to adjust these during running for performance reasons, therefore `let` and not `const`
let currentOffset = 0;
let numberOfCallsToService = 0;
let lookupRecord = lookupRecordBeforeSetup;
let stepSize = stepSizeBeforeSetup;
// ---

do {
// PERFORMANCE - `lookupRecord` is adjusted by removing records with agencyIds
// found in `responseBuilder.agencyIds` and `responseBuilder.results`
lookupRecord = lookupRecord.filter(
(singleLookupRecord) =>
!agencyIdsWithFilteredHoldings.has(singleLookupRecord.responderId)
);

const currentLookupRecord = lookupRecord.slice(0, stepSize);
lookupRecord = lookupRecord.slice(stepSize, lookupRecord.length);
// ---

const tempRes = await fetchDetailedHoldings(context, currentLookupRecord);

const holdings = parseDetailedHoldingsResponse(
tempRes.body.responderDetailed
);

[
AgencyHoldingsFilterEnum.AVAILABLE_NOW,
AgencyHoldingsFilterEnum.AVAILABLE_LATER,
AgencyHoldingsFilterEnum.AVAILABLE_UNKNOWN,
].map((singleFilter) => {
holdings.map((holding) => {
if (
filter.includes(singleFilter) &&
singleFilter !== AgencyHoldingsFilterEnum.ERRORS &&
holding.availability === singleFilter
) {
agencyIdsWithFilteredHoldings.add(holding.agencyId);
}
});
});

currentOffset += stepSize;
numberOfCallsToService += 1;

// PERFORMANCE - `stepSize` is adjusted as results are found to reduce the size of the calls to service
stepSize = Math.ceil(
Math.max(
10,
Math.min(
stepSize,
(resultCount - agencyIdsWithFilteredHoldings.size) * 10
)
)
);
// ---
} while (
!(
agencyIdsWithFilteredHoldings.size >= resultCount ||
currentOffset >= lookupRecordBeforeSetupLength
)
);

const agencyIdsAsArray = Array.from(agencyIdsWithFilteredHoldings);

return {
countUniqueAgencies: agencyIdsAsArray.length,
agencyIdsWithResults: agencyIdsAsArray,
numberOfCallsToService: numberOfCallsToService,
};
}

function queryForDetailedHoldings(agencyIds, pids) {
// PERFORMANCE - We create lookupRecord such that we look at 1 pid for every agencyId first (because most popular pids,
// are first in the list of pids. We do this rather than looking at every pid for 1 agencyId,
// because this means worst case more often
if (!agencyIds || isEmpty(agencyIds) || !pids || isEmpty(pids)) {
return [];
} else {
// Fast version: pid > agencyId
return pids.reduce(
(accumulator, pid) => [
...accumulator,
...agencyIds.map((agencyId) => {
return {
pid: pid,
responderId: agencyId,
};
}),
],
[]
);
}
}

/**
*
* @param {string[]} agencyIds
* @param {string[]} pids
* @param {number} resultCountBeforeCheck
* @param {AgencyHoldingsFilterType[]} filterBeforeCheck
* @param context
* @returns {Promise<{agencyHoldings: *, countUniqueAgencies: *, countUniqueResponses: *, numberOfCallsToService: number}>}
*/
export async function load(
{
agencyIds,
pids,
resultCount: resultCountBeforeCheck,
filter: filterBeforeCheck,
},
context
) {
const resultCount = Math.min(agencyIds.length, resultCountBeforeCheck ?? 10);

const filter =
!filterBeforeCheck || filterBeforeCheck.length === 0
? [AgencyHoldingsFilterEnum.AVAILABLE_NOW]
: filterBeforeCheck;

const lookupRecord = queryForDetailedHoldings(agencyIds, pids);

// PERFORMANCE - We do a progressive load to reduce pressure on server and decrease call time
const {
agencyIdsWithResults,
countUniqueAgencies,
numberOfCallsToService,
} = await progressiveLoad({
context: context,
lookupRecord: lookupRecord,
resultCount: resultCount,
filter: filter,
});
// ---

return {
countUniqueAgencies: countUniqueAgencies,
agencyIds: agencyIdsWithResults,
numberOfCallsToService: numberOfCallsToService,
};
}

export const options = {
redis: {
prefix: holdingsServicePrefix,
ttl: holdingsServiceTtl,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Husk at rette denne i config'en

},
};
34 changes: 34 additions & 0 deletions src/schema/agencyHoldings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* *** EXPERIMENTAL - EXPECT TO BE CHANGED ***
* AgencyHoldings
*/
import { AgencyHoldingsFilterEnum } from "../datasources/agencyHoldings.datasource";

export const typeDef = `
enum AgencyHoldingsFilter {
"""The responder have at least 1 material available now"""
${AgencyHoldingsFilterEnum.AVAILABLE_NOW},
"""The responder have no materials available now, but at least 1 available later"""
${AgencyHoldingsFilterEnum.AVAILABLE_LATER},
"""The materials that no materials available now or later, and availability for all materials is unknown"""
${AgencyHoldingsFilterEnum.AVAILABLE_UNKNOWN},
"""The responders that returned an error"""
${AgencyHoldingsFilterEnum.ERRORS},
}

type AgencyHoldingsResponse {
"""
Count of unique agencies
"""
countUniqueAgencies: Int!

"""
AgencyIds
"""
agencyIds: [String!]!

"""
Number of calls to service (Here HoldingsService)
"""
numberOfCallsToService: Int!
}`;
24 changes: 24 additions & 0 deletions src/schema/root.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "../utils/utils";
import translations from "../utils/translations.json";
import { resolveAccess } from "./draft/draft_utils_manifestations";
import { AgencyHoldingsFilterEnum } from "../datasources/agencyHoldings.datasource";

/**
* The root type definitions
Expand Down Expand Up @@ -88,6 +89,21 @@ type Query {
session: Session
howru:String
localizations(pids:[String!]!): Localizations @complexity(value: 35, multipliers: ["pids"])
agencyHoldings(
agencyIds: [String!]!,
pids: [String!]!,
"""
Total number of unique agencies expected (including errors if requested)
Requests are pooled, so resultCount is an estimate. If available, the number of unique agencies is at least the given resultCount
But sometimes the number of results are less that given resultCount, and then number of results is
"""
resultCount: Int,
"""
Filter of requested responses.
Default filter is ${AgencyHoldingsFilterEnum.AVAILABLE_NOW}
"""
filter: [AgencyHoldingsFilter!]
): AgencyHoldingsResponse
refWorks(pid:String!):String!
ris(pid:String!):String!
relatedSubjects(q:[String!]!, limit:Int ): [String!] @complexity(value: 3, multipliers: ["q", "limit"])
Expand Down Expand Up @@ -213,6 +229,14 @@ export const resolvers = {

return localizations;
},
async agencyHoldings(parent, args, context, info) {
return await context.datasources.getLoader("agencyHoldings").load({
agencyIds: args.agencyIds,
pids: args.pids,
resultCount: args.resultCount || 10,
filter: args.filter,
});
},
howru(parent, args, context, info) {
return "gr8";
},
Expand Down
15 changes: 15 additions & 0 deletions src/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,18 @@ export const fetchOrderStatus = async (args, context) => {
);
return orders;
};

/**
* Get the first match of a series of conditions
* @template T
* @template V
* @param {T} matcherValue
* @param {V} defaultReturn
* @param {[T, V][]} matcherArray
* @returns {V}
*/
export function getFirstMatch(matcherValue, defaultReturn, matcherArray) {
return (
matcherArray?.find((el) => el[0] === matcherValue)?.[1] || defaultReturn
);
}