Skip to content

Commit b1dd4a6

Browse files
authored
Merge pull request #70 from mongodb/docsp-55528-filtering-java-python
DOCSP-56868: Add GET /api/movies/genres endpoint to Java Spring and Python FastAPI backends
2 parents 7e97b61 + c2961c2 commit b1dd4a6

8 files changed

Lines changed: 324 additions & 2 deletions

File tree

mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,26 @@ public ResponseEntity<SuccessResponse<List<Movie>>> getAllMovies(
109109

110110
return ResponseEntity.ok(response);
111111
}
112-
112+
113+
@Operation(
114+
summary = "Get all distinct genres",
115+
description = "Retrieve a list of all unique genre values from the movies collection. " +
116+
"Demonstrates the distinct() operation. Returns genres sorted alphabetically."
117+
)
118+
@GetMapping("/genres")
119+
public ResponseEntity<SuccessResponse<List<String>>> getDistinctGenres() {
120+
List<String> genres = movieService.getDistinctGenres();
121+
122+
SuccessResponse<List<String>> response = SuccessResponse.<List<String>>builder()
123+
.success(true)
124+
.message("Found " + genres.size() + " distinct genres")
125+
.data(genres)
126+
.timestamp(Instant.now().toString())
127+
.build();
128+
129+
return ResponseEntity.ok(response);
130+
}
131+
113132
@Operation(
114133
summary = "Get a single movie by ID",
115134
description = "Retrieve a single movie by its MongoDB ObjectId."

mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ public interface MovieService {
2121

2222
List<Movie> getAllMovies(MovieSearchQuery query);
2323

24+
/**
25+
* Gets all distinct genre values from the movies collection.
26+
* Demonstrates the distinct() operation.
27+
*
28+
* @return List of unique genre strings, sorted alphabetically
29+
*/
30+
List<String> getDistinctGenres();
31+
2432
Movie getMovieById(String id);
2533

2634
Movie createMovie(CreateMovieRequest request);

mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,25 @@ public List<Movie> getAllMovies(MovieSearchQuery query) {
8585

8686
return mongoTemplate.find(mongoQuery, Movie.class);
8787
}
88-
88+
89+
@Override
90+
public List<String> getDistinctGenres() {
91+
// Use MongoTemplate's findDistinct to get all unique values from the genres array field
92+
// MongoDB automatically flattens array fields when using distinct()
93+
List<String> genres = mongoTemplate.findDistinct(
94+
new Query(),
95+
Movie.Fields.GENRES,
96+
Movie.class,
97+
String.class
98+
);
99+
100+
// Filter out null/empty values and sort alphabetically
101+
return genres.stream()
102+
.filter(genre -> genre != null && !genre.isEmpty())
103+
.sorted(String::compareTo)
104+
.collect(Collectors.toList());
105+
}
106+
89107
@Override
90108
public Movie getMovieById(String id) {
91109
if (!ObjectId.isValid(id)) {

mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,4 +936,38 @@ void testDeleteMoviesBatch_EmptyFilter() throws Exception {
936936
.andExpect(jsonPath("$.success").value(true))
937937
.andExpect(jsonPath("$.data.deletedCount").value(0));
938938
}
939+
940+
// ==================== GET DISTINCT GENRES TESTS ====================
941+
942+
@Test
943+
@DisplayName("GET /api/movies/genres - Should return list of distinct genres")
944+
void testGetDistinctGenres_Success() throws Exception {
945+
// Arrange
946+
List<String> genres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi");
947+
when(movieService.getDistinctGenres()).thenReturn(genres);
948+
949+
// Act & Assert
950+
mockMvc.perform(get("/api/movies/genres"))
951+
.andExpect(status().isOk())
952+
.andExpect(jsonPath("$.success").value(true))
953+
.andExpect(jsonPath("$.data").isArray())
954+
.andExpect(jsonPath("$.data", hasSize(5)))
955+
.andExpect(jsonPath("$.data[0]").value("Action"))
956+
.andExpect(jsonPath("$.data[1]").value("Comedy"))
957+
.andExpect(jsonPath("$.data[2]").value("Drama"));
958+
}
959+
960+
@Test
961+
@DisplayName("GET /api/movies/genres - Should return empty list when no genres exist")
962+
void testGetDistinctGenres_EmptyList() throws Exception {
963+
// Arrange
964+
when(movieService.getDistinctGenres()).thenReturn(Arrays.asList());
965+
966+
// Act & Assert
967+
mockMvc.perform(get("/api/movies/genres"))
968+
.andExpect(status().isOk())
969+
.andExpect(jsonPath("$.success").value(true))
970+
.andExpect(jsonPath("$.data").isArray())
971+
.andExpect(jsonPath("$.data", hasSize(0)));
972+
}
939973
}

mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,4 +801,82 @@ void testFindSimilarMovies_MovieNotFound() {
801801
// Act & Assert
802802
assertThrows(ResourceNotFoundException.class, () -> movieService.findSimilarMovies(movieId, 10));
803803
}
804+
805+
// ==================== GET DISTINCT GENRES TESTS ====================
806+
807+
@Test
808+
@DisplayName("Should get distinct genres successfully")
809+
void testGetDistinctGenres_Success() {
810+
// Arrange
811+
List<String> expectedGenres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi");
812+
when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)))
813+
.thenReturn(expectedGenres);
814+
815+
// Act
816+
List<String> result = movieService.getDistinctGenres();
817+
818+
// Assert
819+
assertNotNull(result);
820+
assertEquals(5, result.size());
821+
assertEquals("Action", result.get(0));
822+
assertEquals("Comedy", result.get(1));
823+
verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class));
824+
}
825+
826+
@Test
827+
@DisplayName("Should return empty list when no genres exist")
828+
void testGetDistinctGenres_EmptyList() {
829+
// Arrange
830+
when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)))
831+
.thenReturn(Arrays.asList());
832+
833+
// Act
834+
List<String> result = movieService.getDistinctGenres();
835+
836+
// Assert
837+
assertNotNull(result);
838+
assertEquals(0, result.size());
839+
verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class));
840+
}
841+
842+
@Test
843+
@DisplayName("Should filter out null and empty genres")
844+
void testGetDistinctGenres_FiltersNullAndEmpty() {
845+
// Arrange
846+
List<String> genresWithNulls = new ArrayList<>(Arrays.asList("Action", null, "", "Drama", "Comedy"));
847+
when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)))
848+
.thenReturn(genresWithNulls);
849+
850+
// Act
851+
List<String> result = movieService.getDistinctGenres();
852+
853+
// Assert
854+
assertNotNull(result);
855+
// The service should filter out null and empty values
856+
assertEquals(3, result.size());
857+
assertTrue(result.contains("Action"));
858+
assertTrue(result.contains("Drama"));
859+
assertTrue(result.contains("Comedy"));
860+
assertFalse(result.contains(null));
861+
assertFalse(result.contains(""));
862+
}
863+
864+
@Test
865+
@DisplayName("Should return genres sorted alphabetically")
866+
void testGetDistinctGenres_SortedAlphabetically() {
867+
// Arrange
868+
List<String> unsortedGenres = Arrays.asList("Drama", "Action", "Comedy");
869+
when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)))
870+
.thenReturn(unsortedGenres);
871+
872+
// Act
873+
List<String> result = movieService.getDistinctGenres();
874+
875+
// Assert
876+
assertNotNull(result);
877+
assertEquals(3, result.size());
878+
assertEquals("Action", result.get(0));
879+
assertEquals("Comedy", result.get(1));
880+
assertEquals("Drama", result.get(2));
881+
}
804882
}

mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,53 @@ describeSearch("MongoDB Search Integration Tests", () => {
455455
expect(response.body.error).toBeDefined();
456456
});
457457
});
458+
459+
describe("GET /api/movies/genres", () => {
460+
test("should return list of distinct genres", async () => {
461+
const response = await request(app)
462+
.get("/api/movies/genres")
463+
.expect(200);
464+
465+
expect(response.body.success).toBe(true);
466+
expect(response.body.data).toBeDefined();
467+
expect(Array.isArray(response.body.data)).toBe(true);
468+
expect(response.body.data.length).toBeGreaterThan(0);
469+
470+
// Verify genres are strings
471+
response.body.data.forEach((genre: any) => {
472+
expect(typeof genre).toBe("string");
473+
expect(genre.length).toBeGreaterThan(0);
474+
});
475+
});
476+
477+
test("should return genres sorted alphabetically", async () => {
478+
const response = await request(app)
479+
.get("/api/movies/genres")
480+
.expect(200);
481+
482+
expect(response.body.success).toBe(true);
483+
const genres = response.body.data;
484+
485+
// Verify alphabetical sorting
486+
for (let i = 0; i < genres.length - 1; i++) {
487+
expect(genres[i].localeCompare(genres[i + 1])).toBeLessThanOrEqual(0);
488+
}
489+
});
490+
491+
test("should include common genres like Action, Drama, Comedy", async () => {
492+
const response = await request(app)
493+
.get("/api/movies/genres")
494+
.expect(200);
495+
496+
expect(response.body.success).toBe(true);
497+
const genres = response.body.data;
498+
499+
// The sample_mflix dataset should contain these common genres
500+
expect(genres).toContain("Action");
501+
expect(genres).toContain("Drama");
502+
expect(genres).toContain("Comedy");
503+
});
504+
});
458505
});
459506

460507

mflix/server/python-fastapi/src/routers/movies.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
Search movies using MongoDB Vector Search to enable semantic search capabilities over
3333
the plot field.
3434
35+
- GET /api/movies/genres :
36+
Retrieve all distinct genre values from the movies collection.
37+
Demonstrates the distinct() operation.
38+
3539
- GET /api/movies/{id} :
3640
Retrieve a single movie by its ID.
3741
@@ -427,6 +431,41 @@ async def vector_search_movies(
427431
detail=f"Error performing vector search: {str(e)}"
428432
)
429433

