Help wanted (#23)
Create a "help wanted" area of the site (#14)
This commit was merged in pull request #23.
This commit is contained in:
1
src/JobsJobsJobs/Api/.gitignore
vendored
Normal file
1
src/JobsJobsJobs/Api/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
wwwroot
|
||||
34
src/JobsJobsJobs/Api/Api.fsproj
Normal file
34
src/JobsJobsJobs/Api/Api.fsproj
Normal file
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Auth.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="App.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Domain\Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include=".\wwwroot" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Giraffe" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.8" />
|
||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="Polly" Version="7.2.2" />
|
||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.11.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
81
src/JobsJobsJobs/Api/App.fs
Normal file
81
src/JobsJobsJobs/Api/App.fs
Normal file
@@ -0,0 +1,81 @@
|
||||
/// The main API application for Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.Api.App
|
||||
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.Hosting
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Hosting
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
|
||||
/// Configure the ASP.NET Core pipeline to use Giraffe
|
||||
let configureApp (app : IApplicationBuilder) =
|
||||
app
|
||||
.UseCors(fun p -> p.AllowAnyOrigin().AllowAnyHeader() |> ignore)
|
||||
.UseStaticFiles()
|
||||
.UseRouting()
|
||||
.UseAuthentication()
|
||||
.UseAuthorization()
|
||||
.UseGiraffeErrorHandler(Handlers.Error.unexpectedError)
|
||||
.UseEndpoints(fun e ->
|
||||
e.MapGiraffeEndpoints Handlers.allEndpoints
|
||||
e.MapFallbackToFile "index.html" |> ignore)
|
||||
|> ignore
|
||||
|
||||
open Newtonsoft.Json
|
||||
open NodaTime
|
||||
open Microsoft.AspNetCore.Authentication.JwtBearer
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open Microsoft.IdentityModel.Tokens
|
||||
open System.Text
|
||||
|
||||
/// Configure dependency injection
|
||||
let configureServices (svc : IServiceCollection) =
|
||||
svc.AddGiraffe () |> ignore
|
||||
svc.AddSingleton<IClock> SystemClock.Instance |> ignore
|
||||
svc.AddLogging () |> ignore
|
||||
svc.AddCors () |> ignore
|
||||
|
||||
let jsonCfg = JsonSerializerSettings ()
|
||||
Data.Converters.all () |> List.iter jsonCfg.Converters.Add
|
||||
svc.AddSingleton<Json.ISerializer> (NewtonsoftJson.Serializer jsonCfg) |> ignore
|
||||
|
||||
let svcs = svc.BuildServiceProvider ()
|
||||
let cfg = svcs.GetRequiredService<IConfiguration> ()
|
||||
|
||||
svc.AddAuthentication(fun o ->
|
||||
o.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||
o.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||
o.DefaultScheme <- JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(fun o ->
|
||||
o.RequireHttpsMetadata <- false
|
||||
o.TokenValidationParameters <- TokenValidationParameters (
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "https://noagendacareers.com",
|
||||
ValidIssuer = "https://noagendacareers.com",
|
||||
IssuerSigningKey = SymmetricSecurityKey (
|
||||
Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"]))))
|
||||
|> ignore
|
||||
svc.AddAuthorization () |> ignore
|
||||
|
||||
let dbCfg = cfg.GetSection "Rethink"
|
||||
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
|
||||
let conn = Data.Startup.createConnection dbCfg log
|
||||
svc.AddSingleton conn |> ignore
|
||||
Data.Startup.establishEnvironment dbCfg log conn |> Data.awaitIgnore
|
||||
|
||||
[<EntryPoint>]
|
||||
let main _ =
|
||||
Host.CreateDefaultBuilder()
|
||||
.ConfigureWebHostDefaults(
|
||||
fun webHostBuilder ->
|
||||
webHostBuilder
|
||||
.Configure(configureApp)
|
||||
.ConfigureServices(configureServices)
|
||||
|> ignore)
|
||||
.Build()
|
||||
.Run ()
|
||||
0
|
||||
108
src/JobsJobsJobs/Api/Auth.fs
Normal file
108
src/JobsJobsJobs/Api/Auth.fs
Normal file
@@ -0,0 +1,108 @@
|
||||
/// Authorization / authentication functions
|
||||
module JobsJobsJobs.Api.Auth
|
||||
|
||||
open System.Text.Json.Serialization
|
||||
|
||||
/// The variables we need from the account information we get from No Agenda Social
|
||||
[<NoComparison; NoEquality; AllowNullLiteral>]
|
||||
type MastodonAccount () =
|
||||
/// The user name (what we store as naUser)
|
||||
[<JsonPropertyName "username">]
|
||||
member val Username = "" with get, set
|
||||
/// The account name; will be the same as username for local (non-federated) accounts
|
||||
[<JsonPropertyName "acct">]
|
||||
member val AccountName = "" with get, set
|
||||
/// The user's display name as it currently shows on No Agenda Social
|
||||
[<JsonPropertyName "display_name">]
|
||||
member val DisplayName = "" with get, set
|
||||
/// The user's profile URL
|
||||
[<JsonPropertyName "url">]
|
||||
member val Url = "" with get, set
|
||||
|
||||
|
||||
open FSharp.Control.Tasks
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open System
|
||||
open System.Net.Http
|
||||
open System.Net.Http.Headers
|
||||
open System.Net.Http.Json
|
||||
open System.Text.Json
|
||||
|
||||
/// Verify the authorization code with Mastodon and get the user's profile
|
||||
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task {
|
||||
|
||||
use http = new HttpClient()
|
||||
|
||||
// Use authorization code to get an access token from NAS
|
||||
use! codeResult =
|
||||
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token",
|
||||
{| client_id = cfg.["ClientId"]
|
||||
client_secret = cfg.["Secret"]
|
||||
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"]
|
||||
grant_type = "authorization_code"
|
||||
code = authCode
|
||||
scope = "read"
|
||||
|})
|
||||
match codeResult.IsSuccessStatusCode with
|
||||
| true ->
|
||||
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
|
||||
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
|
||||
match tokenResponse with
|
||||
| null ->
|
||||
return Error "Could not parse authorization code result"
|
||||
| _ ->
|
||||
// Use access token to get profile from NAS
|
||||
use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"])
|
||||
req.Headers.Authorization <- AuthenticationHeaderValue
|
||||
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
|
||||
use! profileResult = http.SendAsync req
|
||||
|
||||
match profileResult.IsSuccessStatusCode with
|
||||
| true ->
|
||||
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
|
||||
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
|
||||
| null ->
|
||||
return Error "Could not parse profile result"
|
||||
| x when x.Username <> x.AccountName ->
|
||||
return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}"
|
||||
| profile ->
|
||||
return Ok profile
|
||||
| false ->
|
||||
return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
|
||||
| false ->
|
||||
let! err = codeResult.Content.ReadAsStringAsync ()
|
||||
log.LogError $"Could not get token result from Mastodon:\n {err}"
|
||||
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
|
||||
|
||||
}
|
||||
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.Types
|
||||
open Microsoft.IdentityModel.Tokens
|
||||
open System.IdentityModel.Tokens.Jwt
|
||||
open System.Security.Claims
|
||||
open System.Text
|
||||
|
||||
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
||||
let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
|
||||
|
||||
let tokenHandler = JwtSecurityTokenHandler ()
|
||||
let token =
|
||||
tokenHandler.CreateToken (
|
||||
SecurityTokenDescriptor (
|
||||
Subject = ClaimsIdentity [|
|
||||
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id)
|
||||
Claim (ClaimTypes.Name, Citizen.name citizen)
|
||||
|],
|
||||
Expires = DateTime.UtcNow.AddHours 2.,
|
||||
Issuer = "https://noagendacareers.com",
|
||||
Audience = "https://noagendacareers.com",
|
||||
SigningCredentials = SigningCredentials (
|
||||
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]),
|
||||
SecurityAlgorithms.HmacSha256Signature)
|
||||
)
|
||||
)
|
||||
tokenHandler.WriteToken token
|
||||
|
||||
554
src/JobsJobsJobs/Api/Data.fs
Normal file
554
src/JobsJobsJobs/Api/Data.fs
Normal file
@@ -0,0 +1,554 @@
|
||||
/// Data access functions for Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.Api.Data
|
||||
|
||||
open FSharp.Control.Tasks
|
||||
open JobsJobsJobs.Domain.Types
|
||||
open Polly
|
||||
open RethinkDb.Driver
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Shorthand for the RethinkDB R variable (how every command starts)
|
||||
let private r = RethinkDB.R
|
||||
|
||||
/// Shorthand for await task / run sync / ignore (used in non-async contexts)
|
||||
let awaitIgnore x = x |> Async.AwaitTask |> Async.RunSynchronously |> ignore
|
||||
|
||||
|
||||
/// JSON converters used with RethinkDB persistence
|
||||
module Converters =
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open Microsoft.FSharpLu.Json
|
||||
open Newtonsoft.Json
|
||||
open System
|
||||
|
||||
/// JSON converter for citizen IDs
|
||||
type CitizenIdJsonConverter() =
|
||||
inherit JsonConverter<CitizenId>()
|
||||
override __.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) =
|
||||
writer.WriteValue (CitizenId.toString value)
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> CitizenId.ofString) reader.Value
|
||||
|
||||
/// JSON converter for continent IDs
|
||||
type ContinentIdJsonConverter() =
|
||||
inherit JsonConverter<ContinentId>()
|
||||
override __.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) =
|
||||
writer.WriteValue (ContinentId.toString value)
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> ContinentId.ofString) reader.Value
|
||||
|
||||
/// JSON converter for Markdown strings
|
||||
type MarkdownStringJsonConverter() =
|
||||
inherit JsonConverter<MarkdownString>()
|
||||
override __.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) =
|
||||
let (Text text) = value
|
||||
writer.WriteValue text
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) =
|
||||
(string >> Text) reader.Value
|
||||
|
||||
/// JSON converter for listing IDs
|
||||
type ListingIdJsonConverter() =
|
||||
inherit JsonConverter<ListingId>()
|
||||
override __.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) =
|
||||
writer.WriteValue (ListingId.toString value)
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> ListingId.ofString) reader.Value
|
||||
|
||||
/// JSON converter for skill IDs
|
||||
type SkillIdJsonConverter() =
|
||||
inherit JsonConverter<SkillId>()
|
||||
override __.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) =
|
||||
writer.WriteValue (SkillId.toString value)
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> SkillId.ofString) reader.Value
|
||||
|
||||
/// JSON converter for success report IDs
|
||||
type SuccessIdJsonConverter() =
|
||||
inherit JsonConverter<SuccessId>()
|
||||
override __.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) =
|
||||
writer.WriteValue (SuccessId.toString value)
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> SuccessId.ofString) reader.Value
|
||||
|
||||
/// All JSON converters needed for the application
|
||||
let all () = [
|
||||
CitizenIdJsonConverter () :> JsonConverter
|
||||
upcast ContinentIdJsonConverter ()
|
||||
upcast MarkdownStringJsonConverter ()
|
||||
upcast ListingIdJsonConverter ()
|
||||
upcast SkillIdJsonConverter ()
|
||||
upcast SuccessIdJsonConverter ()
|
||||
upcast CompactUnionJsonConverter ()
|
||||
]
|
||||
|
||||
|
||||
/// Table names
|
||||
[<RequireQualifiedAccess>]
|
||||
module Table =
|
||||
/// The user (citizen of Gitmo Nation) table
|
||||
let Citizen = "citizen"
|
||||
/// The continent table
|
||||
let Continent = "continent"
|
||||
/// The job listing table
|
||||
let Listing = "listing"
|
||||
/// The citizen employment profile table
|
||||
let Profile = "profile"
|
||||
/// The success story table
|
||||
let Success = "success"
|
||||
/// All tables
|
||||
let all () = [ Citizen; Continent; Listing; Profile; Success ]
|
||||
|
||||
|
||||
/// Functions run at startup
|
||||
[<RequireQualifiedAccess>]
|
||||
module Startup =
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open NodaTime
|
||||
open NodaTime.Serialization.JsonNet
|
||||
|
||||
/// Create a RethinkDB connection
|
||||
let createConnection (cfg : IConfigurationSection) (log : ILogger) =
|
||||
|
||||
// Add all required JSON converters
|
||||
Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore
|
||||
Converters.all ()
|
||||
|> List.iter Converter.Serializer.Converters.Add
|
||||
// Read the configuration and create a connection
|
||||
let bldr =
|
||||
seq<Connection.Builder -> Connection.Builder> {
|
||||
yield fun b -> match cfg.["Hostname"] with null -> b | host -> b.Hostname host
|
||||
yield fun b -> match cfg.["Port"] with null -> b | port -> (int >> b.Port) port
|
||||
yield fun b -> match cfg.["AuthKey"] with null -> b | key -> b.AuthKey key
|
||||
yield fun b -> match cfg.["Db"] with null -> b | db -> b.Db db
|
||||
yield fun b -> match cfg.["Timeout"] with null -> b | time -> (int >> b.Timeout) time
|
||||
}
|
||||
|> Seq.fold (fun b step -> step b) (r.Connection ())
|
||||
match log.IsEnabled LogLevel.Debug with
|
||||
| true -> log.LogDebug $"RethinkDB: Connecting to {bldr.Hostname}:{bldr.Port}, database {bldr.Db}"
|
||||
| false -> ()
|
||||
bldr.Connect () :> IConnection
|
||||
|
||||
/// Ensure the data, tables, and indexes that are required exist
|
||||
let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task {
|
||||
// Ensure the database exists
|
||||
match cfg.["Db"] |> Option.ofObj with
|
||||
| Some database ->
|
||||
let! dbs = r.DbList().RunResultAsync<string list> conn
|
||||
match dbs |> List.contains database with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation $"Creating database {database}..."
|
||||
let! _ = r.DbCreate(database).RunWriteAsync conn
|
||||
()
|
||||
| None -> ()
|
||||
// Ensure the tables exist
|
||||
let! tables = r.TableList().RunResultAsync<string list> conn
|
||||
Table.all ()
|
||||
|> List.iter (
|
||||
fun tbl ->
|
||||
match tables |> List.contains tbl with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation $"Creating {tbl} table..."
|
||||
r.TableCreate(tbl).RunWriteAsync conn |> awaitIgnore)
|
||||
// Ensure the indexes exist
|
||||
let ensureIndexes table indexes = task {
|
||||
let! tblIdxs = r.Table(table).IndexList().RunResultAsync<string list> conn
|
||||
indexes
|
||||
|> List.iter (
|
||||
fun idx ->
|
||||
match tblIdxs |> List.contains idx with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation $"Creating \"{idx}\" index on {table}"
|
||||
r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore)
|
||||
}
|
||||
do! ensureIndexes Table.Citizen [ "naUser" ]
|
||||
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
|
||||
do! ensureIndexes Table.Profile [ "continentId" ]
|
||||
do! ensureIndexes Table.Success [ "citizenId" ]
|
||||
}
|
||||
|
||||
|
||||
/// Determine if a record type (not nullable) is null
|
||||
let toOption x = match x |> box |> isNull with true -> None | false -> Some x
|
||||
|
||||
[<AutoOpen>]
|
||||
module private Reconnect =
|
||||
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Execute a query with a retry policy that will reconnect to RethinkDB if it has gone away
|
||||
let withReconn (conn : IConnection) (f : IConnection -> Task<'T>) =
|
||||
Policy
|
||||
.Handle<ReqlDriverError>()
|
||||
.RetryAsync(System.Action<exn, int> (fun ex _ ->
|
||||
printf "Encountered RethinkDB exception: %s" ex.Message
|
||||
match ex.Message.Contains "socket" with
|
||||
| true ->
|
||||
printf "Reconnecting to RethinkDB"
|
||||
(conn :?> Connection).Reconnect false
|
||||
| false -> ()))
|
||||
.ExecuteAsync(fun () -> f conn)
|
||||
|
||||
/// Execute a query that returns one or none item, using the reconnect logic
|
||||
let withReconnOption (conn : IConnection) (f : IConnection -> Task<'T>) =
|
||||
fun c -> task {
|
||||
let! it = f c
|
||||
return toOption it
|
||||
}
|
||||
|> withReconn conn
|
||||
|
||||
/// Execute a query that does not return a result, using the above reconnect logic
|
||||
let withReconnIgnore (conn : IConnection) (f : IConnection -> Task<'T>) =
|
||||
fun c -> task {
|
||||
let! _ = f c
|
||||
()
|
||||
}
|
||||
|> withReconn conn
|
||||
|
||||
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
|
||||
let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)%s"
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
/// Profile data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
||||
let count conn =
|
||||
r.Table(Table.Profile)
|
||||
.Count()
|
||||
.RunResultAsync<int64>
|
||||
|> withReconn conn
|
||||
|
||||
/// Find a profile by citizen ID
|
||||
let findById (citizenId : CitizenId) conn =
|
||||
r.Table(Table.Profile)
|
||||
.Get(citizenId)
|
||||
.RunResultAsync<Profile>
|
||||
|> withReconnOption conn
|
||||
|
||||
/// Insert or update a profile
|
||||
let save (profile : Profile) conn =
|
||||
r.Table(Table.Profile)
|
||||
.Get(profile.id)
|
||||
.Replace(profile)
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Delete a citizen's profile
|
||||
let delete (citizenId : CitizenId) conn =
|
||||
r.Table(Table.Profile)
|
||||
.Get(citizenId)
|
||||
.Delete()
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Search profiles (logged-on users)
|
||||
let search (srch : ProfileSearch) conn =
|
||||
fun c ->
|
||||
(seq {
|
||||
match srch.continentId with
|
||||
| Some conId ->
|
||||
yield (fun (q : ReqlExpr) ->
|
||||
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.remoteWork with
|
||||
| "" -> ()
|
||||
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||
match srch.skill with
|
||||
| Some skl ->
|
||||
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
||||
upcast it.G("skills").Contains (ReqlFunction1(fun s ->
|
||||
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.bioExperience with
|
||||
| Some text ->
|
||||
let txt = regexContains text
|
||||
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
||||
upcast it.G("biography").Match(txt).Or (it.G("experience").Match txt))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
}
|
||||
|> Seq.toList
|
||||
|> List.fold
|
||||
(fun q f -> f q)
|
||||
(r.Table(Table.Profile)
|
||||
.EqJoin("id", r.Table Table.Citizen)
|
||||
.Without(r.HashMap ("right", "id"))
|
||||
.Zip () :> ReqlExpr))
|
||||
.Merge(ReqlFunction1 (fun it ->
|
||||
upcast r
|
||||
.HashMap("displayName",
|
||||
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||
it.G "naUser"))
|
||||
.With ("citizenId", it.G "id")))
|
||||
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn")
|
||||
.OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ()))
|
||||
.RunResultAsync<ProfileSearchResult list> c
|
||||
|> withReconn conn
|
||||
|
||||
// Search profiles (public)
|
||||
let publicSearch (srch : PublicSearch) conn =
|
||||
fun c ->
|
||||
(seq {
|
||||
match srch.continentId with
|
||||
| Some conId ->
|
||||
yield (fun (q : ReqlExpr) ->
|
||||
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.region with
|
||||
| Some reg ->
|
||||
yield (fun q ->
|
||||
q.Filter (ReqlFunction1 (fun it -> upcast it.G("region").Match (regexContains reg))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.remoteWork with
|
||||
| "" -> ()
|
||||
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||
match srch.skill with
|
||||
| Some skl ->
|
||||
yield (fun q -> q.Filter (ReqlFunction1 (fun it ->
|
||||
upcast it.G("skills").Contains (ReqlFunction1(fun s ->
|
||||
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
}
|
||||
|> Seq.toList
|
||||
|> List.fold
|
||||
(fun q f -> f q)
|
||||
(r.Table(Table.Profile)
|
||||
.EqJoin("continentId", r.Table Table.Continent)
|
||||
.Without(r.HashMap ("right", "id"))
|
||||
.Zip()
|
||||
.Filter(r.HashMap ("isPublic", true)) :> ReqlExpr))
|
||||
.Merge(ReqlFunction1 (fun it ->
|
||||
upcast r
|
||||
.HashMap("skills",
|
||||
it.G("skills").Map (ReqlFunction1 (fun skill ->
|
||||
upcast r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description",
|
||||
skill.G("description").Add(" (").Add(skill.G("notes")).Add ")"))))
|
||||
.With("continent", it.G "name")))
|
||||
.Pluck("continent", "region", "skills", "remoteWork")
|
||||
.RunResultAsync<PublicSearchResult list> c
|
||||
|> withReconn conn
|
||||
|
||||
/// Citizen data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Citizen =
|
||||
|
||||
/// Find a citizen by their ID
|
||||
let findById (citizenId : CitizenId) conn =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.RunResultAsync<Citizen>
|
||||
|> withReconnOption conn
|
||||
|
||||
/// Find a citizen by their No Agenda Social username
|
||||
let findByNaUser (naUser : string) conn =
|
||||
r.Table(Table.Citizen)
|
||||
.GetAll(naUser).OptArg("index", "naUser").Nth(0)
|
||||
.RunResultAsync<Citizen>
|
||||
|> withReconnOption conn
|
||||
|
||||
/// Add a citizen
|
||||
let add (citizen : Citizen) conn =
|
||||
r.Table(Table.Citizen)
|
||||
.Insert(citizen)
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Update the display name and last seen on date for a citizen
|
||||
let logOnUpdate (citizen : Citizen) conn =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizen.id)
|
||||
.Update(r.HashMap( nameof citizen.displayName, citizen.displayName)
|
||||
.With (nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Delete a citizen
|
||||
let delete citizenId conn =
|
||||
fun c -> task {
|
||||
do! Profile.delete citizenId c
|
||||
let! _ =
|
||||
r.Table(Table.Success)
|
||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||
.Delete()
|
||||
.RunWriteAsync c
|
||||
let! _ =
|
||||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||
.Delete()
|
||||
.RunWriteAsync c
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.Delete()
|
||||
.RunWriteAsync c
|
||||
()
|
||||
}
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Update a citizen's real name
|
||||
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.Update(r.HashMap (nameof realName, realName))
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
|
||||
/// Continent data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Continent =
|
||||
|
||||
/// Get all continents
|
||||
let all conn =
|
||||
r.Table(Table.Continent)
|
||||
.RunResultAsync<Continent list>
|
||||
|> withReconn conn
|
||||
|
||||
/// Get a continent by its ID
|
||||
let findById (contId : ContinentId) conn =
|
||||
r.Table(Table.Continent)
|
||||
.Get(contId)
|
||||
.RunResultAsync<Continent>
|
||||
|> withReconnOption conn
|
||||
|
||||
|
||||
/// Job listing data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Listing =
|
||||
|
||||
open NodaTime
|
||||
|
||||
/// Find all job listings posted by the given citizen
|
||||
let findByCitizen (citizenId : CitizenId) conn =
|
||||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||
.EqJoin("continentId", r.Table Table.Continent)
|
||||
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
||||
.RunResultAsync<ListingForView list>
|
||||
|> withReconn conn
|
||||
|
||||
/// Find a listing by its ID
|
||||
let findById (listingId : ListingId) conn =
|
||||
r.Table(Table.Listing)
|
||||
.Get(listingId)
|
||||
.RunResultAsync<Listing>
|
||||
|> withReconnOption conn
|
||||
|
||||
/// Find a listing by its ID for viewing (includes continent information)
|
||||
let findByIdForView (listingId : ListingId) conn =
|
||||
fun c -> task {
|
||||
let! listing =
|
||||
r.Table(Table.Listing)
|
||||
.Filter(r.HashMap ("id", listingId))
|
||||
.EqJoin("continentId", r.Table Table.Continent)
|
||||
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
||||
.RunResultAsync<ListingForView list> c
|
||||
return List.tryHead listing
|
||||
}
|
||||
|> withReconn conn
|
||||
|
||||
/// Add a listing
|
||||
let add (listing : Listing) conn =
|
||||
r.Table(Table.Listing)
|
||||
.Insert(listing)
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Update a listing
|
||||
let update (listing : Listing) conn =
|
||||
r.Table(Table.Listing)
|
||||
.Get(listing.id)
|
||||
.Replace(listing)
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Expire a listing
|
||||
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn =
|
||||
r.Table(Table.Listing)
|
||||
.Get(listingId)
|
||||
.Update(r.HashMap("isExpired", true).With("wasFilledHere", fromHere).With ("updatedOn", now))
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
/// Search job listings
|
||||
let search (srch : ListingSearch) conn =
|
||||
fun c ->
|
||||
(seq {
|
||||
match srch.continentId with
|
||||
| Some conId ->
|
||||
yield (fun (q : ReqlExpr) ->
|
||||
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.region with
|
||||
| Some rgn ->
|
||||
yield (fun q ->
|
||||
q.Filter (ReqlFunction1 (fun it ->
|
||||
upcast it.G(nameof srch.region).Match (regexContains rgn))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.remoteWork with
|
||||
| "" -> ()
|
||||
| _ ->
|
||||
yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||
match srch.text with
|
||||
| Some text ->
|
||||
yield (fun q ->
|
||||
q.Filter (ReqlFunction1 (fun it ->
|
||||
upcast it.G(nameof srch.text).Match (regexContains text))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
}
|
||||
|> Seq.toList
|
||||
|> List.fold
|
||||
(fun q f -> f q)
|
||||
(r.Table(Table.Listing)
|
||||
.GetAll(false).OptArg ("index", "isExpired") :> ReqlExpr))
|
||||
.EqJoin("continentId", r.Table Table.Continent)
|
||||
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
||||
.RunResultAsync<ListingForView list> c
|
||||
|> withReconn conn
|
||||
|
||||
|
||||
/// Success story data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Success =
|
||||
|
||||
/// Find a success report by its ID
|
||||
let findById (successId : SuccessId) conn =
|
||||
r.Table(Table.Success)
|
||||
.Get(successId)
|
||||
.RunResultAsync<Success>
|
||||
|> withReconnOption conn
|
||||
|
||||
/// Insert or update a success story
|
||||
let save (success : Success) conn =
|
||||
r.Table(Table.Success)
|
||||
.Get(success.id)
|
||||
.Replace(success)
|
||||
.RunWriteAsync
|
||||
|> withReconnIgnore conn
|
||||
|
||||
// Retrieve all success stories
|
||||
let all conn =
|
||||
r.Table(Table.Success)
|
||||
.EqJoin("citizenId", r.Table Table.Citizen)
|
||||
.Without(r.HashMap ("right", "id"))
|
||||
.Zip()
|
||||
.Merge(ReqlFunction1 (fun it ->
|
||||
upcast r
|
||||
.HashMap("citizenName",
|
||||
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||
it.G "naUser"))
|
||||
.With ("hasStory", it.G("story").Default_("").Gt "")))
|
||||
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory")
|
||||
.OrderBy(r.Desc "recordedOn")
|
||||
.RunResultAsync<StoryEntry list>
|
||||
|> withReconn conn
|
||||
535
src/JobsJobsJobs/Api/Handlers.fs
Normal file
535
src/JobsJobsJobs/Api/Handlers.fs
Normal file
@@ -0,0 +1,535 @@
|
||||
/// Route handlers for Giraffe endpoints
|
||||
module JobsJobsJobs.Api.Handlers
|
||||
|
||||
open FSharp.Control.Tasks
|
||||
open Giraffe
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
open JobsJobsJobs.Domain.Types
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Handler to return the files required for the Vue client app
|
||||
module Vue =
|
||||
|
||||
/// Handler that returns index.html (the Vue client app)
|
||||
let app = htmlFile "wwwroot/index.html"
|
||||
|
||||
|
||||
/// Handlers for error conditions
|
||||
module Error =
|
||||
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// URL prefixes for the Vue app
|
||||
let vueUrls = [
|
||||
"/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
||||
"/so-long"; "/success-story"
|
||||
]
|
||||
|
||||
/// Handler that will return a status code 404 and the text "Not Found"
|
||||
let notFound : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
let fac = ctx.GetService<ILoggerFactory>()
|
||||
let log = fac.CreateLogger("Handler")
|
||||
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||
| true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) ->
|
||||
log.LogInformation "Returning Vue app"
|
||||
return! Vue.app next ctx
|
||||
| _ ->
|
||||
log.LogInformation "Returning 404"
|
||||
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Handler that returns a 403 NOT AUTHORIZED response
|
||||
let notAuthorized : HttpHandler =
|
||||
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||
|
||||
/// Handler to log 500s and return a message we can display in the application
|
||||
let unexpectedError (ex: exn) (log : ILogger) =
|
||||
log.LogError(ex, "An unexpected error occurred")
|
||||
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
||||
|
||||
|
||||
/// Helper functions
|
||||
[<AutoOpen>]
|
||||
module Helpers =
|
||||
|
||||
open NodaTime
|
||||
open Microsoft.Extensions.Configuration
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Security.Claims
|
||||
|
||||
/// Get the NodaTime clock from the request context
|
||||
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
||||
|
||||
/// Get the application configuration from the request context
|
||||
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
||||
|
||||
/// Get the logger factory from the request context
|
||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||
|
||||
/// Get the RethinkDB connection from the request context
|
||||
let conn (ctx : HttpContext) = ctx.GetService<IConnection> ()
|
||||
|
||||
/// `None` if a `string option` is `None`, whitespace, or empty
|
||||
let noneIfBlank (s : string option) =
|
||||
s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten
|
||||
|
||||
/// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
|
||||
let noneIfEmpty = Option.ofObj >> noneIfBlank
|
||||
|
||||
/// Try to get the current user
|
||||
let tryUser (ctx : HttpContext) =
|
||||
ctx.User.FindFirst ClaimTypes.NameIdentifier
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun x -> x.Value)
|
||||
|
||||
/// Require a user to be logged in
|
||||
let authorize : HttpHandler =
|
||||
fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx
|
||||
|
||||
/// Get the ID of the currently logged in citizen
|
||||
// NOTE: if no one is logged in, this will raise an exception
|
||||
let currentCitizenId = tryUser >> Option.get >> CitizenId.ofString
|
||||
|
||||
/// Return an empty OK response
|
||||
let ok : HttpHandler = Successful.OK ""
|
||||
|
||||
|
||||
|
||||
/// Handlers for /api/citizen routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Citizen =
|
||||
|
||||
// GET: /api/citizen/log-on/[code]
|
||||
let logOn authCode : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
// Step 1 - Verify with Mastodon
|
||||
let cfg = (config ctx).GetSection "Auth"
|
||||
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
||||
|
||||
match! Auth.verifyWithMastodon authCode cfg log with
|
||||
| Ok account ->
|
||||
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
||||
let now = (clock ctx).GetCurrentInstant ()
|
||||
let dbConn = conn ctx
|
||||
let! citizen = task {
|
||||
match! Data.Citizen.findByNaUser account.Username dbConn with
|
||||
| None ->
|
||||
let it : Citizen =
|
||||
{ id = CitizenId.create ()
|
||||
naUser = account.Username
|
||||
displayName = noneIfEmpty account.DisplayName
|
||||
realName = None
|
||||
profileUrl = account.Url
|
||||
joinedOn = now
|
||||
lastSeenOn = now
|
||||
}
|
||||
do! Data.Citizen.add it dbConn
|
||||
return it
|
||||
| Some citizen ->
|
||||
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
|
||||
do! Data.Citizen.logOnUpdate it dbConn
|
||||
return it
|
||||
}
|
||||
|
||||
// Step 3 - Generate JWT
|
||||
return!
|
||||
json
|
||||
{ jwt = Auth.createJwt citizen cfg
|
||||
citizenId = CitizenId.toString citizen.id
|
||||
name = Citizen.name citizen
|
||||
} next ctx
|
||||
| Error err ->
|
||||
return! RequestErrors.BAD_REQUEST err next ctx
|
||||
}
|
||||
|
||||
// GET: /api/citizen/[id]
|
||||
let get citizenId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Citizen.findById (CitizenId citizenId) (conn ctx) with
|
||||
| Some citizen -> return! json citizen next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// DELETE: /api/citizen
|
||||
let delete : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
do! Data.Citizen.delete (currentCitizenId ctx) (conn ctx)
|
||||
return! ok next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/continent routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Continent =
|
||||
|
||||
// GET: /api/continent/all
|
||||
let all : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
let! continents = Data.Continent.all (conn ctx)
|
||||
return! json continents next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/listing[s] routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Listing =
|
||||
|
||||
open NodaTime
|
||||
open System
|
||||
|
||||
/// Parse the string we receive from JSON into a NodaTime local date
|
||||
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
|
||||
|
||||
// GET: /api/listings/mine
|
||||
let mine : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx)
|
||||
return! json listings next ctx
|
||||
}
|
||||
|
||||
// GET: /api/listing/[id]
|
||||
let get listingId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Listing.findById (ListingId listingId) (conn ctx) with
|
||||
| Some listing -> return! json listing next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /api/listing/view/[id]
|
||||
let view listingId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Listing.findByIdForView (ListingId listingId) (conn ctx) with
|
||||
| Some listing -> return! json listing next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST: /listings
|
||||
let add : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||
let now = (clock ctx).GetCurrentInstant ()
|
||||
do! Data.Listing.add
|
||||
{ id = ListingId.create ()
|
||||
citizenId = currentCitizenId ctx
|
||||
createdOn = now
|
||||
title = form.title
|
||||
continentId = ContinentId.ofString form.continentId
|
||||
region = form.region
|
||||
remoteWork = form.remoteWork
|
||||
isExpired = false
|
||||
updatedOn = now
|
||||
text = Text form.text
|
||||
neededBy = (form.neededBy |> Option.map parseDate)
|
||||
wasFilledHere = None
|
||||
} (conn ctx)
|
||||
return! ok next ctx
|
||||
}
|
||||
|
||||
// PUT: /api/listing/[id]
|
||||
let update listingId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let dbConn = conn ctx
|
||||
match! Data.Listing.findById (ListingId listingId) dbConn with
|
||||
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||
| Some listing ->
|
||||
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||
do! Data.Listing.update
|
||||
{ listing with
|
||||
title = form.title
|
||||
continentId = ContinentId.ofString form.continentId
|
||||
region = form.region
|
||||
remoteWork = form.remoteWork
|
||||
text = Text form.text
|
||||
neededBy = form.neededBy |> Option.map parseDate
|
||||
updatedOn = (clock ctx).GetCurrentInstant ()
|
||||
} dbConn
|
||||
return! ok next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH: /api/listing/[id]
|
||||
let expire listingId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let dbConn = conn ctx
|
||||
let now = clock(ctx).GetCurrentInstant ()
|
||||
match! Data.Listing.findById (ListingId listingId) dbConn with
|
||||
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||
| Some listing ->
|
||||
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
|
||||
do! Data.Listing.expire listing.id form.fromHere now dbConn
|
||||
match form.successStory with
|
||||
| Some storyText ->
|
||||
do! Data.Success.save
|
||||
{ id = SuccessId.create()
|
||||
citizenId = currentCitizenId ctx
|
||||
recordedOn = now
|
||||
fromHere = form.fromHere
|
||||
source = "listing"
|
||||
story = (Text >> Some) storyText
|
||||
} dbConn
|
||||
| None -> ()
|
||||
return! ok next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
|
||||
}
|
||||
|
||||
// GET: /api/listing/search
|
||||
let search : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let search = ctx.BindQueryString<ListingSearch> ()
|
||||
let! results = Data.Listing.search search (conn ctx)
|
||||
return! json results next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/profile routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
||||
// GET: /api/profile
|
||||
// This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet
|
||||
// is not an error). The "get" handler returns a 404 if a profile is not found.
|
||||
let current : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Profile.findById (currentCitizenId ctx) (conn ctx) with
|
||||
| Some profile -> return! json profile next ctx
|
||||
| None -> return! Successful.NO_CONTENT next ctx
|
||||
}
|
||||
|
||||
// GET: /api/profile/get/[id]
|
||||
let get citizenId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Profile.findById (CitizenId citizenId) (conn ctx) with
|
||||
| Some profile -> return! json profile next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /api/profile/view/[id]
|
||||
let view citizenId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let citId = CitizenId citizenId
|
||||
let dbConn = conn ctx
|
||||
match! Data.Profile.findById citId dbConn with
|
||||
| Some profile ->
|
||||
match! Data.Citizen.findById citId dbConn with
|
||||
| Some citizen ->
|
||||
match! Data.Continent.findById profile.continentId dbConn with
|
||||
| Some continent ->
|
||||
return!
|
||||
json {
|
||||
profile = profile
|
||||
citizen = citizen
|
||||
continent = continent
|
||||
} next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /api/profile/count
|
||||
let count : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let! theCount = Data.Profile.count (conn ctx)
|
||||
return! json { count = theCount } next ctx
|
||||
}
|
||||
|
||||
// POST: /api/profile/save
|
||||
let save : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let citizenId = currentCitizenId ctx
|
||||
let dbConn = conn ctx
|
||||
let! form = ctx.BindJsonAsync<ProfileForm>()
|
||||
let! profile = task {
|
||||
match! Data.Profile.findById citizenId dbConn with
|
||||
| Some p -> return p
|
||||
| None -> return { Profile.empty with id = citizenId }
|
||||
}
|
||||
do! Data.Profile.save
|
||||
{ profile with
|
||||
seekingEmployment = form.isSeekingEmployment
|
||||
isPublic = form.isPublic
|
||||
continentId = ContinentId.ofString form.continentId
|
||||
region = form.region
|
||||
remoteWork = form.remoteWork
|
||||
fullTime = form.fullTime
|
||||
biography = Text form.biography
|
||||
lastUpdatedOn = (clock ctx).GetCurrentInstant ()
|
||||
experience = noneIfBlank form.experience |> Option.map Text
|
||||
skills = form.skills
|
||||
|> List.map (fun s ->
|
||||
{ id = match s.id.StartsWith "new" with
|
||||
| true -> SkillId.create ()
|
||||
| false -> SkillId.ofString s.id
|
||||
description = s.description
|
||||
notes = noneIfBlank s.notes
|
||||
})
|
||||
} dbConn
|
||||
do! Data.Citizen.realNameUpdate citizenId (noneIfBlank (Some form.realName)) dbConn
|
||||
return! ok next ctx
|
||||
}
|
||||
|
||||
// PATCH: /api/profile/employment-found
|
||||
let employmentFound : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let dbConn = conn ctx
|
||||
match! Data.Profile.findById (currentCitizenId ctx) dbConn with
|
||||
| Some profile ->
|
||||
do! Data.Profile.save { profile with seekingEmployment = false } dbConn
|
||||
return! ok next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// DELETE: /api/profile
|
||||
let delete : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
do! Data.Profile.delete (currentCitizenId ctx) (conn ctx)
|
||||
return! ok next ctx
|
||||
}
|
||||
|
||||
// GET: /api/profile/search
|
||||
let search : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let search = ctx.BindQueryString<ProfileSearch> ()
|
||||
let! results = Data.Profile.search search (conn ctx)
|
||||
return! json results next ctx
|
||||
}
|
||||
|
||||
// GET: /api/profile/public-search
|
||||
let publicSearch : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
let search = ctx.BindQueryString<PublicSearch> ()
|
||||
let! results = Data.Profile.publicSearch search (conn ctx)
|
||||
return! json results next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/success routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Success =
|
||||
|
||||
open System
|
||||
|
||||
// GET: /api/success/[id]
|
||||
let get successId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Success.findById (SuccessId successId) (conn ctx) with
|
||||
| Some story -> return! json story next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /api/success/list
|
||||
let all : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let! stories = Data.Success.all (conn ctx)
|
||||
return! json stories next ctx
|
||||
}
|
||||
|
||||
// POST: /api/success/save
|
||||
let save : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let citizenId = currentCitizenId ctx
|
||||
let dbConn = conn ctx
|
||||
let now = (clock ctx).GetCurrentInstant ()
|
||||
let! form = ctx.BindJsonAsync<StoryForm> ()
|
||||
let! success = task {
|
||||
match form.id with
|
||||
| "new" ->
|
||||
return Some { id = SuccessId.create ()
|
||||
citizenId = citizenId
|
||||
recordedOn = now
|
||||
fromHere = form.fromHere
|
||||
source = "profile"
|
||||
story = noneIfEmpty form.story |> Option.map Text
|
||||
}
|
||||
| successId ->
|
||||
match! Data.Success.findById (SuccessId.ofString successId) dbConn with
|
||||
| Some story when story.citizenId = citizenId ->
|
||||
return Some { story with
|
||||
fromHere = form.fromHere
|
||||
story = noneIfEmpty form.story |> Option.map Text
|
||||
}
|
||||
| Some _ | None -> return None
|
||||
}
|
||||
match success with
|
||||
| Some story ->
|
||||
do! Data.Success.save story dbConn
|
||||
return! ok next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// All available endpoints for the application
|
||||
let allEndpoints = [
|
||||
subRoute "/api" [
|
||||
subRoute "/citizen" [
|
||||
GET_HEAD [
|
||||
routef "/log-on/%s" Citizen.logOn
|
||||
routef "/%O" Citizen.get
|
||||
]
|
||||
DELETE [ route "" Citizen.delete ]
|
||||
]
|
||||
GET_HEAD [ route "/continents" Continent.all ]
|
||||
subRoute "/listing" [
|
||||
GET_HEAD [
|
||||
routef "/%O" Listing.get
|
||||
route "/search" Listing.search
|
||||
routef "/%O/view" Listing.view
|
||||
route "s/mine" Listing.mine
|
||||
]
|
||||
PATCH [
|
||||
routef "/%O" Listing.expire
|
||||
]
|
||||
POST [
|
||||
route "s" Listing.add
|
||||
]
|
||||
PUT [
|
||||
routef "/%O" Listing.update
|
||||
]
|
||||
]
|
||||
subRoute "/profile" [
|
||||
GET_HEAD [
|
||||
route "" Profile.current
|
||||
route "/count" Profile.count
|
||||
routef "/%O" Profile.get
|
||||
routef "/%O/view" Profile.view
|
||||
route "/public-search" Profile.publicSearch
|
||||
route "/search" Profile.search
|
||||
]
|
||||
PATCH [ route "/employment-found" Profile.employmentFound ]
|
||||
POST [ route "" Profile.save ]
|
||||
]
|
||||
subRoute "/success" [
|
||||
GET_HEAD [
|
||||
routef "/%O" Success.get
|
||||
route "es" Success.all
|
||||
]
|
||||
POST [ route "" Success.save ]
|
||||
]
|
||||
]
|
||||
]
|
||||
6
src/JobsJobsJobs/Api/appsettings.json
Normal file
6
src/JobsJobsJobs/Api/appsettings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Rethink": {
|
||||
"Hostname": "localhost",
|
||||
"Db": "jobsjobsjobs"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user