Server-Side Swift Beta

Learn web development with Swift, using Vapor and Kitura.

Elasticsearch in Vapor: Getting Started

Elasticsearch is a distributed search and analytics engine for all types of structured and unstructured data. In this tutorial, you’ll set up a Vapor server to interact with an Elasticsearch server, to store and retrieve recipe documents.

5/5 4 Ratings

Version

  • Swift 5, macOS 10.15, Xcode 11

Elasticsearch is a distributed search and analytics engine for all types of structured and unstructured data. It’s built on Apache Lucene, and is known for its speed and scalability. Elasticsearch provides you with a set of REST APIs to interact with data.

Developers typically use Elasticsearch when they need to deal with a huge amount of data — for example, when storing logs. You’ll use it to index data, and make that data available through a powerful search.

In this tutorial you’ll learn the basics of Elasticsearch, and how to:

  • Set it up for the development environment.
  • Perform CRUD (Create, Read, Update, Delete) operations.
  • Leverage it to search for documents.

You’ll do all this while developing a Recipe Finder Web API in Vapor.

Note: This tutorial assumes you’ve installed Docker and have some basic experience with it. See Docker on macOS: Getting Started to learn more about Docker and how to install it.

Additionally, you should have some experience using Vapor to build web apps. See Getting Started with Server-side Swift with Vapor if you’re new to Vapor.

Getting Started

Use the Download Materials button at the top or bottom of this page to download the starter project for the Recipe Finder Web API.

Opening the Vapor Project

If you don’t have Vapor Toolbox installed, follow the steps in Getting Started with Server-side Swift with Vapor to install it.

With Vapor Toolbox installed, locate starter/recipe-finder-vapor/ in Finder, and double-click Package.swift to open the project in Xcode. Wait while Xcode resolves and fetches the package dependencies — don’t stop the build!

Note: Double-clicking Package.swift only works in Xcode 11. If you’re using Xcode 10, run the Terminal command vapor xcode -y from the root directory starter/recipe-finder-vapor/.

The starter project is a Vapor server that you’ll set up to interact with an Elasticsearch server, to store and retrieve recipe documents. There’s an ElasticsearchClient.swift file in Sources/App/Services/Elasticsearch. Now you just need an Elasticsearch server.

Running Elasticsearch in Docker

To avoid version problems and to isolate Elasticsearch from your system, you’ll run Elasticsearch in a Docker container.

In a Terminal window, run this command:

docker run -d --name elasticsearch -p 9200:9200 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.2.1

This downloads the Elasticsearch image, if it wasn’t already downloaded, and runs it in a Docker container named elasticsearch. The detach option -d runs the process in the background.

Elasticsearch exposes its API over HTTP on port 9200. The publish option -p 9200:9200 tells Docker to map the container’s internal port 9200 to port 9200 of your host machine.

The environment variable discovery.type starts a single-node Elasticsearch cluster for development or testing, which bypasses bootstrap checks.

Note: This command doesn’t set up any shared volumes to persist your data. So any data stored in this Elasticsearch instance won’t exist after you stop and re-run the container.

Now check your Elasticsearch instance is running properly: In a browser window, open http://localhost:9200. You should see something like this:

Congrats! You’re now ready to play around with Elasticsearch. You’ll connect your Vapor server to the Elasticsearch server, then implement a REST API for CRUD and search operations on the Vapor server. Requests sent to the Vapor server’s 8080 port will be forwarded to the Elasticsearch server’s 9200 port.

Elasticsearch

Elasticsearch is a good choice for relevance-based searching, full-text search, synonym or phonetic search and, if you want to get good search results, even for misspelled search terms.

Indexes and Documents

Instead of storing information in rows and columns, Elasticsearch stores JSON documents in indexes. You can think of an index as a schematic collection of similar documents, while documents are the basic unit of information you can index.

Each index also has one mapping type which determines how documents are indexed. Here’s how you differentiate between the two types:

  • Dynamic mapping: This is the default type. You don’t have to define fields and mapping types beforehand. When you add a document to the index all new fields are added to the mapping type automatically.
  • Explicit mapping: You have to provide the fields and mapping types when creating the index. You can add more fields later.

Explicit mapping has the benefit of giving you control over which fields are relevant for the index and how their values should be preprocessed. This will make your life easier when doing complex searches. It also has a positive effect on storage and performance.

Major Differences Compared to Relational Databases

