From 67b169524ae444e431f9f5faddfd5a0af4237ec2 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 9 Jul 2021 23:39:05 -0400 Subject: [PATCH] Migrate more handlers / support funcs --- src/JobsJobsJobs/Api/App.fs | 17 ++- src/JobsJobsJobs/Api/Data.fs | 86 ++++++++++--- src/JobsJobsJobs/Api/Handlers.fs | 168 +++++++++++++++++++++---- src/JobsJobsJobs/Domain/Modules.fs | 33 +++-- src/JobsJobsJobs/Domain/SharedTypes.fs | 66 ++++++++++ 5 files changed, 322 insertions(+), 48 deletions(-) diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs index a8307d9..d5dde63 100644 --- a/src/JobsJobsJobs/Api/App.fs +++ b/src/JobsJobsJobs/Api/App.fs @@ -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 ] ] ] ] diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs index de369a3..6648ccb 100644 --- a/src/JobsJobsJobs/Api/Data.fs +++ b/src/JobsJobsJobs/Api/Data.fs @@ -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 +[] +module Continent = + + /// Get all continents + let all conn = + withReconn(conn).ExecuteAsync(fun () -> + r.Table(Table.Continent) + .RunResultAsync conn) + /// Profile data access functions [] module Profile = + let count conn = + withReconn(conn).ExecuteAsync(fun () -> + r.Table(Table.Profile) + .Count() + .RunResultAsync 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 diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs index bc04b93..202f74c 100644 --- a/src/JobsJobsJobs/Api/Handlers.fs +++ b/src/JobsJobsJobs/Api/Handlers.fs @@ -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 None + /// Helper functions [] 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 () @@ -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 +[] +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 +[] +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() + 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 + } + \ No newline at end of file diff --git a/src/JobsJobsJobs/Domain/Modules.fs b/src/JobsJobsJobs/Domain/Modules.fs index 9223b70..d65bade 100644 --- a/src/JobsJobsJobs/Domain/Modules.fs +++ b/src/JobsJobsJobs/Domain/Modules.fs @@ -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 diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index 689aa45..4fcda5d 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -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 +[] +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 + }) + }