524 lines
20 KiB
Forth
524 lines
20 KiB
Forth
/// Route handlers for Giraffe endpoints
|
|
module JobsJobsJobs.Api.Handlers
|
|
|
|
open Giraffe
|
|
open JobsJobsJobs.Domain
|
|
open JobsJobsJobs.Domain.SharedTypes
|
|
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"
|
|
let path = string ctx.Request.Path
|
|
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
|
| true when path = "/" || vueUrls |> List.exists path.StartsWith ->
|
|
log.LogInformation "Returning Vue app"
|
|
return! Vue.app next ctx
|
|
| _ ->
|
|
log.LogInformation "Returning 404"
|
|
return! RequestErrors.NOT_FOUND $"The URL {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
|
|
|
|
|
|
open NodaTime
|
|
|
|
/// Helper functions
|
|
[<AutoOpen>]
|
|
module Helpers =
|
|
|
|
open System.Security.Claims
|
|
open Microsoft.Extensions.Configuration
|
|
open Microsoft.Extensions.Options
|
|
|
|
/// Get the NodaTime clock from the request context
|
|
let now (ctx : HttpContext) = ctx.GetService<IClock>().GetCurrentInstant ()
|
|
|
|
/// Get the application configuration from the request context
|
|
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
|
|
|
/// Get the authorization configuration from the request context
|
|
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
|
|
|
|
/// Get the logger factory from the request context
|
|
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
|
|
|
/// `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 ""
|
|
|
|
|
|
open System
|
|
open JobsJobsJobs.Data
|
|
|
|
/// Handlers for /api/citizen routes
|
|
[<RequireQualifiedAccess>]
|
|
module Citizen =
|
|
|
|
// POST: /api/citizen/register
|
|
let register : HttpHandler = fun next ctx -> task {
|
|
let! form = ctx.BindJsonAsync<CitizenRegistrationForm> ()
|
|
if form.Password.Length < 8 || form.ConfirmPassword.Length < 8 || form.Password <> form.ConfirmPassword then
|
|
return! RequestErrors.BAD_REQUEST "Password out of range" next ctx
|
|
else
|
|
let now = now ctx
|
|
let noPass =
|
|
{ Citizen.empty with
|
|
Id = CitizenId.create ()
|
|
Email = form.Email
|
|
FirstName = form.FirstName
|
|
LastName = form.LastName
|
|
DisplayName = noneIfBlank form.DisplayName
|
|
JoinedOn = now
|
|
LastSeenOn = now
|
|
}
|
|
let citizen = { noPass with PasswordHash = Auth.Passwords.hash noPass form.Password }
|
|
let security =
|
|
{ SecurityInfo.empty with
|
|
Id = citizen.Id
|
|
AccountLocked = true
|
|
Token = Some (Auth.createToken citizen)
|
|
TokenUsage = Some "confirm"
|
|
TokenExpires = Some (now + (Duration.FromDays 3))
|
|
}
|
|
let! success = Citizens.register citizen security
|
|
if success then
|
|
let! emailResponse = Email.sendAccountConfirmation citizen security
|
|
let logFac = logger ctx
|
|
let log = logFac.CreateLogger "JobsJobsJobs.Api.Handlers.Citizen"
|
|
log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}"
|
|
return! ok next ctx
|
|
else
|
|
return! RequestErrors.CONFLICT "" next ctx
|
|
}
|
|
|
|
// PATCH: /api/citizen/confirm
|
|
let confirmToken : HttpHandler = fun next ctx -> task {
|
|
let! form = ctx.BindJsonAsync<{| token : string |}> ()
|
|
let! valid = Citizens.confirmAccount form.token
|
|
return! json {| Valid = valid |} next ctx
|
|
}
|
|
|
|
// DELETE: /api/citizen/deny
|
|
let denyToken : HttpHandler = fun next ctx -> task {
|
|
let! form = ctx.BindJsonAsync<{| token : string |}> ()
|
|
let! valid = Citizens.denyAccount form.token
|
|
return! json {| Valid = valid |} next ctx
|
|
}
|
|
|
|
// POST: /api/citizen/log-on
|
|
let logOn : HttpHandler = fun next ctx -> task {
|
|
let! form = ctx.BindJsonAsync<LogOnForm> ()
|
|
match! Citizens.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with
|
|
| Ok citizen ->
|
|
return!
|
|
json
|
|
{ Jwt = Auth.createJwt citizen (authConfig ctx)
|
|
CitizenId = CitizenId.toString citizen.Id
|
|
Name = Citizen.name citizen
|
|
} next ctx
|
|
| Error msg -> return! RequestErrors.BAD_REQUEST msg next ctx
|
|
}
|
|
|
|
// GET: /api/citizen/[id]
|
|
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
|
|
match! Citizens.findById (CitizenId citizenId) with
|
|
| Some citizen -> return! json { citizen with PasswordHash = "" } next ctx
|
|
| None -> return! Error.notFound next ctx
|
|
}
|
|
|
|
// PATCH: /api/citizen/account
|
|
let account : HttpHandler = authorize >=> fun next ctx -> task {
|
|
let! form = ctx.BindJsonAsync<AccountProfileForm> ()
|
|
match! Citizens.findById (currentCitizenId ctx) with
|
|
| Some citizen ->
|
|
let password =
|
|
if defaultArg form.NewPassword "" = "" then citizen.PasswordHash
|
|
else Auth.Passwords.hash citizen form.NewPassword.Value
|
|
do! Citizens.save
|
|
{ citizen with
|
|
FirstName = form.FirstName
|
|
LastName = form.LastName
|
|
DisplayName = noneIfBlank form.DisplayName
|
|
PasswordHash = password
|
|
OtherContacts = form.Contacts
|
|
|> List.map (fun c -> {
|
|
Id = if c.Id.StartsWith "new" then OtherContactId.create ()
|
|
else OtherContactId.ofString c.Id
|
|
ContactType = ContactType.parse c.ContactType
|
|
Name = noneIfBlank c.Name
|
|
Value = c.Value
|
|
IsPublic = c.IsPublic
|
|
})
|
|
}
|
|
return! ok next ctx
|
|
| None -> return! Error.notFound next ctx
|
|
}
|
|
|
|
// DELETE: /api/citizen
|
|
let delete : HttpHandler = authorize >=> fun next ctx -> task {
|
|
do! Citizens.deleteById (currentCitizenId 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 = Continents.all ()
|
|
return! json continents next ctx
|
|
}
|
|
|
|
|
|
/// Handlers for /api/listing[s] routes
|
|
[<RequireQualifiedAccess>]
|
|
module Listing =
|
|
|
|
/// 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 = Listings.findByCitizen (currentCitizenId ctx)
|
|
return! json listings next ctx
|
|
}
|
|
|
|
// GET: /api/listing/[id]
|
|
let get listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
|
match! Listings.findById (ListingId listingId) 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! Listings.findByIdForView (ListingId listingId) 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 = now ctx
|
|
do! Listings.save {
|
|
Id = ListingId.create ()
|
|
CitizenId = currentCitizenId ctx
|
|
CreatedOn = now
|
|
Title = form.Title
|
|
ContinentId = ContinentId.ofString form.ContinentId
|
|
Region = form.Region
|
|
IsRemote = form.RemoteWork
|
|
IsExpired = false
|
|
UpdatedOn = now
|
|
Text = Text form.Text
|
|
NeededBy = (form.NeededBy |> Option.map parseDate)
|
|
WasFilledHere = None
|
|
IsLegacy = false
|
|
}
|
|
return! ok next ctx
|
|
}
|
|
|
|
// PUT: /api/listing/[id]
|
|
let update listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
|
match! Listings.findById (ListingId listingId) with
|
|
| Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
|
| Some listing ->
|
|
let! form = ctx.BindJsonAsync<ListingForm> ()
|
|
do! Listings.save
|
|
{ listing with
|
|
Title = form.Title
|
|
ContinentId = ContinentId.ofString form.ContinentId
|
|
Region = form.Region
|
|
IsRemote = form.RemoteWork
|
|
Text = Text form.Text
|
|
NeededBy = form.NeededBy |> Option.map parseDate
|
|
UpdatedOn = now ctx
|
|
}
|
|
return! ok next ctx
|
|
| None -> return! Error.notFound next ctx
|
|
}
|
|
|
|
// PATCH: /api/listing/[id]
|
|
let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
|
let now = now ctx
|
|
match! Listings.findById (ListingId listingId) with
|
|
| Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
|
| Some listing ->
|
|
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
|
|
do! Listings.save
|
|
{ listing with
|
|
IsExpired = true
|
|
WasFilledHere = Some form.FromHere
|
|
UpdatedOn = now
|
|
}
|
|
match form.SuccessStory with
|
|
| Some storyText ->
|
|
do! Successes.save
|
|
{ Id = SuccessId.create()
|
|
CitizenId = currentCitizenId ctx
|
|
RecordedOn = now
|
|
IsFromHere = form.FromHere
|
|
Source = "listing"
|
|
Story = (Text >> Some) storyText
|
|
}
|
|
| 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 = Listings.search search
|
|
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! Profiles.findById (currentCitizenId 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! Profiles.findById (CitizenId citizenId) 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 {
|
|
match! Profiles.findByIdForView (CitizenId citizenId) with
|
|
| Some profile -> return! json profile next ctx
|
|
| None -> return! Error.notFound next ctx
|
|
}
|
|
|
|
// GET: /api/profile/count
|
|
let count : HttpHandler = authorize >=> fun next ctx -> task {
|
|
let! theCount = Profiles.count ()
|
|
return! json {| Count = theCount |} next ctx
|
|
}
|
|
|
|
// POST: /api/profile/save
|
|
let save : HttpHandler = authorize >=> fun next ctx -> task {
|
|
let citizenId = currentCitizenId ctx
|
|
let! form = ctx.BindJsonAsync<ProfileForm>()
|
|
let! profile = task {
|
|
match! Profiles.findById citizenId with
|
|
| Some p -> return p
|
|
| None -> return { Profile.empty with Id = citizenId }
|
|
}
|
|
do! Profiles.save
|
|
{ profile with
|
|
IsSeekingEmployment = form.IsSeekingEmployment
|
|
IsPubliclySearchable = form.IsPublic
|
|
ContinentId = ContinentId.ofString form.ContinentId
|
|
Region = form.Region
|
|
IsRemote = form.RemoteWork
|
|
IsFullTime = form.FullTime
|
|
Biography = Text form.Biography
|
|
LastUpdatedOn = now ctx
|
|
Experience = noneIfBlank form.Experience |> Option.map Text
|
|
Skills = form.Skills
|
|
|> List.map (fun s ->
|
|
{ Id = if s.Id.StartsWith "new" then SkillId.create ()
|
|
else SkillId.ofString s.Id
|
|
Description = s.Description
|
|
Notes = noneIfBlank s.Notes
|
|
})
|
|
}
|
|
return! ok next ctx
|
|
}
|
|
|
|
// PATCH: /api/profile/employment-found
|
|
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
|
|
match! Profiles.findById (currentCitizenId ctx) with
|
|
| Some profile ->
|
|
do! Profiles.save { profile with IsSeekingEmployment = false }
|
|
return! ok next ctx
|
|
| None -> return! Error.notFound next ctx
|
|
}
|
|
|
|
// DELETE: /api/profile
|
|
let delete : HttpHandler = authorize >=> fun next ctx -> task {
|
|
do! Profiles.deleteById (currentCitizenId ctx)
|
|
return! ok next ctx
|
|
}
|
|
|
|
// GET: /api/profile/search
|
|
let search : HttpHandler = authorize >=> fun next ctx -> task {
|
|
let search = ctx.BindQueryString<ProfileSearch> ()
|
|
let! results = Profiles.search search
|
|
return! json results next ctx
|
|
}
|
|
|
|
// GET: /api/profile/public-search
|
|
let publicSearch : HttpHandler = fun next ctx -> task {
|
|
let search = ctx.BindQueryString<PublicSearch> ()
|
|
let! results = Profiles.publicSearch search
|
|
return! json results next ctx
|
|
}
|
|
|
|
|
|
/// Handlers for /api/success routes
|
|
[<RequireQualifiedAccess>]
|
|
module Success =
|
|
|
|
// GET: /api/success/[id]
|
|
let get successId : HttpHandler = authorize >=> fun next ctx -> task {
|
|
match! Successes.findById (SuccessId successId) 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 = Successes.all ()
|
|
return! json stories next ctx
|
|
}
|
|
|
|
// POST: /api/success/save
|
|
let save : HttpHandler = authorize >=> fun next ctx -> task {
|
|
let citizenId = currentCitizenId ctx
|
|
let! form = ctx.BindJsonAsync<StoryForm> ()
|
|
let! success = task {
|
|
match form.Id with
|
|
| "new" ->
|
|
return Some { Id = SuccessId.create ()
|
|
CitizenId = citizenId
|
|
RecordedOn = now ctx
|
|
IsFromHere = form.FromHere
|
|
Source = "profile"
|
|
Story = noneIfEmpty form.Story |> Option.map Text
|
|
}
|
|
| successId ->
|
|
match! Successes.findById (SuccessId.ofString successId) with
|
|
| Some story when story.CitizenId = citizenId ->
|
|
return Some { story with
|
|
IsFromHere = form.FromHere
|
|
Story = noneIfEmpty form.Story |> Option.map Text
|
|
}
|
|
| Some _ | None -> return None
|
|
}
|
|
match success with
|
|
| Some story ->
|
|
do! Successes.save story
|
|
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 "/%O" Citizen.get ]
|
|
PATCH [
|
|
route "/account" Citizen.account
|
|
route "/confirm" Citizen.confirmToken
|
|
]
|
|
POST [
|
|
route "/log-on" Citizen.logOn
|
|
route "/register" Citizen.register
|
|
]
|
|
DELETE [
|
|
route "" Citizen.delete
|
|
route "/deny" Citizen.denyToken
|
|
]
|
|
]
|
|
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 ]
|
|
]
|
|
]
|
|
]
|