Unlike relational databases, Elasticsearch works best without relations. While it’s possible to have some basic relations set between documents, it’s not as powerful as what you’re used to with relational databases such as MySQL or PostgreSQL.

While in SQL, you try to normalize data as much as possible, the opposite is true in Elasticsearch. You can’t create relations and join indexes to perform queries, so you have to denormalize the data in your indexes. All of this is required to build a powerful cluster that can scale up quickly as needed.

Implementing CRUD

Now, back to the Xcode project to implement some basic CRUD operations — Create, Read, Update, Delete — so you can store and read recipes from Elasticsearch. You’ll modify files in Sources/App.

First, you need to specify your model for recipes. Open Models/Recipe.swift and add these properties to the Recipe class:

var id: String?
var name: String
var description: String
var instructions: String
var ingredients: [String]

This is a very basic model of a recipe, but it’ll do the job. id is Optional, since a Recipe object only gets one after it’s stored in Elasticsearch.

Here, ingredients is a simple array of String. In a more sophisticated version, you’d have a struct with more fields, such as amount and unit.

Next, inspect the Services/Elasticsearch classes:

Open ElasticsearchClient.swift. It’s already capable of sending requests to a URL and returning a response — Future<Response> — or a parsed object — Future<D: Decodable>.

In this file, you’ll only have to worry about the Requests section, where you’ll find method signatures prepared without implementation, except for fatalError() statements, to make the project build.

Creating a Recipe

You’ll start with storing a recipe to Elasticsearch. First, look at the createDocument(_:in:on:) method signature:

public func createDocument<Document: Encodable>(
  _ document: Document,
  in indexName: String,
  on container: Container
) throws -> Future<ESCreateDocumentResponse>

It has a generic type Document, which is expected to conform to Encodable. This lets you store any object that can be encoded.

Furthermore, the method expects the name of the index you want to store the document in. It’ll return a Future<ESCreateDocumentResponse>, which wraps the response body coming from the Elasticsearch API.

Time to start coding! Replace the fatalError() line in createDocument(_:in:on:) with this code:

// 1
let url = baseURL(path: "/\(indexName)/_doc")
// 2
var request = try HTTPRequest(method: .POST, url: url, body:
  ElasticsearchClient.jsonEncoder.encode(document))
// 3
request.contentType = .json
return try sendRequest(request, to: url, on: container)

Here’s what you did:

  1. You declare the URL, which is composed of the baseURL, the indexName, which is recipes throughout this tutorial, and _doc, which is the endpoint for dealing with single documents.
  2. Then you pass both the url and the JSON encoded object into a HTTPRequest object with method POST.
  3. Next, you set the content-type header to application/json, and send the request.

Connecting Elasticsearch to Your API

To test your implementation, you’ll have to create routes and pass everything through. You’ll implement all recipe-related routes in Controllers/RecipeFinderController.swift.

First, replace the TODO comment in the RecipeFinderController class with this code:

let elasticClient: ElasticsearchClient

init(elasticClient: ElasticsearchClient) {
  self.elasticClient = elasticClient
}

You’ve added an ElasticsearchClient property, as well as an initializer.

Now you need to tell Vapor you want to use this controller as a route collection. Open App/routes.swift, and replace the TODO comment in the routes function with this code:

let elasticClient = ElasticsearchClient(host: "localhost", 
  port: 9200)
let recipeFinderController = RecipeFinderController(elasticClient: 
  elasticClient)
try router.register(collection: recipeFinderController)

This initializes your ElasticsearchClient with the host of your Elasticsearch docker instance, and passes it to the controller. Then it adds the controller to the router as a RouteCollection.

Jump to the definition of register to see that Vapor then asks your RecipeFinderController for a list of supported routes in its boot(router:) method:

/// Groups collections of routes together for adding to a router.
public protocol RouteCollection {
    /// Registers routes to the incoming router.
    ///
    /// - parameters:
    ///     - router: `Router` to register any new routes to.
    func boot(router: Router) throws
}

extension Router {
    /// Registers all of the routes in the group to this router.
    ///
    /// - parameters:
    ///     - collection: `RouteCollection` to register.
    public func register(collection: RouteCollection) throws {
        try collection.boot(router: self)
    }
}

Next, back in RecipeFinderController.swift, add this new method below the boot(router:) method in the RouteCollection extension:

// 1
func createHandler(_ req: Request, recipe: Recipe) throws -> 
  Future<Recipe> {
  // 2
  return try elasticClient
    .createDocument(recipe, in: "recipes", on: req)
    .map { response in
      // 3
      recipe.id = response.id
      return recipe
  }
}

