Env swap #21

Merged
danieljsummers merged 30 commits from env-swap into help-wanted 2021-08-10 03:23:50 +00:00
5 changed files with 322 additions and 48 deletions
Showing only changes of commit 67b169524a - Show all commits

View File

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

View File

@ -211,31 +211,81 @@ module Citizen =
} }
/// Add a citizen /// Add a citizen
let add (citizen : Citizen) conn = task { let add (citizen : Citizen) conn =
let! _ = withReconn(conn).ExecuteAsync(fun () -> task {
withReconn(conn).ExecuteAsync(fun () -> let! _ =
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 =
let! _ = withReconn(conn).ExecuteAsync(fun () -> task {
withReconn(conn).ExecuteAsync(fun () -> let! _ =
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 =
let! _ = withReconn(conn).ExecuteAsync(fun () -> task {
withReconn(conn).ExecuteAsync(fun () -> let! _ =
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

View File

@ -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> ()
@ -32,29 +62,28 @@ module Helpers =
/// Return None if the string is null, empty, or whitespace; otherwise, return Some and the trimmed string /// Return None if the string is null, empty, or whitespace; otherwise, return Some and the trimmed string
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
/// 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" /// Handlers for /api/citizen routes
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
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
}

View File

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

View File

@ -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
})
}