Skip to content

Commit

Permalink
Add Microservice API for videos
Browse files Browse the repository at this point in the history
  • Loading branch information
dbelokon committed Dec 8, 2021
1 parent c0fba55 commit f3dab4e
Show file tree
Hide file tree
Showing 17 changed files with 612 additions and 0 deletions.
9 changes: 9 additions & 0 deletions config/env.development
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ PARSER_PORT=10000
# Parser Service URL
PARSER_URL=http://localhost/v1/parser

################################################################################
# Videos Service
################################################################################

# Video Service Port (default is 11234)
VIDEOS_PORT=11234

# Video Service URL
VIDEOS_URL=http://localhost/v1/videos

################################################################################
# Planet Service
Expand Down
27 changes: 27 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,31 @@ services:
- PLANET_PORT
- POSTS_URL

# videos service
videos:
container_name: 'videos'
build:
context: ../src/api/videos
dockerfile: Dockerfile
environment:
- NODE_ENV
- VIDEOS_PORT
- VIDEOS_URL
depends_on:
- traefik
- redis
labels:
# Enable Traefik
- 'traefik.enable=true'
# Traefik routing for the videos service at /v1/videos
- 'traefik.http.routers.videos.rule=PathPrefix(`/${API_VERSION}/videos`)'
# Specify the posts service port
- 'traefik.http.services.videos.loadbalancer.server.port=${VIDEOS_PORT}'
# Add middleware to this route to strip the /v1/posts prefix
- 'traefik.http.middlewares.strip_videos_prefix.stripprefix.prefixes=/${API_VERSION}/videos'
- 'traefik.http.middlewares.strip_videos_prefix.stripprefix.forceSlash=true'
- 'traefik.http.routers.videos.middlewares=strip_videos_prefix'

##############################################################################
# Third-Party Dependencies and Support Services
##############################################################################
Expand All @@ -285,6 +310,8 @@ services:
- SEARCH_URL=${SEARCH_URL}
- FEED_DISCOVERY_URL=${FEED_DISCOVERY_URL}
- STATUS_URL=${STATUS_URL}
- VIDEOS_URL=${VIDEOS_URL}

container_name: 'nginx'
environment:
- TELESCOPE_HOST
Expand Down
6 changes: 6 additions & 0 deletions src/api/videos/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
Empty file added src/api/videos/.gitignore
Empty file.
14 changes: 14 additions & 0 deletions src/api/videos/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM node:lts-alpine as base

# https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/
RUN apk add dumb-init

WORKDIR /app

COPY --chown=node:node . .

RUN npm install --only=production --no-package-lock

USER node

CMD ["dumb-init", "node", "src/server.js"]
36 changes: 36 additions & 0 deletions src/api/videos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Videos Service

The Video Service returns YouTube links to videos uploaded from user's registered channels.
Its main purpose is to embed a YouTube video player on Telescope.
This service is really similar to the Post service.

## Install

```
npm install
```

## Usage

```
# normal mode
npm start
# dev mode with automatic restarts
npm run dev
```

By default, the server will run on <http://loclahost:11234/>.

### Available routes with examples

- `GET /videos` - Returns the 10 latests videos tracked. Query parameters:
- `page` - a number. It is a number from 1 or greater. It specifies the page you want to see
from the results.
- `per_page` - a number. It is a number from 1 or greater. It specifies how many videos should be returned by page.
- `GET /videos/:id` - Returns the information associated to the video ID.

## Docker

- To build and tag: `docker build . -t telescope_videos_svc:latest`
- To run locally: `docker run -p 11234:11234 telescope_videos_svc:latest`
8 changes: 8 additions & 0 deletions src/api/videos/jest.config.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const baseConfig = require('../../../jest.config.base');

module.exports = {
...baseConfig,
rootDir: '../../..',
testMatch: ['<rootDir>/src/api/videos/test/e2e/*.test.js'],
collectCoverageFrom: ['<rootDir>/src/api/videos/src/**/*.js'],
};
8 changes: 8 additions & 0 deletions src/api/videos/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const baseConfig = require('../../../jest.config.base');

module.exports = {
...baseConfig,
rootDir: '../../..',
testMatch: ['<rootDir>/src/api/videos/test/*.test.js'],
collectCoverageFrom: ['<rootDir>/src/api/videos/src/**/*.js'],
};
29 changes: 29 additions & 0 deletions src/api/videos/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@senecacdot/videos-service",
"version": "1.0.0",
"private": true,
"description": "A service for retrieving videos from YouTube",
"scripts": {
"start": "node src/server.js"
},
"repository": "Seneca-CDOT/telescope",
"license": "BSD-2-Clause",
"bugs": {
"url": "https://github.com/Seneca-CDOT/telescope/issues"
},
"homepage": "https://github.com/Seneca-CDOT/telescope#readme",
"engines": {
"node": ">=12.0.0"
},
"dependencies": {
"@senecacdot/satellite": "^1.x",
"express-validator": "^6.10.0",
"ioredis": "^4.25.0",
"ioredis-mock": "^5.4.1",
"jsdom": "^18.0.0",
"normalize-url": "^6.0.1"
},
"devDependencies": {
"supertest": "^6.1.3"
}
}
8 changes: 8 additions & 0 deletions src/api/videos/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const { Satellite } = require('@senecacdot/satellite');

const service = new Satellite();
const videos = require('./routes/videos');

service.router.use('/', videos);

module.exports = service;
43 changes: 43 additions & 0 deletions src/api/videos/src/models/feed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const normalizeUrl = require('normalize-url');
const { hash } = require('@senecacdot/satellite');

const { getFeed } = require('../storage');

const urlToId = (url) => hash(normalizeUrl(url));