What does this do? Glad you asked! This is the handler for your create-recipe requests:

  1. First, it takes a request and a Recipe object, and returns a Recipe object.
  2. You use your newly created createDocument(_:in:on:) method to store recipe in Elasticsearch’s recipes index.
  3. Finally, you use the id from the response to set your Recipe object’s id property. The document is stored under this id in the recipes index.

There’s just one more step before you can test this. In the same file, replace the TODO comment in the boot(router:) method with this code:

let routes = router.grouped("api", "recipes")
routes.post(Recipe.self, use: createHandler)

You’re adding the route POST /api/recipes and connecting it to your createHandler(_:recipe:).

Now select the Run scheme:

Then build and run the project, and fire up the REST client of your choice to create a new recipe. For example, to use cURL, run this command in your terminal:

curl -X "POST" "http://localhost:8080/api/recipes" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "instructions": "Mix apple with cake.",
  "name": "Apple Cake",
  "ingredients": [
    "Apple",
    "Cake"
  ],
  "description": "As easy as apple pie."
}'

The Xcode console displays the Elasticsearch response:

HTTP/1.1 201 Created
Content-Encoding: gzip
Content-Type: application/json; charset=UTF-8
Location: /recipes/_doc/oOnTKm4Bcnc4_Sk4gx4E
Content-Length: 166
{"_index":"recipes","_type":"_doc","_id":"oOnTKm4Bcnc4_Sk4gx4E","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}

And your Vapor server returns the recipe, now with an Elasticsearch id, so this appears in the terminal window:

{"ingredients":["Apple","Cake"],"id":"oOnTKm4Bcnc4_Sk4gx4E","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix apple with cake."}

If you prefer to use a request client like Rested, it looks like this:

As you can see, your recipe has an id now. Congratulations! You’ve stored your first document in Elasticsearch!

Reading a Recipe

Now that you know the id of the document, you can easily get it from Elasticsearch.

Back in ElasticsearchClient.swift, look at the getDocument(id:from:on:) method:

public func getDocument<Document: Decodable>(
  id: String,
  from indexName: String,
  on container: Container
) throws -> Future<ESGetSingleDocumentResponse<Document>>

This method expects the id of the document you want to retrieve and the index name where the document is stored. Then it returns a Future<ESGetSingleDocumentResponse<Document>>, where Document is an object that conforms to Decodable and ESGetSingleDocumentResponse wraps the response from Elasticsearch, including the actual Document.

Now replace the fatalError() line in this method with this code:

let url = baseURL(path: "/\(indexName)/_doc/\(id)")
let request = HTTPRequest(method: .GET, url: url)
return try sendRequest(request, to: url, on: container)

This code is quite similar to what you did earlier to create a document. This time you use GET instead of POST, and the URL includes the id of the document.

Next, back in RecipeFinderController.swift, you’ll add the handler for your new request and connect it to a route.

First, add the following method in the RouteCollection extension:

func getSingleHandler(_ req: Request) throws -> Future<Recipe> {
  // 1
  let id: String = try req.parameters.next()
  return try elasticClient
    // 2
    .getDocument(id: id, from: "recipes", on: req)
    .map { (response: ESGetSingleDocumentResponse<Recipe>) in
      // 3
      let recipe = response.source
      recipe.id = response.id
      return recipe
  }
}

In this code, you:

  1. Grab the next parameter from the URL: This will be the recipe id.
  2. Call your new method to get a document with this id from Elasticsearch.
  3. Return the source property of the Elasticsearch response, which is your recipe object, and add the Elasticsearch id.

Now add this route to your controller’s boot(router:) method:

routes.get(String.parameter, use: getSingleHandler)

You’re defining a route GET /api/recipes/{String} where {String} is the id of the recipe you want to fetch.

Build and run your project, then enter this command in the terminal:

curl "http://localhost:8080/api/recipes/oOnTKm4Bcnc4_Sk4gx4E"

Replace my id oOnTKm4Bcnc4_Sk4gx4E with the id of the recipe you created earlier.

And there’s your recipe again!

{"ingredients":["Apple","Cake"],"id":"oOnTKm4Bcnc4_Sk4gx4E","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix apple with cake."}
Note: If you’ve restarted Elasticsearch since you created the recipe, the database might be empty since you don’t persist anything in your Docker configuration. Create a new recipe using your POST /api/recipes endpoint, then work with the id from the response.

Updating a Recipe

Updating a recipe requires a mixture of what you’ve learned so far. You have to specify both the id of the document you want to update in the URL and the updated recipe in the request body. To update a resource, you use the PUT HTTP method.

Note: If a document with the specified id doesn’t exist, Elasticsearch will create the document instead.

First, in ElasticsearchClient.swift, replace the fatalError() line in updateDocument(_:id:in:on:) with this code:

let url = baseURL(path: "/\(indexName)/_doc/\(id)")
var request = try HTTPRequest(method: .PUT, url: url, body:
  ElasticsearchClient.jsonEncoder.encode(document))
request.contentType = .json
return try sendRequest(request, to: url, on: container)

Next, in RecipeFinderController.swift, add this new handler in the RouteCollection extension:

func updateHandler(_ req: Request, recipe: Recipe) throws -> 
  Future<Recipe> {
  let id: String = try req.parameters.next()
  return try elasticClient.updateDocument(recipe, id: id, 
    in: "recipes", on: req).map { response in
    return recipe
  }
}

Finally, add the new route to boot(router:):

routes.put(Recipe.self, at: String.parameter, use: updateHandler)

Now you can update a recipe with an id: Replace my id oOnTKm4Bcnc4_Sk4gx4E with your recipe’s id value, then run this command in the terminal:

curl -X "PUT" "http://localhost:8080/api/recipes/oOnTKm4Bcnc4_Sk4gx4E" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "instructions": "Mix the two apples with one cake.",
  "name": "Apple Cake",
  "ingredients": [
    "Apple",
    "Cake"
  ],
  "description": "As easy as apple pie."
}'

