Complete tutorial of building Spring REST API with Elastic Search.

Configuration
Java Compilation:
Java Runtime:
Web Server:
Elastic Search:
Maven:
Java Compilation:
Java Runtime:
Web Server:
Elastic Search:
Gradle:
JDK 17.0.2
JRE OpenJDK 17.0.2
Tomcat 10.1.16
Elastic Search 8.14.0
Maven 3.9.6
JDK 17.0.2
JRE OpenJDK 17.0.2
Tomcat 10.1.16
Elastic Search 8.14.0
Gradle 8.6
Elastic Search allows adding an document to an index without first defining the schema, since it will create the schema on the fly according to the documents added. However, it is a better practice to define the schema of the index so that we would be assured what data the index contains
To define an index schema, which is called a mapping in Elastic Search, you could just run a curl command or use postman. The following curl command is to create an index customer_index with schema defined: (replace <ES_SERVER> with actual Elastic Search location)
curl --request PUT 'http://<ES_SERVER>:9200/customer_index' --header 'Content-Type: application/json' --data-raw '{ "mappings": { "properties": { "name": { "type": "nested", "properties": { "first_name": { "type": "text" }, "last_name": { "type": "text" } } }, "age": { "enabled": false }, "phone_number": { "type": "keyword" }, "created": { "type": "date" } } } }'
Create the following folder structure:
folder structure of Spring REST API with Elastic Search
pom.xml: (if using Gradle, replace pom.xml with build.gradle)
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>net.maxjava</groupId> <artifactId>SpringRESTAPIElasticSearch</artifactId> <version>1.0</version> <packaging>war</packaging> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>6.1.6</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.4</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <source>17</source> <target>17</target> </configuration> </plugin> </plugins> </build> </project>
plugins { id 'java' id 'war' } java { sourceCompatibility = 17 targetCompatibility = 17 } group 'net.maxjava' version '1.0' repositories { mavenCentral() } dependencies { implementation group: 'org.springframework', name: 'spring-webmvc', version: '6.1.6' implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.15.4' compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30' annotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.30' }
Some notes about pom.xml:
  • spring-webmvc for necessary Spring REST controllers and its dependencies
  • jackson-databind is for the java object serialization and deserialization
  • lombok to remove boilerplate code like get and set
  • maven-compiler-plugin is for compiling the code using source version 17 and target version 17
  • A war artifact will be created for deployment to servlet containers
Some notes about build.gradle:
  • spring-webmvc for necessary Spring REST controllers and its dependencies
  • jackson-databind is for the java object serialization and deserialization
  • lombok to remove boilerplate code like get and set
  • sourceCompatibility and targetCompatibility is for compiling the code using source version 17 and target version 17
  • A war artifact will be created for deployment to servlet containers
web.xml:
<web-app> <display-name>Spring REST API with Elastic Search</display-name> <servlet> <servlet-name>SpringRESTAPIElasticSearch</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>/WEB-INF/myApplicationContext.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>SpringRESTAPIElasticSearch</servlet-name> <url-pattern>/*</url-pattern> </servlet-mapping> </web-app>
Some notes about web.xml:
  • org.springframework.web.servlet.DispatcherServlet is the servlet class for Spring
  • /WEB-INF/myApplicationContext.xml is the location of the Spring context configuration file
myApplicationContext.xml:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd"> <context:component-scan base-package="controller,service"/> <mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"/> </mvc:message-converters> </mvc:annotation-driven> </beans>
Some notes about myApplicationContext.xml:
  • <context:component-scan base-package="controller,service"/> is for Spring to look for classes in specific packages to create beans
  • <mvc:message-converters> is to specify library for json serialization and deserialization
MyController.java:
package controller; import es.ESCustomer; import es.ESCustomerUpdateRequest; import es.ESSearchCustomerRequest; import es.ESSearchCustomerResponse; import model.Customer; import model.WebSearchCustomerResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import service.ElasticSearchService; import java.text.SimpleDateFormat; import java.util.*; @RestController @RequestMapping("/myapp") public class MyController { @Autowired private ElasticSearchService elasticSearchService; @PostMapping("/customer") public String addCustomer(@RequestBody Customer customer) { String response = ""; ESCustomer esCustomer = new ESCustomer(); ESCustomer.Name esName = new ESCustomer.Name(); esName.setFirstName(customer.getName().getFirstName()); esName.setLastName(customer.getName().getLastName()); esCustomer.setName(esName); esCustomer.setAge(customer.getAge()); esCustomer.setCreated(new Date()); esCustomer.setPhoneNumber(customer.getPhoneNumber()); try { response = elasticSearchService.addCustomer(esCustomer); } catch (Exception e) { response = e.getMessage(); } return response; } @PutMapping("/customer") public String updateCustomer(@RequestBody Customer customer) { String response = ""; ESCustomerUpdateRequest esCustomerUpdateRequest = new ESCustomerUpdateRequest(); ESCustomer esCustomer = new ESCustomer(); if (customer.getName() != null) { ESCustomer.Name esName = new ESCustomer.Name(); esName.setFirstName(customer.getName().getFirstName()); esName.setLastName(customer.getName().getLastName()); esCustomer.setName(esName); } esCustomer.setAge(customer.getAge()); esCustomer.setPhoneNumber(customer.getPhoneNumber()); esCustomerUpdateRequest.setDoc(esCustomer); try { response = elasticSearchService.updateCustomer(esCustomerUpdateRequest, customer.getId()); } catch (Exception e) { response = e.getMessage(); } return response; } @DeleteMapping("/customer") public String deleteCustomer(@RequestParam(name = "_id") String id) { String response = ""; try { response = elasticSearchService.deleteCustomer(id); } catch (Exception e) { response = e.getMessage(); } return response; } @GetMapping("/searchByCreatedDate") public WebSearchCustomerResponse SearchByCreatedDate(@RequestParam(name = "fromTime") String fromTime, @RequestParam(name = "toTime") String toTime) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); WebSearchCustomerResponse webSearchCustomerResponse = new WebSearchCustomerResponse(); ESSearchCustomerRequest.Query query = new ESSearchCustomerRequest.Query(); Map<String, String> rangeFields = new HashMap<>(); rangeFields.put("gte", fromTime); rangeFields.put("lte", toTime); Map<String, Map<String, String>> createdRange = new HashMap<>(); createdRange.put("created", rangeFields); query.setRange(createdRange); ESSearchCustomerRequest esSearchCustomerRequest = new ESSearchCustomerRequest(); esSearchCustomerRequest.setQuery(query); try { ESSearchCustomerResponse esSearchCustomerResponse = elasticSearchService.searchCustomer(esSearchCustomerRequest); List<Customer> customers = new ArrayList<>(); for (ESSearchCustomerResponse.Hits.CustomerHits customerHits : esSearchCustomerResponse.getHits().getHits()) { Customer customer = new Customer(); customer.setId(customerHits.getId()); customer.setAge(customerHits.getCustomer().getAge()); customer.setPhoneNumber(customerHits.getCustomer().getPhoneNumber()); customer.setCreated(simpleDateFormat.format(customerHits.getCustomer().getCreated())); Customer.Name name = new Customer.Name(); name.setFirstName(customerHits.getCustomer().getName().getFirstName()); name.setLastName(customerHits.getCustomer().getName().getLastName()); customer.setName(name); customers.add(customer); } webSearchCustomerResponse.setCustomers(customers); } catch (Exception e) { webSearchCustomerResponse.setErrorMessage(e.getMessage()); } return webSearchCustomerResponse; } @GetMapping("/searchByLastName") public WebSearchCustomerResponse SearchByLastName(@RequestParam(name = "lastName") String lastName) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); WebSearchCustomerResponse webSearchCustomerResponse = new WebSearchCustomerResponse(); ESSearchCustomerRequest.Query query = new ESSearchCustomerRequest.Query(); ESSearchCustomerRequest.Query.Nested nested = new ESSearchCustomerRequest.Query.Nested(); Map<String, String> matchFields = new HashMap<>(); matchFields.put("name.last_name", lastName); ESSearchCustomerRequest.Query nestedQuery = new ESSearchCustomerRequest.Query(); ESSearchCustomerRequest.Query.Match match = new ESSearchCustomerRequest.Query.Match(); match.setMatch(matchFields); nestedQuery.setMatch(match); nested.setPath("name"); nested.setQuery(nestedQuery); query.setNested(nested); ESSearchCustomerRequest esSearchCustomerRequest = new ESSearchCustomerRequest(); esSearchCustomerRequest.setQuery(query); try { ESSearchCustomerResponse esSearchCustomerResponse = elasticSearchService.searchCustomer(esSearchCustomerRequest); List<Customer> customers = new ArrayList<>(); for (ESSearchCustomerResponse.Hits.CustomerHits customerHits : esSearchCustomerResponse.getHits().getHits()) { Customer customer = new Customer(); customer.setId(customerHits.getId()); customer.setAge(customerHits.getCustomer().getAge()); customer.setPhoneNumber(customerHits.getCustomer().getPhoneNumber()); customer.setCreated(simpleDateFormat.format(customerHits.getCustomer().getCreated())); Customer.Name name = new Customer.Name(); name.setFirstName(customerHits.getCustomer().getName().getFirstName()); name.setLastName(customerHits.getCustomer().getName().getLastName()); customer.setName(name); customers.add(customer); } webSearchCustomerResponse.setCustomers(customers); } catch (Exception e) { webSearchCustomerResponse.setErrorMessage(e.getMessage()); } return webSearchCustomerResponse; } }
Some notes about MyController.java:
  • This is Spring REST API which putting @RestController will do the trick (and make sure the package of this class got scanned in bean creation)
  • Autowires ElasticSearchService, which provides interactions with Elastic Search
  • Provides APIs to add a customer, update a customer, delete a customer and most importantly, search the customer by date created or last name
Customer.java:
package model; import lombok.Data; @Data public class Customer { private Name name; private String phoneNumber; private Short age; private String id; private String created; @Data static public class Name { private String lastName; private String firstName; } }
Some notes about Customer.java:
  • This is the object used in the request body and response body of the REST APIs
WebSearchCustomerResponse.java:
package model.web; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; import java.util.List; @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class WebSearchCustomerResponse { String errorMessage; List<Customer> customers; }
Some notes about WebSearchCustomerResponse.java:
  • This is the response body of the searching REST APIs
ElasticSearchService.java:
package service; import com.fasterxml.jackson.databind.ObjectMapper; import es.*; import org.springframework.stereotype.Service; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @Service public class ElasticSearchService { final String INDEX_URL = "http://localhost:9200/customer_index"; ObjectMapper objectMapper = new ObjectMapper(); HttpClient httpClient = HttpClient.newBuilder().build(); public String updateCustomer(ESCustomerUpdateRequest esCustomerUpdateRequest, String id) throws Exception { String response = ""; String customerJson = objectMapper.writeValueAsString(esCustomerUpdateRequest); HttpRequest httpRequest = HttpRequest.newBuilder() .uri(URI.create(INDEX_URL + "/_update/" + id)) .POST(HttpRequest.BodyPublishers.ofString(customerJson)) .header("Content-Type", "application/json") .build(); HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); response = httpResponse.body(); ESResponse esResponse = objectMapper.readValue(response, ESResponse.class); return esResponse.getId(); } public String addCustomer(ESCustomer customer) throws Exception { String response = ""; String customerJson = objectMapper.writeValueAsString(customer); HttpRequest httpRequest = HttpRequest.newBuilder() .uri(URI.create(INDEX_URL + "/_doc")) .POST(HttpRequest.BodyPublishers.ofString(customerJson)) .header("Content-Type", "application/json") .build(); HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); response = httpResponse.body(); ESResponse esResponse = objectMapper.readValue(response, ESResponse.class); return esResponse.getId(); } public String deleteCustomer(String id) throws Exception { String response = ""; HttpRequest httpRequest = HttpRequest.newBuilder() .uri(URI.create(INDEX_URL + "/_doc/" + id)) .DELETE() .header("Content-Type", "application/json") .build(); HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); response = httpResponse.body(); ESResponse esResponse = objectMapper.readValue(response, ESResponse.class); return esResponse.getResult(); } public ESSearchCustomerResponse searchCustomer(ESSearchCustomerRequest esSearchCustomerRequest) throws Exception { String response = ""; String customerJson = objectMapper.writeValueAsString(esSearchCustomerRequest); HttpRequest httpRequest = HttpRequest.newBuilder() .uri(URI.create(INDEX_URL + "/_search")) .POST(HttpRequest.BodyPublishers.ofString(customerJson)) .header("Content-Type", "application/json") .build(); HttpResponse<String> httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); response = httpResponse.body(); ESSearchCustomerResponse esSearchCustomerResponse = objectMapper.readValue(response, ESSearchCustomerResponse.class); return esSearchCustomerResponse; } }
Some notes about ElasticSearchService.java:
  • Assuming Elastic Search is installed at localhost with port 9200, otherwise please update the INDEX_URL value
  • Built-in Java HttpClient is used to send HTTP request to Elastic Search server
  • In addCustomer, a new Customer document will be sent to Elastic Search and the _id of the document will be returned
  • In updateCustomer, an existing Customer document will be updated by using document _id
  • In deleteCustomer, an existing Customer document will be deleted by using document _id
  • In searchCustomer, the search query passed from esSearchCustomerRequest will be sent to Elastic Search, and a list of matched documents will be returned
ESCustomer.java:
package es; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.util.Date; @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ESCustomer { private Name name; @JsonProperty("phone_number") private String phoneNumber; private Date created; private Short age; @Data @JsonInclude(JsonInclude.Include.NON_NULL) static public class Name { @JsonProperty("last_name") private String lastName; @JsonProperty("first_name") private String firstName; } }
Some notes about ESCustomer.java:
  • This is the Customer document of Elastic Search
  • Uses @JsonInclude(JsonInclude.Include.NON_NULL) to exclude the field which is null during serialization
ESResponse.java:
package es; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) public class ESResponse { @JsonProperty("_id") private String id; private String result; }
Some notes about ESResponse.java:
  • This is the response from Elastic Search for non-searching operations
ESCustomerUpdateRequest.java:
package es; import lombok.Data; @Data public class ESCustomerUpdateRequest { ESCustomer doc; }
Some notes about ESCustomerUpdateRequest.java:
  • This is the request for updating field(s) in Elastic Search
ESSearchCustomerRequest.java:
package es; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; import java.util.Map; @Data public class ESSearchCustomerRequest { private Query query; @Data @JsonInclude(JsonInclude.Include.NON_NULL) public static class Query { @Data public static class Nested { private String path; private Query query; } private Nested nested; private Match match; private Map<String, Map<String, String>> range; @Data public static class Match { private Map<String, String> match; @JsonAnyGetter public Map<String, String> getMatch() { return match; } } } }
Some notes about ESSearchCustomerRequest.java:
  • This is the request body for searching in Elastic Search
ESSearchCustomerResponse.java:
package es; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.util.List; @Data @JsonIgnoreProperties(ignoreUnknown = true) public class ESSearchCustomerResponse { private Hits hits; @Data @JsonIgnoreProperties(ignoreUnknown = true) static public class Hits { List<CustomerHits> hits; @Data @JsonIgnoreProperties(ignoreUnknown = true) static public class CustomerHits { @JsonProperty("_id") String id; @JsonProperty("_source") ESCustomer customer; } } }
Some notes about ESSearchCustomerResponse.java:
  • This is the response body for searching in Elastic Search
Sample requests and responses(assume the war file is deployed to <CONTEXT_PATH>):
  • Add a new Customer:
    curl --request POST '<CONTEXT_PATH>/myapp/customer' --header 'Content-Type: application/json' --data-raw '{ "name": { "firstName": "Ethan", "lastName": "Smith" }, "age": 55, "phoneNumber": "650-123-1234" }'
    Response:
    "Epr_E4EBoTQRmWa4YxNm"

  • Searching by last name
    curl --request GET '<CONTEXT_PATH>/myapp/searchByLastName?lastName=Smith'
    Response:
    { "customers": [ { "name": { "lastName": "Smith", "firstName": "Ethan" }, "phoneNumber": "650-123-1234", "age": 55, "id": "Epr_E4EBoTQRmWa4YxNm", "created": "2024-05-30T01:05:13" } ] }
  • Updating the Customer's phone number and age only by id:
    curl --request PUT '<CONTEXT_PATH>/myapp/customer' --header 'Content-Type: application/json' --data-raw '{ "age": 56, "id": "Epr_E4EBoTQRmWa4YxNm", "phoneNumber": "650-123-1235" }'
    Response:
    "Epr_E4EBoTQRmWa4YxNm"
  • Search customer by name again, the Customer is changed
    curl --request GET '<CONTEXT_PATH>/myapp/searchByLastName?lastName=Smith'
    Response:
    { "customers": [ { "name": { "lastName": "Smith", "firstName": "Ethan" }, "phoneNumber": "650-123-1235", "age": 56, "id": "Epr_E4EBoTQRmWa4YxNm", "created": "2024-05-30T01:05:13" } ] }
  • Add another Customer:
    curl --request POST '<CONTEXT_PATH>/myapp/customer' --header 'Content-Type: application/json' --data-raw '{ "name": { "firstName": "Tom", "lastName": "Smith" }, "age": 20, "phoneNumber": "310-321-4321" }'
    Response:
    "E5oWFIEBoTQRmWa4GxPa"
  • Search customer by name again, 2 Customers will be returned:
    curl --request GET '<CONTEXT_PATH>/myapp/searchByLastName?lastName=Smith'
    Response:
    { "customers": [ { "name": { "lastName": "Smith", "firstName": "Ethan" }, "phoneNumber": "650-123-1235", "age": 56, "id": "Epr_E4EBoTQRmWa4YxNm", "created": "2024-05-30T01:16:30" }, { "name": { "lastName": "Smith", "firstName": "Tom" }, "phoneNumber": "310-321-4321", "age": 20, "id": "E5oWFIEBoTQRmWa4GxPa", "created": "2024-05-30T01:30:02" } ] }
  • Search customer by creation time:
    curl --request GET '<CONTEXT_PATH>/myapp/searchByCreatedDate?fromTime=2024-05-30T01:10:30-07:00&toTime=2024-05-30T01:20:30-07:00'
    Response:
    { "customers": [ { "name": { "lastName": "Smith", "firstName": "Ethan" }, "phoneNumber": "650-123-1235", "age": 56, "id": "Epr_E4EBoTQRmWa4YxNm", "created": "2024-05-30T01:16:30" } ] }
    Note that when the record is created the server timezone is used for the "created" field, so when passing the searching parameter the same timezone has to be used (here is -07:00)
  • deleting a document by id:
    curl --request DELETE '<CONTEXT_PATH>/myapp/customer?id=Epr_E4EBoTQRmWa4YxNm'
    Response:
    "deleted"