Env swap #21
| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user