You’re just changing the instructions to use two apples. And Vapor displays the updated recipe:

{"ingredients":["Apple","Cake"],"name":"Apple Cake","instructions":"Mix the two apples with one cake.","description":"As easy as apple pie."}

Getting All the Recipes

To get all the documents of an index in Elasticsearch you use the _search endpoint with no search query parameters. In ElasticsearchClient.swift, look at the getAllDocuments(from:on:) method signature:

public func getAllDocuments<Document: Decodable>(
  from indexName: String,
  on container: Container
) throws -> Future<ESGetMultipleDocumentsResponse<Document>>

This time, the response is type ESGetMultipleDocumentsResponse, which contains a list of hits, each representing a ESGetSingleDocumentResponse.

First, replace the fatalError() line in getAllDocuments(from:on:) with this code:

let url = baseURL(path: "/\(indexName)/_search")
let request = HTTPRequest(method: .GET, url: url)
return try sendRequest(request, to: url, on: container)

And, you know the drill by now: Add the handler to the RouteCollection extension in RecipeFinderController.swift:

func getAllHandler(_ req: Request) throws -> Future<[Recipe]> {
  return try elasticClient
    .getAllDocuments(from: "recipes", on: req)
    .map { (response: ESGetMultipleDocumentsResponse<Recipe>) in
      return response.hits.hits.map { doc in
        let recipe = doc.source
        recipe.id = doc.id
        return recipe
    }
  }
}

And finally, update your boot(router:) method with the new route:

routes.get(use: getAllHandler)

Build and run your project, then call GET /api/recipes/ via cURL:

curl "http://localhost:8080/api/recipes"

As a result, you’ll receive an array of all the recipes you’ve stored in Elasticsearch:

[{"ingredients":["Apple","Cake"],"id":"nunRKm4Bcnc4_Sk4TR7y","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix apple with cake."},
{"ingredients":["Apple","Cake"],"id":"oOnTKm4Bcnc4_Sk4gx4E","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix the two apples with one cake."}]

In this case, there’s one I created with Rested and one I created and updated with cURL.

Deleting a Recipe

To delete a recipe, you’ll use the DELETE HTTP method with the same URL you used for updating or reading a resource.

First, in ElasticsearchClient.swift, replace the fatalError() line in deleteDocument(id:from:on:) with this code:

let url = baseURL(path: "/\(indexName)/_doc/\(id)")
let request = HTTPRequest(method: .DELETE, url: url)
return try sendRequest(request, to: url, on: container)

Next, in RecipeFinderController.swift, add the handler to the RouteCollection extension:

func deleteHandler(_ req: Request) throws -> Future<HTTPStatus> {
  let id: String = try req.parameters.next()
  return try elasticClient.deleteDocument(id: id, from: "recipes", 
    on: req).map { response in
    return .ok
  }
}

