Skip to content

Commit bdb8ce4

Browse files
committed
feat: add sentiment service
1 parent 9d78e49 commit bdb8ce4

File tree

10 files changed

+250
-3
lines changed

10 files changed

+250
-3
lines changed

.env.sample

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ LOG_LEVEL=debug
88
JWT_SECRET=dummy-secret
99
JWT_EXPIRES_IN=3600
1010
JWT_ISSUER=dummy-issuer
11-
HASH_SALT_ROUNDS=10
11+
HASH_SALT_ROUNDS=10
12+
13+
APILAYER_KEY=dummy-key

README.md

+16-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ To locally run the provided Postman collection against your backend, execute:
5454
```
5555
APIURL=http://localhost:5000/api ./run-api-tests.sh
5656
```
57+
58+
You can also run TAP tests
59+
```
60+
npm test
61+
```
62+
63+
64+
# EXTRA
65+
66+
There is an additional endpoint to get the sentiment score of a text.
67+
68+
```sh
69+
curl -d '{"content":"You have done an excellent job. Well done!"}' -H "Content-Type: application/json" -X POST http://localhost:5000/api/sentiment/score
70+
```
5771
# Contributing
5872

5973
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
6377
We need your help to make this project better and keep it up to date!
6478

6579
# License
66-
MIT
80+
MIT
81+

lib/config/config.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async function getConfig () {
2020
.prop('JWT_EXPIRES_IN', S.string().required())
2121
.prop('JWT_ISSUER', S.string().required())
2222
.prop('HASH_SALT_ROUNDS', S.number().default(10))
23+
.prop('APILAYER_KEY', S.string().required())
2324

2425
})
2526

@@ -52,7 +53,11 @@ async function getConfig () {
5253
jwtIssuer: env.JWT_ISSUER,
5354
hashSaltRounds: env.HASH_SALT_ROUNDS
5455
},
55-
knex: knexconf[env.NODE_ENV]
56+
knex: knexconf[env.NODE_ENV],
57+
apilayer: {
58+
key: env.APILAYER_KEY
59+
}
60+
5661
}
5762

5863
return config

lib/plugins/apilayer/index.js

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use strict'
2+
const fp = require('fastify-plugin')
3+
4+
async function apiLayer (fastify, options) {
5+
const apiKey = options.apilayer.key
6+
7+
fastify.decorate('apiLayer', {
8+
async post22 (content, url) {
9+
/* const myHeaders = new Headers()
10+
myHeaders.append('apikey', '6EwQsWNDR4AoSvFCHAsFUhmxkuALN13F')
11+
*/
12+
const raw = 'You have done excellent work, and well done.'
13+
14+
const requestOptions = {
15+
method: 'POST',
16+
redirect: 'follow',
17+
headers: {
18+
'content-type': 'text/plain',
19+
apikey: '6EwQsWNDR4AoSvFCHAsFUhmxkuALN13F'
20+
},
21+
body: raw
22+
}
23+
const response = await fetch(url, requestOptions)
24+
if (!response.ok) {
25+
const message = `An error has occured: ${response.status}`
26+
throw new Error(message)
27+
}
28+
return await response.json()
29+
},
30+
async post (content, url) {
31+
const options = {
32+
method: 'POST',
33+
redirect: 'follow',
34+
headers: {
35+
'content-type': 'text/plain',
36+
apikey: apiKey
37+
},
38+
body: content
39+
}
40+
41+
const response = await fetch(url, options)
42+
if (!response.ok) {
43+
const message = `An error has occured: ${response.status}`
44+
throw new Error(message)
45+
}
46+
return await response.json()
47+
}
48+
49+
})
50+
}
51+
52+
module.exports = fp(apiLayer)

lib/routes/sentiment/index.js

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const fp = require('fastify-plugin')
2+
const schema = require('./schema')
3+
4+
async function comments (server, options, done) {
5+
const sentimentService = server.sentimentService
6+
7+
server.route({
8+
method: 'POST',
9+
path: options.prefix + 'sentiment/score',
10+
onRequest: [server.authenticate_optional],
11+
schema: schema.sentiment,
12+
handler: onSentimentScore
13+
})
14+
async function onSentimentScore (req, reply) {
15+
const score = await sentimentService.getSentiment(req.body.content)
16+
return { score }
17+
}
18+
19+
done()
20+
}
21+
22+
module.exports = fp(comments)

lib/routes/sentiment/schema.js

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const S = require('fluent-json-schema')
2+
3+
const sentiment = {
4+
body: S.object()
5+
.prop('content', S.string().required()),
6+
response: {
7+
200: S.object().prop('score', S.string().required())
8+
}
9+
}
10+
11+
module.exports = { sentiment }