434+
"""
435+
GET /api/movies/genres
436+
437+
Retrieve all distinct genre values from the movies collection.
438+
Demonstrates the distinct() operation.
439+
440+
Returns:
441+
SuccessResponse[List[str]]: A response object containing the list of unique genres, sorted alphabetically.
442+
"""
443+
444+
@router.get("/genres",
445+
response_model=SuccessResponse[List[str]],
446+
status_code=200,
447+
summary="Retrieve all distinct genres from the movies collection.")
448+
async def get_distinct_genres():
449+
movies_collection = get_collection("movies")
450+
451+
try:
452+
# Use distinct() to get all unique values from the genres array field
453+
# MongoDB automatically flattens array fields when using distinct()
454+
genres = await movies_collection.distinct("genres")
455+
except Exception as e:
456+
raise HTTPException(
457+
status_code=500,
458+
detail=f"Database error occurred: {str(e)}"
459+
)
460+
461+
# Filter out null/empty values and sort alphabetically
462+
valid_genres = sorted([
463+
genre for genre in genres
464+
if isinstance(genre, str) and len(genre) > 0
465+
])
466+
467+
return create_success_response(valid_genres, f"Found {len(valid_genres)} distinct genres")
468+
430469
"""
431470
GET /api/movies/{id}
432471
Retrieve a single movie by its ID.

mflix/server/python-fastapi/tests/test_movie_routes.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,3 +1091,82 @@ async def test_aggregate_directors_empty_results(self, mock_execute_aggregation)
10911091
# Assertions
10921092
assert result.success is True
10931093
assert len(result.data) == 0
1094+
1095+
1096+
@pytest.mark.unit
1097+
@pytest.mark.asyncio
1098+
class TestGetDistinctGenres:
1099+
"""Tests for GET /api/movies/genres endpoint."""
1100+
1101+
@patch('src.routers.movies.get_collection')
1102+
async def test_get_distinct_genres_success(self, mock_get_collection):
1103+
"""Should return list of distinct genres sorted alphabetically."""
1104+
# Setup mock
1105+
mock_collection = AsyncMock()
1106+
mock_collection.distinct.return_value = ["Drama", "Action", "Comedy", "Horror", "Sci-Fi"]
1107+
mock_get_collection.return_value = mock_collection
1108+
1109+
# Call the route handler
1110+
from src.routers.movies import get_distinct_genres
1111+
result = await get_distinct_genres()
1112+
1113+
# Assertions
1114+
assert result.success is True
1115+
assert len(result.data) == 5
1116+
# Verify alphabetical sorting
1117+
assert result.data == ["Action", "Comedy", "Drama", "Horror", "Sci-Fi"]
1118+
mock_collection.distinct.assert_called_once_with("genres")
1119+
1120+
@patch('src.routers.movies.get_collection')
1121+
async def test_get_distinct_genres_empty_list(self, mock_get_collection):
1122+
"""Should return empty list when no genres exist."""
1123+
# Setup mock
1124+
mock_collection = AsyncMock()
1125+
mock_collection.distinct.return_value = []
1126+
mock_get_collection.return_value = mock_collection
1127+
1128+
# Call the route handler
1129+
from src.routers.movies import get_distinct_genres
1130+
result = await get_distinct_genres()
1131+
1132+
# Assertions
1133+
assert result.success is True
1134+
assert len(result.data) == 0
1135+
1136+
@patch('src.routers.movies.get_collection')
1137+
async def test_get_distinct_genres_filters_null_and_empty(self, mock_get_collection):
1138+
"""Should filter out null and empty genre values."""
1139+
# Setup mock
1140+
mock_collection = AsyncMock()
1141+
mock_collection.distinct.return_value = ["Action", None, "", "Drama", "Comedy"]
1142+
mock_get_collection.return_value = mock_collection
1143+
1144+
# Call the route handler
1145+
from src.routers.movies import get_distinct_genres
1146+
result = await get_distinct_genres()
1147+
1148+
# Assertions
1149+
assert result.success is True
1150+
assert len(result.data) == 3
1151+
assert "Action" in result.data
1152+
assert "Drama" in result.data
1153+
assert "Comedy" in result.data
1154+
assert None not in result.data
1155+
assert "" not in result.data
1156+
1157+
@patch('src.routers.movies.get_collection')
1158+
async def test_get_distinct_genres_database_error(self, mock_get_collection):
1159+
"""Should handle database errors gracefully."""
1160+
# Setup mock to raise exception
1161+
mock_collection = AsyncMock()
1162+
mock_collection.distinct.side_effect = Exception("Database connection failed")
1163+
mock_get_collection.return_value = mock_collection
1164+
1165+
# Call the route handler
1166+
from src.routers.movies import get_distinct_genres
1167+
with pytest.raises(HTTPException) as exc_info:
1168+
await get_distinct_genres()
1169+
1170+
# Assertions
1171+
assert exc_info.value.status_code == 500
1172+
assert "Database error" in str(exc_info.value.detail)

0 commit comments

Comments
 (0)