Skip to content

Commit

Permalink
Merge pull request #9 from mathiasberggren/feature/search-by-movie-ba…
Browse files Browse the repository at this point in the history
…ckend

[Backend] Add feature: search by movie title using IMDB API
  • Loading branch information
rasouza authored Apr 12, 2024
2 parents d971297 + ca609c0 commit eb6c81d
Show file tree
Hide file tree
Showing 26 changed files with 440 additions and 4 deletions.
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="postgresql://api:admin@localhost:5432/postgres?schema=public"

RAPID_API_KEY=""

STREAMING_AVAILABILITY_API_HOST="https://streaming-availability.p.rapidapi.com"
IMDB_API_HOST="https://imdb188.p.rapidapi.com"
2 changes: 2 additions & 0 deletions apps/api/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
# Keep environment variables out of version control
.env

.nestjs_repl_history
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@
"console": "npm run start -- --watch --entryFile repl"
},
"dependencies": {
"@nestjs/axios": "^3.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.2",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.1",
"@prisma/client": "^5.11.0",
"axios": "^1.6.8",
"nest-winston": "^1.9.4",
"nestjs-zod": "^3.0.0",
"reflect-metadata": "^0.2.0",
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { APP_PIPE } from '@nestjs/core'
import { ZodValidationPipe } from 'nestjs-zod'

import { DatabaseModule } from '../database/database.module'
import { MoviesModule } from '../movies/movies.module'

import { AppController } from './app.controller'
import { AppService } from './app.service'
import { validate } from './config/validate'

@Module({
imports: [ConfigModule.forRoot(), DatabaseModule],
imports: [ConfigModule.forRoot({ isGlobal: true, validate }), DatabaseModule, MoviesModule],
controllers: [AppController],
providers: [
Logger,
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/app/config/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from 'nestjs-zod/z'

export function validate (config: Record<string, unknown>) {
const schema = z.object({
DATABASE_URL: z.string().url(),
STREAMING_AVAILABILITY_API_HOST: z.string().url().optional(),
IMDB_API_HOST: z.string().url().optional(),
RAPID_API_KEY: z.string().optional()

})

schema.parse(config)

return config
}
6 changes: 6 additions & 0 deletions apps/api/src/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model User {
id Int @id @default(autoincrement())
email String @unique
name String?
}
17 changes: 17 additions & 0 deletions apps/api/src/movies/clients/imdb-api.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HttpModule, HttpService } from '@nestjs/axios'
import { Module } from '@nestjs/common'
import { ImdbApiBuilder, ImdbApiHttpService } from './imdb-api.service'

@Module({
imports: [HttpModule.registerAsync({
useClass: ImdbApiBuilder
})],
providers: [
{
provide: ImdbApiHttpService,
useExisting: HttpService
}
],
exports: [ImdbApiHttpService]
})
export class ImdbApiModule {}
20 changes: 20 additions & 0 deletions apps/api/src/movies/clients/imdb-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { HttpModuleOptions, HttpModuleOptionsFactory, HttpService } from '@nestjs/axios'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'

@Injectable()
export class ImdbApiBuilder implements HttpModuleOptionsFactory {
constructor (private readonly config: ConfigService) {
}

createHttpOptions (): HttpModuleOptions {
return {
baseURL: this.config.get('IMDB_API_HOST'),
headers: {
'X-RapidAPI-Key': this.config.get('RAPID_API_KEY')
}
}
}
}

export abstract class ImdbApiHttpService extends HttpService {}
17 changes: 17 additions & 0 deletions apps/api/src/movies/clients/streaming-api.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HttpModule, HttpService } from '@nestjs/axios'
import { Module } from '@nestjs/common'
import { StreamingApiBuilder, StreamingApiHttpService } from './streaming-api.service'

@Module({
imports: [HttpModule.registerAsync({
useClass: StreamingApiBuilder
})],
providers: [
{
provide: StreamingApiHttpService,
useExisting: HttpService
}
],
exports: [StreamingApiHttpService]
})
export class StreamingApiModule {}
20 changes: 20 additions & 0 deletions apps/api/src/movies/clients/streaming-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { HttpModuleOptions, HttpModuleOptionsFactory, HttpService } from '@nestjs/axios'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'

@Injectable()
export class StreamingApiBuilder implements HttpModuleOptionsFactory {
constructor (private readonly config: ConfigService) {
}

createHttpOptions (): HttpModuleOptions {
return {
baseURL: this.config.get('STREAMING_AVAILABILITY_API_HOST'),
headers: {
'X-RapidAPI-Key': this.config.get('RAPID_API_KEY')
}
}
}
}

export abstract class StreamingApiHttpService extends HttpService {}
2 changes: 2 additions & 0 deletions apps/api/src/movies/dto/create-movie.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
export class CreateMovieDto {}
4 changes: 4 additions & 0 deletions apps/api/src/movies/dto/update-movie.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger'
import { CreateMovieDto } from './create-movie.dto'

export class UpdateMovieDto extends PartialType(CreateMovieDto) {}
2 changes: 2 additions & 0 deletions apps/api/src/movies/entities/movie.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
export class Movie {}
16 changes: 16 additions & 0 deletions apps/api/src/movies/interfaces/imdb-api.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface IMDBSearchResponse {
status: boolean
message: string
timestamp: number
data: IMDBMovie[]
}

export interface IMDBMovie {
id: string
qid: string
title: string
year: number
stars: string
q: string
image: string
}
5 changes: 5 additions & 0 deletions apps/api/src/movies/interfaces/movies-search.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface MoviesSearch {
// TODO: Implement Movie response interface
// eslint-disable-next-line @typescript-eslint/no-explicit-any
findByTitle: (title: string) => Promise<any>
}
73 changes: 73 additions & 0 deletions apps/api/src/movies/movies-search.api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Test, TestingModule } from '@nestjs/testing'
import { MoviesSearchApiService } from './movies-search.api.service'
import { ImdbApiHttpService } from './clients/imdb-api.service'
import { HttpService } from '@nestjs/axios'
import { createMock } from '@golevelup/ts-jest'
import { IMDBSearchResponse } from './interfaces/imdb-api.interface'
import { AxiosResponse } from 'axios'
import { of } from 'rxjs'

describe('MoviesSearchApiService', () => {
let service: MoviesSearchApiService
let httpService: ImdbApiHttpService

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MoviesSearchApiService,
{
provide: ImdbApiHttpService,
useExisting: HttpService
}
]
}).useMocker(createMock).compile()

