Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
<argLine>@{argLine} "-javaagent:${org.mockito:mockito-core:jar}"</argLine>
</configuration>
</plugin>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Twitter Backend - Moo: Twitter Clone Application Backend by Scaler
* Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package xyz.subho.clone.twitter.constant;

/** SearchV1Constants - API endpoint constants for Search V1 Controller. */
public class SearchV1Constants {

public static final String BASE_PATH = ApiVersion.V1 + "/search";
public static final String POSTS = "/posts";
public static final String USERS = "/users";

private SearchV1Constants() {
// Prevent instantiation
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Twitter Backend - Moo: Twitter Clone Application Backend by Scaler
* Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package xyz.subho.clone.twitter.controller;

import io.micrometer.core.annotation.Counted;
import io.micrometer.core.annotation.Timed;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import xyz.subho.clone.twitter.constant.SearchV1Constants;
import xyz.subho.clone.twitter.model.PostModel;
import xyz.subho.clone.twitter.model.SearchResultModel;
import xyz.subho.clone.twitter.model.UserModel;
import xyz.subho.clone.twitter.service.SearchService;
import xyz.subho.clone.twitter.utility.Utility;

@RestController
@RequestMapping(SearchV1Constants.BASE_PATH)
@Timed(value = "moo.search.timer", description = "Time taken to process search requests")
public class SearchController {

private final SearchService searchService;

public SearchController(SearchService searchService) {
this.searchService = searchService;
}

@GetMapping
@Counted(value = "moo.search.all", description = "Number of combined search requests")
public ResponseEntity<SearchResultModel> searchAll(
@RequestParam("q") String query,
@PageableDefault(
size = 20,
sort = {"createdAt", "id"},
direction = Sort.Direction.DESC)
Pageable pageable) {
Utility.validatePageable(pageable, "createdAt", "id");
SearchResultModel result = searchService.searchAll(query, pageable);
return new ResponseEntity<>(result, HttpStatus.OK);
}

@GetMapping(SearchV1Constants.POSTS)
@Counted(value = "moo.search.posts", description = "Number of post search requests")
public ResponseEntity<Page<PostModel>> searchPosts(
@RequestParam("q") String query,
@PageableDefault(
size = 20,
sort = {"createdAt", "id"},
direction = Sort.Direction.DESC)
Pageable pageable) {
Utility.validatePageable(pageable, "createdAt", "id");
Page<PostModel> posts = searchService.searchPosts(query, pageable);
return new ResponseEntity<>(posts, HttpStatus.OK);
}

@GetMapping(SearchV1Constants.USERS)
@Counted(value = "moo.search.users", description = "Number of user search requests")
public ResponseEntity<Page<UserModel>> searchUsers(
@RequestParam("q") String query,
@PageableDefault(
size = 20,
sort = {"createdAt", "id"},
direction = Sort.Direction.DESC)
Pageable pageable) {
Utility.validatePageable(pageable, "createdAt", "id", "followerCount", "name", "username");
Page<UserModel> users = searchService.searchUsers(query, pageable);
return new ResponseEntity<>(users, HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
Expand Down Expand Up @@ -82,7 +83,8 @@ public ResponseEntity<ErrorResponse> validationExceptionHandler(
@ExceptionHandler({
HttpMessageNotReadableException.class,
MethodArgumentTypeMismatchException.class,
ConstraintViolationException.class
ConstraintViolationException.class,
MissingServletRequestParameterException.class
})
public ResponseEntity<ErrorResponse> malformedRequestExceptionHandler(WebRequest request) {
return errorResponse(HttpStatus.BAD_REQUEST, "Malformed request", request);
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/xyz/subho/clone/twitter/model/SearchResultModel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Twitter Backend - Moo: Twitter Clone Application Backend by Scaler
* Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package xyz.subho.clone.twitter.model;

import org.springframework.data.domain.Page;

public record SearchResultModel(Page<UserModel> users, Page<PostModel> posts) {}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import jakarta.persistence.LockModeType;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -44,4 +45,9 @@ public interface PostsRepository extends JpaRepository<Posts, UUID> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Posts p where p.id = :id")
Optional<Posts> findByIdForUpdate(@Param("id") UUID id);

@Query(
"select p from Posts p where "
+ "(p.text is not null and lower(p.text) like lower(concat('%', :query, '%')) escape '\\')")
Page<Posts> searchPosts(@Param("query") @NonNull String query, @NonNull Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
import jakarta.persistence.LockModeType;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -38,4 +41,11 @@ public interface UsersRepository extends JpaRepository<Users, UUID> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from Users u where u.id = :id")
Optional<Users> findByIdForUpdate(@Param("id") UUID id);

@Query(
"select u from Users u where "
+ "lower(u.username) like lower(concat('%', :query, '%')) escape '\\' or "
+ "lower(u.name) like lower(concat('%', :query, '%')) escape '\\' or "
+ "(u.bio is not null and lower(u.bio) like lower(concat('%', :query, '%')) escape '\\')")
Page<Users> searchUsers(@Param("query") @NonNull String query, @NonNull Pageable pageable);
}
35 changes: 35 additions & 0 deletions src/main/java/xyz/subho/clone/twitter/service/SearchService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Twitter Backend - Moo: Twitter Clone Application Backend by Scaler
* Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package xyz.subho.clone.twitter.service;

import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import xyz.subho.clone.twitter.model.PostModel;
import xyz.subho.clone.twitter.model.SearchResultModel;
import xyz.subho.clone.twitter.model.UserModel;

public interface SearchService {

public @NonNull Page<PostModel> searchPosts(@NonNull String query, @NonNull Pageable pageable);

public @NonNull Page<UserModel> searchUsers(@NonNull String query, @NonNull Pageable pageable);

public @NonNull SearchResultModel searchAll(@NonNull String query, @NonNull Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Twitter Backend - Moo: Twitter Clone Application Backend by Scaler
* Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package xyz.subho.clone.twitter.service.impl;

import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import xyz.subho.clone.twitter.exception.BadRequestException;
import xyz.subho.clone.twitter.model.PostModel;
import xyz.subho.clone.twitter.model.SearchResultModel;
import xyz.subho.clone.twitter.model.UserModel;
import xyz.subho.clone.twitter.repository.PostsRepository;
import xyz.subho.clone.twitter.repository.UsersRepository;
import xyz.subho.clone.twitter.service.SearchService;
import xyz.subho.clone.twitter.utility.PostMapper;
import xyz.subho.clone.twitter.utility.UserMapper;

@Service
public class SearchServiceImpl implements SearchService {

private static final int MAX_QUERY_LENGTH = 100;

private final PostsRepository postsRepository;
private final UsersRepository usersRepository;
private final PostMapper postMapper;
private final UserMapper userMapper;

public SearchServiceImpl(
PostsRepository postsRepository,
UsersRepository usersRepository,
PostMapper postMapper,
UserMapper userMapper) {
this.postsRepository = postsRepository;
this.usersRepository = usersRepository;
this.postMapper = postMapper;
this.userMapper = userMapper;
}

@Override
@Transactional(readOnly = true)
public @NonNull Page<PostModel> searchPosts(@NonNull String query, @NonNull Pageable pageable) {
String sanitizedQuery = validateAndSanitizeQuery(query);
var postsPage = postsRepository.searchPosts(sanitizedQuery, pageable);
return postsPage.map(postMapper::toModel);
}

@Override
@Transactional(readOnly = true)
public @NonNull Page<UserModel> searchUsers(@NonNull String query, @NonNull Pageable pageable) {
String rawQuery = query != null ? query.trim() : "";
if (rawQuery.startsWith("@") && rawQuery.length() > 1) {
rawQuery = rawQuery.substring(1);
}
String sanitizedQuery = validateAndSanitizeQuery(rawQuery);
var usersPage = usersRepository.searchUsers(sanitizedQuery, pageable);
return usersPage.map(userMapper::toModel);
}

@Override
@Transactional(readOnly = true)
public @NonNull SearchResultModel searchAll(@NonNull String query, @NonNull Pageable pageable) {
Page<UserModel> users = searchUsers(query, pageable);
Page<PostModel> posts = searchPosts(query, pageable);
return new SearchResultModel(users, posts);
}

private String validateAndSanitizeQuery(String query) {
if (query == null) {
throw new BadRequestException("Search query must not be blank");
}
String trimmed = query.trim();
if (trimmed.isEmpty()) {
throw new BadRequestException("Search query must not be blank");
}
if (trimmed.length() > MAX_QUERY_LENGTH) {
throw new BadRequestException(
"Search query must not exceed " + MAX_QUERY_LENGTH + " characters");
}
return escapeLikeWildcards(trimmed);
}

private String escapeLikeWildcards(String input) {
return input.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
8 changes: 8 additions & 0 deletions src/test/java/xyz/subho/clone/twitter/SecurityMvcTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.test.web.servlet.MockMvc;
import xyz.subho.clone.twitter.constant.AuthV1Constants;
import xyz.subho.clone.twitter.constant.PostV1Constants;
import xyz.subho.clone.twitter.constant.SearchV1Constants;
import xyz.subho.clone.twitter.constant.UserV1Constants;

@SpringBootTest
Expand Down Expand Up @@ -62,6 +63,13 @@ void malformedBearerTokensDoNotBecomeServerErrors() throws Exception {
.andExpect(status().isUnauthorized());
}

@Test
void searchEndpointsRequireAuthentication() throws Exception {
mockMvc
.perform(get(SearchV1Constants.BASE_PATH + SearchV1Constants.POSTS).param("q", "hello"))
.andExpect(status().isUnauthorized());
}

@Test
void openApiDocumentIsPublicAndAvailable() throws Exception {
mockMvc
Expand Down
Loading
Loading