diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..a708638 --- /dev/null +++ b/.env.sample @@ -0,0 +1,16 @@ +MONGO_INITDB_ROOT_USERNAME=your_mongo_username +MONGO_INITDB_ROOT_PASSWORD=your_mongo_password +MONGO_INITDB_DATABASE=your_database_name + +PORT_API=3000 +HOST_API=localhost +HELMET_ENABLED= +SWAGGER_ENABLED= +SWAGGER_TITLE= +SWAGGER_DESC= +API_VERSION= +SWAGGER_PATH= +SWAGGER_USER= +SWAGGER_PASSWORD= +SWAGGER_ENV= +CORS_ENABLED= diff --git a/.gitignore b/.gitignore index fdf3604..4951d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,6 @@ Thumbs.db .nx/cache pnpm-lock.yaml package-lock.json + data-access/client .env diff --git a/api-e2e/assets/audio-file.mp3 b/api-e2e/assets/audio-file.mp3 new file mode 100755 index 0000000..9f5945f Binary files /dev/null and b/api-e2e/assets/audio-file.mp3 differ diff --git a/api-e2e/assets/non-audio-file.png b/api-e2e/assets/non-audio-file.png new file mode 100644 index 0000000..46abab3 Binary files /dev/null and b/api-e2e/assets/non-audio-file.png differ diff --git a/api-e2e/project.json b/api-e2e/project.json index 17101f1..e6637ab 100644 --- a/api-e2e/project.json +++ b/api-e2e/project.json @@ -1,22 +1,31 @@ { "name": "api-e2e", "$schema": "../node_modules/nx/schemas/project-schema.json", - "implicitDependencies": ["api"], + "implicitDependencies": [ + "api" + ], "projectType": "application", "targets": { "e2e": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"], + "outputs": [ + "{workspaceRoot}/coverage/{e2eProjectRoot}" + ], "options": { "jestConfig": "api-e2e/jest.config.ts", - "passWithNoTests": true + "passWithNoTests": true, + "detectOpenHandle": true } }, "lint": { "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"], + "outputs": [ + "{options.outputFile}" + ], "options": { - "lintFilePatterns": ["api-e2e/**/*.{js,ts}"] + "lintFilePatterns": [ + "api-e2e/**/*.{js,ts}" + ] } } } diff --git a/api-e2e/src/api/api.spec.ts b/api-e2e/src/api/api.spec.ts index e8ac2a6..660211a 100644 --- a/api-e2e/src/api/api.spec.ts +++ b/api-e2e/src/api/api.spec.ts @@ -1,8 +1,8 @@ import axios from 'axios'; -describe('GET /api', () => { +describe('GET /api/v1', () => { it('should return a message', async () => { - const res = await axios.get(`/api`); + const res = await axios.get(`/api/v1`); expect(res.status).toBe(200); expect(res.data).toEqual({ message: 'Hello API' }); diff --git a/api-e2e/src/mock-data.ts b/api-e2e/src/mock-data.ts new file mode 100644 index 0000000..9da3584 --- /dev/null +++ b/api-e2e/src/mock-data.ts @@ -0,0 +1,14 @@ +export let MockData = { + musicCreation: { + invalidMusicFile: { + filepath: 'api-e2e/assets/non-audio-file.png', + name: 'A music with invalid file format', + id: 'unset', + }, + validMusicFile: { + filepath: 'api-e2e/assets/audio-file.mp3', + name: 'A music with valid file format', + id: 'unset', + }, + }, +}; diff --git a/api-e2e/src/music/music.spec.ts b/api-e2e/src/music/music.spec.ts new file mode 100644 index 0000000..8c1ce8b --- /dev/null +++ b/api-e2e/src/music/music.spec.ts @@ -0,0 +1,52 @@ +import axios from 'axios'; +import { AppModule } from '@musica/api'; +import { MockData } from '../mock-data'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; + +import * as request from 'supertest'; + +describe('Music Module', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + describe('POST /api/v1/music | Music creation', () => { + it('should throw error if file is not provided', async () => { }); + it('should throw error if file is not an audio', async () => { }); + it('should create music and upload file', async () => { }); + }); + + describe('GET /api/v1/music/file/:id | Get music file', () => { + it('should find the music and get the file to play', async () => { }); + }); + + describe('GET /api/v1/music | Get music file', () => { + it('should return all music records with default parameters', async () => { }); + it('should return music records based on specific query parameters', async () => { }); + it('should handle invalid JSON in query parameters gracefully', async () => { }); + }); + + describe('GET /api/v1/music/:id | Get music file', () => { + it('should retrieve a music by ID (findOne)', async () => { }); + }); + + describe('PATCH /api/v1/music/:id | Get music file', () => { + it('should update a music by ID (update)', async () => { }); + }); + + describe('DELETE /api/v1/music/:id | Get music file', () => { + it('should remove a music by ID (remove)', async () => { }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/api-e2e/src/support/test-setup.ts b/api-e2e/src/support/test-setup.ts index 07f2870..99d6159 100644 --- a/api-e2e/src/support/test-setup.ts +++ b/api-e2e/src/support/test-setup.ts @@ -2,9 +2,9 @@ import axios from 'axios'; -module.exports = async function () { +module.exports = async function() { // Configure axios for tests to use. - const host = process.env.HOST ?? 'localhost'; - const port = process.env.PORT ?? '3000'; + const host = process.env.HOST_API ?? 'localhost'; + const port = process.env.PORT_API ?? '3000'; axios.defaults.baseURL = `http://${host}:${port}`; }; diff --git a/api-e2e/src/utils.ts b/api-e2e/src/utils.ts new file mode 100644 index 0000000..1999283 --- /dev/null +++ b/api-e2e/src/utils.ts @@ -0,0 +1,8 @@ +import path from 'path'; +import fs from 'fs'; + +export const fileStreamByPath = (filePath: string) => { + const cwd = process.cwd(); + const absolutePathToFile = path.resolve(cwd, filePath); + return fs.createReadStream(absolutePathToFile); +}; diff --git a/api/src/app/app.controller.ts b/api/src/app/app.controller.ts index dff210a..a635603 100644 --- a/api/src/app/app.controller.ts +++ b/api/src/app/app.controller.ts @@ -4,7 +4,7 @@ import { AppService } from './app.service'; @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + constructor(private readonly appService: AppService) { } @Get() getData() { diff --git a/api/src/app/app.module.ts b/api/src/app/app.module.ts index 6a9bc16..c09d3b3 100644 --- a/api/src/app/app.module.ts +++ b/api/src/app/app.module.ts @@ -1,11 +1,23 @@ import { Module } from '@nestjs/common'; +import { CustomPrismaModule } from 'nestjs-prisma/dist/custom'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { MusicModule } from '../music/music.module'; +import { PrismaClient } from '@musica/data-access/client'; +import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [], + imports: [ + CustomPrismaModule.forRoot({ + name: 'DataAccessService', + client: new PrismaClient(), + isGlobal: true, + }), + MusicModule, + ConfigModule.forRoot({ isGlobal: true, cache: true }), + ], controllers: [AppController], providers: [AppService], }) -export class AppModule {} +export class AppModule { } diff --git a/api/src/configs/admin.interface.ts b/api/src/configs/admin.interface.ts new file mode 100644 index 0000000..0641ee4 --- /dev/null +++ b/api/src/configs/admin.interface.ts @@ -0,0 +1,6 @@ +export interface AdminConfig { + id: number; + username: string; + email: string; + password: string; +} diff --git a/api/src/configs/backend.interface.ts b/api/src/configs/backend.interface.ts new file mode 100644 index 0000000..6fb9fd7 --- /dev/null +++ b/api/src/configs/backend.interface.ts @@ -0,0 +1,3 @@ +export interface BackendConfig { + port: number; +} diff --git a/api/src/configs/config.interface.ts b/api/src/configs/config.interface.ts new file mode 100644 index 0000000..d39deb6 --- /dev/null +++ b/api/src/configs/config.interface.ts @@ -0,0 +1,17 @@ +import { BackendConfig } from './backend.interface'; +import { CorsConfig } from './cors.interface'; +import { SwaggerConfig } from './swagger.interface'; +import { SecurityConfig } from './security.interface'; +import { HelmetConfig } from './helmet.interface'; +import { AdminConfig } from './admin.interface'; +import { StorageConfig } from './storage.interface'; + +export interface Config { + backend: BackendConfig; + cors: CorsConfig; + swagger: SwaggerConfig; + security: SecurityConfig; + helmet: HelmetConfig; + admin: AdminConfig; + storage: StorageConfig; +} diff --git a/api/src/configs/config.ts b/api/src/configs/config.ts new file mode 100644 index 0000000..746c701 --- /dev/null +++ b/api/src/configs/config.ts @@ -0,0 +1,40 @@ +import type { Config } from './config.interface'; + +const config: Config = { + backend: { + port: 3000, + }, + helmet: { + enabled: true, + }, + cors: { + enabled: true, + }, + swagger: { + enabled: true, + title: 'Musica API ', + description: 'Swagger API Documentation server', + version: '1', + path: 'swg', + user: 'admin', + password: '4#2M0!s0D1N#2398@M1N233l', + env: ['local', 'staging', 'development'], + }, + security: { + expiresIn: '5m', + refreshIn: '7d', + bcryptSaltOrRound: 10, + passwordResetTokenExpiresIn: '5m', + }, + admin: { + username: 'admin', + email: 'admin@example.com', + password: '4#2M0!s0D1N#2398@M1N233l', + id: 0, + }, + storage: { + musicStorageDest: '/tmp/musica/musics', + }, +}; + +export default config; diff --git a/api/src/configs/cors.interface.ts b/api/src/configs/cors.interface.ts new file mode 100644 index 0000000..37778f7 --- /dev/null +++ b/api/src/configs/cors.interface.ts @@ -0,0 +1,3 @@ +export interface CorsConfig { + enabled: boolean; +} diff --git a/api/src/configs/helmet.interface.ts b/api/src/configs/helmet.interface.ts new file mode 100644 index 0000000..5633821 --- /dev/null +++ b/api/src/configs/helmet.interface.ts @@ -0,0 +1,3 @@ +export interface HelmetConfig { + enabled: boolean; +} diff --git a/api/src/configs/security.interface.ts b/api/src/configs/security.interface.ts new file mode 100644 index 0000000..d136d30 --- /dev/null +++ b/api/src/configs/security.interface.ts @@ -0,0 +1,6 @@ +export interface SecurityConfig { + expiresIn: string; + refreshIn: string; + bcryptSaltOrRound: string | number; + passwordResetTokenExpiresIn: string; +} diff --git a/api/src/configs/storage.interface.ts b/api/src/configs/storage.interface.ts new file mode 100644 index 0000000..951e899 --- /dev/null +++ b/api/src/configs/storage.interface.ts @@ -0,0 +1,3 @@ +export interface StorageConfig { + musicStorageDest: string; +} diff --git a/api/src/configs/swagger.interface.ts b/api/src/configs/swagger.interface.ts new file mode 100644 index 0000000..b37bdbe --- /dev/null +++ b/api/src/configs/swagger.interface.ts @@ -0,0 +1,10 @@ +export interface SwaggerConfig { + enabled: boolean; + title: string; + description: string; + version: string; + path: string; + password: string; + user: string; + env: string[]; +} diff --git a/api/src/main.ts b/api/src/main.ts index a124382..02bf26a 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,21 +1,96 @@ -/** - * This is not a production server yet! - * This is only a minimal backend to get started. - */ - -import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; - +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app/app.module'; +import { ConfigService } from '@nestjs/config'; +import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common'; +import config from './configs/config'; +import helmet from 'helmet'; +import { BackendConfig } from './configs/backend.interface'; +import { HelmetConfig } from './configs/helmet.interface'; +import { CorsConfig } from './configs/cors.interface'; +import { SwaggerConfig } from './configs/swagger.interface'; +import * as basicAuth from 'express-basic-auth'; async function bootstrap() { const app = await NestFactory.create(AppModule); - const globalPrefix = 'api'; - app.setGlobalPrefix(globalPrefix); - const port = process.env.PORT || 3000; - await app.listen(port); + + // Validation + app.useGlobalPipes(new ValidationPipe()); + + // Retrieve configuration values using configService + const configService = app.get(ConfigService); + + const backendConfig: BackendConfig = { + port: configService.get('PORT_API') || config.backend.port, + }; + + const helmetConfig: HelmetConfig = { + enabled: + configService.get('HELMET_ENABLED') ?? config.helmet.enabled, + }; + + const swaggerConfig: SwaggerConfig = { + enabled: + configService.get('SWAGGER_ENABLED') ?? config.swagger.enabled, + title: configService.get('SWAGGER_TITLE') ?? config.swagger.title, + description: + configService.get('SWAGGER_DESC') ?? config.swagger.description, + version: configService.get('API_VERSION') ?? config.swagger.version, + path: configService.get('SWAGGER_PATH') ?? config.swagger.path, + user: configService.get('SWAGGER_USER') ?? config.swagger.user, + password: + configService.get('SWAGGER_PASSWORD') ?? config.swagger.password, + env: configService.get('SWAGGER_ENV') ?? config.swagger.env, + }; + const corsConfig: CorsConfig = { + enabled: configService.get('CORS_ENABLED') ?? config.cors.enabled, + }; + + if (corsConfig.enabled) { + app.enableCors(); + } + + if (helmetConfig.enabled) { + app.use(helmet()); + } + + // set api version + app.setGlobalPrefix('api/' + 'v' + swaggerConfig.version, { + exclude: [{ path: 'health', method: RequestMethod.GET }], + }); + + // Swagger Api + if ( + swaggerConfig.enabled && + swaggerConfig.env.includes(process.env.NODE_ENV) + ) { + app.use( + [swaggerConfig.path, swaggerConfig.path + '-json'], + basicAuth.default({ + challenge: true, + users: { + admin: swaggerConfig.user, + password: swaggerConfig.password, + }, + }) + ); + const options = new DocumentBuilder() + .setTitle(swaggerConfig.title) + .setDescription(swaggerConfig.description) + .setVersion(swaggerConfig.version) + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, options); + + SwaggerModule.setup(swaggerConfig.path, app, document); + } + + // Start the server4 + await app.listen(backendConfig.port); Logger.log( - `🚀 Application is running on: http://localhost:${port}/${globalPrefix}` + `(  ) Application is running on: http://localhost:${backendConfig.port}/${ + 'v' + swaggerConfig.version + }` ); } diff --git a/api/src/music/music.controller.ts b/api/src/music/music.controller.ts new file mode 100644 index 0000000..e370833 --- /dev/null +++ b/api/src/music/music.controller.ts @@ -0,0 +1,167 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Query, + Param, + Delete, + Logger, + UseInterceptors, + UploadedFile, + BadRequestException, +} from '@nestjs/common'; +import { diskStorage } from 'multer'; +import { MusicService } from './music.service'; +import { Prisma } from '@musica/data-access/client'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + multerDiskStorageDestination, + audioFileFilter, + multerDiskStorageFilename, +} from '../utils/mutler'; +import { FileInterceptor } from '@nestjs/platform-express'; + +@ApiTags('Music') +@Controller('music') +export class MusicController { + constructor(private readonly musicService: MusicService) { } + + private readonly logger = new Logger(MusicService.name); + + @Post() + @ApiOperation({ description: 'Create music' }) + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: multerDiskStorageDestination, + filename: multerDiskStorageFilename, + }), + fileFilter: audioFileFilter, + }) + ) + async create( + @Body() data: Prisma.MusicCreateInput, + @UploadedFile() + file: Express.Multer.File + ) { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + + const result = await this.musicService.create({ + ...data, + fileName: file.filename, + }); + this.logger.debug(`MUSIC CREATE | Recieved data client:\n${result}`); + this.logger.verbose(`MUSIC CREATE | Music file uploaded: ${file.filename}`); + return { + message: 'Music was successfully created', + success: true, + data: { + date: result.createdAt, + fileName: file.filename, + id: result.id, + }, + }; + } + + @Get('file/:id') + public async getFile(@Param('id') id: string) { + return this.musicService.getMusicFile(id); + } + + @Get() + public async findAll( + @Query('skip') skipQu?: string, + @Query('take') takeQu?: string, + @Query('cursor') cursorQu?: string, + @Query('where') whereQu?: string, + @Query('orderBy') orderByQu?: string + ) { + const parsedSkip = this.parseQueryParamNumber(skipQu); + const parsedTake = this.parseQueryParamNumber(takeQu, 40); + const where = this.parseWhereQueryParam(whereQu); + const cursor = this.parseWhereQueryParam(cursorQu); + const orderBy = this.parseQueryParamWithRelationInput(orderByQu); + const params = { + skip: parsedSkip, + take: parsedTake, + cursor, + where, + orderBy, + }; + const result = await this.musicService.findAll(params); + this.logRequestParameters(params); + + return { message: 'Operation was successful', data: result }; + } + + private parseQueryParamNumber( + value: string | undefined, + defaultValue: number = 0 + ): number { + return value ? Number(value) : defaultValue; + } + private parseWhereQueryParam( + value: string | undefined + ): Prisma.MusicWhereUniqueInput | undefined { + try { + return value ? JSON.parse(value) : undefined; + } catch (error) { + throw new BadRequestException('Invalid JSON provided in query parameter'); + } + } + + private parseQueryParamWithRelationInput( + value: string | undefined + ): Prisma.MusicOrderByWithRelationInput | undefined { + try { + return value ? JSON.parse(value) : undefined; + } catch (error) { + throw new BadRequestException( + 'Invalid JSON provided in orderBy query parameter' + ); + } + } + + private logRequestParameters(params: Record): void { + const logParams = Object.entries(params) + .filter(([_, value]) => value !== undefined) + .map(([name, value]) => `${name}: ${value}`) + .join('\n'); + + this.logger.debug(`Request parameters:\n${logParams}`); + } + + @Get(':id') + public async findOne(@Param('id') id: string) { + const result = await this.musicService.findOne({ id }); + this.logger.debug(`Request parameters: ${id}`); + this.logger.verbose(`Data Retrived from user query:\n${result}`); + return { message: 'Operation was successful', data: result }; + } + + @Patch(':id') + public async update( + @Param('id') id: string, + @Body() updateMusicDto: Prisma.MusicUpdateInput + ) { + const result = await this.musicService.update({ + where: { id }, + data: updateMusicDto, + }); + this.logger.debug(`Request parameters: ${id}`); + this.logger.verbose(`Music info updated to:\n${result}`); + return { message: 'Music successfully updated', date: result.updatedAt }; + } + + @Delete(':id') + public async remove(@Param('id') id: string) { + const result = await this.musicService.remove({ id }); + this.logger.debug(`Request parameters: ${id}`); + this.logger.verbose(`Music successfully deleted:\n${result}`); + return { message: 'Music successfully deleted', date: result.updatedAt }; + } +} diff --git a/api/src/music/music.module.ts b/api/src/music/music.module.ts new file mode 100644 index 0000000..6d37e89 --- /dev/null +++ b/api/src/music/music.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MusicService } from './music.service'; +import { MusicController } from './music.controller'; + +@Module({ + imports: [], + controllers: [MusicController], + providers: [MusicService], +}) +export class MusicModule {} diff --git a/api/src/music/music.service.ts b/api/src/music/music.service.ts new file mode 100644 index 0000000..af1611b --- /dev/null +++ b/api/src/music/music.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Inject, StreamableFile } from '@nestjs/common'; +import { CustomPrismaService } from 'nestjs-prisma/dist/custom'; +import { Prisma, Music, PrismaClient } from '@musica/data-access/client'; +import { ConfigService } from '@nestjs/config'; +import config from '../configs/config'; +import { createReadStream } from 'fs'; + +@Injectable() +export class MusicService { + constructor( + @Inject('DataAccessService') + private prisma: CustomPrismaService, + private readonly configService: ConfigService + ) { } + + create(data: Prisma.MusicCreateInput) { + return this.prisma.client.music.create({ data }); + } + + async getMusicFile(id: string): Promise { + const uploadStorage: string = + this.configService.get('MUSIC_STORAGE') || + config.storage.musicStorageDest; + const music = await this.prisma.client.music.findUnique({ where: { id } }); + const filePath = uploadStorage + '/' + music.fileName; + const file = createReadStream(filePath); + return new StreamableFile(file); + } + + async findOne(where: Prisma.MusicWhereUniqueInput): Promise { + return this.prisma.client.music.findUnique({ + where, + }); + } + + findAll(params: { + skip?: number; + take?: number; + cursor?: Prisma.MusicWhereUniqueInput; + where?: Prisma.MusicWhereInput; + orderBy?: Prisma.MusicOrderByWithRelationInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prisma.client.music.findMany({ + skip, + take, + cursor, + where, + orderBy, + }); + } + + update(params: { + where: Prisma.MusicWhereUniqueInput; + data: Prisma.MusicUpdateInput; + }): Promise { + const { where, data } = params; + return this.prisma.client.music.update({ + data, + where, + }); + } + + remove(where: Prisma.MusicWhereUniqueInput): Promise { + return this.prisma.client.music.delete({ + where, + }); + } +} diff --git a/api/src/utils/audioFileFilter.ts b/api/src/utils/audioFileFilter.ts new file mode 100644 index 0000000..73cf661 --- /dev/null +++ b/api/src/utils/audioFileFilter.ts @@ -0,0 +1,6 @@ +export const audioFileFilter = (req, file, callBack) => { + if (file.originalname.match(/\.(mp3|ogg|m4a)$/)) { + return callBack(new Error('Only audio files are allowed!'), false); + } + callBack(null, true); +}; diff --git a/api/src/utils/musicFileName.ts b/api/src/utils/musicFileName.ts new file mode 100644 index 0000000..b5f37f0 --- /dev/null +++ b/api/src/utils/musicFileName.ts @@ -0,0 +1,21 @@ +import crypto from 'crypto'; +import * as path from 'path'; + +const generateRandomString = (length: number) => { + return crypto.randomBytes(length / 2).toString('hex'); +}; + +/** Generating music filename based on music filename + * @param file - Mutler object file which provide original filename + * @returns Generated filename to be used in database and saved file + */ +const musicFileName = (file: Express.Multer.File): string => { + const currentDateInMillisAsString = new Date().toISOString(); + return `${currentDateInMillisAsString}-${path + .parse(file.originalname) + .name.replace(' ', '-') + .toLowerCase()}-${generateRandomString(8)}${path.parse(file.originalname).ext + }`; +}; + +export default musicFileName; diff --git a/api/src/utils/mutler.ts b/api/src/utils/mutler.ts new file mode 100644 index 0000000..3fcc0e6 --- /dev/null +++ b/api/src/utils/mutler.ts @@ -0,0 +1,66 @@ +import { ConfigService } from '@nestjs/config'; +import fs from 'fs'; +import config from '../configs/config'; +import crypto from 'crypto'; +import * as path from 'path'; +import { MusicController } from '../music/music.controller'; +import { BadRequestException, Logger } from '@nestjs/common'; + +const configService = new ConfigService(); + +export const audioFileFilter = ( + req, + file, + callback: (error: Error | null, acceptFile: boolean) => void +) => { + const allowedExtensions = ['.mp3', '.ogg', '.m4a']; + const ext = path.parse(file.originalname).ext; + + if (allowedExtensions.includes(ext)) { + return callback(null, true); + } else { + return callback( + new BadRequestException('Only audio files (mp3, ogg, m4a) are allowed!'), + false + ); + } +}; + +const generateRandomString = (length: number) => { + return crypto.randomBytes(length / 2).toString('hex'); +}; + +const uploadDirectoryExistanceAssurance = (dir: string) => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + const logger = new Logger(MusicController.name); + logger.log(`Directory '${dir}' created successfully.`); + } +}; + +/** Generating music filename based on music filename + * @param file - Mutler object file which provide original filename + * @returns Generated filename to be used in database and saved file + */ +export const musicFileName = (file: Express.Multer.File): string => { + const currentDateInMillisAsString = new Date().toISOString(); + return `${currentDateInMillisAsString}-${path + .parse(file.originalname) + .name.replace(' ', '-') + .toLowerCase()}-${generateRandomString(8)}${path.parse(file.originalname).ext + }`; +}; + +export function multerDiskStorageDestination( + req: Express.Request, + file: Express.Multer.File, + callback: (error: Error | null, destination: string) => void +) { + const musicStorageDestEnv = configService.get('MUSIC_STORAGE'); + uploadDirectoryExistanceAssurance(musicStorageDestEnv); + callback(null, musicStorageDestEnv || config.storage.musicStorageDest); +} + +export const multerDiskStorageFilename = (req, file, callBack) => { + callBack(null, musicFileName(file)); +}; diff --git a/api/src/utils/value-or-none.ts b/api/src/utils/value-or-none.ts new file mode 100644 index 0000000..ac90629 --- /dev/null +++ b/api/src/utils/value-or-none.ts @@ -0,0 +1,3 @@ +export default function valueOrNone(value: any): any | string { + return value !== undefined ? value : 'none'; +} diff --git a/data-access/.env.sample b/data-access/.env.sample new file mode 100644 index 0000000..30f486c --- /dev/null +++ b/data-access/.env.sample @@ -0,0 +1 @@ +DATABASE_URL="mongodb://username:password@localhost:27017/mydatabase" diff --git a/data-access/.eslintrc.json b/data-access/.eslintrc.json new file mode 100644 index 0000000..1ad7cf0 --- /dev/null +++ b/data-access/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/data-access/.gitignore b/data-access/.gitignore new file mode 100644 index 0000000..11ddd8d --- /dev/null +++ b/data-access/.gitignore @@ -0,0 +1,3 @@ +node_modules +# Keep environment variables out of version control +.env diff --git a/data-access/prisma/schema.prisma b/data-access/prisma/schema.prisma new file mode 100644 index 0000000..01f78ed --- /dev/null +++ b/data-access/prisma/schema.prisma @@ -0,0 +1,60 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + output = "../client" + binaryTargets = "native" + previewFeatures = ["fullTextIndex", "fullTextSearch"] +} + +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model Music { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String? + album Album? @relation(fields: [albumId], references: [id]) + albumId String? @db.ObjectId + artists Artist[] @relation(fields: [artistIds], references: [id]) + artistIds String[] @db.ObjectId + playlists Playlist[] @relation(fields: [playlistIds], references: [id]) + playlistIds String[] @db.ObjectId + releaseDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + fileName String @unique +} + +model Playlist { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + musics Music[] @relation(fields: [musicIds], references: [id]) + musicIds String[] @db.ObjectId + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Album { + id String @id @default(auto()) @map("_id") @db.ObjectId + title String + releaseDate DateTime + musics Music[] + artists Artist[] @relation(fields: [artistIds], references: [id]) + artistIds String[] @db.ObjectId + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Artist { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + musics Music[] @relation(fields: [musicIds], references: [id]) + musicIds String[] @db.ObjectId + albums Album[] @relation(fields: [musicIds], references: [id]) + albumIds String[] @db.ObjectId + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/data-access/project.json b/data-access/project.json new file mode 100644 index 0000000..a16ca6c --- /dev/null +++ b/data-access/project.json @@ -0,0 +1,27 @@ +{ + "name": "data-access", + "$schema": "../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "data-access/src", + "projectType": "library", + "targets": { + "prisma": { + "command": "prisma", + "options": { + "cwd": "data-access" + } + }, + "migrate": { + "command": "prisma migrate dev", + "options": { + "cwd": "data-access" + } + }, + "generate-types": { + "command": "prisma generate", + "options": { + "cwd": "data-access" + } + } + }, + "tags": [] +} diff --git a/data-access/tsconfig.json b/data-access/tsconfig.json new file mode 100644 index 0000000..ed9466a --- /dev/null +++ b/data-access/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/data-access/tsconfig.lib.json b/data-access/tsconfig.lib.json new file mode 100644 index 0000000..6f3c503 --- /dev/null +++ b/data-access/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/docker-compose.db.yml b/docker-compose.db.yml new file mode 100644 index 0000000..cbc5c0d --- /dev/null +++ b/docker-compose.db.yml @@ -0,0 +1,16 @@ +version: '3.8' +services: + musica-db: + image: mongo:latest + container_name: musica-db + restart: always + ports: + - '27017:27017' + env_file: + - .env + volumes: + - ./tmp/mongodb_data:/data/db + +volumes: + mongodb_data: + name: musica-db-data diff --git a/nx.json b/nx.json index 1f47381..d99fbfc 100644 --- a/nx.json +++ b/nx.json @@ -65,7 +65,8 @@ }, "library": { "style": "none", - "linter": "eslint" + "linter": "eslint", + "unitTestRunner": "jest" } } } diff --git a/package.json b/package.json index 74006c2..f15731b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "@swc/cli": "~0.1.62", "@swc/core": "~1.3.85", "@testing-library/react": "14.0.0", + "@types/express": "^4.17.21", "@types/jest": "^29.4.0", + "@types/multer": "^1.4.10", "@types/node": "18.14.2", "@types/react": "18.2.33", "@types/react-dom": "18.2.14", @@ -46,6 +48,8 @@ "nx": "17.1.2", "postcss": "8.4.21", "prettier": "^2.6.2", + "prisma": "^5.6.0", + "supertest": "^6.3.3", "tailwindcss": "3.2.7", "ts-jest": "^29.1.0", "ts-node": "10.9.1", @@ -55,10 +59,22 @@ }, "dependencies": { "@nestjs/common": "^10.0.2", + "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.2", + "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^10.0.2", + "@nestjs/swagger": "^7.1.16", + "@prisma/client": "^5.6.0", "@swc/helpers": "~0.5.2", "axios": "^1.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "express": "^4.18.2", + "express-basic-auth": "^1.2.1", + "form-data": "^4.0.0", + "helmet": "^7.1.0", + "multer": "1.4.5-lts.1", + "nestjs-prisma": "^0.22.0", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.11.2", diff --git a/tsconfig.base.json b/tsconfig.base.json index b73cce6..ba1f408 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,7 +14,11 @@ "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", - "paths": {} + "paths": { + "@musica/api": ["api"], + "@musica/data-access": ["data-access/client"], + "@musica/data-access/client": ["data-access/client"] + } }, "exclude": ["node_modules", "tmp"] }