From bdb8ce4e130916a76970d8df1bf1a3291d0f5227 Mon Sep 17 00:00:00 2001 From: Andrea Vanelli Date: Wed, 22 Mar 2023 15:23:06 +0100 Subject: [PATCH] feat: add sentiment service --- .env.sample | 4 +- README.md | 17 ++++++- lib/config/config.js | 7 ++- lib/plugins/apilayer/index.js | 52 ++++++++++++++++++++++ lib/routes/sentiment/index.js | 22 ++++++++++ lib/routes/sentiment/schema.js | 11 +++++ lib/server.js | 5 +++ lib/services/sentiment.js | 61 ++++++++++++++++++++++++++ test/plugins/apilayer/apilayer.test.js | 20 +++++++++ test/services/sentiment.test.js | 54 +++++++++++++++++++++++ 10 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 lib/plugins/apilayer/index.js create mode 100644 lib/routes/sentiment/index.js create mode 100644 lib/routes/sentiment/schema.js create mode 100644 lib/services/sentiment.js create mode 100644 test/plugins/apilayer/apilayer.test.js create mode 100644 test/services/sentiment.test.js diff --git a/.env.sample b/.env.sample index c7eb071..7190385 100644 --- a/.env.sample +++ b/.env.sample @@ -8,4 +8,6 @@ LOG_LEVEL=debug JWT_SECRET=dummy-secret JWT_EXPIRES_IN=3600 JWT_ISSUER=dummy-issuer -HASH_SALT_ROUNDS=10 \ No newline at end of file +HASH_SALT_ROUNDS=10 + +APILAYER_KEY=dummy-key \ No newline at end of file diff --git a/README.md b/README.md index 6513344..2e3907d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,20 @@ To locally run the provided Postman collection against your backend, execute: ``` APIURL=http://localhost:5000/api ./run-api-tests.sh ``` + +You can also run TAP tests +``` +npm test +``` + + +# EXTRA + +There is an additional endpoint to get the sentiment score of a text. + +```sh +curl -d '{"content":"You have done an excellent job. Well done!"}' -H "Content-Type: application/json" -X POST http://localhost:5000/api/sentiment/score +``` # Contributing If you find a bug please submit an Issue and, if you are willing, a Pull Request. @@ -63,4 +77,5 @@ If you want to suggest a different best practice, style or project structure ple We need your help to make this project better and keep it up to date! # License -MIT \ No newline at end of file +MIT + diff --git a/lib/config/config.js b/lib/config/config.js index bb9970f..a0a8c5a 100644 --- a/lib/config/config.js +++ b/lib/config/config.js @@ -20,6 +20,7 @@ async function getConfig () { .prop('JWT_EXPIRES_IN', S.string().required()) .prop('JWT_ISSUER', S.string().required()) .prop('HASH_SALT_ROUNDS', S.number().default(10)) + .prop('APILAYER_KEY', S.string().required()) }) @@ -52,7 +53,11 @@ async function getConfig () { jwtIssuer: env.JWT_ISSUER, hashSaltRounds: env.HASH_SALT_ROUNDS }, - knex: knexconf[env.NODE_ENV] + knex: knexconf[env.NODE_ENV], + apilayer: { + key: env.APILAYER_KEY + } + } return config diff --git a/lib/plugins/apilayer/index.js b/lib/plugins/apilayer/index.js new file mode 100644 index 0000000..fd186e0 --- /dev/null +++ b/lib/plugins/apilayer/index.js @@ -0,0 +1,52 @@ +'use strict' +const fp = require('fastify-plugin') + +async function apiLayer (fastify, options) { + const apiKey = options.apilayer.key + + fastify.decorate('apiLayer', { + async post22 (content, url) { + /* const myHeaders = new Headers() + myHeaders.append('apikey', '6EwQsWNDR4AoSvFCHAsFUhmxkuALN13F') +*/ + const raw = 'You have done excellent work, and well done.' + + const requestOptions = { + method: 'POST', + redirect: 'follow', + headers: { + 'content-type': 'text/plain', + apikey: '6EwQsWNDR4AoSvFCHAsFUhmxkuALN13F' + }, + body: raw + } + const response = await fetch(url, requestOptions) + if (!response.ok) { + const message = `An error has occured: ${response.status}` + throw new Error(message) + } + return await response.json() + }, + async post (content, url) { + const options = { + method: 'POST', + redirect: 'follow', + headers: { + 'content-type': 'text/plain', + apikey: apiKey + }, + body: content + } + + const response = await fetch(url, options) + if (!response.ok) { + const message = `An error has occured: ${response.status}` + throw new Error(message) + } + return await response.json() + } + + }) +} + +module.exports = fp(apiLayer) diff --git a/lib/routes/sentiment/index.js b/lib/routes/sentiment/index.js new file mode 100644 index 0000000..209ee4d --- /dev/null +++ b/lib/routes/sentiment/index.js @@ -0,0 +1,22 @@ +const fp = require('fastify-plugin') +const schema = require('./schema') + +async function comments (server, options, done) { + const sentimentService = server.sentimentService + + server.route({ + method: 'POST', + path: options.prefix + 'sentiment/score', + onRequest: [server.authenticate_optional], + schema: schema.sentiment, + handler: onSentimentScore + }) + async function onSentimentScore (req, reply) { + const score = await sentimentService.getSentiment(req.body.content) + return { score } + } + + done() +} + +module.exports = fp(comments) diff --git a/lib/routes/sentiment/schema.js b/lib/routes/sentiment/schema.js new file mode 100644 index 0000000..8be026b --- /dev/null +++ b/lib/routes/sentiment/schema.js @@ -0,0 +1,11 @@ +const S = require('fluent-json-schema') + +const sentiment = { + body: S.object() + .prop('content', S.string().required()), + response: { + 200: S.object().prop('score', S.string().required()) + } +} + +module.exports = { sentiment } diff --git a/lib/server.js b/lib/server.js index fa40d1d..070ec89 100644 --- a/lib/server.js +++ b/lib/server.js @@ -6,6 +6,7 @@ const cors = require('@fastify/cors') /** * Configure and starts Fastify server with all required plugins and routes * @async + * @param {Fastify.Server} server - Fastify server instance * @param {Object} config - optional configuration options (default to ./config module) * May contain a key per plugin (key is plugin name), and an extra * 'fastify' key containing the server configuration object @@ -19,6 +20,10 @@ async function plugin (server, config) { dir: path.join(__dirname, 'plugins'), options: config }) + .register(autoLoad, { + dir: path.join(__dirname, 'services'), + options: config + }) .register(autoLoad, { dir: path.join(__dirname, 'routes'), options: config, diff --git a/lib/services/sentiment.js b/lib/services/sentiment.js new file mode 100644 index 0000000..7f148d1 --- /dev/null +++ b/lib/services/sentiment.js @@ -0,0 +1,61 @@ +'use strict' +const fp = require('fastify-plugin') + +async function sentimentService (fastify, options) { + const apiLayer = fastify.apiLayer + + fastify.decorate('sentimentService', { + async getSentiment (content) { + const slices = this.splitString(content, 1000) + + let score = 0 + for (const slice of slices) { + score += await this.getSentimentApi(slice) + } + return score / slices.length + }, + + async getSentimentApi (content) { + try { + const response = await apiLayer.post(content, + 'https://api.apilayer.com/sentiment/analysis') + switch (response.sentiment) { + case 'positive': + return 1 + case 'negative': + return -1 + default: + return 0 + } + } catch (err) { + console.log(err) + return 0 + } + }, + + splitString (str, chunkSize = 1000) { + const words = str.split(' ') + const chunks = [] + let currentChunk = '' + + for (let i = 0; i < words.length; i++) { + const word = words[i] + + if ((currentChunk + ' ' + word).length <= chunkSize) { + currentChunk += (currentChunk === '' ? '' : ' ') + word + } else { + chunks.push(currentChunk) + currentChunk = word + } + } + + if (currentChunk !== '') { + chunks.push(currentChunk) + } + return chunks + } + + }) +} + +module.exports = fp(sentimentService) diff --git a/test/plugins/apilayer/apilayer.test.js b/test/plugins/apilayer/apilayer.test.js new file mode 100644 index 0000000..3188384 --- /dev/null +++ b/test/plugins/apilayer/apilayer.test.js @@ -0,0 +1,20 @@ +const test = require('tap').test +const apiLayer = require('../../../lib/plugins/apilayer') +const fetchMock = require('fetch-mock') + +test('post returns the expected response', async (t) => { + let myservice + const fastify = { + decorate: + (name, service) => { + myservice = service + } + } + await apiLayer(fastify, { apilayer: { key: '123' } }) + + fetchMock.mock('*', { tags: ['tag1', 'tag2'] }) + const response = await myservice.post('Some content', 'host', 'url') + + t.ok(fetchMock.called(), 'fetch was called') + t.equal(response.tags[0], 'tag1', 'should return the expected response') +}) diff --git a/test/services/sentiment.test.js b/test/services/sentiment.test.js new file mode 100644 index 0000000..526ddd3 --- /dev/null +++ b/test/services/sentiment.test.js @@ -0,0 +1,54 @@ +const test = require('tap').test +const sentimentService = require('../../lib/services/sentiment') + +test('getSentiment returns the expected score', async (t) => { + let myservice + const fastify = { + decorate: + (name, service) => { + myservice = service + }, + apiLayer: { + post: () => { + return { sentiment: 'positive' } + } + } + } + await sentimentService(fastify, {}) + + const response = await myservice.getSentiment('Some content') + t.equal(response, 1, 'should return the expected score') +}) + +test('splitString returns correct chunks number with long text', async (t) => { + let myservice + const fastify = { + decorate: + (name, service) => { + myservice = service + } + } + await sentimentService(fastify, {}) + + const response = await myservice.splitString('Some content '.repeat(10), 100) + t.equal(response.length, 2, 'should return the expected number of chunks') +}) + +test('getSentimentApi returns 0 on apiLayer error', async (t) => { + let myservice + const fastify = { + decorate: + (name, service) => { + myservice = service + }, + rapidApi: { + post: () => { + throw new Error('Error') + } + } + } + await sentimentService(fastify, {}) + + const response = await myservice.getSentimentApi('Some content') + t.equal(response, 0, 'should return 0 on error') +})