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 ]
]
]
]