Skip to content

8 Geo based Search Implementation

8 Geo-based Search Implementation

Goal

  • Implement the geo-based search service.

Screenshot 2024-07-21 at 16.40.02

Search Implementation based on Elasticsearch

Model Creation

  1. Open your project in Intellij and find the pom.xml file. Add the following dependency.
     <dependencies>

  <!-- ... existing dependencies -->

  <!-- Only insert the following, do not change or touch other lines -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    </dependency>
  <!-- Only insert the above, do not change or touch other lines -->
</dependencies>

Screenshot 2024-07-21 at 17.12.28

  1. Go to the com.eve.staybooking.model package, create a new class called Location.
package com.eve.staybooking.model;

public class Location {
}
  1. Add the id and geopoint as the private field and the corresponding getters/setters to the Location class.
package com.eve.staybooking.model;

import org.springframework.data.elasticsearch.core.geo.GeoPoint;

public class Location {
    private Long id;
    private GeoPoint geoPoint;

    public Location(Long id, GeoPoint geoPoint){
        this.id = id;
        this.geoPoint = geoPoint;
    }

    public Long getId(){
        return id;
    }

    public GeoPoint getGeoPoint(){
        return geoPoint;
    }
}
  1. Add the Elasticsearch related annotations so that we can create the mapping between the Location class and an Elasticsearch document.
package com.eve.staybooking.model;

import jakarta.persistence.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.annotations.GeoPointField;
import org.springframework.data.elasticsearch.core.geo.GeoPoint;
// take care about the import
import java.io.Serializable;

@Document(indexName = "loc")
public class Location implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id
    @Field(type = FieldType.Long)
    private Long id;

    @GeoPointField
    private GeoPoint geoPoint;

    public Location(Long id, GeoPoint geoPoint){
        this.id = id;
        this.geoPoint = geoPoint;
    }

    public Long getId(){
        return id;
    }

    public GeoPoint getGeoPoint(){
        return geoPoint;
    }
}

Repository Update

  1. Create an interface named LocationRepository under the com.eve.staybooking.repository package.

As you can see, the LocationRepository extends ElasticsearchRepository instead of JpaRepository since Elastcisearch has a different query implementation than MySQL. But similar to JpaRepository, LocationRepository also provides some basic query functions like find(), save() and delete(). But since our service needs to support search based on Geolocation, we need to implement the search function ourselves.

package com.eve.staybooking.repository;

import com.eve.staybooking.model.Location;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface LocationRepository extends ElasticsearchRepository<Location, Long> {
}

Screenshot 2024-07-21 at 17.23.35

  1. Create another CustomLocationRepository interface next to the LocationRepository interface, and add a method called CustomLocationRepository().
package com.eve.staybooking.repository;

import java.util.List;

public interface CustomLocationRepository {
    List<Long> searchByDistance(double lat, double lon, String distance);

}

Screenshot 2024-07-27 at 17.45.18

  1. Make the LocationRepository interface extend the CustomLocationRepository interface.
package com.eve.staybooking.repository;

import com.eve.staybooking.model.Location;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface LocationRepository extends ElasticsearchRepository<Location, Long>, CustomLocationRepository {
}
  1. Create a CustomLocationRepositoryImpl.class.
package com.eve.staybooking.repository;

public class CustomLocationRepositoryImpl {
}
  1. Implement the searchByDistance() method in the CustomLocationRepositoryImpl.class.
package com.eve.staybooking.repository;

import com.eve.staybooking.model.Location;
import org.elasticsearch.common.unit.DistanceUnit;
import org.elasticsearch.index.query.GeoDistanceQueryBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;

import java.util.ArrayList;
import java.util.List;

public class CustomLocationRepositoryImpl implements CustomLocationRepository {
    private final String DEFAULT_DISTANCE = "50";
    private ElasticsearchOperations elasticsearchOperations;

    @Autowired
    public CustomLocationRepositoryImpl(ElasticsearchOperations elasticsearchOperations) {
        this.elasticsearchOperations = elasticsearchOperations;
    }

    @Override
    public List<Long> searchByDistance(double lat, double lon, String distance) {
        if (distance == null || distance.isEmpty()) {
            distance = DEFAULT_DISTANCE;
        }
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
        queryBuilder.withFilter(new GeoDistanceQueryBuilder("geoPoint").point(lat, lon).distance(distance, DistanceUnit.KILOMETERS));

        SearchHits<Location> searchResult = elasticsearchOperations.search(queryBuilder.build(), Location.class);
        List<Long> locationIDs = new ArrayList<>();
        for (SearchHit<Location> hit : searchResult.getSearchHits()) {
            locationIDs.add(hit.getContent().getId());
        }
        return locationIDs;
    }
}
  1. Under the same com.eve.staybooking.repository package, create a new interface called StayReservationDateRepository.
