├── .nvmrc ├── .prettierrc.json ├── .prettierignore ├── .gitignore ├── functions ├── src │ ├── index.ts │ ├── bin │ │ ├── utils.ts │ │ ├── README.md │ │ └── import.ts │ ├── utils.ts │ ├── shipToElastic.ts │ ├── toAppSearch.ts │ ├── shipToElastic.test.ts │ └── toAppSearch.test.ts ├── jest.config.js ├── .gitignore ├── tsconfig.json └── package.json ├── test_project ├── firestore.indexes.json ├── .firebaserc ├── firestore.rules ├── seed │ ├── firestore_export │ │ ├── all_namespaces │ │ │ └── all_kinds │ │ │ │ ├── output-0 │ │ │ │ └── all_namespaces_all_kinds.export_metadata │ │ └── firestore_export.overall_export_metadata │ └── firebase-export-metadata.json ├── test-params.env.example ├── firebase.json └── .gitignore ├── CHANGELOG.md ├── load-data ├── README.md ├── load-data.js └── nationalparks.json ├── package.json ├── README.md ├── PREINSTALL.md ├── CONTRIBUTING.md ├── extension.yaml ├── LICENSE.txt └── POSTINSTALL.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | functions/lib 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | load-data/credentials.json -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | export { shipToElastic } from "./shipToElastic"; 2 | -------------------------------------------------------------------------------- /test_project/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /test_project/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "nationalparks" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /functions/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | }; 6 | -------------------------------------------------------------------------------- /test_project/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read, write; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | lib/**/*.js 3 | lib/**/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /test_project/seed/firestore_export/all_namespaces/all_kinds/output-0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/app-search-firestore-extension/master/test_project/seed/firestore_export/all_namespaces/all_kinds/output-0 -------------------------------------------------------------------------------- /test_project/seed/firestore_export/firestore_export.overall_export_metadata: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/app-search-firestore-extension/master/test_project/seed/firestore_export/firestore_export.overall_export_metadata -------------------------------------------------------------------------------- /test_project/seed/firebase-export-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "9.16.6", 3 | "firestore": { 4 | "version": "1.13.1", 5 | "path": "firestore_export", 6 | "metadata_file": "firestore_export/firestore_export.overall_export_metadata" 7 | } 8 | } -------------------------------------------------------------------------------- /test_project/seed/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/app-search-firestore-extension/master/test_project/seed/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata -------------------------------------------------------------------------------- /test_project/test-params.env.example: -------------------------------------------------------------------------------- 1 | LOCATION=us-central1 2 | COLLECTION_PATH=nationalparks 3 | APP_SEARCH_API_KEY=private-79iadc5dzd3qxgfgd9w9ryc7 4 | APP_SEARCH_ENGINE_NAME=nationalparks 5 | ENTERPRISE_SEARCH_URL=http://localhost:3002 6 | INDEXED_FIELDS=title,description,visitors,acres,location,date_established 7 | -------------------------------------------------------------------------------- /functions/src/bin/utils.ts: -------------------------------------------------------------------------------- 1 | export const batchArray = ( 2 | array: Array, 3 | size: number 4 | ): Array> => { 5 | const batches = []; 6 | let index = 0; 7 | 8 | while (index < array.length) { 9 | batches.push(array.slice(index, index + size)); 10 | index += size; 11 | } 12 | 13 | return batches; 14 | }; 15 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "compileOnSave": true, 12 | "exclude": ["node_modules", "**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 0.4.1 2 | 3 | fixed - Fixed API key in backport command in POSTINSTALL.md 4 | 5 | ## Version 0.4.0 6 | 7 | feature - Use "secret" type for private API key configuration (#27) 8 | docs - Multiple updates to preinstall, postinstall, and extension configuration guides 9 | fixed - added dependencies required for firebase installation (#28) 10 | 11 | ## Version 0.3.0 12 | 13 | Initial release 14 | -------------------------------------------------------------------------------- /test_project/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "functions": { 7 | "predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"] 8 | }, 9 | "emulators": { 10 | "functions": { 11 | "port": 5001 12 | }, 13 | "firestore": { 14 | "port": 8081 15 | }, 16 | "ui": { 17 | "enabled": true 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /load-data/README.md: -------------------------------------------------------------------------------- 1 | You can use the script `load-data.js` to load national-parks data to a Cloud Firestore. 2 | 3 | Before running the script, first follow the instructions [here](https://firebase.google.com/docs/admin/setup#initialize-sdk) to "To generate a private key file for your service account". Download it and save it in this directory as `credentials.json` 4 | 5 | From the root of this project run: 6 | 7 | ``` 8 | npm install 9 | ``` 10 | 11 | From this directory run: 12 | 13 | ``` 14 | node ./load-data.js 15 | ``` 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "postinstall": "cd functions && npm install", 4 | "build": "cd functions && npm run build", 5 | "watch": "cd functions && npm run watch", 6 | "dev": "cd test_project && firebase ext:dev:emulators:start --test-config=firebase.json --test-params=test-params.env --project=nationalparks --import seed", 7 | "test": "cd functions && npm run test", 8 | "watch-tests": "cd functions && npm run watch-tests", 9 | "format": "prettier --write ." 10 | }, 11 | "devDependencies": { 12 | "@types/jest": "^27.0.1", 13 | "firebase-admin": "^9.11.1", 14 | "prettier": "2.3.2" 15 | }, 16 | "private": true 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elastic App Search extension for Firestore 2 | 3 | This extension syncs data from Google's [Cloud Firestore](https://firebase.google.com/products/firestore) to [Elastic App Search](https://www.elastic.co/app-search/). 4 | 5 | Out-of-the-box, Cloud Firestore provides no mechanism for full-text search on data. Syncing your Cloud Firestore data to Elastic App Search not only gives you a mechanism for full-text search on your data, it also lets you enjoy App Search's powerful relevance tuning features and search analytics data. 6 | 7 | **NOTE:** This extension is no longer maintained. We encourage the community use the open and supported [connectors framework](https://www.elastic.co/guide/en/enterprise-search/current/connectors.html#connectors-overview-framework) to build an Elasticsearch connector for integration with Google Cloud Firestore. 8 | 9 | ## Install 10 | 11 | ### From the web 12 | 13 | Visit the following link: https://console.firebase.google.com/project/_/extensions/install?ref=elastic/firestore-elastic-app-search@0.4.1 14 | 15 | ### From source 16 | 17 | After pulling this project source locally, follow these steps: 18 | 19 | ```shell 20 | npm install -g firebase-tools 21 | npm install 22 | firebase login 23 | firebase ext:install . --project= 24 | ``` 25 | 26 | ## Contributing 27 | 28 | Plan to pull this code and run it locally? See [CONTRIBUTING.md](CONTRIBUTING.md). 29 | -------------------------------------------------------------------------------- /functions/src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as admin from "firebase-admin"; 2 | 3 | // @ts-ignore There are no type definitions for this library 4 | import * as AppSearchClient from "@elastic/app-search-node"; 5 | 6 | // @ts-ignore There are no type definitions for this library 7 | import * as AppSearchLowLevelClient from "@elastic/app-search-node/lib/client"; 8 | 9 | export const getNewAppSearchClient = (): any => { 10 | return new AppSearchClient( 11 | undefined, 12 | process.env.APP_SEARCH_API_KEY, 13 | () => `${process.env.ENTERPRISE_SEARCH_URL}/api/as/v1/` 14 | ); 15 | }; 16 | 17 | export const getNewAppSearchLowLevelClient = (): any => { 18 | return new AppSearchLowLevelClient( 19 | process.env.APP_SEARCH_API_KEY, 20 | `${process.env.ENTERPRISE_SEARCH_URL}/api/as/v1/` 21 | ); 22 | }; 23 | 24 | export const getFirestore = (): FirebaseFirestore.Firestore => { 25 | // initialize the application using either: 26 | // 1) the Google Credentials in the GOOGLE_APPLICATION_CREDENTIALS environment variable to connect to a cloud instance 27 | // 2) the FIRESTORE_EMULATOR_HOST environment variable to connect to an emulated instance 28 | admin.initializeApp({ 29 | credential: admin.credential.applicationDefault(), 30 | }); 31 | return admin.firestore(); 32 | }; 33 | 34 | export const parseIndexedFields = (indexedFields: string = ""): string[] => { 35 | return indexedFields.split(",").map((f) => f.trim()); 36 | }; 37 | -------------------------------------------------------------------------------- /test_project/.gitignore: -------------------------------------------------------------------------------- 1 | test-params.env 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | firebase-debug.log* 10 | firebase-debug.*.log* 11 | 12 | # Firebase cache 13 | .firebase/ 14 | 15 | # Firebase config 16 | 17 | # Uncomment this if you'd like others to create their own Firebase project. 18 | # For a team working on the same Firebase project(s), it is recommended to leave 19 | # it commented so all members can deploy to the same project(s) in .firebaserc. 20 | # .firebaserc 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (http://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elastic/app-search-firestore-extension", 3 | "version": "0.4.0", 4 | "description": "An extension that syncs data from Google's Cloud Firestore to Elastic App Search.", 5 | "scripts": { 6 | "prepare": "npm run build", 7 | "build": "tsc", 8 | "watch": "tsc -w", 9 | "test": "jest", 10 | "watch-tests": "jest --watchAll" 11 | }, 12 | "engines": { 13 | "node": "14" 14 | }, 15 | "bin": { 16 | "app-search-firestore-extension": "./lib/bin/import.js" 17 | }, 18 | "directories": { 19 | "lib": "lib" 20 | }, 21 | "files": [ 22 | "lib" 23 | ], 24 | "main": "lib/index.js", 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/elastic/app-search-firestore-extension.git" 28 | }, 29 | "keywords": [ 30 | "firebase", 31 | "elastic" 32 | ], 33 | "author": "Elastic", 34 | "license": "Apache-2.0", 35 | "bugs": { 36 | "url": "https://github.com/elastic/app-search-firestore-extension/issues" 37 | }, 38 | "homepage": "https://github.com/elastic/app-search-firestore-extension#readme", 39 | "dependencies": { 40 | "@elastic/app-search-node": "^7.14.0", 41 | "@types/lodash": "^4.14.175", 42 | "firebase-admin": "^9.8.0", 43 | "firebase-functions": "^3.14.1", 44 | "lodash": "^4.17.21", 45 | "typescript": "^3.8.0" 46 | }, 47 | "devDependencies": { 48 | "@types/jest": "^27.0.1", 49 | "firebase-functions-test": "^0.2.0", 50 | "jest": "^27.1.1", 51 | "ts-jest": "^27.0.5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /load-data/load-data.js: -------------------------------------------------------------------------------- 1 | const serviceAccount = require("./credentials.json"); 2 | 3 | const admin = require("firebase-admin"); 4 | admin.initializeApp({ 5 | credential: admin.credential.cert(serviceAccount), 6 | }); 7 | 8 | const nationalParks = require("./nationalparks.json"); 9 | 10 | const firestore = admin.firestore(); 11 | const collection = firestore.collection("nationalparks"); 12 | 13 | const addDocs = async () => { 14 | for (const nationalPark of nationalParks) { 15 | try { 16 | const doc = { 17 | ...nationalPark, 18 | date_established: admin.firestore.Timestamp.fromDate( 19 | new Date(nationalPark.date_established) 20 | ), 21 | location: new admin.firestore.GeoPoint( 22 | parseFloat(nationalPark.location.split(",")[0]), 23 | parseFloat(nationalPark.location.split(",")[1]) 24 | ), 25 | visitors: parseInt(nationalPark.visitors), 26 | square_km: parseInt(nationalPark.square_km), 27 | acres: parseInt(nationalPark.acres), 28 | world_heritage_site: 29 | nationalPark.world_heritage_site === "true" ? true : false, 30 | attributes: { 31 | testValue: "Mountains", 32 | testValue1: "Rivers", 33 | testValue2: "Forests", 34 | }, 35 | }; 36 | const docRef = await collection.add(doc); 37 | console.log("Document written with ID: ", docRef.id); 38 | } catch (e) { 39 | console.error("Error adding document: ", e); 40 | } 41 | } 42 | }; 43 | 44 | addDocs().then(() => { 45 | process.exit(0); 46 | }); 47 | -------------------------------------------------------------------------------- /functions/src/bin/README.md: -------------------------------------------------------------------------------- 1 | ## Running this script 2 | 3 | This script uses environment variables that correspond to the configuration that you've set in your installed extension. 4 | 5 | See the POSTINSTALL.md for instructions on running this script to backfill and reindex data from a Firestore colletion to an App Search engine. 6 | 7 | ## Developing 8 | 9 | ### Building the script 10 | 11 | All setup must be run from the directory `/app-search-firestore-extension/functions` 12 | 13 | Typescript must be compiled before we can run this script 14 | 15 | ``` 16 | npm run build 17 | ``` 18 | 19 | If you are developing the script you can add a `-- -w` flag to watch it 20 | 21 | ``` 22 | npm run build -- -w 23 | ``` 24 | 25 | ## Running the script 26 | 27 | All scripts must be run from the directory `/app-search-firestore-extension/functions`, and only after building the script 28 | 29 | To run against a local Firebase emulator: 30 | 31 | ``` 32 | FIRESTORE_EMULATOR_HOST=localhost:8081 \ 33 | GCLOUD_PROJECT=nationalparks \ 34 | COLLECTION_PATH=nationalparks \ 35 | INDEXED_FIELDS=title,description,visitors,acres,location,date_established \ 36 | ENTERPRISE_SEARCH_URL=http://localhost:3002 \ 37 | APP_SEARCH_API_KEY=private-asfdsaafdsagfsgfd \ 38 | APP_SEARCH_ENGINE_NAME=nationalparks \ 39 | node ./lib/bin/import.js 40 | ``` 41 | 42 | To run against a cloud Firebase instance: 43 | 44 | ``` 45 | GOOGLE_APPLICATION_CREDENTIALS=~/Downloads/app-search-extension-testing-firebase-adminsdk-asdfsa-fdasfdsa.json \ 46 | GCLOUD_PROJECT=nationalparks \ 47 | COLLECTION_PATH=nationalparks \ 48 | INDEXED_FIELDS=title,description,visitors,acres,location,date_established \ 49 | ENTERPRISE_SEARCH_URL=http://localhost:3002 \ 50 | APP_SEARCH_API_KEY=private-asfdsaafdsagfsgfd \ 51 | APP_SEARCH_ENGINE_NAME=nationalparks \ 52 | node ./lib/bin/import.js 53 | ``` 54 | -------------------------------------------------------------------------------- /functions/src/shipToElastic.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { toAppSearch } from "./toAppSearch"; 3 | 4 | import { getNewAppSearchClient } from "./utils"; 5 | 6 | const appSearchClient = getNewAppSearchClient(); 7 | 8 | // We separate and curry this function from shipToElastic so we can test with less mocking 9 | export const handler = (client: any) => { 10 | return async ( 11 | change: functions.Change 12 | ) => { 13 | functions.logger.info(`Received request to ship to ship to Elastic`, { 14 | change, 15 | }); 16 | if (change.before.exists === false) { 17 | functions.logger.info(`Creating document`, { id: change.after.id }); 18 | try { 19 | client.indexDocuments(process.env.APP_SEARCH_ENGINE_NAME, [ 20 | { 21 | id: change.after.id, 22 | ...toAppSearch(change.after.data()), 23 | }, 24 | ]); 25 | } catch (e) { 26 | functions.logger.error(`Error while creating document`, { 27 | id: change.after.id, 28 | }); 29 | throw e; 30 | } 31 | } else if (change.after.exists === false) { 32 | functions.logger.info(`Deleting document`, { id: change.before.id }); 33 | try { 34 | client.destroyDocuments(process.env.APP_SEARCH_ENGINE_NAME, [ 35 | change.before.id, 36 | ]); 37 | } catch (e) { 38 | functions.logger.error(`Error while deleting document`, { 39 | id: change.before.id, 40 | }); 41 | throw e; 42 | } 43 | } else { 44 | functions.logger.info(`Updating document`, { id: change.after.id }); 45 | try { 46 | client.indexDocuments(process.env.APP_SEARCH_ENGINE_NAME, [ 47 | { 48 | id: change.after.id, 49 | ...toAppSearch(change.after.data()), 50 | }, 51 | ]); 52 | } catch (e) { 53 | functions.logger.error(`Error while updating document`, { 54 | id: change.after.id, 55 | }); 56 | throw e; 57 | } 58 | } 59 | return change.after; 60 | }; 61 | }; 62 | 63 | // Note that in extensions, functions get declared slightly differently then typical extensions: 64 | // https://firebase.google.com/docs/extensions/alpha/construct-functions#firestore 65 | // Also note that tyipcally in a function you specify the path in the call to `document` like `/${config.collectionName}/{documentId}`. 66 | // In an extension, the path is specified in extension.yaml, in eventTrigger. 67 | export const shipToElastic = functions.handler.firestore.document.onWrite( 68 | handler(appSearchClient) 69 | ); 70 | -------------------------------------------------------------------------------- /functions/src/bin/import.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { QueryDocumentSnapshot } from "firebase-functions/v1/firestore"; 4 | import { toAppSearch } from "../toAppSearch"; 5 | 6 | import { getFirestore, getNewAppSearchClient } from "../utils"; 7 | import { batchArray } from "./utils"; 8 | 9 | const appSearchClient = getNewAppSearchClient(); 10 | 11 | const firestore = getFirestore(); 12 | 13 | const collectionPath = process.env.COLLECTION_PATH; 14 | 15 | if (!collectionPath) { 16 | throw Error("Please provide a COLLECTION_PATH environment variable"); 17 | } 18 | 19 | const batchSize = parseInt(process.env.BATCH_SIZE || "") || 50; 20 | 21 | const appSearchEngineName = process.env.APP_SEARCH_ENGINE_NAME; 22 | 23 | if (!appSearchEngineName) { 24 | throw Error("Please provide a APP_SEARCH_ENGINE_NAME environment variable"); 25 | } 26 | 27 | const main = async () => { 28 | console.log(`Importing all documents from collection ${collectionPath}`); 29 | 30 | let collectionDocs: QueryDocumentSnapshot[] = []; 31 | let preparedDocs: Record[]; 32 | 33 | try { 34 | const querySnapshot = await firestore.collectionGroup(collectionPath).get(); 35 | collectionDocs = querySnapshot.docs; 36 | 37 | console.log(`Retrieved ${collectionDocs.length} documents`); 38 | } catch (e) { 39 | console.error( 40 | `Error retrieving documents for collection ${collectionPath}` 41 | ); 42 | throw e; 43 | } 44 | 45 | preparedDocs = collectionDocs.map((document) => ({ 46 | id: document.id, 47 | ...toAppSearch(document.data()), 48 | })); 49 | 50 | const documentBatches = batchArray(preparedDocs, batchSize); 51 | 52 | console.log( 53 | `Submitting ${collectionDocs.length} documents in ${documentBatches.length} batches of size ${batchSize} to engine ${appSearchEngineName}` 54 | ); 55 | 56 | for (const [index, documentBatch] of documentBatches.entries()) { 57 | try { 58 | console.log( 59 | `Submitting ${documentBatch.length} documents as a part of batch ${index}` 60 | ); 61 | 62 | const response = await appSearchClient.indexDocuments( 63 | appSearchEngineName, 64 | documentBatch 65 | ); 66 | 67 | response.forEach((r: { errors: string[] }) => 68 | r.errors.forEach(console.error) 69 | ); 70 | } catch (e) { 71 | console.error( 72 | `Error submitting batch ${index} to ${appSearchEngineName}` 73 | ); 74 | throw e; 75 | } 76 | 77 | console.log( 78 | `Successfully submitted batch ${index} to engine ${appSearchEngineName}` 79 | ); 80 | } 81 | 82 | console.log( 83 | `Successfully imported all ${collectionDocs.length} documents to engine ${appSearchEngineName}` 84 | ); 85 | }; 86 | 87 | main().then(() => { 88 | process.exit(0); 89 | }); 90 | -------------------------------------------------------------------------------- /functions/src/toAppSearch.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | 3 | import { parseIndexedFields } from "./utils"; 4 | import { get } from "lodash"; 5 | 6 | const isDate = (value: any): boolean => 7 | !!value && 8 | !!value.hasOwnProperty && 9 | value.hasOwnProperty("_seconds") && 10 | value.hasOwnProperty("_nanoseconds"); 11 | 12 | const isGeo = (value: any): boolean => 13 | !!value && 14 | !!value.hasOwnProperty && 15 | value.hasOwnProperty("_latitude") && 16 | value.hasOwnProperty("_longitude"); 17 | 18 | export const toAppSearch = ( 19 | data: Record = {} 20 | ): Record => { 21 | const indexedFields = parseIndexedFields(process.env.INDEXED_FIELDS); 22 | 23 | return Object.entries(indexedFields).reduce((acc, [_, fieldName]) => { 24 | let fieldValue; 25 | let parsedFieldName = fieldName.split("::")[0]; 26 | let renameTo = fieldName.split("::")[1]; 27 | 28 | // If a user specified 'a::1' and there was literally an 'a::1' field, then dont attempt any renaming 29 | if (data.hasOwnProperty(fieldName)) { 30 | fieldValue = data[fieldName]; 31 | parsedFieldName = fieldName; 32 | renameTo = ""; 33 | } else if (data.hasOwnProperty(parsedFieldName)) { 34 | fieldValue = data[parsedFieldName]; 35 | } else { 36 | fieldValue = get(data, parsedFieldName.split("__").join(".")); 37 | } 38 | 39 | if (fieldValue === undefined) return acc; 40 | 41 | // App Search only supports lowercased alpha numeric names or underscores 42 | const processedFieldName = (renameTo || parsedFieldName) 43 | .replace(/[^A-Za-z0-9_]/g, "") 44 | .toLowerCase(); 45 | 46 | if (processedFieldName === "") { 47 | functions.logger.warn( 48 | `Skipped indexing a field named ${parsedFieldName}. Attempted to rename the field to remove special characters which resulted in an empty string. Please use the "::" syntax to rename this field. Example: ${parsedFieldName}::some_other_field_name.` 49 | ); 50 | return acc; 51 | } 52 | 53 | if (isDate(fieldValue)) { 54 | return { 55 | ...acc, 56 | [processedFieldName]: new Date( 57 | fieldValue._seconds * 1000 58 | ).toISOString(), 59 | }; 60 | } 61 | 62 | if (isGeo(fieldValue)) { 63 | return { 64 | ...acc, 65 | [processedFieldName]: `${fieldValue._latitude},${fieldValue._longitude}`, 66 | }; 67 | } 68 | 69 | if (Array.isArray(fieldValue)) { 70 | return { 71 | ...acc, 72 | [processedFieldName]: fieldValue.reduce((acc, arrayFieldValue) => { 73 | // App search does not support nested arrays, so ignore nested arrays 74 | if (Array.isArray(arrayFieldValue)) return acc; 75 | 76 | if (isDate(arrayFieldValue)) { 77 | return [ 78 | ...acc, 79 | new Date(arrayFieldValue._seconds * 1000).toISOString(), 80 | ]; 81 | } 82 | 83 | if (isGeo(arrayFieldValue)) { 84 | return [ 85 | ...acc, 86 | `${arrayFieldValue._latitude},${arrayFieldValue._longitude}`, 87 | ]; 88 | } 89 | 90 | return [...acc, arrayFieldValue]; 91 | }, []), 92 | }; 93 | } 94 | 95 | return { 96 | ...acc, 97 | [processedFieldName]: fieldValue, 98 | }; 99 | }, {}); 100 | }; 101 | -------------------------------------------------------------------------------- /functions/src/shipToElastic.test.ts: -------------------------------------------------------------------------------- 1 | import { handler } from "./shipToElastic"; 2 | 3 | describe("shipToElastic", () => { 4 | let originalValue: string | undefined; 5 | 6 | beforeAll(() => { 7 | originalValue = process.env.APP_SEARCH_ENGINE_NAME; 8 | }); 9 | 10 | afterAll(() => { 11 | process.env.APP_SEARCH_ENGINE_NAME = originalValue; 12 | }); 13 | 14 | const client = { 15 | indexDocuments: jest.fn(), 16 | destroyDocuments: jest.fn(), 17 | }; 18 | 19 | const shipToElastic = handler(client); 20 | 21 | beforeEach(() => { 22 | jest.resetAllMocks(); 23 | process.env.APP_SEARCH_ENGINE_NAME = "test_engine"; 24 | process.env.INDEXED_FIELDS = "foo,bar"; 25 | }); 26 | 27 | const getDocCreated = (id: string, data: object) => { 28 | return { 29 | before: { exists: false }, 30 | after: { 31 | id, 32 | exists: true, 33 | data: () => data, 34 | }, 35 | }; 36 | }; 37 | 38 | const getDocUpdated = (id: string, before: object, after: object) => { 39 | return { 40 | before: { 41 | id, 42 | exists: true, 43 | data: () => before, 44 | }, 45 | after: { 46 | id, 47 | exists: true, 48 | data: () => after, 49 | }, 50 | }; 51 | }; 52 | 53 | const getDocDeleted = (id: string, data: object) => { 54 | return { 55 | before: { 56 | id, 57 | exists: true, 58 | data: () => data, 59 | }, 60 | after: { 61 | exists: false, 62 | }, 63 | }; 64 | }; 65 | 66 | it("if a document is created, it should index it to app search", async () => { 67 | const change = getDocCreated("1", { 68 | foo: "foo", 69 | bar: "bar", 70 | }) as any; 71 | 72 | await shipToElastic(change); 73 | 74 | expect(client.indexDocuments).toHaveBeenCalledWith("test_engine", [ 75 | { id: "1", foo: "foo", bar: "bar" }, 76 | ]); 77 | }); 78 | 79 | it("if a document is updated, it should index it to app search", async () => { 80 | const change = getDocUpdated( 81 | "1", 82 | { 83 | foo: "foo", 84 | }, 85 | { 86 | foo: "foo", 87 | bar: "bar", 88 | } 89 | ) as any; 90 | 91 | await shipToElastic(change); 92 | 93 | expect(client.indexDocuments).toHaveBeenCalledWith("test_engine", [ 94 | { id: "1", foo: "foo", bar: "bar" }, 95 | ]); 96 | }); 97 | 98 | it("if a document is deleted, it should delete it from app search", async () => { 99 | const change = getDocDeleted("1", { 100 | foo: "foo", 101 | bar: "bar", 102 | }) as any; 103 | 104 | await shipToElastic(change); 105 | 106 | expect(client.destroyDocuments).toHaveBeenCalledWith("test_engine", ["1"]); 107 | }); 108 | 109 | describe("when indexing documents in app search", () => { 110 | it("will only index the specified fields", async () => { 111 | // So it should only add foo and bar to the indexed object because that's all we have specified here 112 | process.env.INDEXED_FIELDS = "foo,bar"; 113 | 114 | const change = getDocCreated("1", { 115 | foo: "foo", 116 | bar: "bar", 117 | baz: "baz", 118 | }) as any; 119 | 120 | await shipToElastic(change); 121 | 122 | expect(client.indexDocuments).toHaveBeenCalledWith("test_engine", [ 123 | { id: "1", foo: "foo", bar: "bar" }, 124 | ]); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /PREINSTALL.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | The Elastic App Search Firestore extension enables comprehensive [full-text search](https://firebase.google.com/docs/firestore/solutions/search) for your Firebase applications. 11 | 12 | This extension indexes and syncs the documents in a Cloud Firestore collection to an [Elastic App Search](https://www.elastic.co/app-search?ultron=firebase-extension&blade=preinstall&hulk=product) deployment by creating a Cloud Function which syncs changes in your collection on any [write event](https://firebase.google.com/docs/functions/firestore-events#function_triggers) (any time you create, update, or a delete a document). 13 | 14 | #### Elastic App Search 15 | 16 | Elastic App Search provides a comprehensive API for implementing common search patterns like auto-completed search suggestions and faceted filter navigation. You'll also have tooling so your team can easily track and tweak search relevance based on usage data. 17 | 18 | App Search is a part of [Elastic Enterprise Search](https://www.elastic.co/guide/en/enterprise-search/current/installation.html). You'll need an Enterprise Search deployment, which is created and maintained outside of Firebase. 19 | 20 | #### Getting started 21 | 22 | 1. Start an Enterprise Search deployment. You can provision one easily with [Elastic Cloud on GCP](https://console.cloud.google.com/marketplace/product/endpoints/elasticsearch-service.gcpmarketplace.elastic.co). 23 | 2. Once you have a deployment running, you'll need an [App Search Engine](https://www.elastic.co/guide/en/app-search/current/getting-started.html#getting-started-with-app-search-engine) to sync to your collection. 24 | 3. Once you've installed the extension and your Firestore collection is synced to App Search, you're ready to [start searching](https://www.elastic.co/guide/en/app-search/current/search-guide.html)! 25 | 26 | You can use the App Search [Search API](https://www.elastic.co/guide/en/app-search/current/search.html) for full-text search and everything you need to build a complete search experience: facets, filters, click analytics, query suggestion, relevance tuning and much more. 27 | 28 | If you have documents in your collection already, this extension also provides a [script](https://github.com/elastic/app-search-firestore-extension/tree/master/functions/src/bin) for backfilling existing data to App Search. 29 | 30 | 31 | 32 | #### Billing 33 | 34 | To install an extension, your project must be on the [Blaze (pay as you go) plan](https://firebase.google.com/pricing) 35 | 36 | - You will be charged a small amount (typically around $0.01/month) for the Firebase resources required by this extension (even if it is not used). 37 | - This extension uses other Firebase and Google Cloud Platform services, which have associated charges if you exceed the service’s free tier: 38 | - Cloud Functions (Node.js 10+ runtime. See [FAQs](https://firebase.google.com/support/faq#expandable-24)) 39 | - Cloud Firestore 40 | - Cloud Secret Manager 41 | 42 | If you host your Elastic Enterprise Search instance on Elastic Cloud, you will also be responsible for charges associated with that service. 43 | 44 | [Learn more about Elastic Cloud](https://www.elastic.co/cloud?ultron=firebase-extension&blade=preinstall&hulk=product). 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This project was created using document https://firebase.google.com/docs/extensions/alpha/overview-build-extensions. To view the documentation, your google account must be whitelisted by Google. 2 | 3 | This repo contains the source for the Elastic App Search Firebase extension, as well as a test Forebase project (`/test_project`) that we can use to test the extension within. 4 | 5 | ## Set up the firebase CLI 6 | 7 | This project uses the firebase CLI, you should install and log into the CLI first. 8 | 9 | You'll use the CLI directly to run the test project locally, as well as deploying and publishing the extension. 10 | 11 | ``` 12 | npm install -g firebase-tools 13 | firebase login 14 | ``` 15 | 16 | You'll also need to unlock the extension development tools: 17 | 18 | ``` 19 | firebase --open-sesame extdev 20 | # Run the following to see what this unlocked for you: firebase --help | grep ext:dev 21 | ``` 22 | 23 | ## Setup 24 | 25 | ```shell 26 | nvm use 27 | npm install 28 | ``` 29 | 30 | ## Set up connection to App Search before running locally 31 | 32 | 1. Set up and run App Search locally. 33 | 2. Create a new empty engine called 'nationalparks' 34 | 3. In the next section, you'll add the connection details from this instance to `test-params.env`. 35 | 36 | ## Run locally inside of a test project (emulated environment) 37 | 38 | The best way to develop an extension is using a local emulation of a Firebase project, not an actual project on Firebase in the cloud. 39 | 40 | For an extension in an emulated environment you don't actually collect configuration from a user via UI, you set the values in `test-params.env`. 41 | 42 | Copy `test-params.env.example` to `test-params.env` and replace the values with your configuration. 43 | 44 | We have a project configured for use in a local emulation already under `/test_project`. The project is called "nationalparks" and uses data set. You can read more about running an emulated environment for testing here [here](https://firebase.google.com/docs/emulator-suite) and [here](https://firebase.google.com/docs/extensions/alpha/test#emulator). 45 | 46 | ```shell 47 | npm run watch # Typescript must be compiled before we can run them 48 | 49 | # In a new tab 50 | npm run dev 51 | ``` 52 | 53 | Running the `dev` commands runs the following under the hood: 54 | 55 | ``` 56 | firebase ext:dev:emulators:start --test-config=firebase.json --test-params=test-params.env --project=nationalparks --import seed 57 | ``` 58 | 59 | This will run your local emulator with the extension installed. 60 | 61 | Navigate to http://localhost:4000/ 62 | 63 | Breaking that down: 64 | 65 | - `cd test_project` - We run the emulator from the `test_project` directory to keep all log files contained there. It's also where we store config for the emulator. 66 | - `ext:dev:emulators:start` - this is a command only available once `firebase --open-sesame extdev` is run. 67 | - `--test-config=firebase.json` - tells the emulator which emulators to use and which ports to start them on. 68 | - `--test-params=test-params.env` - This file is discussed above, it provides user configuration for testing. 69 | - `--project=nationalparks` - For testing in the emulator, we assume we're working on a hypothetical `nationalparks` data set. 70 | - `--import seed` - We stored `seed` data in a `seed` directory using the `export` command, this will reimport that data and populate our `nationalparks` collection. 71 | 72 | ## Install and run in an actual cloud project: 73 | 74 | https://firebase.google.com/docs/extensions/alpha/test#install-in-project 75 | 76 | This is most useful for demoing and stepping through the CLI install experience as an actual user would. It's a bit harder to use when you're developing the project as you have to push the changes to the extension every time you make them. 77 | 78 | If you don't have a "nationalparks" project in your cloud Firebase yet, you would need to log into Firebase cloud and create one with the name and id "nationalparks". 79 | 80 | Note that you can install this extension to ANY project you have in the cloud, not just the nationalparks project that this emulated environment uses. Just specify a "--project" id in the CLI. 81 | 82 | Ex. 83 | 84 | ``` 85 | # Make sure your extension has been rebuilt with your latest changes before publishing 86 | npm run build 87 | 88 | # If you'd like to step through the configuration experience 89 | firebase ext:install . --project=nationalparks 90 | 91 | # If you'd like to read configuration from your .env file 92 | firebase ext:install . --project=nationalparks --params=test_project/test-params.env 93 | ``` 94 | 95 | ## To import National Parks dataset into an actual cloud project: 96 | 97 | Use the script located in the `/load-data` directory. There is a README there describing how to use it. 98 | 99 | ## Testing the extension after the emulator is running 100 | 101 | In the Firestore emulator, create a collection called "nationalparks" and add a document to it. That document should be syned to your App Search instance. 102 | 103 | You can check the logs to see if it ran in the "Logs" tab of the emulator. 104 | 105 | Also, try querying App Search via the search endpoint: http://localhost:5001/nationalparks/us-central1/search?query=rocky 106 | 107 | ## Publishing 108 | 109 | The npm script and extension should maintain the same version number and should always be published together to maintain compatibility. 110 | 111 | To publish the npm script: 112 | 113 | ``` 114 | # Update the version number in functions/package.json 115 | cd functions 116 | npm run build 117 | npm publish 118 | ``` 119 | 120 | To publish the extension: 121 | [Docs](https://firebase.google.com/docs/extensions/alpha/share) 122 | 123 | ``` 124 | # Update the version in extension.yaml 125 | # Create a new entry for this version in CHANGELOG.md 126 | firebase ext:dev:publish elastic/firestore-elastic-app-search 127 | ``` 128 | 129 | Make sure you are logged into the firebase CLI with your `elastic.co` account. 130 | 131 | Note that this plugin is linked to the `elastic-official` project in the `elastic.co` organization in Google Cloud. 132 | -------------------------------------------------------------------------------- /extension.yaml: -------------------------------------------------------------------------------- 1 | # Learn detailed information about the fields of an extension.yaml file in the docs: 2 | # https://firebase.google.com/docs/extensions/alpha/ref-extension-yaml 3 | 4 | name: firestore-elastic-app-search # Identifier for your extension 5 | version: 0.4.1 # Follow semver versioning 6 | specVersion: v1beta # Version of the Firebase Extensions specification 7 | 8 | # Friendly display name for your extension (~3-5 words) 9 | displayName: Search with Elastic App Search 10 | 11 | # Brief description of the task your extension performs (~1 sentence) 12 | description: >- 13 | Syncs documents from a Firestore collection to Elastic App Search to enable full-text search. 14 | 15 | license: Apache-2.0 # https://spdx.org/licenses/ 16 | 17 | author: 18 | authorName: Elastic 19 | url: https://www.elastic.co/ 20 | 21 | # Public URL for the source code of your extension 22 | sourceUrl: https://github.com/elastic/app-search-firestore-extension 23 | 24 | # Specify whether a paid-tier billing plan is required to use your extension. 25 | # Learn more in the docs: https://firebase.google.com/docs/extensions/alpha/ref-extension-yaml#billing-required-field 26 | billingRequired: true 27 | 28 | # Any additional non-Google services used by the extension (typically REST APIs) 29 | externalServices: 30 | - name: Elastic Enterprise Search 31 | pricingUri: https://www.elastic.co/pricing/ 32 | 33 | # In an `apis` field, list any Google APIs (like Cloud Translation, BigQuery, etc.) 34 | # required for your extension to operate. 35 | # Learn more in the docs: https://firebase.google.com/docs/extensions/alpha/ref-extension-yaml#apis-field 36 | 37 | # In a `roles` field, list any IAM access roles required for your extension to operate. 38 | # Learn more in the docs: https://firebase.google.com/docs/extensions/alpha/ref-extension-yaml#roles-field 39 | roles: 40 | - role: datastore.user 41 | reason: Allows the extension to read configuration and build bundles from Firestore. 42 | 43 | # In the `resources` field, list each of your extension's functions, including the trigger for each function. 44 | # Learn more in the docs: https://firebase.google.com/docs/extensions/alpha/ref-extension-yaml#resources-field 45 | resources: 46 | - name: shipToElastic 47 | type: firebaseextensions.v1beta.function 48 | description: >- 49 | Function triggered on Create, Update, or Delete of a document in the specified collection which syncs the change 50 | to App Search. 51 | properties: 52 | # LOCATION is a user-configured parameter value specified by the user during installation. 53 | location: ${LOCATION} 54 | # https://cloud.google.com/functions/docs/reference/rest/v1/projects.locations.functions#EventTrigger 55 | eventTrigger: 56 | # Various event types for Firestore are listed here: https://firebase.google.com/docs/extensions/alpha/construct-functions 57 | eventType: providers/cloud.firestore/eventTypes/document.write 58 | # Parameters like PROJECT_ID are autopopulated: https://firebase.google.com/docs/extensions/alpha/parameters#auto-populated-parameters 59 | # Note, the id is a wildcard path parameter, which can be read from the collection 60 | # https://firebase.google.com/docs/firestore/extend-with-functions#wildcards-parameters 61 | resource: projects/${PROJECT_ID}/databases/(default)/documents/${COLLECTION_PATH}/{documentId} 62 | runtime: "nodejs14" 63 | 64 | # In the `params` field, set up your extension's user-configured parameters. 65 | # Learn more in the docs: https://firebase.google.com/docs/extensions/alpha/ref-extension-yaml#params-field 66 | # TODO We need more params here 67 | params: 68 | - param: LOCATION 69 | label: Cloud Functions location 70 | description: >- 71 | Choose where you want to deploy the functions created for this extension. 72 | For help selecting a location, refer to the [location selection 73 | guide](https://firebase.google.com/docs/functions/locations). 74 | type: select 75 | options: 76 | - label: Iowa (us-central1) 77 | value: us-central1 78 | - label: South Carolina (us-east1) 79 | value: us-east1 80 | - label: Northern Virginia (us-east4) 81 | value: us-east4 82 | - label: Los Angeles (us-west2) 83 | value: us-west2 84 | - label: Salt Lake City (us-west3) 85 | value: us-west3 86 | - label: Las Vegas (us-west4) 87 | value: us-west4 88 | - label: Warsaw (europe-central2) 89 | value: europe-central2 90 | - label: Belgium (europe-west1) 91 | value: europe-west1 92 | - label: London (europe-west2) 93 | value: europe-west2 94 | - label: Frankfurt (europe-west3) 95 | value: europe-west3 96 | - label: Zurich (europe-west6) 97 | value: europe-west6 98 | - label: Hong Kong (asia-east2) 99 | value: asia-east2 100 | - label: Tokyo (asia-northeast1) 101 | value: asia-northeast1 102 | - label: Osaka (asia-northeast2) 103 | value: asia-northeast2 104 | - label: Seoul (asia-northeast3) 105 | value: asia-northeast3 106 | - label: Mumbai (asia-south1) 107 | value: asia-south1 108 | - label: Jakarta (asia-southeast2) 109 | value: asia-southeast2 110 | - label: Montreal (northamerica-northeast1) 111 | value: northamerica-northeast1 112 | - label: Sao Paulo (southamerica-east1) 113 | value: southamerica-east1 114 | - label: Sydney (australia-southeast1) 115 | value: australia-southeast1 116 | required: true 117 | immutable: true 118 | 119 | - param: COLLECTION_PATH 120 | label: Collection path 121 | description: > 122 | The path to the collection that you want to sync to App Search. 123 | example: movies 124 | validationRegex: "^[^/]+(/[^/]+/[^/]+)*$" 125 | validationErrorMessage: Must be a valid Cloud Firestore Collection 126 | required: true 127 | 128 | - param: APP_SEARCH_ENGINE_NAME 129 | label: Elastic App Search engine name 130 | description: > 131 | The name of the Elastic App Search "engine" you want to sync to your collection. 132 | example: movies 133 | required: true 134 | 135 | - param: APP_SEARCH_API_KEY 136 | label: Elastic App Search private API key 137 | type: secret 138 | description: >- 139 | A "private" API key from your Elastic App Search deployment with access to the engine named above. 140 | API keys can be found in the [App Search "Credentials" page](https://www.elastic.co/guide/en/app-search/current/authentication.html#authentication-api-keys). 141 | example: private-79iadc5dzd3qxgfgd9w9ryc7 142 | required: true 143 | 144 | - param: ENTERPRISE_SEARCH_URL 145 | label: Elastic Enterprise Search URL 146 | description: > 147 | The base URL of your Enterprise Search deployment. 148 | This can also be found in the [App Search "Credentials" page](https://www.elastic.co/guide/en/app-search/current/authentication.html#authentication-api-keys). 149 | example: https://example.ent.us-west1.gcp.cloud.es.io/ 150 | required: true 151 | 152 | # TODO Note which field types are indexable, and how to specify nested fields? 153 | - param: INDEXED_FIELDS 154 | label: Indexed fields 155 | description: >- 156 | A comma separated list of the fields to index from your collection. Only the fields listed will be synced and searchable in App Search. You can specify [fields nested](https://github.com/elastic/app-search-firestore-extension/blob/master/POSTINSTALL.md#nested-fields) in maps with underscores: `field__subField`, 157 | and [new names for fields](https://github.com/elastic/app-search-firestore-extension/blob/master/POSTINSTALL.md#field-name-compatibility-and-renaming) in App Search using a double colon: `PreviousName::newname`. 158 | example: producer,director__name,year 159 | required: true 160 | -------------------------------------------------------------------------------- /functions/src/toAppSearch.test.ts: -------------------------------------------------------------------------------- 1 | import { toAppSearch } from "./toAppSearch"; 2 | 3 | describe("toAppSearch", () => { 4 | let originalValue: string | undefined; 5 | 6 | beforeAll(() => { 7 | originalValue = process.env.APP_SEARCH_ENGINE_NAME; 8 | }); 9 | 10 | afterAll(() => { 11 | process.env.APP_SEARCH_ENGINE_NAME = originalValue; 12 | }); 13 | 14 | beforeEach(() => { 15 | process.env.INDEXED_FIELDS = "foo,bar"; 16 | }); 17 | 18 | it('should only use fields from documents that have been marked as "indexed"', () => { 19 | // So it should only add foo and bar to the indexed object because that's all we have specified here 20 | process.env.INDEXED_FIELDS = "foo,bar"; 21 | 22 | expect( 23 | toAppSearch({ 24 | foo: "foo", 25 | bar: "bar", 26 | baz: "baz", 27 | }) 28 | ).toEqual({ 29 | foo: "foo", 30 | bar: "bar", 31 | }); 32 | }); 33 | 34 | it("should lowercase field names before indexing", () => { 35 | process.env.INDEXED_FIELDS = "Foo,Bar"; 36 | 37 | expect( 38 | toAppSearch({ 39 | Foo: "foo", 40 | Bar: "bar", 41 | Baz: "baz", 42 | }) 43 | ).toEqual({ 44 | foo: "foo", 45 | bar: "bar", 46 | }); 47 | }); 48 | 49 | it("will strip characters that are not alpha-numeric or underscores", () => { 50 | process.env.INDEXED_FIELDS = "a大,a1-b-c,d e_f,大"; 51 | 52 | expect( 53 | toAppSearch({ 54 | a大: "a大", 55 | "a1-b-c": "a1-b-c", 56 | "d e_f": "d e_f", 57 | 大: "大", 58 | }) 59 | ).toEqual({ 60 | a: "a大", 61 | a1bc: "a1-b-c", 62 | de_f: "d e_f", 63 | // 大 is ommited entirely because it serialized to an empty string 64 | }); 65 | }); 66 | 67 | it("what happens when multiple fields end up processing to the same field name?", () => { 68 | process.env.INDEXED_FIELDS = "A,a,a大"; 69 | 70 | expect( 71 | toAppSearch({ 72 | A: "A", 73 | a: "a", 74 | a大: "a大", 75 | }) 76 | ).toEqual({ 77 | a: "a大", 78 | }); 79 | }); 80 | 81 | describe("renaming", () => { 82 | it("should let users specify alternate names for fields using the `::` syntax", () => { 83 | process.env.INDEXED_FIELDS = "A,a::a1,a大::a2"; 84 | 85 | expect( 86 | toAppSearch({ 87 | A: "A", 88 | a: "a", 89 | a大: "a大", 90 | }) 91 | ).toEqual({ 92 | a: "A", 93 | a1: "a", 94 | a2: "a大", 95 | }); 96 | }); 97 | 98 | it("will look for the literal field first, before interpreting `::` as renaming syntax", () => { 99 | process.env.INDEXED_FIELDS = "A,a::a1,a大::a2"; 100 | 101 | expect( 102 | toAppSearch({ 103 | A: "A", 104 | "a::a1": "a", 105 | a大: "a大", 106 | }) 107 | ).toEqual({ 108 | a: "A", 109 | aa1: "a", 110 | a2: "a大", 111 | }); 112 | }); 113 | }); 114 | 115 | describe("nested fields", () => { 116 | it("will index nested fields that are specified as a separate field in app search", () => { 117 | process.env.INDEXED_FIELDS = "foo,bar__baz__qux"; 118 | 119 | expect( 120 | toAppSearch({ 121 | foo: "foo", 122 | bar: { 123 | baz: { 124 | qux: "test", 125 | }, 126 | }, 127 | }) 128 | ).toEqual({ 129 | foo: "foo", 130 | bar__baz__qux: "test", 131 | }); 132 | }); 133 | 134 | it("will look for the literal field first, before looking for the nested field", () => { 135 | process.env.INDEXED_FIELDS = "foo,bar__baz__qux"; 136 | 137 | expect( 138 | toAppSearch({ 139 | bar__baz__qux: "bar__baz__qux", 140 | foo: "foo", 141 | bar: { 142 | baz: { 143 | qux: "qux", 144 | }, 145 | }, 146 | }) 147 | ).toEqual({ 148 | foo: "foo", 149 | // App search doesn't support dot notation so we need to join them with "__" 150 | bar__baz__qux: "bar__baz__qux", 151 | }); 152 | }); 153 | }); 154 | 155 | describe("field types", () => { 156 | // This section just explicitly spells out how we'll handle each data type firestore supports 157 | // https://firebase.google.com/docs/firestore/manage-data/data-types 158 | 159 | it("should handle 'map' data type fields", () => { 160 | process.env.INDEXED_FIELDS = "baz"; 161 | 162 | expect( 163 | toAppSearch({ 164 | baz: { 165 | qux: "qux", 166 | quux: "quux", 167 | quuz: "quuz", 168 | }, 169 | }) 170 | // When this is sent to app search it will be converted to stringified JSON 171 | ).toEqual({ 172 | baz: { 173 | qux: "qux", 174 | quux: "quux", 175 | quuz: "quuz", 176 | }, 177 | }); 178 | }); 179 | 180 | it("should handle numeric fields", () => { 181 | process.env.INDEXED_FIELDS = "baz"; 182 | 183 | expect( 184 | toAppSearch({ 185 | baz: 1, 186 | }) 187 | // After this is sent to app search it will be converted from a number to a string 188 | ).toEqual({ 189 | baz: 1, 190 | }); 191 | }); 192 | 193 | it("should handle boolean fields", () => { 194 | process.env.INDEXED_FIELDS = "baz"; 195 | 196 | expect( 197 | toAppSearch({ 198 | baz: true, 199 | }) 200 | // After this is sent to app search it will be converted from a boolean to a string 201 | ).toEqual({ 202 | baz: true, 203 | }); 204 | }); 205 | 206 | it("should handle array fields", () => { 207 | process.env.INDEXED_FIELDS = "a,b,c,d,e,f"; 208 | 209 | expect( 210 | toAppSearch({ 211 | a: ["a", "b", "c"], 212 | b: ["a", 1, true], 213 | c: [{ a: "a" }, { b: "b" }], 214 | d: [ 215 | { 216 | _latitude: 41.12, 217 | _longitude: -71.34, 218 | }, 219 | { 220 | _seconds: 1631213624, 221 | _nanoseconds: 176000000, 222 | }, 223 | ], 224 | e: [ 225 | { 226 | time: { 227 | _seconds: 1631213624, 228 | _nanoseconds: 176000000, 229 | }, 230 | }, 231 | ], 232 | f: ["a", ["b"]], 233 | }) 234 | ).toEqual({ 235 | // ["a", "b", "c"] is passed is not touched, an array of strings is valid in app search 236 | a: ["a", "b", "c"], 237 | // ["a", 1, true] is passed is not touched, an array of simple values is valid in app search 238 | b: ["a", 1, true], 239 | // [{ a: "a" }, { b: "b" }] is passed is not touched, an array of objects is valid in app search, they will just be serialized to strings 240 | c: [{ a: "a" }, { b: "b" }], 241 | // geo values and date values are converted to app search format even if they are in an array 242 | d: ["41.12,-71.34", "2021-09-09T18:53:44.000Z"], 243 | // if geo values or date values are NESTED inside of an object in an array, they are NOT converted, since objects are just converted to strings on the app search side 244 | e: [ 245 | { 246 | time: { 247 | _seconds: 1631213624, 248 | _nanoseconds: 176000000, 249 | }, 250 | }, 251 | ], 252 | // ["a", ["b"]] nested arrays are dropped before sending them to app search, because app search does not support them 253 | f: ["a"], 254 | }); 255 | }); 256 | 257 | it("should handle date data types by converting to an ISO string", () => { 258 | process.env.INDEXED_FIELDS = "baz"; 259 | 260 | expect( 261 | toAppSearch({ 262 | baz: { 263 | _seconds: 1631213624, 264 | _nanoseconds: 176000000, 265 | }, 266 | }) 267 | ).toEqual({ 268 | baz: "2021-09-09T18:53:44.000Z", 269 | }); 270 | }); 271 | 272 | it("should handle geo data types by converting to a geo string", () => { 273 | process.env.INDEXED_FIELDS = "baz"; 274 | 275 | expect( 276 | toAppSearch({ 277 | baz: { 278 | _latitude: 41.12, 279 | _longitude: -71.34, 280 | }, 281 | }) 282 | ).toEqual({ 283 | baz: "41.12,-71.34", 284 | }); 285 | }); 286 | 287 | it("should handle null data types", () => { 288 | process.env.INDEXED_FIELDS = "baz"; 289 | 290 | expect( 291 | toAppSearch({ 292 | baz: null, 293 | }) 294 | ).toEqual({ 295 | // This is an actual data type in firestore, app search can support it so we just pass it through as null 296 | baz: null, 297 | }); 298 | }); 299 | 300 | it("should handle reference data types", () => { 301 | process.env.INDEXED_FIELDS = "baz"; 302 | 303 | expect( 304 | toAppSearch({ 305 | baz: { 306 | _firestore: { 307 | projectId: "nationalparks", 308 | }, 309 | _path: { 310 | segments: ["nationalparks", "123"], 311 | }, 312 | _converter: {}, 313 | }, 314 | }) 315 | ).toEqual({ 316 | // References will end up getting converted to serialized JSON objects of the following format, this is probably 317 | // unexpected for the user. 318 | baz: { 319 | _firestore: { 320 | projectId: "nationalparks", 321 | }, 322 | _path: { 323 | segments: ["nationalparks", "123"], 324 | }, 325 | _converter: {}, 326 | }, 327 | }); 328 | }); 329 | }); 330 | 331 | it("should handle missing, null, or undefined values", () => { 332 | // So it should only add foo and bar to the indexed object because that's all we have specified here 333 | process.env.INDEXED_FIELDS = "foo,bar,baz.qux"; 334 | 335 | expect( 336 | toAppSearch({ 337 | foo: null, 338 | bar: undefined, 339 | }) 340 | ).toEqual({ 341 | foo: null, 342 | }); 343 | }); 344 | 345 | it("not fail if INDEXED_FIELDS config is missing", () => { 346 | process.env.INDEXED_FIELDS = undefined; 347 | 348 | expect( 349 | toAppSearch({ 350 | foo: "foo", 351 | bar: "bar", 352 | }) 353 | ).toEqual({}); 354 | }); 355 | 356 | it("not fail if INDEXED_FIELDS is something weird", () => { 357 | process.env.INDEXED_FIELDS = "this is just totally invalid garbage"; 358 | 359 | expect( 360 | toAppSearch({ 361 | foo: "foo", 362 | bar: "bar", 363 | }) 364 | ).toEqual({}); 365 | }); 366 | 367 | it("should handle empty field config and extra space", () => { 368 | process.env.INDEXED_FIELDS = ",,, foo, , ,, bar"; 369 | 370 | expect( 371 | toAppSearch({ 372 | foo: "foo", 373 | bar: "bar", 374 | }) 375 | ).toEqual({ 376 | foo: "foo", 377 | bar: "bar", 378 | }); 379 | }); 380 | }); 381 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Elasticsearch B.V. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /POSTINSTALL.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | ### See it in action 14 | 15 | You can test out this extension right away! 16 | 17 | 1. Go to your [Cloud Firestore dashboard](https://console.firebase.google.com/project/${param:PROJECT_ID}/firestore/data) in the Firebase console. 18 | 19 | 2. If it doesn't already exist, create the collection you specified during installation: `${param:COLLECTION_PATH}` 20 | 21 | 3. Create a document in the collection that contains any of the fields you specified as indexed fields during installation: 22 | 23 | ```js 24 | `${param:INDEXED_FIELDS}` 25 | ``` 26 | 27 | 4. Go to the documents page of the Engine you created inside of your [App Search Dashboard](${param:ENTERPRISE_SEARCH_URL}/as#/engines/${param:APP_SEARCH_ENGINE_NAME}/documents). You should see the that document you just created listed on this page. 28 | 29 | ### Using the extension 30 | 31 | Whenever a document is created, updated, imported, or deleted in the specified collection, this extension sends that update to App Search. You can then run tull-text searches on this mirrored dataset. 32 | 33 | After documents are indexed into App Search, you'll have the complete App Search [Search API](https://www.elastic.co/guide/en/app-search/current/search.html) available to you for searching. 34 | 35 | Note that this extension only listens for document changes in the collection, but not changes in any subcollection. 36 | 37 | ### _(Optional)_ Backfill or import existing documents 38 | 39 | This extension only sends the content of documents that have been changed -- it does not export your full dataset of existing documents into App Search. So, to backfill your dataset with all the documents in your collection, you can run the import script provided by this extension. 40 | 41 | Before running the script, first follow the instructions [here](https://firebase.google.com/docs/admin/setup#initialize-sdk) to "To generate a private key file for your service account". Download it and save it somewhere as `serviceAccountKey.json`. 42 | 43 | ```shell 44 | GOOGLE_APPLICATION_CREDENTIALS= /path/to/your/serviceAccountKey.json \ 45 | COLLECTION_PATH=${param:COLLECTION_PATH} \ 46 | INDEXED_FIELDS=${param:INDEXED_FIELDS} \ 47 | ENTERPRISE_SEARCH_URL=${param:ENTERPRISE_SEARCH_URL} \ 48 | APP_SEARCH_API_KEY= { your private app search API key here } \ 49 | APP_SEARCH_ENGINE_NAME=${param:APP_SEARCH_ENGINE_NAME} \ 50 | npx @elastic/app-search-firestore-extension import 51 | ``` 52 | 53 | ### _(Optional)_ Configure App Search engine schema 54 | 55 | It is important to note that all data is initially indexed into App Search as text fields. 56 | 57 | This means that even if your field is a `timestamp` or `number` in Firestore, it will be indexed as text in App Search initially. 58 | 59 | This is fine for fields that you'd like to perform full-text search on. However, if you plan to do something like sort numerically or implement range filters when calling `search`, you should first visit the Schema page for your Engine in the App Search Dashboard and select the correct types for your fields. 60 | 61 | You can read more about Schemas [here](https://www.elastic.co/guide/en/app-search/current/indexing-documents-guide.html#indexing-documents-guide-schema). 62 | 63 | ### _(Optional)_ Reindex 64 | 65 | There may be times where you want to reindex all of your documents from this collection to App Search. 66 | 67 | For instance, if you change the "indexed fields" configuration in this extension, you should then run a reindex in order to make sure that the changes are picked up in App Search. 68 | 69 | To reindex data, use the steps listed above for "Backfill or import existing documents". 70 | 71 | ### How documents are indexed in App Search 72 | 73 | The TLDR for this section is: 74 | 75 | - `text` and `number` type fields are indexed as-is to App Search. 76 | - `geo` and `timestamp` fields are formatted slightly differently when indexed. 77 | - `map`, `boolean`, and `reference` are not supported by App Search, they will be indexed as text. 78 | - nested arrays are not supported at all and will be dropped before indexing in App Search. 79 | - While `map`s are not supported, you _can_ specify that fields within a map get indexed as top level fields in App Search, using the `__` syntax when configuring indexed fields 80 | - App Search only supports lower-cased alphanumeric characters and underscores ("\_") in field names. This extension will rename fields that don't match, or you can use the `::` syntax to specify what it is renamed to when configuring indexed fields. 81 | 82 | It is important to note that not all [data types supported by Firestore](https://firebase.google.com/docs/firestore/manage-data/data-types) are compatible with the [data types supported by App Search](https://www.elastic.co/guide/en/app-search/current/api-reference.html#overview-api-references-schema-design). 83 | 84 | Some types are supported in a 1-to-1 way: `text`, `number`. 85 | 86 | Others are supported, but formatted slightly differently: `timestamp`, `geo`. 87 | 88 | Others are simply not supported: `boolean`, `map`, `reference`. 89 | 90 | **It is also important to note that ONLY fields that you specify as Indexed Fields in this extension will be indexed into App Search**. 91 | 92 | For example, given the following document in Firestore: 93 | 94 | ```json 95 | { 96 | "id": "12345", 97 | "name": "Rocky Mountain", 98 | "nps_link": "https://www.nps.gov/romo/index.htm", 99 | "states": ["Colorado"], 100 | "visitors": 4517585, 101 | "world_heritage_site": false, 102 | "location": { 103 | "_latitude": 41.12, 104 | "_longitude": -71.34 105 | }, 106 | "acres": 265795.2, 107 | "square_km": 1075.6, 108 | "date_established": { 109 | "_seconds": 1631213624, 110 | "_nanoseconds": 176000000 111 | } 112 | } 113 | ``` 114 | 115 | If you've configured the plugin with indexed fields of `name,states`, then the document will be indexed into App Search as the following: 116 | 117 | ```json 118 | { 119 | "id": "12345", 120 | "name": "Rocky Mountain", 121 | "states": ["Colorado"] 122 | } 123 | ``` 124 | 125 | That means you could then perform a search with the App Search Search API over the `name` and `states` fields for results. 126 | 127 | #### Similar types, formatted differently 128 | 129 | As mentioned above, types are somtimes formatted differently in App Search. So given the same example document above, but configured with `name,states,location,date_established` as the indexed fields, you'll see that the `location` and `date_established` fields have been formatted slightly differently. 130 | 131 | ```json 132 | { 133 | "id": "12345", 134 | "name": "Rocky Mountain", 135 | "states": ["Colorado"], 136 | "location": "41.12,-71.34", 137 | "date_established": "2021-09-09T18:53:44.000Z" 138 | } 139 | ``` 140 | 141 | We put them in this special format so that App Search is able to recognize them as the correct types. Unlike the name and states fields, you may want to do more than just searching on these fields. In fact, you most likely won't want to search on these fields at all; it's much more likely that you'll want to use these for things like filtering and sorting. 142 | 143 | #### Types not supported by App Search 144 | 145 | There are some types of fields that ARE supported by Firestore, but not by App Search. So, when data is indexed, you may see that some data is dropped, or see that it is indexed in a way you may not have expected. 146 | 147 | **Maps** 148 | 149 | App Search does not support the concepts of maps. You may only have top-level fields. If you send a map to App Search, it will simply serialize your map and store it as a text: 150 | 151 | Firestore: 152 | 153 | ```json 154 | { 155 | "id": "12345", 156 | "foo": { 157 | "bar": { 158 | "baz": "some value" 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | App Search: 165 | 166 | ```json 167 | { 168 | "id": "12345", 169 | "foo": { 170 | "bar": "{\"bar\":{\"baz\":\"some value\"}}" 171 | } 172 | } 173 | ``` 174 | 175 | **So are values in maps searchable? Yes, see the Nested Fields section for more info.** 176 | 177 | **Nested arrays** 178 | 179 | Nested arrays are not supported by App Search. Nested arrays will simply be dropped. 180 | 181 | Firestore: 182 | 183 | ```json 184 | { 185 | "id": "12345", 186 | "foo": [["a"]] 187 | } 188 | ``` 189 | 190 | App Search: 191 | 192 | ```json 193 | { 194 | "id": "12345", 195 | "foo": [] 196 | } 197 | ``` 198 | 199 | **Reference** 200 | 201 | If you try to index a `reference` field to App Search, it will simply be serialized as if it were any other object, as serialized text: 202 | 203 | Firestore: 204 | 205 | ```json 206 | { 207 | "id": "12345", 208 | "some_reference": { 209 | "_firestore": { 210 | "projectId": "national_parks" 211 | }, 212 | "_path": { 213 | "segments": ["national_parks", "123"] 214 | }, 215 | "_converter": {} 216 | } 217 | } 218 | ``` 219 | 220 | App Search: 221 | 222 | ```json 223 | { 224 | "id": "12345", 225 | "some_reference": "{\"_firestore\":{\"projectId\":\"national_parks\"},\"_path\":{\"segments\":[\"national_parks\",\"123\"]},\"_converter\":{}}" 226 | } 227 | ``` 228 | 229 | #### Nested fields 230 | 231 | While the `map` type is not supported in App Search, you _can_ index fields from within a `map` into App Search. It will convert them to a new top-level field. 232 | 233 | In the provided example, if you used underscores to specify a sub field as indexed, it will index as follows into App Search. 234 | 235 | Indexed field: `name,foo__bar__baz` 236 | 237 | Firestore: 238 | 239 | ```json 240 | { 241 | "id": "12345", 242 | "name": "test name", 243 | "foo": { 244 | "bar": { 245 | "baz": "some value" 246 | } 247 | } 248 | ``` 249 | 250 | App Search: 251 | 252 | ```json 253 | { 254 | "id": "12345", 255 | "name": "test name", 256 | "foo__bar__baz": "some value" 257 | } 258 | ``` 259 | 260 | Please note that we are adding an additional top name field to your schema, in which we use "\_\_" as a delimiter. This could potentially conflict with other top-level field names, though that will most likely not be the case. 261 | 262 | #### Field name compatibility and renaming 263 | 264 | App Search only supports lower-cased alphanumeric characters and underscores ("\_") in field names. Field values that do not match will be renamed to match: 265 | 266 | Indexed field: `name,foo__bar__baz` 267 | 268 | Firestore: 269 | 270 | ```json 271 | { 272 | "id": "12345", 273 | "a大": "a大", 274 | "A1-b-c": "A1-b-c", 275 | "d e_f": "d e_f", 276 | "大": "大" 277 | } 278 | ``` 279 | 280 | App Search: 281 | 282 | ```json 283 | { 284 | "id": "12345", 285 | "a": "a大", 286 | "a1bc": "A1-b-c", 287 | "de_f": "d e_f" 288 | // 大 is ommited entirely because it serialized to an empty string 289 | } 290 | ``` 291 | 292 | As this could have underirable effects we allow renaming of fields by using the `::` when specifying indexed fields: 293 | 294 | Indexed field: `你好::hello,爱::love,幸福::happiness` 295 | 296 | Firestore: 297 | 298 | ```json 299 | { 300 | "id": "12345", 301 | "你好": "你好", 302 | "爱": "爱", 303 | "幸福": "幸福" 304 | } 305 | ``` 306 | 307 | App Search: 308 | 309 | ```json 310 | { 311 | "id": "12345", 312 | "hello": "你好", 313 | "love": "爱", 314 | "happiness": "幸福" 315 | } 316 | ``` 317 | 318 | ### Monitoring 319 | 320 | As a best practice, you can [monitor the activity](https://firebase.google.com/docs/extensions/manage-installed-extensions#monitor) of your installed extension, including checks on its health, usage, and logs. 321 | -------------------------------------------------------------------------------- /load-data/nationalparks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "Covering most of Mount Desert Island and other coastal islands, Acadia features the tallest mountain on the Atlantic coast of the United States, granite peaks, ocean shoreline, woodlands, and lakes. There are freshwater, estuary, forest, and intertidal habitats.", 4 | "nps_link": "https://www.nps.gov/acad/index.htm", 5 | "states": ["Maine"], 6 | "title": "Acadia", 7 | "visitors": "3303393", 8 | "world_heritage_site": "false", 9 | "location": "44.35,-68.21", 10 | "acres": "49057.36", 11 | "square_km": "198.5", 12 | "date_established": "1919-02-26T06:00:00Z", 13 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/D7EA0C7E-DB65-18FD-6C41020E643A4686.jpg", 14 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/ner/homepage/D7EA0C7E-DB65-18FD-6C41020E643A4686.jpg", 15 | "id": "park_acadia" 16 | }, 17 | { 18 | "description": "The southernmost National Park is on three Samoan islands and protects coral reefs, rainforests, volcanic mountains, and white beaches. The area is also home to flying foxes, brown boobies, sea turtles, and 900 species of fish.", 19 | "nps_link": "https://www.nps.gov/npsa/index.htm", 20 | "states": ["American Samoa"], 21 | "title": "American Samoa", 22 | "visitors": "28892", 23 | "world_heritage_site": "false", 24 | "location": "-14.25,-170.68", 25 | "acres": "8256.67", 26 | "square_km": "33.4", 27 | "date_established": "1988-10-31T06:00:00Z", 28 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/25FAA5B5-1DD8-B71B-0B96C6CD4CEB4517.jpg", 29 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/25FAA5B5-1DD8-B71B-0B96C6CD4CEB4517.jpg", 30 | "id": "park_american-samoa" 31 | }, 32 | { 33 | "description": "This site features more than 2,000 natural sandstone arches, with some of the most popular arches in the park being Delicate Arch, Landscape Arch and Double Arch. Millions of years of erosion have created these structures located in a desert climate where the arid ground has life-sustaining biological soil crusts and potholes that serve as natural water-collecting basins. Other geologic formations include stone pinnacles, fins, and balancing rocks.", 34 | "nps_link": "https://www.nps.gov/arch/index.htm", 35 | "states": ["Utah"], 36 | "title": "Arches", 37 | "visitors": "1585718", 38 | "world_heritage_site": "false", 39 | "location": "38.68,-109.57", 40 | "acres": "76678.98", 41 | "square_km": "310.3", 42 | "date_established": "1971-11-12T06:00:00Z", 43 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/F824D1FE-9838-6CB2-D4BC129C79275EDA.jpg", 44 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/F824D1FE-9838-6CB2-D4BC129C79275EDA.jpg", 45 | "id": "park_arches" 46 | }, 47 | { 48 | "description": "The Badlands are a collection of buttes, pinnacles, spires, and mixed-grass prairies. The White River Badlands contain the largest assemblage of known late Eocene and Oligocene mammal fossils. The wildlife includes bison, bighorn sheep, black-footed ferrets, and prairie dogs.", 49 | "nps_link": "https://www.nps.gov/badl/index.htm", 50 | "states": ["South Dakota"], 51 | "title": "Badlands", 52 | "visitors": "996263", 53 | "world_heritage_site": "false", 54 | "location": "43.75,-102.5", 55 | "acres": "242755.94", 56 | "square_km": "982.4", 57 | "date_established": "1978-11-10T06:00:00Z", 58 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/491D568A-1DD8-B71B-0B8DC6D83B0CDA51.jpg", 59 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/mwr/homepage/491D568A-1DD8-B71B-0B8DC6D83B0CDA51.jpg", 60 | "id": "park_badlands" 61 | }, 62 | { 63 | "description": "Named for the prominent bend in the Rio Grande along the U.S.–Mexico border, this park encompasses a large and remote part of the Chihuahuan Desert. Its main attraction is backcountry recreation in the arid Chisos Mountains and in canyons along the river. A wide variety of Cretaceous and Tertiary fossils as well as cultural artifacts of Native Americans also exist within its borders.", 64 | "nps_link": "https://www.nps.gov/bibe/index.htm", 65 | "states": ["Texas"], 66 | "title": "Big Bend", 67 | "visitors": "388290", 68 | "world_heritage_site": "false", 69 | "location": "29.25,-103.25", 70 | "acres": "801163.21", 71 | "square_km": "3242.2", 72 | "date_established": "1944-06-12T05:00:00Z", 73 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/F69740AB-1DD8-B71B-0BF4CCD783D01EDD.JPG", 74 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/F69740AB-1DD8-B71B-0BF4CCD783D01EDD.JPG", 75 | "id": "park_big-bend" 76 | }, 77 | { 78 | "description": "Located in Biscayne Bay, this park at the north end of the Florida Keys has four interrelated marine ecosystems: mangrove forest, the Bay, the Keys, and coral reefs. Threatened animals include the West Indian manatee, American crocodile, various sea turtles, and peregrine falcon.", 79 | "nps_link": "https://www.nps.gov/bisc/index.htm", 80 | "states": ["Florida"], 81 | "title": "Biscayne", 82 | "visitors": "514709", 83 | "world_heritage_site": "false", 84 | "location": "25.65,-80.08", 85 | "acres": "172971.11", 86 | "square_km": "700", 87 | "date_established": "1980-06-28T05:00:00Z", 88 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/C7A242B8-E5FB-95AE-8EE9B600B1444CD6.jpg", 89 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/C7A242B8-E5FB-95AE-8EE9B600B1444CD6.jpg", 90 | "id": "park_biscayne" 91 | }, 92 | { 93 | "description": "The park protects a quarter of the Gunnison River, which slices sheer canyon walls from dark Precambrian-era rock. The canyon features some of the steepest cliffs and oldest rock in North America, and is a popular site for river rafting and rock climbing. The deep, narrow canyon is composed of gneiss and schist which appears black when in shadow.", 94 | "nps_link": "https://www.nps.gov/blca/index.htm", 95 | "states": ["Colorado"], 96 | "title": "Black Canyon of the Gunnison", 97 | "visitors": "238018", 98 | "world_heritage_site": "false", 99 | "location": "38.57,-107.72", 100 | "acres": "30749.75", 101 | "square_km": "124.4", 102 | "date_established": "1999-10-21T05:00:00Z", 103 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/0B6DFCE9-1DD8-B71B-0B379BA8D30DC0D6.jpg", 104 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/0B6DFCE9-1DD8-B71B-0B379BA8D30DC0D6.jpg", 105 | "id": "park_black-canyon-of-the-gunnison" 106 | }, 107 | { 108 | "description": "Bryce Canyon is a geological amphitheater on the Paunsaugunt Plateau with hundreds of tall, multicolored sandstone hoodoos formed by erosion. The region was originally settled by Native Americans and later by Mormon pioneers.", 109 | "nps_link": "https://www.nps.gov/brca/index.htm", 110 | "states": ["Utah"], 111 | "title": "Bryce Canyon", 112 | "visitors": "2365110", 113 | "world_heritage_site": "false", 114 | "location": "37.57,-112.18", 115 | "acres": "35835.08", 116 | "square_km": "145", 117 | "date_established": "1928-02-25T06:00:00Z", 118 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/BC95A62E-AF5D-E8CF-72BE9896E188843F.jpg", 119 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/BC95A62E-AF5D-E8CF-72BE9896E188843F.jpg", 120 | "id": "park_bryce-canyon" 121 | }, 122 | { 123 | "description": "This landscape was eroded into a maze of canyons, buttes, and mesas by the combined efforts of the Colorado River, Green River, and their tributaries, which divide the park into three districts. The park also contains rock pinnacles and arches, as well as artifacts from Ancient Pueblo peoples.", 124 | "nps_link": "https://www.nps.gov/cany/index.htm", 125 | "states": ["Utah"], 126 | "title": "Canyonlands", 127 | "visitors": "776218", 128 | "world_heritage_site": "false", 129 | "location": "38.2,-109.93", 130 | "acres": "337597.83", 131 | "square_km": "1366.2", 132 | "date_established": "1964-09-12T05:00:00Z", 133 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/9E7FC0DB-1DD8-B71B-0BC3880DC2250415.jpg", 134 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/9E7FC0DB-1DD8-B71B-0BC3880DC2250415.jpg", 135 | "id": "park_canyonlands" 136 | }, 137 | { 138 | "description": "The park's Waterpocket Fold is a 100-mile (160 km) monocline that exhibits the earth's diverse geologic layers. Other natural features include monoliths, cliffs, and sandstone domes shaped like the United States Capitol.", 139 | "nps_link": "https://www.nps.gov/care/index.htm", 140 | "states": ["Utah"], 141 | "title": "Capitol Reef", 142 | "visitors": "1064904", 143 | "world_heritage_site": "false", 144 | "location": "38.2,-111.17", 145 | "acres": "241904.26", 146 | "square_km": "979", 147 | "date_established": "1971-12-18T06:00:00Z", 148 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/27E22C89-1DD8-B71B-0B1D8E1F50037612.jpg", 149 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/27E22C89-1DD8-B71B-0B1D8E1F50037612.jpg", 150 | "id": "park_capitol-reef" 151 | }, 152 | { 153 | "description": "Carlsbad Caverns has 117 caves, the longest of which is over 120 miles (190 km) long. The Big Room is almost 4,000 feet (1,200 m) long, and the caves are home to over 400,000 Mexican free-tailed bats and sixteen other species. Above ground are the Chihuahuan Desert and Rattlesnake Springs.", 154 | "nps_link": "https://www.nps.gov/cave/index.htm", 155 | "states": ["New Mexico"], 156 | "title": "Carlsbad Caverns", 157 | "visitors": "466773", 158 | "world_heritage_site": "true", 159 | "location": "32.17,-104.44", 160 | "acres": "46766.45", 161 | "square_km": "189.3", 162 | "date_established": "1930-05-14T05:00:00Z", 163 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/1CF45171-AA55-D49A-F7F2E2AF45DBEB8E.jpeg", 164 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/1CF45171-AA55-D49A-F7F2E2AF45DBEB8E.jpeg", 165 | "id": "park_carlsbad-caverns" 166 | }, 167 | { 168 | "description": "Five of the eight Channel Islands are protected, and half of the park's area is underwater. The islands have a unique Mediterranean ecosystem originally settled by the Chumash people. They are home to over 2,000 species of land plants and animals, and 145 are unique to them, including the island fox. Ferry services offer transportation to the islands from the mainland.", 169 | "nps_link": "https://www.nps.gov/chis/index.htm", 170 | "states": ["California"], 171 | "title": "Channel Islands", 172 | "visitors": "364807", 173 | "world_heritage_site": "false", 174 | "location": "34.01,-119.42", 175 | "acres": "249561", 176 | "square_km": "1009.9", 177 | "date_established": "1980-03-05T06:00:00Z", 178 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/8B1831DF-1DD8-B71B-0BF9865C590EE5A5.jpg", 179 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/8B1831DF-1DD8-B71B-0BF9865C590EE5A5.jpg", 180 | "id": "park_channel-islands" 181 | }, 182 | { 183 | "description": "On the Congaree River, this park is the largest portion of old-growth floodplain forest left in North America. Some of the trees are the tallest in the eastern United States. An elevated walkway called the Boardwalk Loop guides visitors through the swamp.", 184 | "nps_link": "https://www.nps.gov/cong/index.htm", 185 | "states": ["South Carolina"], 186 | "title": "Congaree", 187 | "visitors": "143843", 188 | "world_heritage_site": "false", 189 | "location": "33.78,-80.78", 190 | "acres": "26275.82", 191 | "square_km": "106.3", 192 | "date_established": "2003-11-10T06:00:00Z", 193 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/C632FF77-1DD8-B71B-0B63EA949BD9BBD0.jpg", 194 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/C632FF77-1DD8-B71B-0B63EA949BD9BBD0.jpg", 195 | "id": "park_congaree" 196 | }, 197 | { 198 | "description": "Crater Lake lies in the caldera of an ancient volcano called Mount Mazama that collapsed 7,700 years ago. It is the deepest lake in the United States and is noted for its vivid blue color and water clarity. There are two more recent volcanic islands in the lake, and, with no inlets or outlets, all water comes through precipitation.", 199 | "nps_link": "https://www.nps.gov/crla/index.htm", 200 | "states": ["Oregon"], 201 | "title": "Crater Lake", 202 | "visitors": "756344", 203 | "world_heritage_site": "false", 204 | "location": "42.94,-122.1", 205 | "acres": "183224.05", 206 | "square_km": "741.5", 207 | "date_established": "1902-05-22T05:00:00Z", 208 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/6B97B34B-1DD8-B71B-0B609E4FF8532FE9.jpg", 209 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/6B97B34B-1DD8-B71B-0B609E4FF8532FE9.jpg", 210 | "id": "park_crater-lake" 211 | }, 212 | { 213 | "description": "This park along the Cuyahoga River has waterfalls, hills, trails, and exhibits on early rural living. The Ohio and Erie Canal Towpath Trail follows the Ohio and Erie Canal, where mules towed canal boats. The park has numerous historic homes, bridges, and structures, and also offers a scenic train ride.", 214 | "nps_link": "https://www.nps.gov/cuva/index.htm", 215 | "states": ["Ohio"], 216 | "title": "Cuyahoga Valley", 217 | "visitors": "2423390", 218 | "world_heritage_site": "false", 219 | "location": "41.24,-81.55", 220 | "acres": "32572.35", 221 | "square_km": "131.8", 222 | "date_established": "2000-10-11T05:00:00Z", 223 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/F11F8BD6-A465-5854-25470B33D1AFB3FA.jpg", 224 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/mwr/homepage/F11F8BD6-A465-5854-25470B33D1AFB3FA.jpg", 225 | "id": "park_cuyahoga-valley" 226 | }, 227 | { 228 | "description": "Death Valley is the hottest, lowest, and driest place in the United States. Daytime temperatures have topped 130 °F (54 °C) and it is home to Badwater Basin, the lowest elevation in North America. The park contains canyons, badlands, sand dunes, and mountain ranges, while more than 1000 species of plants grow in this geologic graben. Additional points of interest include salt flats, historic mines, and springs.", 229 | "nps_link": "https://www.nps.gov/deva/index.htm", 230 | "states": ["California", "Nevada"], 231 | "title": "Death Valley", 232 | "visitors": "1296283", 233 | "world_heritage_site": "false", 234 | "location": "36.24,-116.82", 235 | "acres": "3373063.14", 236 | "square_km": "13650.3", 237 | "date_established": "1994-10-31T06:00:00Z", 238 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/D0D65293-1DD8-B71B-0B90C84869AED282.jpg", 239 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/D0D65293-1DD8-B71B-0B90C84869AED282.jpg", 240 | "id": "park_death-valley" 241 | }, 242 | { 243 | "description": "Centered on Denali, the tallest mountain in North America, Denali is serviced by a single road leading to Wonder Lake. Denali and other peaks of the Alaska Range are covered with long glaciers and boreal forest. Wildlife includes grizzly bears, Dall sheep, caribou, and gray wolves.", 244 | "nps_link": "https://www.nps.gov/dena/index.htm", 245 | "states": ["Alaska"], 246 | "title": "Denali", 247 | "visitors": "587412", 248 | "world_heritage_site": "false", 249 | "location": "63.33,-150.5", 250 | "acres": "4740911.16", 251 | "square_km": "19185.8", 252 | "date_established": "1917-02-26T06:00:00Z", 253 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/ECE79510-155D-9421-1FCF6F833BD84413.jpg", 254 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/ECE79510-155D-9421-1FCF6F833BD84413.jpg", 255 | "id": "park_denali" 256 | }, 257 | { 258 | "description": "The islands of the Dry Tortugas, at the westernmost end of the Florida Keys, are the site of Fort Jefferson, a Civil War-era fort that is the largest masonry structure in the Western Hemisphere. With most of the park being remote ocean, it is home to undisturbed coral reefs and shipwrecks and is only accessible by plane or boat.", 259 | "nps_link": "https://www.nps.gov/drto/index.htm", 260 | "states": ["Florida"], 261 | "title": "Dry Tortugas", 262 | "visitors": "73661", 263 | "world_heritage_site": "false", 264 | "location": "24.63,-82.87", 265 | "acres": "64701.22", 266 | "square_km": "261.8", 267 | "date_established": "1992-10-26T06:00:00Z", 268 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/B8CF5C7E-1DD8-B71B-0B62F152705DCACA.jpg", 269 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/B8CF5C7E-1DD8-B71B-0B62F152705DCACA.jpg", 270 | "id": "park_dry-tortugas" 271 | }, 272 | { 273 | "description": "The Everglades are the largest tropical wilderness in the United States. This mangrove and tropical rainforest ecosystem and marine estuary is home to 36 protected species, including the Florida panther, American crocodile, and West Indian manatee. Some areas have been drained and developed; restoration projects aim to restore the ecology.", 274 | "nps_link": "https://www.nps.gov/ever/index.htm", 275 | "states": ["Florida"], 276 | "title": "Everglades", 277 | "visitors": "930907", 278 | "world_heritage_site": "true", 279 | "location": "25.32,-80.93", 280 | "acres": "1508968.1", 281 | "square_km": "6106.6", 282 | "date_established": "1934-05-30T05:00:00Z", 283 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/510DA558-1DD8-B71B-0BF2DBBE49B06F9F.jpg", 284 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/510DA558-1DD8-B71B-0BF2DBBE49B06F9F.jpg", 285 | "id": "park_everglades" 286 | }, 287 | { 288 | "description": "The country's northernmost park protects an expanse of pure wilderness in Alaska's Brooks Range and has no park facilities. The land is home to Alaska Natives who have relied on the land and caribou for 11,000 years.", 289 | "nps_link": "https://www.nps.gov/gaar/index.htm", 290 | "states": ["Alaska"], 291 | "title": "Gates of the Arctic", 292 | "visitors": "10047", 293 | "world_heritage_site": "false", 294 | "location": "67.78,-153.3", 295 | "acres": "7523897.45", 296 | "square_km": "30448.1", 297 | "date_established": "1980-12-02T06:00:00Z", 298 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/80BF2CBB-1DD8-B71B-0B0EE177F0BF9659.jpg", 299 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/80BF2CBB-1DD8-B71B-0B0EE177F0BF9659.jpg", 300 | "id": "park_gates-of-the-arctic" 301 | }, 302 | { 303 | "description": "The U.S. half of Waterton-Glacier International Peace Park, this park includes 26 glaciers and 130 named lakes surrounded by Rocky Mountain peaks. There are historic hotels and a landmark road called the Going-to-the-Sun Road in this region of rapidly receding glaciers. The local mountains, formed by an overthrust, expose Paleozoic fossils including trilobites, mollusks, giant ferns and dinosaurs.", 304 | "nps_link": "https://www.nps.gov/glac/index.htm", 305 | "states": ["Montana"], 306 | "title": "Glacier", 307 | "visitors": "2946681", 308 | "world_heritage_site": "true", 309 | "location": "48.8,-114", 310 | "acres": "1013128.94", 311 | "square_km": "4100", 312 | "date_established": "1910-05-11T05:00:00Z", 313 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/6922C721-C3ED-FF58-C24344DC8176E269.jpg", 314 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/6922C721-C3ED-FF58-C24344DC8176E269.jpg", 315 | "id": "park_glacier" 316 | }, 317 | { 318 | "description": "Glacier Bay contains tidewater glaciers, mountains, fjords, and a temperate rainforest, and is home to large populations of grizzly bears, mountain goats, whales, seals, and eagles. When discovered in 1794 by George Vancouver, the entire bay was covered by ice, but the glaciers have since receded more than 65 miles (105 km).", 319 | "nps_link": "https://www.nps.gov/glba/index.htm", 320 | "states": ["Alaska"], 321 | "title": "Glacier Bay", 322 | "visitors": "520171", 323 | "world_heritage_site": "true", 324 | "location": "58.5,-137", 325 | "acres": "3223383.43", 326 | "square_km": "13044.6", 327 | "date_established": "1980-12-02T06:00:00Z", 328 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/80FF40BC-DB29-10E6-447D35579E210592.jpg", 329 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/80FF40BC-DB29-10E6-447D35579E210592.jpg", 330 | "id": "park_glacier-bay" 331 | }, 332 | { 333 | "description": "The Grand Canyon, carved by the mighty Colorado River, is 277 miles (446 km) long, up to 1 mile (1.6 km) deep, and up to 15 miles (24 km) wide. Millions of years of erosion have exposed the multicolored layers of the Colorado Plateau in mesas and canyon walls, visible from both the north and south rims, or from a number of trails that descend into the canyon itself.", 334 | "nps_link": "https://www.nps.gov/grca/index.htm", 335 | "states": ["Arizona"], 336 | "title": "Grand Canyon", 337 | "visitors": "5969811", 338 | "world_heritage_site": "true", 339 | "location": "36.06,-112.14", 340 | "acres": "1201647.03", 341 | "square_km": "4862.9", 342 | "date_established": "1919-02-26T06:00:00Z", 343 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/99556161-1DD8-B71B-0B896E4D786C6B47.jpg", 344 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/99556161-1DD8-B71B-0B896E4D786C6B47.jpg", 345 | "id": "park_grand-canyon" 346 | }, 347 | { 348 | "description": "Grand Teton is the tallest mountain in the Teton Range. The park's historic Jackson Hole and reflective piedmont lakes teem with endemic wildlife, with a backdrop of craggy mountains that rise abruptly from the sage-covered valley.", 349 | "nps_link": "https://www.nps.gov/grte/index.htm", 350 | "states": ["Wyoming"], 351 | "title": "Grand Teton", 352 | "visitors": "3270076", 353 | "world_heritage_site": "false", 354 | "location": "43.73,-110.8", 355 | "acres": "310043.96", 356 | "square_km": "1254.7", 357 | "date_established": "1929-02-26T06:00:00Z", 358 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/80DDA74C-1DD8-B71B-0B7F0D6464FA6E5E.JPG", 359 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/80DDA74C-1DD8-B71B-0B7F0D6464FA6E5E.JPG", 360 | "id": "park_grand-teton" 361 | }, 362 | { 363 | "description": "Based around Nevada's second tallest mountain, Wheeler Peak, Great Basin National Park contains 5,000-year-old bristlecone pines, a rock glacier, and the limestone Lehman Caves. Due to its remote location, the park has some of the country's darkest night skies. Wildlife includes the Townsend's big-eared bat, pronghorn, and Bonneville cutthroat trout.", 364 | "nps_link": "https://www.nps.gov/grba/index.htm", 365 | "states": ["Nevada"], 366 | "title": "Great Basin", 367 | "visitors": "144846", 368 | "world_heritage_site": "false", 369 | "location": "38.98,-114.3", 370 | "acres": "77180", 371 | "square_km": "312.3", 372 | "date_established": "1986-10-27T06:00:00Z", 373 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/6335B973-1DD8-B71B-0BA4AA7F19E16904.jpg", 374 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/6335B973-1DD8-B71B-0BA4AA7F19E16904.jpg", 375 | "id": "park_great-basin" 376 | }, 377 | { 378 | "description": "The tallest sand dunes in North America, up to 750 feet (230 m) tall, were formed by deposits of the ancient Rio Grande in the San Luis Valley. Abutting a variety of grasslands, shrublands, and wetlands, the park also has alpine lakes, six 13,000-foot mountains, and old-growth forests.", 379 | "nps_link": "https://www.nps.gov/grsa/index.htm", 380 | "states": ["Colorado"], 381 | "title": "Great Sand Dunes", 382 | "visitors": "388308", 383 | "world_heritage_site": "false", 384 | "location": "37.73,-105.51", 385 | "acres": "107341.87", 386 | "square_km": "434.4", 387 | "date_established": "2004-09-13T05:00:00Z", 388 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/0805F9FA-1DD8-B71B-0BD0B5F4D6BDCE8F.jpg", 389 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/0805F9FA-1DD8-B71B-0BD0B5F4D6BDCE8F.jpg", 390 | "id": "park_great-sand-dunes" 391 | }, 392 | { 393 | "description": "The Great Smoky Mountains, part of the Appalachian Mountains, span a wide range of elevations, making them home to over 400 vertebrate species, 100 tree species, and 5000 plant species. Hiking is the park's main attraction, with over 800 miles (1,300 km) of trails, including 70 miles (110 km) of the Appalachian Trail. Other activities include fishing, horseback riding, and touring nearly 80 historic structures.", 394 | "nps_link": "https://www.nps.gov/grsm/index.htm", 395 | "states": ["Tennessee", "North Carolina"], 396 | "title": "Great Smoky Mountains", 397 | "visitors": "11312786", 398 | "world_heritage_site": "true", 399 | "location": "35.68,-83.53", 400 | "acres": "522426.88", 401 | "square_km": "2114.2", 402 | "date_established": "1934-06-15T05:00:00Z", 403 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/3FF6F0E2-BCB9-2C92-3D4AC60512137D8A.jpg", 404 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/3FF6F0E2-BCB9-2C92-3D4AC60512137D8A.jpg", 405 | "id": "park_great-smoky-mountains" 406 | }, 407 | { 408 | "description": "This park contains Guadalupe Peak, the highest point in Texas, as well as the scenic McKittrick Canyon filled with bigtooth maples, a corner of the arid Chihuahuan Desert, and a fossilized coral reef from the Permian era.", 409 | "nps_link": "https://www.nps.gov/gumo/index.htm", 410 | "states": ["Texas"], 411 | "title": "Guadalupe Mountains", 412 | "visitors": "181839", 413 | "world_heritage_site": "false", 414 | "location": "31.92,-104.87", 415 | "acres": "86367.1", 416 | "square_km": "349.5", 417 | "date_established": "1966-10-15T05:00:00Z", 418 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/127F00C2-1DD8-B71B-0BBF78268AF69C99.jpg", 419 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/127F00C2-1DD8-B71B-0BBF78268AF69C99.jpg", 420 | "id": "park_guadalupe-mountains" 421 | }, 422 | { 423 | "description": "The Haleakala volcano on Maui features a very large crater with numerous cinder cones, Hosmer's Grove of alien trees, the Kipahulu section's scenic pools of freshwater fish, and the native Hawaiian goose. It is home to the greatest number of endangered species within a U.S. National Park.", 424 | "nps_link": "https://www.nps.gov/hale/index.htm", 425 | "states": ["Hawaii"], 426 | "title": "Haleakala", 427 | "visitors": "1263558", 428 | "world_heritage_site": "false", 429 | "location": "20.72,-156.17", 430 | "acres": "33264.62", 431 | "square_km": "134.6", 432 | "date_established": "1916-08-01T05:00:00Z", 433 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/357AE736-1DD8-B71B-0B099C77F75FF816.jpg", 434 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/357AE736-1DD8-B71B-0B099C77F75FF816.jpg", 435 | "id": "park_haleakala" 436 | }, 437 | { 438 | "description": "This park on the Big Island protects the Kīlauea and Mauna Loa volcanoes, two of the world's most active geological features. Diverse ecosystems range from tropical forests at sea level to barren lava beds at more than 13,000 feet (4,000 m).", 439 | "nps_link": "https://www.nps.gov/havo/index.htm", 440 | "states": ["Hawaii"], 441 | "title": "Hawaii Volcanoes", 442 | "visitors": "1887580", 443 | "world_heritage_site": "true", 444 | "location": "19.38,-155.2", 445 | "acres": "323431.38", 446 | "square_km": "1308.9", 447 | "date_established": "1916-08-01T05:00:00Z", 448 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/D9667D5A-0F06-FF3A-763702075902EB0F.jpg", 449 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/D9667D5A-0F06-FF3A-763702075902EB0F.jpg", 450 | "id": "park_hawaii-volcanoes" 451 | }, 452 | { 453 | "description": "Hot Springs was established by act of Congress as a federal reserve on April 20, 1832. As such it is the oldest park managed by the National Park Service. Congress changed the reserve's designation to National Park on March 4, 1921 after the National Park Service was established in 1916. Hot Springs is the smallest and only National Park in an urban area and is based around natural hot springs that flow out of the low lying Ouachita Mountains. The springs provide opportunities for relaxation in an historic setting; Bathhouse Row preserves numerous examples of 19th-century architecture.", 454 | "nps_link": "https://www.nps.gov/hosp/index.htm", 455 | "states": ["Arkansas"], 456 | "title": "Hot Springs", 457 | "visitors": "1544300", 458 | "world_heritage_site": "false", 459 | "location": "34.51,-93.05", 460 | "acres": "5549.1", 461 | "square_km": "22.5", 462 | "date_established": "1921-03-04T06:00:00Z", 463 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/3FA77A7E-1DD8-B71B-0B6388E53AF5DC8C.jpg", 464 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/mwr/homepage/3FA77A7E-1DD8-B71B-0B6388E53AF5DC8C.jpg", 465 | "id": "park_hot-springs" 466 | }, 467 | { 468 | "description": "The largest island in Lake Superior is a place of isolation and wilderness. Along with its many shipwrecks, waterways, and hiking trails, the park also includes over 400 smaller islands within 4.5 miles (7.2 km) of its shores. There are only 20 mammal species on the entire island, though the relationship between its wolf and moose populations is especially unique.", 469 | "nps_link": "https://www.nps.gov/isro/index.htm", 470 | "states": ["Michigan"], 471 | "title": "Isle Royale", 472 | "visitors": "24966", 473 | "world_heritage_site": "false", 474 | "location": "48.1,-88.55", 475 | "acres": "571790.11", 476 | "square_km": "2314", 477 | "date_established": "1940-04-03T05:00:00Z", 478 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/23993A5C-1DD8-B71B-0B9C90A798DD420B.jpg", 479 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/mwr/homepage/23993A5C-1DD8-B71B-0B9C90A798DD420B.jpg", 480 | "id": "park_isle-royale" 481 | }, 482 | { 483 | "description": "Covering large areas of the Colorado and Mojave Deserts and the Little San Bernardino Mountains, this desert landscape is populated by vast stands of Joshua trees. Large changes in elevation reveal various contrasting environments including bleached sand dunes, dry lakes, rugged mountains, and maze-like clusters of monzogranite monoliths.", 484 | "nps_link": "https://www.nps.gov/jotr/index.htm", 485 | "states": ["California"], 486 | "title": "Joshua Tree", 487 | "visitors": "2505286", 488 | "world_heritage_site": "false", 489 | "location": "33.79,-115.9", 490 | "acres": "790635.74", 491 | "square_km": "3199.6", 492 | "date_established": "1994-10-31T06:00:00Z", 493 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/F836E601-1DD8-B71B-0BC2936CDBC2FC26.jpg", 494 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/F836E601-1DD8-B71B-0BC2936CDBC2FC26.jpg", 495 | "id": "park_joshua-tree" 496 | }, 497 | { 498 | "description": "This park on the Alaska Peninsula protects the Valley of Ten Thousand Smokes, an ash flow formed by the 1912 eruption of Novarupta, as well as Mount Katmai. Over 2,000 grizzly bears come here each year to catch spawning salmon. Other wildlife includes caribou, wolves, moose, and wolverines.", 499 | "nps_link": "https://www.nps.gov/katm/index.htm", 500 | "states": ["Alaska"], 501 | "title": "Katmai", 502 | "visitors": "37818", 503 | "world_heritage_site": "false", 504 | "location": "58.5,-155", 505 | "acres": "3674529.33", 506 | "square_km": "14870.3", 507 | "date_established": "1980-12-02T06:00:00Z", 508 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/C301C0A0-E8BC-CDBD-4CD5634EA8A02059.jpg", 509 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/C301C0A0-E8BC-CDBD-4CD5634EA8A02059.jpg", 510 | "id": "park_katmai" 511 | }, 512 | { 513 | "description": "Near Seward on the Kenai Peninsula, this park protects the Harding Icefield and at least 38 glaciers and fjords stemming from it. The only area accessible to the public by road is Exit Glacier; the rest must be viewed or reached from boat tours.", 514 | "nps_link": "https://www.nps.gov/kefj/index.htm", 515 | "states": ["Alaska"], 516 | "title": "Kenai Fjords", 517 | "visitors": "346534", 518 | "world_heritage_site": "false", 519 | "location": "59.92,-149.65", 520 | "acres": "669983.65", 521 | "square_km": "2711.3", 522 | "date_established": "1980-12-02T06:00:00Z", 523 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/C6958F15-1DD8-B71B-0B4F85BE63854422.JPG", 524 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/C6958F15-1DD8-B71B-0B4F85BE63854422.JPG", 525 | "id": "park_kenai-fjords" 526 | }, 527 | { 528 | "description": "Home to several giant sequoia groves and the General Grant Tree, the world's second largest measured tree, this park also features part of the Kings River, sculptor of the dramatic granite canyon that is its namesake, and the San Joaquin River, as well as Boyden Cave.", 529 | "nps_link": "https://www.nps.gov/seki/index.htm", 530 | "states": ["California"], 531 | "title": "Kings Canyon", 532 | "visitors": "607479", 533 | "world_heritage_site": "false", 534 | "location": "36.8,-118.55", 535 | "acres": "461901.2", 536 | "square_km": "1869.2", 537 | "date_established": "1940-03-04T06:00:00Z", 538 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/E1511E88-1DD8-B71B-0BD8E4869528C181.jpg", 539 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/E1511E88-1DD8-B71B-0BD8E4869528C181.jpg", 540 | "id": "park_kings-canyon" 541 | }, 542 | { 543 | "description": "Kobuk Valley protects 61 miles (98 km) of the Kobuk River and three regions of sand dunes. Created by glaciers, the Great Kobuk, Little Kobuk, and Hunt River Sand Dunes can reach 100 feet (30 m) high and 100 °F (38 °C), and they are the largest dunes in the Arctic. Twice a year, half a million caribou migrate through the dunes and across river bluffs that expose well-preserved ice age fossils.", 544 | "nps_link": "https://www.nps.gov/kova/index.htm", 545 | "states": ["Alaska"], 546 | "title": "Kobuk Valley", 547 | "visitors": "15500", 548 | "world_heritage_site": "false", 549 | "location": "67.55,-159.28", 550 | "acres": "1750716.16", 551 | "square_km": "7084.9", 552 | "date_established": "1980-12-02T06:00:00Z", 553 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/9387BAE7-1DD8-B71B-0B99E9438363CFE6.jpg", 554 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/9387BAE7-1DD8-B71B-0B99E9438363CFE6.jpg", 555 | "id": "park_kobuk-valley" 556 | }, 557 | { 558 | "description": "The region around Lake Clark features four active volcanoes, including Mount Redoubt, as well as an abundance of rivers, glaciers, and waterfalls. Temperate rainforests, a tundra plateau, and three mountain ranges complete the landscape.", 559 | "nps_link": "https://www.nps.gov/lacl/index.htm", 560 | "states": ["Alaska"], 561 | "title": "Lake Clark", 562 | "visitors": "21102", 563 | "world_heritage_site": "false", 564 | "location": "60.97,-153.42", 565 | "acres": "2619816.49", 566 | "square_km": "10602", 567 | "date_established": "1980-12-02T06:00:00Z", 568 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/C7497C49-A643-0F49-991E9C68CC21EC16.jpg", 569 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/C7497C49-A643-0F49-991E9C68CC21EC16.jpg", 570 | "id": "park_lake-clark" 571 | }, 572 | { 573 | "description": "Lassen Peak, the largest plug dome volcano in the world, is joined by all three other types of volcanoes in this park: shield, cinder dome, and composite. Though Lassen itself last erupted in 1915, most of the rest of the park is continuously active. Numerous hydrothermal features, including fumaroles, boiling pools, and bubbling mud pots, are heated by molten rock from beneath the peak.", 574 | "nps_link": "https://www.nps.gov/lavo/index.htm", 575 | "states": ["California"], 576 | "title": "Lassen Volcanic", 577 | "visitors": "536068", 578 | "world_heritage_site": "false", 579 | "location": "40.49,-121.51", 580 | "acres": "106589.02", 581 | "square_km": "431.4", 582 | "date_established": "1916-08-09T05:00:00Z", 583 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/FBD3F8FA-1DD8-B71B-0B12C9B31279B4E7.jpg", 584 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/FBD3F8FA-1DD8-B71B-0B12C9B31279B4E7.jpg", 585 | "id": "park_lassen-volcanic" 586 | }, 587 | { 588 | "description": "With more than 400 miles (640 km) of passageways explored, Mammoth Cave is the world's longest known cave system. Subterranean wildlife includes eight bat species, Kentucky cave shrimp, Northern cavefish, and cave salamanders. Above ground, the park provides recreation on the Green River, 70 miles of hiking trails, and plenty of sinkholes and springs.", 589 | "nps_link": "https://www.nps.gov/maca/index.htm", 590 | "states": ["Kentucky"], 591 | "title": "Mammoth Cave", 592 | "visitors": "586514", 593 | "world_heritage_site": "true", 594 | "location": "37.18,-86.1", 595 | "acres": "52830.19", 596 | "square_km": "213.8", 597 | "date_established": "1941-07-01T05:00:00Z", 598 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/980CB50C-1DD8-B71B-0B4F8385CDB02DF2.jpg", 599 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/980CB50C-1DD8-B71B-0B4F8385CDB02DF2.jpg", 600 | "id": "park_mammoth-cave" 601 | }, 602 | { 603 | "description": "This area constitutes over 4,000 archaeological sites of the Ancestral Puebloan people, who lived here and elsewhere in the Four Corners region for at least 700 years. Cliff dwellings built in the 12th and 13th centuries include Cliff Palace, which has 150 rooms and 23 kivas, and the Balcony House, with its many passages and tunnels.", 604 | "nps_link": "https://www.nps.gov/meve/index.htm", 605 | "states": ["Colorado"], 606 | "title": "Mesa Verde", 607 | "visitors": "583527", 608 | "world_heritage_site": "true", 609 | "location": "37.18,-108.49", 610 | "acres": "52485.17", 611 | "square_km": "212.4", 612 | "date_established": "1906-06-29T05:00:00Z", 613 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/5BBAD16E-1DD8-B71B-0B2BA1769344B1AD.jpg", 614 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/5BBAD16E-1DD8-B71B-0B2BA1769344B1AD.jpg", 615 | "id": "park_mesa-verde" 616 | }, 617 | { 618 | "description": "Mount Rainier, an active stratovolcano, is the most prominent peak in the Cascades and is covered by 26 named glaciers including Carbon Glacier and Emmons Glacier, the largest in the contiguous United States. The mountain is popular for climbing, and more than half of the park is covered by subalpine and alpine forests and meadows seasonally in bloom with wildflowers. Paradise on the south slope is the snowiest place on Earth where snowfall is measured regularly. The Longmire visitor center is the start of the Wonderland Trail, which encircles the mountain.", 619 | "nps_link": "https://www.nps.gov/mora/index.htm", 620 | "states": ["Washington"], 621 | "title": "Mount Rainier", 622 | "visitors": "1356913", 623 | "world_heritage_site": "false", 624 | "location": "46.85,-121.75", 625 | "acres": "236381.64", 626 | "square_km": "956.6", 627 | "date_established": "1899-03-02T06:00:00Z", 628 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/EFEE6DC6-1DD8-B71B-0B9A2188BE6539A9.jpg", 629 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/EFEE6DC6-1DD8-B71B-0B9A2188BE6539A9.jpg", 630 | "id": "park_mount-rainier" 631 | }, 632 | { 633 | "description": "This complex encompasses two units of the National Park itself as well as the Ross Lake and Lake Chelan National Recreation Areas. The highly glaciated mountains are spectacular examples of Cascade geology. Popular hiking and climbing areas include Cascade Pass, Mount Shuksan, Mount Triumph, and Eldorado Peak.", 634 | "nps_link": "https://www.nps.gov/noca/index.htm", 635 | "states": ["Washington"], 636 | "title": "North Cascades", 637 | "visitors": "28646", 638 | "world_heritage_site": "false", 639 | "location": "48.7,-121.2", 640 | "acres": "504780.94", 641 | "square_km": "2042.8", 642 | "date_established": "1968-10-02T05:00:00Z", 643 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/575C3B44-1DD8-B71B-0BD879DB3BC0AD69.jpg", 644 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/575C3B44-1DD8-B71B-0BD879DB3BC0AD69.jpg", 645 | "id": "park_north-cascades" 646 | }, 647 | { 648 | "description": "Situated on the Olympic Peninsula, this park includes a wide range of ecosystems from Pacific shoreline to temperate rainforests to the alpine slopes of the Olympic Mountains, the tallest of which is Mount Olympus. The Hoh Rainforest and Quinault Rainforest are the wettest area in the contiguous United States, with the Hoh receiving an average of almost 12 ft (3.7 m) of rain every year.", 649 | "nps_link": "https://www.nps.gov/olym/index.htm", 650 | "states": ["Washington"], 651 | "title": "Olympic", 652 | "visitors": "3390221", 653 | "world_heritage_site": "true", 654 | "location": "47.97,-123.5", 655 | "acres": "922650.1", 656 | "square_km": "3733.8", 657 | "date_established": "1938-06-29T05:00:00Z", 658 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/344389AF-1DD8-B71B-0BC1B4DA476CD781.jpg", 659 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/344389AF-1DD8-B71B-0BC1B4DA476CD781.jpg", 660 | "id": "park_olympic" 661 | }, 662 | { 663 | "description": "This portion of the Chinle Formation has a large concentration of 225-million-year-old petrified wood. The surrounding Painted Desert features eroded cliffs of red-hued volcanic rock called bentonite. Dinosaur fossils and over 350 Native American sites are also protected in this park.", 664 | "nps_link": "https://www.nps.gov/pefo/index.htm", 665 | "states": ["Arizona"], 666 | "title": "Petrified Forest", 667 | "visitors": "643274", 668 | "world_heritage_site": "false", 669 | "location": "35.07,-109.78", 670 | "acres": "221415.77", 671 | "square_km": "896", 672 | "date_established": "1962-12-09T06:00:00Z", 673 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/A1DDB679-D857-3748-A29C50A8CF47789F.jpg", 674 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/A1DDB679-D857-3748-A29C50A8CF47789F.jpg", 675 | "id": "park_petrified-forest" 676 | }, 677 | { 678 | "description": "Named for the eroded leftovers of a portion of an extinct volcano, the park's massive black and gold monoliths of andesite and rhyolite are a popular destination for rock climbers. Hikers have access to trails crossing the Coast Range wilderness. The park is home to the endangered California condor (Gymnogyps californianus) and one of the few locations in the world where these extremely rare birds can be seen in the wild. Pinnacles also supports a dense population of prairie falcons, and more than 13 species of bat which populate its talus caves.", 679 | "nps_link": "https://www.nps.gov/pinn/index.htm", 680 | "states": ["California"], 681 | "title": "Pinnacles", 682 | "visitors": "215555", 683 | "world_heritage_site": "false", 684 | "location": "36.48,-121.16", 685 | "acres": "26685.73", 686 | "square_km": "108", 687 | "date_established": "2013-01-10T06:00:00Z", 688 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/2913AF89-1DD8-B71B-0B428CC0594DA1F3.jpg", 689 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/2913AF89-1DD8-B71B-0B428CC0594DA1F3.jpg", 690 | "id": "park_pinnacles" 691 | }, 692 | { 693 | "description": "This park and the co-managed state parks protect almost half of all remaining coastal redwoods, the tallest trees on earth. There are three large river systems in this very seismically active area, and 37 miles (60 km) of protected coastline reveal tide pools and seastacks. The prairie, estuary, coast, river, and forest ecosystems contain a wide variety of animal and plant species.", 694 | "nps_link": "https://www.nps.gov/redw/index.htm", 695 | "states": ["California"], 696 | "title": "Redwood", 697 | "visitors": "536297", 698 | "world_heritage_site": "true", 699 | "location": "41.3,-124", 700 | "acres": "138999.37", 701 | "square_km": "562.5", 702 | "date_established": "1968-10-02T05:00:00Z", 703 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/E7886391-B283-E7E6-3AB2428BB1035429.jpg", 704 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/E7886391-B283-E7E6-3AB2428BB1035429.jpg", 705 | "id": "park_redwood" 706 | }, 707 | { 708 | "description": "Bisected north to south by the Continental Divide, this portion of the Rockies has ecosystems varying from over 150 riparian lakes to montane and subalpine forests to treeless alpine tundra. Wildlife including mule deer, bighorn sheep, black bears, and cougars inhabit its igneous mountains and glacial valleys. Longs Peak, a classic Colorado fourteener, and the scenic Bear Lake are popular destinations, as well as the historic Trail Ridge Road, which reaches an elevation of more than 12,000 feet (3,700 m).", 709 | "nps_link": "https://www.nps.gov/romo/index.htm", 710 | "states": ["Colorado"], 711 | "title": "Rocky Mountain", 712 | "visitors": "4517585", 713 | "world_heritage_site": "false", 714 | "location": "40.4,-105.58", 715 | "acres": "265795.2", 716 | "square_km": "1075.6", 717 | "date_established": "1915-01-26T06:00:00Z", 718 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/8E9F91A3-1DD8-B71B-0B763387D1AACB61.jpg", 719 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/8E9F91A3-1DD8-B71B-0B763387D1AACB61.jpg", 720 | "id": "park_rocky-mountain" 721 | }, 722 | { 723 | "description": "Split into the separate Rincon Mountain and Tucson Mountain districts, this park is evidence that the dry Sonoran Desert is still home to a great variety of life spanning six biotic communities. Beyond the namesake giant saguaro cacti, there are barrel cacti, chollas, and prickly pears, as well as lesser long-nosed bats, spotted owls, and javelinas.", 724 | "nps_link": "https://www.nps.gov/sagu/index.htm", 725 | "states": ["Arizona"], 726 | "title": "Saguaro", 727 | "visitors": "820426", 728 | "world_heritage_site": "false", 729 | "location": "32.25,-110.5", 730 | "acres": "91715.72", 731 | "square_km": "371.2", 732 | "date_established": "1994-10-14T05:00:00Z", 733 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/5D2733EE-1DD8-B71B-0B24869B10725BC0.jpg", 734 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/5D2733EE-1DD8-B71B-0B24869B10725BC0.jpg", 735 | "id": "park_saguaro" 736 | }, 737 | { 738 | "description": "This park protects the Giant Forest, which boasts some of the world's largest trees, the General Sherman being the largest measured tree in the park. Other features include over 240 caves, a long segment of the Sierra Nevada including the tallest mountain in the contiguous United States, and Moro Rock, a large granite dome.", 739 | "nps_link": "https://www.nps.gov/seki/index.htm", 740 | "states": ["California"], 741 | "title": "Sequoia", 742 | "visitors": "1254688", 743 | "world_heritage_site": "false", 744 | "location": "36.43,-118.68", 745 | "acres": "404062.63", 746 | "square_km": "1635.2", 747 | "date_established": "1890-09-25T05:00:00Z", 748 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/E1511E88-1DD8-B71B-0BD8E4869528C181.jpg", 749 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/E1511E88-1DD8-B71B-0BD8E4869528C181.jpg", 750 | "id": "park_sequoia" 751 | }, 752 | { 753 | "description": "Shenandoah's Blue Ridge Mountains are covered by hardwood forests that teem with a wide variety of wildlife. The Skyline Drive and Appalachian Trail run the entire length of this narrow park, along with more than 500 miles (800 km) of hiking trails passing scenic overlooks and cataracts of the Shenandoah River.", 754 | "nps_link": "https://www.nps.gov/shen/index.htm", 755 | "states": ["Virginia"], 756 | "title": "Shenandoah", 757 | "visitors": "1437341", 758 | "world_heritage_site": "false", 759 | "location": "38.53,-78.35", 760 | "acres": "199195.27", 761 | "square_km": "806.1", 762 | "date_established": "1935-12-26T06:00:00Z", 763 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/EC803A56-1DD8-B71B-0BD7EE282E4EAECD.jpg", 764 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/ner/homepage/EC803A56-1DD8-B71B-0BD7EE282E4EAECD.jpg", 765 | "id": "park_shenandoah" 766 | }, 767 | { 768 | "description": "This region that enticed and influenced President Theodore Roosevelt consists of a park of three units in the northern badlands. Besides Roosevelt's historic cabin, there are numerous scenic drives and backcountry hiking opportunities. Wildlife includes American bison, pronghorn, bighorn sheep, and wild horses.", 769 | "nps_link": "https://www.nps.gov/thro/index.htm", 770 | "states": ["North Dakota"], 771 | "title": "Theodore Roosevelt", 772 | "visitors": "753880", 773 | "world_heritage_site": "false", 774 | "location": "46.97,-103.45", 775 | "acres": "70446.89", 776 | "square_km": "285.1", 777 | "date_established": "1978-11-10T06:00:00Z", 778 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/2118F52C-1DD8-B71B-0B0713EA8F3E1AEA.jpg", 779 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/mwr/homepage/2118F52C-1DD8-B71B-0B0713EA8F3E1AEA.jpg", 780 | "id": "park_theodore-roosevelt" 781 | }, 782 | { 783 | "description": "This island park on Saint John preserves Taíno archaeological sites and the ruins of sugar plantations from Columbus's time, as well as all the natural environs. Surrounding the pristine beaches are mangrove forests, seagrass beds, and coral reefs.", 784 | "nps_link": "https://www.nps.gov/viis/index.htm", 785 | "states": ["US Virgin Islands"], 786 | "title": "Virgin Islands", 787 | "visitors": "411343", 788 | "world_heritage_site": "false", 789 | "location": "18.33,-64.73", 790 | "acres": "14948.46", 791 | "square_km": "60.5", 792 | "date_established": "1956-08-02T05:00:00Z", 793 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/9F5976B7-D4D2-3A7A-7B1B9359FB8E1FBB.jpg", 794 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/9F5976B7-D4D2-3A7A-7B1B9359FB8E1FBB.jpg", 795 | "id": "park_virgin-islands" 796 | }, 797 | { 798 | "description": "This park protecting four lakes near the Canada–US border is a site for canoeing, kayaking, and fishing. The park also preserves a history populated by Ojibwe Native Americans, French fur traders called voyageurs, and gold miners. Formed by glaciers, the region features tall bluffs, rock gardens, islands, bays, and several historic buildings.", 799 | "nps_link": "https://www.nps.gov/voya/index.htm", 800 | "states": ["Minnesota"], 801 | "title": "Voyageurs", 802 | "visitors": "241912", 803 | "world_heritage_site": "false", 804 | "location": "48.5,-92.88", 805 | "acres": "218200.15", 806 | "square_km": "883", 807 | "date_established": "1971-01-08T06:00:00Z", 808 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/1F7E73A5-1DD8-B71B-0B40F740BAA7EB37.JPG", 809 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/mwr/homepage/1F7E73A5-1DD8-B71B-0B40F740BAA7EB37.JPG", 810 | "id": "park_voyageurs" 811 | }, 812 | { 813 | "description": "Wind Cave is distinctive for its calcite fin formations called boxwork, a unique formation rarely found elsewhere, and needle-like growths called frostwork. The cave is one of the longest and most complex caves in the world. Above ground is a mixed-grass prairie with animals such as bison, black-footed ferrets, and prairie dogs, and ponderosa pine forests that are home to cougars and elk. The cave is culturally significant to the Lakota people as the site 'from which Wakan Tanka, the Great Mystery, sent the buffalo out into their hunting grounds.'", 814 | "nps_link": "https://www.nps.gov/wica/index.htm", 815 | "states": ["South Dakota"], 816 | "title": "Wind Cave", 817 | "visitors": "617377", 818 | "world_heritage_site": "false", 819 | "location": "43.57,-103.48", 820 | "acres": "33970.84", 821 | "square_km": "137.5", 822 | "date_established": "1903-01-09T06:00:00Z", 823 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/CC25AAB0-1DD8-B71B-0B48DF48A0B8DE84.JPG", 824 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/mwr/homepage/CC25AAB0-1DD8-B71B-0B48DF48A0B8DE84.JPG", 825 | "id": "park_wind-cave" 826 | }, 827 | { 828 | "description": "An over 8 million acres (32,375 km2) plot of mountainous country—the largest National Park in the system—protects the convergence of the Alaska, Chugach, and Wrangell-Saint Elias Ranges, which include many of the continent's tallest mountains and volcanoes, including the 18,008-foot Mount Saint Elias. More than a quarter of the park is covered with glaciers, including the tidewater Hubbard Glacier, piedmont Malaspina Glacier, and valley Nabesna Glacier.", 829 | "nps_link": "https://www.nps.gov/wrst/index.htm", 830 | "states": ["Alaska"], 831 | "title": "Wrangell–St. Elias", 832 | "visitors": "79047", 833 | "world_heritage_site": "true", 834 | "location": "61,-142", 835 | "acres": "8323146.48", 836 | "square_km": "33682.6", 837 | "date_established": "1980-12-02T06:00:00Z", 838 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/F8805363-1DD8-B71B-0BE59C3D3428BD7B.jpg", 839 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/akr/homepage/F8805363-1DD8-B71B-0BE59C3D3428BD7B.jpg", 840 | "id": "park_wrangell–st.-elias" 841 | }, 842 | { 843 | "description": "Situated on the Yellowstone Caldera, the park has an expansive network of geothermal areas including boiling mud pots, vividly colored hot springs such as Grand Prismatic Spring, and regularly erupting geysers, the best-known being Old Faithful. The yellow-hued Grand Canyon of the Yellowstone River contains several high waterfalls, while four mountain ranges traverse the park. More than 60 mammal species including gray wolves, grizzly bears, black bears, lynxes, bison, and elk, make this park one of the best wildlife viewing spots in the country.", 844 | "nps_link": "https://www.nps.gov/yell/index.htm", 845 | "states": ["Wyoming", "Montana", "Idaho"], 846 | "title": "Yellowstone", 847 | "visitors": "4257177", 848 | "world_heritage_site": "true", 849 | "location": "44.6,-110.5", 850 | "acres": "2219790.71", 851 | "square_km": "8983.2", 852 | "date_established": "1872-03-01T06:00:00Z", 853 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/51D13BEA-1DD8-B71B-0B786860A6FE90FC.jpg", 854 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/51D13BEA-1DD8-B71B-0B786860A6FE90FC.jpg", 855 | "id": "park_yellowstone" 856 | }, 857 | { 858 | "description": "Yosemite features sheer granite cliffs, exceptionally tall waterfalls, and old-growth forests at a unique intersection of geology and hydrology. Half Dome and El Capitan rise from the park's centerpiece, the glacier-carved Yosemite Valley, and from its vertical walls drop Yosemite Falls, one of North America's tallest waterfalls at 2,425 feet (739 m) high. Three giant sequoia groves, along with a pristine wilderness in the heart of the Sierra Nevada, are home to a wide variety of rare plant and animal species.", 859 | "nps_link": "https://www.nps.gov/yose/index.htm", 860 | "states": ["California"], 861 | "title": "Yosemite", 862 | "visitors": "5028868", 863 | "world_heritage_site": "true", 864 | "location": "37.83,-119.5", 865 | "acres": "761747.5", 866 | "square_km": "3082.7", 867 | "date_established": "1890-10-01T05:00:00Z", 868 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/36C683FE-1DD8-B71B-0B6B4AE9C34946B8.jpg", 869 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/pwr/homepage/36C683FE-1DD8-B71B-0B6B4AE9C34946B8.jpg", 870 | "id": "park_yosemite" 871 | }, 872 | { 873 | "description": "Located at the junction of the Colorado Plateau, Great Basin, and Mojave Desert, this park contains sandstone features such as mesas, rock towers, and canyons, including the Virgin River Narrows. The various sandstone formations and the forks of the Virgin River create a wilderness divided into four ecosystems: desert, riparian, woodland, and coniferous forest.", 874 | "nps_link": "https://www.nps.gov/zion/index.htm", 875 | "states": ["Utah"], 876 | "title": "Zion", 877 | "visitors": "4295127", 878 | "world_heritage_site": "false", 879 | "location": "37.3,-113.05", 880 | "acres": "147237.02", 881 | "square_km": "595.8", 882 | "date_established": "1919-11-19T06:00:00Z", 883 | "image_url": "https://storage.googleapis.com/public-demo-assets.swiftype.info/swiftype-dot-com-search-ui-national-parks-demo/0047D4F0-1DD8-B71B-0B56566586F793FA.jpg", 884 | "nps_image_url": "https://www.nps.gov/common/uploads/banner_image/imr/homepage/0047D4F0-1DD8-B71B-0B56566586F793FA.jpg", 885 | "id": "park_zion" 886 | } 887 | ] 888 | --------------------------------------------------------------------------------