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

allow clients to filter content api call #15115

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 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
87 changes: 87 additions & 0 deletions test/api/unit/libs/content.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import fs from 'fs';
import * as contentLib from '../../../../website/server/libs/content';
import content from '../../../../website/common/script/content';
import {
generateRes,
} from '../../../helpers/api-unit.helper';

describe('contentLib', () => {
describe('CONTENT_CACHE_PATH', () => {
Expand All @@ -13,5 +17,88 @@ describe('contentLib', () => {
contentLib.getLocalizedContentResponse();
expect(typeof content.backgrounds.backgrounds062014.beach.text).to.equal('function');
});

it('removes keys from the content data', () => {
const response = contentLib.localizeContentData(content, 'en', { backgroundsFlat: true, dropHatchingPotions: true });
expect(response.backgroundsFlat).to.not.exist;
expect(response.backgrounds).to.exist;
expect(response.dropHatchingPotions).to.not.exist;
expect(response.hatchingPotions).to.exist;
});

it('removes nested keys from the content data', () => {
const response = contentLib.localizeContentData(content, 'en', { gear: { tree: true } });
expect(response.gear.tree).to.not.exist;
expect(response.gear.flat).to.exist;
});
});

it('generates a hash for a filter', () => {
const hash = contentLib.hashForFilter('backgroundsFlat,gear.flat');
expect(hash).to.equal('-1791877526');
});

it('serves content', () => {
const resSpy = generateRes();
contentLib.serveContent(resSpy, 'en', '', false);
expect(resSpy.send).to.have.been.calledOnce;
});

it('serves filtered content', () => {
const resSpy = generateRes();
contentLib.serveContent(resSpy, 'en', 'backgroundsFlat,gear.flat', false);
expect(resSpy.send).to.have.been.calledOnce;
});

describe('caches content', async () => {
let resSpy;
beforeEach(() => {
resSpy = generateRes();
fs.rmdirSync(contentLib.CONTENT_CACHE_PATH, { recursive: true });
fs.mkdirSync(contentLib.CONTENT_CACHE_PATH);
});

it('does not cache requests in development mode', async () => {
contentLib.serveContent(resSpy, 'en', '', false);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.false;
});

it('caches unfiltered requests', async () => {
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.false;
contentLib.serveContent(resSpy, 'en', '', true);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.true;
});

it('serves cached requests', async () => {
fs.writeFileSync(
`${contentLib.CONTENT_CACHE_PATH}en.json`,
'{"success": true, "data": {"all": {}}}',
'utf8',
);
contentLib.serveContent(resSpy, 'en', '', true);
expect(resSpy.sendFile).to.have.been.calledOnce;
expect(resSpy.sendFile).to.have.been.calledWith(`${contentLib.CONTENT_CACHE_PATH}en.json`);
});

it('caches filtered requests', async () => {
const filter = 'backgroundsFlat,gear.flat';
const hash = contentLib.hashForFilter(filter);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`)).to.be.false;
contentLib.serveContent(resSpy, 'en', filter, true);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`)).to.be.true;
});

it('serves filtered cached requests', async () => {
const filter = 'backgroundsFlat,gear.flat';
const hash = contentLib.hashForFilter(filter);
fs.writeFileSync(
`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`,
'{"success": true, "data": {}}',
'utf8',
);
contentLib.serveContent(resSpy, 'en', filter, true);
expect(resSpy.sendFile).to.have.been.calledOnce;
expect(resSpy.sendFile).to.have.been.calledWith(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`);
});
});
});
34 changes: 34 additions & 0 deletions test/api/v3/integration/content/GET-content.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,38 @@ describe('GET /content', () => {
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText'));
});

it('does not filter content for regular requests', async () => {
const res = await requester().get('/content');
expect(res).to.have.nested.property('backgrounds.backgrounds062014');
expect(res).to.have.nested.property('gear.tree');
});

it('filters content automatically for iOS requests', async () => {
const res = await requester(null, { 'x-client': 'habitica-ios' }).get('/content');
expect(res).to.have.nested.property('appearances.background.beach');
expect(res).to.not.have.nested.property('backgrounds.backgrounds062014');
expect(res).to.not.have.property('backgroundsFlat');
expect(res).to.not.have.nested.property('gear.tree');
});

it('filters content automatically for Android requests', async () => {
const res = await requester(null, { 'x-client': 'habitica-android' }).get('/content');
expect(res).to.not.have.nested.property('appearances.background.beach');
expect(res).to.have.nested.property('backgrounds.backgrounds062014');
expect(res).to.not.have.property('backgroundsFlat');
expect(res).to.not.have.nested.property('gear.tree');
});

it('filters content if the request specifies a filter', async () => {
const res = await requester().get('/content?filter=backgroundsFlat,gear.flat');
expect(res).to.not.have.property('backgroundsFlat');
expect(res).to.have.nested.property('gear.tree');
expect(res).to.not.have.nested.property('gear.flat');
});

it('filters content if the request contains invalid filters', async () => {
const res = await requester().get('/content?filter=backgroundsFlat,invalid');
expect(res).to.not.have.property('backgroundsFlat');
});
});
1 change: 1 addition & 0 deletions test/helpers/api-unit.helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function generateRes (options = {}) {
redirect: sandbox.stub(),
render: sandbox.stub(),
send: sandbox.stub(),
sendFile: sandbox.stub(),
sendStatus: sandbox.stub().returnsThis(),
set: sandbox.stub(),
status: sandbox.stub().returnsThis(),
Expand Down
26 changes: 16 additions & 10 deletions website/server/controllers/api-v3/content.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import nconf from 'nconf';
import { langCodes } from '../../libs/i18n';
import { CONTENT_CACHE_PATH, getLocalizedContentResponse } from '../../libs/content';
import { serveContent } from '../../libs/content';

const IS_PROD = nconf.get('IS_PROD');

const api = {};

const MOBILE_FILTER = `achievements,questSeriesAchievements,animalColorAchievements,animalSetAchievements,stableAchievements,
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be easier to maintain if this would use an array as source which then at the end used .join(',') to get the filter complete

mystery,bundles,loginIncentives,pets,premiumPets,specialPets,questPets,wackyPets,mounts,premiumMounts,specialMounts,questMounts,
events,dropEggs,questEggs,dropHatchingPotions,premiumHatchingPotions,wackyHatchingPotions,backgroundsFlat,questsByLevel,gear.tree,
tasksByCategory,userDefaults,timeTravelStable,gearTypes,cardTypes`;

/**
* @api {get} /api/v3/content Get all available content objects
* @apiDescription Does not require authentication.
Expand Down Expand Up @@ -65,16 +70,17 @@ api.getContent = {
language = proposedLang;
}

if (IS_PROD) {
res.sendFile(`${CONTENT_CACHE_PATH}${language}.json`);
} else {
res.set({
'Content-Type': 'application/json',
});

const jsonResString = getLocalizedContentResponse(language);
res.status(200).send(jsonResString);
let filter = req.query.filter || '';
// apply defaults for mobile clients
if (filter === '') {
if (req.headers['x-client'] === 'habitica-android') {
filter = `${MOBILE_FILTER},appearances.background`;
} else if (req.headers['x-client'] === 'habitica-ios') {
filter = `${MOBILE_FILTER},backgrounds`;
}
}

serveContent(res, language, filter, IS_PROD);
},
};

Expand Down
81 changes: 75 additions & 6 deletions website/server/libs/content.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,96 @@
import _ from 'lodash';
import path from 'path';
import fs from 'fs';
import common from '../../common';
import packageInfo from '../../../package.json';

export const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../content_cache/');

function walkContent (obj, lang) {
const CACHED_HASHES = [

];

function walkContent (obj, lang, removedKeys = {}) {
_.each(obj, (item, key, source) => {
if (key in removedKeys && removedKeys[key] === true) {
delete source[key];
return;
}
if (_.isPlainObject(item) || _.isArray(item)) {
walkContent(item, lang);
if (key in removedKeys && _.isPlainObject(removedKeys[key])) {
walkContent(item, lang, removedKeys[key]);
} else {
walkContent(item, lang);
}
} else if (_.isFunction(item) && item.i18nLangFunc) {
source[key] = item(lang);
}
});
}

export function localizeContentData (data, langCode) {
export function localizeContentData (data, langCode, removedKeys = {}) {
const dataClone = _.cloneDeep(data);
walkContent(dataClone, langCode);
walkContent(dataClone, langCode, removedKeys);
return dataClone;
}

export function getLocalizedContentResponse (langCode) {
const localizedContent = localizeContentData(common.content, langCode);
export function getLocalizedContentResponse (langCode, removedKeys = {}) {
const localizedContent = localizeContentData(common.content, langCode, removedKeys);
return `{"success": true, "data": ${JSON.stringify(localizedContent)}, "appVersion": "${packageInfo.version}"}`;
}

export function hashForFilter (filter) {
let hash = 0;
let i; let
chr;
if (filter.length === 0) return '';
for (i = 0; i < filter.length; i++) { // eslint-disable-line
chr = filter.charCodeAt(i);
hash = ((hash << 5) - hash) + chr; // eslint-disable-line
hash |= 0; // eslint-disable-line
}
return String(hash);
}

export function serveContent (res, language, filter, isProd) {
// Build usable filter object
const filterObj = {};
filter.split(',').forEach(item => {
if (item.includes('.')) {
const [key, subkey] = item.split('.');
if (!filterObj[key]) {
filterObj[key] = {};
}
filterObj[key][subkey.trim()] = true;
} else {
filterObj[item.trim()] = true;
}
});

if (isProd) {
const filterHash = language + hashForFilter(filter);
if (CACHED_HASHES.includes(filterHash)) {
// Content is already cached, so just send it.
res.sendFile(`${CONTENT_CACHE_PATH}${filterHash}.json`);
} else {
// Content is not cached, so cache it and send it.
res.set({
'Content-Type': 'application/json',
});
const jsonResString = getLocalizedContentResponse(language, filterObj);
fs.writeFileSync(
`${CONTENT_CACHE_PATH}${filterHash}.json`,
jsonResString,
'utf8',
);
CACHED_HASHES.push(filterHash);
res.status(200).send(jsonResString);
}
} else {
res.set({
'Content-Type': 'application/json',
});
const jsonResString = getLocalizedContentResponse(language, filterObj);
res.status(200).send(jsonResString);
}
}
Loading