diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c150827..635da42 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -79,27 +79,17 @@ components: type: array items: type: object + additionalProperties: true + description: Dynamic keys/fields allowed properties: - _index: + name: type: string - _id: + status: + type: string + ID: + type: string + collection: type: string - _score: - type: number - _source: - type: object - additionalProperties: true - description: Dynamic keys/fields allowed - properties: - name: - type: string - status: - type: string - ID: - type: string - collection: - type: string - Config: type: object diff --git a/src/ElasticIO.test.ts b/src/ElasticIO.test.ts index c4fa294..5285497 100644 --- a/src/ElasticIO.test.ts +++ b/src/ElasticIO.test.ts @@ -2,6 +2,7 @@ import { Client } from "@elastic/elasticsearch"; import ElasticIO from "./ElasticIO"; import env from "../util/validateEnv"; import Config from "./config"; +import { SearchHit } from "@elastic/elasticsearch/lib/api/types"; //Mock the Client jest.mock("@elastic/elasticsearch"); @@ -64,6 +65,95 @@ describe("ElasticIO", () => }); }); + describe("processResults", () => + { + it("should return an empty array when hits is undefined", () => + { + const result = elasticIO.processResults(undefined); + expect(result).toEqual([]); + }); + it("should return an empty array when hits is an empty array", () => + { + const result = elasticIO.processResults([]); + expect(result).toEqual([]); + }); + it("should sort the search results by _score in descending order and return their _source properties", () => + { + const hits: SearchHit[] = [ + { _score: 1, _source: { id: 1, name: "Item 1" } } as SearchHit, + { _score: 3, _source: { id: 3, name: "Item 3" } } as SearchHit, + { _score: 2, _source: { id: 2, name: "Item 2" } } as SearchHit, + ]; + + const result = elasticIO.processResults(hits); + + expect(result).toEqual([ + { id: 3, name: "Item 3" }, + { id: 2, name: "Item 2" }, + { id: 1, name: "Item 1" }, + ]); + }); + }); + + describe("search", () => + { + it("should should call elasticsearchClient.search with the correct parameters", async () => + { + const mockClient = { + search: jest.fn().mockResolvedValue({ + hits: { + hits: [ + { _source: { name: "Junior Jobs", status: "Active", ID: { "$oid": "bb00" } } }, + { _source: { name: "Michael Smith", status: "Active", ID: { "$oid": "bb01" } } }, + ] + } + }) + }; + + elasticIO["elasticsearchClient"] = mockClient as unknown as Client; + + const queryString = 'active'; + const searchRes = await elasticIO.search(queryString); + + expect(mockClient.search).toHaveBeenCalledWith({ + index: env.INDEX_NAME, + body: { + query: { + bool: { + should: [ + { + multi_match: { + query: `${queryString}*`, + type: "best_fields", + fields: ["name", "status", "firstName", "lastName"] + } + }, + { + query_string: { + query: `${queryString}*`, + fields: ["name", "status", "firstName", "lastName"] + } + } + ] + } + } + } + }); + if (searchRes) { + searchRes.hits.hits.forEach(result => + { + expect(result._source).toEqual( + expect.objectContaining({ + ID: expect.any(Object), + name: expect.any(String), + status: expect.any(String), + }) + ); + }); + } + }); + }); + describe("disconnect", () => { it("should close Elasticsearch client connection if client is defined", async () => diff --git a/src/ElasticIO.ts b/src/ElasticIO.ts index c1b51c5..b3b6a27 100644 --- a/src/ElasticIO.ts +++ b/src/ElasticIO.ts @@ -9,8 +9,6 @@ export default class ElasticIO constructor() { } - - private connectionBody: { node: string; auth: { @@ -48,6 +46,46 @@ export default class ElasticIO } } + processResults(hits: import("@elastic/elasticsearch/lib/api/types").SearchHit[] | undefined) + { + if (!hits) return []; + + hits.sort((a, b) => (b._score ?? 0) - (a._score ?? 0)); + + let processedHits = hits.map((hit) => hit._source); + + return processedHits; + } + + public async search(queryString: string) + { + const searchRes = await this.elasticsearchClient?.search({ + index: env.INDEX_NAME, + body: { + query: { + bool: { + should: [ + { + multi_match: { + query: `${queryString}*`, + type: "best_fields", + fields: ["name", "status", "firstName", "lastName"] + } + }, + { + query_string: { + query: `${queryString}*`, + fields: ["name", "status", "firstName", "lastName"] + } + } + ] + } + } + } + }); + return searchRes; + } + public async disconnect(config: Config): Promise { try { diff --git a/src/app.ts b/src/app.ts index 7cda0ab..5e5c978 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ app.get('/', (req, res) => res.send('Express + TypeScript Server'); }); + app.get('/api/search', async (req, res, next) => { //initialize client @@ -32,43 +33,21 @@ app.get('/api/search', async (req, res, next) => throw createHttpError(400, 'Bad Request: Missing or invalid "query" header'); } const queryString = req.headers.query as string; - const searchRes = await client?.search({ - index: env.INDEX_NAME, - body: { - query: { - bool: { - should: [ - { - multi_match: { - query: `${queryString}*`, - type: "best_fields", - fields: ["name", "status"] - } - }, - { - query_string: { - query: `${queryString}*`, - fields: ["name", "status"] - } - } - ] - } - } - } - } - ); + const searchRes = await elasticIO.search(queryString); if (searchRes?.timed_out) { createHttpError(504, "Search database response has timed out. Please try again later."); } + const processedResults = elasticIO.processResults(searchRes?.hits.hits); //respond with elasticsearch response - res.status(200).json(searchRes?.hits.hits); + res.status(200).json(processedResults); + console.info("Search results returned successfully"); } catch (error) { console.error(error); next(error); } finally { - await elasticIO?.disconnect(new Config()); + await elasticIO.disconnect(new Config()); } }); @@ -79,6 +58,7 @@ app.get('/api/health', async (req, res, next) => res.status(200); res.set('Content-Type', register.contentType); res.end(metrics); + console.info("GetHealth Completed"); } catch (error) { console.error(error); @@ -91,6 +71,7 @@ app.get('/api/config', async (req, res, next) => try { const config = new Config(); res.status(200).json(config); + console.info("GetConfig Completed"); } catch (error) { console.error(error); diff --git a/src/docker/Dockerfile b/src/docker/Dockerfile index 18b7f20..08e396c 100644 --- a/src/docker/Dockerfile +++ b/src/docker/Dockerfile @@ -18,7 +18,7 @@ RUN BRANCH=$(git rev-parse --abbrev-ref HEAD) && \ # Stage 2: Run the app in a lightweight image FROM node:21-alpine as deploy -ENV ELASTICSEARCH_PROTOCOL=http +ENV ELASTICSEARCH_PROTOCOL=https ENV ELASTICSEARCH_HOST=localhost ENV ELASTICSEARCH_USERNAME=elastic ENV ELASTICSEARCH_PASS="o0=eLmmQbsrdEW89a-Id" @@ -26,7 +26,7 @@ ENV ELASTICSEARCH_PORT=9200 ENV API_PORT=8081 ENV INDEX_NAME=search-index ENV CONNECTION_TIMEOUT=10 -ENV CONNECTION_STRING=http://admin:admin@mentorhub-searchdb:9200 +ENV CONNECTION_STRING=https://@mentorhub-searchdb:9200 WORKDIR /app