Elasticsearch in Vapor: Getting Started

In this tutorial, you’ll set up a Vapor server to interact with an Elasticsearch server running locally with Docker to store and retrieve recipe documents. By Christian Weinberger.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Creating a Recipe

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

public func createDocument<Document: Encodable>(
  _ document: Document,
  in indexName: String
) throws -> EventLoopFuture<ESCreateDocumentResponse>

It has a generic type, Document, which conforms to Encodable. This lets you store any encodable object.

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

Now, it’s time to start coding!

Replace the fatalError() line in createDocument(_:in:) with the following:

// 1
let url = baseURL(path: "/\(indexName)/_doc")
// 2
var request = try ClientRequest(method: .POST, url: url, body: document)
// 3
request.headers.contentType = .json
return try sendRequest(request)

Here’s what this code does:

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

Look at ClientRequest+ConvenienceInit.swift to learn how it works.

Note: In this tutorial, you add the the initializer ClientRequest().init(method:url:headers:body) through an extension. Doing so removes the complexity of JSON encoding the object and writing it into a ByteBuffer.

Connecting Elasticsearch to Your API

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

First, 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 routes(_:) with the following:

let recipeFinderController = RecipeFinderController()
try app.grouped("api", "recipes").register(collection: recipeFinderController)

This initializes your RecipeFinderController and adds it to the app as a RouteCollection.

Next, jump to the definition of register(collection:). Vapor then asks your RecipeFinderController for a list of supported routes in boot(router:):

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

extension RoutesBuilder {
    /// 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(routes: self)
    }
}

Back in RecipeFinderController.swift, add this new method inside the class definition, replacing the first TODO:

// 1
func createHandler(_ req: Request) throws -> EventLoopFuture<Recipe> {
  // 2
  let recipe = try req.content.decode(Recipe.self)
  // 3
  return try req.esClient
    // 4
    .createDocument(recipe, in: "recipes")
    .map { response in
      // 5
      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 returns a Recipe object.
  2. Second, it decodes the Recipe from the Request‘s body
  3. By calling req.esClient, you’ll receive an instance of ElasticsearchClient. This is defined in Request+ElasticsearchClient.swift and follows the new services approach of Vapor 4.
  4. You use your newly-created createDocument(_:in:) to store recipe in Elasticsearch’s recipes index.
  5. Finally, you use the id from the response to set your Recipe object’s id property. You store the document 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 in boot(routes:) with the following:

routes.post(use: createHandler)

Here, you add the route POST /api/recipes and connect it to your createHandler(_:).

Now, select the Run scheme:

Select The Run Scheme

Build and run, then 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:

Response:

HTTP/1.1 201 Created
Location: /recipes/_doc/25TxfXEBLVx2dJNCgFQB
content-type: application/json; charset=UTF-8
content-length: 174

{"_index":"recipes","_type":"_doc","_id":"25TxfXEBLVx2dJNCgFQB","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}

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

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

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

A request in Insomnia

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 getDocument(id:from:):

public func getDocument<Document: Decodable>(
  id: String,
  from indexName: String
) throws -> EventLoopFuture<ESGetSingleDocumentResponse<Document>>

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

Now, replace fatalError() in this method with the following:

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

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

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

Start by adding the following method below createHandler(_:):

func getSingleHandler(_ req: Request) throws -> EventLoopFuture<Recipe> {
  // 1
  guard let id: String = req.parameters.get("id") else { throw Abort(.notFound) }
  return try req.esClient
    // 2
    .getDocument(id: id, from: "recipes")
    .map { (response: ESGetSingleDocumentResponse<Recipe>) in
      // 3
      let recipe = response.source
      recipe.id = response.id
      return recipe
  }
}

In this code, you:

  1. Grab the parameter id from the URL. This will be the recipe id.
  2. Call your new method to get a document from Elasticsearch with this id.
  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(routes:):

routes.get(":id", use: getSingleHandler)

You’re defining a route GET /api/recipes/:id with a named parameter :id that refers to the id of the recipe you want to fetch.

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

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

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

And there’s your recipe again!

{"ingredients":["Apple","Cake"],"id":"25TxfXEBLVx2dJNCgFQB","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 will be empty. Remember, your Docker configuration doesn’t persist anything. In that case, create a new recipe using your POST /api/recipes endpoint, then work with the id from the response.

In your next step, you’ll learn how to make changes to the recipes you’ve retrieved.