service = module.get<MoviesSearchApiService>(MoviesSearchApiService)
httpService = module.get<ImdbApiHttpService>(ImdbApiHttpService)
})

it('should be defined', () => {
expect(service).toBeDefined()
})

it('should return all movies with given title', async () => {
const data: IMDBSearchResponse = {
status: true,
message: 'Success',
timestamp: 1689187551887,
data: [
{
id: 'tt9603212',
qid: 'movie',
title: 'Mission: Impossible - Dead Reckoning Part One',
year: 2023,
stars: 'Tom Cruise, Hayley Atwell',
q: 'feature',
image: 'https://m.media-amazon.com/images/M/MV5BY2VmZDhhNjgtNDcxYi00M2I3LThlMTQtMWRiNWI2Y2I4ZjRmXkEyXkFqcGdeQXVyMTMxMTIwMTE0._V1_.jpg'
},
{
id: 'tt0117060',
qid: 'movie',
title: 'Mission: Impossible',
year: 1996,
stars: 'Tom Cruise, Jon Voight',
q: 'feature',
image: 'https://m.media-amazon.com/images/M/MV5BMTc3NjI2MjU0Nl5BMl5BanBnXkFtZTgwNDk3ODYxMTE@._V1_.jpg'
}
]
}

const response: AxiosResponse<IMDBSearchResponse> = {
data,
status: 200,
statusText: 'OK',
headers: {},
config: { url: 'http://localhost:3000/mockUrl' }
}

jest.spyOn(httpService, 'get').mockReturnValue(of(response))

const movies = await service.findByTitle('Mission')
expect(movies).toEqual(data.data)
})
})
17 changes: 17 additions & 0 deletions apps/api/src/movies/movies-search.api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common'