And add the route to boot(router:):

routes.delete(String.parameter, use: deleteHandler)

Now you can delete a recipe with the DELETE command:

curl -X "DELETE" "http://localhost:8080/api/recipes/oOnTKm4Bcnc4_Sk4gx4E"

And check that it’s gone with your get-all command:

curl "http://localhost:8080/api/recipes"

Sure enough, now there’s only one recipe:

[{"ingredients":["Apple","Cake"],"id":"nunRKm4Bcnc4_Sk4TR7y","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix apple with cake."}]

Congrats! You’ve made it through CRUD. Now you can focus on the most interesting part of Elasticsearch: Search.

Searching Recipes

Elasticsearch provides you with two options to search documents:

  • URI Search: A simple search using URI query parameters. You’ll focus on this option for this tutorial.
  • Query DSL: This option allows for complex and powerful searches using Elasticsearch’s Query DSL syntax. This tutorial doesn’t cover Query DSL because it can easily fill its own tutorial.

In this section, you’ll add a simple search to the recipe finder that looks for the search term in name, description and ingredients. It will also allow partial searches. For example, a search for App or pple will return apple recipes.

First, in ElasticsearchClient.swift, replace the fatalError() line in searchDocuments(from:searchTerm:on:) with this code:

let url = baseURL(
  path: "/\(indexName)/_search",
  queryItems: [URLQueryItem(name: "q", value: searchTerm)]
)
let request = HTTPRequest(method: .GET, url: url)
return try sendRequest(request, to: url, on: container)

The endpoint for searching documents is located under {indexName}/_search. You can provide a query string to the parameter q — take a look now at how to construct a query string.

Query String Syntax

The query string parameter has its own small syntax. It lets you customize the search and filter field names. You can also have wildcards and regular expressions.

Note: If you don’t provide any query parameters, you’ll receive all documents of an index. You used that for the getAllDocuments(from:on:) method.

Here are some relevant examples and operators:

  • Single term: name:cake finds results that have cake in the name field.
  • Multiple terms: name:(cake OR pie) finds results that have cake or pie in name.
  • Exact match: name:"apple cake" finds results that have apple cake in name.
  • Fuzziness: name:apple~ finds results that have apple in name, even if they are misspelled (e.g., appel) to some extent.
  • Wildcard: name:app* finds results that have words starting with app in name.
  • Multiple fields: (name:cake AND ingredients:apple) finds results that have cake in name and apple in ingredients.
  • Boosting: (name:cake^2 OR ingredients:apple) does the same as Multiple terms, but hits in name are boosted and are more relevant.
Note: The letter case doesn’t matter.

A full description of the query string syntax is at Elasticsearch Reference > Query string syntax

Now you’re ready to construct your searchTerm: In RecipeFinderController.swift, add this method to the RouteCollection extension:

func searchHandler(_ req: Request) throws -> Future<[Recipe]> {
  // 1
  guard let term = req.query[String.self, at: "term"] else {
    throw Abort(.badRequest, reason: "`term` is mandatory")
  }
  // 2
  let searchTerm = "(name:*\(term)* OR description:*\(term)* 
    OR ingredients:*\(term)*)"
  // 3
  return try elasticClient
    .searchDocuments(from: "recipes", searchTerm: searchTerm, 
      on: req)
    .map { (response: ESGetMultipleDocumentsResponse<Recipe>) in
    return response.hits.hits.map { doc in
      let recipe = doc.source
      recipe.id = doc.id
      return recipe
    }
  }
}

Here, you:

  1. Make sure the user has provided a term.
  2. Compose searchTerm, using the query string syntax to find searchTerm in name, description or ingredients, and allowing for partial matches.
  3. Use the same implementation as in getAllHandler(_:).

Then add the new route to boot(router:):

routes.get("search", use: searchHandler)

Testing Search

Build and run the project. Before testing search, run this command in Terminal to add another recipe:

curl -X "POST" "http://localhost:8080/api/recipes" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "name": "Banana Pie",
  "ingredients": [
    "Banana",
    "Sugar",
    "Milk",
    "Vanilla",
    "Egg yolks",
    "Baked pastry shell"
  ],
  "description": "If you like bananas, this is for you",
  "instructions": "Mix all the ingredients. Enjoy!"
}'

And your Vapor server returns your new recipe, with its Elasticsearch id:

{"ingredients":["Banana","Sugar","Milk","Vanilla","Egg yolks","Baked pastry shell"],"id":"4SUaUW4BBAdqyeGzwlUn","name":"Banana Pie","description":"If you like bananas, this is for you","instructions":"Mix all the ingredients. Enjoy!"}

Now that you have two recipes in your database, run this command to test if a partial search results in a tasty apple cake:

curl "http://localhost:8080/api/recipes/search?term=app"

And here’s what this search finds:

[{"ingredients":["Apple","Cake"],"id":"nunRKm4Bcnc4_Sk4TR7y","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix apple with cake."}]

Great! It found your Apple Cake!
But you haven’t added a Banana Pie for no reason! Run this command to search for pies:

curl "http://localhost:8080/api/recipes/search?term=pie"

The search returns both recipes:

{"ingredients":["Apple","Cake"],"id":"nunRKm4Bcnc4_Sk4TR7y","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix apple with cake."},{"ingredients":["Banana","Sugar","Milk","Vanilla","Egg yolks","Baked pastry shell"],"id":"4SUaUW4BBAdqyeGzwlUn","name":"Banana Pie","description":"If you like bananas, this is for you","instructions":"Mix all the ingredients. Enjoy!"}]

As you can see, it returns two results: the Apple Cake at first position and the Banana Pie on second. The reason for this is that both recipes contain the term Pie in their data — in Apple Cake’s description and in Banana Pie’s name and description. It probably would be a good idea to prioritize hits in the name and ingredients fields over matching terms in the description field.

Weighted search

To make results in name and ingredients more relevant, you will add the boost operator ^ (caret) to your searchTerm.

In RecipeFinderController.swift, replace the let searchTerm = "(name:*\(term)* OR description:*\(term)* OR ingredients:*\(term)*)" line in searchHandler(_:) with this code:

let searchTerm = "(name:*\(term)*^5 OR description:*\(term)* OR ingredients:*\(term)*^2)"

This will boost any matches in name by 5 and in ingredients by 2.

Now build and run the project again, then retry the search for pie:

curl "http://localhost:8080/api/recipes/search?term=pie"

This time, the results put the Banana Pie recipe first, because the search term in the name Banana Pie weighs more than the search term in the description As easy as apple pie.

[{"ingredients":["Banana","Sugar","Milk","Vanilla","Egg yolks","Baked pastry shell"],"id":"4SUaUW4BBAdqyeGzwlUn","name":"Banana Pie","description":"If you like bananas, this is for you","instructions":"Mix all the ingredients. Enjoy!"},{"ingredients":["Apple","Cake"],"id":"nunRKm4Bcnc4_Sk4TR7y","name":"Apple Cake","description":"As easy as apple pie.","instructions":"Mix apple with cake."}

Nice work – you did it!

Cleaning Up

Stop your Xcode project to stop the Vapor server.

To stop the Elasticsearch container, then remove it from your system, run these commands in Terminal:

docker stop elasticsearch
docker rm $(docker ps -a -q -f status=exited)

You named the container when you started it, which makes it easier to stop it — you don’t have to search for its ID. The longer command finds all exited container processes, then removes them.

To remove the Elasticsearch image, first find its IMAGE ID:

docker images

Here’s my output:

docker.elastic.co/elasticsearch/elasticsearch   7.2.1   1e8add8d7b66  ...

Then use its IMAGE ID to remove it — replace my IMAGE ID with yours:

docker rmi 1e8add8d7b66

Where to Go From Here?

You can download the completed Recipe Finder Web API project using the Download Materials button at the top or bottom of this page.

In the completed project, you’ll find some implementations not covered by this tutorial — instructions are in the project’s README.md file:

  • DeleteIndexCommand: Useful to delete your index.
  • ImportRecipesCommand: An example of how to import data into Elasticsearch via a command. If you deal with a lot of data in production, you should have a look at the Elasticsearch Bulk API instead.

You can learn more about Elasticsearch by reviewing its comprehensive documentation at Elasticsearch Reference.

When working with the more complex Query DSL syntax, you might want to use a third party SDK that wraps the complexity for you. One library for Vapor that’s still in development, but already offers a good amount of features is Github ryangrimm/VaporElasticsearch. Feel free to contribute to it, and make it even better and more complete. :]

I hope you enjoyed this tutorial. If you have any questions or feedback, please join the forum discussion below.

Average Rating

5/5

Add a rating for this content

4 ratings

Contributors

Comments