package com.eve.staybooking.repository;

import com.eve.staybooking.model.StayReservedDate;
import com.eve.staybooking.model.StayReservedDateKey;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface StayReservationDateRepository extends JpaRepository<StayReservedDate, StayReservedDateKey> {

}
  1. Add a method named findByIdInAndDateBetween() so that we can search results only contain stays that are reserved between check-in date and checkout date.
package com.eve.staybooking.repository;

import com.eve.staybooking.model.StayReservedDate;
import com.eve.staybooking.model.StayReservedDateKey;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.List;
import java.util.Set;

@Repository
public interface StayReservationDateRepository extends JpaRepository<StayReservedDate, StayReservedDateKey> {
    Set<Long> findByIdInAndDateBetween(List<Long> stayIds, LocalDate startDate, LocalDate endDate);

}
  1. Obviously, the JpaRepository cannot support the custom findByIdInAndDateBetween() method, so we need to provide the implementation by ourselves. We can use the same solution as LocationRepository to create an implementation class, or in this case, just write the SQL query on top of the method.
package com.eve.staybooking.repository;

import com.eve.staybooking.model.StayReservedDate;
import com.eve.staybooking.model.StayReservedDateKey;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.List;
import java.util.Set;

@Repository
public interface StayReservationDateRepository extends JpaRepository<StayReservedDate, StayReservedDateKey> {
    @Query(value = "SELECT srd.id.stay_id FROM StayReservedDate srd WHERE srd.id.stay_id IN ?1 AND srd.id.date BETWEEN ?2 AND ?3 GROUP BY srd.id.stay_id")
    Set<Long> findByIdInAndDateBetween(List<Long> stayIds, LocalDate startDate, LocalDate endDate);

}
  1. Go to the StayRepository interface and add a new method called findByIdInAndGuestNumberGreaterThanEqual(). So besides location, the guest number is another parameter for search. Can you think of some other search parameters we can support?
package com.eve.staybooking.repository;

import com.eve.staybooking.model.Stay;
import com.eve.staybooking.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface StayRepository extends JpaRepository<Stay, Long> {
    List<Stay> findByHost(User user);

    Stay findByIdAndHost(Long id, User host);

    List<Stay> findByIdInAndGuestNumberGreaterThanEqual(List<Long> ids, int guestNumber);

}

Implement Search Service

  1. Go to com.eve.staybooking.service package and create the SearchService class.
package com.eve.staybooking.service;

import org.springframework.stereotype.Service;

@Service
public class SearchService {

}
  1. Add StayRepository, StayReservationDateRepository and LocationRepository as the private field and create a constructor for initialization.
package com.eve.staybooking.service;

import com.eve.staybooking.repository.LocationRepository;
import com.eve.staybooking.repository.StayRepository;
import com.eve.staybooking.repository.StayReservationDateRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class SearchService {
    private StayRepository stayRepository;
    private StayReservationDateRepository stayReservationDateRepository;
    private LocationRepository locationRepository;

    @Autowired
    public SearchService(StayRepository stayRepository, StayReservationDateRepository stayReservationDateRepository, LocationRepository locationRepository) {
        this.stayRepository = stayRepository;
        this.stayReservationDateRepository = stayReservationDateRepository;
        this.locationRepository = locationRepository;
    }

}
  1. Add the search method to use the repositories we just created
package com.eve.staybooking.service;

import com.eve.staybooking.model.Stay;
import com.eve.staybooking.repository.LocationRepository;
import com.eve.staybooking.repository.StayRepository;
import com.eve.staybooking.repository.StayReservationDateRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

@Service
public class SearchService {
    private StayRepository stayRepository;
    private StayReservationDateRepository stayReservationDateRepository;
    private LocationRepository locationRepository;

