├── .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 |
--------------------------------------------------------------------------------