DEV Community

Cover image for How To Build Autocomplete search with Nestjs, Elasticsearch and Vue
mkop
mkop

Posted on • Edited on

12 3

How To Build Autocomplete search with Nestjs, Elasticsearch and Vue

In this Post, we will cover

  • create and build docker images
  • create and setup Nest app
  • configure Elasticsearch with Nest app
  • create and setup Vue app

let's start

Project root directory structure:

.
├── client
├── server
└── dev.yml

1. Setup Nest and Elasticsearch

Server directory structure:

.
├── src
│   ├── config
│   │   ├── config.module.ts
│   │   └── config.service.ts
│   ├── modules
│   │   ├── movie
│   │   │   ├── movie.controller.ts
│   │   │   ├── movie.module.ts
│   │   │   └── movie.service.ts
│   │   └── search
│   │       ├── search.module.ts
│   │       └── search.service.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── main.ts
│   └── typings.d.ts
├── Dockerfile
├── .env
├── movies.json
├── package.json
└── tslint.json
  • ConfigModule and ConfigService

in the ConfigModule we have to import env variables in project

  • config.module.ts

    import { Module } from '@nestjs/common';
    import { ConfigService } from './config.service';
    @Module({
    providers: [
    {
    provide: ConfigService,
    useValue: new ConfigService('.env'),
    },
    ],
    exports: [ConfigService],
    })
    export class ConfigModule {}
  • config.service.ts

    import * as dotenv from 'dotenv';
    import * as fs from 'fs';
    export class ConfigService {
    private readonly envConfig: { [key: string]: string };
    constructor(filePath: string) {
    this.envConfig = dotenv.parse(fs.readFileSync(filePath));
    }
    get(key: string): string {
    return this.envConfig[key];
    }
    }
  • SearchModule and SearchService

in the SearchModule we have to configure Elasticsearch and fill movie-index with data from movies.json

movie-indexautomatically created when you start a project.

  • search.module.ts

    import { Module, OnModuleInit } from '@nestjs/common';
    import { SearchService } from './search.service';
    import { ElasticsearchModule } from '@nestjs/elasticsearch';
    import { ConfigModule } from '../../config/config.module';
    import { ConfigService } from '../../config/config.service';
    @Module({
    imports: [
    ElasticsearchModule.registerAsync({
    imports: [ConfigModule],
    useFactory: async (configService: ConfigService) => ({
    node: configService.get('ELASTICSEARCH_NODE'),
    maxRetries: 10,
    requestTimeout: 60000,
    pingTimeout: 60000,
    sniffOnStart: true,
    }),
    inject: [ConfigService],
    }),
    ConfigModule,
    ],
    providers: [SearchService],
    exports: [SearchService],
    })
    export class SearchModule implements OnModuleInit {
    constructor(private searchService: SearchService) {}
    onModuleInit() {
    this.searchService.createIndex().then();
    }
    }
  • search.service.ts

    import { Injectable } from '@nestjs/common';
    import { ElasticsearchService } from '@nestjs/elasticsearch';
    import * as moviesJson from '../../../movies.json';
    import { ConfigService } from '../../config/config.service';
    interface MoviesJsonResponse {
    title: string;
    year: number;
    cast: string[];
    genres: string[];
    }
    @Injectable()
    export class SearchService {
    constructor(private readonly esService: ElasticsearchService, private readonly configService: ConfigService) {}
    async createIndex() {
    const checkIndex = await this.esService.indices.exists({ index: this.configService.get('ELASTICSEARCH_INDEX') });
    if (checkIndex.statusCode === 404) {
    this.esService.indices.create(
    {
    index: this.configService.get('ELASTICSEARCH_INDEX'),
    body: {
    settings: {
    analysis: {
    analyzer: {
    autocomplete_analyzer: {
    tokenizer: 'autocomplete',
    filter: ['lowercase'],
    },
    autocomplete_search_analyzer: {
    tokenizer: 'keyword',
    filter: ['lowercase'],
    },
    },
    tokenizer: {
    autocomplete: {
    type: 'edge_ngram',
    min_gram: 1,
    max_gram: 30,
    token_chars: ['letter', 'digit', 'whitespace'],
    },
    },
    },
    },
    mappings: {
    properties: {
    title: {
    type: 'text',
    fields: {
    complete: {
    type: 'text',
    analyzer: 'autocomplete_analyzer',
    search_analyzer: 'autocomplete_search_analyzer',
    },
    },
    },
    year: { type: 'integer' },
    genres: { type: 'nested' },
    actors: { type: 'nested' },
    },
    },
    },
    },
    (err) => {
    if (err) {
    console.error(err);
    }
    },
    );
    const body = await this.parseAndPrepareData();
    this.esService.bulk(
    {
    index: this.configService.get('ELASTICSEARCH_INDEX'),
    body,
    },
    (err) => {
    if (err) {
    console.error(err);
    }
    },
    );
    }
    }
    async search(search: string) {
    let results = [];
    const { body } = await this.esService.search({
    index: this.configService.get('ELASTICSEARCH_INDEX'),
    body: {
    size: 12,
    query: {
    match: {
    'title.complete': {
    query: search,
    },
    },
    },
    },
    });
    const hits = body.hits.hits;
    hits.map(item => {
    results.push(item._source);
    });
    return { results, total: body.hits.total.value };
    }
    async parseAndPrepareData() {
    let body = [];
    const listMovies: MoviesJsonResponse[] = moviesJson;
    listMovies.map((item, index) => {
    let actorsData = [];
    item.cast.map(actor => {
    const splited = actor.split(' ');
    actorsData.push({ firstName: splited[0], lastName: splited[1] });
    });
    body.push(
    { index: { _index: this.configService.get('ELASTICSEARCH_INDEX'), _id: index } },
    {
    title: item.title,
    year: item.year,
    genres: item.genres.map(genre => ({ genre })),
    actors: actorsData,
    },
    );
    });
    return body;
    }
    }
  • MovieModule, MovieService and MovieController

