diff --git a/test/api/v3/integration/content/GET-content.test.js b/test/api/v3/integration/content/GET-content.test.js new file mode 100644 index 0000000000..9324fce398 --- /dev/null +++ b/test/api/v3/integration/content/GET-content.test.js @@ -0,0 +1,25 @@ +import { + requester, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import i18n from '../../../../../common/script/i18n'; + +describe('GET /content', () => { + it('returns content (and does not require authentication)', async () => { + let res = await requester().get('/content'); + expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + + it('returns content not in English', async () => { + let res = await requester().get('/content?language=de'); + expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); + }); + + it('falls back to English if the desired language is not found', async () => { + let res = await requester().get('/content?language=wrong'); + expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); +}); diff --git a/website/src/controllers/api-v3/content.js b/website/src/controllers/api-v3/content.js new file mode 100644 index 0000000000..6f249bd27d --- /dev/null +++ b/website/src/controllers/api-v3/content.js @@ -0,0 +1,106 @@ +import common from '../../../../common'; +import _ from 'lodash'; +import { langCodes } from '../../libs/api-v3/i18n'; +import Q from 'q'; +import fsCallback from 'fs'; +import path from 'path'; + +// Transform fs methods that accept callbacks in ones that return promises +const fs = { + readFile: Q.denodeify(fsCallback.readFile), + writeFile: Q.denodeify(fsCallback.writeFile), + stat: Q.denodeify(fsCallback.stat), + mkdir: Q.denodeify(fsCallback.mkdir), +}; + +let api = {}; + +function walkContent (obj, lang) { + _.each(obj, (item, key, source) => { + if (_.isPlainObject(item) || _.isArray(item)) return walkContent(item, lang); + if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); + }); +} + +// After the getContent route is called the first time for a certain language +// the response is saved on disk and subsequentially served directly from there to reduce computation. +// Example: if `cachedContentResponses.en` is true it means that the response is cached +let cachedContentResponses = {}; + +// Language key set to true while the cache file is being written +let cacheBeingWritten = {}; + +_.each(langCodes, code => { + cachedContentResponses[code] = false; + cacheBeingWritten[code] = false; +}); + + +const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../build/content_cache/'); + +async function saveContentToDisk (language, content) { + try { + cacheBeingWritten[language] = true; + + await fs.stat(CONTENT_CACHE_PATH); // check if the directory exists, if it doesn't an error is thrown + await fs.writeFile(`${CONTENT_CACHE_PATH}${language}.json`, content, 'utf8'); + + cacheBeingWritten[language] = false; + cachedContentResponses[language] = true; + } catch (err) { + if (err.code === 'ENOENT' && err.syscall === 'stat') { // the directory doesn't exists, create it and retry + await fs.mkdir(CONTENT_CACHE_PATH); + return saveContentToDisk(language, content); + } else { + cacheBeingWritten[language] = false; + // TODO log error + return; + } + } +} + +/** + * @api {get} /content Get all available content objects. Does not require authentication. + * @apiVersion 3.0.0 + * @apiName ContentGet + * @apiGroup Content + * + * @apiParam {string} language Optional query parameter, the language code used for the items' strings. Defaulting to english + * + * @apiSuccess {Object} content All the content available on Habitica + */ +api.getContent = { + method: 'GET', + url: '/content', + async handler (req, res) { + let language = 'en'; + let proposedLang = req.query.language && req.query.language.toString(); + + if (proposedLang in cachedContentResponses) { + language = proposedLang; + } + + let content; + + // is the content response for this language cached? + if (cachedContentResponses[language] === true) { + content = await fs.readFile(`${CONTENT_CACHE_PATH}${language}.json`, 'utf8'); + } else { // generate the response + content = _.cloneDeep(common.content); + walkContent(content, language); + content = JSON.stringify(content); + } + + res.set({ + 'Content-Type': 'application/json', + }); + res.status(200).send(content); + + // save the file in background unless it's already cached or being written right now + if (cachedContentResponses[language] !== true && cacheBeingWritten[language] !== true) { + saveContentToDisk(language, content); + } + }, +}; + +module.exports = api;