├── .gitignore ├── Dockerfile ├── LICENSE.txt ├── README.md ├── build.gradle ├── docker-compose.yml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images ├── admin.png ├── overview.png ├── search-aggs-filterable.png ├── search-products.png ├── search-with-aggs.png └── search-with-selectable-aggs.png ├── micronaut-cli.yml ├── settings.gradle └── src └── main ├── java └── elasticsearch │ └── ecommerce │ └── app │ ├── Application.java │ ├── controller │ ├── AdminController.java │ ├── MainController.java │ └── SearchController.java │ ├── entities │ ├── Product.java │ └── Query.java │ ├── factory │ └── HighLevelRestClientFactory.java │ ├── json │ └── ResponseSerializer.java │ └── service │ ├── ProductIndexService.java │ └── ProductQueryService.java └── resources ├── application.yml ├── index-mappings.json ├── index-settings.json ├── logback.xml └── static ├── admin.html ├── admin.js ├── index.css ├── index.html └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | Thumbs.db 2 | .DS_Store 3 | .gradle 4 | build/ 5 | target/ 6 | out/ 7 | .idea 8 | *.iml 9 | *.ipr 10 | *.iws 11 | .project 12 | .settings 13 | .classpath 14 | es_data 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM adoptopenjdk/openjdk11-openj9:jdk-11.0.1.13-alpine-slim 2 | COPY build/libs/*.jar elasticsearch-ecommerce-search-app.jar 3 | EXPOSE 8080 4 | CMD java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Dcom.sun.management.jmxremote -noverify ${JAVA_OPTS} -jar elasticsearch-ecommerce-search-app.jar 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elasticsearch Ecommerce Search App 2 | 3 | This repository features a simple and small search UI for fake products in 4 | order to demo Elasticsearch search and aggregation functionality. 5 | 6 | ![Sample screenshot](/images/overview.png?raw=true "Sample screenshot") 7 | 8 | You need docker-compose and java 12 in order to run this. Just clone the 9 | repository and run 10 | 11 | ``` 12 | docker-compose up 13 | # open a new terminal and run 14 | ./gradlew run 15 | ``` 16 | 17 | This will start Elasticsearch on port 9200, Kibana on port 5601 and the 18 | micronaut based web application on port 8080. 19 | 20 | There are two URLs you can visit. First [the main 21 | URL](http://localhost:8080), which contains the frontend, second the 22 | [administrative URL](http://localhost:8080/admin.html). 23 | 24 | ## Technologies used 25 | 26 | This demo uses several other frameworks to keep it's own code small and 27 | lean. 28 | 29 | * [Micronaut](https://micronaut.io) is a JVM based web framework. It has a 30 | small footprint and very fast startup time. This app usually starts up 31 | in 1.5s on my rather slow notebook. 32 | * [Vue.js](https://vuejs.org) is a JavaScript framework for the frontend 33 | helping to write this single page application. 34 | * [bulma](https://bulma.io) is very easy to use, yet good looking CSS 35 | framework based on flexbox. I've never used it before, but will use it a 36 | lot more in the future. 37 | * [docker-compose](https://docs.docker.com/compose/) helps running 38 | multiple docker containers 39 | * [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) 40 | is a full text search engine doing all the hard work. Also, the Java 41 | Application uses the Elasticsearch Java Client to query Elasticsearch. 42 | * [Kibana](https://www.elastic.co/guide/en/kibana/current/index.html) is 43 | not strictly required, but will help during the demo in case you want to 44 | execute searches against the Elasticsearch instance. 45 | 46 | ## Features 47 | 48 | First, go to the admin page and click on the `reindex` button. Check the 49 | log output of the gradle window and wait until indexation is finished. 50 | 51 | Then go back to the main page and enter something like `autos` in the 52 | search window and you just see some search hits. The fake data generated 53 | by the reindex action is in german by default, but you can change the 54 | locale in the `ProductIndexService` class. 55 | 56 | If you are in a search view that allows to select filters, you can filter 57 | by certain product features like brand or material by clicking on them. 58 | You can remove the filter by clicking the `x` in the list of tags. 59 | 60 | Pagination is supported as well. 61 | 62 | Every query is logged in the webapp, so you can copy it over into the console. 63 | 64 | Every search response is directly forwarded back to the browser. 65 | 66 | A search request to Elasticsearch is constructed from the data sent to the webapp. 67 | A request can look like this 68 | 69 | ``` 70 | { 71 | "query":"autos", 72 | "from":0, 73 | "filters": [ 74 | {"key":"material","value":"Stahl","type":"term"}, 75 | {"key":"brand","value":"Cleem GmbH","type":"term"}, 76 | {"key":"stock","value":"1-","type":"range","from":1} 77 | ] 78 | } 79 | ``` 80 | 81 | The `filters` part will be used to create the aggregation. 82 | 83 | ### Different search types 84 | 85 | You can select between different search types. 86 | 87 | #### Search products only 88 | 89 | You can select different modes of operation right next to the search bar 90 | 91 | ##### `Products` view 92 | 93 | ![Products only](/images/search-products.png?raw=true "Products only") 94 | 95 | This view features a list of products returned by the search query. 96 | 97 | ##### `Products & Aggregations` view 98 | 99 | ![Aggs & counts](/images/search-aggs-filterable.png?raw=true "Aggs & counts") 100 | 101 | This view contains aggregations on the left including counts. 102 | 103 | ##### `Products & Selectable Aggs` view 104 | 105 | ![Selectable Aggs](/images/search-aggs-filterable.png?raw=true "Selectable Aggs") 106 | 107 | This view allows to filter products by selecting aggregations on the left. 108 | 109 | ##### `Products & Selectable Filtered Aggs` view 110 | 111 | ![Filtered Aggs](/images/search-aggs-filterable.png?raw=true "Filtered Aggs") 112 | 113 | This view tries to fix the counts when selecting aggregations. 114 | 115 | ### Admin interface 116 | 117 | The admin interface allows you to maintain synonyms or reindex your data. 118 | 119 | ![Admin Interface](/images/admin.png?raw=true "Admin interface") 120 | 121 | When clicking the button to configure synonyms, the index will be closed, the 122 | synonyms will be applied and then opened again. Note, that there is no error 123 | handling, so if you create invalid synonyms, then you might need to reopen the 124 | index manually. 125 | 126 | 127 | # TODO 128 | 129 | A few things will be added over time, some are just here to show, that 130 | such a small prototype is light years away from a real search 131 | implementation. It rather serves as a basis for discussion. 132 | 133 | * Score based on commission 134 | * Rank feature or dense/sparse vector for recommendation? 135 | * Suggestions/Did you mean functionality 136 | * Switch pagination to search after? Implement a hard cut off? 137 | * Packaging the app will result in the HTML files not being found very likely 138 | 139 | 140 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "io.spring.dependency-management" version "1.0.9.RELEASE" 3 | id "com.github.johnrengelman.shadow" version "5.2.0" 4 | id "application" 5 | id "java" 6 | id "net.ltgt.apt-eclipse" version "0.21" 7 | id "net.ltgt.apt-idea" version "0.21" 8 | } 9 | 10 | version "0.1" 11 | group "elasticsearch.ecommerce.app" 12 | 13 | repositories { 14 | mavenCentral() 15 | maven { url "https://jcenter.bintray.com" } 16 | } 17 | 18 | dependencyManagement { 19 | imports { 20 | mavenBom 'io.micronaut:micronaut-bom:1.3.4' 21 | } 22 | } 23 | 24 | configurations { 25 | // for dependencies that are needed for development only 26 | developmentOnly 27 | } 28 | 29 | dependencies { 30 | annotationProcessor "io.micronaut:micronaut-inject-java" 31 | annotationProcessor "io.micronaut:micronaut-validation" 32 | compile "io.micronaut:micronaut-http-client" 33 | compile "io.micronaut:micronaut-inject" 34 | compile "io.micronaut:micronaut-validation" 35 | compile "io.micronaut:micronaut-runtime" 36 | compile "io.micronaut:micronaut-http-server-netty" 37 | compile "io.micronaut:micronaut-management" 38 | compile "javax.annotation:javax.annotation-api" 39 | 40 | compile 'com.github.javafaker:javafaker:1.0.2' 41 | compile 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.8.0' 42 | compile "ch.qos.logback:logback-classic:1.2.3" 43 | 44 | testAnnotationProcessor "io.micronaut:micronaut-inject-java" 45 | testCompile "org.junit.jupiter:junit-jupiter-api" 46 | testCompile "io.micronaut.test:micronaut-test-junit5" 47 | testRuntime "org.junit.jupiter:junit-jupiter-engine" 48 | } 49 | 50 | test.classpath += configurations.developmentOnly 51 | 52 | mainClassName = "elasticsearch.ecommerce.app.Application" 53 | // use JUnit 5 platform 54 | test { 55 | useJUnitPlatform() 56 | } 57 | 58 | shadowJar { 59 | mergeServiceFiles() 60 | } 61 | 62 | run.classpath += configurations.developmentOnly 63 | run.jvmArgs('-noverify', '-XX:TieredStopAtLevel=1', '-Dcom.sun.management.jmxremote') 64 | tasks.withType(JavaCompile){ 65 | options.encoding = "UTF-8" 66 | options.compilerArgs.add('-parameters') 67 | options.compilerArgs.add('-Xlint:deprecation') 68 | } 69 | 70 | java { 71 | sourceCompatibility = JavaVersion.VERSION_14 72 | targetCompatibility = JavaVersion.VERSION_14 73 | } 74 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.6' 3 | services: 4 | elasticsearch_ecommerce_search_sample: 5 | image: docker.elastic.co/elasticsearch/elasticsearch:7.8.0 6 | container_name: elasticsearch_ecommerce_search_sample 7 | ports: ['9200:9200'] 8 | networks: ['stack'] 9 | volumes: 10 | - ./es_data:/var/lib/elasticsearch/data 11 | environment: 12 | - node.name=elasticsearch_ecommerce_01 13 | - cluster.initial_master_nodes=elasticsearch_ecommerce_01 14 | - cluster.name=ecommerce_sample_cluster 15 | - bootstrap.memory_lock=true 16 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 17 | ulimits: 18 | memlock: 19 | soft: -1 20 | hard: -1 21 | networks: 22 | - stack 23 | 24 | kibana_ecommerce_search_sample: 25 | image: docker.elastic.co/kibana/kibana:7.8.0 26 | container_name: kibana_ecommerce_search_sample 27 | ports: ['5601:5601'] 28 | networks: ['stack'] 29 | depends_on: ['elasticsearch_ecommerce_search_sample'] 30 | networks: 31 | - stack 32 | environment: 33 | ELASTICSEARCH_HOSTS: http://elasticsearch_ecommerce_search_sample:9200 34 | 35 | networks: {stack: {}} 36 | 37 | # use docker volume to persist ES data outside of a container. 38 | volumes: 39 | es_data: 40 | driver: local 41 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinscale/elasticsearch-ecommerce-search-app/adefadf5dca35df219dfdd576560550bc45c87fd/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /images/admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinscale/elasticsearch-ecommerce-search-app/adefadf5dca35df219dfdd576560550bc45c87fd/images/admin.png -------------------------------------------------------------------------------- /images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinscale/elasticsearch-ecommerce-search-app/adefadf5dca35df219dfdd576560550bc45c87fd/images/overview.png -------------------------------------------------------------------------------- /images/search-aggs-filterable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinscale/elasticsearch-ecommerce-search-app/adefadf5dca35df219dfdd576560550bc45c87fd/images/search-aggs-filterable.png -------------------------------------------------------------------------------- /images/search-products.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinscale/elasticsearch-ecommerce-search-app/adefadf5dca35df219dfdd576560550bc45c87fd/images/search-products.png -------------------------------------------------------------------------------- /images/search-with-aggs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinscale/elasticsearch-ecommerce-search-app/adefadf5dca35df219dfdd576560550bc45c87fd/images/search-with-aggs.png -------------------------------------------------------------------------------- /images/search-with-selectable-aggs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinscale/elasticsearch-ecommerce-search-app/adefadf5dca35df219dfdd576560550bc45c87fd/images/search-with-selectable-aggs.png -------------------------------------------------------------------------------- /micronaut-cli.yml: -------------------------------------------------------------------------------- 1 | profile: service 2 | defaultPackage: elasticsearch.ecommerce.app 3 | --- 4 | testFramework: junit 5 | sourceLanguage: java -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name="elasticsearch-ecommerce-search-app" 2 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/Application.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app; 2 | 3 | import io.micronaut.runtime.Micronaut; 4 | 5 | public class Application { 6 | 7 | public static void main(String[] args) { 8 | Micronaut.run(Application.class); 9 | } 10 | } -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/controller/AdminController.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.controller; 2 | 3 | import elasticsearch.ecommerce.app.service.ProductIndexService; 4 | import io.micronaut.http.HttpStatus; 5 | import io.micronaut.http.MediaType; 6 | import io.micronaut.http.annotation.Body; 7 | import io.micronaut.http.annotation.Controller; 8 | import io.micronaut.http.annotation.Post; 9 | import io.micronaut.http.annotation.QueryValue; 10 | 11 | import javax.inject.Inject; 12 | import java.io.IOException; 13 | import java.util.Map; 14 | import java.util.concurrent.CompletableFuture; 15 | 16 | @Controller("/admin") 17 | public class AdminController { 18 | 19 | private final ProductIndexService indexService; 20 | 21 | @Inject 22 | public AdminController(ProductIndexService indexService) { 23 | this.indexService = indexService; 24 | } 25 | 26 | @Post("/index_data") 27 | public CompletableFuture index(@QueryValue Integer numberOfProducts) throws IOException { 28 | if (numberOfProducts <= 0) { 29 | numberOfProducts = 50000; 30 | } 31 | 32 | return indexService.indexProducts(numberOfProducts); 33 | } 34 | 35 | @Post(value = "configure_synonyms", consumes = MediaType.APPLICATION_JSON) 36 | public CompletableFuture index(@Body Map synonyms) throws IOException { 37 | return indexService.configureSynonyms(synonyms.get("synonyms")); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/controller/MainController.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.controller; 2 | 3 | import io.micronaut.http.HttpResponse; 4 | import io.micronaut.http.annotation.Controller; 5 | import io.micronaut.http.annotation.Get; 6 | 7 | import java.net.URI; 8 | 9 | @Controller 10 | public class MainController { 11 | 12 | @Get 13 | public HttpResponse redirect() { 14 | return HttpResponse.redirect(URI.create("/index.html")); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/controller/SearchController.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.controller; 2 | 3 | import elasticsearch.ecommerce.app.entities.Query; 4 | import elasticsearch.ecommerce.app.service.ProductQueryService; 5 | import io.micronaut.http.MediaType; 6 | import io.micronaut.http.annotation.Body; 7 | import io.micronaut.http.annotation.Controller; 8 | import io.micronaut.http.annotation.Post; 9 | import org.elasticsearch.client.Response; 10 | 11 | import javax.inject.Inject; 12 | import java.io.IOException; 13 | import java.util.concurrent.CompletableFuture; 14 | 15 | @Controller("/search") 16 | public class SearchController { 17 | 18 | private final ProductQueryService service; 19 | 20 | @Inject 21 | public SearchController(ProductQueryService service) { 22 | this.service = service; 23 | } 24 | 25 | @Post(value = "products_only", produces = MediaType.APPLICATION_JSON, consumes = MediaType.APPLICATION_JSON) 26 | public CompletableFuture searchProductsOnly(@Body Query query) throws IOException { 27 | return service.searchProductsOnly(query); 28 | } 29 | 30 | @Post(value = "products_with_aggs", produces = MediaType.APPLICATION_JSON, consumes = MediaType.APPLICATION_JSON) 31 | public CompletableFuture searchWithAggs(@Body Query query) throws IOException { 32 | return service.searchWithAggs(query); 33 | } 34 | 35 | @Post(value = "products_with_filtered_aggs", produces = MediaType.APPLICATION_JSON, consumes = MediaType.APPLICATION_JSON) 36 | public CompletableFuture searchWithFilteredAggs(@Body Query query) throws IOException { 37 | return service.searchWithFilteredAggs(query); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/entities/Product.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.entities; 2 | 3 | import java.util.Date; 4 | 5 | // adhere to bean properties, so we can use jackson bean introspection 6 | public class Product { 7 | 8 | private final String name; 9 | private final double price; 10 | private final String color; 11 | private final String material; 12 | private final String id; 13 | private final String productImageUrl; 14 | private final String brand; 15 | private final String brandLogoUrl; 16 | private final Date lastUpdated; 17 | private final int stock; 18 | private final int commission; 19 | 20 | public Product(String name, double price, String color, String material, String id, String productImageUrl, String brand, String brandLogoUrl, 21 | Date lastUpdated, int stock, int commission) { 22 | this.name = name; 23 | this.price = price; 24 | this.color = color; 25 | this.material = material; 26 | this.id = id; 27 | this.productImageUrl = productImageUrl; 28 | this.brand = brand; 29 | this.brandLogoUrl = brandLogoUrl; 30 | this.lastUpdated = lastUpdated; 31 | this.stock = stock; 32 | this.commission = commission; 33 | } 34 | 35 | public String getName() { 36 | return name; 37 | } 38 | 39 | public double getPrice() { 40 | return price; 41 | } 42 | 43 | public String getColor() { 44 | return color; 45 | } 46 | 47 | public String getId() { 48 | return id; 49 | } 50 | 51 | public String getProductImageUrl() { 52 | return productImageUrl; 53 | } 54 | 55 | public String getBrand() { 56 | return brand; 57 | } 58 | 59 | public String getBrandLogoUrl() { 60 | return brandLogoUrl; 61 | } 62 | 63 | public Date getLastUpdated() { 64 | return lastUpdated; 65 | } 66 | 67 | public int getStock() { 68 | return stock; 69 | } 70 | 71 | public int getCommission() { 72 | return commission; 73 | } 74 | 75 | public String getMaterial() { 76 | return material; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/entities/Query.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.entities; 2 | 3 | import org.elasticsearch.common.Strings; 4 | import org.elasticsearch.index.query.QueryBuilder; 5 | import org.elasticsearch.index.query.QueryBuilders; 6 | import org.elasticsearch.index.query.RangeQueryBuilder; 7 | 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | 11 | public class Query { 12 | 13 | private String query; 14 | private Integer from = 0; 15 | private List filters = new ArrayList<>(); 16 | 17 | public String getQuery() { 18 | return query; 19 | } 20 | 21 | public void setQuery(String query) { 22 | this.query = query; 23 | } 24 | 25 | public List getFilters() { 26 | return filters; 27 | } 28 | 29 | public void setFilters(List filters) { 30 | this.filters = filters; 31 | } 32 | 33 | public Integer getFrom() { 34 | return from; 35 | } 36 | 37 | public void setFrom(Integer from) { 38 | this.from = from; 39 | } 40 | 41 | public static final class Filter { 42 | private String key; 43 | private String value; 44 | private String from; 45 | private String to; 46 | private String type; 47 | 48 | public String getType() { 49 | return type; 50 | } 51 | 52 | public String getFrom() { 53 | return from; 54 | } 55 | 56 | public String getTo() { 57 | return to; 58 | } 59 | 60 | public String getKey() { 61 | return key; 62 | } 63 | 64 | public String getValue() { 65 | return value; 66 | } 67 | 68 | public QueryBuilder toQuery() { 69 | if ("term".equals(type)) { 70 | return QueryBuilders.termQuery(this.key + ".keyword", this.value); 71 | } else if ("range".equals(type)) { 72 | return createRangeQueryBuilder(key, from, to); 73 | } else { 74 | throw new RuntimeException("Unknown type: " + type); 75 | } 76 | } 77 | 78 | private RangeQueryBuilder createRangeQueryBuilder(String name, String from, String to) { 79 | RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery(name); 80 | if (Strings.isEmpty(from) == false) { 81 | rangeQueryBuilder.from(from); 82 | } 83 | if (Strings.isEmpty(to) == false) { 84 | rangeQueryBuilder.to(to); 85 | } 86 | 87 | return rangeQueryBuilder; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/factory/HighLevelRestClientFactory.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.factory; 2 | 3 | import io.micronaut.context.annotation.Factory; 4 | import org.apache.http.HttpHost; 5 | import org.apache.lucene.util.IOUtils; 6 | import org.elasticsearch.client.RestClient; 7 | import org.elasticsearch.client.RestHighLevelClient; 8 | 9 | import javax.annotation.PreDestroy; 10 | import javax.inject.Inject; 11 | import javax.inject.Singleton; 12 | 13 | @Factory 14 | public class HighLevelRestClientFactory { 15 | 16 | private final RestHighLevelClient client; 17 | 18 | @Inject 19 | public HighLevelRestClientFactory() { 20 | this.client = new RestHighLevelClient(RestClient.builder(new HttpHost("localhost", 9200, "http"))); 21 | } 22 | 23 | @Singleton 24 | public RestHighLevelClient getRestHighLevelClient() { 25 | return client; 26 | } 27 | 28 | @PreDestroy 29 | public void closeClient() { 30 | IOUtils.closeWhileHandlingException(client); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/json/ResponseSerializer.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.json; 2 | 3 | import com.fasterxml.jackson.core.JsonGenerator; 4 | import com.fasterxml.jackson.databind.JsonSerializer; 5 | import com.fasterxml.jackson.databind.SerializerProvider; 6 | import org.elasticsearch.client.Response; 7 | import org.elasticsearch.common.io.Streams; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | import javax.inject.Singleton; 12 | import java.io.IOException; 13 | import java.io.InputStreamReader; 14 | import java.io.Reader; 15 | 16 | /** 17 | * Custom serializer for low level requests returned from a search operation 18 | */ 19 | @Singleton 20 | public class ResponseSerializer extends JsonSerializer { 21 | 22 | private static final Logger LOG = LoggerFactory.getLogger(ResponseSerializer.class); 23 | 24 | @Override 25 | public void serialize(Response response, JsonGenerator gen, SerializerProvider provider) throws IOException { 26 | try (Reader in = new InputStreamReader(response.getEntity().getContent())) { 27 | gen.writeRaw(Streams.copyToString(in)); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/service/ProductIndexService.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.service; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.github.javafaker.Faker; 5 | import elasticsearch.ecommerce.app.entities.Product; 6 | import io.micronaut.http.HttpStatus; 7 | import org.elasticsearch.action.admin.indices.delete.DeleteIndexRequest; 8 | import org.elasticsearch.action.admin.indices.open.OpenIndexRequest; 9 | import org.elasticsearch.action.admin.indices.settings.put.UpdateSettingsRequest; 10 | import org.elasticsearch.action.bulk.BulkRequest; 11 | import org.elasticsearch.action.bulk.BulkResponse; 12 | import org.elasticsearch.action.index.IndexRequest; 13 | import org.elasticsearch.action.support.WriteRequest; 14 | import org.elasticsearch.client.RequestOptions; 15 | import org.elasticsearch.client.RestHighLevelClient; 16 | import org.elasticsearch.client.indices.CloseIndexRequest; 17 | import org.elasticsearch.client.indices.CreateIndexRequest; 18 | import org.elasticsearch.client.indices.GetIndexRequest; 19 | import org.elasticsearch.common.io.Streams; 20 | import org.elasticsearch.common.settings.Settings; 21 | import org.elasticsearch.common.unit.ByteSizeUnit; 22 | import org.elasticsearch.common.unit.ByteSizeValue; 23 | import org.elasticsearch.common.xcontent.XContentType; 24 | import org.slf4j.Logger; 25 | import org.slf4j.LoggerFactory; 26 | 27 | import javax.inject.Inject; 28 | import javax.inject.Singleton; 29 | import java.io.IOException; 30 | import java.io.InputStreamReader; 31 | import java.io.Reader; 32 | import java.util.Date; 33 | import java.util.HashSet; 34 | import java.util.List; 35 | import java.util.Locale; 36 | import java.util.Set; 37 | import java.util.concurrent.CompletableFuture; 38 | import java.util.concurrent.TimeUnit; 39 | 40 | @Singleton 41 | public class ProductIndexService { 42 | 43 | // Found via 44 | // for i in $(jot 1000) ; do curl -s -X HEAD "https://picsum.photos/id/$i/200/200" -w '%{http_code} %{url_effective}\n' | tee -a /tmp/head.log ; done 45 | // for i in $(grep "^404" /tmp/head.log | cut -d '/' -f 5) ; do echo -n "$i, " ; done 46 | private static List NON_EXISTING_IMAGE_IDS = List.of(86, 97, 105, 138, 148, 150, 205, 207, 224, 226, 245, 246, 262, 285, 286, 47 | 298, 303, 332, 333, 346, 359, 394, 414, 422, 438, 462, 463, 470, 489, 540, 561, 578, 587, 589, 592, 595, 597, 601, 624, 632, 48 | 636, 644, 647, 673, 697, 706, 707, 708, 709, 710, 711, 712, 713, 714, 720, 725, 734, 745, 746, 747, 748, 749, 750, 751, 752, 49 | 753, 754, 759, 761, 762, 763, 771, 792, 801, 812, 843, 850, 854, 895, 897, 899, 917, 920, 934, 956, 963, 968); 50 | 51 | private static final int BRANDS_MAX = 10; 52 | private static final long MAX_BULK_SIZE_IN_BYTES = new ByteSizeValue(5, ByteSizeUnit.MB).getBytes(); 53 | private static final String INDEX = "products"; 54 | private static final Faker faker = Faker.instance(Locale.GERMAN); 55 | private static final Logger LOG = LoggerFactory.getLogger(ProductIndexService.class); 56 | 57 | private final RestHighLevelClient client; 58 | private final ObjectMapper mapper; 59 | 60 | @Inject 61 | public ProductIndexService(RestHighLevelClient client, ObjectMapper mapper) { 62 | this.client = client; 63 | this.mapper = mapper; 64 | } 65 | 66 | /** 67 | * Create some random products, the user can specify how many 68 | * 69 | * @param count Number of products to be created 70 | * @throws IOException 71 | */ 72 | public CompletableFuture indexProducts(int count) throws IOException { 73 | return CompletableFuture.supplyAsync(() -> { 74 | try { 75 | 76 | Set brands = new HashSet<>(); 77 | while (brands.size() < BRANDS_MAX) { 78 | brands.add(faker.company().name()); 79 | } 80 | String[] brandsArray = brands.toArray(new String[0]); 81 | 82 | boolean exists = client.indices().exists(new GetIndexRequest(INDEX), RequestOptions.DEFAULT); 83 | if (exists) { 84 | client.indices().delete(new DeleteIndexRequest(INDEX), RequestOptions.DEFAULT); 85 | } 86 | 87 | try (Reader readerSettings = new InputStreamReader(this.getClass().getResourceAsStream("/index-settings.json")); 88 | Reader readerMappings = new InputStreamReader(this.getClass().getResourceAsStream("/index-mappings.json"))) { 89 | String settings = Streams.copyToString(readerSettings); 90 | String mapping = Streams.copyToString(readerMappings); 91 | CreateIndexRequest createIndexRequest = new CreateIndexRequest(INDEX).settings(settings, XContentType.JSON).mapping(mapping, XContentType.JSON); 92 | client.indices().create(createIndexRequest, RequestOptions.DEFAULT); 93 | } 94 | 95 | BulkRequest request = new BulkRequest(); 96 | for (int i = 0; i < count; i++) { 97 | String productName = faker.commerce().productName(); 98 | // This is to replace german prices with a comma with a proper decimal space... 99 | double price = Double.valueOf(faker.commerce().price(1, 1000).replace(",", ".")); 100 | String material = faker.commerce().material(); 101 | String color = faker.color().name(); 102 | String id = faker.number().digits(20); 103 | String brand = faker.options().nextElement(brandsArray); 104 | // no text, we would need to deal with spaces and umlauts 105 | int productImageId = faker.number().numberBetween(1, 1000); 106 | while (NON_EXISTING_IMAGE_IDS.contains(productImageId)) { 107 | productImageId = faker.number().numberBetween(1, 1000); 108 | } 109 | String productImage = "https://picsum.photos/id/" + productImageId + "/200/200?blur=1"; 110 | String brandLogo = faker.company().logo(); 111 | Date lastUpdated = faker.date().past(365, TimeUnit.DAYS); 112 | int remainingStock = faker.number().numberBetween(0, 10); 113 | int commission = faker.number().numberBetween(5, 20); 114 | 115 | Product product = new Product(productName, price, color, material, id, productImage, brand, brandLogo, lastUpdated, remainingStock, commission); 116 | IndexRequest indexRequest = new IndexRequest(INDEX).id(id); 117 | indexRequest.source(mapper.writeValueAsString(product), XContentType.JSON); 118 | request.add(indexRequest); 119 | 120 | if (request.estimatedSizeInBytes() > MAX_BULK_SIZE_IN_BYTES) { 121 | BulkResponse response = client.bulk(request, RequestOptions.DEFAULT); 122 | LOG.info("Indexed [{}] documents in [{}]", response.getItems().length, response.getTook()); 123 | request = new BulkRequest(); 124 | } 125 | } 126 | 127 | request.setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE); 128 | BulkResponse response = client.bulk(request, RequestOptions.DEFAULT); 129 | LOG.info("Finished indexing run. Indexed [{}] documents in [{}]", response.getItems().length, response.getTook()); 130 | return HttpStatus.OK; 131 | } catch (IOException e) { 132 | throw new RuntimeException(e); 133 | } 134 | }); 135 | } 136 | 137 | public CompletableFuture configureSynonyms(String synonyms) { 138 | return CompletableFuture.supplyAsync(() -> { 139 | try { 140 | client.indices().close(new CloseIndexRequest(INDEX), RequestOptions.DEFAULT); 141 | Settings settings = Settings.builder() 142 | .putList("index.analysis.filter.my_synonym_filter.synonyms", synonyms.split("\n")) 143 | .build(); 144 | client.indices().putSettings(new UpdateSettingsRequest(INDEX).settings(settings), RequestOptions.DEFAULT); 145 | client.indices().open(new OpenIndexRequest().indices(INDEX), RequestOptions.DEFAULT); 146 | return HttpStatus.OK; 147 | } catch (IOException e) { 148 | throw new RuntimeException(e); 149 | } 150 | }); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/main/java/elasticsearch/ecommerce/app/service/ProductQueryService.java: -------------------------------------------------------------------------------- 1 | package elasticsearch.ecommerce.app.service; 2 | 3 | import elasticsearch.ecommerce.app.entities.Query; 4 | import org.apache.http.client.methods.HttpPost; 5 | import org.apache.http.entity.ContentType; 6 | import org.apache.http.nio.entity.NByteArrayEntity; 7 | import org.apache.lucene.util.BytesRef; 8 | import org.elasticsearch.action.search.SearchRequest; 9 | import org.elasticsearch.client.Request; 10 | import org.elasticsearch.client.Response; 11 | import org.elasticsearch.client.ResponseListener; 12 | import org.elasticsearch.client.RestHighLevelClient; 13 | import org.elasticsearch.common.SuppressForbidden; 14 | import org.elasticsearch.common.unit.Fuzziness; 15 | import org.elasticsearch.common.xcontent.ToXContent; 16 | import org.elasticsearch.common.xcontent.XContentHelper; 17 | import org.elasticsearch.common.xcontent.XContentType; 18 | import org.elasticsearch.index.query.BoolQueryBuilder; 19 | import org.elasticsearch.index.query.QueryBuilder; 20 | import org.elasticsearch.index.query.QueryBuilders; 21 | import org.elasticsearch.search.aggregations.AggregationBuilder; 22 | import org.elasticsearch.search.aggregations.AggregationBuilders; 23 | import org.elasticsearch.search.aggregations.bucket.filter.FiltersAggregationBuilder; 24 | import org.elasticsearch.search.aggregations.bucket.filter.FiltersAggregator; 25 | import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder; 26 | import org.elasticsearch.search.aggregations.metrics.MaxAggregationBuilder; 27 | import org.elasticsearch.search.aggregations.metrics.MinAggregationBuilder; 28 | import org.elasticsearch.search.builder.SearchSourceBuilder; 29 | import org.slf4j.Logger; 30 | import org.slf4j.LoggerFactory; 31 | 32 | import javax.inject.Inject; 33 | import javax.inject.Singleton; 34 | import java.io.IOException; 35 | import java.nio.charset.Charset; 36 | import java.util.List; 37 | import java.util.Map; 38 | import java.util.concurrent.CompletableFuture; 39 | import java.util.stream.Collectors; 40 | 41 | @Singleton 42 | public class ProductQueryService { 43 | 44 | // TODO have search with aggs + custom scoring 45 | // TODO have search searching for impressum/jobs 46 | // TODO search with search as you type 47 | 48 | private static final String INDEX = "products"; 49 | private static final Logger LOG = LoggerFactory.getLogger(ProductQueryService.class); 50 | 51 | private final RestHighLevelClient client; 52 | 53 | @Inject 54 | public ProductQueryService(RestHighLevelClient client) { 55 | this.client = client; 56 | } 57 | 58 | // search only across hits, don't include any aggregations 59 | public CompletableFuture searchProductsOnly(Query query) throws IOException { 60 | return asyncSearch(createFullTextSearchQuery(query), null, query.getFrom()); 61 | } 62 | 63 | /** 64 | * This search filters for the specified aggregations, uses a post filter 65 | * The drawback of this solution is, that on selection of an aggregation, this is only appended to the post 66 | * filter, so the aggregation counts never change 67 | * 68 | * Stock and Price are created as regular filters as part of the query, which indeed will change the aggregations 69 | */ 70 | public CompletableFuture searchWithAggs(Query query) throws IOException { 71 | BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); 72 | queryBuilder.must(createFullTextSearchQuery(query)); 73 | // filter for price and stock, as they become range queries 74 | query.getFilters().stream().filter(filter -> List.of("stock", "price").contains(filter.getKey())) 75 | .forEach(filter -> queryBuilder.must(filter.toQuery())); 76 | 77 | MinAggregationBuilder minPriceAgg = AggregationBuilders.min("min_price").field("price"); 78 | MaxAggregationBuilder maxPriceAgg = AggregationBuilders.max("max_price").field("price"); 79 | TermsAggregationBuilder byMaterialAgg = AggregationBuilders.terms("by_material").field("material.keyword"); 80 | TermsAggregationBuilder byBrand = AggregationBuilders.terms("by_brand").field("brand.keyword"); 81 | TermsAggregationBuilder byColor = AggregationBuilders.terms("by_color").field("color.keyword"); 82 | FiltersAggregator.KeyedFilter notInStockFilter = new FiltersAggregator.KeyedFilter("not_in_stock", QueryBuilders.termQuery("stock", 0)); 83 | FiltersAggregator.KeyedFilter inStockFilter = new FiltersAggregator.KeyedFilter("in_stock", QueryBuilders.rangeQuery("stock").gt(0)); 84 | FiltersAggregationBuilder inStockAgg = AggregationBuilders.filters("by_stock", inStockFilter, notInStockFilter); 85 | 86 | BoolQueryBuilder postFilterQuery = QueryBuilders.boolQuery(); 87 | Map> byKey = query.getFilters().stream() 88 | .filter(filter -> List.of("stock", "price").contains(filter.getKey()) == false) 89 | .collect(Collectors.groupingBy(Query.Filter::getKey)); 90 | for (Map.Entry> entry : byKey.entrySet()) { 91 | BoolQueryBuilder orQueryBuilder = QueryBuilders.boolQuery(); 92 | 93 | for (Query.Filter filter : entry.getValue()) { 94 | orQueryBuilder.should(QueryBuilders.termQuery(filter.getKey() + ".keyword", filter.getValue())); 95 | } 96 | 97 | postFilterQuery.filter(orQueryBuilder); 98 | } 99 | 100 | postFilterQuery = postFilterQuery.filter().isEmpty() ? null : postFilterQuery; 101 | 102 | return asyncSearch(queryBuilder, postFilterQuery, query.getFrom(), byColor, byBrand, byMaterialAgg, minPriceAgg, maxPriceAgg, inStockAgg); 103 | } 104 | 105 | /** 106 | * This is the ultimate query, where all facets are filtered based on the fields of the other facets. 107 | * This will result in a bigger query, but return proper numbers 108 | */ 109 | public CompletableFuture searchWithFilteredAggs(Query query) throws IOException { 110 | // this is the query for the total hits and the initial aggregations 111 | BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); 112 | queryBuilder.must(createFullTextSearchQuery(query)); 113 | // filter for price and stock, as they become range queries 114 | query.getFilters().stream().filter(filter -> List.of("stock", "price").contains(filter.getKey())) 115 | .forEach(filter -> queryBuilder.must(filter.toQuery())); 116 | 117 | // TODO these also need to be possibly filtered! 118 | MinAggregationBuilder minPriceAgg = AggregationBuilders.min("min_price").field("price"); 119 | MaxAggregationBuilder maxPriceAgg = AggregationBuilders.max("max_price").field("price"); 120 | 121 | FiltersAggregator.KeyedFilter notInStockFilter = new FiltersAggregator.KeyedFilter("not_in_stock", QueryBuilders.termQuery("stock", 0)); 122 | FiltersAggregator.KeyedFilter inStockFilter = new FiltersAggregator.KeyedFilter("in_stock", QueryBuilders.rangeQuery("stock").gt(0)); 123 | FiltersAggregationBuilder inStockAgg = AggregationBuilders.filters("by_stock", inStockFilter, notInStockFilter); 124 | 125 | AggregationBuilder byMaterialAgg = createPossiblyFilteredAgg(query, "by_material", "material"); 126 | AggregationBuilder byBrand = createPossiblyFilteredAgg(query, "by_brand", "brand"); 127 | AggregationBuilder byColor = createPossiblyFilteredAgg(query, "by_color", "color"); 128 | 129 | // additional post filter for material, brand and color 130 | BoolQueryBuilder postFilterQuery = QueryBuilders.boolQuery(); 131 | Map> byKey = query.getFilters().stream() 132 | .filter(filter -> List.of("stock", "price").contains(filter.getKey()) == false) 133 | .collect(Collectors.groupingBy(Query.Filter::getKey)); 134 | for (Map.Entry> entry : byKey.entrySet()) { 135 | BoolQueryBuilder orQueryBuilder = QueryBuilders.boolQuery(); 136 | 137 | for (Query.Filter filter : entry.getValue()) { 138 | orQueryBuilder.should(QueryBuilders.termQuery(filter.getKey() + ".keyword", filter.getValue())); 139 | } 140 | 141 | postFilterQuery.filter(orQueryBuilder); 142 | } 143 | postFilterQuery = postFilterQuery.filter().isEmpty() ? null : postFilterQuery; 144 | 145 | 146 | return asyncSearch(queryBuilder, postFilterQuery, query.getFrom(), byColor, byBrand, byMaterialAgg, minPriceAgg, maxPriceAgg, inStockAgg); 147 | } 148 | 149 | /** 150 | * Creates regular text search query 151 | */ 152 | private QueryBuilder createFullTextSearchQuery(Query query) { 153 | BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); 154 | queryBuilder.must(QueryBuilders.multiMatchQuery(query.getQuery(), "name", "color", "brand", "material") 155 | .minimumShouldMatch("66%") 156 | .fuzziness(Fuzziness.AUTO)); 157 | // increase scoring if we match in color, brand or material compared to product name 158 | // queryBuilder.should(QueryBuilders.matchQuery("material", query.getQuery())); 159 | // queryBuilder.should(QueryBuilders.matchQuery("color", query.getQuery())); 160 | // queryBuilder.should(QueryBuilders.matchQuery("brand", query.getQuery())); 161 | 162 | return queryBuilder; 163 | } 164 | 165 | private AggregationBuilder createPossiblyFilteredAgg(Query query, String aggregationName, String fieldName) { 166 | AggregationBuilder aggregationBuilder = AggregationBuilders.terms(aggregationName).field(fieldName + ".keyword"); 167 | BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); 168 | query.getFilters().stream() 169 | .filter(filter -> filter.getKey().equals(fieldName) == false) // filter out itself 170 | .forEach(filter -> queryBuilder.filter(filter.toQuery())); 171 | 172 | if (queryBuilder.filter().isEmpty() == false) { 173 | aggregationBuilder = AggregationBuilders.filter(aggregationName, queryBuilder).subAggregation(aggregationBuilder); 174 | } 175 | return aggregationBuilder; 176 | } 177 | 178 | private CompletableFuture asyncSearch(QueryBuilder queryBuilder, QueryBuilder postFilterQuery, int from, AggregationBuilder... aggs) throws IOException { 179 | SearchRequest request = search(queryBuilder, postFilterQuery, from, aggs); 180 | final CompletableFuture future = new CompletableFuture<>(); 181 | ResponseListener listener = newResponseListener(future); 182 | 183 | Request lowLevelRequest = new Request(HttpPost.METHOD_NAME, INDEX + "/_search"); 184 | BytesRef source = XContentHelper.toXContent(request.source(), XContentType.JSON, ToXContent.EMPTY_PARAMS, true).toBytesRef(); 185 | LOG.info("QUERY {}", source.utf8ToString()); 186 | lowLevelRequest.setEntity(new NByteArrayEntity(source.bytes, source.offset, source.length, createContentType(XContentType.JSON))); 187 | 188 | client.getLowLevelClient().performRequestAsync(lowLevelRequest, listener); 189 | return future; 190 | } 191 | 192 | private SearchRequest search(QueryBuilder queryBuilder, QueryBuilder postFilterQuery, int from, AggregationBuilder... aggs) { 193 | SearchRequest request = new SearchRequest(INDEX); 194 | SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); 195 | searchSourceBuilder.size(16); 196 | searchSourceBuilder.from(from); 197 | searchSourceBuilder.query(queryBuilder); 198 | if (postFilterQuery != null) { 199 | searchSourceBuilder.postFilter(postFilterQuery); 200 | } 201 | for (AggregationBuilder agg : aggs) { 202 | searchSourceBuilder.aggregation(agg); 203 | } 204 | request.source(searchSourceBuilder); 205 | return request; 206 | } 207 | 208 | // copied from RequestConverts.java, as it is private 209 | @SuppressForbidden(reason = "Only allowed place to convert a XContentType to a ContentType") 210 | private static ContentType createContentType(final XContentType xContentType) { 211 | return ContentType.create(xContentType.mediaTypeWithoutParameters(), (Charset) null); 212 | } 213 | 214 | private ResponseListener newResponseListener(final CompletableFuture future) { 215 | return new ResponseListener() { 216 | 217 | @Override 218 | public void onSuccess(Response response) { 219 | future.complete(response); 220 | } 221 | 222 | @Override 223 | public void onFailure(Exception exception) { 224 | future.completeExceptionally(exception); 225 | } 226 | }; 227 | } 228 | 229 | } 230 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | --- 2 | micronaut: 3 | application: 4 | name: elasticsearch-ecommerce-search-app 5 | router: 6 | static-resources: 7 | default: 8 | enabled: true 9 | mapping: /** 10 | paths: 11 | # - classpath:static 12 | - file:src/main/resources/static 13 | 14 | --- 15 | elasticsearch: 16 | httpHosts: "http://localhost:9200" 17 | 18 | --- 19 | # WARNING: MASSIVW SECURITY HOLE! 20 | endpoints: 21 | all: 22 | sensitive: false 23 | enabled: true 24 | 25 | --- 26 | jackson: 27 | bean-introspection-module: true 28 | serialization: 29 | indentOutput: true 30 | writeDatesAsTimestamps: false 31 | -------------------------------------------------------------------------------- /src/main/resources/index-mappings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dynamic_templates": [ 3 | { 4 | "strings": { 5 | "match_mapping_type": "string", 6 | "mapping": { 7 | "type": "text", 8 | "analyzer": "standard", 9 | "search_analyzer": "my_synonym_analyzer", 10 | "fields": { 11 | "keyword": { 12 | "type": "keyword", 13 | "ignore_above": 256 14 | } 15 | } 16 | } 17 | } 18 | } 19 | ], 20 | "properties" : { 21 | "commission" : { 22 | "type" : "rank_feature" 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/resources/index-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "analysis": { 4 | "analyzer": { 5 | "my_synonym_analyzer": { 6 | "tokenizer": "whitespace", 7 | "filter": [ 8 | "my_synonym_filter" 9 | ] 10 | } 11 | }, 12 | "filter": { 13 | "my_synonym_filter": { 14 | "type": "synonym_graph", 15 | "synonyms": [] 16 | } 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | 7 | 8 | %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/static/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Search Example App 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 |
22 | 23 |
{{ message }}
24 | 25 |
26 |

Reindex (# products)

27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 |
38 |

Synonyms

39 |
40 | 41 |
42 |
43 | 44 |
45 |
46 | 47 |
48 |
49 |
50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/main/resources/static/admin.js: -------------------------------------------------------------------------------- 1 | var app = new Vue({ 2 | el: '#app', 3 | data: { 4 | message: "", 5 | numberOfProducts: 100000, 6 | synonyms: 'orangsch => orange\nkfz => autos,\nplastikk => plastik' 7 | }, 8 | methods: { 9 | reindex : function() { 10 | this.message = "Reindexing..." 11 | axios 12 | .post("http://localhost:8080/admin/index_data?numberOfProducts=" + this.numberOfProducts) 13 | .then(response => ( this.message = "" )) 14 | .catch(error => this.message = "Error reindexing: " + JSON.stringify(error.response.data)) 15 | }, 16 | configure_synonyms: function() { 17 | this.message = "Configuring synonyms..." 18 | axios 19 | .post("http://localhost:8080/admin/configure_synonyms", {synonyms: this.synonyms}) 20 | .then(response => { 21 | console.log("IN RESPONSE123 ", response) 22 | this.message = "" 23 | }) 24 | .catch(error => this.message = "Error updating synonyms: " + JSON.stringify(error.response.data)) 25 | } 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /src/main/resources/static/index.css: -------------------------------------------------------------------------------- 1 | /*@import url(http://fonts.googleapis.com/css?family=Roboto:400,100,100italic,300,300ita‌​lic,400italic,500,500italic,700,700italic,900italic,900); 2 | html, body, html * { 3 | font-family: 'Roboto', sans-serif; 4 | }*/ 5 | 6 | .aggregations div.aggregation { 7 | padding-bottom: 20px; 8 | } 9 | 10 | .searchInput { 11 | padding-bottom: 2em; 12 | } 13 | 14 | .searchInput .filters button { 15 | margin-right: 5px; 16 | } -------------------------------------------------------------------------------- /src/main/resources/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Search Example App 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 |
26 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 | Search! 41 |
42 |
43 | 44 |
45 |
46 | 50 |
51 |
52 |
53 | 54 | 230 | 231 | 232 |
233 |
234 |
235 | 236 | 237 | 238 | 239 | -------------------------------------------------------------------------------- /src/main/resources/static/index.js: -------------------------------------------------------------------------------- 1 | Vue.filter('format', function (value) { 2 | return new Intl.NumberFormat('en-IN', { maximumFractionDigits: 2 }).format(value); 3 | }) 4 | 5 | var app = new Vue({ 6 | el: '#app', 7 | data: { 8 | feature: "products_with_filtered_aggs", 9 | query: "autoss", 10 | searchResponse: null, 11 | filters: [], 12 | from : 0, 13 | price_from: null, 14 | price_to: null, 15 | features : { 16 | products_only : { 17 | url : "products_only" 18 | }, 19 | products_with_aggs : { 20 | url : "products_with_aggs" 21 | }, 22 | products_with_filter_aggs : { 23 | url : "products_with_aggs" 24 | }, 25 | products_with_filtered_aggs : { 26 | url : "products_with_filtered_aggs" 27 | } 28 | } 29 | }, 30 | watch: { 31 | filters: function(newFilters, oldFilters) { 32 | this.search(); 33 | }, 34 | feature: function(newFeature, oldFeature) { 35 | // reset filters 36 | this.filters.splice(0, this.filters.length); 37 | this.searchResponse = null 38 | }, 39 | from: function(newFrom, oldFrom) { 40 | this.search(); 41 | } 42 | }, 43 | methods: { 44 | add_price_filter : function() { 45 | console.log("GOT PRICE", this.price_from, " TO ", this.price_to) 46 | index = this.filters.findIndex( function(e) { return e.key == "price" } ) 47 | lower = this.price_from !== null ? this.price_from : "" 48 | upper = this.price_to !== null ? this.price_to : "" 49 | filter = { key: 'price', value: lower + "-" + upper, type: 'range', from: lower, to: upper} 50 | if (index === undefined) { 51 | this.filters.push(filter) 52 | } else { 53 | this.filters.splice(index, 1, filter) 54 | } 55 | }, 56 | search : function() { 57 | url = this.features[this.feature].url 58 | query = { query: this.query, from: this.from } 59 | if (this.filters !== null) { 60 | query.filters = this.filters 61 | } 62 | console.log("URL", url, " and query ", JSON.stringify(query)) 63 | axios 64 | .post("http://localhost:8080/search/" + url, query) 65 | .then(response => { 66 | console.log("RESPONSE ", response.data) 67 | this.searchResponse = response.data 68 | }) 69 | } 70 | } 71 | }); 72 | --------------------------------------------------------------------------------