Env swap #21
|
@ -12,7 +12,22 @@ open Giraffe.EndpointRouting
|
||||||
let webApp = [
|
let webApp = [
|
||||||
subRoute "/api" [
|
subRoute "/api" [
|
||||||
subRoute "/citizen" [
|
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
|
/// Add a citizen
|
||||||
let add (citizen : Citizen) conn = task {
|
let add (citizen : Citizen) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! _ =
|
let! _ =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Citizen)
|
r.Table(Table.Citizen)
|
||||||
.Insert(citizen)
|
.Insert(citizen)
|
||||||
.RunWriteAsync conn)
|
.RunWriteAsync conn
|
||||||
()
|
()
|
||||||
}
|
})
|
||||||
|
|
||||||
/// Update the display name and last seen on date for a citizen
|
/// Update the display name and last seen on date for a citizen
|
||||||
let logOnUpdate (citizen : Citizen) conn = task {
|
let logOnUpdate (citizen : Citizen) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! _ =
|
let! _ =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Citizen)
|
r.Table(Table.Citizen)
|
||||||
.Get(citizen.id)
|
.Get(citizen.id)
|
||||||
.Update(r.HashMap(nameof citizen.displayName, citizen.displayName)
|
.Update(r.HashMap(nameof citizen.displayName, citizen.displayName)
|
||||||
.With(nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
.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
|
/// Profile data access functions
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Profile =
|
module Profile =
|
||||||
|
|
||||||
|
let count conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () ->
|
||||||
|
r.Table(Table.Profile)
|
||||||
|
.Count()
|
||||||
|
.RunResultAsync<int64> conn)
|
||||||
|
|
||||||
/// Find a profile by citizen ID
|
/// Find a profile by citizen ID
|
||||||
let findById (citizenId : CitizenId) conn = task {
|
let findById (citizenId : CitizenId) conn = task {
|
||||||
let! profile =
|
let! profile =
|
||||||
|
@ -247,15 +297,15 @@ module Profile =
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert or update a profile
|
/// Insert or update a profile
|
||||||
let save (profile : Profile) conn = task {
|
let save (profile : Profile) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! _ =
|
let! _ =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Profile)
|
r.Table(Table.Profile)
|
||||||
.Get(profile.id)
|
.Get(profile.id)
|
||||||
.Replace(profile)
|
.Replace(profile)
|
||||||
.RunWriteAsync conn)
|
.RunWriteAsync conn
|
||||||
()
|
()
|
||||||
}
|
})
|
||||||
|
|
||||||
|
|
||||||
/// Success story data access functions
|
/// Success story data access functions
|
||||||
|
|
|
@ -6,16 +6,46 @@ open Giraffe
|
||||||
open JobsJobsJobs.Domain
|
open JobsJobsJobs.Domain
|
||||||
open JobsJobsJobs.Domain.SharedTypes
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
open JobsJobsJobs.Domain.Types
|
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
|
/// Helper functions
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module Helpers =
|
module Helpers =
|
||||||
|
|
||||||
open NodaTime
|
open NodaTime
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open Microsoft.Extensions.Configuration
|
open Microsoft.Extensions.Configuration
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
open RethinkDb.Driver.Net
|
open RethinkDb.Driver.Net
|
||||||
|
open System.Security.Claims
|
||||||
|
|
||||||
/// Get the NodaTime clock from the request context
|
/// Get the NodaTime clock from the request context
|
||||||
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
||||||
|
@ -33,28 +63,27 @@ module Helpers =
|
||||||
let noneIfEmpty x =
|
let noneIfEmpty x =
|
||||||
match (defaultArg (Option.ofObj x) "").Trim () with | "" -> None | it -> Some it
|
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
|
||||||
|
|
||||||
/// Handlers for error conditions
|
/// Try to get the current user
|
||||||
module Error =
|
let tryUser (ctx : HttpContext) =
|
||||||
|
ctx.User.FindFirst ClaimTypes.NameIdentifier
|
||||||
|
|> Option.ofObj
|
||||||
|
|> Option.map (fun x -> x.Value)
|
||||||
|
|
||||||
/// Handler that will return a status code 404 and the text "Not Found"
|
/// Require a user to be logged in
|
||||||
let notFound : HttpHandler =
|
let authorize : HttpHandler =
|
||||||
fun next ctx ->
|
fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx
|
||||||
RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" 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
|
||||||
|
|
||||||
|
|
||||||
/// Handler to return the files required for the Vue client app
|
|
||||||
module Vue =
|
|
||||||
|
|
||||||
/// Handler that returns index.html (the Vue client app)
|
/// Handlers for /api/citizen routes
|
||||||
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
|
|
||||||
module Citizen =
|
module Citizen =
|
||||||
|
|
||||||
// GET: /api/citizen/log-on/[code]
|
// GET: /api/citizen/log-on/[code]
|
||||||
|
@ -99,3 +128,100 @@ module Citizen =
|
||||||
| Error err ->
|
| Error err ->
|
||||||
return! RequestErrors.BAD_REQUEST err next ctx
|
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
|
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
|
/// Support functions for listing IDs
|
||||||
module ListingId =
|
module ListingId =
|
||||||
/// Create a new job listing ID
|
/// Create a new job listing ID
|
||||||
|
@ -66,6 +58,31 @@ module ListingId =
|
||||||
let ofString = fromShortGuid >> 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
|
/// Support functions for skill IDs
|
||||||
module SkillId =
|
module SkillId =
|
||||||
/// Create a new skill ID
|
/// Create a new skill ID
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
/// Types intended to be shared between the API and the client application
|
/// Types intended to be shared between the API and the client application
|
||||||
module JobsJobsJobs.Domain.SharedTypes
|
module JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
|
||||||
// fsharplint:disable FieldNames
|
// fsharplint:disable FieldNames
|
||||||
|
|
||||||
/// A successful logon
|
/// A successful logon
|
||||||
|
@ -12,3 +14,67 @@ type LogOnSuccess = {
|
||||||
/// The name of the logged-in citizen
|
/// The name of the logged-in citizen
|
||||||
name : string
|
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