From 53cddbe6a692fee02aae81dd1511072951894123 Mon Sep 17 00:00:00 2001 From: Matheus Ribeiro Pimenta Nunes Date: Tue, 8 Oct 2019 19:02:58 -0300 Subject: [PATCH] feat: search cities by a search string Closes #12 --- .../controllers/CityController.test.ts | 142 ++++++++++++++++++ package.json | 3 + src/config/googleMaps.ts | 3 + src/controllers/CityController.ts | 43 ++++++ src/routes.ts | 4 + src/types.ts | 4 + yarn.lock | 34 ++++- 7 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 __tests__/integration/controllers/CityController.test.ts create mode 100644 src/config/googleMaps.ts create mode 100644 src/controllers/CityController.ts diff --git a/__tests__/integration/controllers/CityController.test.ts b/__tests__/integration/controllers/CityController.test.ts new file mode 100644 index 0000000..e2439e9 --- /dev/null +++ b/__tests__/integration/controllers/CityController.test.ts @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/camelcase */ + +import request from 'supertest' +import moxios from 'moxios' + +import app from '../../../src/app' + +describe('CityController', () => { + beforeEach(function () { + moxios.install() + }) + + afterEach(function () { + moxios.uninstall() + }) + + describe('search', () => { + it('should return the cities when the request is mada with valid args', async () => { + moxios.wait(() => { + const request = moxios.requests.mostRecent() + request.respondWith({ + status: 200, + response: { + predictions: [ + { + description: 'Victoria, BC, Canadá', + id: 'd5892cffd777f0252b94ab2651fea7123d2aa34a', + matched_substrings: [ + { + length: 4, + offset: 0 + } + ], + place_id: 'ChIJcWGw3Ytzj1QR7Ui7HnTz6Dg', + reference: 'ChIJcWGw3Ytzj1QR7Ui7HnTz6Dg', + structured_formatting: { + main_text: 'Victoria', + main_text_matched_substrings: [ + { + length: 4, + offset: 0 + } + ], + secondary_text: 'BC, Canadá' + }, + terms: [ + { + offset: 0, + value: 'Victoria' + }, + { + offset: 10, + value: 'BC' + }, + { + offset: 14, + value: 'Canadá' + } + ], + types: [ + 'locality', + 'political', + 'geocode' + ] + }, + { + description: 'Victorville, CA, EUA', + id: 'dd296d3fde2a539b9279cdd817c01183f69d07a7', + matched_substrings: [ + { + length: 4, + offset: 0 + } + ], + place_id: 'ChIJedLdY1pkw4ARdjT0JVkRlQ0', + reference: 'ChIJedLdY1pkw4ARdjT0JVkRlQ0', + structured_formatting: { + main_text: 'Victorville', + main_text_matched_substrings: [ + { + length: 4, + offset: 0 + } + ], + secondary_text: 'CA, EUA' + }, + terms: [ + { + offset: 0, + value: 'Victorville' + }, + { + offset: 13, + value: 'CA' + }, + { + offset: 17, + value: 'EUA' + } + ], + types: [ + 'locality', + 'political', + 'geocode' + ] + } + ] + } + }) + }) + + const response = await request(app) + .get('/cities?search=Vict') + + expect(response.body.cities).toHaveLength(2) + }) + + it('should return an error when the google maps api request return an error message', async () => { + moxios.wait(() => { + const request = moxios.requests.mostRecent() + request.respondWith({ + status: 200, + response: { + error_message: 'You have exceeded your daily request quota for this API.' + } + }) + }) + + const response = await request(app) + .get('/cities?search=anycity') + + expect(response.status).toBe(400) + }) + + it('should not return the cities if the search string is invalid', async () => { + const response = await request(app) + .get('/cities?search=') + + expect(response.status).toBe(400) + }) + }) +}) diff --git a/package.json b/package.json index c221bf3..ddb81f2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ } }, "dependencies": { + "axios": "^0.19.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^8.1.0", @@ -49,6 +50,7 @@ "@types/jest": "^24.0.18", "@types/jsonwebtoken": "^8.3.4", "@types/mongoose": "^5.5.19", + "@types/moxios": "^0.4.9", "@types/supertest": "^2.0.8", "@typescript-eslint/eslint-plugin": "^2.3.2", "@typescript-eslint/parser": "^2.3.2", @@ -67,6 +69,7 @@ "husky": "^3.0.8", "jest": "^24.9.0", "lint-staged": "^9.4.2", + "moxios": "^0.4.0", "nodemon": "^1.19.3", "prettier": "^1.18.2", "sucrase": "^3.10.1", diff --git a/src/config/googleMaps.ts b/src/config/googleMaps.ts new file mode 100644 index 0000000..0b507ef --- /dev/null +++ b/src/config/googleMaps.ts @@ -0,0 +1,3 @@ +export default { + placeUrl: 'https://maps.googleapis.com/maps/api/place/autocomplete/json' +} diff --git a/src/controllers/CityController.ts b/src/controllers/CityController.ts new file mode 100644 index 0000000..c4cf738 --- /dev/null +++ b/src/controllers/CityController.ts @@ -0,0 +1,43 @@ +import { Request, Response } from 'express' +import * as Yup from 'yup' +import axios from 'axios' +import map from 'lodash/map' + +import { GoogleMapPrediction } from '../types' +import googleMapsConfig from '../config/googleMaps' + +class CityController { + async search (req: Request, res: Response): Promise { + const schema = Yup.string().required() + + if (!(await schema.isValid(req.query.search))) { + return res.status(400).json({ error: 'Validation fails' }) + } + + try { + const response = await axios.get(googleMapsConfig.placeUrl, { + params: { + input: req.query.search, + types: '(cities)', + language: 'es_US', + key: process.env.GOOGLE_MAPS_API_KEY + } + }) + + if (response.data.error_message) { + return res.status(400).json({ error: response.data.error_message }) + } + + const normalizedCities = map( + response.data.predictions, + (prediction: GoogleMapPrediction) => prediction.description + ) + + return res.status(200).json({ cities: normalizedCities }) + } catch (error) { + return res.status(400).json({ error: 'Unable to search cities' }) + } + } +} + +export default new CityController() diff --git a/src/routes.ts b/src/routes.ts index cbf4474..b1d176e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,6 +5,7 @@ import authMiddleware from './middlewares/auth' import UserController from './controllers/UserController' import AuthController from './controllers/AuthController' import PostController from './controllers/PostController' +import CityController from './controllers/CityController' const routes = Router() @@ -14,6 +15,9 @@ routes.post('/login', AuthController.login) // User routes.post('/register', UserController.register) +// City +routes.get('/cities', CityController.search) + // Auth Middleware routes.use(authMiddleware) diff --git a/src/types.ts b/src/types.ts index eb12ed0..7a9320e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,3 +5,7 @@ import { Request } from 'express' export interface AuthRequest extends Request { userId: string } + +export interface GoogleMapPrediction { + description: string +} diff --git a/yarn.lock b/yarn.lock index e558cd4..7da7c12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -535,6 +535,13 @@ "@types/mongodb" "*" "@types/node" "*" +"@types/moxios@^0.4.9": + version "0.4.9" + resolved "https://registry.yarnpkg.com/@types/moxios/-/moxios-0.4.9.tgz#64f5c067e0e0173f72944b785689420e04015b86" + integrity sha512-Sd1b24QRW2N194j2LEDPQAZK1h0TBtpN+2EIH+rERCgm38qm14JZwC7NlpE7n3jULhlCIPZBG8uNcbjF8KcCaQ== + dependencies: + axios "^0.19.0" + "@types/node@*", "@types/node@^12.0.2": version "12.7.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.11.tgz#be879b52031cfb5d295b047f5462d8ef1a716446" @@ -871,6 +878,14 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== +axios@^0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0.tgz#8e09bff3d9122e133f7b8101c8fbdd00ed3d2ab8" + integrity sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ== + dependencies: + follow-redirects "1.5.10" + is-buffer "^2.0.2" + babel-jest@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-24.9.0.tgz#3fc327cb8467b89d14d7bc70e315104a783ccd54" @@ -1508,7 +1523,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: dependencies: ms "2.0.0" -debug@3.1.0: +debug@3.1.0, debug@=3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== @@ -2323,6 +2338,13 @@ fn-name@~2.0.1: resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7" integrity sha1-UhTXU3pNBqSjAcDMJi/rhBiAAuc= +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -2910,6 +2932,11 @@ is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-buffer@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623" + integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== + is-callable@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" @@ -4187,6 +4214,11 @@ mongoose@^5.7.3: sift "7.0.1" sliced "1.0.1" +moxios@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/moxios/-/moxios-0.4.0.tgz#fc0da2c65477d725ca6b9679d58370ed0c52f53b" + integrity sha1-/A2ixlR31yXKa5Z51YNw7QxS9Ts= + mpath@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.6.0.tgz#aa922029fca4f0f641f360e74c5c1b6a4c47078e"