Migrate more handlers / support funcs
This commit is contained in:
parent
77f402d9dd
commit
67b169524a
@ -12,7 +12,22 @@ open Giraffe.EndpointRouting
|
||||
let webApp = [
|
||||
subRoute "/api" [
|
||||
subRoute "/citizen" [
|
||||
GET [ routef "/log-on/%s" Handlers.Citizen.logOn ]
|
||||
GET [
|
||||
routef "/log-on/%s" Handlers.Citizen.logOn
|
||||
routef "/get/%O" Handlers.Citizen.get
|
||||
]
|
||||
DELETE [ route "" Handlers.Citizen.delete ]
|
||||
]
|
||||
subRoute "/continent" [
|
||||
GET [ route "/all" Handlers.Continent.all ]
|
||||
]
|
||||
subRoute "/profile" [
|
||||
GET [
|
||||
route "" Handlers.Profile.current
|
||||
route "/count" Handlers.Profile.count
|
||||
routef "/get/%O" Handlers.Profile.get
|
||||
]
|
||||
POST [ route "/save" Handlers.Profile.save ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
@ -211,31 +211,81 @@ module Citizen =
|
||||
}
|
||||
|
||||
/// Add a citizen
|
||||
let add (citizen : Citizen) conn = task {
|
||||
let! _ =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let add (citizen : Citizen) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Insert(citizen)
|
||||
.RunWriteAsync conn)
|
||||
()
|
||||
}
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Update the display name and last seen on date for a citizen
|
||||
let logOnUpdate (citizen : Citizen) conn = task {
|
||||
let! _ =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let logOnUpdate (citizen : Citizen) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizen.id)
|
||||
.Update(r.HashMap(nameof citizen.displayName, citizen.displayName)
|
||||
.With(nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
||||
.RunWriteAsync conn)
|
||||
()
|
||||
}
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Delete a citizen
|
||||
let delete (citizenId : CitizenId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Profile)
|
||||
.Get(citizenId)
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
let! _ =
|
||||
r.Table(Table.Success)
|
||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Update a citizen's real name
|
||||
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.Update(r.HashMap(nameof realName, realName))
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
|
||||
/// Continent data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Continent =
|
||||
|
||||
/// Get all continents
|
||||
let all conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Continent)
|
||||
.RunResultAsync<Continent list> conn)
|
||||
|
||||
|
||||
/// Profile data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
||||
let count conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Profile)
|
||||
.Count()
|
||||
.RunResultAsync<int64> conn)
|
||||
|
||||
/// Find a profile by citizen ID
|
||||
let findById (citizenId : CitizenId) conn = task {
|
||||
let! profile =
|
||||
@ -247,15 +297,15 @@ module Profile =
|
||||
}
|
||||
|
||||
/// Insert or update a profile
|
||||
let save (profile : Profile) conn = task {
|
||||
let! _ =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let save (profile : Profile) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Profile)
|
||||
.Get(profile.id)
|
||||
.Replace(profile)
|
||||
.RunWriteAsync conn)
|
||||
()
|
||||
}
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
|
||||
/// Success story data access functions
|
||||
|
@ -6,16 +6,46 @@ open Giraffe
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
open JobsJobsJobs.Domain.Types
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// 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
|
||||
|
||||
/// Handler that will return a status code 404 and the text "Not Found"
|
||||
let notFound : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||
| true ->
|
||||
// TODO: check for valid URL prefixes
|
||||
return! Vue.app next ctx
|
||||
| false ->
|
||||
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
|
||||
|
||||
|
||||
/// Helper functions
|
||||
[<AutoOpen>]
|
||||
module Helpers =
|
||||
|
||||
open NodaTime
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Security.Claims
|
||||
|
||||
/// Get the NodaTime clock from the request context
|
||||
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
||||
@ -32,29 +62,28 @@ module Helpers =
|
||||
/// Return None if the string is null, empty, or whitespace; otherwise, return Some and the trimmed string
|
||||
let noneIfEmpty x =
|
||||
match (defaultArg (Option.ofObj x) "").Trim () with | "" -> None | it -> Some it
|
||||
|
||||
/// Return None if an optional string is None or empty
|
||||
let noneIfBlank s =
|
||||
s |> Option.map (fun x -> match x with "" -> None | _ -> Some x) |> Option.flatten
|
||||
|
||||
/// 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 ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
|
||||
|
||||
|
||||
/// Handlers for error conditions
|
||||
module Error =
|
||||
|
||||
/// Handler that will return a status code 404 and the text "Not Found"
|
||||
let notFound : HttpHandler =
|
||||
fun next ctx ->
|
||||
RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next ctx
|
||||
|
||||
|
||||
/// Handler to return the files required for the Vue client app
|
||||
module Vue =
|
||||
|
||||
/// Handler that returns index.html (the Vue client app)
|
||||
let app : HttpHandler =
|
||||
fun next ctx ->
|
||||
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||
| true -> htmlFile "wwwroot/index.html" next ctx
|
||||
| false -> Error.notFound next ctx
|
||||
|
||||
|
||||
/// Handler for /api/citizen routes
|
||||
/// Handlers for /api/citizen routes
|
||||
module Citizen =
|
||||
|
||||
// GET: /api/citizen/log-on/[code]
|
||||
@ -99,3 +128,100 @@ module Citizen =
|
||||
| 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! Successful.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/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/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 = SkillId.ofString s.id
|
||||
description = s.description
|
||||
notes = noneIfBlank s.notes
|
||||
})
|
||||
} dbConn
|
||||
do! Data.Citizen.realNameUpdate citizenId (noneIfBlank (Some form.realName)) dbConn
|
||||
return! Successful.OK "" next ctx
|
||||
}
|
||||
|
@ -48,14 +48,6 @@ module ContinentId =
|
||||
let ofString = fromShortGuid >> ContinentId
|
||||
|
||||
|
||||
/// Support functions for Markdown strings
|
||||
module MarkdownString =
|
||||
/// The Markdown conversion pipeline (enables all advanced features)
|
||||
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
||||
/// Convert this Markdown string to HTML
|
||||
let toHtml = function (Text text) -> Markdown.ToHtml (text, pipeline)
|
||||
|
||||
|
||||
/// Support functions for listing IDs
|
||||
module ListingId =
|
||||
/// Create a new job listing ID
|
||||
@ -66,6 +58,31 @@ module ListingId =
|
||||
let ofString = fromShortGuid >> ListingId
|
||||
|
||||
|
||||
/// Support functions for Markdown strings
|
||||
module MarkdownString =
|
||||
/// The Markdown conversion pipeline (enables all advanced features)
|
||||
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
||||
/// Convert this Markdown string to HTML
|
||||
let toHtml = function (Text text) -> Markdown.ToHtml (text, pipeline)
|
||||
|
||||
|
||||
/// Support functions for Profiles
|
||||
module Profile =
|
||||
// An empty profile
|
||||
let empty =
|
||||
{ id = CitizenId Guid.Empty
|
||||
seekingEmployment = false
|
||||
isPublic = false
|
||||
continentId = ContinentId Guid.Empty
|
||||
region = ""
|
||||
remoteWork = false
|
||||
fullTime = false
|
||||
biography = Text ""
|
||||
lastUpdatedOn = NodaTime.Instant.MinValue
|
||||
experience = None
|
||||
skills = []
|
||||
}
|
||||
|
||||
/// Support functions for skill IDs
|
||||
module SkillId =
|
||||
/// Create a new skill ID
|
||||
|
@ -1,6 +1,8 @@
|
||||
/// Types intended to be shared between the API and the client application
|
||||
module JobsJobsJobs.Domain.SharedTypes
|
||||
|
||||
open JobsJobsJobs.Domain.Types
|
||||
|
||||
// fsharplint:disable FieldNames
|
||||
|
||||
/// A successful logon
|
||||
@ -12,3 +14,67 @@ type LogOnSuccess = {
|
||||
/// The name of the logged-in citizen
|
||||
name : string
|
||||
}
|
||||
|
||||
|
||||
/// A count
|
||||
type Count = {
|
||||
// The count being returned
|
||||
count : int64
|
||||
}
|
||||
|
||||
|
||||
/// The fields required for a skill
|
||||
type SkillForm = {
|
||||
/// The ID of this skill
|
||||
id : string
|
||||
/// The description of the skill
|
||||
description : string
|
||||
/// Notes regarding the skill
|
||||
notes : string option
|
||||
}
|
||||
|
||||
/// The data required to update a profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ProfileForm = {
|
||||
/// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
isSeekingEmployment : bool
|
||||
/// Whether this profile should appear in the public search
|
||||
isPublic : bool
|
||||
/// The user's real name
|
||||
realName : string
|
||||
/// The ID of the continent on which the citizen is located
|
||||
continentId : string
|
||||
/// The area within that continent where the citizen is located
|
||||
region : string
|
||||
/// If the citizen is available for remote work
|
||||
remoteWork : bool
|
||||
/// If the citizen is seeking full-time employment
|
||||
fullTime : bool
|
||||
/// The user's professional biography
|
||||
biography : string
|
||||
/// The user's past experience
|
||||
experience : string option
|
||||
/// The skills for the user
|
||||
skills : SkillForm list
|
||||
}
|
||||
|
||||
/// Support functions for the ProfileForm type
|
||||
module ProfileForm =
|
||||
/// Create an instance of this form from the given profile
|
||||
let fromProfile (profile : Types.Profile) =
|
||||
{ isSeekingEmployment = profile.seekingEmployment
|
||||
isPublic = profile.isPublic
|
||||
realName = ""
|
||||
continentId = string profile.continentId
|
||||
region = profile.region
|
||||
remoteWork = profile.remoteWork
|
||||
fullTime = profile.fullTime
|
||||
biography = match profile.biography with Text bio -> bio
|
||||
experience = profile.experience |> Option.map (fun x -> match x with Text exp -> exp)
|
||||
skills = profile.skills
|
||||
|> List.map (fun s ->
|
||||
{ id = string s.id
|
||||
description = s.description
|
||||
notes = s.notes
|
||||
})
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user