we create MovieController, MovieService and import SearchModule for access method search in SearchService

  • movie.module.ts

    import { Module } from '@nestjs/common';
    import { MovieService } from './movie.service';
    import { MovieController } from './movie.controller';
    import { SearchModule } from '../search/search.module';
    @Module({
    imports: [SearchModule],
    providers: [MovieService],
    controllers: [MovieController],
    })
    export class MovieModule {}
    view raw movie.module.ts hosted with ❤ by GitHub
  • movie.controller.ts

    import { Controller, Get, Query } from '@nestjs/common';
    import { MovieService } from './movie.service';
    @Controller()
    export class MovieController {
    constructor(public readonly movieService: MovieService) {}
    @Get('movies')
    async getMovies(@Query('search') search: string) {
    if (search !== undefined && search.length > 1) {
    return await this.movieService.findMovies(search);
    }
    }
    }
  • movie.service.ts

    import { Injectable } from '@nestjs/common';
    import { SearchService } from '../search/search.service';
    @Injectable()
    export class MovieService {
    constructor(readonly esService: SearchService) {}
    async findMovies(search: string = '') {
    return await this.esService.search(search);
    }
    }
  • AppModule

in this step we will create healthcheck endpoint in AppController for docker health.
we have to import MovieModule, ConfigModule and SearchModule into AppModule

  • app.module.ts

    import { Module } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { ConfigModule } from './config/config.module';
    import { SearchModule } from './modules/search/search.module';
    import { MovieModule } from './modules/movie/movie.module';
    @Module({
    imports: [ConfigModule, SearchModule, MovieModule],
    controllers: [AppController],
    })
    export class AppModule {}
    view raw app.module.ts hosted with ❤ by GitHub
  • app.controller.ts

    import { Controller, Get } from '@nestjs/common';
    @Controller()
    export class AppController {
    private start: number;
    constructor() {
    this.start = Date.now();
    }
    @Get('healthcheck')
    async healthcheck() {
    const now = Date.now();
    return {
    status: 'API Online',
    uptime: Number((now - this.start) / 1000).toFixed(0),
    };
    }
    }
  • main.ts

    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ConfigService } from './config/config.service';
    async function bootstrap() {
    const configService = new ConfigService('.env');
    const app = await NestFactory.create(AppModule);
    app.enableCors();
    app.setGlobalPrefix(configService.get('GLOBAL_PREFIX'));
    await app.listen(configService.get('NODE_PORT'));
    }
    bootstrap();
    view raw main.ts hosted with ❤ by GitHub
  • create server/Dockerfile

docker image for Nest app

FROM node:13
WORKDIR /app/server
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000/tcp
CMD [ "node", "dist/main.js" ]

Create .env file in server directory

#App
APP_ENV=dev
GLOBAL_PREFIX=api
#Elastic
ELASTICSEARCH_NODE=http://dockerip:9200
NODE_PORT=3000
ELASTICSEARCH_INDEX=movies-index

how to find elastic url in docker:

docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' nest-elasticsearch

Replace the given dockerIp with ELASTICSEARCH_NODE=http://dockerIp:9200 in .env

2. Setup Vue app

  • Client directory structure:
.
├── Dockerfile
├── package.json
├── public
│   └── index.html
└── src
    ├── App.vue
    ├── asset
    ├── components
    │   └── Home.vue
    ├── main.js
    ├── plugins
    │   └── boostrap-vue.js
    └── router.js

  • App.vue

<router-view> The component is a functional component that renders the matched component for the given path. Components rendered in <router-view> can also contain their own <router-view>, which will render components for nested paths.

<template>
<div id="app">
<router-view/>
</div>
</template>
view raw App.vue hosted with ❤ by GitHub
  • Home.vue
