├── .gitignore ├── .paket ├── Paket.Restore.targets ├── paket.exe └── paket.targets ├── LICENSE ├── README.md ├── build.cmd ├── build.fsx ├── houseprice-sales.sln ├── package-lock.json ├── package.json ├── paket.dependencies ├── paket.lock ├── src ├── arm-template.json ├── client │ ├── App.fs │ ├── Style.fs │ ├── client.fsproj │ ├── index.html │ ├── pages │ │ ├── Details.fs │ │ ├── Filter.fs │ │ ├── Search.fs │ │ └── Shared.fs │ ├── paket.references │ ├── public │ │ └── fable.ico │ └── webpack.config.js ├── scripts │ ├── azure-schema.json │ ├── importdata.fsx │ ├── price-paid-schema.csv │ └── uk-postcodes-schema.csv └── server │ ├── Configuration.fs │ ├── Contracts.fs │ ├── FableJson.fs │ ├── Program.fs │ ├── Properties │ └── launchSettings.json │ ├── Routing.fs │ ├── Search.Azure.fs │ ├── Search.InMemory.fs │ ├── Search.fs │ ├── Storage.fs │ ├── paket.references │ └── server.fsproj └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | #Ignore thumbnails created by Windows 3 | Thumbs.db 4 | #Ignore files built by Visual Studio 5 | *.obj 6 | *.exe 7 | *.pdb 8 | *.user 9 | *.aps 10 | *.pch 11 | *.vspscc 12 | *_i.c 13 | *_p.c 14 | *.ncb 15 | *.suo 16 | *.tlb 17 | *.tlh 18 | *.bak 19 | *.cache 20 | *.ilk 21 | *.log 22 | [Bb]in 23 | [Dd]ebug*/ 24 | *.lib 25 | *.sbr 26 | obj/ 27 | [Rr]elease*/ 28 | _ReSharper*/ 29 | [Tt]est[Rr]esult* 30 | .vs/ 31 | #Nuget packages folder 32 | packages/ 33 | .paket/ 34 | paket-files/paket.restore.cached 35 | /src/client/node_modules/ 36 | /appSettings.json 37 | /src/client/public 38 | /node_modules/ 39 | /.fake/ 40 | /src/server/appSettings.json 41 | /src/server/properties.json 42 | /paket-files/ 43 | /ukpostcodes.csv 44 | /src/server/*appSettings.json 45 | -------------------------------------------------------------------------------- /.paket/Paket.Restore.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | $(MSBuildAllProjects);$(MSBuildThisFileFullPath) 8 | 9 | true 10 | $(MSBuildThisFileDirectory) 11 | $(MSBuildThisFileDirectory)..\ 12 | $(PaketRootPath)paket-files\paket.restore.cached 13 | $(PaketRootPath)paket.lock 14 | /Library/Frameworks/Mono.framework/Commands/mono 15 | mono 16 | 17 | $(PaketRootPath)paket.exe 18 | $(PaketToolsPath)paket.exe 19 | "$(PaketExePath)" 20 | $(MonoPath) --runtime=v4.0.30319 "$(PaketExePath)" 21 | 22 | 23 | <_PaketExeExtension>$([System.IO.Path]::GetExtension("$(PaketExePath)")) 24 | dotnet "$(PaketExePath)" 25 | 26 | 27 | "$(PaketExePath)" 28 | 29 | $(PaketRootPath)paket.bootstrapper.exe 30 | $(PaketToolsPath)paket.bootstrapper.exe 31 | "$(PaketBootStrapperExePath)" 32 | $(MonoPath) --runtime=v4.0.30319 "$(PaketBootStrapperExePath)" 33 | 34 | 35 | 36 | 37 | true 38 | true 39 | 40 | 41 | 42 | 43 | 44 | 45 | true 46 | $(NoWarn);NU1603 47 | 48 | 49 | 50 | 51 | /usr/bin/shasum $(PaketRestoreCacheFile) | /usr/bin/awk '{ print $1 }' 52 | /usr/bin/shasum $(PaketLockFilePath) | /usr/bin/awk '{ print $1 }' 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | $([System.IO.File]::ReadAllText('$(PaketRestoreCacheFile)')) 66 | $([System.IO.File]::ReadAllText('$(PaketLockFilePath)')) 67 | true 68 | false 69 | true 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | $(MSBuildProjectDirectory)\obj\$(MSBuildProjectFile).paket.references.cached 79 | 80 | $(MSBuildProjectFullPath).paket.references 81 | 82 | $(MSBuildProjectDirectory)\$(MSBuildProjectName).paket.references 83 | 84 | $(MSBuildProjectDirectory)\paket.references 85 | $(MSBuildProjectDirectory)\obj\$(MSBuildProjectFile).$(TargetFramework).paket.resolved 86 | true 87 | references-file-or-cache-not-found 88 | 89 | 90 | 91 | 92 | $([System.IO.File]::ReadAllText('$(PaketReferencesCachedFilePath)')) 93 | $([System.IO.File]::ReadAllText('$(PaketOriginalReferencesFilePath)')) 94 | references-file 95 | false 96 | 97 | 98 | 99 | 100 | false 101 | 102 | 103 | 104 | 105 | true 106 | target-framework '$(TargetFramework)' 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | $([System.String]::Copy('%(PaketReferencesFileLines.Identity)').Split(',')[0]) 124 | $([System.String]::Copy('%(PaketReferencesFileLines.Identity)').Split(',')[1]) 125 | $([System.String]::Copy('%(PaketReferencesFileLines.Identity)').Split(',')[4]) 126 | 127 | 128 | %(PaketReferencesFileLinesInfo.PackageVersion) 129 | All 130 | 131 | 132 | 133 | 134 | $(MSBuildProjectDirectory)/obj/$(MSBuildProjectFile).paket.clitools 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | $([System.String]::Copy('%(PaketCliToolFileLines.Identity)').Split(',')[0]) 144 | $([System.String]::Copy('%(PaketCliToolFileLines.Identity)').Split(',')[1]) 145 | 146 | 147 | %(PaketCliToolFileLinesInfo.PackageVersion) 148 | 149 | 150 | 151 | 155 | 156 | 157 | 158 | 159 | 160 | false 161 | 162 | 163 | 164 | 165 | 166 | <_NuspecFilesNewLocation Include="$(BaseIntermediateOutputPath)$(Configuration)\*.nuspec"/> 167 | 168 | 169 | 170 | $(MSBuildProjectDirectory)/$(MSBuildProjectFile) 171 | true 172 | false 173 | true 174 | $(BaseIntermediateOutputPath)$(Configuration) 175 | $(BaseIntermediateOutputPath) 176 | 177 | 178 | 179 | <_NuspecFiles Include="$(AdjustedNuspecOutputPath)\*.nuspec"/> 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 232 | 233 | 274 | 275 | 276 | 277 | -------------------------------------------------------------------------------- /.paket/paket.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAFE-Stack/SAFE-Search/5c6b04188c5cd4cefdc2d855369be2c156e90a76/.paket/paket.exe -------------------------------------------------------------------------------- /.paket/paket.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | $(MSBuildThisFileDirectory) 8 | $(MSBuildThisFileDirectory)..\ 9 | $(PaketRootPath)paket.lock 10 | $(PaketRootPath)paket-files\paket.restore.cached 11 | /Library/Frameworks/Mono.framework/Commands/mono 12 | mono 13 | 14 | 15 | 16 | 17 | $(PaketRootPath)paket.exe 18 | $(PaketToolsPath)paket.exe 19 | "$(PaketExePath)" 20 | $(MonoPath) --runtime=v4.0.30319 "$(PaketExePath)" 21 | 22 | 23 | 24 | 25 | 26 | $(MSBuildProjectFullPath).paket.references 27 | 28 | 29 | 30 | 31 | $(MSBuildProjectDirectory)\$(MSBuildProjectName).paket.references 32 | 33 | 34 | 35 | 36 | $(MSBuildProjectDirectory)\paket.references 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | $(PaketCommand) restore --references-file "$(PaketReferences)" 49 | 50 | RestorePackages; $(BuildDependsOn); 51 | 52 | 53 | 54 | true 55 | 56 | 57 | 58 | $([System.IO.File]::ReadAllText('$(PaketRestoreCacheFile)')) 59 | $([System.IO.File]::ReadAllText('$(PaketLockFilePath)')) 60 | true 61 | false 62 | true 63 | 64 | 65 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This repo is deprecated 2 | 3 | Please see https://github.com/CompositionalIT/safe-search-3 for a more recent sample. 4 | 5 | # houseprice-sales 6 | 7 | This is a SAFE F# project (Suave model, Azure, Fable, Elmish) designed to show integration of Azure Search and Storage within an Elmish application using Bootstrap. 8 | 9 | ## Running locally 10 | 11 | To run locally, simply execute `build.cmd`. This will build and run both server and client, as well as downloading the first 1000 transactions from the latest online property dataset and store it locally. Using a local file, the search experience is reduced and there is no support for postcode matching, but it is a quick way to use the app. 12 | 13 | ### WARNING! 14 | Many issues around data and azure need to be addressed (please see issues list). This will be updated in the coming days / weeks. In the meantime, there are a few up-for-grabs issues if someone does want to help out at this early stage. 15 | -------------------------------------------------------------------------------- /build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | cls 3 | 4 | .paket\paket.exe restore 5 | if errorlevel 1 ( 6 | exit /b %errorlevel% 7 | ) 8 | 9 | packages\build\FAKE\tools\FAKE.exe build.fsx %* -------------------------------------------------------------------------------- /build.fsx: -------------------------------------------------------------------------------- 1 | // -------------------------------------------------------------------------------------- 2 | // FAKE build script 3 | // -------------------------------------------------------------------------------------- 4 | 5 | #I @".\packages\Newtonsoft.Json\lib\netstandard1.3" 6 | #r @"packages/build/FAKE/tools/FakeLib.dll" 7 | #r @"packages/build/Microsoft.Rest.ClientRuntime.Azure/lib/netstandard1.4/Microsoft.Rest.ClientRuntime.Azure.dll" 8 | #load @"src\scripts\importdata.fsx" 9 | @".paket\load\netstandard2.0\Build\build.group.fsx" 10 | @"paket-files\build\CompositionalIT\fshelpers\src\FsHelpers\ArmHelper\ArmHelper.fs" 11 | 12 | open Cit.Helpers.Arm 13 | open Cit.Helpers.Arm.Parameters 14 | open Fake 15 | open System 16 | open System.IO 17 | open Microsoft.IdentityModel.Clients.ActiveDirectory 18 | open Microsoft.Azure.Management.ResourceManager.Fluent 19 | open Microsoft.Azure.Management.ResourceManager.Fluent.Core 20 | 21 | let project = "Property Mapper" 22 | let summary = "A project to illustrate use of Azure Search to map UK property sales using SAFE." 23 | let description = summary 24 | 25 | let clientPath = FullName "./src/Client" 26 | let serverPath = FullName "./src/Server/" 27 | let dotnetcliVersion = DotNetCli.getVersion() 28 | let mutable dotnetExePath = "dotnet" 29 | let deployDir = "./deploy" 30 | 31 | // -------------------------------------------------------------------------------------- 32 | // END TODO: The rest of the file includes standard build steps 33 | // -------------------------------------------------------------------------------------- 34 | 35 | let run cmd args dir = 36 | if execProcess (fun info -> 37 | info.FileName <- cmd 38 | if not (String.IsNullOrWhiteSpace dir) then 39 | info.WorkingDirectory <- dir 40 | info.Arguments <- args 41 | ) System.TimeSpan.MaxValue |> not then 42 | failwithf "Error while running '%s' with args: %s" cmd args 43 | 44 | let runDotnet workingDir args = 45 | let result = 46 | ExecProcess (fun info -> 47 | info.FileName <- dotnetExePath 48 | info.WorkingDirectory <- workingDir 49 | info.Arguments <- args) TimeSpan.MaxValue 50 | if result <> 0 then failwithf "dotnet %s failed" args 51 | 52 | let platformTool tool winTool = 53 | let tool = if isUnix then tool else winTool 54 | tool 55 | |> ProcessHelper.tryFindFileOnPath 56 | |> function Some t -> t | _ -> failwithf "%s not found" tool 57 | 58 | let nodeTool = platformTool "node" "node.exe" 59 | let npmTool = platformTool "npm" "npm.cmd" 60 | let yarnTool = platformTool "yarn" "yarn.cmd" 61 | 62 | do if not isWindows then 63 | // We have to set the FrameworkPathOverride so that dotnet sdk invocations know 64 | // where to look for full-framework base class libraries 65 | let mono = platformTool "mono" "mono" 66 | let frameworkPath = IO.Path.GetDirectoryName(mono) ".." "lib" "mono" "4.5" 67 | setEnvironVar "FrameworkPathOverride" frameworkPath 68 | 69 | // -------------------------------------------------------------------------------------- 70 | // Clean build results 71 | 72 | Target "Clean" (fun _ -> 73 | !!"src/**/bin" |> CleanDirs 74 | !! "src/**/obj/*.nuspec" |> DeleteFiles 75 | CleanDirs ["bin"; "temp"; "docs/output"; deployDir; Path.Combine(clientPath,"public/bundle")]) 76 | 77 | Target "InstallDotNetCore" (fun _ -> dotnetExePath <- DotNetCli.InstallDotNetSDK dotnetcliVersion) 78 | 79 | // -------------------------------------------------------------------------------------- 80 | // Build library 81 | 82 | Target "BuildServer" (fun _ -> runDotnet serverPath "build") 83 | 84 | Target "InstallClient" (fun _ -> 85 | printfn "Node version:" 86 | run nodeTool "--version" __SOURCE_DIRECTORY__ 87 | printfn "Yarn version:" 88 | run yarnTool "--version" __SOURCE_DIRECTORY__ 89 | run yarnTool "install --frozen-lockfile" __SOURCE_DIRECTORY__) 90 | 91 | Target "BuildClient" (fun _ -> 92 | runDotnet clientPath "restore" 93 | runDotnet clientPath "fable webpack -- -p") 94 | 95 | // -------------------------------------------------------------------------------------- 96 | // Azure Deployment 97 | 98 | Target "DeployArmTemplate" <| fun _ -> 99 | let armTemplate = @"src\arm-template.json" 100 | let environment = getBuildParamOrDefault "environment" (Guid.NewGuid().ToString().ToLower().Split '-' |> Array.head) 101 | let resourceGroupName = sprintf "safe-property-mapper" 102 | 103 | tracefn "Deploying template '%s' to resource group '%s'..." armTemplate resourceGroupName 104 | 105 | let deployment = 106 | { DeploymentName = "FAKE-PropertyMapper-Deploy" 107 | ResourceGroup = ResourceGroupType.New(resourceGroupName, Microsoft.Azure.Management.ResourceManager.Fluent.Core.Region.EuropeWest) 108 | ArmTemplate = File.ReadAllText armTemplate 109 | Parameters = 110 | [ "environment", environment 111 | "searchSize", getBuildParam "searchSize" 112 | "webServerSize", getBuildParam "webServerSize" 113 | "alwaysOn", getBuildParam "alwaysOn" ] 114 | |> List.choose(fun (k, v) -> if String.IsNullOrWhiteSpace v then None else Some (k, ArmString v)) 115 | |> Parameters.Simple 116 | DeploymentMode = Incremental } 117 | 118 | let authCtx = 119 | match getBuildParam "subscriptionId", (getBuildParam "clientId", getBuildParam "clientSecret", getBuildParam "tenantId") with 120 | | "", _ -> failwith "No subscription id supplied!" 121 | | subscriptionId, ("", _, _ | _, "", _ | _, _, "")-> 122 | let client = AuthenticationContext "https://login.microsoftonline.com/common" 123 | async { 124 | let! deviceResult = client.AcquireDeviceCodeAsync("https://management.core.windows.net/", "84edda74-cb5b-49e3-99b3-82d1c419e651") |> Async.AwaitTask 125 | tracefn "%s" deviceResult.Message 126 | let! tokenResult = client.AcquireTokenByDeviceCodeAsync(deviceResult) |> Async.AwaitTask 127 | let client = RestClient.Configure().WithEnvironment(AzureEnvironment.AzureGlobalCloud).WithCredentials(Microsoft.Rest.TokenCredentials(tokenResult.AccessToken)).Build() 128 | return ResourceManager.Authenticate(client).WithSubscription subscriptionId |> AuthenticatedContext } |> Async.RunSynchronously 129 | | subscriptionId, (clientId, clientSecret, tenantId) -> 130 | let authCredentials = 131 | { ClientId = Guid.Parse clientId 132 | ClientSecret = clientSecret 133 | TenantId = Guid.Parse tenantId } 134 | authenticate authCredentials (Guid.Parse subscriptionId) 135 | 136 | deployment 137 | |> deployWithProgress authCtx 138 | |> Seq.iter(function 139 | | DeploymentInProgress (state, operations) -> tracefn "State is %s, completed %d operations." state operations 140 | | DeploymentError (statusCode, message) -> traceError <| sprintf "DEPLOYMENT ERROR: %s - '%s'" statusCode message 141 | | DeploymentCompleted _ -> ()) 142 | 143 | // -------------------------------------------------------------------------------------- 144 | // Data Import 145 | open PropertyMapper 146 | open Importdata 147 | 148 | let (|Local|Cloud|) (x:string) = 149 | match x.ToLower() with 150 | | "cloud" -> Cloud 151 | | _ -> Local 152 | 153 | Target "ImportData" (fun _ -> 154 | let mode = getBuildParam "DataMode" 155 | 156 | // Insert postcode / geo lookup 157 | if not (fileExists (FullName "ukpostcodes.csv")) then 158 | log "Downloading transaction data..." 159 | let txns = fetchTransactions 1000 160 | 161 | log "Downloading geolocation data..." 162 | 163 | let archivePath = "ukpostcodes.zip" 164 | do 165 | use wc = new Net.WebClient() 166 | wc.DownloadFile(Uri "https://www.freemaptools.com/download/full-postcodes/ukpostcodes.zip", archivePath) 167 | archivePath |> Unzip "." 168 | DeleteFile archivePath 169 | 170 | let postCodes = 171 | let loadedPostcodes = txns |> Array.Parallel.choose(fun t -> t.Address.PostCode) |> Set 172 | fetchPostcodes (FullName "ukpostcodes.csv") 173 | |> Array.filter(fun (r:Importdata.GeoPostcode) -> loadedPostcodes.Contains r.PostCodeDescription) 174 | 175 | let tryFindGeo = postCodes |> Seq.map(fun r -> r.PostCodeDescription, (r.Latitude, r.Longitude)) |> Map.ofSeq |> fun m -> m.TryFind 176 | let insertPostcodeLookup (ConnectionString connectionString) = 177 | postCodes 178 | |> insertPostcodes connectionString 179 | |> Array.collect snd 180 | |> Array.countBy(function FSharp.Azure.StorageTypeProvider.Table.SuccessfulResponse _ -> "Success" | _ -> "Failed") 181 | |> logfn "%A" 182 | 183 | log "Now inserting property transactions into search index and creating postcode lookup..." 184 | match mode with 185 | | Local -> 186 | let path = serverPath "properties.json" 187 | File.WriteAllText(path, FableJson.toJson txns) 188 | AzureHelper.StartStorageEmulator() 189 | insertPostcodeLookup (ConnectionString "UseDevelopmentStorage=true") 190 | 191 | | Cloud -> 192 | let config = 193 | { AzureSearchServiceName = "" 194 | AzureStorage = ConnectionString "" 195 | AzureSearch = ConnectionString "" } 196 | 197 | Search.Azure.Management.initialize config 198 | txns 199 | |> Search.Azure.insertProperties config tryFindGeo 200 | |> fun t -> t.Result 201 | |> logf "%A" 202 | 203 | insertPostcodeLookup config.AzureStorage) 204 | 205 | // -------------------------------------------------------------------------------------- 206 | // Run the Website 207 | 208 | let ipAddress = "localhost" 209 | let port = 8080 210 | 211 | FinalTarget "KillProcess" (fun _ -> 212 | killProcess "dotnet" 213 | killProcess "dotnet.exe") 214 | 215 | Target "Run" (fun _ -> 216 | runDotnet clientPath "restore" 217 | 218 | let server = async { runDotnet serverPath "run" } 219 | let fablewatch = async { runDotnet clientPath "fable webpack-dev-server" } 220 | let openBrowser = async { 221 | System.Threading.Thread.Sleep(5000) 222 | Diagnostics.Process.Start("http://"+ ipAddress + sprintf ":%d" port) |> ignore } 223 | 224 | Async.Parallel [| server; fablewatch; openBrowser |] 225 | |> Async.RunSynchronously 226 | |> ignore) 227 | 228 | Target "BundleClient" (fun _ -> 229 | let result = 230 | ExecProcess (fun info -> 231 | info.FileName <- dotnetExePath 232 | info.WorkingDirectory <- serverPath 233 | info.Arguments <- "publish -c Release -o \"" + FullName deployDir + "\"") TimeSpan.MaxValue 234 | if result <> 0 then failwith "Publish failed" 235 | 236 | let clientDir = deployDir "client" 237 | let publicDir = clientDir "public" 238 | let jsDir = clientDir "js" 239 | let cssDir = clientDir "css" 240 | let imageDir = clientDir "Images" 241 | 242 | !! "src/Client/public/**/*.*" |> CopyFiles publicDir 243 | !! "src/Client/js/**/*.*" |> CopyFiles jsDir 244 | !! "src/Client/css/**/*.*" |> CopyFiles cssDir 245 | !! "src/Client/Images/**/*.*" |> CopyFiles imageDir 246 | 247 | "src/Client/index.html" |> CopyFile clientDir) 248 | 249 | // ------------------------------------------------------------------------------------- 250 | Target "Build" DoNothing 251 | Target "All" DoNothing 252 | 253 | "Clean" 254 | ==> "InstallDotNetCore" 255 | ==> "InstallClient" 256 | ==> "BuildServer" 257 | ==> "BuildClient" 258 | ==> "BundleClient" 259 | ==> "All" 260 | 261 | "BuildClient" 262 | ==> "Build" 263 | 264 | "InstallClient" 265 | ==> "ImportData" 266 | ==> "Run" 267 | 268 | RunTargetOrDefault "Run" -------------------------------------------------------------------------------- /houseprice-sales.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27130.2027 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "client", "src\client\client.fsproj", "{8A7FB08D-11BF-4EFC-AF2F-5C0B5CFF1BF0}" 7 | EndProject 8 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "server", "src\server\server.fsproj", "{666F1FE8-BB94-4BB5-BF63-8B90AAB41AD3}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{CB4357BB-47B5-47D5-9143-40B5C6785A4C}" 11 | ProjectSection(SolutionItems) = preProject 12 | src\arm-template.json = src\arm-template.json 13 | build.fsx = build.fsx 14 | src\scripts\importdata.fsx = src\scripts\importdata.fsx 15 | paket.dependencies = paket.dependencies 16 | EndProjectSection 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Release|Any CPU = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {8A7FB08D-11BF-4EFC-AF2F-5C0B5CFF1BF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {8A7FB08D-11BF-4EFC-AF2F-5C0B5CFF1BF0}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {8A7FB08D-11BF-4EFC-AF2F-5C0B5CFF1BF0}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {8A7FB08D-11BF-4EFC-AF2F-5C0B5CFF1BF0}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {666F1FE8-BB94-4BB5-BF63-8B90AAB41AD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {666F1FE8-BB94-4BB5-BF63-8B90AAB41AD3}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {666F1FE8-BB94-4BB5-BF63-8B90AAB41AD3}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {666F1FE8-BB94-4BB5-BF63-8B90AAB41AD3}.Release|Any CPU.Build.0 = Release|Any CPU 32 | EndGlobalSection 33 | GlobalSection(SolutionProperties) = preSolution 34 | HideSolutionNode = FALSE 35 | EndGlobalSection 36 | GlobalSection(ExtensibilityGlobals) = postSolution 37 | SolutionGuid = {6AA64DCF-9BA6-4D4C-8190-0B41AC4D4BC7} 38 | EndGlobalSection 39 | EndGlobal 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "babel-polyfill": "6.26.0", 5 | "babel-runtime": "6.26.0", 6 | "react": "16.1.1", 7 | "react-bootstrap": "0.31.5", 8 | "react-dom": "16.1.1", 9 | "remotedev": "0.2.7" 10 | }, 11 | "devDependencies": { 12 | "babel-core": "6.26.0", 13 | "babel-loader": "7.1.2", 14 | "babel-plugin-transform-runtime": "6.23.0", 15 | "babel-preset-env": "1.6.1", 16 | "concurrently": "3.5.1", 17 | "fable-loader": "1.1.6", 18 | "fable-utils": "1.0.6", 19 | "webpack": "3.8.1", 20 | "webpack-dev-server": "2.9.4" 21 | }, 22 | "scripts": { 23 | "prebuildServer": "dotnet restore src/Server/Server.fsproj", 24 | "buildServer": "dotnet build src/Server/Server.fsproj", 25 | "restoreClient": "cd src/Client && yarn install", 26 | "restoreNetClient": "dotnet restore src/Client/Client.fsproj", 27 | "prestartClient": "concurrently \"npm run restoreClient\" \"npm run restoreNetClient\" ", 28 | "startClient": "cd src/Client && dotnet fable webpack-dev-server" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /paket.dependencies: -------------------------------------------------------------------------------- 1 | generate_load_scripts: true 2 | source https://www.nuget.org/api/v2/ 3 | 4 | nuget Giraffe.Razor 5 | nuget Microsoft.AspNetCore.Authentication.Cookies 6 | nuget Microsoft.AspNetCore.Server.IISIntegration 7 | nuget Microsoft.AspNetCore.Server.Kestrel 8 | nuget Microsoft.AspNetCore.StaticFiles 9 | nuget Microsoft.Extensions.Logging.Console 10 | nuget Microsoft.Extensions.Logging.Debug 11 | nuget Microsoft.Extensions.Configuration.Json 12 | nuget Microsoft.Azure.Search 13 | nuget WindowsAzure.Storage 14 | nuget Fable.JsonConverter 15 | nuget Fable.Elmish.Browser 16 | nuget Fable.Elmish.Debugger 17 | nuget Fable.Elmish.React 18 | nuget Fable.Elmish.HMR 19 | clitool Microsoft.DotNet.Watcher.Tools 20 | clitool dotnet-fable 21 | github CompositionalIT/fshelpers src/FsHelpers/Elmish.Debounce/Elmish.Debounce.fs 22 | 23 | group Build 24 | generate_load_scripts: true 25 | source https://www.nuget.org/api/v2/ 26 | github CompositionalIT/fshelpers src/FsHelpers/ArmHelper/ArmHelper.fs 27 | github CompositionalIT/fshelpers src/FsHelpers/FSharpCore/String.fs 28 | nuget FSharp.Data 29 | nuget FSharp.Azure.StorageTypeProvider 30 | nuget Fake 31 | -------------------------------------------------------------------------------- /src/arm-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "webServerSize": { 6 | "type": "string", 7 | "defaultValue": "F1" 8 | }, 9 | "searchSize": { 10 | "type": "string", 11 | "defaultValue": "free", 12 | "allowedValues": [ 13 | "free", 14 | "basic", 15 | "standard", 16 | "standard2", 17 | "standard3" 18 | ] 19 | }, 20 | "environment": { 21 | "type": "string" 22 | }, 23 | "alwaysOn": { 24 | "type": "string", 25 | "defaultValue": "Off", 26 | "allowedValues": [ 27 | "On", 28 | "Off" 29 | ] 30 | } 31 | }, 32 | "variables": { 33 | "appHost": "[concat('houseprice-apphost-', parameters('environment'))]", 34 | "webapp": "[concat('houseprice-web-', parameters('environment'))]", 35 | "storage": "[concat('housepricedata', parameters('environment'))]", 36 | "search": "[concat('houseprice-search-', parameters('environment'))]", 37 | "location": "West Europe" 38 | }, 39 | "resources": [ 40 | { 41 | "type": "Microsoft.Storage/storageAccounts", 42 | "apiVersion": "2016-01-01", 43 | "kind": "Storage", 44 | "location": "[variables('location')]", 45 | "name": "[variables('storage')]", 46 | "sku": { 47 | "name": "Standard_LRS" 48 | }, 49 | "properties": {} 50 | }, 51 | { 52 | "type": "Microsoft.Search/searchServices", 53 | "sku": { 54 | "name": "[parameters('searchSize')]" 55 | }, 56 | "name": "[variables('search')]", 57 | "apiVersion": "2015-08-19", 58 | "location": "[variables('location')]", 59 | "properties": { 60 | "replicaCount": 1, 61 | "partitionCount": 1, 62 | "hostingMode": "default" 63 | }, 64 | "resources": [], 65 | "dependsOn": [] 66 | }, 67 | { 68 | "type": "Microsoft.Web/serverfarms", 69 | "sku": { 70 | "name": "[parameters('webServerSize')]" 71 | }, 72 | "location": "[variables('location')]", 73 | "name": "[variables('appHost')]", 74 | "apiVersion": "2015-08-01", 75 | "properties": { 76 | "name": "[variables('appHost')]", 77 | "numberOfWorkers": 1 78 | } 79 | }, 80 | { 81 | "type": "Microsoft.Web/sites", 82 | "apiVersion": "2015-08-01", 83 | "dependsOn": [ 84 | "[concat('Microsoft.Web/serverfarms/', variables('appHost'))]", 85 | "[concat('Microsoft.Storage/storageAccounts/', variables('storage'))]" 86 | ], 87 | "name": "[variables('webapp')]", 88 | "location": "[variables('location')]", 89 | "properties": { 90 | "serverFarmId": "[variables('appHost')]", 91 | "name": "[variables('webapp')]", 92 | "siteConfig": { 93 | "connectionStrings": [ 94 | { 95 | "name": "AzureStorage", 96 | "connectionString": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storage'), ';AccountKey=', listKeys(concat('Microsoft.Storage/storageAccounts/', variables('storage')), '2016-01-01').keys[0].value)]", 97 | "type": 3 98 | } 99 | ], 100 | "AlwaysOn": "[parameters('alwaysOn')]" 101 | } 102 | }, 103 | "resources": [ 104 | { 105 | "name": "appsettings", 106 | "apiVersion": "2015-08-01", 107 | "type": "config", 108 | "properties": { 109 | "AzureSearchName": "[variables('search')]", 110 | "AzureSearchApiKey": "[listAdminKeys(concat('Microsoft.Search/searchServices/', variables('search')), '2015-08-19').primaryKey]", 111 | "AzureStorageAccountName": "[concat('https://',variables('storage'),'.blob.core.windows.net')]" 112 | }, 113 | "tags": { 114 | "displayName": "WebAppSettings" 115 | }, 116 | "dependsOn": [ 117 | "[concat('Microsoft.Web/sites/', variables('webapp'))]", 118 | "[concat('Microsoft.Search/searchServices/', variables('search'))]" 119 | ] 120 | } 121 | ] 122 | } 123 | ], 124 | "outputs": { 125 | "azureStorageConnectionString": { 126 | "type": "string", 127 | "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storage'), ';AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts/', variables('storage')), '2016-01-01').keys[0].value)]" 128 | }, 129 | "searchServiceName": { 130 | "type": "string", 131 | "value": "[variables('search')]" 132 | }, 133 | "azureSearchAdminKey": { 134 | "type": "string", 135 | "value": "[listAdminKeys(resourceId('Microsoft.Search/searchServices/', variables('search')), '2015-08-19').primaryKey]" 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /src/client/App.fs: -------------------------------------------------------------------------------- 1 | module App 2 | 3 | open Elmish 4 | open Elmish.React 5 | open Elmish.Browser.Navigation 6 | open Elmish.HMR 7 | open Elmish.Debug 8 | open Fable.Core 9 | open Fable.Helpers.React 10 | open Fable.Helpers.React.Props 11 | open Pages 12 | 13 | type Model = 14 | { SearchModel : Search.Model } 15 | 16 | type AppMsg = 17 | | SearchMsg of Search.Msg 18 | 19 | let update msg model = 20 | match msg with 21 | | SearchMsg msg -> 22 | let searchModel, searchCmd = Search.update msg model.SearchModel 23 | { model with SearchModel = searchModel }, Cmd.map SearchMsg searchCmd 24 | 25 | let init _ = 26 | let model = 27 | { SearchModel = Search.init () } 28 | model, Cmd.none 29 | 30 | let view model dispatch = 31 | div [ ClassName "container-fluid" ] [ 32 | div [ ClassName "row" ] [ div [ ClassName "col" ] [ h1 [] [ str "Property Search" ] ] ] 33 | div [ ClassName "row" ] [ Search.view model.SearchModel (SearchMsg >> dispatch) ] 34 | ] 35 | 36 | JsInterop.importSideEffects "whatwg-fetch" 37 | JsInterop.importSideEffects "babel-polyfill" 38 | 39 | // App 40 | Program.mkProgram init update view 41 | |> Program.toNavigable (fun _ -> "#home") (fun _ m -> m, Cmd.none) 42 | |> Program.withConsoleTrace 43 | |> Program.withHMR 44 | |> Program.withReact "elmish-app" 45 | |> Program.withDebugger 46 | |> Program.run -------------------------------------------------------------------------------- /src/client/Style.fs: -------------------------------------------------------------------------------- 1 | module Client.Style 2 | 3 | open Fable.Helpers.React.Props 4 | open Fable.Core 5 | open Fable.Core.JsInterop 6 | open Fable.Import 7 | 8 | module KeyCode = 9 | let enter = 13. 10 | let upArrow = 38. 11 | let downArrow = 40. 12 | 13 | module R = Fable.Helpers.React 14 | 15 | let buttonLink cssClass onClick elements = 16 | R.a [ ClassName cssClass 17 | OnClick (fun _ -> onClick()) 18 | OnTouchStart (fun _ -> onClick()) 19 | Style [ !!("cursor", "pointer") ] ] elements 20 | 21 | let onKeyDown keyCodeActions = 22 | OnKeyDown (fun (ev:React.KeyboardEvent) -> 23 | keyCodeActions 24 | |> List.tryFind (fst >> (=) ev.keyCode) 25 | |> Option.iter (fun (keyCode, action) -> 26 | ev.preventDefault() 27 | action ev)) 28 | -------------------------------------------------------------------------------- /src/client/client.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | House Price Finder 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/client/pages/Details.fs: -------------------------------------------------------------------------------- 1 | module Pages.Details 2 | 3 | open Fable.Helpers.React 4 | open Fable.Helpers.React.Props 5 | open PropertyMapper.Contracts 6 | open Fable 7 | 8 | let private content txn = 9 | let field name value = 10 | value 11 | |> Option.map(fun v -> 12 | div [ ClassName "form-group row" ] [ 13 | label [ HtmlFor name; ClassName "col-sm-4 col-form-label" ] [ str name ] 14 | div [ ClassName "col-sm-8" ] [ input [ Type "text"; ClassName "form-control-plaintext"; Id name; Value v; ReadOnly true ] ] 15 | ]) 16 | |> Option.defaultValue (div [] []) 17 | 18 | div [ ClassName "modal-content" ] [ 19 | div [ ClassName "modal-header" ] [ 20 | h5 [ ClassName "modal-title"; Id "exampleModalLabel" ] [ str txn.Address.FirstLine ] 21 | button [ Type "button"; ClassName "close"; unbox ("data-dismiss", "modal"); unbox ("aria-label", "Close") ] [ 22 | span [ unbox ("aria-hidden", true) ] [ str "x" ] 23 | ] 24 | ] 25 | div [ ClassName "modal-body" ] [ 26 | field "Building / Street" (Some txn.Address.FirstLine) 27 | field "Town" (Some txn.Address.TownCity) 28 | field "District" (Some txn.Address.District) 29 | field "County" (Some txn.Address.County) 30 | field "Locality" txn.Address.Locality 31 | field "Post Code" txn.Address.PostCode 32 | field "Price" (Some(sprintf "£%s" (txn.Price |> commaSeparate))) 33 | field "Date of Transfer" (Some (txn.DateOfTransfer.ToShortDateString())) 34 | field "Property Type" (txn.BuildDetails.PropertyType |> Option.map(fun pt -> pt.Description)) 35 | field "Build Type" (Some (string txn.BuildDetails.Build.Description)) 36 | field "Contract" (Some (string txn.BuildDetails.Contract.Description)) 37 | ] 38 | div [ ClassName "modal-footer" ] [ 39 | button [ Type "button"; ClassName "btn btn-primary"; unbox ("data-dismiss", "modal") ] [ str "Close" ] 40 | ] 41 | ] 42 | 43 | let view txn = 44 | div [ ClassName "modal fade"; Id "exampleModal"; Role "dialog"; unbox ("aria-labelledby", "exampleModalLabel"); unbox ("aria-hidden", "true") ] [ 45 | div [ ClassName "modal-dialog"; Role "document" ] (match txn with Some txn -> [ content txn ] | None -> []) 46 | ] -------------------------------------------------------------------------------- /src/client/pages/Filter.fs: -------------------------------------------------------------------------------- 1 | module Pages.Filter 2 | 3 | open Fable.Helpers.React 4 | open Fable.Helpers.React.Props 5 | open PropertyMapper.Contracts 6 | 7 | let toFilterCard dispatch facet values = 8 | match values with 9 | | [] -> div [] [] 10 | | values -> 11 | div [ ClassName "card" ] [ 12 | div [ ClassName "card-header"; Role "tab"; Id (sprintf "heading-%s" facet) ] [ 13 | h5 [ ClassName "mb-0" ] [ 14 | a [ DataToggle "collapse"; Href (sprintf "#collapse-%s" facet); AriaExpanded true ] [ str facet ] 15 | ] 16 | ] 17 | div [ Id (sprintf "collapse-%s" facet); Role "tabpanel"; ClassName "collapse show" ] [ 18 | div [ ClassName "card-body" ] [ 19 | div [ ClassName "list-group" ] 20 | [ for value in values -> 21 | button [ 22 | ClassName ("list-group-item list-group-item-action") 23 | OnClick (fun _ -> dispatch (facet, value)) ] 24 | [ str value ] ] 25 | ] 26 | ] 27 | ] 28 | 29 | let createFilters dispatch facets = 30 | div [ ClassName "container" ] [ 31 | div [ ClassName "row" ] [ h4 [] [ str "Filters" ] ] 32 | div [ ClassName "row" ] [ 33 | div [ Id "accordion"; Role "tablist" ] [ 34 | toFilterCard dispatch "County" facets.Counties 35 | toFilterCard dispatch "District" facets.Districts 36 | toFilterCard dispatch "Town" facets.Towns 37 | toFilterCard dispatch "Locality" facets.Localities 38 | ] 39 | ] 40 | ] -------------------------------------------------------------------------------- /src/client/pages/Search.fs: -------------------------------------------------------------------------------- 1 | module Pages.Search 2 | 3 | open Elmish 4 | open Fable.Core.JsInterop 5 | open Fable.Helpers.React 6 | open Fable.Helpers.React.Props 7 | open Fable.PowerPack 8 | open PropertyMapper.Contracts 9 | open System 10 | open Client.Style 11 | open Fable.Import 12 | 13 | type SearchState = Searching | Displaying 14 | 15 | type SearchParameters = { Facet : (string * string) option; Page : int; Sort : (PropertyTableColumn * SortDirection) option } 16 | type SearchResults = { SearchTerm : SearchTerm; Response : SearchResponse } 17 | type Suggestions = { SuggestedTerms : string array; SelectedSuggestion : int option; Show : bool } 18 | type Model = 19 | { Term : SearchTerm 20 | DebouncedTerm : Debounce.Model 21 | Suggestions: Suggestions 22 | LastSearch : SearchTerm 23 | Status : SearchState 24 | Parameters : SearchParameters 25 | SearchResults : SearchResults option 26 | Selected : PropertyResult option } 27 | 28 | type Msg = 29 | | SetSearch of string 30 | | DoSearch of SearchTerm 31 | | SearchBoxEnter 32 | | SetFilter of string * string 33 | | SetSort of (PropertyTableColumn * SortDirection) option 34 | | ChangePage of int 35 | | SearchCompleted of SearchTerm * SearchResponse 36 | | SearchError of exn 37 | | SelectTransaction of PropertyResult 38 | | ShowSuggestions of bool 39 | | SelectSuggestionOffset of offset:int 40 | | SelectSuggestion of index:int 41 | | FetchSuggestions of SearchTerm 42 | | ReceiveSuggestions of string array 43 | | DebounceSearchTermMsg of Debounce.Msg 44 | 45 | let emptySuggestions = { SuggestedTerms = [| |]; SelectedSuggestion = None; Show = false } 46 | 47 | let init _ = 48 | { Term = SearchTerm.Empty 49 | DebouncedTerm = Debounce.init (TimeSpan.FromMilliseconds 300.) SearchTerm.Empty 50 | Suggestions = emptySuggestions 51 | LastSearch = SearchTerm.Empty 52 | Status = SearchState.Displaying 53 | Parameters = { Facet = None; Page = 0; Sort = None } 54 | SearchResults = None 55 | Selected = None } 56 | 57 | let viewResults searchResults currentSort dispatch = 58 | let toTh col = 59 | let currentSortDisplay, nextSortDir = 60 | match currentSort with 61 | | Some (sortCol, sortDirection) when sortCol = col -> 62 | match sortDirection with 63 | | Ascending -> " ▲", Some Descending 64 | | Descending -> " ▼", None 65 | | _ -> "", Some Ascending 66 | let nextSort = nextSortDir |> Option.map (fun sortDir -> col, sortDir) 67 | th [ Scope "col"; Style [ Cursor "pointer" ]; OnClick(fun _ -> dispatch (SetSort nextSort)) ] [ str (string col + currentSortDisplay) ] 68 | let toDetailsLink row c = 69 | td [ Scope "row" ] [ 70 | a [ Href "#" 71 | DataToggle "modal" 72 | unbox ("data-target", "#exampleModal") 73 | OnClick(fun _ -> dispatch (SelectTransaction row)) ] [ str c ] 74 | ] 75 | let toTd c = td [ Scope "row" ] [ str c ] 76 | div [ ClassName "border rounded m-3 p-3 bg-light" ] [ 77 | match searchResults with 78 | | None -> yield div [ ClassName "row" ] [ div [ ClassName "col" ] [ h3 [] [ str "Please perform a search!" ] ] ] 79 | | Some { Response = { Results = [||] } } -> yield div [ ClassName "row" ] [ div [ ClassName "col" ] [ h3 [] [ str "Your search yielded no results." ] ] ] 80 | | Some { SearchTerm = term; Response = response } -> 81 | let hits = response.TotalTransactions |> Option.map (commaSeparate >> sprintf " (%s hits)") |> Option.defaultValue "" 82 | let description = 83 | match term with 84 | | Term term -> sprintf "Search results for '%s'%s." term hits 85 | | PostcodeSearch postcode -> sprintf "Showing properties within a 1km radius of '%s'%s." postcode hits 86 | 87 | yield div [ ClassName "row" ] [ 88 | div [ ClassName "col-2" ] [ Filter.createFilters (SetFilter >> dispatch) response.Facets ] 89 | div [ ClassName "col-10" ] [ 90 | div [ ClassName "row" ] [ div [ ClassName "col" ] [ h4 [] [ str description ] ] ] 91 | table [ ClassName "table table-bordered table-hover" ] [ 92 | thead [] [ 93 | tr [] [ toTh Street 94 | toTh Town 95 | toTh Postcode 96 | toTh Date 97 | toTh Price ] 98 | ] 99 | tbody [] [ 100 | for row in response.Results -> 101 | let postcodeLink = 102 | a 103 | [ Href "#"; OnClick(fun _ -> row.Address.PostCode |> Option.iter(PostcodeSearch >> DoSearch >> dispatch)) ] 104 | [ row.Address.PostCode |> Option.defaultValue "" |> str ] 105 | tr [] [ toDetailsLink row row.Address.FirstLine 106 | toTd row.Address.TownCity 107 | td [ Scope "row" ] [ postcodeLink ] 108 | toTd (row.DateOfTransfer.ToShortDateString()) 109 | toTd (sprintf "£%s" (commaSeparate row.Price)) ] 110 | ] 111 | ] 112 | nav [] [ 113 | ul [ ClassName "pagination" ] [ 114 | let buildPager enabled content current page = 115 | li [ ClassName ("page-item" + (if enabled then "" else " disabled") + (if current then " active" else "")) ] [ 116 | button [ ClassName "page-link"; Style [ Cursor "pointer" ]; OnClick (fun _ -> dispatch (ChangePage page)) ] [ str content ] 117 | ] 118 | let currentPage = response.Page 119 | let totalPages = int ((response.TotalTransactions |> Option.defaultValue 0 |> float) / 20.) 120 | yield buildPager (currentPage > 0) "Previous" false (currentPage - 1) 121 | yield! 122 | [ for page in 0 .. totalPages -> 123 | buildPager true (string (page + 1)) (page = currentPage) page ] 124 | yield buildPager (currentPage < totalPages) "Next" false (currentPage + 1) 125 | ] 126 | ] 127 | ] 128 | ] 129 | ] 130 | 131 | let viewSuggestions dispatch suggestions = 132 | match suggestions.SuggestedTerms with 133 | | _ when not suggestions.Show -> [] 134 | | [| |] -> [] 135 | | _ -> 136 | let viewSuggestion i sug = 137 | let attributes = [ 138 | yield OnMouseDown (fun _ -> dispatch (SelectSuggestion i)) :> IHTMLProp 139 | if Some i = suggestions.SelectedSuggestion then yield ClassName "bg-primary" :> IHTMLProp ] 140 | div attributes [ str sug ] 141 | [ div 142 | [ Style [ Position "absolute"; ZIndex 10. ]; ClassName "border bg-light" ] 143 | [ yield! suggestions.SuggestedTerms |> Array.mapi viewSuggestion ] ] 144 | 145 | let searchValue = "searchValue" 146 | let setSearchValue text = Browser.document.getElementById(searchValue)?value <- text 147 | 148 | let view model dispatch = 149 | let progressBarVisibility = match model.Status with | Searching -> "visible" | Displaying -> "invisible" 150 | div [ ClassName "col" ] [ 151 | Details.view model.Selected 152 | div [ ClassName "border rounded m-3 p-3 bg-light" ] [ 153 | div [ ClassName "form-group" ] [ 154 | yield label [ HtmlFor searchValue ] [ str "Search for" ] 155 | yield input [ 156 | ClassName "form-control" 157 | Id searchValue 158 | Placeholder "Enter Search" 159 | OnChange (fun ev -> dispatch (SetSearch !!ev.target?value); dispatch (ShowSuggestions true)) 160 | OnFocus (fun _ -> dispatch (ShowSuggestions true)) 161 | OnBlur (fun _ -> Browser.window.setTimeout((fun _ -> dispatch (ShowSuggestions false)), 100) |> ignore) 162 | onKeyDown [ 163 | KeyCode.upArrow, fun _ -> dispatch (SelectSuggestionOffset -1) 164 | KeyCode.downArrow, fun _ -> dispatch (SelectSuggestionOffset 1) 165 | KeyCode.enter, fun _ -> dispatch SearchBoxEnter ] 166 | ] 167 | yield! viewSuggestions dispatch model.Suggestions 168 | ] 169 | div [ ClassName "form-group" ] [ 170 | div [ ClassName ("progress " + progressBarVisibility) ] [ 171 | div [ ClassName "progress-bar progress-bar-striped progress-bar-animated" 172 | Role "progressbar" 173 | Style [ Width "100%" ] ] 174 | [ str <| sprintf "Searching for '%s'..." model.LastSearch.Description ] 175 | ] 176 | ] 177 | button [ ClassName "btn btn-primary"; OnClick (fun _ -> dispatch (DoSearch model.Term)) ] [ str "Search!" ] ] 178 | viewResults model.SearchResults model.Parameters.Sort dispatch ] 179 | 180 | let queryString parameters = 181 | [ match parameters.Facet with Some f -> yield f | None -> () 182 | match parameters.Sort with 183 | | Some (col, dir) -> yield! [ ("SortColumn", (string col)); ("SortDirection", string dir) ] 184 | | None -> () ] 185 | |> List.map (fun (key:string, value) -> sprintf "%s=%s" key value) 186 | |> String.concat "&" 187 | |> function "" -> "" | s -> "?" + s 188 | 189 | let host = "http://localhost:5000" 190 | 191 | let findTransactions (text, parameters) = 192 | Fetch.fetchAs (sprintf "%s/property/find/%s/%d%s" host text parameters.Page (queryString parameters)) [] 193 | 194 | let findByPostcode (postCode, page, parameters) = 195 | Fetch.fetchAs (sprintf "%s/property/%s/1/%d%s" host postCode page (queryString parameters)) [] 196 | 197 | let fetchSuggestions text = 198 | match text with 199 | | Term text when not (String.IsNullOrWhiteSpace text) && text.Trim().Length >= 3 -> 200 | Fetch.fetchAs (sprintf "%s/property/find-suggestion/%s" host text) [] 201 | | Term _ | PostcodeSearch _ -> promise { return { Suggestions = [||] } } 202 | 203 | let updateSelectedSuggestion offset suggestions = 204 | let count = suggestions.SuggestedTerms.Length 205 | let newSelected = 206 | match suggestions.SelectedSuggestion with 207 | | Some current -> current 208 | | None when offset > 0 -> -1 209 | | None -> count 210 | |> fun initial -> (initial + offset + count) % count 211 | { suggestions with SelectedSuggestion = Some newSelected } 212 | 213 | let update msg model : Model * Cmd = 214 | let initiateSearch model term parameters = 215 | let cmd = 216 | match term with 217 | | Term text -> Cmd.ofPromise findTransactions (text, parameters) 218 | | PostcodeSearch postcode -> Cmd.ofPromise findByPostcode (postcode, parameters.Page, parameters) 219 | let cmd = cmd (fun response -> SearchCompleted(term, response)) SearchError 220 | { model with Status = Searching; LastSearch = term; Parameters = parameters; Suggestions = emptySuggestions }, cmd 221 | let setSearchToSuggestion model i = 222 | let suggestion = model.Suggestions.SuggestedTerms |> Array.tryItem i |> Option.defaultValue "" |> sprintf "\"%s\"" 223 | setSearchValue suggestion 224 | { model with Suggestions = emptySuggestions }, Cmd.ofMsg (SetSearch suggestion) 225 | match msg with 226 | | SetSearch text -> 227 | let term = Term text 228 | { model with Term = term }, Debounce.inputCmd term DebounceSearchTermMsg 229 | | DebounceSearchTermMsg msg -> 230 | Debounce.updateWithCmd 231 | msg 232 | DebounceSearchTermMsg 233 | model.DebouncedTerm 234 | (fun x -> { model with DebouncedTerm = x }) 235 | (FetchSuggestions >> Cmd.ofMsg) 236 | | DoSearch (Term text) when String.IsNullOrWhiteSpace text || text.Length <= 3 -> model, Cmd.none 237 | | DoSearch term -> initiateSearch model term { Facet = None; Page = 0; Sort = None } 238 | | SearchBoxEnter -> 239 | match model.Suggestions.SelectedSuggestion with 240 | | Some i -> setSearchToSuggestion model i 241 | | None -> model, Cmd.ofMsg (DoSearch model.Term) 242 | | SetFilter (facet, value) -> initiateSearch model model.LastSearch { model.Parameters with Facet = Some(facet, value) } 243 | | SetSort sort -> initiateSearch model model.LastSearch { model.Parameters with Sort = sort } 244 | | ChangePage page -> initiateSearch model model.LastSearch { model.Parameters with Page = page } 245 | | SearchCompleted (term, response) -> 246 | { model with 247 | Status = Displaying 248 | SearchResults = Some { SearchTerm = term; Response = response } 249 | Selected = None }, Cmd.none 250 | | SearchError _ -> model, Cmd.none 251 | | SelectTransaction transaction -> { model with Selected = Some transaction }, Cmd.none 252 | | ShowSuggestions b -> { model with Suggestions = { model.Suggestions with Show = b } }, Cmd.none 253 | | SelectSuggestionOffset offset -> { model with Suggestions = updateSelectedSuggestion offset model.Suggestions }, Cmd.none 254 | | SelectSuggestion i -> setSearchToSuggestion model i 255 | | FetchSuggestions text -> model, Cmd.ofPromise fetchSuggestions text (fun r -> ReceiveSuggestions r.Suggestions) (fun _ -> ReceiveSuggestions [||]) 256 | | ReceiveSuggestions sugs -> { model with Suggestions = { model.Suggestions with SuggestedTerms = sugs; SelectedSuggestion = None } }, Cmd.none 257 | -------------------------------------------------------------------------------- /src/client/pages/Shared.fs: -------------------------------------------------------------------------------- 1 | namespace Pages 2 | open PropertyMapper.Contracts 3 | 4 | type SearchTerm = 5 | | Term of string 6 | | PostcodeSearch of string 7 | static member Empty = Term "" 8 | member this.Description = match this with | Term x | PostcodeSearch x -> x 9 | 10 | [] 11 | module Helpers = 12 | let commaSeparate : int -> _ = string >> Seq.toArray >> Array.rev >> Array.chunkBySize 3 >> Array.map Array.rev >> Array.rev >> Array.map System.String >> String.concat "," 13 | -------------------------------------------------------------------------------- /src/client/paket.references: -------------------------------------------------------------------------------- 1 | dotnet-fable 2 | Fable.Elmish.Browser 3 | Fable.Elmish.Debugger 4 | Fable.Elmish.React 5 | Fable.Elmish.HMR -------------------------------------------------------------------------------- /src/client/public/fable.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAFE-Stack/SAFE-Search/5c6b04188c5cd4cefdc2d855369be2c156e90a76/src/client/public/fable.ico -------------------------------------------------------------------------------- /src/client/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var webpack = require("webpack"); 3 | var fableUtils = require("fable-utils"); 4 | 5 | function resolve(filePath) { 6 | return path.join(__dirname, filePath) 7 | } 8 | 9 | var babelOptions = fableUtils.resolveBabelOptions({ 10 | presets: [ 11 | ["env", { 12 | "targets": { 13 | "browsers": ["last 2 versions"] 14 | }, 15 | "modules": false 16 | }] 17 | ], 18 | plugins: ["transform-runtime"] 19 | }); 20 | 21 | 22 | var isProduction = process.argv.indexOf("-p") >= 0; 23 | var port = process.env.SUAVE_FABLE_PORT || "8085"; 24 | console.log("Bundling for " + (isProduction ? "production" : "development") + "..."); 25 | 26 | module.exports = { 27 | devtool: "source-map", 28 | entry: resolve('client.fsproj'), 29 | output: { 30 | path: resolve('public'), 31 | publicPath: "public", 32 | filename: "bundle.js" 33 | }, 34 | resolve: { 35 | modules: [ resolve("../../node_modules/")] 36 | }, 37 | devServer: { 38 | proxy: { 39 | '/api/*': { 40 | target: 'http://localhost:' + port, 41 | changeOrigin: true 42 | } 43 | }, 44 | hot: true, 45 | inline: true 46 | }, 47 | module: { 48 | rules: [ 49 | { 50 | test: /\.fs(x|proj)?$/, 51 | use: { 52 | loader: "fable-loader", 53 | options: { 54 | babel: babelOptions, 55 | define: isProduction ? [] : ["DEBUG"] 56 | } 57 | } 58 | }, 59 | { 60 | test: /\.js$/, 61 | exclude: /node_modules/, 62 | use: { 63 | loader: 'babel-loader', 64 | options: babelOptions 65 | }, 66 | } 67 | ] 68 | }, 69 | plugins : isProduction ? [] : [ 70 | new webpack.HotModuleReplacementPlugin(), 71 | new webpack.NamedModulesPlugin() 72 | ] 73 | }; 74 | -------------------------------------------------------------------------------- /src/scripts/azure-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "postcodes": { 3 | "Postcode": { 4 | "Type": "String" 5 | }, 6 | "Longitude": { 7 | "Type": "double" 8 | }, 9 | "Latitude": { 10 | "Type": "double" 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/scripts/importdata.fsx: -------------------------------------------------------------------------------- 1 | /// This script creates a local dataset that can be used instead of Azure Search. 2 | 3 | #I @"..\.." 4 | #I @"..\..\.paket\load\" 5 | #load @"net461\Build\FSharp.Data.fsx" 6 | @"packages\build\FSharp.Azure.StorageTypeProvider\StorageTypeProvider.fsx" 7 | @"netstandard2.0\Fable.JsonConverter.fsx" 8 | @"src\server\Contracts.fs" 9 | @"netstandard2.0\Giraffe.fsx" 10 | @"netstandard2.0\Microsoft.Azure.Search.fsx" 11 | @"netstandard2.0\System.ComponentModel.Annotations.fsx" 12 | @"src\server\Configuration.fs" 13 | @"src\server\Search.fs" 14 | @"src\server\Search.Azure.fs" 15 | 16 | open FSharp.Data 17 | open Newtonsoft.Json 18 | open PropertyMapper.Contracts 19 | 20 | [] 21 | let PricePaidSchema = __SOURCE_DIRECTORY__ + @"\price-paid-schema.csv" 22 | type PricePaid = CsvProvider 23 | let fetchTransactions rows = 24 | let data = PricePaid.Load "http://prod.publicdata.landregistry.gov.uk.s3-website-eu-west-1.amazonaws.com/pp-monthly-update-new-version.csv" 25 | data.Rows 26 | |> Seq.take rows 27 | |> Seq.map(fun t -> 28 | { TransactionId = t.TransactionId 29 | Address = 30 | { Building = [ Some t.PAON; t.SAON ] |> List.choose id |> String.concat " " 31 | Street = t.Street 32 | Locality = t.Locality 33 | TownCity = t.``Town/City`` 34 | District = t.District 35 | County = t.County 36 | PostCode = t.Postcode } 37 | BuildDetails = 38 | { PropertyType = t.PropertyType |> Option.bind PropertyType.Parse 39 | Build = t.Duration |> BuildType.Parse 40 | Contract = t.``Old/New`` |> ContractType.Parse } 41 | Price = t.Price 42 | DateOfTransfer = t.Date }) 43 | |> Seq.toArray 44 | 45 | module FableJson = 46 | let private jsonConverter = Fable.JsonConverter() :> JsonConverter 47 | let toJson value = JsonConvert.SerializeObject(value, [|jsonConverter|]) 48 | let ofJson (json:string) = JsonConvert.DeserializeObject<'a>(json, [|jsonConverter|]) 49 | 50 | [] 51 | let PostCodesSchema = __SOURCE_DIRECTORY__ + @"\uk-postcodes-schema.csv" 52 | 53 | type Postcodes = CsvProvider 54 | 55 | type GeoPostcode = 56 | { PostCode : string * string 57 | Latitude : float 58 | Longitude : float } 59 | member this.PostCodeDescription = sprintf "%s %s" (fst this.PostCode) (snd this.PostCode) 60 | let fetchPostcodes (path:string) = 61 | let data = Postcodes.Load path 62 | data.Rows 63 | |> Seq.choose(fun r -> 64 | match r.Postcode.Split ' ', r.Latitude, r.Longitude with 65 | | [| partA; partB |], Some latitude, Some longitude -> 66 | Some 67 | { PostCode = (partA, partB) 68 | Latitude = float latitude 69 | Longitude = float longitude } 70 | | _ -> None) 71 | |> Seq.toArray 72 | 73 | open FSharp.Azure.StorageTypeProvider 74 | open FSharp.Azure.StorageTypeProvider.Table 75 | 76 | [] 77 | let AzureSchema = __SOURCE_DIRECTORY__ + @"\azure-schema.json" 78 | type Azure = AzureTypeProvider 79 | let table = Azure.Tables.postcodes 80 | let insertPostcodes connectionString postcodes = 81 | table.AsCloudTable().CreateIfNotExists() |> ignore 82 | let entities = 83 | postcodes 84 | |> Seq.map(fun p -> 85 | let partA, partB = p.PostCode 86 | Azure.Domain.postcodesEntity(Partition partA, Row partB, p.PostCodeDescription, p.Longitude, p.Latitude)) 87 | |> Seq.toArray 88 | table.Insert(entities, TableInsertMode.Upsert, connectionString) 89 | 90 | -------------------------------------------------------------------------------- /src/scripts/price-paid-schema.csv: -------------------------------------------------------------------------------- 1 | TransactionId,Price,Date,Postcode,PropertyType,Old/New,Duration,PAON,SAON,Street,Locality,Town/City,District,County,PpdCategoryType,RecordStatus 2 | {2AC10E4F-AE75-1AF6-E050-A8C063052BA1},159000,06/01/2016 00:00,,,N,F,KINGSMEAD,Test,,,LINCOLN,NORTH KESTEVEN,LINCOLNSHIRE,A,A 3 | {2AC10E4F-AE78-1AF6-E050-A8C063052BA1},125000,21/01/2016 00:00,LN7 6EH,D,N,F,KINGSMEAD,,MIDDLE STREET,NORTH KELSEY,MARKET RASEN,WEST LINDSEY,LINCOLNSHIRE,A,A 4 | -------------------------------------------------------------------------------- /src/scripts/uk-postcodes-schema.csv: -------------------------------------------------------------------------------- 1 | id,postcode,latitude,longitude 2 | 1,AB10 1XG,57.144165160000000,-2.114847768000000 3 | 2,AB10 6RN,57.137879760000000,-2.121486688000000 -------------------------------------------------------------------------------- /src/server/Configuration.fs: -------------------------------------------------------------------------------- 1 | namespace PropertyMapper 2 | 3 | type ConnectionString = ConnectionString of string 4 | type Configuration = 5 | { AzureStorage : ConnectionString 6 | AzureSearch : ConnectionString 7 | AzureSearchServiceName : string } 8 | 9 | -------------------------------------------------------------------------------- /src/server/Contracts.fs: -------------------------------------------------------------------------------- 1 | namespace PropertyMapper.Contracts 2 | 3 | open System 4 | 5 | type SortDirection = 6 | | Ascending | Descending 7 | static member TryParse str = 8 | if string Ascending = str then Some Ascending 9 | elif string Descending = str then Some Descending 10 | else None 11 | [] 12 | type Sort = { SortColumn : string option; SortDirection : SortDirection option } 13 | [] 14 | type PropertyFilter = 15 | { Town : string option 16 | Locality : string option 17 | District : string option 18 | County : string option 19 | MaxPrice : int option 20 | MinPrice : int option } 21 | type PropertyTableColumn = 22 | | Street | Town | Postcode | Date | Price 23 | static member TryParse s = 24 | if s = string Street then Some Street 25 | elif s = string Town then Some Town 26 | elif s = string Postcode then Some Postcode 27 | elif s = string Date then Some Date 28 | elif s = string Price then Some Price 29 | else None 30 | type PropertyType = 31 | | Detached | SemiDetached | Terraced | FlatsMaisonettes | Other 32 | member this.Description = 33 | match this with 34 | | PropertyType.SemiDetached -> "Semi Detatch" 35 | | PropertyType.FlatsMaisonettes -> "Flats / Maisonettes" 36 | | _ -> string this 37 | static member Parse = function "D" -> Some Detached | "S" -> Some SemiDetached | "T" -> Some Terraced | "F" -> Some FlatsMaisonettes | "O" -> Some Other | _ -> None 38 | type BuildType = 39 | | NewBuild | OldBuild 40 | member this.Description = 41 | match this with 42 | | BuildType.OldBuild -> "Old Build" 43 | | BuildType.NewBuild -> "New Build" 44 | static member Parse = function "Y" -> NewBuild | _ -> OldBuild 45 | 46 | type ContractType = 47 | | Freehold | Leasehold 48 | member this.Description = string this 49 | static member Parse = function "F" -> Freehold | _ -> Leasehold 50 | 51 | type Address = 52 | { Building : string 53 | Street : string option 54 | Locality : string option 55 | TownCity : string 56 | District : string 57 | County : string 58 | PostCode : string option } 59 | member address.FirstLine = 60 | [ Some address.Building; address.Street ] 61 | |> List.choose id 62 | |> String.concat " " 63 | type BuildDetails = 64 | { PropertyType : PropertyType option 65 | Build : BuildType 66 | Contract : ContractType } 67 | type PropertyResult = 68 | { TransactionId : Guid 69 | BuildDetails : BuildDetails 70 | Address : Address 71 | Price : int 72 | DateOfTransfer : DateTime } 73 | type Facets = 74 | { Towns : string list 75 | Localities : string list 76 | Districts : string list 77 | Counties : string list 78 | Prices : string list } 79 | type SearchResponse = 80 | { Results : PropertyResult array 81 | TotalTransactions : int option 82 | Page : int 83 | Facets : Facets } 84 | type SuggestResponse = 85 | { Suggestions : string array } 86 | -------------------------------------------------------------------------------- /src/server/FableJson.fs: -------------------------------------------------------------------------------- 1 | module FableJson 2 | 3 | open Newtonsoft.Json 4 | open Giraffe 5 | open Microsoft.AspNetCore.Http 6 | 7 | // The Fable.JsonConverter serializes F# types so they can be deserialized on the 8 | // client side by Fable into full type instances, see http://fable.io/blog/Introducing-0-7.html#JSON-Serialization 9 | // The converter includes a cache to improve serialization performance. Because of this, 10 | // it's better to keep a single instance during the server lifetime. 11 | let private jsonConverter = Fable.JsonConverter() :> JsonConverter 12 | 13 | let toJson value = 14 | JsonConvert.SerializeObject(value, [|jsonConverter|]) 15 | 16 | let ofJson<'a> (json:string) : 'a = 17 | JsonConvert.DeserializeObject<'a>(json, [|jsonConverter|]) 18 | 19 | let serialize x = 20 | setStatusCode 200 21 | >=> setHttpHeader "Content-Type" "application/json" 22 | >=> setBodyAsString (toJson x) 23 | 24 | 25 | let getJsonFromCtx<'a> (ctx:HttpContext) = task { 26 | let! t = ctx.ReadBodyFromRequestAsync() 27 | return ofJson<'a> t 28 | } -------------------------------------------------------------------------------- /src/server/Program.fs: -------------------------------------------------------------------------------- 1 | module PropertyMapper.App 2 | 3 | open System 4 | open System.IO 5 | open Microsoft.AspNetCore.Builder 6 | open Microsoft.AspNetCore.Cors.Infrastructure 7 | open Microsoft.AspNetCore.Hosting 8 | open Microsoft.Extensions.Logging 9 | open Microsoft.Extensions.Configuration 10 | open Microsoft.Extensions.DependencyInjection 11 | open Giraffe 12 | open Giraffe.Razor 13 | open PropertyMapper.Routing 14 | 15 | // --------------------------------- 16 | // Error handler 17 | // --------------------------------- 18 | 19 | let errorHandler (ex : Exception) (logger : ILogger) = 20 | logger.LogError(EventId(), ex, "An unhandled exception has occurred while executing the request.") 21 | clearResponse >=> setStatusCode 500 >=> text ex.Message 22 | 23 | // --------------------------------- 24 | // Config and Main 25 | // --------------------------------- 26 | 27 | let configureCors (builder : CorsPolicyBuilder) = 28 | builder.WithOrigins("http://localhost:8080").AllowAnyMethod().AllowAnyHeader() |> ignore 29 | 30 | let createSearch config = 31 | match config with 32 | | _ when String.IsNullOrWhiteSpace config.AzureSearchServiceName -> 33 | { new Search.ISearch with 34 | member __.GenericSearch request = Search.InMemory.findGeneric request 35 | member __.PostcodeSearch request = Search.InMemory.findByPostcode request 36 | member __.Suggest request = Search.InMemory.suggest request } 37 | | config -> 38 | { new Search.ISearch with 39 | member __.GenericSearch request = Search.Azure.findGeneric config request 40 | member __.PostcodeSearch request = Search.Azure.findByPostcode config AzureStorage.tryGetGeo request 41 | member __.Suggest request = Search.Azure.suggest config request } 42 | 43 | let configureApp searcher (app : IApplicationBuilder) = 44 | app.UseCors(configureCors) 45 | .UseGiraffeErrorHandler(errorHandler) 46 | .UseStaticFiles() 47 | .UseGiraffe(webApp searcher) 48 | 49 | let configureServices (services : IServiceCollection) = 50 | let sp = services.BuildServiceProvider() 51 | let env = sp.GetService() 52 | let viewsFolderPath = Path.Combine(env.ContentRootPath, "Views") 53 | services.AddRazorEngine viewsFolderPath |> ignore 54 | services.AddCors() |> ignore 55 | 56 | let configureLogging (builder : ILoggingBuilder) = 57 | let filter (l : LogLevel) = l.Equals LogLevel.Error 58 | builder.AddFilter(filter).AddConsole().AddDebug() |> ignore 59 | 60 | let appConfig = 61 | lazy 62 | let builder = 63 | ConfigurationBuilder() 64 | .SetBasePath(Directory.GetCurrentDirectory()) 65 | .AddJsonFile("appsettings.json", optional = true) 66 | .AddEnvironmentVariables() 67 | .Build() 68 | 69 | { AzureStorage = builder.GetConnectionString "AzureStorage" |> ConnectionString 70 | AzureSearch = builder.GetConnectionString "AzureSearch" |> ConnectionString 71 | AzureSearchServiceName = builder.["AzureSearchName"] } 72 | 73 | [] 74 | let main _ = 75 | let contentRoot = Directory.GetCurrentDirectory() 76 | let webRoot = Path.Combine(contentRoot, "WebRoot") 77 | let configureApp = appConfig.Value |> createSearch |> configureApp 78 | 79 | WebHostBuilder() 80 | .UseKestrel() 81 | .UseContentRoot(contentRoot) 82 | .UseIISIntegration() 83 | .UseWebRoot(webRoot) 84 | .Configure(Action configureApp) 85 | .ConfigureServices(configureServices) 86 | .ConfigureLogging(configureLogging) 87 | .Build() 88 | .Run() 89 | 0 -------------------------------------------------------------------------------- /src/server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:5000/", 7 | "sslPort": 0 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "server": { 19 | "commandName": "Project", 20 | "launchBrowser": true, 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development" 23 | }, 24 | "applicationUrl": "http://localhost:5000/" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/server/Routing.fs: -------------------------------------------------------------------------------- 1 | module PropertyMapper.Routing 2 | 3 | open PropertyMapper.Search 4 | open PropertyMapper.Contracts 5 | open Giraffe 6 | open Microsoft.AspNetCore.Http 7 | 8 | let searchProperties (searcher:Search.ISearch) (postCode:string, distance, page) next (ctx:HttpContext) = task { 9 | let! properties = 10 | searcher.PostcodeSearch 11 | { Filter = ctx.BindQueryString() 12 | Postcode = postCode.ToUpper() 13 | MaxDistance = distance 14 | Page = page } 15 | return! FableJson.serialize properties next ctx } 16 | 17 | let searchSuggest (searcher:Search.ISearch) text next (ctx:HttpContext) = 18 | task { 19 | let! properties = searcher.Suggest { Text = text } 20 | return! FableJson.serialize properties next ctx } 21 | 22 | let genericSearch (searcher:Search.ISearch) (text, page) next (ctx:HttpContext) = 23 | let request = 24 | { Page = page 25 | Text = if System.String.IsNullOrWhiteSpace text then None else Some text 26 | Filter = ctx.BindQueryString() 27 | Sort = 28 | { SortColumn = ctx.TryGetQueryStringValue "SortColumn" 29 | SortDirection = ctx.TryGetQueryStringValue "SortDirection" |> Option.bind SortDirection.TryParse } } 30 | task { 31 | let! properties = searcher.GenericSearch request 32 | return! FableJson.serialize properties next ctx } 33 | 34 | let webApp searcher : HttpHandler = 35 | choose [ 36 | GET >=> 37 | choose [ 38 | routef "/property/find/%s/%i" (genericSearch searcher) 39 | routef "/property/find-suggestion/%s" (searchSuggest searcher) 40 | routef "/property/%s/%i/%i" (searchProperties searcher) 41 | routef "/property/%s/%i" (fun (postcode, distance) -> searchProperties searcher (postcode, distance, 0)) 42 | ] 43 | setStatusCode 404 >=> text "Not Found" ] 44 | 45 | -------------------------------------------------------------------------------- /src/server/Search.Azure.fs: -------------------------------------------------------------------------------- 1 | module PropertyMapper.Search.Azure 2 | 3 | open Giraffe.Tasks 4 | open Microsoft.Azure.Search 5 | open Microsoft.Azure.Search.Models 6 | open Microsoft.Spatial 7 | open PropertyMapper 8 | open PropertyMapper.Contracts 9 | open System 10 | open System.ComponentModel.DataAnnotations 11 | open System.Threading.Tasks 12 | 13 | type SearchableProperty = 14 | { [] TransactionId : string 15 | [] Price : int 16 | [] DateOfTransfer : DateTime 17 | [] PostCode : string 18 | [] PropertyType : string 19 | [] Build : string 20 | [] Contract : string 21 | [] Building : string 22 | [] Street : string 23 | [] Locality : string 24 | [] Town : string 25 | [] District : string 26 | [] County : string 27 | [] Geo : GeographyPoint } 28 | 29 | let suggesterName = "suggester" 30 | 31 | [] 32 | module Management = 33 | open System.Collections.Generic 34 | let searchClient = 35 | let connections = Dictionary() 36 | fun config -> 37 | if not (connections.ContainsKey config) then 38 | let (ConnectionString c) = config.AzureSearch 39 | connections.[config] <- new SearchServiceClient(config.AzureSearchServiceName, SearchCredentials c) 40 | connections.[config] 41 | 42 | let propertiesIndex config = 43 | let client = searchClient config 44 | client.Indexes.GetClient "properties" 45 | 46 | let initialize config = 47 | let client = searchClient config 48 | if (client.Indexes.Exists "properties") then client.Indexes.Delete "properties" 49 | let suggester = Suggester(Name = suggesterName, SourceFields = [| "Street"; "Locality"; "Town"; "District"; "County" |]) 50 | Index(Name = "properties", Fields = FieldBuilder.BuildForType(), Suggesters = [| suggester |]) 51 | |> client.Indexes.Create 52 | 53 | [] 54 | module QueryBuilder = 55 | let findByDistance (geo:GeographyPoint) maxDistance = 56 | SearchParameters(Filter=sprintf "geo.distance(Geo, geography'POINT(%f %f)') le %d" geo.Longitude geo.Latitude maxDistance) 57 | let withFilter (parameters:SearchParameters) (field, value:string option) = 58 | parameters.Filter <- 59 | [ (match parameters.Filter with f when String.IsNullOrWhiteSpace f -> None | f -> Some f) 60 | (value |> Option.map(fun value -> sprintf "(%s eq '%s')" field (value.ToUpper()))) ] 61 | |> List.choose id 62 | |> String.concat " and " 63 | parameters 64 | 65 | let toSearchColumns col = 66 | match PropertyTableColumn.TryParse col with 67 | | Some Street -> [ "Building"; "Street" ] 68 | | Some Town -> [ "Town" ] 69 | | Some Postcode -> [ "PostCode" ] 70 | | Some Date -> [ "DateOfTransfer" ] 71 | | Some Price -> [ "Price" ] 72 | | None -> [] 73 | 74 | let orderBy sort (parameters:SearchParameters) = 75 | sort.SortColumn |> Option.iter (fun col -> 76 | let searchCols = toSearchColumns col 77 | let direction = match sort.SortDirection with Some Ascending | None -> "asc" | Some Descending -> "desc" 78 | parameters.OrderBy <- searchCols |> List.map (fun col -> sprintf "%s %s" col direction) |> ResizeArray) 79 | parameters 80 | 81 | let doSearch config page searchText (parameters:SearchParameters) = task { 82 | let searchText = searchText |> Option.map(fun searchText -> searchText + "*") |> Option.defaultValue "" 83 | parameters.Facets <- ResizeArray [ "Town"; "Locality"; "District"; "County"; "Price" ] 84 | parameters.Skip <- Nullable(page * 20) 85 | parameters.Top <- Nullable 20 86 | parameters.IncludeTotalResultCount <- true 87 | let! searchResult = (propertiesIndex config).Documents.SearchAsync(searchText, parameters) 88 | let facets = 89 | searchResult.Facets 90 | |> Seq.map(fun x -> x.Key, x.Value |> Seq.map(fun r -> r.Value |> string) |> Seq.toList) 91 | |> Map.ofSeq 92 | |> fun x -> x.TryFind >> Option.defaultValue [] 93 | return facets, searchResult.Results |> Seq.toArray |> Array.map(fun r -> r.Document), searchResult.Count |> Option.ofNullable |> Option.map int } 94 | 95 | let insertProperties config tryGetGeo (properties:PropertyResult seq) = 96 | let index = propertiesIndex config 97 | properties 98 | |> Seq.map(fun r -> 99 | { TransactionId = string r.TransactionId 100 | Price = r.Price 101 | DateOfTransfer = r.DateOfTransfer 102 | PostCode = r.Address.PostCode |> Option.toObj 103 | PropertyType = r.BuildDetails.PropertyType |> Option.map string |> Option.toObj 104 | Build = r.BuildDetails.Build.ToString() 105 | Contract = r.BuildDetails.Contract.ToString() 106 | Building = r.Address.Building 107 | Street = r.Address.Street |> Option.toObj 108 | Locality = r.Address.Locality |> Option.toObj 109 | Town = r.Address.TownCity 110 | District = r.Address.District 111 | County = r.Address.County 112 | Geo = r.Address.PostCode |> Option.bind tryGetGeo |> Option.map(fun (lat, long) -> GeographyPoint.Create(lat, long)) |> Option.toObj }) 113 | |> IndexBatch.Upload 114 | |> index.Documents.IndexAsync 115 | 116 | let private toFindPropertiesResponse findFacet count page results = 117 | { Results = 118 | results 119 | |> Array.map(fun result -> 120 | { TransactionId = Guid.Parse result.TransactionId 121 | BuildDetails = 122 | { PropertyType = result.PropertyType |> PropertyType.Parse 123 | Build = result.Build |> BuildType.Parse 124 | Contract = result.Contract |> ContractType.Parse } 125 | Address = 126 | { Building = result.Building 127 | Street = result.Street |> Option.ofObj 128 | Locality = result.Locality |> Option.ofObj 129 | TownCity = result.Town 130 | District = result.District 131 | County = result.County 132 | PostCode = result.PostCode |> Option.ofObj } 133 | Price = result.Price 134 | DateOfTransfer = result.DateOfTransfer }) 135 | TotalTransactions = count 136 | Facets = 137 | { Towns = findFacet "TownCity" 138 | Localities = findFacet "Locality" 139 | Districts = findFacet "District" 140 | Counties = findFacet "County" 141 | Prices = findFacet "Price" } 142 | Page = page } 143 | 144 | let applyFilters (filter:PropertyFilter) parameters = 145 | [ "TownCity", filter.Town 146 | "County", filter.County 147 | "Locality", filter.Locality 148 | "District", filter.District ] 149 | |> List.fold withFilter parameters 150 | 151 | let findGeneric config request = task { 152 | let! findFacet, searchResults, count = 153 | SearchParameters() 154 | |> applyFilters request.Filter 155 | |> orderBy request.Sort 156 | |> doSearch config request.Page request.Text 157 | return searchResults |> toFindPropertiesResponse findFacet count request.Page } 158 | 159 | let findByPostcode config tryGetGeo request = task { 160 | let! geo = 161 | match request.Postcode.Split ' ' with 162 | | [| partA; partB |] -> tryGetGeo config partA partB 163 | | _ -> Task.FromResult None 164 | let! findFacet, searchResults, count = 165 | match geo with 166 | | Some geo -> task { 167 | let geo = GeographyPoint.Create(geo.Lat, geo.Long) 168 | return! 169 | findByDistance geo request.MaxDistance 170 | |> applyFilters request.Filter 171 | |> doSearch config request.Page None } 172 | | None -> Task.FromResult ((fun _ -> []), [||], None) 173 | return searchResults |> toFindPropertiesResponse findFacet count request.Page } 174 | 175 | let suggest config request = task { 176 | let index = propertiesIndex config 177 | let! result = index.Documents.SuggestAsync(request.Text, suggesterName, SuggestParameters(Top = Nullable(10))) 178 | return { Suggestions = result.Results |> Seq.map (fun x -> x.Text) |> Seq.distinct |> Seq.toArray } } 179 | -------------------------------------------------------------------------------- /src/server/Search.InMemory.fs: -------------------------------------------------------------------------------- 1 | module PropertyMapper.Search.InMemory 2 | 3 | open PropertyMapper.Contracts 4 | open Giraffe.Tasks 5 | open System.Text.RegularExpressions 6 | 7 | let private data = lazy ("properties.json" |> System.IO.File.ReadAllText |> FableJson.ofJson) 8 | 9 | let findByPostcode (request:FindNearestRequest) = task { 10 | return 11 | { Results = [||] 12 | TotalTransactions = None 13 | Page = request.Page 14 | Facets = 15 | { Towns = [] 16 | Localities = [] 17 | Districts = [] 18 | Counties = [] 19 | Prices = [] } } } 20 | 21 | let sortResults sortParams results = 22 | match sortParams.SortColumn with 23 | | Some col -> 24 | let directedCompare a b = 25 | match PropertyTableColumn.TryParse col with 26 | | Some Street -> compare a.Address.FirstLine b.Address.FirstLine 27 | | Some Town -> compare a.Address.TownCity b.Address.TownCity 28 | | Some Postcode -> compare a.Address.PostCode b.Address.PostCode 29 | | Some Date -> compare a.DateOfTransfer b.DateOfTransfer 30 | | Some Price -> compare a.Price b.Price 31 | | None -> 0 32 | |> fun v -> 33 | match sortParams.SortDirection with 34 | | Some Ascending | None -> v 35 | | Some Descending -> -v 36 | results |> Array.sortWith directedCompare 37 | | None -> results 38 | 39 | let findGeneric (request:FindGenericRequest) = task { 40 | let genericFilter = 41 | match request.Text with 42 | | Some text -> 43 | let text = text.ToUpper() 44 | fun r -> 45 | r.Address.County.ToUpper().Contains text || 46 | r.Address.District.ToUpper().Contains text || 47 | r.Address.Locality |> Option.map(fun l -> l.ToUpper().Contains text) |> Option.defaultValue false || 48 | r.Address.TownCity.ToUpper().Contains text || 49 | r.Address.Street |> Option.map (fun s -> s.ToUpper().Contains text) |> Option.defaultValue false 50 | | None -> fun _ -> true 51 | let facetFilter filter mapper = 52 | match filter with 53 | | Some filter -> mapper >> fun (s:string) -> s.ToUpper() = filter 54 | | None -> fun _ -> true 55 | 56 | let matches = 57 | data.Value 58 | |> Array.filter genericFilter 59 | |> Array.filter (facetFilter request.Filter.County (fun r -> r.Address.County)) 60 | |> Array.filter (facetFilter request.Filter.District (fun r -> r.Address.District)) 61 | |> Array.filter (facetFilter request.Filter.Locality (fun r -> r.Address.Locality |> Option.defaultValue "")) 62 | |> Array.filter (facetFilter request.Filter.Town (fun r -> r.Address.TownCity)) 63 | |> sortResults request.Sort 64 | 65 | let getFacets mapper = Array.choose mapper >> Array.distinct >> Array.truncate 10 >> Array.toList 66 | return 67 | { Results = matches |> Array.skip (request.Page * 20) |> Array.truncate 20 68 | TotalTransactions = Some matches.Length 69 | Page = request.Page 70 | Facets = 71 | { Towns = matches |> getFacets (fun m -> Some m.Address.TownCity) 72 | Localities = matches |> getFacets (fun m -> m.Address.Locality) 73 | Districts = matches |> getFacets (fun m -> Some m.Address.District) 74 | Counties = matches |> getFacets (fun m -> Some m.Address.County) 75 | Prices = [] } } 76 | } 77 | 78 | let suggest request = task { 79 | let terms = Regex.Split(request.Text.ToLower(), "\s+") |> Array.filter ((<>) "") |> Array.distinct 80 | let termMatch (s:string) = terms |> Array.exists (fun t -> s.ToLower().Contains t) 81 | let suggestions = 82 | data.Value 83 | |> Seq.collect (fun x -> 84 | let add = x.Address 85 | [| add.FirstLine 86 | add.Locality |> Option.defaultValue "" 87 | add.TownCity 88 | add.District 89 | add.County 90 | add.PostCode |> Option.defaultValue "" |] 91 | |> Array.filter termMatch) 92 | |> Seq.truncate 20 93 | |> Seq.toArray 94 | return { Suggestions = suggestions } } -------------------------------------------------------------------------------- /src/server/Search.fs: -------------------------------------------------------------------------------- 1 | namespace PropertyMapper.Search 2 | 3 | open PropertyMapper.Contracts 4 | open System.Threading.Tasks 5 | 6 | type FindNearestRequest = { Postcode : string; MaxDistance : int; Page : int; Filter : PropertyFilter } 7 | type FindGenericRequest = { Text : string option; Page : int; Filter : PropertyFilter; Sort : Sort } 8 | type Geo = { Lat : float; Long : float } 9 | type SuggestRequest = { Text : string } 10 | 11 | type ISearch = 12 | abstract GenericSearch : FindGenericRequest -> SearchResponse Task 13 | abstract PostcodeSearch : FindNearestRequest -> SearchResponse Task 14 | abstract Suggest : SuggestRequest -> SuggestResponse Task -------------------------------------------------------------------------------- /src/server/Storage.fs: -------------------------------------------------------------------------------- 1 | module PropertyMapper.AzureStorage 2 | 3 | open Microsoft.WindowsAzure.Storage 4 | open Microsoft.WindowsAzure.Storage.Table 5 | open PropertyMapper 6 | open Giraffe.Tasks 7 | open PropertyMapper.Search 8 | 9 | let table (ConnectionString connection) = 10 | let tableClient = 11 | let connection = CloudStorageAccount.Parse connection 12 | connection.CreateCloudTableClient() 13 | tableClient.GetTableReference "postcodes" 14 | 15 | let tryGetGeo config postcodeA postcodeB = task { 16 | let! result = TableOperation.Retrieve(postcodeA, postcodeB) |> (table config.AzureStorage).ExecuteAsync 17 | let tryGetProp name (entity:DynamicTableEntity) = entity.Properties.[name].DoubleValue |> Option.ofNullable 18 | return 19 | result.Result 20 | |> Option.ofObj 21 | |> Option.bind(function 22 | | :? DynamicTableEntity as entity -> 23 | match entity |> tryGetProp "Lat", entity |> tryGetProp "Long" with 24 | | Some lat, Some long -> Some { Lat = lat; Long = long } 25 | | _ -> None 26 | | _ -> None) } 27 | -------------------------------------------------------------------------------- /src/server/paket.references: -------------------------------------------------------------------------------- 1 | Fable.JsonConverter 2 | Giraffe.Razor 3 | Microsoft.AspNetCore.Authentication.Cookies 4 | Microsoft.AspNetCore.Server.Kestrel 5 | Microsoft.AspNetCore.Server.IISIntegration 6 | Microsoft.AspNetCore.StaticFiles 7 | Microsoft.Azure.Search 8 | Microsoft.DotNet.Watcher.Tools 9 | Microsoft.Extensions.Configuration.Json 10 | Microsoft.Extensions.Logging.Console 11 | Microsoft.Extensions.Logging.Debug 12 | WindowsAzure.Storage -------------------------------------------------------------------------------- /src/server/server.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | netcoreapp2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | --------------------------------------------------------------------------------