    @Autowired
    public SearchService(StayRepository stayRepository, StayReservationDateRepository stayReservationDateRepository, LocationRepository locationRepository) {
        this.stayRepository = stayRepository;
        this.stayReservationDateRepository = stayReservationDateRepository;
        this.locationRepository = locationRepository;
    }
    public List<Stay> search(int guestNumber, LocalDate checkinDate, LocalDate checkoutDate, double lat, double lon, String distance) {

        List<Long> stayIds = locationRepository.searchByDistance(lat, lon, distance);
        if (stayIds == null || stayIds.isEmpty()) {
            return new ArrayList<>();
        }

        Set<Long> reservedStayIds = stayReservationDateRepository.findByIdInAndDateBetween(stayIds, checkinDate, checkoutDate.minusDays(1));

        List<Long> filteredStayIds = new ArrayList<>();
        for (Long stayId : stayIds) {
            if (!reservedStayIds.contains(stayId)) {
                filteredStayIds.add(stayId);
            }
        }
        return stayRepository.findByIdInAndGuestNumberGreaterThanEqual(filteredStayIds, guestNumber);
    }
}

Add Search Controller

  1. Go to the com.eve.staybooking.config package and create a new class named ElasticsearchConfig.
package com.eve.staybooking.config;

import org.springframework.context.annotation.Configuration;

@Configuration
public class ElasticsearchConfig {
}
  1. Add the elasticsearchClient() method to create a Elasticsearch client bean.
package com.eve.staybooking.config;

import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.client.ClientConfiguration;
import org.springframework.data.elasticsearch.client.RestClients;

@Configuration
public class ElasticsearchConfig {
    @Value("${elasticsearch.address}")
    private String elasticsearchAddress;

    @Value("${elasticsearch.username}")
    private String elasticsearchUsername;

    @Value("${elasticsearch.password}")
    private String elasticsearchPassword;

    @Bean
    public RestHighLevelClient elasticsearchClient() {
        ClientConfiguration clientConfiguration
                = ClientConfiguration.builder()
                .connectedTo(elasticsearchAddress)
                .withBasicAuth(elasticsearchUsername, elasticsearchPassword)
                .build();

        return RestClients.create(clientConfiguration).rest();
    }
}
  1. Go to com.eve.staybooking.exception package and create a new exception InvalidSearchDateException.
package com.eve.staybooking.exception;


public class InvalidSearchDateException extends RuntimeException {
    public InvalidSearchDateException(String message) {
        super(message);
    }
}
  1. Update the CustomExceptionHandler to handle InvalidSearchDateException.
package com.eve.staybooking.controller;

import com.eve.staybooking.exception.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;

@ControllerAdvice
public class CustomExceptionHandler {
    @ExceptionHandler(UserAlreadyExistException.class)
    public final ResponseEntity<String> handleUserAlreadyExistExceptions(Exception ex, WebRequest request){
        return new ResponseEntity<>(ex.getMessage(), HttpStatus.CONFLICT);
    }

    @ExceptionHandler(UserNotExistException.class)
    public final ResponseEntity<String> handleUserNotExistExceptions(Exception ex, WebRequest request){
        return new ResponseEntity<>(ex.getMessage(), HttpStatus.UNAUTHORIZED);
    }

    @ExceptionHandler(StayNotExistException.class)
    public final ResponseEntity<String> handleStayNotExistExceptions(Exception ex, WebRequest request) {
        return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(GCSUploadException.class)
    public final ResponseEntity<String> handleGCSUploadExceptions(Exception ex, WebRequest request) {
        return new ResponseEntity<>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(InvalidSearchDateException.class)
    public final ResponseEntity<String> handleInvalidSearchDateExceptions(Exception ex, WebRequest request) {
        return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST);
    }




}
  1. Go to com.eve.staybooking.controller package and create the SearchController clas
package com.eve.staybooking.controller;

import org.springframework.web.bind.annotation.RestController;

@RestController
public class SearchController {

}
  1. Implement the searchStays method
package com.eve.staybooking.controller;

import com.eve.staybooking.exception.InvalidSearchDateException;
import com.eve.staybooking.model.Stay;
import com.eve.staybooking.service.SearchService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;

@RestController
public class SearchController {
    private SearchService searchService;

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

    @GetMapping(value = "/search")
    public List<Stay> searchStays(
            @RequestParam(name = "guest_number") int guestNumber,
            @RequestParam(name = "checkin_date") String start,
            @RequestParam(name = "checkout_date") String end,
            @RequestParam(name = "lat") double lat,
            @RequestParam(name = "lon") double lon,
            @RequestParam(name = "distance", required=false) String distance) {
        LocalDate checkinDate = LocalDate.parse(start, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        LocalDate checkoutDate = LocalDate.parse(end, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        if (checkinDate.equals(checkoutDate) || checkinDate.isAfter(checkoutDate) || checkinDate.isBefore(LocalDate.now())) {
            throw new InvalidSearchDateException("Invalid date for search");
        }
        return searchService.search(guestNumber, checkinDate, checkoutDate, lat, lon, distance);
    }
}