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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Elasticsearch in Vapor: Getting Started
30 mins
- Getting Started
- Running Elasticsearch in Docker
- Using Elasticsearch
- Understanding Indexes and Documents
- Comparing Elasticsearch to Relational Databases
- Implementing CRUD
- Creating a Recipe
- Connecting Elasticsearch to Your API
- Reading a Recipe
- Updating a Recipe
- Getting All the Recipes
- Deleting a Recipe
- Searching Recipes
- Testing Search
- Adding Weighted Search
- Cleaning Up
- Where to Go From Here?
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:
- You declare the URL, which you compose using
baseURL
, theindexName
, which isrecipes
throughout this tutorial, and_doc
, which is the endpoint for dealing with single documents. - Next, you pass both the
url
and thedocument
into aClientRequest
object with methodPOST
. - Finally, you set the content-type header to application/json and send the request.
Look at ClientRequest+ConvenienceInit.swift to learn how it works.
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
.
Look at ClientRequest+ConvenienceInit.swift to learn how it works.
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:
- First, it takes a request and returns a
Recipe
object. - Second, it decodes the
Recipe
from theRequest
‘s body - By calling
req.esClient
, you’ll receive an instance ofElasticsearchClient
. This is defined in Request+ElasticsearchClient.swift and follows the new services approach of Vapor 4. - You use your newly-created
createDocument(_:in:)
to storerecipe
in Elasticsearch’srecipes
index. - Finally, you use the
id
from theresponse
to set yourRecipe
object’sid
property. You store the document under thisid
in therecipes
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:
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:
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:
- Grab the parameter
id
from the URL. This will be the recipeid
. - Call your new method to get a document from Elasticsearch with this
id
. - Return the
source
property of the Elasticsearchresponse
, which is your recipe object, and add the Elasticsearchid
.
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."}
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.