Skip to content

Commit

Permalink
Merge pull request #1 from cagov/hypermedia-api
Browse files Browse the repository at this point in the history
HTML-based API response
  • Loading branch information
xjensen authored Oct 30, 2023
2 parents 345f48c + 84ccee5 commit 679b399
Show file tree
Hide file tree
Showing 10 changed files with 172 additions and 123 deletions.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@
},
"workspaces": [
"widget"
]
],
"volta": {
"node": "18.17.0"
}
}
40 changes: 37 additions & 3 deletions src/http/get-benefits/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ const arc = require("@architect/functions");
const { assembleLinks } = require("@architect/shared/links");
const { applyRules } = require("@architect/shared/rules");
const { getDefinitions } = require("@architect/shared/s3");
const { generateHtml } = require("@architect/shared/templates");
const { matchHostDef } = require("@architect/shared/hosts");
const { getThrottles } = require("@architect/shared/throttles");

// Definitions will be loaded from benefits-recs-defs.json in S3.
// We keep it outside the handler to cache it between Lambda runs.
Expand All @@ -12,15 +15,37 @@ let definitions = {};
exports.handler = arc.http.async(async (req) => {
// If definitions is empty, fetch it from S3.
if (Object.keys(definitions).length === 0) {
console.log("Definitions cache is cold. Fetching definitions from S3.");
definitions = await getDefinitions();
}

// Grab data from URL query parameters.
const {
targets: targetDefs = [],
hosts: hostDefs = [],
throttles: throttleDefs = [],
} = definitions;

// Grab data from headers and URL query parameters.
const host = decodeURIComponent(req.query.host || "");
const language = req.query.language || "en";
const acceptHeader = req.headers?.accept;

// Process metadata.
const hostDef = matchHostDef(host, hostDefs);
const throttles = await getThrottles(throttleDefs);

// Create target links.
const allLinks = assembleLinks(targetDefs, language, hostDef);
const links = await applyRules(throttles, allLinks, hostDef);

// If we don't have any links, exit now with no content.
if (links.length === 0) {
return {
cors: true,
statusCode: 204,
};
}

const allLinks = assembleLinks(definitions, language, host);
const links = await applyRules(definitions, allLinks, host);
const data = {
header: "Apply for more benefits!",
tagline: "You might be able to get:",
Expand All @@ -29,6 +54,15 @@ exports.handler = arc.http.async(async (req) => {
links,
};

// If the client wants HTML, send it.
if (acceptHeader === "text/html") {
return {
cors: true,
html: generateHtml(data, hostDef),
};
}

// Otherwise, default to JSON.
return {
cors: true,
json: JSON.stringify(data),
Expand Down
26 changes: 26 additions & 0 deletions src/shared/hosts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const url = require("./url");

/**
* Find the host definition for a given host url.
* We want to match up the origin of the request (host) with our own metadata (hostDefs).
* @param {string} host
* The host page, as a URL (string), from which the widget sent this request.
* @param {import('./s3.js').Host[]} hostDefs
* A list of host objects from the Airtable-derived definitions.
* @returns {import('./s3.js').Host|undefined}
* A matching host definition, or undefined if none are found.
*/
exports.matchHostDef = (host, hostDefs) => {
const pHostUrl = url.parse(host);

if (pHostUrl && hostDefs) {
return hostDefs.find((hostDef) =>
hostDef.urls.some((hostDefUrl) => {
const pHostDefUrl = url.parse(hostDefUrl);
return pHostDefUrl.hostname === pHostUrl.hostname;
})
);
} else {
return undefined;
}
};
38 changes: 5 additions & 33 deletions src/shared/links.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,6 @@ const AnalyticEngines = {
PIWIK: "piwik",
};

/**
* Find the host definition for a given host url.
* We want to match up the origin of the request (host) with our own metadata (hostDefs).
* @param {string} host
* The host page, as a URL (string), from which the widget sent this request.
* @param {import('./s3.js').Host[]} hostDefs
* A list of host objects from the Airtable-derived definitions.
* @returns {import('./s3.js').Host|undefined}
* A matching host definition, or undefined if none are found.
*/
const findHostDef = (host, hostDefs) => {
const pHostUrl = url.parse(host);

if (pHostUrl) {
return hostDefs.find((hostDef) =>
hostDef.urls.some((hostDefUrl) => {
const pHostDefUrl = url.parse(hostDefUrl);
return pHostDefUrl.hostname === pHostUrl.hostname;
})
);
} else {
return undefined;
}
};

/**
* Add query string parameters for the target site's analytics engine.
* @param {string} linkUrl
Expand Down Expand Up @@ -76,20 +51,17 @@ const addAnalytics = (linkUrl, analytics, hostDef) => {
*/

/**
* Construct link objects for the given language and host.
* @param {import('./s3.js').Definitions} definitions
* A parsed object representing the Airtable-derived `benefits-recs-defs.json` file.
* From the list of target definitions, construct link objects for the given language and host.
* @param {import('./s3.js').Target[]} targets
* Target definitions from the Airtable-derived `benefits-recs-defs.json` file.
* @param {string} language
* The language for this request, as an ISO 639-1 code.
* @param {string} host
* @param {import('./s3.js').Host} hostDef
* The host page, as a URL (string), from which the widget sent this request.
* @returns {TargetLink[]}
* A processed list of target links in the requested language.
*/
exports.assembleLinks = (definitions, language, host) => {
const { targets, hosts: hostDefs } = definitions;
const hostDef = findHostDef(host, hostDefs);

exports.assembleLinks = (targets, language, hostDef) => {
// Unless it's Chinese, strip the language code down to two characters.
// We need to preserve the Chinese code to display Traditional vs. Simplified.
const langKey = language.startsWith("zh") ? language : language.slice(0, 2);
Expand Down
23 changes: 10 additions & 13 deletions src/shared/rules.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const url = require("./url");
const { getThrottles } = require("./throttles");

/**
* rules.js
Expand All @@ -17,8 +16,8 @@ const { getThrottles } = require("./throttles");
* A processed list of all target links in the user's requested language.
* @param {object} params
* A collection of parameters from the request to help inform the rule.
* @param {string} [params.host]
* The host page, as a URL (string), from which the widget sent this request.
* @param {import('./s3.js').Host} [params.hostDef]
* The host partner from which the widget sent this request.
* @param {import('./s3.js').Throttle[]} [params.throttles]
* A processed list of target link throttles.
* @returns {import('./links.js').TargetLink[]}
Expand All @@ -42,8 +41,8 @@ const removeThrottledLinks = (links, { throttles }) =>
* Remove links that point back to the same host site as the widget.
* @type {Rule}
*/
const removeLinkBacks = (links, { host }) =>
links.filter((link) => !url.matchHosts(host, link.url));
const removeLinkBacks = (links, { hostDef }) =>
links.filter((link) => !url.findHostMatch(hostDef?.urls || [], link.url));

/**
* Randomize the order of the links.
Expand Down Expand Up @@ -74,20 +73,18 @@ const rules = [

/**
* Apply the rules to the given list of links.
* @param {import('./s3.js').Definitions} definitions
* A parsed object representing the Airtable-derived `benefits-recs-defs.json` file.
* @param {import('./s3.js').Throttle[]} throttles
* Active throttles against target links.
* @param {import('./links.js').TargetLink[]} allLinks
* A processed list of all target links in the user's requested language.
* @param {string} host
* The host page, as a URL (string), from which the widget sent this request.
* @param {import('./s3.js').Host} hostDef
* The identified host partner from which the widget sent this request.
* @returns {Promise<import('./links.js').TargetLink[]>}
* A targetted list of target links.
*/
const applyRules = async (definitions, allLinks, host) => {
const throttles = await getThrottles(definitions);

const applyRules = async (throttles, allLinks, hostDef) => {
const params = {
host,
hostDef,
throttles,
};

Expand Down
58 changes: 46 additions & 12 deletions widget/src/templates.js → src/shared/templates.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const css = /* css */ `
const defaultCss = /* css */ `
:host {
--benefits-recs-background-color: #E1F1EE;
--benefits-recs-highlight-color: #006C58;
Expand Down Expand Up @@ -119,16 +119,7 @@ ul.link-list li a:hover {
}
`;

export const rootHtml = /* html */ `
<section aria-label="benefits recommendations">
<h2 part="header2">Claim more benefits</h2>
<p class="tagline">You could qualify to get:</p>
<ul class="link-list">
</ul>
</section>
`;

export const openIcon = /* html */ `
const openIcon = /* html */ `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1425_1913)">
<path d="M13.4696 5.88672V7.35634H16.1149L8.9138 14.5575L9.94253 15.5862L17.1437 8.38507V11.0304H18.6133V5.88672H13.4696ZM17.1437 17.6437H6.85634V7.35634H12V5.88672H6.85634C6.04805 5.88672 5.38672 6.54805 5.38672 7.35634V17.6437C5.38672 18.452 6.04805 19.1133 6.85634 19.1133H17.1437C17.952 19.1133 18.6133 18.452 18.6133 17.6437V12.5H17.1437V17.6437Z" fill="black"/>
Expand All @@ -141,7 +132,7 @@ export const openIcon = /* html */ `
</svg>
`;

export const linkHtml = (link) => /* html */ `
const defaultLinkHtml = (link) => /* html */ `
<li>
<a href="${link.url}" target="_blank" rel="noopener noreferrer">
<span class="link-start">
Expand All @@ -155,3 +146,46 @@ export const linkHtml = (link) => /* html */ `
</a>
</li>
`;

const defaultHtml = (data) => {
const linkList = data.links.map((link) => defaultLinkHtml(link)).join("\n");

return /* html */ `
<section
aria-label="benefits recommendations"
data-experimentName="${data.experimentName}"
data-experimentVariation="${data.experimentVariation}"
>
<h2>${data.header}</h2>
<p class="tagline">${data.tagline}</p>
<ul class="link-list">
${linkList}
</ul>
</section>
`;
};

const defaultTemplate = (data) => /* html */ `
<style>
${defaultCss}
</style>
${defaultHtml(data)}
`;

const eddUiTemplate = (data) => /* html */ `
<style>
${defaultCss}
section {
margin: 3rem 0;
}
</style>
${defaultHtml(data)}
`;

exports.generateHtml = (data, hostDef) => {
if (hostDef?.id === "edd_ui_recert") {
return eddUiTemplate(data);
}

return defaultTemplate(data);
};
10 changes: 4 additions & 6 deletions src/shared/throttles.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ const arc = require("@architect/functions");
* We check DynamoDB to see if each throttle is still within daily clickthrough limits.
* We also look at other parameters, like start and end dates.
* The goal is to see which throttles need to be activated for this request.
* @param {import('./s3.js').Definitions} definitions
* A parsed object representing the Airtable-derived `benefits-recs-defs.json` file.
* @param {import('./s3.js').Throttle[]} throttleDefs
* Throttle definitions from the Airtable-derived `benefits-recs-defs.json` file.
* @returns {Promise<import('./s3.js').Throttle[]>}
* A hydrated list of target link throttles.
*/
exports.getThrottles = async (definitions) => {
const { throttles: throttleDefs } = definitions;

exports.getThrottles = async (throttleDefs) => {
try {
const throttles = [...throttleDefs];

Expand Down Expand Up @@ -47,7 +45,7 @@ exports.getThrottles = async (definitions) => {
}

// If the throttle is still open, we need to check DynamoDB.
if ((throttle.exceeded = false)) {
if (limit && throttle.exceeded === false) {
const promise = dynamo.throttleclicks
.get({ name, day })
.then((response) => {
Expand Down
18 changes: 17 additions & 1 deletion src/shared/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const matchPaths = (url1, url2) => {

/**
* Checks if a URL is included in a list of other URLs.
* @param {Array} urls An array of URL strings.
* @param {string[]} urls An array of URL strings.
* @param {string} queryUrl A string representation of the URL to check.
* @returns {boolean}
*/
Expand All @@ -122,8 +122,24 @@ const findMatch = (urls, queryUrl) => {
return pQueryUrl ? pUrls.some((pUrl) => compare(pUrl, pQueryUrl)) : false;
};

/**
* Checks if a URL's host is included in a list of other URLs.
* @param {string[]} urls An array of URL strings.
* @param {string} queryUrl A string representation of the URL to check.
* @returns {boolean}
*/
const findHostMatch = (urls, queryUrl) => {
const pQueryUrl = parse(queryUrl);
const pUrls = urls.map((u) => parse(u)).filter((pU) => pU !== undefined);

return pQueryUrl
? pUrls.some((pUrl) => compareHosts(pUrl, pQueryUrl))
: false;
};

exports.parse = parse;
exports.match = match;
exports.matchHosts = matchHosts;
exports.matchPaths = matchPaths;
exports.findMatch = findMatch;
exports.findHostMatch = findHostMatch;
2 changes: 1 addition & 1 deletion widget/preview/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const fs = require("fs/promises");

const generate = (props) => {
const pr = props.prNumber
? `(<a href="https://github.com/cagov/benefits-recommendation-widget-front/pull/${props.prNumber}">Pull Request #${props.prNumber}</a>)`
? `(<a href="https://github.com/cagov/benefits-recommender/pull/${props.prNumber}">Pull Request #${props.prNumber}</a>)`
: "";

return /* html */ `
Expand Down
Loading

0 comments on commit 679b399

Please sign in to comment.