<template>
<b-container>
<br>
<b-list-group>
<h2 align="center">Search Movies by title</h2>
<b-input-group-text>
<b-input label="Search" type="text" v-model="search" placeholder="Search in 91,770 movies" />
<span class="input-group-text">Total: {{total}}</span>
</b-input-group-text>
<br>
</b-list-group>
<br>
<b-row>
<b-card-group columns>
<b-col v-for="movie in movies" v-bind:key="movie.id">
<b-card :bg-variant="movie.variant" text-variant="black" :header="movie.title">
<b-card-body>
<b-card-text >Year: {{ movie.year}}</b-card-text>
<b-card-text>Actors:</b-card-text>
<b-card-text v-for="actor in movie.actors" v-bind:key="actor">
<li>{{ actor.firstName }} {{ actor.lastName }}</li>
</b-card-text>
<b-card-footer >
<span v-for="genre in movie.genres" v-bind:key="genre">
<span> #{{ genre.genre }} </span>
</span>
</b-card-footer>
</b-card-body>
</b-card>
</b-col>
</b-card-group>
</b-row>
</b-container>
</template>
<script>
import axios from "axios";
export default {
name: 'AutocompletePage',
data() {
return {
movies: [],
search: '',
total: 0,
};
},
watch:{
search() {
return this.getSearch();
}
},
methods: {
getSearch() {
axios.get(`http://localhost:3000/api/movies?search=` + this.search)
.then(response => {
this.movies = response.data.results;
this.total = response.data.total;
})
}
},
};
</script>
<style>
.col {
min-width: 360px;
}
</style>
view raw Home.vue hosted with ❤ by GitHub
  • boostrap-vue.js

setup boostrap with vue

import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
Vue.use(BootstrapVue);
view raw boostrap-vue.js hosted with ❤ by GitHub
  • main.js
import "@babel/polyfill";
import Vue from "vue";
import "./plugins/boostrap-vue";
import App from "./App.vue";
import router from "./router";
Vue.config.productionTip = false;
new Vue({
router,
render: h => h(App)
}).$mount("#app");
view raw main.js hosted with ❤ by GitHub
  • router.js

    import Vue from 'vue'
    import Router from 'vue-router'
    import Home from "@/components/Home";
    Vue.use(Router)
    export default new Router({
    mode: 'history',
    routes: [
    { path: '/', redirect: { name: 'home' } },
    { path: '/home', name: 'home', component: Home },
    ]
    });
    view raw router.js hosted with ❤ by GitHub
  • create client/Dockerfile

    docker for Vue app

FROM node:11.1-alpine as develop-stage
WORKDIR /app/client
COPY package*.json ./
RUN npm install
COPY . .
FROM develop-stage as build-stage
RUN npm run build
COPY --from=build-stage /app/client/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

3. Setup Docker for project

why use Docker?

Docker is a tool designed to make it easy to create, deploy and run application by using containers.

version: "3.7"
services:
nest-app:
build: server
container_name: nest-app
healthcheck:
test: ["CMD-SHELL", "curl --silent --fail localhost:3000/api/healthcheck || exit 1"]
interval: 50s
timeout: 30s
retries: 5
depends_on:
- nest-elasticsearch
command: "npm run start:dev"
volumes:
- ./server/src:/app/server/src/
ports:
- 3000:3000
nest-elasticsearch:
container_name: nest-elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch:7.0.1
healthcheck:
test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cat/health?h=st || exit 1"]
interval: 50s
timeout: 30s
retries: 5
environment:
- cluster.name=movies-cluster
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- discovery.type=single-node
ports:
- 9300:9300
- 9200:9200
nest-vue:
build:
target: 'develop-stage'
context: client
container_name: nest-vue
volumes:
- ./client:/app/client
ports:
- 8080:8080
command: /bin/sh -c "npm run serve"
view raw dev.yml hosted with ❤ by GitHub

Check out the full project on GitHub

That's it!

Feel free to ask questions, make comments or suggestions, or just say hello in the comments below.

Image of Datadog

The Essential Toolkit for Front-end Developers

Take a user-centric approach to front-end monitoring that evolves alongside increasingly complex frameworks and single-page applications.

Get The Kit

Top comments (4)

Collapse
 
margkoss profile image
Margkoss

Hi, thanks for the tutorial
I'm following along but i get a connection timeout error when creating the index. I created an index with a curl command and it seems that the indices.exists(...) is working fine. Can you point me towards a solution?
(I can provide more info if needed)

Collapse
 
harrisking profile image
HARRISKING

how to get the docker IP?

Collapse
 
crazyoptimist profile image
crazyoptimist

Service name (nest-elasticsearch in above case) is used when you use docker-compose for ochestrating everything. Otherwise, bind the port to the host and use localhost. For example:

ports:
  - 9200:9200
  - 9300:9300
Enter fullscreen mode Exit fullscreen mode

Then you can access the elasticsearch node using http://localhost:9200

Collapse
 
kop7 profile image
mkop

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay