diff --git a/packages/geo/__tests__/Geo.test.ts b/packages/geo/__tests__/Geo.test.ts index a3d44eb7f3a..e175ac4a475 100644 --- a/packages/geo/__tests__/Geo.test.ts +++ b/packages/geo/__tests__/Geo.test.ts @@ -31,7 +31,19 @@ import { awsConfig, TestPlacePascalCase, testPlaceCamelCase, -} from './data'; + validGeometry, + validGeofences, + validGeofence1, + singleGeofenceCamelcaseResults, + batchGeofencesCamelcaseResults, + geofencesWithInvalidId, +} from './testData'; + +import { + mockBatchPutGeofenceCommand, + mockGetGeofenceCommand, + mockListGeofencesCommand, +} from './testUtils'; LocationClient.prototype.send = jest.fn(async command => { if ( @@ -147,7 +159,7 @@ describe('Geo', () => { geo.configure({}); expect(() => geo.getAvailableMaps()).toThrow( - "No map resources found in amplify config, run 'amplify add geo' to create them and run `amplify push` after" + "No map resources found in amplify config, run 'amplify add geo' to create one and run `amplify push` after" ); }); @@ -172,7 +184,7 @@ describe('Geo', () => { geo.configure({}); expect(() => geo.getDefaultMap()).toThrow( - "No map resources found in amplify config, run 'amplify add geo' to create them and run `amplify push` after" + "No map resources found in amplify config, run 'amplify add geo' to create one and run `amplify push` after" ); }); @@ -438,7 +450,7 @@ describe('Geo', () => { }); describe('searchByCoordinates', () => { - const testCoordinates: Coordinates = [12345, 67890]; + const testCoordinates: Coordinates = [45, 90]; test('should search with just coordinate input', async () => { jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { @@ -500,4 +512,192 @@ describe('Geo', () => { ); }); }); + + describe('saveGeofences', () => { + test('saveGeofences with a single geofence', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementationOnce(mockBatchPutGeofenceCommand); + + const geo = new GeoClass(); + geo.configure(awsConfig); + + // Check that results are what's expected + const results = await geo.saveGeofences(validGeofence1); + expect(results).toEqual(singleGeofenceCamelcaseResults); + + // Expect that the API was called with the proper input + const spyon = jest.spyOn(LocationClient.prototype, 'send'); + const input = spyon.mock.calls[0][0].input; + const output = { + Entries: [ + { + GeofenceId: validGeofence1.geofenceId, + Geometry: { + Polygon: validGeofence1.geometry.polygon, + }, + }, + ], + CollectionName: 'geofenceCollectionExample', + }; + expect(input).toEqual(output); + }); + + test('saveGeofences with multiple geofences', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementation(mockBatchPutGeofenceCommand); + + const geo = new GeoClass(); + geo.configure(awsConfig); + + // Check that results are what's expected + const results = await geo.saveGeofences(validGeofences); + expect(results).toEqual(batchGeofencesCamelcaseResults); + + // Expect that the API was called the right amount of times + const expectedNumberOfCalls = Math.floor(validGeofences.length / 10) + 1; + expect(LocationClient.prototype.send).toHaveBeenCalledTimes( + expectedNumberOfCalls + ); + }); + + test('should error if there is a bad geofence in the input', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + const geo = new GeoClass(); + geo.configure(awsConfig); + + await expect( + geo.saveGeofences(geofencesWithInvalidId) + ).rejects.toThrowError( + `Invalid geofenceId: t|-|!$ !$ N()T V@|_!D Ids can only contain alphanumeric characters, hyphens, underscores and periods.` + ); + }); + + test('should fail if there is no provider', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + const geo = new GeoClass(); + geo.configure(awsConfig); + geo.removePluggable('AmazonLocationService'); + + await expect(geo.saveGeofences(validGeofence1)).rejects.toThrow( + 'No plugin found in Geo for the provider' + ); + }); + }); + + describe('getGeofence', () => { + test('getGeofence returns the right geofence', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementationOnce(mockGetGeofenceCommand); + + const geo = new GeoClass(); + geo.configure(awsConfig); + + // Check that results are what's expected + const results = await geo.getGeofence('testGeofenceId'); + const expected = { + geofenceId: 'testGeofenceId', + geometry: validGeometry, + createTime: '2020-04-01T21:00:00.000Z', + updateTime: '2020-04-01T21:00:00.000Z', + status: 'ACTIVE', + }; + expect(results).toEqual(expected); + + // Expect that the API was called with the proper input + const spyon = jest.spyOn(LocationClient.prototype, 'send'); + const input = spyon.mock.calls[0][0].input; + const output = { + GeofenceId: 'testGeofenceId', + CollectionName: 'geofenceCollectionExample', + }; + expect(input).toEqual(output); + }); + + test('getGeofence errors when a bad geofenceId is given', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementationOnce(mockGetGeofenceCommand); + + const geo = new GeoClass(); + geo.configure(awsConfig); + + const badGeofenceId = 't|-|!$ !$ N()T V@|_!D'; + await expect(geo.getGeofence(badGeofenceId)).rejects.toThrow( + `Invalid geofenceId: ${badGeofenceId} Ids can only contain alphanumeric characters, hyphens, underscores and periods.` + ); + }); + }); + + describe('listGeofences', () => { + test('listGeofences gets the first 100 geofences when no arguments are given', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementationOnce(mockListGeofencesCommand); + + const geo = new GeoClass(); + geo.configure(awsConfig); + + // Check that results are what's expected + const results = await geo.listGeofences(); + expect(results.entries.length).toEqual(100); + }); + + test('listGeofences gets the second 100 geofences when nextToken is passed', async () => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementation(mockListGeofencesCommand); + + const geo = new GeoClass(); + geo.configure(awsConfig); + + // Check that results are what's expected + + const first100Geofences = await geo.listGeofences(); + + const second100Geofences = await geo.listGeofences({ + nextToken: first100Geofences.nextToken, + }); + + expect(second100Geofences.entries.length).toEqual(100); + expect(second100Geofences.entries[0].geofenceId).toEqual( + 'validGeofenceId100' + ); + expect(second100Geofences.entries[99].geofenceId).toEqual( + 'validGeofenceId199' + ); + }); + }); }); diff --git a/packages/geo/__tests__/Providers/AmazonLocationServiceProvider.test.ts b/packages/geo/__tests__/Providers/AmazonLocationServiceProvider.test.ts index 69514fc0b5e..be02b6a1644 100644 --- a/packages/geo/__tests__/Providers/AmazonLocationServiceProvider.test.ts +++ b/packages/geo/__tests__/Providers/AmazonLocationServiceProvider.test.ts @@ -24,11 +24,22 @@ import { awsConfig, TestPlacePascalCase, testPlaceCamelCase, -} from '../data'; + validGeofences, + batchGeofencesCamelcaseResults, + validGeometry, +} from '../testData'; +import { + createGeofenceInputArray, + mockBatchPutGeofenceCommand, + mockGetGeofenceCommand, + mockListGeofencesCommand, + mockDeleteGeofencesCommand, +} from '../testUtils'; import { SearchByTextOptions, SearchByCoordinatesOptions, Coordinates, + AmazonLocationServiceGeofence, } from '../../src/types'; LocationClient.prototype.send = jest.fn(async command => { @@ -104,7 +115,7 @@ describe('AmazonLocationServiceProvider', () => { const provider = new AmazonLocationServiceProvider(); provider.configure(); expect(() => provider.getAvailableMaps()).toThrow( - "No map resources found in amplify config, run 'amplify add geo' to create them and run `amplify push` after" + "No map resources found in amplify config, run 'amplify add geo' to create one and run `amplify push` after" ); }); @@ -128,7 +139,7 @@ describe('AmazonLocationServiceProvider', () => { provider.configure(); expect(() => provider.getDefaultMap()).toThrow( - "No map resources found in amplify config, run 'amplify add geo' to create them and run `amplify push` after" + "No map resources found in amplify config, run 'amplify add geo' to create one and run `amplify push` after" ); }); @@ -302,7 +313,7 @@ describe('AmazonLocationServiceProvider', () => { locationProvider.configure({}); expect(locationProvider.searchByText(testString)).rejects.toThrow( - 'No Search Index found, please run `amplify add geo` to add one and run `amplify push` after.' + 'No Search Index found in amplify config, please run `amplify add geo` to create one and run `amplify push` after.' ); }); }); @@ -452,13 +463,13 @@ describe('AmazonLocationServiceProvider', () => { await expect( locationProvider.searchForSuggestions(testString) ).rejects.toThrow( - 'No Search Index found, please run `amplify add geo` to add one and run `amplify push` after.' + 'No Search Index found in amplify config, please run `amplify add geo` to create one and run `amplify push` after.' ); }); }); describe('searchByCoordinates', () => { - const testCoordinates: Coordinates = [12345, 67890]; + const testCoordinates: Coordinates = [45, 90]; test('should search with just text input', async () => { jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { @@ -540,10 +551,364 @@ describe('AmazonLocationServiceProvider', () => { const locationProvider = new AmazonLocationServiceProvider(); locationProvider.configure({}); - expect( + await expect( locationProvider.searchByCoordinates(testCoordinates) ).rejects.toThrow( - 'No Search Index found, please run `amplify add geo` to add one and run `amplify push` after.' + 'No Search Index found in amplify config, please run `amplify add geo` to create one and run `amplify push` after.' + ); + }); + }); + + describe('saveGeofences', () => { + test('saveGeofences with multiple geofences', async () => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementation(mockBatchPutGeofenceCommand); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const results = await locationProvider.saveGeofences(validGeofences); + + expect(results).toEqual(batchGeofencesCamelcaseResults); + }); + + test('saveGeofences calls batchPutGeofences in batches of 10 from input', async () => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const numberOfGeofences = 44; + const input = createGeofenceInputArray(numberOfGeofences); + + const spyonProvider = jest.spyOn(locationProvider, 'saveGeofences'); + const spyonClient = jest.spyOn(LocationClient.prototype, 'send'); + + const results = await locationProvider.saveGeofences(input); + + const expected = { + successes: input.map(({ geofenceId }) => { + return { + geofenceId, + createTime: '2020-04-01T21:00:00.000Z', + updateTime: '2020-04-01T21:00:00.000Z', + }; + }), + errors: [], + }; + expect(results).toEqual(expected); + + const spyProviderInput = spyonProvider.mock.calls[0][0]; + + const spyClientInput = spyonClient.mock.calls; + + expect(spyClientInput.length).toEqual( + Math.ceil(spyProviderInput.length / 10) + ); + }); + + test('saveGeofences properly handles errors with bad network calls', async () => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const input = createGeofenceInputArray(44); + input[22].geofenceId = 'badId'; + const validEntries = [...input.slice(0, 20), ...input.slice(30, 44)]; + + const spyonClient = jest.spyOn(LocationClient.prototype, 'send'); + spyonClient.mockImplementation(geofenceInput => { + const entries = geofenceInput.input as any; + + if (entries.Entries.some(entry => entry.GeofenceId === 'badId')) { + return Promise.reject(new Error('Bad network call')); + } + + const resolution = { + Successes: entries.Entries.map(({ GeofenceId }) => { + return { + GeofenceId, + CreateTime: '2020-04-01T21:00:00.000Z', + UpdateTime: '2020-04-01T21:00:00.000Z', + }; + }), + Errors: [], + }; + return Promise.resolve(resolution); + }); + + const results = await locationProvider.saveGeofences(input); + const badResults = input.slice(20, 30).map(input => { + return { + error: { + code: 'APIConnectionError', + message: 'Bad network call', + }, + geofenceId: input.geofenceId, + }; + }); + const expected = { + successes: validEntries.map(({ geofenceId }) => { + return { + geofenceId, + createTime: '2020-04-01T21:00:00.000Z', + updateTime: '2020-04-01T21:00:00.000Z', + }; + }), + errors: badResults, + }; + expect(results).toEqual(expected); + }); + + test('should error if there are no geofenceCollections in config', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure({}); + + await expect( + locationProvider.saveGeofences(validGeofences) + ).rejects.toThrow( + 'No Geofence Collections found, please run `amplify add geo` to create one and run `amplify push` after.' + ); + }); + }); + + describe('getGeofence', () => { + test('getGeofence returns the right geofence', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementation(mockGetGeofenceCommand); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const results: AmazonLocationServiceGeofence = + await locationProvider.getGeofence('geofenceId'); + + const expected = { + geofenceId: 'geofenceId', + geometry: validGeometry, + createTime: '2020-04-01T21:00:00.000Z', + updateTime: '2020-04-01T21:00:00.000Z', + status: 'ACTIVE', + }; + + await expect(results).toEqual(expected); + }); + + test('should error if there are no geofenceCollections in config', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure({}); + + await expect(locationProvider.getGeofence('geofenceId')).rejects.toThrow( + 'No Geofence Collections found, please run `amplify add geo` to create one and run `amplify push` after.' + ); + }); + }); + + describe('listGeofences', () => { + test('listGeofences gets the first 100 geofences when no arguments are given', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementation(mockListGeofencesCommand); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const geofences = await locationProvider.listGeofences(); + + expect(geofences.entries.length).toEqual(100); + }); + + test('listGeofences gets the second 100 geofences when nextToken is passed', async () => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementation(mockListGeofencesCommand); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const first100Geofences = await locationProvider.listGeofences(); + + const second100Geofences = await locationProvider.listGeofences({ + nextToken: first100Geofences.nextToken, + }); + + expect(second100Geofences.entries.length).toEqual(100); + expect(second100Geofences.entries[0].geofenceId).toEqual( + 'validGeofenceId100' + ); + expect(second100Geofences.entries[99].geofenceId).toEqual( + 'validGeofenceId199' + ); + }); + + test('should error if there are no geofenceCollections in config', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure({}); + + await expect(locationProvider.listGeofences()).rejects.toThrow( + 'No Geofence Collections found, please run `amplify add geo` to create one and run `amplify push` after.' + ); + }); + }); + + describe('deleteGeofences', () => { + test('deleteGeofences deletes given geofences successfully', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + LocationClient.prototype.send = jest + .fn() + .mockImplementation(mockDeleteGeofencesCommand); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const geofenceIds = validGeofences.map(({ geofenceId }) => geofenceId); + + const results = await locationProvider.deleteGeofences(geofenceIds); + + const expected = { + successes: geofenceIds, + errors: [], + }; + + expect(results).toEqual(expected); + }); + + test('deleteGeofences calls batchDeleteGeofences in batches of 10 from input', async () => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const geofenceIds = validGeofences.map(({ geofenceId }) => geofenceId); + + const spyonProvider = jest.spyOn(locationProvider, 'deleteGeofences'); + const spyonClient = jest.spyOn(LocationClient.prototype, 'send'); + spyonClient.mockImplementation(mockDeleteGeofencesCommand); + + const results = await locationProvider.deleteGeofences(geofenceIds); + + const expected = { + successes: geofenceIds, + errors: [], + }; + expect(results).toEqual(expected); + + const spyProviderInput = spyonProvider.mock.calls[0][0]; + + const spyClientInput = spyonClient.mock.calls; + + expect(spyClientInput.length).toEqual( + Math.ceil(spyProviderInput.length / 10) + ); + }); + + test('deleteGeofences properly handles errors with bad network calls', async () => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure(awsConfig.geo.amazon_location_service); + + const input = createGeofenceInputArray(44).map( + ({ geofenceId }) => geofenceId + ); + input[22] = 'badId'; + const validEntries = [...input.slice(0, 20), ...input.slice(30, 44)]; + + const spyonClient = jest.spyOn(LocationClient.prototype, 'send'); + spyonClient.mockImplementation(geofenceInput => { + const entries = geofenceInput.input as any; + + if (entries.GeofenceIds.some(entry => entry === 'badId')) { + return Promise.reject(new Error('ResourceDoesNotExist')); + } + + const resolution = { + Errors: [ + { + Error: { + Code: 'ResourceDoesNotExist', + Message: 'Resource does not exist', + }, + GeofenceId: 'badId', + }, + ], + }; + return Promise.resolve(resolution); + }); + + const results = await locationProvider.deleteGeofences(input); + const badResults = input.slice(20, 30).map(geofenceId => { + return { + error: { + code: 'ResourceDoesNotExist', + message: 'ResourceDoesNotExist', + }, + geofenceId, + }; + }); + const expected = { + successes: validEntries, + errors: badResults, + }; + expect(results).toEqual(expected); + }); + + test('should error if there are no geofenceCollections in config', async () => { + jest.spyOn(Credentials, 'get').mockImplementationOnce(() => { + return Promise.resolve(credentials); + }); + + const locationProvider = new AmazonLocationServiceProvider(); + locationProvider.configure({}); + + const geofenceIds = validGeofences.map(({ geofenceId }) => geofenceId); + + await expect( + locationProvider.deleteGeofences(geofenceIds) + ).rejects.toThrow( + 'No Geofence Collections found, please run `amplify add geo` to create one and run `amplify push` after.' ); }); }); diff --git a/packages/geo/__tests__/data.ts b/packages/geo/__tests__/data.ts deleted file mode 100644 index db9f64feeac..00000000000 --- a/packages/geo/__tests__/data.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with - * the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -import camelcaseKeys from 'camelcase-keys'; - -export const credentials = { - accessKeyId: 'accessKeyId', - sessionToken: 'sessionToken', - secretAccessKey: 'secretAccessKey', - identityId: 'identityId', - authenticated: true, -}; - -export const awsConfig = { - geo: { - amazon_location_service: { - maps: { - items: { - geoJsExampleMap1: { - style: 'VectorEsriStreets', - }, - geoJsExampleMap2: { - style: 'VectorEsriTopographic', - }, - }, - default: 'geoJsExampleMap1', - }, - search_indices: { - items: ['geoJSSearchExample'], - default: 'geoJSSearchExample', - }, - region: 'us-west-2', - }, - }, - credentials, -}; - -export const TestPlacePascalCase = { - AddressNumber: '123', - Country: 'United States', - Geometry: { - Point: [2345, 6789], - }, - Label: "don't try to label me", - Municipality: 'Muni', - Neighborhood: 'hay', - PostalCode: '00000', - Region: 'us-west-2', - Street: 'Heylo St', - SubRegion: 'Underground', -}; - -export const testPlaceCamelCase = camelcaseKeys(TestPlacePascalCase, { - deep: true, -}); diff --git a/packages/geo/__tests__/testData.ts b/packages/geo/__tests__/testData.ts new file mode 100644 index 00000000000..50d9b9b588b --- /dev/null +++ b/packages/geo/__tests__/testData.ts @@ -0,0 +1,219 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +import camelcaseKeys from 'camelcase-keys'; + +import { + Coordinates, + LinearRing, + GeofencePolygon, + GeofenceInput, + PolygonGeometry, +} from '../src/types'; + +export const credentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', + identityId: 'identityId', + authenticated: true, +}; + +export const awsConfig = { + geo: { + amazon_location_service: { + maps: { + items: { + geoJsExampleMap1: { + style: 'VectorEsriStreets', + }, + geoJsExampleMap2: { + style: 'VectorEsriTopographic', + }, + }, + default: 'geoJsExampleMap1', + }, + search_indices: { + items: ['geoJSSearchExample'], + default: 'geoJSSearchExample', + }, + geofenceCollections: { + items: ['geofenceCollectionExample'], + default: 'geofenceCollectionExample', + }, + region: 'us-west-2', + }, + }, + credentials, +}; + +export const TestPlacePascalCase = { + AddressNumber: '123', + Country: 'United States', + Geometry: { + Point: [2345, 6789], + }, + Label: "don't try to label me", + Municipality: 'Muni', + Neighborhood: 'hay', + PostalCode: '00000', + Region: 'us-west-2', + Street: 'Heylo St', + SubRegion: 'Underground', +}; + +export const testPlaceCamelCase = camelcaseKeys(TestPlacePascalCase, { + deep: true, +}); + +// Coordinates +export const validCoordinates1: Coordinates = [ + -123.14695358276366, 49.290090146520434, +]; +export const validCoordinates2: Coordinates = [ + -123.1358814239502, 49.294960279811974, +]; +export const validCoordinates3: Coordinates = [ + -123.15021514892577, 49.29300108863353, +]; +export const validCoordinates4: Coordinates = [ + -123.14909934997559, 49.29132171993048, +]; +export const validCoordinates5: Coordinates = [ + -123.14695358276361, 49.290090146520431, +]; + +export const invalidLngCoordinates1: Coordinates = [181, 0]; +export const invalidLngCoordinates2: Coordinates = [-181, 0]; +export const invalidLngCoordinates: LinearRing = [ + invalidLngCoordinates1, + invalidLngCoordinates2, +]; + +export const invalidLatCoordinates1: Coordinates = [0, -91]; +export const invalidLatCoordinates2: Coordinates = [0, 91]; +export const invalidLatCoordinates: LinearRing = [ + invalidLatCoordinates1, + invalidLatCoordinates2, +]; + +export const infiniteLngCoordinate1: Coordinates = [Infinity, 0]; +export const infiniteLngCoordinate2: Coordinates = [-Infinity, 0]; +export const infiniteLatCoordinate1: Coordinates = [0, Infinity]; +export const infiniteLatCoordinate2: Coordinates = [0, -Infinity]; +export const infiniteCoordinates: LinearRing = [ + infiniteLngCoordinate1, + infiniteLngCoordinate2, + infiniteLatCoordinate1, + infiniteLatCoordinate2, +]; + +// Linear Rings +export const validLinearRing: LinearRing = [ + validCoordinates1, + validCoordinates2, + validCoordinates3, + validCoordinates4, + validCoordinates1, +]; + +export const linearRingIncomplete: LinearRing = [ + validCoordinates1, + validCoordinates2, + validCoordinates3, + validCoordinates4, +]; +export const linearRingTooSmall: LinearRing = [ + validCoordinates1, + validCoordinates2, + validCoordinates1, +]; +export const linearRingBadCoordinates: LinearRing = [ + invalidLngCoordinates1, + invalidLatCoordinates1, + validCoordinates2, + validCoordinates3, + validCoordinates4, +]; + +// Polygons +export const validPolygon: GeofencePolygon = [validLinearRing]; +export const polygonTooBig: GeofencePolygon = [ + validLinearRing, + validLinearRing, +]; + +// Geometry +export const validGeometry: PolygonGeometry = { + polygon: validPolygon, +}; + +// Geofences +export const validGeofence1: GeofenceInput = { + geofenceId: 'validGeofenceId1', + geometry: validGeometry, +}; +export const validGeofence2: GeofenceInput = { + geofenceId: 'validGeofenceId2', + geometry: validGeometry, +}; +export const validGeofence3: GeofenceInput = { + geofenceId: 'validGeofenceId3', + geometry: validGeometry, +}; +export const geofenceWithInvalidId: GeofenceInput = { + geofenceId: 't|-|!$ !$ N()T V@|_!D', + geometry: validGeometry, +}; + +export const validGeofences = []; +for (let i = 0; i < 132; i++) { + validGeofences.push({ + geofenceId: `validGeofenceId${i}`, + geometry: validGeometry, + }); +} + +export const geofencesWithDuplicate = [ + validGeofence1, + validGeofence2, + validGeofence3, + validGeofence1, +]; +export const geofencesWithInvalidId = [ + validGeofence1, + validGeofence2, + validGeofence3, + geofenceWithInvalidId, +]; + +export const singleGeofenceCamelcaseResults = { + successes: [ + { + createTime: '2020-04-01T21:00:00.000Z', + updateTime: '2020-04-01T21:00:00.000Z', + geofenceId: 'validGeofenceId1', + }, + ], + errors: [], +}; + +export const batchGeofencesCamelcaseResults = { + successes: validGeofences.map(({ geofenceId }, i) => { + return { + createTime: '2020-04-01T21:00:00.000Z', + updateTime: '2020-04-01T21:00:00.000Z', + geofenceId, + }; + }), + errors: [], +}; diff --git a/packages/geo/__tests__/testUtils.ts b/packages/geo/__tests__/testUtils.ts new file mode 100644 index 00000000000..78f4a319627 --- /dev/null +++ b/packages/geo/__tests__/testUtils.ts @@ -0,0 +1,91 @@ +import { + BatchPutGeofenceCommand, + GetGeofenceCommand, + ListGeofencesCommand, + BatchDeleteGeofenceCommand, +} from '@aws-sdk/client-location'; + +import { validPolygon, validGeometry } from './testData'; + +export function createGeofenceInputArray(numberOfGeofences) { + const geofences = []; + for (let i = 0; i < numberOfGeofences; i++) { + geofences.push({ + geofenceId: `validGeofenceId${i}`, + geometry: validGeometry, + }); + } + return geofences; +} + +export function createGeofenceOutputArray(numberOfGeofences) { + const geofences = []; + for (let i = 0; i < numberOfGeofences; i++) { + geofences.push({ + GeofenceId: `validGeofenceId${i}`, + Geometry: { + Polygon: validPolygon, + }, + Status: 'ACTIVE', + CreateTime: '2020-04-01T21:00:00.000Z', + UpdateTime: '2020-04-01T21:00:00.000Z', + }); + } + return geofences; +} + +export function mockBatchPutGeofenceCommand(command) { + if (command instanceof BatchPutGeofenceCommand) { + return { + Successes: command.input.Entries.map(geofence => { + return { + CreateTime: '2020-04-01T21:00:00.000Z', + UpdateTime: '2020-04-01T21:00:00.000Z', + GeofenceId: geofence.GeofenceId, + }; + }), + Errors: [], + }; + } + if (command instanceof GetGeofenceCommand) { + return mockGetGeofenceCommand(command); + } +} + +export function mockGetGeofenceCommand(command) { + if (command instanceof GetGeofenceCommand) { + return { + GeofenceId: command.input.GeofenceId, + Geometry: { + Polygon: validPolygon, + }, + CreateTime: '2020-04-01T21:00:00.000Z', + UpdateTime: '2020-04-01T21:00:00.000Z', + Status: 'ACTIVE', + }; + } +} + +export function mockListGeofencesCommand(command) { + if (command instanceof ListGeofencesCommand) { + const geofences = createGeofenceOutputArray(200); + if (command.input.NextToken === 'THIS IS YOUR TOKEN') { + return { + Entries: geofences.slice(100, 200), + NextToken: 'THIS IS YOUR SECOND TOKEN', + }; + } + return { + Entries: geofences.slice(0, 100), + NextToken: 'THIS IS YOUR TOKEN', + }; + } +} + +export function mockDeleteGeofencesCommand(command) { + if (command instanceof BatchDeleteGeofenceCommand) { + return { + Errors: [], + }; + } +} diff --git a/packages/geo/__tests__/util.test.ts b/packages/geo/__tests__/util.test.ts new file mode 100644 index 00000000000..a3234a1bc1c --- /dev/null +++ b/packages/geo/__tests__/util.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { + validateCoordinates, + validateLinearRing, + validatePolygon, + validateGeofences, +} from '../src/util'; + +import { + invalidLngCoordinates, + invalidLatCoordinates, + infiniteCoordinates, + validLinearRing, + linearRingIncomplete, + linearRingTooSmall, + linearRingBadCoordinates, + validPolygon, + polygonTooBig, + validGeofences, + geofencesWithDuplicate, + geofencesWithInvalidId, +} from './testData'; + +describe('Geo utility functions', () => { + describe('validateCoordinates', () => { + test('should not throw an error for valid coordinates', () => { + validLinearRing.forEach(([lng, lat]) => { + expect(() => validateCoordinates(lng, lat)).not.toThrowError(); + }); + }); + + test('should error with message for bad longitude', () => { + invalidLngCoordinates.forEach(([lng, lat]) => { + expect(() => validateCoordinates(lng, lat)).toThrowError( + 'Longitude must be between -180 and 180 degrees inclusive.' + ); + }); + }); + + test('should error with message for bad latitude', () => { + invalidLatCoordinates.forEach(([lng, lat]) => { + expect(() => validateCoordinates(lng, lat)).toThrowError( + 'Latitude must be between -90 and 90 degrees inclusive.' + ); + }); + }); + + test('should error with message for coordinates with infinity', () => { + infiniteCoordinates.forEach(([lng, lat]) => { + expect(() => validateCoordinates(lng, lat)).toThrowError( + `Invalid coordinates: [${lng},${lat}]` + ); + }); + }); + }); + + describe('validateLinearRing', () => { + test('should not throw an error for a valid LinearRing', () => { + const result = validateLinearRing(validLinearRing); + expect(() => result).not.toThrowError(); + }); + test('should error if first and last coordinates do not match', () => { + expect(() => validateLinearRing(linearRingIncomplete)).toThrowError( + `LinearRing's first and last coordinates are not the same` + ); + }); + test('should error if LinearRing has less than 4 elements', () => { + expect(() => validateLinearRing(linearRingTooSmall)).toThrowError( + 'LinearRing must contain 4 or more coordinates.' + ); + }); + test('should error if any coordinates are not valid', () => { + expect(() => validateLinearRing(linearRingBadCoordinates)).toThrowError( + 'One or more of the coordinates are not valid: [{"coordinates":[181,0],"error":"Longitude must be between -180 and 180 degrees inclusive."},{"coordinates":[0,-91],"error":"Latitude must be between -90 and 90 degrees inclusive."}]' + ); + }); + }); + + describe('validatePolygon', () => { + test('should not throw an error for a valid Polygon', () => { + expect(() => validatePolygon(validPolygon)).not.toThrowError(); + }); + test('should error if polygon is not a length of 1', () => { + expect(() => validatePolygon(polygonTooBig)).toThrowError( + `Polygon ${JSON.stringify( + polygonTooBig + )} geometry.polygon must have a single LinearRing array` + ); + expect(() => validatePolygon([])).toThrowError( + `Polygon ${JSON.stringify( + [] + )} geometry.polygon must have a single LinearRing array` + ); + }); + }); + + describe('validateGeofences', () => { + test('should not throw an error for valid geofences', () => { + const result = validateGeofences(validGeofences); + expect(() => result).not.toThrowError(); + }); + test('should error if a geofenceId is not unique', () => { + expect(() => validateGeofences(geofencesWithDuplicate)).toThrowError( + `Duplicate geofenceId: validGeofenceId1` + ); + }); + test('should error if a geofenceId is not valid', () => { + expect(() => validateGeofences(geofencesWithInvalidId)).toThrowError( + `Invalid geofenceId: t|-|!$ !$ N()T V@|_!D Ids can only contain alphanumeric characters, hyphens, underscores and periods.` + ); + }); + }); +}); diff --git a/packages/geo/package.json b/packages/geo/package.json index bfd85776a26..73756b15820 100644 --- a/packages/geo/package.json +++ b/packages/geo/package.json @@ -71,7 +71,8 @@ "__tests__/model.ts", "__tests__/schema.ts", "__tests__/helpers.ts", - "__tests__/data.ts" + "__tests__/testData.ts", + "__tests__/testUtils.ts" ], "moduleFileExtensions": [ "ts", diff --git a/packages/geo/src/Geo.ts b/packages/geo/src/Geo.ts index fae4708f313..0d1a71c8ecb 100644 --- a/packages/geo/src/Geo.ts +++ b/packages/geo/src/Geo.ts @@ -18,6 +18,13 @@ import { import { AmazonLocationServiceProvider } from './Providers/AmazonLocationServiceProvider'; import { + validateCoordinates, + validateGeofences, + validateGeofenceId, +} from './util'; + +import { + Place, GeoConfig, Coordinates, SearchByTextOptions, @@ -25,6 +32,13 @@ import { SearchByCoordinatesOptions, GeoProvider, MapStyle, + GeofenceInput, + GeofenceOptions, + SaveGeofencesResults, + Geofence, + ListGeofenceOptions, + ListGeofenceResults, + DeleteGeofencesResults, } from './types'; const logger = new Logger('Geo'); @@ -143,7 +157,10 @@ export class GeoClass { * @param {SearchByTextOptions} options? - Optional parameters to the search * @returns {Promise} - Promise resolves to a list of Places that match search parameters */ - public async searchByText(text: string, options?: SearchByTextOptions) { + public async searchByText( + text: string, + options?: SearchByTextOptions + ): Promise { const { providerName = DEFAULT_PROVIDER } = options || {}; const prov = this.getPluggable(providerName); @@ -185,17 +202,142 @@ export class GeoClass { public async searchByCoordinates( coordinates: Coordinates, options?: SearchByCoordinatesOptions - ) { + ): Promise { const { providerName = DEFAULT_PROVIDER } = options || {}; const prov = this.getPluggable(providerName); + const [lng, lat] = coordinates; try { + validateCoordinates(lng, lat); return await prov.searchByCoordinates(coordinates, options); } catch (error) { logger.debug(error); throw error; } } + + /** + * Create geofences + * @param geofences - Single or array of geofence objects to create + * @param options? - Optional parameters for creating geofences + * @returns {Promise} - Promise that resolves to an object with: + * successes: list of geofences successfully created + * errors: list of geofences that failed to create + */ + public async saveGeofences( + geofences: GeofenceInput | GeofenceInput[], + options?: GeofenceOptions + ): Promise { + const { providerName = DEFAULT_PROVIDER } = options || {}; + const prov = this.getPluggable(providerName); + + // If single geofence input, make it an array for batch call + let geofenceInputArray; + if (!Array.isArray(geofences)) { + geofenceInputArray = [geofences]; + } else { + geofenceInputArray = geofences; + } + + try { + // Validate all geofences are unique and valid before calling Provider + validateGeofences(geofenceInputArray); + return await prov.saveGeofences(geofenceInputArray, options); + } catch (error) { + logger.debug(error); + throw error; + } + } + + /** + * Get a single geofence by geofenceId + * @param geofenceId: string + * @param options?: GeofenceOptions + * @returns Promise - Promise that resolves to a geofence object + */ + public async getGeofence( + geofenceId: string, + options?: GeofenceOptions + ): Promise { + const { providerName = DEFAULT_PROVIDER } = options || {}; + const prov = this.getPluggable(providerName); + + try { + // Validate geofenceId is valid before calling Provider + validateGeofenceId(geofenceId); + return await prov.getGeofence(geofenceId, options); + } catch (error) { + logger.debug(error); + throw error; + } + } + + /** + * List geofences + * @param options?: ListGeofenceOptions + * @returns {Promise} - Promise that resolves to an object with: + * entries: list of geofences - 100 geofences are listed per page + * nextToken: token for next page of geofences + */ + public async listGeofences( + options?: ListGeofenceOptions + ): Promise { + const { providerName = DEFAULT_PROVIDER } = options || {}; + const prov = this.getPluggable(providerName); + + try { + return await prov.listGeofences(options); + } catch (error) { + logger.debug(error); + throw error; + } + } + + /** + * Delete geofences + * @param geofenceIds: string|string[] + * @param options?: GeofenceOptions + * @returns {Promise} - Promise that resolves to an object with: + * successes: list of geofences successfully deleted + * errors: list of geofences that failed to delete + */ + public async deleteGeofences( + geofenceIds: string | string[], + options?: GeofenceOptions + ): Promise { + const { providerName = DEFAULT_PROVIDER } = options || {}; + const prov = this.getPluggable(providerName); + + // If single geofence input, make it an array for batch call + let geofenceIdsInputArray; + if (!Array.isArray(geofenceIds)) { + geofenceIdsInputArray = [geofenceIds]; + } else { + geofenceIdsInputArray = geofenceIds; + } + + // Validate all geofenceIds are valid + const badGeofenceIds = geofenceIdsInputArray.filter(geofenceId => { + try { + validateGeofenceId(geofenceId); + } catch (error) { + return false; + } + }); + if (badGeofenceIds.length > 0) { + const errorString = `Invalid geofence ids: ${badGeofenceIds}`; + logger.debug(errorString); + throw new Error(errorString); + } + + // Delete geofences + try { + return await prov.deleteGeofences(geofenceIdsInputArray, options); + } catch (error) { + logger.debug(error); + throw error; + } + } } export const Geo = new GeoClass(); diff --git a/packages/geo/src/Providers/AmazonLocationServiceProvider.ts b/packages/geo/src/Providers/AmazonLocationServiceProvider.ts index 7b57effd4ca..50c9f877ceb 100644 --- a/packages/geo/src/Providers/AmazonLocationServiceProvider.ts +++ b/packages/geo/src/Providers/AmazonLocationServiceProvider.ts @@ -25,6 +25,19 @@ import { SearchPlaceIndexForSuggestionsCommandInput, SearchPlaceIndexForPositionCommand, SearchPlaceIndexForPositionCommandInput, + BatchPutGeofenceCommand, + BatchPutGeofenceCommandInput, + BatchPutGeofenceRequestEntry, + BatchPutGeofenceCommandOutput, + GetGeofenceCommand, + GetGeofenceCommandInput, + GetGeofenceCommandOutput, + ListGeofencesCommand, + ListGeofencesCommandInput, + ListGeofencesCommandOutput, + BatchDeleteGeofenceCommand, + BatchDeleteGeofenceCommandInput, + BatchDeleteGeofenceCommandOutput, } from '@aws-sdk/client-location'; import { @@ -36,6 +49,15 @@ import { AmazonLocationServiceMapStyle, Coordinates, SearchForSuggestionsResults, + GeofenceInput, + AmazonLocationServiceGeofenceOptions, + AmazonLocationServiceListGeofenceOptions, + ListGeofenceResults, + AmazonLocationServiceGeofenceStatus, + SaveGeofencesResults, + AmazonLocationServiceGeofence, + GeofencePolygon, + AmazonLocationServiceDeleteGeofencesResults, } from '../types'; const logger = new Logger('AmazonLocationServiceProvider'); @@ -333,6 +355,320 @@ export class AmazonLocationServiceProvider implements GeoProvider { return results; } + /** + * Create geofences inside of a geofence collection + * @param geofences - Array of geofence objects to create + * @param options? - Optional parameters for creating geofences + * @returns {Promise} - Promise that resolves to an object with: + * successes: list of geofences successfully created + * errors: list of geofences that failed to create + */ + public async saveGeofences( + geofences: GeofenceInput[], + options?: AmazonLocationServiceGeofenceOptions + ): Promise { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + throw new Error('No credentials'); + } + + try { + this._verifyGeofenceCollections(options?.collectionName); + } catch (error) { + logger.debug(error); + throw error; + } + + // Convert geofences to PascalCase for Amazon Location Service format + const PascalGeofences: BatchPutGeofenceRequestEntry[] = geofences.map( + ({ geofenceId, geometry: { polygon } }) => { + return { + GeofenceId: geofenceId, + Geometry: { + Polygon: polygon, + }, + }; + } + ); + const results: SaveGeofencesResults = { + successes: [], + errors: [], + }; + + const batches = []; + + while (PascalGeofences.length > 0) { + // Splice off 10 geofences from input clone due to Amazon Location Service API limit + batches.push(PascalGeofences.splice(0, 10)); + } + + await Promise.all( + batches.map(async batch => { + // for (const batch of batches) { + // Make API call for the 10 geofences + let response: BatchPutGeofenceCommandOutput; + try { + response = await this._AmazonLocationServiceBatchPutGeofenceCall( + batch, + options?.collectionName || this._config.geofenceCollections.default + ); + } catch (error) { + // If the API call fails, add the geofences to the errors array and move to next batch + batch.forEach(geofence => { + results.errors.push({ + geofenceId: geofence.GeofenceId, + error: { + code: 'APIConnectionError', + message: error.message, + }, + }); + }); + return; + } + + // Push all successes to results + response.Successes.forEach(success => { + const { GeofenceId, CreateTime, UpdateTime } = success; + results.successes.push({ + geofenceId: GeofenceId, + createTime: CreateTime, + updateTime: UpdateTime, + }); + }); + + // Push all errors to results + response.Errors.forEach(error => { + const { + Error: { Code, Message }, + GeofenceId, + } = error; + results.errors.push({ + error: { + code: Code, + message: Message, + }, + geofenceId: GeofenceId, + }); + }); + }) + ); + + return results; + } + + /** + * Get geofence from a geofence collection + * @param geofenceId:string + * @param options?: Optional parameters for getGeofence + * @returns {Promise} - Promise that resolves to a geofence object + */ + public async getGeofence( + geofenceId: string, + options?: AmazonLocationServiceGeofenceOptions + ): Promise { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + throw new Error('No credentials'); + } + + // Verify geofence collection exists in aws-config.js + try { + this._verifyGeofenceCollections(options?.collectionName); + } catch (error) { + logger.debug(error); + throw error; + } + + // Create Amazon Location Service Client + const client = new LocationClient({ + credentials: this._config.credentials, + region: this._config.region, + customUserAgent: getAmplifyUserAgent(), + }); + + // Create Amazon Location Service command + const commandInput: GetGeofenceCommandInput = { + GeofenceId: geofenceId, + CollectionName: + options?.collectionName || this._config.geofenceCollections.default, + }; + const command = new GetGeofenceCommand(commandInput); + + // Make API call + let response: GetGeofenceCommandOutput; + try { + response = await client.send(command); + } catch (error) { + logger.debug(error); + throw error; + } + + // Convert response to camelCase for return + const { GeofenceId, CreateTime, UpdateTime, Status, Geometry } = response; + const geofence: AmazonLocationServiceGeofence = { + createTime: CreateTime, + geofenceId: GeofenceId, + geometry: { + polygon: Geometry.Polygon as GeofencePolygon, + }, + status: Status as AmazonLocationServiceGeofenceStatus, + updateTime: UpdateTime, + }; + + return geofence; + } + + /** + * List geofences from a geofence collection + * @param options?: ListGeofenceOptions + * @returns {Promise} - Promise that resolves to an object with: + * entries: list of geofences - 100 geofences are listed per page + * nextToken: token for next page of geofences + */ + public async listGeofences( + options?: AmazonLocationServiceListGeofenceOptions + ): Promise { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + throw new Error('No credentials'); + } + + // Verify geofence collection exists in aws-config.js + try { + this._verifyGeofenceCollections(options?.collectionName); + } catch (error) { + logger.debug(error); + throw error; + } + + // Create Amazon Location Service Client + const client = new LocationClient({ + credentials: this._config.credentials, + region: this._config.region, + customUserAgent: getAmplifyUserAgent(), + }); + + // Create Amazon Location Service input + const listGeofencesInput: ListGeofencesCommandInput = { + NextToken: options?.nextToken, + CollectionName: + options?.collectionName || this._config.geofenceCollections.default, + }; + + // Create Amazon Location Service command + const command: ListGeofencesCommand = new ListGeofencesCommand( + listGeofencesInput + ); + + // Make API call + let response: ListGeofencesCommandOutput; + try { + response = await client.send(command); + } catch (error) { + logger.debug(error); + throw error; + } + + // Convert response to camelCase for return + const { NextToken, Entries } = response; + + const results: ListGeofenceResults = { + entries: Entries.map( + ({ + GeofenceId, + CreateTime, + UpdateTime, + Status, + Geometry: { Polygon }, + }) => { + return { + geofenceId: GeofenceId, + createTime: CreateTime, + updateTime: UpdateTime, + status: Status, + geometry: { + polygon: Polygon as GeofencePolygon, + }, + }; + } + ), + nextToken: NextToken, + }; + + return results; + } + + /** + * Delete geofences from a geofence collection + * @param geofenceIds: string|string[] + * @param options?: GeofenceOptions + * @returns {Promise} - Promise that resolves to an object with: + * successes: list of geofences successfully deleted + * errors: list of geofences that failed to delete + */ + public async deleteGeofences( + geofenceIds: string[], + options?: AmazonLocationServiceGeofenceOptions + ): Promise { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + throw new Error('No credentials'); + } + + // Verify geofence collection exists in aws-config.js + try { + this._verifyGeofenceCollections(options?.collectionName); + } catch (error) { + logger.debug(error); + throw error; + } + + const results: AmazonLocationServiceDeleteGeofencesResults = { + successes: [], + errors: [], + }; + + const batches = []; + + let count = 0; + while (count < geofenceIds.length) { + batches.push(geofenceIds.slice(count, (count += 10))); + } + + await Promise.all( + batches.map(async batch => { + let response; + try { + response = await this._AmazonLocationServiceBatchDeleteGeofenceCall( + batch, + options?.collectionName || this._config.geofenceCollections.default + ); + } catch (error) { + // If the API call fails, add the geofences to the errors array and move to next batch + batch.forEach(geofenceId => { + const errorObject = { + geofenceId, + error: { + code: error.message, + message: error.message, + }, + }; + results.errors.push(errorObject); + }); + return; + } + + const badGeofenceIds = response.Errors.map( + ({ geofenceId }) => geofenceId + ); + results.successes.push( + ...batch.filter(Id => !badGeofenceIds.includes(Id)) + ); + }) + ); + return results; + } + /** * @private */ @@ -345,7 +681,7 @@ export class AmazonLocationServiceProvider implements GeoProvider { this._config.credentials = cred; return true; } catch (error) { - logger.warn('Ensure credentials error. Credentials are:', error); + logger.debug('Ensure credentials error. Credentials are:', error); return false; } } @@ -353,14 +689,14 @@ export class AmazonLocationServiceProvider implements GeoProvider { private _verifyMapResources() { if (!this._config.maps) { const errorString = - "No map resources found in amplify config, run 'amplify add geo' to create them and run `amplify push` after"; - logger.warn(errorString); + "No map resources found in amplify config, run 'amplify add geo' to create one and run `amplify push` after"; + logger.debug(errorString); throw new Error(errorString); } if (!this._config.maps.default) { const errorString = "No default map resource found in amplify config, run 'amplify add geo' to create one and run `amplify push` after"; - logger.warn(errorString); + logger.debug(errorString); throw new Error(errorString); } } @@ -371,9 +707,76 @@ export class AmazonLocationServiceProvider implements GeoProvider { !optionalSearchIndex ) { const errorString = - 'No Search Index found, please run `amplify add geo` to add one and run `amplify push` after.'; - logger.warn(errorString); + 'No Search Index found in amplify config, please run `amplify add geo` to create one and run `amplify push` after.'; + logger.debug(errorString); + throw new Error(errorString); + } + } + + private _verifyGeofenceCollections(optionalGeofenceCollectionName?: string) { + if ( + (!this._config.geofenceCollections || + !this._config.geofenceCollections.default) && + !optionalGeofenceCollectionName + ) { + const errorString = + 'No Geofence Collections found, please run `amplify add geo` to create one and run `amplify push` after.'; + logger.debug(errorString); throw new Error(errorString); } } + + private async _AmazonLocationServiceBatchPutGeofenceCall( + PascalGeofences: BatchPutGeofenceRequestEntry[], + collectionName?: string + ) { + // Create the BatchPutGeofence input + const geofenceInput: BatchPutGeofenceCommandInput = { + Entries: PascalGeofences, + CollectionName: + collectionName || this._config.geofenceCollections.default, + }; + + const client = new LocationClient({ + credentials: this._config.credentials, + region: this._config.region, + customUserAgent: getAmplifyUserAgent(), + }); + const command = new BatchPutGeofenceCommand(geofenceInput); + + let response: BatchPutGeofenceCommandOutput; + try { + response = await client.send(command); + } catch (error) { + throw error; + } + return response; + } + + private async _AmazonLocationServiceBatchDeleteGeofenceCall( + geofenceIds: string[], + collectionName?: string + ): Promise { + // Create the BatchDeleteGeofence input + const deleteGeofencesInput: BatchDeleteGeofenceCommandInput = { + GeofenceIds: geofenceIds, + CollectionName: + collectionName || this._config.geofenceCollections.default, + }; + + const client = new LocationClient({ + credentials: this._config.credentials, + region: this._config.region, + customUserAgent: getAmplifyUserAgent(), + }); + const command = new BatchDeleteGeofenceCommand(deleteGeofencesInput); + + let response: BatchDeleteGeofenceCommandOutput; + try { + response = await client.send(command); + } catch (error) { + throw error; + } + return response; + } } diff --git a/packages/geo/src/types/AmazonLocationServiceProvider.ts b/packages/geo/src/types/AmazonLocationServiceProvider.ts index 91428e72b1c..50c073fa025 100644 --- a/packages/geo/src/types/AmazonLocationServiceProvider.ts +++ b/packages/geo/src/types/AmazonLocationServiceProvider.ts @@ -1,5 +1,60 @@ -import { MapStyle } from './Geo'; +import { + MapStyle, + GeofenceOptions, + ListGeofenceOptions, + Geofence, + DeleteGeofencesResults, + GeofenceError, +} from './Geo'; +// Maps export interface AmazonLocationServiceMapStyle extends MapStyle { region: string; } + +// Geofences +export type AmazonLocationServiceGeofenceOptions = GeofenceOptions & { + collectionName?: string; +}; + +// Status types for Geofences +export type AmazonLocationServiceGeofenceStatus = + | 'ACTIVE' + | 'PENDING' + | 'FAILED' + | 'DELETED' + | 'DELETING'; + +export type AmazonLocationServiceGeofence = Omit & { + status: AmazonLocationServiceGeofenceStatus; +}; + +// List Geofences +export type AmazonLocationServiceListGeofenceOptions = ListGeofenceOptions & { + collectionName?: string; +}; + +// Delete Geofences +export type AmazonLocationServiceBatchGeofenceErrorMessages = + | 'AccessDeniedException' + | 'InternalServerException' + | 'ResourceNotFoundException' + | 'ThrottlingException' + | 'ValidationException'; + +export type AmazonLocationServiceBatchGeofenceError = Omit< + GeofenceError, + 'error' +> & { + error: { + code: string; + message: AmazonLocationServiceBatchGeofenceErrorMessages; + }; +}; + +export type AmazonLocationServiceDeleteGeofencesResults = Omit< + DeleteGeofencesResults, + 'errors' +> & { + errors: AmazonLocationServiceBatchGeofenceError[]; +}; diff --git a/packages/geo/src/types/Geo.ts b/packages/geo/src/types/Geo.ts index aed1f7bca27..3858dd4d34d 100644 --- a/packages/geo/src/types/Geo.ts +++ b/packages/geo/src/types/Geo.ts @@ -22,6 +22,10 @@ export interface GeoConfig { items: string[]; default: string; }; + geofenceCollections?: { + items: string[]; + default: string; + }; }; } @@ -31,10 +35,10 @@ export interface MapStyle { style: string; } -export type Latitude = number; export type Longitude = number; +export type Latitude = number; -// Coordinate point +// Coordinates are a tuple of longitude and latitude export type Coordinates = [Longitude, Latitude]; // SW Longitude point for bounding box @@ -73,6 +77,7 @@ export type SearchByTextOptions = | SearchByTextOptionsWithBiasPosition | SearchByTextOptionsWithSearchAreaConstraints; +// Options object for searchByCoodinates export type SearchByCoordinatesOptions = { maxResults?: number; searchIndexName?: string; @@ -100,3 +105,68 @@ export interface Place { // Return type for searchForSuggestions export type SearchForSuggestionsResults = string[]; +// Array of 4 or more coordinates, where the first and last coordinate are the same to form a closed boundary +export type LinearRing = Coordinates[]; + +// An array of one linear ring +export type GeofencePolygon = LinearRing[]; + +// Geometry object for Polygon +export type PolygonGeometry = { + polygon: GeofencePolygon; +}; + +// Geofence object used as input for saveGeofences +export type GeofenceInput = { + geofenceId: string; + geometry: PolygonGeometry; +}; + +// Options object for saveGeofences +export type GeofenceOptions = { + providerName?: string; +}; + +// Error type for errors related to Geofence API calls +export type GeofenceError = { + error: { + code: string; + message: string; + }; + geofenceId: string; +}; + +// Base geofence object +type GeofenceBase = { + geofenceId: string; + createTime?: Date; + updateTime?: Date; +}; + +// Results object for getGeofence +export type Geofence = GeofenceBase & { + geometry: PolygonGeometry; +}; + +// Results object for saveGeofences +export type SaveGeofencesResults = { + successes: GeofenceBase[]; + errors: GeofenceError[]; +}; + +// Options object for listGeofence +export type ListGeofenceOptions = GeofenceOptions & { + nextToken?: string; +}; + +// Results options for listGeofence +export type ListGeofenceResults = { + entries: Geofence[]; + nextToken: string; +}; + +// Results object for deleteGeofence +export type DeleteGeofencesResults = { + successes: string[]; + errors: GeofenceError[]; +}; diff --git a/packages/geo/src/types/Provider.ts b/packages/geo/src/types/Provider.ts index 94b6bf2a8bb..722eb1eb2fa 100644 --- a/packages/geo/src/types/Provider.ts +++ b/packages/geo/src/types/Provider.ts @@ -10,7 +10,6 @@ * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ - import { SearchByTextOptions, SearchByCoordinatesOptions, @@ -18,6 +17,13 @@ import { Coordinates, Place, MapStyle, + Geofence, + GeofenceInput, + GeofenceOptions, + ListGeofenceOptions, + ListGeofenceResults, + SaveGeofencesResults, + DeleteGeofencesResults, } from './Geo'; export interface GeoProvider { @@ -36,15 +42,39 @@ export interface GeoProvider { // get the map resource listed as default getDefaultMap(): MapStyle; + // search by a text string and return a list of places searchByText(text: string, options?: SearchByTextOptions): Promise; + // search by coordinates and return a matching place searchByCoordinates( coordinates: Coordinates, options?: SearchByCoordinatesOptions ): Promise; + // search for suggestions based on a text string searchForSuggestions( text: string, options?: SearchByTextOptions ): Promise; + + // create geofences + saveGeofences( + geofences: GeofenceInput[], + options?: GeofenceOptions + ): Promise; + + // get a single geofence + getGeofence( + geofenceId: string, + options?: ListGeofenceOptions + ): Promise; + + // list all geofences + listGeofences(options?: ListGeofenceOptions): Promise; + + // Delete geofences + deleteGeofences( + geofenceIds: string[], + options?: GeofenceOptions + ): Promise; } diff --git a/packages/geo/src/util.ts b/packages/geo/src/util.ts new file mode 100644 index 00000000000..09a3d831b10 --- /dev/null +++ b/packages/geo/src/util.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +import { ConsoleLogger as Logger } from '@aws-amplify/core'; + +import { + Longitude, + Latitude, + GeofenceInput, + GeofencePolygon, + LinearRing, +} from './types'; + +const logger = new Logger('Geo'); + +export function validateCoordinates(lng: Longitude, lat: Latitude): void { + if (!Number.isFinite(lng) || !Number.isFinite(lat)) { + throw new Error(`Invalid coordinates: [${lng},${lat}]`); + } + if (lat < -90 || lat > 90) { + const errorString = + 'Latitude must be between -90 and 90 degrees inclusive.'; + logger.debug(errorString); + throw new Error(errorString); + } else if (lng < -180 || lng > 180) { + const errorString = + 'Longitude must be between -180 and 180 degrees inclusive.'; + logger.debug(errorString); + throw new Error(errorString); + } +} + +export function validateGeofenceId(geofenceId: string) { + const geofenceIdRegex = /^[-._\p{L}\p{N}]+$/iu; + + // Check if geofenceId is valid + if (!geofenceIdRegex.test(geofenceId)) { + const errorString = `Invalid geofenceId: ${geofenceId} Ids can only contain alphanumeric characters, hyphens, underscores and periods.`; + logger.debug(errorString); + throw new Error(errorString); + } +} + +export function validateLinearRing(linearRing: LinearRing) { + // Validate LinearRing size, must be at least 4 points + if (linearRing.length < 4) { + const errorString = 'LinearRing must contain 4 or more coordinates.'; + logger.debug(errorString); + throw new Error(errorString); + } + + // Validate all coordinates are valid, error with which ones are bad + const badCoordinates = []; + linearRing.forEach(coordinates => { + try { + validateCoordinates(coordinates[0], coordinates[1]); + } catch (error) { + badCoordinates.push({ coordinates, error: error.message }); + } + }); + if (badCoordinates.length > 0) { + const errorString = `One or more of the coordinates are not valid: ${JSON.stringify( + badCoordinates + )}`; + logger.debug(errorString); + throw new Error(errorString); + } + + // Validate first and last coordinates are the same + const [lngA, latA] = linearRing[0]; + const [lngB, latB] = linearRing[linearRing.length - 1]; + + if (lngA !== lngB || latA !== latB) { + const errorString = `LinearRing's first and last coordinates are not the same`; + logger.debug(errorString); + throw new Error(errorString); + } +} + +export function validatePolygon(polygon: GeofencePolygon): void { + if (!Array.isArray(polygon)) { + const errorString = `Polygon ${JSON.stringify( + polygon + )} is of incorrect structure. It should be an array of 'LinearRing'`; + logger.debug(errorString); + throw new Error(errorString); + } + if (!(polygon.length === 1)) { + const errorString = `Polygon ${JSON.stringify( + polygon + )} geometry.polygon must have a single LinearRing array`; + logger.debug(errorString); + throw new Error(errorString); + } + const verticesCount = polygon.reduce( + (prev, linearRing) => prev + linearRing.length, + 0 + ); + if (verticesCount > 1000) { + const errorString = `Polygon has more than the maximum 1000 vertices.`; + logger.debug(errorString); + throw new Error(errorString); + } +} + +export function validateGeofences(geofences: GeofenceInput[]) { + const geofenceIds = {}; + + geofences.forEach((geofence: GeofenceInput) => { + // verify all required properties are present + if (!geofence.geofenceId) { + const errorString = `Geofence ${JSON.stringify( + geofence + )} is missing geofenceId`; + logger.debug(errorString); + throw new Error(errorString); + } + + if (!geofence.geometry) { + const errorString = `Geofence ${JSON.stringify( + geofence + )} is missing geometry`; + logger.debug(errorString); + throw new Error(errorString); + } + + if (!geofence.geometry.polygon) { + const errorString = `Geofence ${JSON.stringify( + geofence + )} is missing geometry.polygon`; + logger.debug(errorString); + throw new Error(errorString); + } + + const { + geofenceId, + geometry: { polygon }, + } = geofence; + + // Validate geofenceId is valid + try { + validateGeofenceId(geofenceId); + } catch (error) { + throw error; + } + + // Validate geofenceId is unique + if (geofenceIds[geofenceId]) { + const errorString = `Duplicate geofenceId: ${geofenceId}`; + logger.debug(errorString); + throw new Error(errorString); + } else { + geofenceIds[geofenceId] = true; + } + + // Validate polygon length and structure + try { + validatePolygon(polygon); + } catch (error) { + if ( + error.message === `Polygon has more than the maximum 1000 vertices.` + ) { + const errorString = `Geofence ${geofenceId} has more than the maximum of 1000 vertices`; + logger.debug(errorString); + throw new Error(errorString); + } + } + + // Validate LinearRing length, structure, and coordinates + const [linearRing] = polygon; + validateLinearRing(linearRing); + }); +}