class Feed {
constructor(author, url, user, link, etag, lastModified) {
if (!url) {
throw new Error('missing url for feed');
}
if (!author) {
throw new Error('missing author for feed');
}
// Use the feed's normalized url as our unique identifier
this.id = urlToId(url);
this.author = author;
this.url = url;
this.user = user;
this.link = link;

// We may or may not have these cache values when we create a feed.
this.etag = etag === '' ? null : etag;
this.lastModified = lastModified === '' ? null : lastModified;
}

/**
* Returns a Feed from the database using the given id
* @param {String} id - the id of a feed (hashed, normalized url) to get from Redis.
* Returns a Promise<Feed>
*/
static async byId(id) {
const data = await getFeed(id);
// No feed found using this id
if (!(data && data.id)) {
return null;
}
return new Feed(data.author, data.url, data.user, data.link, data.etag, data.lastModified);
}
}

module.exports = Feed;
65 changes: 65 additions & 0 deletions src/api/videos/src/models/video.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const Feed = require('./feed');
const { hash } = require('@senecacdot/satellite');
const { getVideo } = require('../storage');

/**
* Makes sure that a given date can be constructed as a Date object
* Returns a constructed Date object, if possible
* Otherwise throws an Error
* @param {Object} date an Object to construct as a Date object
* @param {Date} [fallbackDate] an optional second Date to construct in case the first fails to do so
*/
function ensureDate(date, fallbackDate) {
if (
date &&
(Object.prototype.toString.call(date) === '[object String]' ||
(Object.prototype.toString.call(date) === '[object Date]' && !Number.isNaN(date)))
) {
return new Date(date);
}
if (Object.prototype.toString.call(fallbackDate) === '[object Date]') {
return new Date(fallbackDate);
}

throw new Error(`video has an invalid date: ${date}'`);
}

/**
* Makes sure that the given feed is a Feed and not just an id. If the latter
* it gets the full feed.
* @param {Feed|String} feed a Feed object or feed id
* Returns a Promise<Feed>
*/
function ensureFeed(feed) {
return feed instanceof Feed ? Promise.resolve(feed) : Feed.byId(feed);
}

class Video {
constructor(title, url, guid, datePublished, dateUpdated, feed) {
this.id = hash(guid);
this.title = title;
this.url = url;
this.guid = guid;
this.published = ensureDate(datePublished);
this.updated = ensureDate(dateUpdated, datePublished);

if (!(feed instanceof Feed)) {
throw new Error(`expected feed to be a Feed Object, got '${feed}'`);
}
this.feed = feed;
}

static async byId(id) {
const data = await getVideo(id);

if (!(data && data.id)) {
return null;
}

const feed = await ensureFeed(data.feed);
const video = new Video(data.title, data.url, data.guid, data.published, data.updated, feed);
return video;
}
}

module.exports = Video;
5 changes: 5 additions & 0 deletions src/api/videos/src/redis.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const { Redis } = require('@senecacdot/satellite');

const redis = Redis();

module.exports = redis;
83 changes: 83 additions & 0 deletions src/api/videos/src/routes/videos.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const { Router, createError, logger } = require('@senecacdot/satellite');
const Video = require('../models/video');
const { getVideos, getVideosCount } = require('../storage');
const { validateVideosQuery, validateVideosIdParam } = require('../validation');

const videosUrl = process.env.VIDEOS_URL || '/';

const videos = Router();

videos.get('/', validateVideosQuery(), async (req, res, next) => {
const defaultNumberOfVideos = process.env.MAX_VIDEOS_PER_PAGE || 30;
const capNumOfVideos = 100;
const page = parseInt(req.query.page || 1, 10);

let ids;
let perPage;
let videosCount = 0;
let from;
let to;

if (req.query.per_page) {
perPage = req.query.per_page > capNumOfVideos ? capNumOfVideos : req.query.per_page;
} else {
perPage = defaultNumberOfVideos;
}

try {
videosCount = await getVideosCount();

from = perPage * (page - 1);

to = perPage * page > videosCount ? videosCount : perPage * page;

ids = await getVideos(from, to);
} catch (err) {
logger.error({ err }, 'Unable to get videos from Redis');
next(createError(503, 'Unable to connect to database'));
}

const nextPage = to >= videosCount ? 1 : page + 1;
const prevPage = from === 0 ? Math.floor(videosCount / perPage) : page - 1;

res.set('X-Total-Count', videosCount);

res.links({
next: `/videos?per_page=${perPage}&page=${nextPage}`,
prev: `/videos?per_page=${perPage}&page=${prevPage}`,
first: `/videos?per_page=${perPage}&page=${1}`,
last: `/videos?per_page=${perPage}&page=${Math.floor(videosCount / perPage)}`,
});

res.json(ids.map((id) => ({ id, url: `${videosUrl}/${id}` })));
});

videos.get('/:id', validateVideosIdParam(), async (req, res, next) => {
const { id } = req.params;

try {
const video = await Video.byId(id);

if (!video) {
res.status(404).json({
message: `Video not found for id ${id}`,
});
} else {
switch (req.accepts(['json'])) {
case 'json':
res.append('Content-type', 'application/json').json(video);
break;
default:
res.status(406).json({
message: 'Invalid content type',
});
break;
}
}
} catch (err) {
logger.error({ err }, 'Unable to get videos from Redis');
next(createError(503, 'Unable to connect to database'));
}
});

module.exports = videos;
10 changes: 10 additions & 0 deletions src/api/videos/src/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const { logger } = require('@senecacdot/satellite');
const service = require('.');

const PORT = parseInt(process.env.VIDEOS_PORT || 11234, 10);

service.start(PORT, () => {
logger.debug(`Videos microservice started on port ${PORT}`);
});

module.exports = service;
Loading

0 comments on commit f3dab4e

Please sign in to comment.