diff --git a/.env b/.env new file mode 100644 index 0000000..a7d9a3f --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +NAME=Node Template +NODE_ENV=development +ENVIRONMENT_NAME=local +PORT=9000 +DB_URI=mysql://root:password@localhost:3306/temp_dev +MYSQL_HOST=localhost +MYSQL_DATABASE=temp_dev +MYSQL_USER=root +MYSQL_PASSWORD=password +REDIS_HOST=localhost +REDIS_PORT=6379 +SUPABASE_URL=https://emwxhdvisuflokvxdilb.supabase.co +SUPABASE_ANON_KEY=sb_publishable_nvQaVjzivkVit45xjyc3bQ_GXozq2FC \ No newline at end of file diff --git a/.env.development b/.env.development index b3192e3..5749511 100644 --- a/.env.development +++ b/.env.development @@ -9,4 +9,6 @@ MYSQL_USER=root MYSQL_PASSWORD=password MYSQL_ROOT_PASSWORD=password REDIS_HOST=redis -REDIS_PORT=6379 \ No newline at end of file +REDIS_PORT=6379 +SUPABASE_URL=https://emwxhdvisuflokvxdilb.supabase.co +SUPABASE_ANON_KEY=sb_publishable_nvQaVjzivkVit45xjyc3bQ_GXozq2FC \ No newline at end of file diff --git a/.env.docker b/.env.docker index c2c42ff..f7d9d50 100644 --- a/.env.docker +++ b/.env.docker @@ -9,4 +9,6 @@ MYSQL_USER=def_user MYSQL_PASSWORD=password MYSQL_ROOT_PASSWORD=password REDIS_HOST=redis -REDIS_PORT=6379 \ No newline at end of file +REDIS_PORT=6379 +SUPABASE_URL=https://emwxhdvisuflokvxdilb.supabase.co +SUPABASE_ANON_KEY=sb_publishable_nvQaVjzivkVit45xjyc3bQ_GXozq2FC \ No newline at end of file diff --git a/.env.local b/.env.local index e4c7b38..a7d9a3f 100644 --- a/.env.local +++ b/.env.local @@ -8,4 +8,6 @@ MYSQL_DATABASE=temp_dev MYSQL_USER=root MYSQL_PASSWORD=password REDIS_HOST=localhost -REDIS_PORT=6379 \ No newline at end of file +REDIS_PORT=6379 +SUPABASE_URL=https://emwxhdvisuflokvxdilb.supabase.co +SUPABASE_ANON_KEY=sb_publishable_nvQaVjzivkVit45xjyc3bQ_GXozq2FC \ No newline at end of file diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index 903cad6..a79ad9c 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -2,7 +2,7 @@ name: Jest Coverage Report with Annotations (CI) on: pull_request_target: branches: - - main + - "main" jobs: coverage_report: name: Jest Coverage Report diff --git a/README.md b/README.md index 0792eee..cb7613b 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,63 @@ An enterprise Hapi template application built using Nodejs showcasing - Testing ### Installation -- Install dependencies using npm - - - `npm install` +- Install dependencies using yarn + - `npm install -g yarn` + - `yarn install` ### Setup - Run `./scripts/setup-local.sh` - This will seed the data in mysql and run the server. +- Server will start on `http://localhost:9000` + + +### Routing + +All routes are organized under the `lib/routes` folder. Any subfolder inside `lib/routes` can contain route files (e.g., `routes.js`). + +Routes are automatically loaded using the `loadRoutes` plugin: + +```js +await loadRoutes.register(server, { + routes: '**/routes.js', // finds all routes.js files recursively + cwd: path.resolve(process.cwd(), 'lib/routes'), // base folder for scanning + log: true, + ignore: '**/routes.test.js' // ignores test files +}); + +``` + +The plugin scans the folder structure, finds all matching route files, and registers them with Hapi using server.route(). Each route file should export either a single route object or an array of route objects. + +### Database Seeding + +The application uses seeders to populate initial OAuth clients, scopes, resources, and users. After running migrations, seeders are executed in order: + +1. **01_oauth_clients.js** - Creates test OAuth2 clients (TEST_CLIENT_ID_0 through TEST_CLIENT_ID_4 with secret TEST_CLIENT_SECRET) +2. **02_users.js** - Creates test users +3. **03_oauth_client_resources.js** - Associates resources with clients +4. **04_oauth_client_scopes.js** - Associates scopes (USER, ADMIN, SUPER_ADMIN, INTERNAL_SERVICE) with clients +5. **05_oauth_access_token.js** - Pre-seeds sample access tokens + +**Important:** Ensure both `oauth_client_scopes` and `oauth_client_resources` have data for each client, otherwise token creation will fail. The seeders handle this automatically. + +**Manual seeding (if needed):** +```bash +ENVIRONMENT_NAME=local npx sequelize db:seed:all +``` + +**To re-seed from scratch:** +```bash +ENVIRONMENT_NAME=local npx sequelize db:drop +ENVIRONMENT_NAME=local npx sequelize db:create +ENVIRONMENT_NAME=local npx sequelize db:migrate +npx sequelize db:seed:all +``` + + +### Public URL/API +- When you do auth: false in options passing to routes array it'll not check the auth token ### Auto Generate models from database @@ -101,41 +150,84 @@ Install Sequelize: Full documentation: https://sequelize.readthedocs.io/en/latest/ -### MySQL Setup +## Authentication & Authorization (OAuth2 Client Credentials) -Install MySQL +This application uses OAuth2 Client Credentials flow for authentication. All protected API endpoints require a valid Bearer token. -- `brew install mysql` +### Getting an Access Token -- This helps in accessing the database(`temp_dev`) +**Step 1: Request a token using client credentials** +```bash +curl -i -X POST http://localhost:9000/oauth2/tokens \ + -H "Content-Type: application/json" \ + -d '{ + "grant_type":"CLIENT_CREDENTIALS", + "client_id":"TEST_CLIENT_ID_0", + "client_secret":"TEST_CLIENT_SECRET" + }' +``` -`ALTER USER '@root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'`; +**Response:** JSON object with `accessToken` field (opaque string valid for ~12 hours) -To Access mysql +**Step 2: Use the token to call protected endpoints** +```bash +TOKEN="" +curl -i -H "Authorization: Bearer $TOKEN" http://localhost:9000/users?page=1&limit=10 +``` -- `mysql -uroot -p` -- This will ask for password and the altered password is `password` +### How Authentication Works -- Start Server - `mysql.server start` +1. **Token Request** (POST /oauth2/tokens): + - Validates `client_id` and `client_secret` against `oauth_clients` table + - Fetches client metadata (scopes and resources) from `oauth_client_scopes` and `oauth_client_resources` + - Creates and stores an access token in `oauth_access_tokens` with expiry + - Returns the token to client -- Stop Server - `mysql.server stop` - -### redis Setup +2. **Protected Endpoint Access**: + - Client sends `Authorization: Bearer ` header + - Hapi bearer-auth middleware intercepts and extracts token + - `config/auth.js` validates function: + - Looks up token in DB and checks expiry + - Validates token's scopes/permissions for the requested route via `validateScopeForRoute` + - Updates token TTL (sliding window: extends expiry by 1 day with each request) + - If valid → route handler executes; if invalid → 401 Unauthorized -Install - -- `brew install redis` +### Architecture Overview -Start +**Database Layer** (`lib/daos/` and `lib/models/`): +- Sequelize ORM models for `users`, `oauth_clients`, `oauth_client_scopes`, `oauth_client_resources`, `oauth_access_tokens` +- DAO pattern for clean data access: `userDao.js`, `oauthClientsDao.js`, `oauthAccessTokensDao.js`, etc. +- All queries abstracted behind DAO functions; routes call DAOs, not models directly -- `brew services start redis` +**Redis Caching** (`utils/cacheConstants.js` and `utils/cacheMethods.js`): +- Catbox Redis provider configured in `lib/testServer.js` and `server.js` +- Server methods (e.g., `findOneUser`) cached with TTL (1 month by default) +- Cache key invalidation available via reset-cache endpoints -Stop +**Swagger Documentation** (`server.js`): +- Registered via `hapi-swaggerui` plugin with `inert` and `vision` +- UI available at `http://localhost:9000/documentation` +- All routes tagged with API categories for Swagger grouping +- Templates served from `node_modules/hapi-swaggerui/templates` -- `brew services stop redis` +**Routing** (`plugins/loadRoutes.js` and `lib/routes/`): +- Directory-based auto-routing: files under `lib/routes/` are auto-discovered and prefixed +- Example: `lib/routes/oauth2/tokens/routes.js` → POST /oauth2/tokens +- Each route exports an array of route objects with handler, method, options, etc. +**Middleware & Plugins** (`server.js`): +- Bearer token auth: `hapi-auth-bearer-token` +- Rate limiting: `hapi-rate-limit` +- Pagination: `hapi-pagination` +- Request ID tracking: `cls-rtracer` +- CORS: `hapi-cors` +- Request/response interceptors: snake_case ↔ camelCase conversion, request logging, error handling + + + + +### MySql and redis setup +MySql and redis setup is been handled by docker compose. ### Migrations @@ -185,3 +277,37 @@ Steps 03_alter_student.sql ``` + +## Troubleshooting + +### Token Request Returns "Error while creating access token" + +**Cause:** OAuth client exists but has no scopes or resources in the database. + +**Solution:** Ensure the client has at least one scope. Run seeders: +```bash +npx sequelize db:seed:all +``` + +Or manually add a scope: +```bash +mysql -u root -p -h 127.0.0.1 -D temp_dev -e "INSERT INTO oauth_client_scopes (oauth_client_id, scope, created_at) VALUES (1, 'ADMIN', NOW());" +``` + +### Seeder Fails with "Function.prototype.apply was called on undefined" + +**Cause:** ESM imports in seeders fail in the Sequelize seeder context. + +**Solution:** Already fixed in this codebase—seeders now hardcode constants instead of importing them. No action needed. + +### Protected Endpoints Return 401 Unauthorized + +**Causes:** +1. Token not included in header: use `Authorization: Bearer ` (case-sensitive) +2. Token expired: request a new token (valid for ~12 hours) +3. Token has insufficient scopes for the route: verify client scopes match route requirements + +**Debug:** Check server logs for `createAccessToken` and `validateScopeForRoute` messages. + + + diff --git a/config/db.js b/config/db.js index d31badd..e156849 100644 --- a/config/db.js +++ b/config/db.js @@ -1,9 +1,10 @@ const mysql2 = require('mysql2'); +require('dotenv').config(); module.exports = { url: process.env.DB_URI || - `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@${process.env.MYSQL_HOST}/${process.env.MYSQL_DATABASE}`, + `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@${process.env.MYSQL_HOST || "127.0.0.1"}/${process.env.MYSQL_DATABASE}`, host: process.env.MYSQL_HOST, dialectModule: mysql2, dialect: 'mysql', diff --git a/config/supabase.js b/config/supabase.js new file mode 100644 index 0000000..992a874 --- /dev/null +++ b/config/supabase.js @@ -0,0 +1,5 @@ +export default { + url: process.env.SUPABASE_URL, + anonKey: process.env.SUPABASE_ANON_KEY, +}; + diff --git a/docker-compose.yml b/docker-compose.yml index 1a5c87a..108e2fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,32 +1,45 @@ -version: '3' services: db_mysql: - image: mysql:5.7 + image: mysql:8.0.35 ports: - '3306:3306' restart: always env_file: - .env.docker - environment: - MYSQL_ROOT_PASSWORD: password + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 redis: image: 'redis:alpine' ports: - '6379:6379' - command: ['redis-server', '--bind', 'redis', '--port', '6379'] + command: ['redis-server', '--bind', '0.0.0.0', '--port', '6379'] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 - app: - depends_on: - - redis - - db_mysql - build: - context: . - args: - ENVIRONMENT_NAME: .docker - BUILD_NAME: docker - restart: always - ports: - - 9000:9000 - env_file: - - .env.docker +volumes: + mysql_data: + + + # app: + # depends_on: + # - redis + # - db_mysql + # build: + # context: . + # args: + # ENVIRONMENT_NAME: .docker + # BUILD_NAME: docker + # restart: always + # ports: + # - 9000:9000 + # env_file: + # - .env.docker diff --git a/lib/daos/likedSongsDao.js b/lib/daos/likedSongsDao.js new file mode 100644 index 0000000..923be60 --- /dev/null +++ b/lib/daos/likedSongsDao.js @@ -0,0 +1,72 @@ +import axios from 'axios'; +import supabaseConfig from '@config/supabase'; +import { badImplementation } from '@utils/responseInterceptors'; +import { + buildSupabaseHeaders, + buildSupabaseInsertHeaders, +} from '@utils/supabaseHeaders'; +import { SUPABASE_NOT_CONFIGURED, TABLE } from '@utils/constants'; + + + +const assertSupabaseConfigured = () => { + if (!supabaseConfig.url || !supabaseConfig.anonKey) { + throw badImplementation( + SUPABASE_NOT_CONFIGURED, + ); + } +}; + +export const getLikedSongs = async ({ userId, accessToken }) => { + assertSupabaseConfigured(); + + const url = `${supabaseConfig.url}/rest/v1/${TABLE}?user_id=eq.${userId}&order=created_at.desc`; + const res = await axios.get(url, buildSupabaseHeaders(accessToken)); + + return res.data.map((row) => ({ + trackId: row.track_id, + trackName: row.track_name, + artistName: row.artist_name, + albumName: row.album_name, + previewUrl: row.preview_url, + artworkUrl: row.artwork_url, + })); +}; + +export const likeSong = async ({ userId, accessToken, song }) => { + assertSupabaseConfigured(); + + const url = `${supabaseConfig.url}/rest/v1/${TABLE}`; + const body = { + user_id: userId, + track_id: song.trackId, + track_name: song.trackName, + artist_name: song.artistName, + album_name: song.albumName, + preview_url: song.previewUrl, + artwork_url: song.artworkUrl, + }; + + const res = await axios.post( + url, + body, + buildSupabaseInsertHeaders(accessToken), + ); + + const row = res.data[0]; + return { + trackId: row.track_id, + trackName: row.track_name, + artistName: row.artist_name, + albumName: row.album_name, + previewUrl: row.preview_url, + artworkUrl: row.artwork_url, + }; +}; + +export const unlikeSong = async ({ userId, accessToken, trackId }) => { + assertSupabaseConfigured(); + + const url = `${supabaseConfig.url}/rest/v1/${TABLE}?user_id=eq.${userId}&track_id=eq.${trackId}`; + await axios.delete(url, buildSupabaseHeaders(accessToken)); +}; diff --git a/lib/daos/oauthAccessTokensDao.js b/lib/daos/oauthAccessTokensDao.js index bd3f306..a806668 100644 --- a/lib/daos/oauthAccessTokensDao.js +++ b/lib/daos/oauthAccessTokensDao.js @@ -90,3 +90,10 @@ export const updateAccessToken = (accessToken, timeToLive) => models.oauthAccess underscoredAll: false, }, ); + +export const deleteAccessToken = (accessToken) => models.oauthAccessTokens.destroy({ + where: { + accessToken, + }, + underscoredAll: false, +}); diff --git a/lib/middlewares/auth.js b/lib/middlewares/auth.js new file mode 100644 index 0000000..550c1d2 --- /dev/null +++ b/lib/middlewares/auth.js @@ -0,0 +1,26 @@ +import { unauthorized } from '@utils/responseInterceptors'; +import { getSupabaseUserFromToken } from '@services/supabaseAuth'; +import { BEARER_REGEX } from '@utils/constants'; + +const authMiddleware = async (request, h) => { + const { authorization: authHeader } = request.headers; + const match = (authHeader || '').match(BEARER_REGEX); + const token = match && match[1]; + + if (!token) { + throw unauthorized('Access denied. Unauthorized user.'); + } + + try { + const user = await getSupabaseUserFromToken({ accessToken: token }); + request.app.user = user; + request.app.accessToken = token; + } catch (error) { + request.log('error', error); + throw unauthorized('Access denied. Unauthorized user.'); + } + + return h.continue; +}; + +export default authMiddleware; \ No newline at end of file diff --git a/lib/routes/github/routes.js b/lib/routes/github/routes.js index c800340..d84a431 100644 --- a/lib/routes/github/routes.js +++ b/lib/routes/github/routes.js @@ -1,7 +1,6 @@ -import Joi from 'joi'; -import { stringAllowedSchema } from '@utils/validationUtils'; import newCircuitBreaker from '@services/circuitbreaker'; import fetchReposFromGithub from '@services/github'; +import querySchema from '@root/lib/schema/github'; const githubBreaker = newCircuitBreaker( fetchReposFromGithub, @@ -17,9 +16,7 @@ export default [ tags: ['api', 'github-circuitbreaker'], auth: false, validate: { - query: Joi.object({ - repo: stringAllowedSchema, - }), + query: querySchema, }, }, handler: async (request, h) => { diff --git a/lib/routes/login/routes.js b/lib/routes/login/routes.js new file mode 100644 index 0000000..a6f6ac7 --- /dev/null +++ b/lib/routes/login/routes.js @@ -0,0 +1,44 @@ +import { badImplementation } from '@utils/responseInterceptors'; +import { supabaseLoginWithPassword } from '@services/supabaseAuth'; +import payloadSchema from '@root/lib/schema/login'; +import { AUTH_OPT, HAPI_RATE_LIMIT_OPTIONS, LOGIN_PATH, METHODS, RATE_LIMIT_OPTIONS, SB_ERROR } from '@utils/constants'; +import { trackLogin } from '@analytics/tracker'; + + +export default [ + { + method: METHODS.POST, + path: LOGIN_PATH, + handler: async (request) => { + const { email, password } = request.payload; + + try { + const data = await supabaseLoginWithPassword({ email, password }); + return { + accessToken: data.access_token, + tokenType: data.token_type, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + user: data.user, + }; + } catch (error) { + request.log('error', error); + throw badImplementation(SB_ERROR); + } + }, + options: { + description: AUTH_OPT.LOGIN_OPT.description, + notes: AUTH_OPT.LOGIN_OPT.notes, + tags: AUTH_OPT.LOGIN_OPT.tags, + plugins: { + [HAPI_RATE_LIMIT_OPTIONS]: { + ...RATE_LIMIT_OPTIONS + }, + }, + auth: false, + validate: { + payload: payloadSchema, + }, + }, + }, +]; diff --git a/lib/routes/login/tests/routes.test.js b/lib/routes/login/tests/routes.test.js new file mode 100644 index 0000000..bb96d9e --- /dev/null +++ b/lib/routes/login/tests/routes.test.js @@ -0,0 +1,57 @@ +const { init } = require('@root/lib/testServer'); + +jest.mock('@services/supabaseAuth', () => ({ + supabaseLoginWithPassword: jest.fn(), +})); + +const { supabaseLoginWithPassword } = require('@services/supabaseAuth'); + +const payload = { + email: 'aman.kumar@wednesday.is', + password: 'aman.kumar@wednesday.is', +}; + +describe('/login route tests', () => { + let server; + + beforeAll(async () => { + server = await init(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('should login with Supabase and return access token', async () => { + supabaseLoginWithPassword.mockResolvedValueOnce({ + access_token: 'token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'refresh', + user: { id: 'user-id' }, + }); + + const res = await server.inject({ + method: 'POST', + url: '/login', + payload, + }); + + expect(res.statusCode).toEqual(200); + expect(res.result.access_token).toEqual('token'); + expect(res.result.user.id).toEqual('user-id'); + }); + + it('should return 500 if Supabase login fails', async () => { + supabaseLoginWithPassword.mockRejectedValueOnce(new Error('fail')); + + const res = await server.inject({ + method: 'POST', + url: '/login', + payload, + }); + + expect(res.statusCode).toEqual(500); + }); +}); + diff --git a/lib/routes/logout/routes.js b/lib/routes/logout/routes.js new file mode 100644 index 0000000..f9f4464 --- /dev/null +++ b/lib/routes/logout/routes.js @@ -0,0 +1,33 @@ +import { badImplementation } from '@utils/responseInterceptors'; +import { supabaseLogout } from '@services/supabaseAuth'; +import { AUTH_OPT, LOGOUT_PATH, METHODS, SB_ERROR } from '@utils/constants'; +import { trackLogout } from '@analytics/tracker'; +import BEARER from '@root/lib/schema/bearerHeader'; +import authMiddleware from '@root/lib/middlewares/auth'; + +export default [ + { + method: METHODS.POST, + path: LOGOUT_PATH, + handler: async (request, h) => { + try { + await supabaseLogout({ accessToken: request.app.accessToken }); + } catch (error) { + request.log('error', error); + throw badImplementation(SB_ERROR); + } + + return h.response().code(204); + }, + options: { + description: AUTH_OPT.LOGOUT_OPT.description, + notes: AUTH_OPT.LOGOUT_OPT.notes, + tags: AUTH_OPT.LOGOUT_OPT.tags, + auth: false, + pre: [{ method: authMiddleware }], + validate: { + headers: BEARER, + }, + }, + }, +]; diff --git a/lib/routes/logout/tests/routes.test.js b/lib/routes/logout/tests/routes.test.js new file mode 100644 index 0000000..4061596 --- /dev/null +++ b/lib/routes/logout/tests/routes.test.js @@ -0,0 +1,46 @@ +const { init } = require('@root/lib/testServer'); + +jest.mock('@services/supabaseAuth', () => ({ + supabaseLogout: jest.fn(), + getSupabaseUserFromToken: jest.fn(), +})); + +const { supabaseLogout, getSupabaseUserFromToken } = require('@services/supabaseAuth'); + +describe('/logout route tests', () => { + let server; + + beforeAll(async () => { + server = await init(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('should revoke the Supabase token and return 204', async () => { + getSupabaseUserFromToken.mockResolvedValueOnce({ id: 'user-id' }); + supabaseLogout.mockResolvedValueOnce({}); + + const res = await server.inject({ + method: 'POST', + url: '/logout', + headers: { + authorization: 'Bearer token', + }, + }); + + expect(res.statusCode).toEqual(204); + expect(supabaseLogout).toHaveBeenCalledWith({ accessToken: 'token' }); + }); + + it('should return 400 if Authorization header is missing', async () => { + const res = await server.inject({ + method: 'POST', + url: '/logout', + }); + + expect(res.statusCode).toEqual(400); + }); +}); + diff --git a/lib/routes/music/library/routes.js b/lib/routes/music/library/routes.js new file mode 100644 index 0000000..6c7065d --- /dev/null +++ b/lib/routes/music/library/routes.js @@ -0,0 +1,97 @@ +import { badGateway, conflict } from '@hapi/boom'; +import { getLikedSongs, likeSong, unlikeSong } from '@daos/likedSongsDao'; +import { + likePayloadSchema, + unlikeParamsSchema, +} from '@root/lib/schema/likedSongs'; +import BEARER from '@root/lib/schema/bearerHeader'; +import authMiddleware from '@root/lib/middlewares/auth'; +import { trackLibraryViewed, trackSongLiked, trackSongUnliked } from '@analytics/tracker'; +import { LIBRARY_PATH, LIKE_SONG_PATH, METHODS, MUSIC_OPT, SERVICE_UNAVAILABLE_MESSAGE, UNLIKE_SONG_PATH } from '@utils/constants'; + +export default [ + { + method: METHODS.GET, + path: LIBRARY_PATH, + options: { + description: MUSIC_OPT.LIBRARY_OPT.description, + notes: MUSIC_OPT.LIBRARY_OPT.notes, + tags: MUSIC_OPT.LIBRARY_OPT.tags, + auth: false, + pre: [{ method: authMiddleware }], + validate: { + headers: BEARER, + }, + }, + handler: async (request) => { + try { + const { id: userId } = request.app.user; + const { accessToken } = request.app; + return await getLikedSongs({ userId, accessToken }); + } catch (error) { + request.log('error', error); + throw badGateway(SERVICE_UNAVAILABLE_MESSAGE); + } + }, + }, + { + method: METHODS.POST, + path: LIKE_SONG_PATH, + options: { + description: MUSIC_OPT.LIBRARY_OPT.description, + notes: MUSIC_OPT.LIBRARY_OPT.notes, + tags: MUSIC_OPT.LIBRARY_OPT.tags, + auth: false, + pre: [{ method: authMiddleware }], + validate: { + headers: BEARER, + payload: likePayloadSchema, + }, + }, + handler: async (request, h) => { + try { + const { id: userId } = request.app.user; + const { accessToken } = request.app; + const song = request.payload; + + const liked = await likeSong({ userId, accessToken, song }); + return h.response(liked).code(201); + } catch (error) { + request.log('error', error); + + if (error.response && error.response.status === 409) { + throw conflict('Song is already liked'); + } + throw badGateway(SERVICE_UNAVAILABLE_MESSAGE); + } + }, + }, + { + method: METHODS.DELETE, + path: UNLIKE_SONG_PATH, + options: { + description: MUSIC_OPT.LIBRARY_OPT.description, + notes: MUSIC_OPT.LIBRARY_OPT.notes, + tags: MUSIC_OPT.LIBRARY_OPT.tags, + auth: false, + pre: [{ method: authMiddleware }], + validate: { + headers: BEARER, + params: unlikeParamsSchema, + }, + }, + handler: async (request, h) => { + try { + const { id: userId } = request.app.user; + const { accessToken } = request.app; + const { trackId } = request.params; + + await unlikeSong({ userId, accessToken, trackId }); + return h.response().code(204); + } catch (error) { + request.log('error', error); + throw badGateway(SERVICE_UNAVAILABLE_MESSAGE); + } + }, + }, +]; diff --git a/lib/routes/music/library/tests/routes.test.js b/lib/routes/music/library/tests/routes.test.js new file mode 100644 index 0000000..20f78e8 --- /dev/null +++ b/lib/routes/music/library/tests/routes.test.js @@ -0,0 +1,188 @@ +import axios from 'axios'; + +const { init } = require('@root/lib/testServer'); + +jest.mock('axios'); +jest.mock('@services/supabaseAuth', () => ({ + getSupabaseUserFromToken: jest.fn(), +})); + +const { getSupabaseUserFromToken } = require('@services/supabaseAuth'); + +describe('music/library route tests', () => { + let server; + const authHeader = { authorization: 'Bearer test-token' }; + const mockUser = { id: 'user-uuid-123' }; + + beforeAll(async () => { + server = await init(); + }); + + afterAll(async () => { + await server.stop(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + getSupabaseUserFromToken.mockResolvedValue(mockUser); + }); + + describe('GET /music/library', () => { + it('should return 200 with liked songs', async () => { + axios.get.mockResolvedValueOnce({ + data: [ + { + track_id: 123, + track_name: 'Test Song', + artist_name: 'Test Artist', + album_name: 'Test Album', + preview_url: 'https://example.com/preview', + artwork_url: 'https://example.com/art', + }, + ], + }); + + const res = await server.inject({ + method: 'GET', + url: '/music/library', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(200); + }); + + it('should return empty array when no liked songs', async () => { + axios.get.mockResolvedValueOnce({ data: [] }); + + const res = await server.inject({ + method: 'GET', + url: '/music/library', + headers: authHeader, + }); + + const payload = JSON.parse(res.payload); + expect(res.statusCode).toEqual(200); + expect(payload).toEqual([]); + }); + + it('should return 400 without auth header', async () => { + const res = await server.inject({ + method: 'GET', + url: '/music/library', + }); + + expect(res.statusCode).toEqual(400); + }); + }); + + describe('POST /music/library/like', () => { + const validPayload = { + trackId: 1468910018, + trackName: 'Señorita', + artistName: 'Shawn Mendes & Camila Cabello', + albumName: 'Señorita - Single', + previewUrl: 'https://example.com/preview.m4a', + artworkUrl: 'https://example.com/art.jpg', + }; + + it('should return 201 when liking a song', async () => { + axios.post.mockResolvedValueOnce({ + data: [ + { + track_id: 1468910018, + track_name: 'Señorita', + artist_name: 'Shawn Mendes & Camila Cabello', + album_name: 'Señorita - Single', + preview_url: 'https://example.com/preview.m4a', + artwork_url: 'https://example.com/art.jpg', + }, + ], + }); + + const res = await server.inject({ + method: 'POST', + url: '/music/library/like', + headers: authHeader, + payload: validPayload, + }); + + expect(res.statusCode).toEqual(201); + }); + + it('should return 400 if trackId is missing', async () => { + const res = await server.inject({ + method: 'POST', + url: '/music/library/like', + headers: authHeader, + payload: { trackName: 'Song', artistName: 'Artist', albumName: 'Album' }, + }); + + expect(res.statusCode).toEqual(400); + }); + + it('should return 400 if trackName is missing', async () => { + const res = await server.inject({ + method: 'POST', + url: '/music/library/like', + headers: authHeader, + payload: { trackId: 123, artistName: 'Artist', albumName: 'Album' }, + }); + + expect(res.statusCode).toEqual(400); + }); + + it('should return 409 when song is already liked', async () => { + const conflictError = new Error('Conflict'); + conflictError.response = { status: 409 }; + axios.post.mockRejectedValueOnce(conflictError); + + const res = await server.inject({ + method: 'POST', + url: '/music/library/like', + headers: authHeader, + payload: validPayload, + }); + + expect(res.statusCode).toEqual(409); + }); + + it('should return 502 when supabase is unavailable', async () => { + axios.post.mockRejectedValueOnce(new Error('Network error')); + + const res = await server.inject({ + method: 'POST', + url: '/music/library/like', + headers: authHeader, + payload: validPayload, + }); + + expect(res.statusCode).toEqual(502); + }); + }); + + describe('DELETE /music/library/unlike/{trackId}', () => { + it('should return 204 when unliking a song', async () => { + axios.delete.mockResolvedValueOnce({}); + + const res = await server.inject({ + method: 'DELETE', + url: '/music/library/unlike/1468910018', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(204); + }); + + it('should return 502 when supabase fails', async () => { + axios.delete.mockRejectedValueOnce(new Error('Network error')); + + const res = await server.inject({ + method: 'DELETE', + url: '/music/library/unlike/123', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(502); + }); + }); +}); diff --git a/lib/routes/music/resources/routes.js b/lib/routes/music/resources/routes.js new file mode 100644 index 0000000..c74b3ad --- /dev/null +++ b/lib/routes/music/resources/routes.js @@ -0,0 +1,78 @@ +import { badGateway } from '@hapi/boom'; +import searchSongsFromItunes from '@services/itunes/search'; +import getSongDetails from '@services/itunes/details'; +import BEARER from '@root/lib/schema/bearerHeader'; +import { querySchema, trackIdParamsSchema } from '@root/lib/schema/music'; +import authMiddleware from '@root/lib/middlewares/auth'; +import { trackSongSearched } from '@analytics/tracker'; +import { + METHODS, + MUSIC_OPT, + SEARCH_SONGS_PATH, + SERVICE_UNAVAILABLE_MESSAGE, + SONG_DETAILS_PATH, +} from '@utils/constants'; + + +export default [ + { + method: METHODS.GET, + path: SEARCH_SONGS_PATH, + options: { + description: MUSIC_OPT.SEARCH_SONG.description, + notes: MUSIC_OPT.SEARCH_SONG.notes, + tags: MUSIC_OPT.SEARCH_SONG.tags, + auth: false, + pre: [{ method: authMiddleware }], + validate: { + query: querySchema, + headers: BEARER, + }, + }, + handler: async (request, h) => { + try { + const { term, limit, offset, country } = request.query; + const result = await searchSongsFromItunes({ term, limit, offset, country }); + + const items = result || []; + const nextOffset = items.length === limit ? offset + items.length : null; + + const response = h.response(items); + response.header('x-limit', String(limit)); + response.header('x-offset', String(offset)); + response.header('x-result-count', String(items.length)); + if (nextOffset !== null) response.header('x-next-offset', String(nextOffset)); + + return response; + } catch (error) { + request.log('error', error); + throw badGateway(SERVICE_UNAVAILABLE_MESSAGE); + } + } + }, + { + method: METHODS.GET, + path: SONG_DETAILS_PATH, + options: { + description: MUSIC_OPT.SONG_DETAILS.description, + notes: MUSIC_OPT.SONG_DETAILS.notes, + tags: MUSIC_OPT.SONG_DETAILS.tags, + auth: false, + pre: [{ method: authMiddleware }], + validate: { + params: trackIdParamsSchema, + headers: BEARER, + }, + }, + handler: async (request) => { + try { + const { trackId } = request.params; + return await getSongDetails({ trackId }); + } catch (error) { + if (error.isBoom) throw error; + request.log('error', error); + throw badGateway(SERVICE_UNAVAILABLE_MESSAGE); + } + } + } +]; diff --git a/lib/routes/music/resources/tests/routes.test.js b/lib/routes/music/resources/tests/routes.test.js new file mode 100644 index 0000000..4691159 --- /dev/null +++ b/lib/routes/music/resources/tests/routes.test.js @@ -0,0 +1,149 @@ +import axios from 'axios'; + +const { init } = require('@root/lib/testServer'); + +jest.mock('axios'); +jest.mock('@services/supabaseAuth', () => ({ + getSupabaseUserFromToken: jest.fn(), +})); + +const { getSupabaseUserFromToken } = require('@services/supabaseAuth'); + +describe('music/resources/songs route tests', () => { + let server; + + beforeAll(async () => { + server = await init(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe('GET /music/resources/songs', () => { + const authHeader = { authorization: 'Bearer token' }; + + beforeEach(() => { + getSupabaseUserFromToken.mockResolvedValue({ id: 'user-id' }); + }); + + it('Should return 200 with valid term', async () => { + axios.get.mockResolvedValueOnce({ + data: { + results: [ + { + trackId: 1, + trackName: 'Song', + artistName: 'Artist', + collectionName: 'Album', + previewUrl: 'https://example.com', + artworkUrl100: 'https://example.com/img.jpg', + }, + ], + }, + }); + + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs?term=drake', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(200); + }); + + it('Should return JSON content type', async () => { + axios.get.mockResolvedValueOnce({ data: { results: [] } }); + + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs?term=drake', + headers: authHeader, + }); + + expect(res.headers['content-type']).toContain('application/json'); + }); + + it('Should return an array in response body', async () => { + axios.get.mockResolvedValueOnce({ data: { results: [] } }); + + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs?term=drake', + headers: authHeader, + }); + + const payload = JSON.parse(res.payload); + expect(Array.isArray(payload)).toBe(true); + }); + + it('Should return 400 if term is missing', async () => { + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(400); + }); + + it('Should return 400 if term is empty', async () => { + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs?term=', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(400); + }); + + it('Should return empty array if no songs found', async () => { + axios.get.mockResolvedValueOnce({ data: { results: [] } }); + + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs?term=asldkfjalskdfj', + headers: authHeader, + }); + + const payload = JSON.parse(res.payload); + + expect(res.statusCode).toEqual(200); + expect(payload).toEqual([]); + }); + + it('Should handle special characters in term', async () => { + axios.get.mockResolvedValueOnce({ data: { results: [] } }); + + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs?term=drake%20feat', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(200); + }); + + it('Should return 404 for unsupported method', async () => { + const res = await server.inject({ + method: 'POST', + url: '/music/resources/songs', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(404); + }); + + it('Should return 502 when iTunes service fails', async () => { + axios.get.mockRejectedValueOnce(new Error('upstream failed')); + + const res = await server.inject({ + method: 'GET', + url: '/music/resources/songs?term=drake', + headers: authHeader, + }); + + expect(res.statusCode).toEqual(502); + }); + }); +}); diff --git a/lib/routes/oauth2/clients/routes.js b/lib/routes/oauth2/clients/routes.js index 5e9416c..0164407 100644 --- a/lib/routes/oauth2/clients/routes.js +++ b/lib/routes/oauth2/clients/routes.js @@ -1,12 +1,6 @@ -import Joi from 'joi'; import { badData } from '@utils/responseInterceptors'; -import { - idOrUUIDAllowedSchema, - stringAllowedSchema, - oneOfAllowedScopes, - grantTypeSchema, -} from '@utils/validationUtils'; import { createOauthClient } from '@daos/oauthClientsDao'; +import payloadSchema from '@root/lib/schema/oauth2Clients'; export default [ { @@ -23,16 +17,7 @@ export default [ }, }, validate: { - payload: Joi.object({ - resources: Joi.array().items({ - resource_id: idOrUUIDAllowedSchema, - resource_type: stringAllowedSchema, - }), - scope: oneOfAllowedScopes, - client_id: Joi.string().required(), - client_secret: Joi.string().optional(), - grant_type: grantTypeSchema, - }), + payload: payloadSchema, }, }, handler: (request) => { diff --git a/lib/routes/oauth2/resources/routes.js b/lib/routes/oauth2/resources/routes.js index f23c890..611f261 100644 --- a/lib/routes/oauth2/resources/routes.js +++ b/lib/routes/oauth2/resources/routes.js @@ -1,4 +1,3 @@ -import Joi from 'joi'; import { findClientResources, findResource, @@ -6,12 +5,7 @@ import { updateResource, } from '@daos/oauthClientResourcesDao'; import { notFound, badRequest } from '@utils/responseInterceptors'; -import { - idAllowedSchema, - stringAllowedSchema, - numberAllowedSchema, - idOrUUIDAllowedSchema, -} from '@utils/validationUtils'; +import { querySchema, payloadSchema } from '@root/lib/schema/oauth2Resources'; export default [ { @@ -44,10 +38,7 @@ export default [ }, }, validate: { - query: Joi.object({ - page: numberAllowedSchema, - limit: numberAllowedSchema, - }), + query: querySchema, }, }, }, @@ -98,11 +89,7 @@ export default [ notes: 'API to create resource', tags: ['api', 'oauth2-resources'], validate: { - payload: Joi.object({ - oauth_client_id: idAllowedSchema, - resource_id: idOrUUIDAllowedSchema, - resource_type: stringAllowedSchema, - }), + payload: payloadSchema, }, }, }, @@ -132,11 +119,7 @@ export default [ notes: 'API to update resource by id', tags: ['api', 'oauth2-resources'], validate: { - payload: Joi.object({ - oauth_client_id: idAllowedSchema, - resource_id: idOrUUIDAllowedSchema, - resource_type: stringAllowedSchema, - }), + payload: payloadSchema, }, }, }, diff --git a/lib/routes/oauth2/scopes/routes.js b/lib/routes/oauth2/scopes/routes.js index c8e1565..7cd09fd 100644 --- a/lib/routes/oauth2/scopes/routes.js +++ b/lib/routes/oauth2/scopes/routes.js @@ -1,15 +1,10 @@ -import Joi from 'joi'; import { findClientScopes, findScope, updateScope, } from '@daos/oauthClientScopesDao'; import { notFound, badRequest } from '@utils/responseInterceptors'; -import { - numberAllowedSchema, - oneOfAllowedScopes, - idAllowedSchema, -} from '@utils/validationUtils'; +import { querySchema, payloadSchema } from '@root/lib/schema/oauth2Scopes'; export default [ { @@ -41,10 +36,7 @@ export default [ }, }, validate: { - query: Joi.object({ - page: numberAllowedSchema, - limit: numberAllowedSchema, - }), + query: querySchema, }, }, }, @@ -87,10 +79,7 @@ export default [ notes: 'API to update scope by id', tags: ['api', 'oauth2-scopes'], validate: { - payload: Joi.object({ - scope: oneOfAllowedScopes, - oauth_client_id: idAllowedSchema, - }), + payload: payloadSchema, }, }, }, diff --git a/lib/routes/oauth2/tokens/routes.js b/lib/routes/oauth2/tokens/routes.js index 59c6e7f..72733a6 100644 --- a/lib/routes/oauth2/tokens/routes.js +++ b/lib/routes/oauth2/tokens/routes.js @@ -1,13 +1,9 @@ import isNil from 'lodash/isNil'; -import Joi from 'joi'; import { unauthorized, badImplementation } from '@utils/responseInterceptors'; -import { - clientCredentialsSchema, - grantTypeSchema, -} from '@utils/validationUtils'; import { createAccessToken } from '@daos/oauthAccessTokensDao'; import { findOneOauthClient } from '@daos/oauthClientsDao'; -import { INVALID_CLIENT_CREDENTIALS } from '@utils/constants'; +import { INVALID_CLIENT_CREDENTIALS, TOKEN_ERROR } from '@utils/constants'; +import payloadSchema from '@root/lib/schema/oauth2Tokens'; export default [ { @@ -23,7 +19,7 @@ export default [ return createAccessToken(oauthClient.id).catch((error) => { request.log('error', error); throw badImplementation( - 'Error while creating access token', + TOKEN_ERROR, error.message, ); }); @@ -42,14 +38,7 @@ export default [ }, auth: false, validate: { - payload: Joi.object({ - grant_type: grantTypeSchema, - client_id: clientCredentialsSchema, - client_secret: clientCredentialsSchema.optional(), - id_token: Joi.string().optional(), - }).options({ - stripUnknown: true, - }), + payload: payloadSchema, }, }, }, diff --git a/lib/routes/routes.js b/lib/routes/routes.js index 32f1583..93cbde4 100644 --- a/lib/routes/routes.js +++ b/lib/routes/routes.js @@ -1,9 +1,11 @@ +import { METHODS } from '@utils/constants'; + const { logger } = require('@utils'); // Note: Unfortunately, wurst does not work well with the ES6 default export syntax. export default [ { - method: 'GET', + method: METHODS.GET, path: '/', handler: (request, h) => { const message = 'Health check up and running!'; diff --git a/lib/routes/signup/routes.js b/lib/routes/signup/routes.js new file mode 100644 index 0000000..a56e941 --- /dev/null +++ b/lib/routes/signup/routes.js @@ -0,0 +1,33 @@ +import { badImplementation } from '@utils/responseInterceptors'; +import { supabaseSignupWithPassword } from '@services/supabaseAuth'; +import payloadSchema from "@root/lib/schema/signup" +import { AUTH_OPT, METHODS, SB_ERROR, SIGNUP_PATH } from '@utils/constants'; +import { trackSignup } from '@analytics/tracker'; + +export default [ + { + method: METHODS.POST, + path: SIGNUP_PATH, + handler: async (request) => { + const { email, password } = request.payload; + + try { + const data = await supabaseSignupWithPassword({ email, password }); + return data; + } catch (error) { + request.log('error', error); + throw badImplementation(SB_ERROR); + } + }, + options: { + description: AUTH_OPT.SIGNUP_OPT.description, + notes: AUTH_OPT.SIGNUP_OPT.notes, + tags: AUTH_OPT.SIGNUP_OPT.tags, + auth: false, + validate: { + payload: payloadSchema, + }, + }, + }, +]; + diff --git a/lib/routes/signup/tests/routes.test.js b/lib/routes/signup/tests/routes.test.js new file mode 100644 index 0000000..1ff8f1a --- /dev/null +++ b/lib/routes/signup/tests/routes.test.js @@ -0,0 +1,54 @@ +const {init} = require("@root/lib/testServer") + +jest.mock('@services/supabaseAuth', () => ({ + supabaseSignupWithPassword: jest.fn(), +})); + +const { supabaseSignupWithPassword } = require('@services/supabaseAuth'); + +describe('/signup route tests', () => { + let server; + + beforeAll(async () => { + server = await init(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('should signup with Supabase and return response', async () => { + supabaseSignupWithPassword.mockResolvedValueOnce({ + id: 'user-id', + email: 'aman.kumar@wednesday.is', + }); + + const res = await server.inject({ + method: 'POST', + url: '/signup', + payload: { + email: 'aman.kumar@wednesday.is', + password: 'password123', + }, + }); + + expect(res.statusCode).toEqual(200); + expect(res.result.id).toEqual('user-id'); + }); + + it('should return 500 if Supabase signup fails', async () => { + supabaseSignupWithPassword.mockRejectedValueOnce(new Error('fail')); + + const res = await server.inject({ + method: 'POST', + url: '/signup', + payload: { + email: 'aman.kumar@wednesday.is', + password: 'password123', + }, + }); + + expect(res.statusCode).toEqual(500); + }); +}); + diff --git a/lib/schema/bearerHeader.js b/lib/schema/bearerHeader.js new file mode 100644 index 0000000..89d409e --- /dev/null +++ b/lib/schema/bearerHeader.js @@ -0,0 +1,8 @@ +import Joi from "joi"; + + +const BEARER = Joi.object({ + authorization: Joi.string().pattern(/^Bearer\s+\S+$/i).required(), +}).unknown(); + +export default BEARER; \ No newline at end of file diff --git a/lib/schema/github.js b/lib/schema/github.js new file mode 100644 index 0000000..111197f --- /dev/null +++ b/lib/schema/github.js @@ -0,0 +1,8 @@ +import Joi from 'joi'; +import { stringAllowedSchema } from '@utils/validationUtils'; + +const querySchema = Joi.object({ + repo: stringAllowedSchema, +}); + +export default querySchema; diff --git a/lib/schema/likedSongs.js b/lib/schema/likedSongs.js new file mode 100644 index 0000000..0c677d2 --- /dev/null +++ b/lib/schema/likedSongs.js @@ -0,0 +1,14 @@ +import Joi from 'joi'; + +export const likePayloadSchema = Joi.object({ + trackId: Joi.number().integer().positive().required(), + trackName: Joi.string().min(1).required(), + artistName: Joi.string().min(1).required(), + albumName: Joi.string().min(1).required(), + previewUrl: Joi.string().uri().allow(null, '').optional(), + artworkUrl: Joi.string().uri().allow(null, '').optional(), +}).options({ stripUnknown: true }); + +export const unlikeParamsSchema = Joi.object({ + trackId: Joi.number().integer().positive().required(), +}); diff --git a/lib/schema/login.js b/lib/schema/login.js new file mode 100644 index 0000000..1e4b9c3 --- /dev/null +++ b/lib/schema/login.js @@ -0,0 +1,9 @@ +import Joi from 'joi'; + + +const payloadSchema = Joi.object({ + email: Joi.string().email({ tlds: { allow: true } }).required(), + password: Joi.string().min(6).required(), +}).options({ stripUnknown: true }); + +export default payloadSchema; \ No newline at end of file diff --git a/lib/schema/music.js b/lib/schema/music.js new file mode 100644 index 0000000..aa94e71 --- /dev/null +++ b/lib/schema/music.js @@ -0,0 +1,10 @@ +import Joi from 'joi'; + +const querySchema = Joi.object({ + term: Joi.string().min(1).required(), + limit: Joi.number().integer().min(1).max(200).default(25), + offset: Joi.number().integer().min(0).default(0), + country: Joi.string().default('US'), +}); + +export default querySchema; diff --git a/lib/schema/oauth2Clients.js b/lib/schema/oauth2Clients.js new file mode 100644 index 0000000..88a351c --- /dev/null +++ b/lib/schema/oauth2Clients.js @@ -0,0 +1,20 @@ +import Joi from 'joi'; +import { + idOrUUIDAllowedSchema, + stringAllowedSchema, + oneOfAllowedScopes, + grantTypeSchema, +} from '@utils/validationUtils'; + +const payloadSchema = Joi.object({ + resources: Joi.array().items({ + resource_id: idOrUUIDAllowedSchema, + resource_type: stringAllowedSchema, + }), + scope: oneOfAllowedScopes, + client_id: Joi.string().required(), + client_secret: Joi.string().optional(), + grant_type: grantTypeSchema, +}); + +export default payloadSchema; diff --git a/lib/schema/oauth2Resources.js b/lib/schema/oauth2Resources.js new file mode 100644 index 0000000..577b104 --- /dev/null +++ b/lib/schema/oauth2Resources.js @@ -0,0 +1,18 @@ +import Joi from 'joi'; +import { + idAllowedSchema, + numberAllowedSchema, + idOrUUIDAllowedSchema, + stringAllowedSchema, +} from '@utils/validationUtils'; + +export const querySchema = Joi.object({ + page: numberAllowedSchema, + limit: numberAllowedSchema, +}); + +export const payloadSchema = Joi.object({ + oauth_client_id: idAllowedSchema, + resource_id: idOrUUIDAllowedSchema, + resource_type: stringAllowedSchema, +}); diff --git a/lib/schema/oauth2Scopes.js b/lib/schema/oauth2Scopes.js new file mode 100644 index 0000000..2be9028 --- /dev/null +++ b/lib/schema/oauth2Scopes.js @@ -0,0 +1,16 @@ +import Joi from 'joi'; +import { + numberAllowedSchema, + oneOfAllowedScopes, + idAllowedSchema, +} from '@utils/validationUtils'; + +export const querySchema = Joi.object({ + page: numberAllowedSchema, + limit: numberAllowedSchema, +}); + +export const payloadSchema = Joi.object({ + scope: oneOfAllowedScopes, + oauth_client_id: idAllowedSchema, +}); diff --git a/lib/schema/oauth2Tokens.js b/lib/schema/oauth2Tokens.js new file mode 100644 index 0000000..e9373f7 --- /dev/null +++ b/lib/schema/oauth2Tokens.js @@ -0,0 +1,16 @@ +import Joi from 'joi'; +import { + clientCredentialsSchema, + grantTypeSchema, +} from '@utils/validationUtils'; + +const payloadSchema = Joi.object({ + grant_type: grantTypeSchema, + client_id: clientCredentialsSchema, + client_secret: clientCredentialsSchema.optional(), + id_token: Joi.string().optional(), +}).options({ + stripUnknown: true, +}); + +export default payloadSchema; diff --git a/lib/schema/signup.js b/lib/schema/signup.js new file mode 100644 index 0000000..1e4b9c3 --- /dev/null +++ b/lib/schema/signup.js @@ -0,0 +1,9 @@ +import Joi from 'joi'; + + +const payloadSchema = Joi.object({ + email: Joi.string().email({ tlds: { allow: true } }).required(), + password: Joi.string().min(6).required(), +}).options({ stripUnknown: true }); + +export default payloadSchema; \ No newline at end of file diff --git a/lib/services/itunes.js b/lib/services/itunes.js new file mode 100644 index 0000000..e39e1b9 --- /dev/null +++ b/lib/services/itunes.js @@ -0,0 +1,48 @@ +import axios from 'axios'; +import { badGateway } from '@hapi/boom'; + +const ITUNES_BASE_URL = 'https://itunes.apple.com/search'; + +/** + * Search songs from iTunes API + * + * @param {Object} params + * @param {String} params.term + * @param {Number} params.limit + * @param {Number} params.offset + * @param {String} params.country + */ +export default async function searchSongsFromItunes ({ + term, + limit = 25, + offset = 0, + country = 'US', +}) { + try { + const response = await axios.get(ITUNES_BASE_URL, { + params: { + term, + entity: 'song', + media: 'music', + limit, + offset, + country, + }, + timeout: 3000, + }); + + const { results = [] } = response.data; + + // Normalize response + return results.map((song) => ({ + trackId: song.trackId, + trackName: song.trackName, + artistName: song.artistName, + albumName: song.collectionName, + previewUrl: song.previewUrl, + artworkUrl: song.artworkUrl100, + })); + } catch (error) { + throw badGateway('iTunes service unavailable'); + } +} diff --git a/lib/services/supabaseAuth.js b/lib/services/supabaseAuth.js new file mode 100644 index 0000000..142faa0 --- /dev/null +++ b/lib/services/supabaseAuth.js @@ -0,0 +1,61 @@ +import axios from 'axios'; +import { badImplementation } from '@utils/responseInterceptors'; +import { SB_HEADER, SUPABASE_NOT_CONFIGURED } from '@utils/constants'; +import supabaseConfig from '@config/supabase'; +import { buildSupabaseHeaders } from '@utils/supabaseHeaders'; + + + +const assertSupabaseConfigured = () => { + if (!supabaseConfig.url || !supabaseConfig.anonKey) { + throw badImplementation( + SUPABASE_NOT_CONFIGURED, + ); + } +}; + +export const supabaseLoginWithPassword = async ({ email, password }) => { + assertSupabaseConfigured(); + + const res = await axios.post( + `${supabaseConfig.url}/auth/v1/token?grant_type=password`, + { email, password }, + SB_HEADER + ); + + return res.data; +}; + +export const supabaseSignupWithPassword = async ({ email, password }) => { + assertSupabaseConfigured(); + + const res = await axios.post( + `${supabaseConfig.url}/auth/v1/signup`, + { email, password }, +SB_HEADER + ); + + return res.data; +}; + +export const supabaseLogout = async () => { + assertSupabaseConfigured(); + + await axios.post( + `${supabaseConfig.url}/auth/v1/logout`, + {}, + SB_HEADER + ); +}; + +export const getSupabaseUserFromToken = async ({ accessToken }) => { + assertSupabaseConfigured(); + + const res = await axios.get( + `${supabaseConfig.url}/auth/v1/user`, + buildSupabaseHeaders(accessToken), + ); + + return res.data; +}; + diff --git a/lib/services/tests/supabaseAuth.test.js b/lib/services/tests/supabaseAuth.test.js new file mode 100644 index 0000000..39deeda --- /dev/null +++ b/lib/services/tests/supabaseAuth.test.js @@ -0,0 +1,42 @@ +import axios from 'axios'; +import { + supabaseLoginWithPassword, + supabaseLogout, +} from '@services/supabaseAuth'; + +jest.mock('axios', () => ({ + post: jest.fn(), + get: jest.fn(), +})); + +jest.mock('@config/supabase', () => ({ + url: 'https://emwxhdvisuflokvxdilb.supabase.co', + anonKey: 'b_publishable_nvQaVjzivkVit45xjyc3bQ_GXozq2FC', +})); + +describe('supabaseAuth service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should login with password', async () => { + axios.post.mockResolvedValueOnce({ + data: { access_token: 'token' }, + }); + + const res = await supabaseLoginWithPassword({ + email: 'test@email.com', + password: 'password', + }); + + expect(res.access_token).toEqual('token'); + }); + + it('should logout', async () => { + axios.post.mockResolvedValueOnce({}); + + await supabaseLogout({ accessToken: 't' }); + + expect(axios.post).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/package.json b/package.json index 11d3bef..a119726 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "prettify": "prettier --write", "precommit": "lint:staged", "test": "jest --coverage", + "clear:test-cache": "npx jest --clearCache", "test:badges": "npm run test && jest-coverage-badges --output './badges'" }, "peerDependencies": { diff --git a/seeders/03_oauth_client_resources.js b/seeders/03_oauth_client_resources.js index 1d96c86..e28c589 100644 --- a/seeders/03_oauth_client_resources.js +++ b/seeders/03_oauth_client_resources.js @@ -1,7 +1,6 @@ const range = require('lodash/range'); -const { OAUTH_CLIENT_ID } = require('esm')(module /* , options */)( - '../utils/constants', -); + +const OAUTH_CLIENT_ID = 'OAUTH_CLIENT_ID'; module.exports = { up: (queryInterface) => { diff --git a/seeders/04_oauth_client_scopes.js b/seeders/04_oauth_client_scopes.js index 30b8737..99f5cf2 100644 --- a/seeders/04_oauth_client_scopes.js +++ b/seeders/04_oauth_client_scopes.js @@ -1,6 +1,9 @@ -const { SCOPE_TYPE } = require('esm')(module /* , options */)( - '../utils/constants' -); +const SCOPE_TYPE = { + USER: 'USER', + ADMIN: 'ADMIN', + SUPER_ADMIN: 'SUPER_ADMIN', + INTERNAL_SERVICE: 'INTERNAL_SERVICE', +}; module.exports = { up: (queryInterface) => { diff --git a/seeders/05_oauth_access_token.js b/seeders/05_oauth_access_token.js index 80e23c5..6e2ce05 100644 --- a/seeders/05_oauth_access_token.js +++ b/seeders/05_oauth_access_token.js @@ -1,8 +1,13 @@ const moment = require('moment'); const range = require('lodash/range'); -const { SCOPE_TYPE, OAUTH_CLIENT_ID } = require('esm')(module /* , options */)( - '../utils/constants' -); + +const SCOPE_TYPE = { + USER: 'USER', + ADMIN: 'ADMIN', + SUPER_ADMIN: 'SUPER_ADMIN', + INTERNAL_SERVICE: 'INTERNAL_SERVICE', +}; +const OAUTH_CLIENT_ID = 'OAUTH_CLIENT_ID'; const { v4: uuidv4 } = require('uuid'); module.exports = { diff --git a/server.js b/server.js index 18f2ad6..da10574 100644 --- a/server.js +++ b/server.js @@ -41,7 +41,7 @@ const prepDatabase = async () => { // eslint-disable-next-line import/prefer-default-export, import/no-mutable-exports export let server; -const initServer = async () => { +export const initServer = async () => { // eslint-disable-next-line global-require require('@utils/configureEnv'); server = Hapi.server(serverConfig); @@ -119,7 +119,8 @@ const initServer = async () => { // Register Wurst plugin await loadRoutes.register(server, { routes: '**/routes.js', - cwd: path.join(__dirname, '../lib/routes'), + cwd: path.resolve(process.cwd(), 'lib/routes'), + log: true, ignore: '**/routes.test.js', }); diff --git a/utils/constants.js b/utils/constants.js index ba70522..f0368b3 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -1,3 +1,6 @@ +import supabaseConfig from '@config/supabase'; + + export const ONE_USER_DATA = { id: 1, first_name: 'mac', @@ -33,6 +36,12 @@ export const SUPER_SCOPES = [ ]; export const GET_USER_PATH = '/users/{userId}'; +export const LOGIN_PATH = '/' +export const SIGNUP_PATH = '/'; +export const LIBRARY_PATH = '/'; +export const LIKE_SONG_PATH = '/like'; +export const UNLIKE_SONG_PATH = '/unlike/{trackId}'; +export const LOGOUT_PATH = '/'; export const USER_ID = 'USER_ID'; @@ -40,3 +49,100 @@ export const DEFAULT_METADATA_OPTIONS = { scope: SCOPE_TYPE.ADMIN, resourceType: OAUTH_CLIENT_ID, }; + +export const ITUNES_BASE_URL = 'https://itunes.apple.com/search'; + + +// Supabase headers + + export const SB_HEADER = { + headers: { + apikey: supabaseConfig.anonKey, + 'Content-Type': 'application/json', + }, + }; + + export const BEARER_REGEX = /^Bearer\s+(.+)$/i; + + + export const SB_ERROR = "Error while signing up with Supabase" + + export const TOKEN_ERROR= "Error while creating access token" + + // Music service constants + export const SEARCH_SONGS_PATH = '/songs'; + export const SONG_DETAILS_PATH = '/songs/{trackId}'; + export const DEFAULT_LIMIT = 25; + export const DEFAULT_OFFSET = 0; + export const DEFAULT_COUNTRY = 'US'; + export const SEARCH_SONGS_DESCRIPTION = 'Search songs'; + export const SEARCH_SONGS_NOTES = 'Search songs using iTunes API'; + export const SONG_DETAILS_DESCRIPTION = 'Get song details'; + export const SONG_DETAILS_NOTES = 'Get detailed information about a song by trackId from iTunes API'; + export const SERVICE_UNAVAILABLE_MESSAGE = 'iTunes service unavailable'; + export const HAPI_RATE_LIMIT_OPTIONS = 'hapi-rate-limit'; + + //Supabase constants + export const TABLE = 'liked_songs'; + export const SUPABASE_NOT_CONFIGURED = 'Supabase is not configured. Set SUPABASE_URL and SUPABASE_ANON_KEY.'; + + export const METHODS = { + GET: 'GET', + POST: 'POST', + PUT: 'PUT', + DELETE: 'DELETE', + } + + export const RATE_LIMIT_OPTIONS = { + userPathLimit: 5, + expiresIn: 60000, // 1 minute + } + + // Login tags + export const AUTH_OPT ={ +LOGIN_OPT :{ + description: 'login', + notes: 'API to login and create access token', + tags: ['api', 'auth', 'login'], + }, + +LOGOUT_OPT :{ + description: 'logout', + notes: 'API to logout and revoke access token', + tags: ['api', 'auth', 'logout'], + }, + + SIGNUP_OPT :{ + description: 'signup', + notes: 'API to signup user via Supabase', + tags: ['api', 'auth', 'signup'], + }, + + + } + + + export const MUSIC_OPT = { + LIBRARY_OPT :{ + description: 'music library', + notes: 'APIs to manage user music library', + tags: ['api', 'music-library'], + }, + + + SEARCH_SONG : { + description: 'search songs', + notes: 'API to search songs using iTunes API', + tags: ['api', 'music', 'search'], + }, + LIKED_SONGS : { + description: 'liked songs', + notes: 'API to get user liked songs', + tags: ['api', 'music', 'liked-songs'], + }, + SONG_DETAILS : { + description: 'song details', + notes: 'API to get song details by trackId', + tags: ['api', 'music', 'song-details'], + }, + } diff --git a/utils/supabaseHeaders.js b/utils/supabaseHeaders.js new file mode 100644 index 0000000..32af0e9 --- /dev/null +++ b/utils/supabaseHeaders.js @@ -0,0 +1,18 @@ +import supabaseConfig from '@config/supabase'; + +export const buildSupabaseHeaders = (accessToken) => ({ + headers: { + apikey: supabaseConfig.anonKey, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, +}); + +export const buildSupabaseInsertHeaders = (accessToken) => ({ + headers: { + apikey: supabaseConfig.anonKey, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, +});