@thirstie/thirstieservices 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@thirstie/thirstieservices",
3
+ "version": "0.1.0",
4
+ "description": "Service layer for Thirstie ecommerce API",
5
+ "author": "Thirstie, Inc. <technology@thirstie.com>",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "source": "src/index.js",
9
+ "main": "dist/bundle.cjs",
10
+ "module": "dist/bundle.mjs",
11
+ "exports": {
12
+ "require": "./dist/bundle.cjs",
13
+ "import": "./dist/bundle.mjs"
14
+ },
15
+ "scripts": {
16
+ "dev": "rollup -c -w",
17
+ "build": "rollup -c",
18
+ "lint": "eslint src/",
19
+ "test:int": "node --experimental-vm-modules ../../node_modules/.bin/jest int --no-cache",
20
+ "test:func": "node --experimental-vm-modules ../../node_modules/.bin/jest func --no-cache",
21
+ "test:coverage": "node --experimental-vm-modules ../../node_modules/.bin/jest unit --coverage",
22
+ "test": "concurrently \"npm run lint\" \"npm run test:coverage\"",
23
+ "test:watch": "node --experimental-vm-modules ../../node_modules/.bin/jest unit --watch"
24
+ },
25
+ "dependencies": {
26
+ "ramda": "^0.29.1"
27
+ },
28
+ "jest": {
29
+ "moduleFileExtensions": [
30
+ "js",
31
+ "json"
32
+ ],
33
+ "testEnvironment": "jsdom",
34
+ "transform": {}
35
+ },
36
+ "gitHead": "3e6f54e763d04240e244a4c085488c2b4a61ff4b"
37
+ }
@@ -0,0 +1,28 @@
1
+ import resolve from '@rollup/plugin-node-resolve';
2
+ import terser from '@rollup/plugin-terser';
3
+
4
+ export default {
5
+ input: 'src/index.js',
6
+ output: [
7
+ {
8
+ file: 'dist/bundle.mjs',
9
+ format: 'esm'
10
+ },
11
+ {
12
+ file: 'dist/bundle.cjs',
13
+ format: 'cjs'
14
+ },
15
+ {
16
+ file: 'dist/bundle.iife.js',
17
+ format: 'iife',
18
+ name: 'thirstieclient',
19
+ plugins: [ terser() ]
20
+ }
21
+ ],
22
+ onwarn (warning, warn) {
23
+ // suppress meaningless warning, see: https://github.com/reduxjs/redux-toolkit/issues/1466
24
+ if (warning.code === 'THIS_IS_UNDEFINED') return;
25
+ warn(warning);
26
+ },
27
+ plugins: [ resolve() ]
28
+ };
@@ -0,0 +1,232 @@
1
+ import * as R from 'ramda';
2
+
3
+ // helper functions
4
+ const findValueBy = R.curry((src, filterKey, searchStr, key) => {
5
+ const found = (obj) => R.includes(searchStr, obj[filterKey]);
6
+ return R.compose(R.prop(key), R.find(found))(src);
7
+ });
8
+ const addressLens = R.lensPath([ 'results', '0', 'address_components' ]);
9
+
10
+ export const transformPredictionResponse = (predictions, options = {}) => {
11
+ const { requestCity, requestState, requestZipcode } = options;
12
+
13
+ const extractPrediction = (prediction) => {
14
+ return { description: prediction.description, placeId: prediction.place_id };
15
+ };
16
+
17
+ let scoredPredictions = null;
18
+ if (!(requestCity || requestState || requestZipcode)) {
19
+ scoredPredictions = predictions.map(extractPrediction);
20
+ }
21
+
22
+ scoredPredictions = predictions.map((row) => {
23
+ const result = {
24
+ description: row.description,
25
+ place_id: row.place_id
26
+ };
27
+ const cityMatch = row.terms.filter((term) => requestCity && term.value === requestCity).length > 0 ? 1 : 0;
28
+ const stateMatch = row.terms.filter((term) => requestState && term.value === requestState).length > 0 ? 1 : 0;
29
+ const zipMatch = row.terms.filter((term) => requestZipcode && term.value === requestZipcode).length > 0 ? 1 : 0;
30
+
31
+ result.score = cityMatch + stateMatch + zipMatch;
32
+ result.stateMatch = !requestState || (requestState && stateMatch);
33
+ result.zipCodeMatch = !requestZipcode || (requestZipcode && zipMatch);
34
+
35
+ return result;
36
+ }).filter(
37
+ rec => !!rec.stateMatch && !!rec.zipCodeMatch
38
+ ).sort((a, b) => {
39
+ if (a.score === b.score) {
40
+ return 0;
41
+ }
42
+ return a.score < b.score ? 1 : -1;
43
+ }).map(extractPrediction);
44
+
45
+ return scoredPredictions;
46
+ };
47
+
48
+ export const parsePlacesAddress = (res) => {
49
+ const placeResult = res.results ? res.results[0] : res.result;
50
+ if (!placeResult || R.isEmpty(placeResult)) {
51
+ return {};
52
+ }
53
+
54
+ let addressComponents = null;
55
+ if (res.results) {
56
+ addressComponents = R.view(addressLens)(res);
57
+ } else {
58
+ addressComponents = placeResult.address_components;
59
+ }
60
+
61
+ const findAddressComponentByType = findValueBy(addressComponents, 'types');
62
+
63
+ const formattedAddress = placeResult.formatted_address;
64
+ const latitude =
65
+ placeResult.geometry &&
66
+ placeResult.geometry.location &&
67
+ placeResult.geometry.location.lat
68
+ ? placeResult.geometry.location.lat
69
+ : null;
70
+ const longitude =
71
+ placeResult.geometry &&
72
+ placeResult.geometry.location &&
73
+ placeResult.geometry.location.lng
74
+ ? placeResult.geometry.location.lng
75
+ : null;
76
+
77
+ const streetNumber = findAddressComponentByType('street_number', 'short_name') || '';
78
+ const streetRoute = findAddressComponentByType('route', 'long_name') || '';
79
+ const street1 =
80
+ (streetNumber || streetRoute) && `${streetNumber} ${streetRoute}`;
81
+
82
+ const address = {
83
+ state: findAddressComponentByType('administrative_area_level_1', 'short_name'),
84
+ city:
85
+ findAddressComponentByType('locality', 'short_name') ||
86
+ findAddressComponentByType('sublocality', 'short_name') ||
87
+ findAddressComponentByType('neighborhood', 'long_name') ||
88
+ findAddressComponentByType('administrative_area_level_3', 'long_name') ||
89
+ findAddressComponentByType('administrative_area_level_2', 'short_name'),
90
+ street_1: street1.trim(),
91
+ country: findAddressComponentByType('country', 'short_name'),
92
+ zipcode: findAddressComponentByType('postal_code', 'long_name'),
93
+ latitude: latitude && Number(latitude),
94
+ longitude: longitude && Number(longitude),
95
+ formattedAddress
96
+ };
97
+ return R.filter(Boolean)(address);
98
+ };
99
+
100
+ class GeoService {
101
+ constructor (mapsKey) {
102
+ this.mapsKey = mapsKey;
103
+ }
104
+
105
+ async geocode (address) {
106
+ const res = await fetch(
107
+ `https://maps.googleapis.com/maps/api/geocode/json?address=${address}&key=${this.mapsKey}`
108
+ );
109
+ const response = await res.json();
110
+ return parsePlacesAddress(response);
111
+ }
112
+
113
+ async geocodeZip (zipCode) {
114
+ const res = await fetch(
115
+ `https://maps.googleapis.com/maps/api/geocode/json?components=country:US|postal_code:${zipCode}&key=${this.mapsKey}`
116
+ );
117
+ const response = await res.json();
118
+ return parsePlacesAddress(response);
119
+ }
120
+
121
+ async reverseGeocode (lat, lng) {
122
+ const res = await fetch(
123
+ `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${this.mapsKey}`
124
+ );
125
+ const response = await res.json();
126
+ return parsePlacesAddress(response);
127
+ }
128
+
129
+ async geoLocate () {
130
+ // https://developers.google.com/maps/documentation/geolocation/overview
131
+ // NOTE: also returns accuracy (95% confidence of lat, lng in meters)
132
+ const url = `https://www.googleapis.com/geolocation/v1/geolocate?key=${this.mapsKey}`;
133
+ const requestConfig = {
134
+ method: 'POST',
135
+ headers: {
136
+ Accept: 'application/json',
137
+ 'Content-Type': 'application/json'
138
+ }
139
+ };
140
+ const request = await fetch(url, requestConfig);
141
+ const response = await request.json();
142
+ const { lat, lng } = response.location;
143
+ return this.reverseGeocode(lat, lng);
144
+ }
145
+
146
+ async getLocationSuggestions (payload) {
147
+ /*
148
+ https://developers.google.com/maps/documentation/places/web-service/autocomplete
149
+ Component should use session token: https://developers.google.com/maps/documentation/places/web-service/autocomplete#sessiontoken
150
+ **/
151
+ const { input, latitude, longitude, radius } = payload;
152
+ const { requestCity, requestState, requestZipcode } = payload;
153
+ const location = R.join(',', R.filter(Boolean, [ latitude, longitude ]));
154
+
155
+ let { sessiontoken } = payload;
156
+ if (!sessiontoken) {
157
+ sessiontoken = crypto.randomUUID().replaceAll('-', '');
158
+ }
159
+
160
+ const params = R.filter(Boolean, {
161
+ input,
162
+ sessiontoken,
163
+ location, // The point around which to retrieve place information.
164
+ radius, // The radius parameter must also be provided when specifying a location
165
+ components: 'country:US',
166
+ types: 'address',
167
+ key: this.mapsKey
168
+ });
169
+
170
+ let queryString = '';
171
+ if (params) {
172
+ const searchParams = new URLSearchParams(params);
173
+ queryString = searchParams.toString();
174
+ }
175
+
176
+ const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?${queryString}`;
177
+ const requestConfig = {
178
+ method: 'GET',
179
+ headers: {
180
+ Accept: 'application/json'
181
+ }
182
+ };
183
+ const request = await fetch(url, requestConfig);
184
+ const response = await request.json();
185
+
186
+ let autocompletePredictions = [];
187
+ if (response.predictions && response.status === 'OK') {
188
+ const options = { requestCity, requestState, requestZipcode };
189
+ autocompletePredictions = transformPredictionResponse(response.predictions, options);
190
+ }
191
+ return { autocompletePredictions, sessiontoken };
192
+ }
193
+
194
+ async getPlaceId (payload) {
195
+ const { placeId, sessiontoken } = payload;
196
+
197
+ const params = R.filter(Boolean, {
198
+ place_id: placeId,
199
+ sessiontoken,
200
+ key: this.mapsKey
201
+ });
202
+ let queryString = '';
203
+ if (params) {
204
+ const searchParams = new URLSearchParams(params);
205
+ queryString = searchParams.toString();
206
+ }
207
+
208
+ const url = `https://maps.googleapis.com/maps/api/place/details/json?${queryString}`;
209
+ const res = await fetch(url);
210
+ const response = await res.json();
211
+ return parsePlacesAddress(response);
212
+ }
213
+
214
+ async getNavigatorPosition () {
215
+ // check if navigator is available
216
+ // NOTE: also returns accuracy (95% confidence of lat, lng in meters)
217
+ // TODO: check accuracy and only return if within acceptable limit, provided in payload
218
+ const handleError = (error) => {
219
+ console.error(error);
220
+ };
221
+ (navigator.geolocation && navigator.geolocation.getCurrentPosition(
222
+ (position) => {
223
+ const lat = position.coords.latitude;
224
+ const lng = position.coords.longitude;
225
+ return this.reverseGeocode(lat, lng);
226
+ },
227
+ handleError
228
+ )) || handleError({ message: 'geolocation is not enabled.' });
229
+ }
230
+ }
231
+
232
+ export default GeoService;
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import ThirstieAPI from './thirstieapi';
2
+ import GeoService from './geoservice';
3
+
4
+ export {
5
+ ThirstieAPI,
6
+ GeoService
7
+ };
@@ -0,0 +1,331 @@
1
+ import apiRequest from './utils/apirequest.js';
2
+
3
+ /**
4
+ * Access Thirstie API and manage session information.
5
+ */
6
+ export default class ThirstieAPI {
7
+ // private fields
8
+ #apiKey;
9
+ #thirstieApiBaseUrl;
10
+ #_apiState;
11
+ #emptyState;
12
+ #environment;
13
+
14
+ /**
15
+ * Initialize Thirstie API with public api key
16
+ * @param {string} apiKey - The public basic auth key, defines application access
17
+ * @param {Object=} config - Optional config settings
18
+ * @param {Object} config.env - Set to 'prod' to use production environment
19
+ * @param {Object} config.initState - Values to initialize session state
20
+ */
21
+ constructor (apiKey, config = {}) {
22
+ const { env, initState } = config;
23
+ this.#environment = env;
24
+ this.#emptyState = {
25
+ sessionToken: null,
26
+ application: null,
27
+ applicationRef: null,
28
+ sessionRef: null,
29
+ userRef: null,
30
+ user: {},
31
+ message: null
32
+ };
33
+ this.#apiKey = apiKey;
34
+ this.#thirstieApiBaseUrl =
35
+ env === 'prod'
36
+ ? 'https://api.thirstie.com'
37
+ : 'https://api.next.thirstie.com';
38
+
39
+ this.#_apiState = Object.seal(this.#emptyState);
40
+ const { sessionToken, application, applicationRef, sessionRef, userRef } = initState || {};
41
+ if (sessionToken || application || applicationRef || sessionRef || userRef) {
42
+ const apiInitState = { sessionToken, application, applicationRef, sessionRef, userRef };
43
+ this.apiState = apiInitState;
44
+ }
45
+ }
46
+
47
+ get sessionToken () {
48
+ return this.#_apiState.sessionToken;
49
+ }
50
+
51
+ get sessionRef () {
52
+ return this.#_apiState.sessionRef;
53
+ }
54
+
55
+ get userRef () {
56
+ return this.#_apiState.userRef;
57
+ }
58
+
59
+ get applicationRef () {
60
+ return this.#_apiState.applicationRef;
61
+ }
62
+
63
+ get application () {
64
+ return this.#_apiState.application;
65
+ }
66
+
67
+ /**
68
+ * Getter for session state
69
+ * This is safe to use for persisting state in localstorage
70
+ * because it does not contain user information.
71
+ */
72
+ get sessionState () {
73
+ const {
74
+ sessionToken, application, applicationRef, sessionRef, userRef
75
+ } = this.#_apiState;
76
+ return { sessionToken, application, applicationRef, sessionRef, userRef };
77
+ }
78
+
79
+ /**
80
+ * Getter for general api state, contains user information
81
+ * Only use for internal comparison.
82
+ */
83
+ get apiState () {
84
+ return this.#_apiState;
85
+ }
86
+
87
+ set apiState (props) {
88
+ try {
89
+ return Object.assign(this.#_apiState, props);
90
+ } catch (error) {
91
+ console.error('set apiState', error);
92
+ return this.#_apiState;
93
+ }
94
+ }
95
+
96
+ #handleSession (data) {
97
+ const sessionData = {
98
+ application: data.application_name,
99
+ applicationRef: data.uuid,
100
+ sessionToken: data.token,
101
+ sessionRef: data.session_uuid
102
+ };
103
+ if (data.user) {
104
+ sessionData.userRef = data.user.id;
105
+ const {
106
+ birthday, email, prefix, guest,
107
+ first_name: firstName, last_name: lastName, phone_number: phoneNumber,
108
+ last_login: lastLogin
109
+ } = data.user;
110
+ sessionData.user = {
111
+ email, birthday, prefix, firstName, lastName, phoneNumber, guest, lastLogin
112
+ };
113
+ }
114
+ this.apiState = sessionData;
115
+ }
116
+
117
+ #handleSessionError (err) {
118
+ this.apiState = this.#emptyState;
119
+ console.log('session error', err);
120
+ }
121
+
122
+ /**
123
+ * Create a new session and retrieve a bearer token.
124
+ *
125
+ * Defaults to creating an anonymous session. If userCredentials are
126
+ * provided, then a user session will be returned.
127
+ *
128
+ * @param {Object=} userCredentials - user email & password
129
+ * @param {string} userCredentials.email
130
+ * @param {string} userCredentials.password
131
+ * @param {boolean} [basicAuth=true] - force use of application basic auth
132
+ * @returns {response}
133
+ */
134
+ async getNewSession (userCredentials = {}, basicAuth = true) {
135
+ const { email, password } = userCredentials;
136
+ const options = { basicAuth };
137
+ if (email && password) {
138
+ options.data = { email, password };
139
+ }
140
+ const url = '/a/v2/sessions';
141
+ const response = await this.apiCaller('POST', url, options);
142
+ if (response && response.ok && response.data) {
143
+ return response.data;
144
+ } else {
145
+ return { code: response.status, message: response.message || 'unknown error', response };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Validate an existing session
151
+ * @param {string} sessionToken - Session JWT to validate
152
+ * @returns {object} session response
153
+ */
154
+ async validateSession (sessionToken) {
155
+ if (sessionToken) {
156
+ this.apiState = { sessionToken };
157
+ }
158
+ if (!this.sessionToken) {
159
+ return {};
160
+ }
161
+
162
+ const url = '/a/v2/sessions';
163
+ const response = await this.apiCaller('GET', url);
164
+ if (response && response.ok && response.data) {
165
+ return response.data;
166
+ } else {
167
+ return await this.getNewSession();
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Create new session, or validates existing session and set apiState
173
+ * @param {string} [existingToken] If provided, session JWT to be validated
174
+ * @returns {object} API State
175
+ */
176
+ async fetchSession (existingToken = null) {
177
+ let sessionData = {};
178
+ if (!this.sessionToken && !existingToken) {
179
+ sessionData = await this.getNewSession();
180
+ } else {
181
+ sessionData = await this.validateSession(existingToken);
182
+ }
183
+
184
+ if (sessionData.token) {
185
+ this.#handleSession(sessionData);
186
+ } else {
187
+ this.#handleSessionError(sessionData);
188
+ }
189
+ return this.apiState;
190
+ }
191
+
192
+ /**
193
+ * Convert an anonymous session to a user session
194
+ * @param {object} userCredentials - { email, password }
195
+ * @returns {object} - api state
196
+ */
197
+ async loginUser (userCredentials) {
198
+ const options = {};
199
+ const { email, password } = userCredentials;
200
+
201
+ // force use of existing anonymous session token if a token exists
202
+ const basicAuth = !this.sessionToken;
203
+ if (email && password) {
204
+ options.data = { email, password };
205
+ } else {
206
+ throw new Error('Invalid credential payload');
207
+ }
208
+
209
+ const responseData = await this.getNewSession(userCredentials, basicAuth);
210
+ if (responseData.token) {
211
+ this.#handleSession(responseData);
212
+ } else {
213
+ this.#handleSessionError(responseData);
214
+ const { response } = responseData;
215
+ const { data } = response;
216
+ if (data?.message) {
217
+ this.apiState.message = data?.message;
218
+ }
219
+ }
220
+ return this.apiState;
221
+ }
222
+
223
+ /**
224
+ * Create a new user
225
+ * @param {object} userData - { email, password, birthday, prefix, firstName, lastName, phoneNumber, guestCheck }
226
+ * @param {Function} [errorHandler] - optional error callback
227
+ * @returns {object} - api state
228
+ */
229
+ async createUser (userData, errorHandler = null) {
230
+ const options = {};
231
+ const { email, password } = userData;
232
+ const { birthday, prefix, firstName, lastName, phoneNumber, guestCheck, emailOptIn } = userData;
233
+ const requestPayload = {
234
+ email,
235
+ birthday,
236
+ prefix,
237
+ first_name: firstName,
238
+ last_name: lastName,
239
+ phone_number: phoneNumber,
240
+ guest_check: !!guestCheck // "allow guest checkout for existing user" if set to True
241
+ };
242
+
243
+ if (emailOptIn) {
244
+ requestPayload.aux_data = {
245
+ thirstieaccess_email_opt_in: emailOptIn
246
+ };
247
+ }
248
+
249
+ // force use of existing anonymous session token if a token exists
250
+ const basicAuth = !this.sessionToken;
251
+ if (email && password) {
252
+ requestPayload.password = password;
253
+ } else {
254
+ requestPayload.guest = true;
255
+ }
256
+ options.data = requestPayload;
257
+ options.basicAuth = basicAuth;
258
+
259
+ const responseData = await this.apiCaller('POST', '/a/v2/users', options, errorHandler);
260
+ if (responseData.data.token) {
261
+ this.#handleSession(responseData.data);
262
+ } else {
263
+ this.#handleSessionError(responseData);
264
+ }
265
+ return this.apiState;
266
+ }
267
+
268
+ /**
269
+ * Invoke Thirstie API Endpoint
270
+ * @param {string} method - 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'
271
+ * @param {string} url - api endpoint url
272
+ * @param {object} [options] - { data, params, basicAuth }
273
+ * @param {callback} [errorHandler] - error callback
274
+ * @returns {object} - api response
275
+ */
276
+ async apiCaller (method, url, options = {}, errorHandler = null) {
277
+ const { data, params, basicAuth } = options;
278
+ const { sessionRef, application, applicationRef, userRef } = this.apiState;
279
+
280
+ if (!this.sessionToken && !basicAuth) {
281
+ throw new Error('Invalid Authorization');
282
+ }
283
+
284
+ const authHeader = (this.sessionToken && !basicAuth)
285
+ ? `Bearer ${this.sessionToken}`
286
+ : `Basic ${btoa(this.#apiKey)}`;
287
+
288
+ const requestConfig = {
289
+ method,
290
+ headers: {
291
+ Authorization: authHeader,
292
+ Accept: 'application/json',
293
+ 'Content-Type': 'application/json'
294
+ }
295
+ };
296
+ if ([ 'POST', 'PUT', 'PATCH' ].includes(method) && !!data) {
297
+ requestConfig.body = JSON.stringify(data);
298
+ }
299
+ let queryString = '';
300
+ if (params) {
301
+ const searchParams = new URLSearchParams(params);
302
+ queryString = searchParams.toString();
303
+ }
304
+
305
+ const telemetryContext = {
306
+ environment: this.#environment,
307
+ sessionRef,
308
+ application,
309
+ applicationRef,
310
+ userRef,
311
+ data,
312
+ queryString,
313
+ url
314
+ };
315
+ const requestUrl = `${this.#thirstieApiBaseUrl}${url}${queryString}`;
316
+ try {
317
+ const response = await apiRequest(requestUrl, requestConfig);
318
+ if (response) {
319
+ if (!response.ok && errorHandler) {
320
+ errorHandler({ code: response.status, message: response.statusText || 'unknown error', response, telemetryContext });
321
+ }
322
+ return response;
323
+ }
324
+ } catch (error) {
325
+ if (errorHandler) {
326
+ errorHandler({ code: 500, message: 'unknown error', error, telemetryContext });
327
+ }
328
+ return error;
329
+ }
330
+ }
331
+ }
@@ -0,0 +1,34 @@
1
+ export default async function apiRequest (url, requestConfig) {
2
+ const apiResponse = {
3
+ ok: null,
4
+ status: null,
5
+ statusText: null,
6
+ data: {}
7
+ };
8
+ try {
9
+ const response = await fetch(url, requestConfig);
10
+
11
+ let responseBody = {};
12
+ const headers = response.headers;
13
+ const hasBody = headers && headers.get('content-type')?.indexOf('application/json') > -1 && parseInt(headers.get('content-length')) > 0;
14
+ if (hasBody) {
15
+ responseBody = await response.json();
16
+ }
17
+ return Object.assign(apiResponse, {
18
+ ok: response.ok,
19
+ status: response.status,
20
+ statusText: response.statusText,
21
+ data: responseBody,
22
+ url
23
+ });
24
+ } catch (error) {
25
+ console.error('apiRequest error: ', error);
26
+ return Object.assign(apiResponse, {
27
+ ok: false,
28
+ status: 500,
29
+ statusText: 'ERROR',
30
+ data: { message: 'Network error', error },
31
+ url
32
+ });
33
+ }
34
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "APIKEY": "",
3
+ "MAPSKEY": "",
4
+ "ADMINKEY": ""
5
+ }