lib/server.js

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const cors = require('@fastify/cors')
66
/**
77
* Configure and starts Fastify server with all required plugins and routes
88
* @async
9+
* @param {Fastify.Server} server - Fastify server instance
910
* @param {Object} config - optional configuration options (default to ./config module)
1011
* May contain a key per plugin (key is plugin name), and an extra
1112
* 'fastify' key containing the server configuration object
@@ -19,6 +20,10 @@ async function plugin (server, config) {
1920
dir: path.join(__dirname, 'plugins'),
2021
options: config
2122
})
23+
.register(autoLoad, {
24+
dir: path.join(__dirname, 'services'),
25+
options: config
26+
})
2227
.register(autoLoad, {
2328
dir: path.join(__dirname, 'routes'),
2429
options: config,

lib/services/sentiment.js

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use strict'
2+
const fp = require('fastify-plugin')
3+
4+
async function sentimentService (fastify, options) {
5+
const apiLayer = fastify.apiLayer
6+
7+
fastify.decorate('sentimentService', {
8+
async getSentiment (content) {
9+
const slices = this.splitString(content, 1000)
10+
11+
let score = 0
12+
for (const slice of slices) {
13+
score += await this.getSentimentApi(slice)
14+
}
15+
return score / slices.length
16+
},
17+
18+
async getSentimentApi (content) {
19+
try {
20+
const response = await apiLayer.post(content,
21+
'https://api.apilayer.com/sentiment/analysis')
22+
switch (response.sentiment) {
23+
case 'positive':
24+
return 1
25+
case 'negative':
26+
return -1
27+
default:
28+
return 0
29+
}
30+
} catch (err) {
31+
console.log(err)
32+
return 0
33+
}
34+
},
35+
36+
splitString (str, chunkSize = 1000) {
37+
const words = str.split(' ')
38+
const chunks = []
39+
let currentChunk = ''
40+
41+
for (let i = 0; i < words.length; i++) {
42+
const word = words[i]
43+
44+
if ((currentChunk + ' ' + word).length <= chunkSize) {
45+
currentChunk += (currentChunk === '' ? '' : ' ') + word
46+
} else {
47+
chunks.push(currentChunk)
48+
currentChunk = word
49+
}
50+
}
51+
52+
if (currentChunk !== '') {
53+
chunks.push(currentChunk)
54+
}
55+
return chunks
56+
}
57+
58+
})
59+
}
60+
61+
module.exports = fp(sentimentService)
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const test = require('tap').test
2+
const apiLayer = require('../../../lib/plugins/apilayer')
3+
const fetchMock = require('fetch-mock')
4+
5+
test('post returns the expected response', async (t) => {
6+
let myservice
7+
const fastify = {
8+
decorate:
9+
(name, service) => {
10+
myservice = service
11+
}
12+
}
13+
await apiLayer(fastify, { apilayer: { key: '123' } })
14+
15+
fetchMock.mock('*', { tags: ['tag1', 'tag2'] })
16+
const response = await myservice.post('Some content', 'host', 'url')
17+
18+
t.ok(fetchMock.called(), 'fetch was called')
19+
t.equal(response.tags[0], 'tag1', 'should return the expected response')
20+
})

test/services/sentiment.test.js

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const test = require('tap').test
2+
const sentimentService = require('../../lib/services/sentiment')
3+
4+
test('getSentiment returns the expected score', async (t) => {
5+
let myservice
6+
const fastify = {
7+
decorate:
8+
(name, service) => {
9+
myservice = service
10+
},
11+
apiLayer: {
12+
post: () => {
13+
return { sentiment: 'positive' }
14+
}
15+
}
16+
}
17+
await sentimentService(fastify, {})
18+
19+
const response = await myservice.getSentiment('Some content')
20+
t.equal(response, 1, 'should return the expected score')
21+
})
22+
23+
test('splitString returns correct chunks number with long text', async (t) => {
24+
let myservice
25+
const fastify = {
26+
decorate:
27+
(name, service) => {
28+
myservice = service
29+
}
30+
}
31+
await sentimentService(fastify, {})
32+
33+
const response = await myservice.splitString('Some content '.repeat(10), 100)
34+
t.equal(response.length, 2, 'should return the expected number of chunks')
35+
})
36+
37+
test('getSentimentApi returns 0 on apiLayer error', async (t) => {
38+
let myservice
39+
const fastify = {
40+
decorate:
41+
(name, service) => {
42+
myservice = service
43+
},
44+
rapidApi: {
45+
post: () => {
46+
throw new Error('Error')
47+
}
48+
}
49+
}
50+
await sentimentService(fastify, {})
51+
52+
const response = await myservice.getSentimentApi('Some content')
53+
t.equal(response, 0, 'should return 0 on error')
54+
})

0 commit comments

Comments
 (0)