├── .gitignore ├── ElasticsearchRecipes.sln ├── README.md ├── app_full.png ├── azure-pipelines.yml ├── example_query_result.png ├── global.json └── src └── ElasticsearchRecipes ├── Controllers ├── Api │ ├── ApiController.cs │ ├── IndexController.cs │ └── RecipeController.cs └── Application │ └── HomeController.cs ├── Elastic ├── DataIndexer.cs ├── ElasticClientProvider.cs ├── ElasticConnectionSettings.cs └── SearchService.cs ├── ElasticsearchRecipes.xproj ├── ElasticsearchRecipes.xproj.user ├── Models ├── AutocompleteResult.cs ├── IndexResult.cs ├── Recipe.cs └── SearchResult.cs ├── Program.cs ├── Project_Readme.html ├── Properties └── launchSettings.json ├── Startup.cs ├── appsettings.json ├── project.json ├── project.lock.json ├── web.config └── wwwroot ├── App.js ├── Controllers ├── DetailsController.js ├── MainController.js ├── MoreLikeThisController.js └── SearchController.js ├── Routes.js ├── Services └── RecipeService.js ├── Views ├── Details.html ├── Main.html ├── MoreLikeThis.html └── SearchResult.html └── index.html /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnikolovv/elasticsearch-recipes-nest-angular/fa561ce49f4140609dcfdbfc904a2b3a5b616a20/.gitignore -------------------------------------------------------------------------------- /ElasticsearchRecipes.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 14 4 | VisualStudioVersion = 14.0.25420.1 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7F8C2D24-44C5-453F-A6DE-C01B79C91C06}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8D8DD44B-1E77-458A-9A58-622E591267D5}" 9 | ProjectSection(SolutionItems) = preProject 10 | global.json = global.json 11 | EndProjectSection 12 | EndProject 13 | Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ElasticsearchRecipes", "src\ElasticsearchRecipes\ElasticsearchRecipes.xproj", "{58BDEA3A-766D-4501-8DA5-AA3F78939980}" 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Release|Any CPU = Release|Any CPU 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {58BDEA3A-766D-4501-8DA5-AA3F78939980}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {58BDEA3A-766D-4501-8DA5-AA3F78939980}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {58BDEA3A-766D-4501-8DA5-AA3F78939980}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {58BDEA3A-766D-4501-8DA5-AA3F78939980}.Release|Any CPU.Build.0 = Release|Any CPU 25 | EndGlobalSection 26 | GlobalSection(SolutionProperties) = preSolution 27 | HideSolutionNode = FALSE 28 | EndGlobalSection 29 | GlobalSection(NestedProjects) = preSolution 30 | {58BDEA3A-766D-4501-8DA5-AA3F78939980} = {7F8C2D24-44C5-453F-A6DE-C01B79C91C06} 31 | EndGlobalSection 32 | EndGlobal 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > This is the finished application if you were to follow my [4 part tutorial](https://devadventures.net/2018/04/16/connect-your-asp-net-core-application-to-elasticsearch-using-nest-5/) on Elasticsearch and NEST 2 | 3 | # The application 4 | The application is pretty simple, but extremely flexible in terms of searching data. 5 | 6 | ![Application][app_full] 7 | 8 | Behind the scenes, the user inputted query is converted to a [query_string][qsdocs] query that is sent to Elastic. The [query_string][qsdocs] query provides native support for a number of [search syntax][qssearchsyntax] options, but we're mostly interested in [wildcards][qswildcards], [boolean operators][qsbooloperators], [phrase matching][qsphrasematching] and [boosting][qsboosting]. 9 | 10 | The user inputted string is proccessed in the following way: 11 | > * Exact phrases to look for are marked with ***quotes*** ("***an example phrase***"). 12 | > * Each word/phrase that isn't marked in any way (**example**) ***will*** be present in the result set's ingredients. 13 | > * Each word/phrase that is marked with a minus (**-example**) ***will not*** be present in the result set's ingredients. 14 | > * Each word/phrase can be [boosted][qsboosting] to become more relevant (score higher) in the search results. 15 | 16 | So, if we want to look for recipes containing ***garlic***, ***tomatoes***, ***chopped onions*** and *not* containing ***eggs*** and ***red-pepper flakes***, we'll have to input the query string: 17 | 18 | > **garlic^2 tomatoes -egg* "chopped onions" -"red-pepper flakes"** // Order doesn't matter 19 | 20 | As you can see, we've boosted the garlic. This means that recipes where garlic appears more frequently will score higher. 21 | 22 | The asterisk symbol following the ***egg*** is called a **wildcard** query. It means that it's going to look for words that start with ***egg*** and continue with anything else, *no matter the length* (for example, **egg*** is going to match **eggs, eggplant, eggbeater**, etc.), very similar to SQL's like. 23 | 24 | You can substitute the asterisk with a "**?**" sign, which will indicate that Elastic should look for just a **single** missing letter. 25 | 26 | This spits out the result: 27 | ![Result][sample_query_result] 28 | ... 29 | 30 | # How to run 31 | 32 | In order to run the application, you must [install and run][install_elastic_url] elastic then execute the following steps: 33 | 34 | 1. Clone the repository. 35 | 2. Open the project in Visual Studio and restore packages (Ctrl + Shift + K, Ctrl + Shift + R). 36 | 3. Run the application (Ctrl + F5). 37 | 38 | In order to make use of the search functionality, you must have some recipes indexed in your elastic cluster. 39 | 40 | First, make sure that the cluster url in `appsettings.json` is correctly configured then: 41 | 42 | 1. Download the [openrecipes dump][openrecipes_dump_download] json file. 43 | 2. Extract the json file in `project-root\src\ElasticsearchRecipes\Data` (create the Data folder if it doesn't exist). 44 | 3. While the application is running, make a get request to `appurl/api/index/file?fileName=openrecipes-big.json`. 45 | 4. In a few minutes you should see a json response confirming that the indexation was valid. 46 | 47 | Everything should be set-up and you are free to play around. 48 | 49 | [app_full]: ./app_full.png 50 | [sample_query_result]: ./example_query_result.png 51 | [install_elastic_url]: https://www.elastic.co/guide/en/elasticsearch/guide/current/running-elasticsearch.html 52 | [openrecipes_dump_download]: https://drive.google.com/open?id=0B-HzAYDi4IbYR0dOek1MWFVkRFU 53 | 54 | [qsdocs]: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html 55 | [qssearchsyntax]: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax 56 | [qsbooloperators]: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#_boolean_operators 57 | [qsboosting]: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#_boosting 58 | [qswildcards]: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#_wildcards 59 | [qsphrasematching]: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax 60 | -------------------------------------------------------------------------------- /app_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnikolovv/elasticsearch-recipes-nest-angular/fa561ce49f4140609dcfdbfc904a2b3a5b616a20/app_full.png -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Starter pipeline 2 | # Start with a minimal pipeline that you can customize to build and deploy your code. 3 | # Add steps that build, run tests, deploy, and more: 4 | # https://aka.ms/yaml 5 | 6 | trigger: 7 | - master 8 | 9 | pool: 10 | vmImage: 'Ubuntu-16.04' 11 | 12 | steps: 13 | - script: echo Hello, world! 14 | displayName: 'Run a one-line script' 15 | 16 | - script: | 17 | echo Add other tasks to build, test, and deploy your project. 18 | echo See https://aka.ms/yaml 19 | displayName: 'Run a multi-line script' 20 | -------------------------------------------------------------------------------- /example_query_result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnikolovv/elasticsearch-recipes-nest-angular/fa561ce49f4140609dcfdbfc904a2b3a5b616a20/example_query_result.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ "src", "test" ], 3 | "sdk": { 4 | "version": "1.0.0-preview2-003131" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Controllers/Api/ApiController.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Controllers.Api 2 | { 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | /// 6 | /// Just used as a shortcut to achieve a global route prefix. 7 | /// 8 | [Route("/api/[controller]")] 9 | public class ApiController : Controller 10 | { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Controllers/Api/IndexController.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Controllers.Api 2 | { 3 | using Elastic; 4 | using Microsoft.AspNetCore.Mvc; 5 | using System.Threading.Tasks; 6 | 7 | public class IndexController : ApiController 8 | { 9 | public IndexController(DataIndexer indexer) 10 | { 11 | this.indexer = indexer; 12 | } 13 | 14 | private readonly DataIndexer indexer; 15 | 16 | /// 17 | /// The file must be present in the project's Data directory 18 | /// 19 | /// 20 | /// 21 | /// 22 | [HttpGet("file")] 23 | public async Task IndexDataFromFile([FromQuery]string fileName, string index, bool deleteIndexIfExists) 24 | { 25 | var response = await this.indexer.IndexRecipesFromFile(fileName, deleteIndexIfExists, index); 26 | return Json(response); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Controllers/Api/RecipeController.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Controllers.Api 2 | { 3 | using Elastic; 4 | using Microsoft.AspNetCore.Mvc; 5 | using System.Threading.Tasks; 6 | 7 | public class RecipeController : ApiController 8 | { 9 | public RecipeController(SearchService searchService) 10 | { 11 | this.searchService = searchService; 12 | } 13 | 14 | private readonly SearchService searchService; 15 | 16 | [HttpGet("search")] 17 | public async Task Search([FromQuery]string query, int page = 1, int pageSize = 10) 18 | { 19 | var result = await this.searchService.Search(query, page, pageSize); 20 | return Json(result); 21 | } 22 | 23 | [HttpGet("{id?}")] 24 | public async Task GetById([FromQuery]string id) 25 | { 26 | var result = await this.searchService.GetById(id); 27 | return Json(result); 28 | } 29 | 30 | [HttpGet("morelikethis")] 31 | public async Task MoreLikeThis([FromQuery]string id, int page = 1, int pageSize = 10) 32 | { 33 | var result = await this.searchService.MoreLikeThis(id, page, pageSize); 34 | return Json(result); 35 | } 36 | 37 | [HttpGet("autocomplete")] 38 | public async Task Autocomplete([FromQuery]string query) 39 | { 40 | var result = await this.searchService.Autocomplete(query); 41 | return Json(result); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Controllers/Application/HomeController.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Controllers.Application 2 | { 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | public class HomeController : Controller 6 | { 7 | // A catch-all route is registered in Startup.cs so that every url leads to this action 8 | public IActionResult Recipes() 9 | { 10 | // The angular app entry point 11 | return File("~/index.html", "text/html"); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Elastic/DataIndexer.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Elastic 2 | { 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.Options; 5 | using Models; 6 | using Nest; 7 | using Newtonsoft.Json; 8 | using System; 9 | using System.Diagnostics; 10 | using System.IO; 11 | using System.Linq; 12 | using System.Threading.Tasks; 13 | 14 | public class DataIndexer 15 | { 16 | public DataIndexer(ElasticClientProvider clientProvider, IHostingEnvironment env, IOptions settings) 17 | { 18 | this.client = clientProvider.Client; 19 | this.contentRootPath = Path.Combine(env.ContentRootPath, "data"); 20 | this.defaultIndex = settings.Value.DefaultIndex; 21 | } 22 | 23 | private readonly ElasticClient client; 24 | private readonly string contentRootPath; 25 | private readonly string defaultIndex; 26 | 27 | public async Task IndexRecipesFromFile(string fileName, bool deleteIndexIfExists, string index = null) 28 | { 29 | SanitizeIndexName(ref index); 30 | Recipe[] mappedCollection = await ParseJsonFile(fileName); 31 | await DeleteIndexIfExists(index, deleteIndexIfExists); 32 | await CreateIndexIfItDoesntExist(index); 33 | await ConfigurePagination(index); 34 | return await IndexDocuments(mappedCollection, index); 35 | } 36 | 37 | private void SanitizeIndexName(ref string index) 38 | { 39 | // The index must be lowercase, this is a requirement from Elastic 40 | if (index == null) 41 | { 42 | index = this.defaultIndex; 43 | } 44 | else 45 | { 46 | index = index.ToLower(); 47 | } 48 | } 49 | 50 | private async Task IndexDocuments(Recipe[] mappedCollection, string index) 51 | { 52 | int batchSize = 10000; // magic 53 | int totalBatches = (int)Math.Ceiling((double)mappedCollection.Length / batchSize); 54 | 55 | for (int i = 0; i < totalBatches; i++) 56 | { 57 | var response = await this.client.IndexManyAsync(mappedCollection.Skip(i * batchSize).Take(batchSize), index); 58 | 59 | if (!response.IsValid) 60 | { 61 | return new IndexResult 62 | { 63 | IsValid = false, 64 | ErrorReason = response.ServerError?.Error?.Reason, 65 | Exception = response.OriginalException 66 | }; 67 | } 68 | else 69 | { 70 | Debug.WriteLine($"Successfully indexed batch {i + 1}"); 71 | } 72 | } 73 | 74 | return new IndexResult 75 | { 76 | IsValid = true 77 | }; 78 | } 79 | 80 | private async Task ConfigurePagination(string index) 81 | { 82 | // Max out the result window so you can have pagination for >100 pages 83 | await this.client.UpdateIndexSettingsAsync(index, ixs => ixs 84 | .IndexSettings(s => s 85 | .Setting("max_result_window", int.MaxValue))); 86 | } 87 | 88 | private async Task CreateIndexIfItDoesntExist(string index) 89 | { 90 | if (!this.client.IndexExists(index).Exists) 91 | { 92 | var indexDescriptor = new CreateIndexDescriptor(index) 93 | .Mappings(mappings => mappings 94 | .Map(m => m.AutoMap())); 95 | 96 | await this.client.CreateIndexAsync(index, i => indexDescriptor); 97 | } 98 | } 99 | 100 | private async Task DeleteIndexIfExists(string index, bool shouldDeleteIndex) 101 | { 102 | if (this.client.IndexExists(index).Exists && shouldDeleteIndex) 103 | { 104 | await this.client.DeleteIndexAsync(index); 105 | } 106 | } 107 | 108 | private async Task ParseJsonFile(string fileName) 109 | { 110 | using (FileStream fs = new FileStream(Path.Combine(contentRootPath, fileName), FileMode.Open)) 111 | { 112 | using (StreamReader reader = new StreamReader(fs)) 113 | { 114 | // Won't be efficient with large files 115 | string rawJsonCollection = await reader.ReadToEndAsync(); 116 | 117 | Recipe[] mappedCollection = JsonConvert.DeserializeObject(rawJsonCollection, new JsonSerializerSettings 118 | { 119 | Error = HandleDeserializationError 120 | }); 121 | 122 | return mappedCollection; 123 | } 124 | } 125 | } 126 | 127 | // https://stackoverflow.com/questions/26107656/ignore-parsing-errors-during-json-net-data-parsing 128 | private void HandleDeserializationError(object sender, Newtonsoft.Json.Serialization.ErrorEventArgs errorArgs) 129 | { 130 | var currentError = errorArgs.ErrorContext.Error.Message; 131 | errorArgs.ErrorContext.Handled = true; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Elastic/ElasticClientProvider.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Elastic 2 | { 3 | using Microsoft.Extensions.Options; 4 | using Nest; 5 | 6 | public class ElasticClientProvider 7 | { 8 | public ElasticClientProvider(IOptions settings) 9 | { 10 | ConnectionSettings connectionSettings = 11 | new ConnectionSettings(new System.Uri(settings.Value.ClusterUrl)); 12 | 13 | connectionSettings.EnableDebugMode(); 14 | 15 | if (settings.Value.DefaultIndex != null) 16 | { 17 | connectionSettings.DefaultIndex(settings.Value.DefaultIndex); 18 | } 19 | 20 | this.Client = new ElasticClient(connectionSettings); 21 | } 22 | 23 | public ElasticClient Client { get; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Elastic/ElasticConnectionSettings.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Elastic 2 | { 3 | public class ElasticConnectionSettings 4 | { 5 | public string ClusterUrl { get; set; } 6 | 7 | public string DefaultIndex 8 | { 9 | get { return defaultIndex; } 10 | set { defaultIndex = value.ToLower(); } 11 | } 12 | 13 | private string defaultIndex; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Elastic/SearchService.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Elastic 2 | { 3 | using Models; 4 | using Nest; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | 9 | public class SearchService 10 | { 11 | public SearchService(ElasticClientProvider clientProvider) 12 | { 13 | this.client = clientProvider.Client; 14 | } 15 | 16 | private readonly ElasticClient client; 17 | 18 | /// 19 | /// Searches elastic for recipes matching the given query. If a word in the query is preceeded by a '-' sign, the results won't contain it. Supports everything QueryStringQuery does, 20 | /// (wildcard queries, phrase matching, proximity matching, etc.) 21 | /// 22 | /// 23 | /// 24 | /// 25 | /// 26 | public async Task> Search(string query, int page, int pageSize) 27 | { 28 | #region RawQuery 29 | /* 30 | Passed query: "chopped onions" eggs -tomatoes -"olive oil" 31 | { 32 | "from": 0, 33 | "size": 10, 34 | "query": { 35 | "bool": { 36 | "must": [ 37 | { 38 | "query_string": { 39 | "query": "\"chopped onions\" eggs -tomatoes -\"olive oil\"" 40 | } 41 | } 42 | ] 43 | } 44 | } 45 | } 46 | */ 47 | #endregion 48 | 49 | var response = await this.client.SearchAsync(searchDescriptor => searchDescriptor 50 | .Query(queryContainerDescriptor => queryContainerDescriptor 51 | .Bool(queryDescriptor => queryDescriptor 52 | .Must(queryStringQuery => queryStringQuery 53 | .QueryString(queryString => queryString 54 | .Query(query))))) 55 | .From((page - 1) * pageSize) 56 | .Size(pageSize)); 57 | 58 | return MapResponseToSearchResult(response, page, pageSize); 59 | } 60 | 61 | public async Task> MoreLikeThis(string id, int page, int pageSize) 62 | { 63 | #region RawQuery 64 | /* 65 | Raw query: 66 | { 67 | "from": (page - 1) * pageSize, 68 | "size": pageSize, 69 | "query": { 70 | "more_like_this": { 71 | "fields": [ 72 | "ingredients" 73 | ], 74 | "like": [ 75 | { 76 | "_index": "recipes", 77 | "_type": "recipe", 78 | "_id": "id" 79 | } 80 | ] 81 | } 82 | } 83 | } 84 | */ 85 | #endregion 86 | 87 | var response = await this.client.SearchAsync(s => s 88 | .Query(q => q 89 | .MoreLikeThis(qd => qd 90 | .Like(l => l.Document(d => d.Id(id))) 91 | .Fields(fd => fd.Fields(r => r.Ingredients)))) 92 | .From((page - 1) * pageSize) 93 | .Size(pageSize)); 94 | 95 | return MapResponseToSearchResult(response, page, pageSize); 96 | } 97 | 98 | public async Task GetById(string id) 99 | { 100 | var response = await this.client.GetAsync(id); 101 | return response.Source; 102 | } 103 | 104 | public async Task> Autocomplete(string query) 105 | { 106 | #region RawQuery 107 | /* 108 | Raw query: 109 | { 110 | "suggest": { 111 | "recipe-name-completion": { 112 | "prefix": "query", 113 | "completion": { 114 | "field": "name", 115 | "fuzzy": { 116 | "fuzziness": "AUTO" 117 | } 118 | } 119 | } 120 | } 121 | } 122 | */ 123 | #endregion 124 | 125 | var suggestionResponse = await this.client.SearchAsync(sr => sr 126 | .Suggest(scd => scd 127 | .Completion("recipe-name-completion", cs => cs 128 | .Prefix(query) 129 | .Fuzzy(fsd => fsd 130 | .Fuzziness(Fuzziness.Auto)) 131 | .Field(r => r.Name)))); 132 | 133 | List suggestions = this.ExtractAutocompleteSuggestions(suggestionResponse); 134 | 135 | return suggestions; 136 | } 137 | 138 | private List ExtractAutocompleteSuggestions(ISearchResponse response) 139 | { 140 | List results = new List(); 141 | 142 | var matchingOptions = response.Suggest["recipe-name-completion"].Select(s => s.Options); 143 | 144 | foreach (var option in matchingOptions) 145 | { 146 | results.AddRange(option.Select(opt => new AutocompleteResult() { Id = opt.Source.Id, Name = opt.Source.Name })); 147 | } 148 | 149 | return results; 150 | } 151 | 152 | private SearchResult MapResponseToSearchResult(ISearchResponse response, int page, int pageSize) 153 | { 154 | return new SearchResult 155 | { 156 | IsValid = response.IsValid, 157 | ErrorMessage = response.ApiCall.OriginalException?.Message, 158 | Total = response.Total, 159 | ElapsedMilliseconds = response.Took, 160 | Page = page, 161 | PageSize = pageSize, 162 | Results = response?.Documents 163 | }; 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/ElasticsearchRecipes.xproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 14.0 5 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 6 | 7 | 8 | 9 | 58bdea3a-766d-4501-8da5-aa3f78939980 10 | ElasticsearchRecipes 11 | .\obj 12 | .\bin\ 13 | v4.5.2 14 | 15 | 16 | 2.0 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/ElasticsearchRecipes.xproj.user: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | ElasticsearchRecipes 5 | false 6 | 7 | 8 | ProjectDebugger 9 | 10 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Models/AutocompleteResult.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Models 2 | { 3 | public class AutocompleteResult 4 | { 5 | public string Id { get; set; } 6 | 7 | public string Name { get; set; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Models/IndexResult.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Models 2 | { 3 | using System; 4 | 5 | public class IndexResult 6 | { 7 | public bool IsValid { get; set; } 8 | 9 | public string ErrorReason { get; set; } 10 | 11 | public Exception Exception { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Models/Recipe.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Models 2 | { 3 | using Nest; 4 | using System; 5 | 6 | [ElasticsearchType(Name = "recipe")] 7 | public class Recipe 8 | { 9 | public string Id { get; set; } 10 | 11 | [Completion] 12 | public string Name { get; set; } 13 | [Text] 14 | public string Ingredients { get; set; } 15 | 16 | public string Url { get; set; } 17 | 18 | public string Image { get; set; } 19 | 20 | public string CookTime { get; set; } 21 | 22 | public string RecipeYield { get; set; } 23 | 24 | public DateTime? DatePublished { get; set; } 25 | 26 | public string PrepTime { get; set; } 27 | [Text] 28 | public string Description { get; set; } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Models/SearchResult.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes.Models 2 | { 3 | using System.Collections.Generic; 4 | 5 | public class SearchResult 6 | { 7 | public bool IsValid { get; set; } 8 | 9 | public string ErrorMessage { get; set; } 10 | 11 | public long Total { get; set; } 12 | 13 | public int Page { get; set; } 14 | 15 | public int PageSize { get; set; } 16 | 17 | public IEnumerable Results { get; set; } 18 | 19 | public long ElapsedMilliseconds { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Builder; 8 | 9 | namespace ElasticsearchRecipes 10 | { 11 | public class Program 12 | { 13 | public static void Main(string[] args) 14 | { 15 | var host = new WebHostBuilder() 16 | .UseKestrel() 17 | .UseContentRoot(Directory.GetCurrentDirectory()) 18 | .UseIISIntegration() 19 | .UseStartup() 20 | .Build(); 21 | 22 | host.Run(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Project_Readme.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Welcome to ASP.NET Core 6 | 127 | 128 | 129 | 130 | 138 | 139 |
140 |
141 |

This application consists of:

142 |
    143 |
  • Sample pages using ASP.NET Core MVC
  • 144 |
  • Bower for managing client-side libraries
  • 145 |
  • Theming using Bootstrap
  • 146 |
147 |
148 | 160 | 172 |
173 |

Run & Deploy

174 | 179 |
180 | 181 | 184 |
185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:56134/", 7 | "sslPort": 0 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "launchUrl": "api/values", 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development" 17 | } 18 | }, 19 | "ElasticsearchRecipes": { 20 | "commandName": "Project", 21 | "launchBrowser": true, 22 | "launchUrl": "http://localhost:5000/", 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/Startup.cs: -------------------------------------------------------------------------------- 1 | namespace ElasticsearchRecipes 2 | { 3 | using Elastic; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.StaticFilesEx; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | 11 | public class Startup 12 | { 13 | public Startup(IHostingEnvironment env) 14 | { 15 | var builder = new ConfigurationBuilder() 16 | .SetBasePath(env.ContentRootPath) 17 | .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) 18 | .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) 19 | .AddEnvironmentVariables(); 20 | Configuration = builder.Build(); 21 | } 22 | 23 | public IConfigurationRoot Configuration { get; } 24 | 25 | // This method gets called by the runtime. Use this method to add services to the container. 26 | public void ConfigureServices(IServiceCollection services) 27 | { 28 | // Add framework services. 29 | services.AddMvc(); 30 | 31 | // Get the connection settings from appsettings.json and inject them into ElasticConnectionSettings 32 | services.AddOptions(); 33 | services.Configure(Configuration.GetSection("ElasticConnectionSettings")); 34 | 35 | services.AddSingleton(typeof(ElasticClientProvider)); 36 | services.AddTransient(typeof(DataIndexer)); 37 | 38 | services.AddTransient(typeof(SearchService)); 39 | } 40 | 41 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 42 | public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) 43 | { 44 | loggerFactory.AddConsole(Configuration.GetSection("Logging")); 45 | loggerFactory.AddDebug(); 46 | 47 | app.UseStaticFiles(); 48 | app.UseMvc(routes => 49 | { 50 | // Always serve the static index.html file, which is the entry point for the Angular app 51 | routes.MapRoute( 52 | name: "default", 53 | template: "{*url}", 54 | defaults: new 55 | { 56 | Controller = "Home", 57 | Action = "Recipes" 58 | }); 59 | }); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "LogLevel": { 5 | "Default": "Debug", 6 | "System": "Information", 7 | "Microsoft": "Information" 8 | } 9 | }, 10 | "ElasticConnectionSettings": { 11 | "ClusterUrl": "http://localhost:9200", 12 | "DefaultIndex": "recipes" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "Microsoft.NETCore.App": { 4 | "version": "1.0.1", 5 | "type": "platform" 6 | }, 7 | "Microsoft.AspNetCore.Mvc": "1.0.1", 8 | "Microsoft.AspNetCore.Routing": "1.0.1", 9 | "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0", 10 | "Microsoft.AspNetCore.Server.Kestrel": "1.0.1", 11 | "Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0", 12 | "Microsoft.Extensions.Configuration.FileExtensions": "1.0.0", 13 | "Microsoft.Extensions.Configuration.Json": "1.0.0", 14 | "Microsoft.Extensions.Logging": "1.0.0", 15 | "Microsoft.Extensions.Logging.Console": "1.0.0", 16 | "Microsoft.Extensions.Logging.Debug": "1.0.0", 17 | "Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0", 18 | "Microsoft.AspNetCore.Diagnostics": "1.1.2", 19 | "Microsoft.AspNetCore.StaticFilesEx": "1.0.0.4", 20 | "NEST": "5.4.0" 21 | }, 22 | 23 | "tools": { 24 | "Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.0.0-preview2-final" 25 | }, 26 | 27 | "frameworks": { 28 | "netcoreapp1.0": { 29 | "imports": [ 30 | "dotnet5.6", 31 | "portable-net45+win8" 32 | ] 33 | } 34 | }, 35 | 36 | "buildOptions": { 37 | "emitEntryPoint": true, 38 | "preserveCompilationContext": true 39 | }, 40 | 41 | "runtimeOptions": { 42 | "configProperties": { 43 | "System.GC.Server": true 44 | } 45 | }, 46 | 47 | "publishOptions": { 48 | "include": [ 49 | "wwwroot", 50 | "**/*.cshtml", 51 | "appsettings.json", 52 | "web.config" 53 | ] 54 | }, 55 | 56 | "scripts": { 57 | "postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/web.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/App.js: -------------------------------------------------------------------------------- 1 | var app = angular.module('app', ['ui.router', 'ui.bootstrap']); -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Controllers/DetailsController.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | app.controller('DetailsController', ['recipe', function (recipe) { 3 | var vm = this; 4 | vm.recipe = recipe; 5 | }]); 6 | })(); -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Controllers/MainController.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | app.controller('MainController', ['$stateParams', '$state', 'RecipeService', function ($stateParams, $state, RecipeService) { 3 | var vm = this; 4 | vm.query = $stateParams.query; 5 | 6 | vm.pageSize = $stateParams.pageSize; 7 | 8 | vm.pageSizes = [ 9 | 10, 10 | 20, 11 | 50, 12 | 100 13 | ]; 14 | 15 | vm.completionSuggestions = function (query) { 16 | if (query.length > 0) { 17 | 18 | // In order to prevent uib-typeahead selecting the first item it encounters 19 | // we are always going to return the query typed by the user first, and then, 20 | // when the API responds with actual suggestions, we're going to append them 21 | var suggestions = [{ name: query }]; 22 | 23 | return RecipeService.autocomplete(query).then(function (response) { 24 | // Append the suggestions to the user query 25 | return suggestions.concat(response.data); 26 | }); 27 | 28 | return suggestions; 29 | } 30 | } 31 | 32 | vm.search = function () { 33 | var params = { 34 | query: vm.query, 35 | pageSize: vm.pageSize, 36 | page: 1 37 | }; 38 | 39 | $state.go('recipes.search', params); 40 | } 41 | 42 | vm.goToSelectedItem = function (item) { 43 | if (item.id) { 44 | $state.go('recipes.details', { id: item.id }); 45 | } else { 46 | // If an id is not specified, then the user has clicked his own query 47 | // (the first suggestion, which is automatically selected) 48 | // That means we should just excecute a search with the query that the user has entered 49 | vm.search(); 50 | } 51 | } 52 | }]); 53 | })(); -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Controllers/MoreLikeThisController.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | app.controller('MoreLikeThisController', ['$state', '$stateParams', 'searchData', function ($state, $stateParams, searchData) { 3 | 4 | var vm = this; 5 | vm.searchData = searchData; 6 | vm.currentRecipeName = $stateParams.recipeName; 7 | 8 | vm.switchPage = function () { 9 | 10 | var params = { 11 | id: $stateParams.id, 12 | page: vm.searchData.page, 13 | recipe: vm.currentRecipeName, 14 | pageSize: $stateParams.pageSize 15 | }; 16 | 17 | $state.go('recipes.morelikethis', params); 18 | }; 19 | }]) 20 | })(); -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Controllers/SearchController.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | app.controller('SearchController', ['$state', '$stateParams', 'searchResult', function ($state, $stateParams, searchResult) { 3 | 4 | var vm = this; 5 | vm.searchResult = searchResult; 6 | 7 | vm.switchPage = function () { 8 | 9 | var params = { 10 | query: $stateParams.query, 11 | page: vm.searchResult.page, 12 | pageSize: $stateParams.pageSize 13 | }; 14 | 15 | $state.go('recipes.search', params); 16 | }; 17 | }]) 18 | })(); -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Routes.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | app.config(['$locationProvider', '$stateProvider', function ($locationProvider, $stateProvider) { 3 | 4 | $stateProvider 5 | .state('default', { 6 | url: '/', 7 | redirectTo: 'recipes' 8 | }); 9 | 10 | $stateProvider 11 | .state('recipes', { 12 | url: '/recipes', 13 | templateUrl: '/Views/Main.html', 14 | controller: 'MainController', 15 | controllerAs: 'model' 16 | }); 17 | 18 | $stateProvider 19 | .state('recipes.search', { 20 | url: '/search/:query?page&pageSize', 21 | resolve: { 22 | searchResult: ['$q', 'RecipeService', '$stateParams', function ($q, RecipeService, $stateParams) { 23 | 24 | var deferred = $q.defer(); 25 | 26 | RecipeService.getRecipes($stateParams.query, $stateParams.page, $stateParams.pageSize).then(function (response) { 27 | deferred.resolve(response.data); 28 | }); 29 | 30 | return deferred.promise; 31 | }] 32 | }, 33 | templateUrl: '/Views/SearchResult.html', 34 | controller: 'SearchController', 35 | controllerAs: 'model' 36 | }); 37 | 38 | $stateProvider 39 | .state('recipes.details', { 40 | url: '/:id', 41 | resolve: { 42 | recipe: ['$q', 'RecipeService', '$stateParams', function ($q, RecipeService, $stateParams) { 43 | 44 | var deferred = $q.defer(); 45 | 46 | RecipeService.getById($stateParams.id).then(function (response) { 47 | deferred.resolve(response.data); 48 | }); 49 | 50 | return deferred.promise; 51 | }] 52 | }, 53 | templateUrl: '/Views/Details.html', 54 | controller: 'DetailsController', 55 | controllerAs: 'model' 56 | }); 57 | 58 | $stateProvider 59 | .state('recipes.morelikethis', { 60 | url: '/morelikethis/:id?page&pageSize&recipeName', 61 | resolve: { 62 | searchData: ['$q', 'RecipeService', '$stateParams', function ($q, RecipeService, $stateParams) { 63 | 64 | var deferred = $q.defer(); 65 | 66 | if (typeof $stateParams.page !== "Number" || $stateParams.page <= 0) { 67 | $stateParams.page = 1; 68 | } 69 | 70 | RecipeService.moreLikeThis($stateParams.id, $stateParams.page, $stateParams.pageSize).then(function (response) { 71 | deferred.resolve(response.data); 72 | }); 73 | 74 | return deferred.promise; 75 | }] 76 | }, 77 | templateUrl: '/Views/MoreLikeThis.html', 78 | controller: 'MoreLikeThisController', 79 | controllerAs: 'model' 80 | }); 81 | 82 | $locationProvider.html5Mode({ 83 | enabled: true, requireBase: false 84 | }); 85 | }]); 86 | })(); -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Services/RecipeService.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | app.factory('RecipeService', ['$http', function ($http) { 3 | 4 | var recipesEndpoint = '/api/recipe/' 5 | 6 | var recipeService = { 7 | 8 | getRecipes: function (query, page, pageSize) { 9 | 10 | var route = recipesEndpoint + 'search'; 11 | 12 | return $http.get(route, { 13 | params: { 14 | query: query, 15 | page: page, 16 | pageSize: pageSize 17 | } 18 | }) 19 | }, 20 | getById: function (id) { 21 | return $http.get(recipesEndpoint, { 22 | params: { 23 | id: id 24 | } 25 | }); 26 | }, 27 | moreLikeThis: function (id, page, pageSize) { 28 | 29 | var route = recipesEndpoint + 'morelikethis'; 30 | 31 | return $http.get(route, { 32 | params: { 33 | id: id, 34 | page: page, 35 | pageSize: pageSize 36 | } 37 | }) 38 | }, 39 | autocomplete: function (query) { 40 | 41 | if (typeof query !== "undefined" && query.length > 0) { 42 | var route = recipesEndpoint + 'autocomplete'; 43 | 44 | return $http.get(route, { 45 | params: { 46 | query: query 47 | } 48 | }) 49 | } 50 | } 51 | } 52 | 53 | return recipeService; 54 | }]); 55 | })(); -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Views/Details.html: -------------------------------------------------------------------------------- 1 | 
2 | 3 |
4 | 5 |

6 | 7 |
8 |

Description:

9 |

10 |
11 | 12 |
13 |

Ingredients:

14 |

15 |
16 | 17 |

18 |

19 | Cook time: {{ model.recipe.cookTime }}, 20 |
21 |
22 | Prep time : {{ model.recipe.prepTime }}, 23 |
24 |
25 | Yield: {{ model.recipe.recipeYield }} 26 |
27 |

28 | 29 | More like this recipe -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Views/Main.html: -------------------------------------------------------------------------------- 1 | 
2 |

Elasticsearch Recipes

3 | 4 |

5 | Supported queries: wildcard (exampl*/exa?ple), must_not (-example), must (example), match_phrase ("example"), must_not match_phrase (-"example"), proximity (examlpe~1), boosting (example^2) 6 | and every other QueryStringQuery option. 7 |

8 | 9 |
10 | Page size 11 | 12 | 18 | 19 | 21 | 22 | 23 |
24 | 25 |
26 | 27 |
28 |
-------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Views/MoreLikeThis.html: -------------------------------------------------------------------------------- 1 | 
2 |
3 | Took: {{ model.searchData.elapsedMilliseconds }}ms, Page: {{ model.searchData.page }}, Results count: {{ model.searchData.total }} 4 |
5 | 6 |

Recipes like "{{ model.currentRecipeName }}"

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 32 | 33 |
IdNameDescriptionIngredientsPrep TimeLinkMore like this
{{ recipe.id }}{{ recipe.name }}{{ recipe.description }}{{ recipe.ingredients }}{{ recipe.prepTime }} 27 | Go to recipe 28 | 30 | More like this 31 |
34 | 35 |
    36 |
    -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/Views/SearchResult.html: -------------------------------------------------------------------------------- 1 | 
    2 |
    3 |
    4 |
    5 | 6 |
    7 |
    8 | Took: {{ model.searchResult.elapsedMilliseconds }}ms, Page: {{ model.searchResult.page }}, Results count: {{ model.searchResult.total }} 9 |
    10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 35 | 36 |
    IdNameDescriptionIngredientsPrep TimeLinkMore like this
    {{ recipe.id }}{{ recipe.name }}{{ recipe.description }}{{ recipe.ingredients }}{{ recipe.prepTime }} 30 | Go to recipe 31 | 33 | More like this 34 |
    37 | 38 |
      39 |
      40 |
      -------------------------------------------------------------------------------- /src/ElasticsearchRecipes/wwwroot/index.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | Elasticsearch Recipes 7 | 8 | 9 | 10 | 11 |
      12 |
      13 |
      14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | --------------------------------------------------------------------------------