import { firstValueFrom } from 'rxjs'
import { MoviesSearch } from './interfaces/movies-search.interface'
import { ImdbApiHttpService } from './clients/imdb-api.service'
import { IMDBMovie } from './interfaces/imdb-api.interface'

@Injectable()
export class MoviesSearchApiService implements MoviesSearch {
constructor (private readonly imdbClient: ImdbApiHttpService) {}

async findByTitle (title: string): Promise<IMDBMovie[]> {
const { data } = await firstValueFrom(this.imdbClient.get('/api/v1/searchIMDB', { params: { query: title } }))
const { data: movies } = data
return movies
}
}
18 changes: 18 additions & 0 deletions apps/api/src/movies/movies-search.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { MoviesSearchService } from './movies-search.service'

describe('MoviesSearchService', () => {
let service: MoviesSearchService

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [MoviesSearchService]
}).compile()

service = module.get<MoviesSearchService>(MoviesSearchService)
})

it('should be defined', () => {
expect(service).toBeDefined()
})
})
11 changes: 11 additions & 0 deletions apps/api/src/movies/movies-search.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common'
import { MoviesSearch } from './interfaces/movies-search.interface'

// Only used for NestJS to find the correct provider to inject into MoviesController

@Injectable()
export abstract class MoviesSearchService implements MoviesSearch {
// TODO: Implement Movie response interface
// eslint-disable-next-line @typescript-eslint/no-explicit-any
abstract findByTitle (title: string): Promise<any>
}
60 changes: 60 additions & 0 deletions apps/api/src/movies/movies.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Test, TestingModule } from '@nestjs/testing'
import { MoviesController } from './movies.controller'
import { MoviesService } from './movies.service'
import { MoviesSearchService } from './movies-search.service'
import { createMock } from '@golevelup/ts-jest'
import { MoviesSearchApiService } from './movies-search.api.service'

const movies = [
{
id: 'tt9603212',
qid: 'movie',
title: 'Mission: Impossible - Dead Reckoning Part One',
year: 2023,
stars: 'Tom Cruise, Hayley Atwell',
q: 'feature',
image: 'https://m.media-amazon.com/images/M/MV5BY2VmZDhhNjgtNDcxYi00M2I3LThlMTQtMWRiNWI2Y2I4ZjRmXkEyXkFqcGdeQXVyMTMxMTIwMTE0._V1_.jpg'
},
{
id: 'tt0117060',
qid: 'movie',
title: 'Mission: Impossible',
year: 1996,
stars: 'Tom Cruise, Jon Voight',
q: 'feature',
image: 'https://m.media-amazon.com/images/M/MV5BMTc3NjI2MjU0Nl5BMl5BanBnXkFtZTgwNDk3ODYxMTE@._V1_.jpg'
}
]

describe('MoviesController', () => {
let controller: MoviesController
let movieSearchService: MoviesSearchService

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MoviesController],
providers: [
MoviesService,
{
provide: MoviesSearchService,
useClass: MoviesSearchApiService
}
]
}).useMocker(createMock).compile()

controller = module.get<MoviesController>(MoviesController)
movieSearchService = module.get<MoviesSearchService>(MoviesSearchService)
})

it('should be defined', () => {
expect(controller).toBeDefined()
})

it('should find a movie by title', async () => {
movieSearchService.findByTitle = jest.fn().mockResolvedValueOnce(movies)

const response = await controller.findByTitle('Mission: Impossible')

expect(response).toEqual(movies)
})
})
Loading

0 comments on commit eb6c81d

Please sign in to comment.