Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
5 changed files with 122 additions and 133 deletions
Showing only changes of commit 74f9709f82 - Show all commits

View File

@ -35,6 +35,8 @@ type Citizen =
/// The other contacts for this user /// The other contacts for this user
otherContacts : OtherContact list otherContacts : OtherContact list
/// Whether this is a legacy citizen
isLegacy : bool
} }
/// Support functions for citizens /// Support functions for citizens
@ -94,6 +96,9 @@ type Listing =
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs? /// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
wasFilledHere : bool option wasFilledHere : bool option
/// Whether this is a legacy listing
isLegacy : bool
} }
@ -170,6 +175,9 @@ type Profile =
/// Skills this citizen possesses /// Skills this citizen possesses
skills : Skill list skills : Skill list
/// Whether this is a legacy profile
isLegacy : bool
} }
/// Support functions for Profiles /// Support functions for Profiles
@ -189,6 +197,7 @@ module Profile =
lastUpdatedOn = Instant.MinValue lastUpdatedOn = Instant.MinValue
experience = None experience = None
skills = [] skills = []
isLegacy = false
} }

View File

@ -24,49 +24,61 @@ let configureApp (app : IApplicationBuilder) =
open Newtonsoft.Json open Newtonsoft.Json
open NodaTime open NodaTime
open Marten
open Microsoft.AspNetCore.Authentication.JwtBearer open Microsoft.AspNetCore.Authentication.JwtBearer
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open Microsoft.IdentityModel.Tokens open Microsoft.IdentityModel.Tokens
open System.Text open System.Text
open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
/// Configure dependency injection /// Configure dependency injection
let configureServices (svc : IServiceCollection) = let configureServices (svc : IServiceCollection) =
svc.AddGiraffe () |> ignore let _ = svc.AddGiraffe ()
svc.AddSingleton<IClock> SystemClock.Instance |> ignore let _ = svc.AddSingleton<IClock> SystemClock.Instance
svc.AddLogging () |> ignore let _ = svc.AddLogging ()
svc.AddCors () |> ignore let _ = svc.AddCors ()
let jsonCfg = JsonSerializerSettings () let jsonCfg = JsonSerializerSettings ()
Data.Converters.all () |> List.iter jsonCfg.Converters.Add Data.Converters.all () |> List.iter jsonCfg.Converters.Add
svc.AddSingleton<Json.ISerializer> (NewtonsoftJson.Serializer jsonCfg) |> ignore let _ = svc.AddSingleton<Json.ISerializer> (NewtonsoftJson.Serializer jsonCfg)
let svcs = svc.BuildServiceProvider () let svcs = svc.BuildServiceProvider ()
let cfg = svcs.GetRequiredService<IConfiguration> () let cfg = svcs.GetRequiredService<IConfiguration> ()
svc.AddAuthentication(fun o -> let _ =
o.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme svc.AddAuthentication(fun o ->
o.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme o.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
o.DefaultScheme <- JwtBearerDefaults.AuthenticationScheme) o.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme
.AddJwtBearer(fun o -> o.DefaultScheme <- JwtBearerDefaults.AuthenticationScheme)
o.RequireHttpsMetadata <- false .AddJwtBearer(fun opt ->
o.TokenValidationParameters <- TokenValidationParameters ( opt.RequireHttpsMetadata <- false
ValidateIssuer = true, opt.TokenValidationParameters <- TokenValidationParameters (
ValidateAudience = true, ValidateIssuer = true,
ValidAudience = "https://noagendacareers.com", ValidateAudience = true,
ValidIssuer = "https://noagendacareers.com", ValidAudience = "https://noagendacareers.com",
IssuerSigningKey = SymmetricSecurityKey ( ValidIssuer = "https://noagendacareers.com",
Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"]))) IssuerSigningKey = SymmetricSecurityKey (
|> ignore Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"])))
svc.AddAuthorization () |> ignore let _ = svc.AddAuthorization ()
svc.Configure<AuthOptions> (cfg.GetSection "Auth") |> ignore let _ = svc.Configure<AuthOptions> (cfg.GetSection "Auth")
let dbCfg = cfg.GetSection "Rethink" let dbCfg = cfg.GetSection "Rethink"
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger "JobsJobsJobs.Api.Data.Startup" let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger "JobsJobsJobs.Api.Data.Startup"
let conn = Data.Startup.createConnection dbCfg log let conn = Data.Startup.createConnection dbCfg log
svc.AddSingleton conn |> ignore let _ = svc.AddSingleton conn |> ignore
Data.Startup.establishEnvironment dbCfg log conn |> Async.AwaitTask |> Async.RunSynchronously //Data.Startup.establishEnvironment dbCfg log conn |> Async.AwaitTask |> Async.RunSynchronously
let _ =
svc.AddMarten(fun (opts : StoreOptions) ->
opts.Connection (cfg.GetConnectionString "PostgreSQL")
opts.RegisterDocumentTypes [
typeof<Citizen>; typeof<Continent>; typeof<Listing>; typeof<Profile>; typeof<SecurityInfo>
typeof<Success>
])
.UseLightweightSessions()
()
[<EntryPoint>] [<EntryPoint>]
let main _ = let main _ =

View File

@ -113,6 +113,7 @@ module private Reconnect =
open RethinkDb.Driver.Ast open RethinkDb.Driver.Ast
open Marten
/// Shorthand for the RethinkDB R variable (how every command starts) /// Shorthand for the RethinkDB R variable (how every command starts)
let private r = RethinkDb.Driver.RethinkDB.R let private r = RethinkDb.Driver.RethinkDB.R
@ -305,6 +306,7 @@ module Map =
displayName = row.stringOrNone "display_name" displayName = row.stringOrNone "display_name"
// TODO: deserialize from JSON // TODO: deserialize from JSON
otherContacts = [] // row.stringOrNone "other_contacts" otherContacts = [] // row.stringOrNone "other_contacts"
isLegacy = false
} }
/// Create a continent from a data row /// Create a continent from a data row
@ -331,6 +333,7 @@ module Map =
text = (row.string >> Text) "listing_text" text = (row.string >> Text) "listing_text"
neededBy = row.fieldValueOrNone<LocalDate> "needed_by" neededBy = row.fieldValueOrNone<LocalDate> "needed_by"
wasFilledHere = row.boolOrNone "was_filled_here" wasFilledHere = row.boolOrNone "was_filled_here"
isLegacy = false
} }
/// Create a job listing for viewing from a data row /// Create a job listing for viewing from a data row
@ -353,6 +356,7 @@ module Map =
lastUpdatedOn = row.fieldValue<Instant> "last_updated_on" lastUpdatedOn = row.fieldValue<Instant> "last_updated_on"
experience = row.stringOrNone "experience" |> Option.map Text experience = row.stringOrNone "experience" |> Option.map Text
skills = [] skills = []
isLegacy = false
} }
/// Create a skill from a data row /// Create a skill from a data row
@ -373,99 +377,34 @@ module Map =
} }
/// Convert a possibly-null record type to an option
let optional<'T> (value : 'T) = if isNull (box value) then None else Some value
open System
open System.Linq
/// Profile data access functions /// Profile data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Profile = module Profile =
/// Count the current profiles /// Count the current profiles
let count conn = let count (session : IQuerySession) =
Sql.existingConnection conn session.Query<Profile>().Where(fun p -> not p.isLegacy).LongCountAsync ()
|> Sql.query
"SELECT COUNT(p.citizen_id)
FROM jjj.profile p
INNER JOIN jjj.citizen c ON c.id = p.citizen_id
WHERE c.is_legacy = FALSE"
|> Sql.executeRowAsync Map.toCount
/// Find a profile by citizen ID /// Find a profile by citizen ID
let findById citizenId conn = backgroundTask { let findById citizenId (session : IQuerySession) = backgroundTask {
let! tryProfile = let! profile = session.LoadAsync<Profile> (CitizenId.value citizenId)
Sql.existingConnection conn return
|> Sql.query match optional profile with
"SELECT * | Some p when not p.isLegacy -> Some p
FROM jjj.profile p | Some _
INNER JOIN jjj.citizen ON c.id = p.citizen_id | None -> None
WHERE p.citizen_id = @id
AND c.is_legacy = FALSE"
|> Sql.parameters [ "@id", Sql.citizenId citizenId ]
|> Sql.executeAsync Map.toProfile
match List.tryHead tryProfile with
| Some profile ->
let! skills =
Sql.existingConnection conn
|> Sql.query "SELECT * FROM jjj.profile_skill WHERE citizen_id = @id"
|> Sql.parameters [ "@id", Sql.citizenId citizenId ]
|> Sql.executeAsync Map.toSkill
return Some { profile with skills = skills }
| None -> return None
} }
/// Insert or update a profile /// Insert or update a profile
let save (profile : Profile) conn = backgroundTask { [<Obsolete "Inline this">]
let! _ = let save (profile : Profile) (session : IDocumentSession) =
Sql.existingConnection conn session.Store profile
|> Sql.executeTransactionAsync [
"INSERT INTO jjj.profile (
citizen_id, is_seeking, is_public_searchable, is_public_linkable, continent_id, region,
is_available_remotely, is_available_full_time, biography, last_updated_on, experience
) VALUES (
@citizenId, @isSeeking, @isPublicSearchable, @isPublicLinkable, @continentId, @region,
@isAvailableRemotely, @isAvailableFullTime, @biography, @lastUpdatedOn, @experience
) ON CONFLICT (citizen_id) DO UPDATE
SET is_seeking = EXCLUDED.is_seeking,
is_public_searchable = EXCLUDED.is_public_searchable,
is_public_linkable = EXCLUDED.is_public_linkable,
continent_id = EXCLUDED.continent_id,
region = EXCLUDED.region,
is_available_remotely = EXCLUDED.is_available_remotely,
is_available_full_time = EXCLUDED.is_available_full_time,
biography = EXCLUDED.biography,
last_updated_on = EXCLUDED.last_updated_on,
experience = EXCLUDED.experience",
[ [ "@citizenId", Sql.citizenId profile.id
"@isSeeking", Sql.bool profile.seekingEmployment
"@isPublicSearchable", Sql.bool profile.isPublic
"@isPublicLinkable", Sql.bool profile.isPublicLinkable
"@continentId", Sql.continentId profile.continentId
"@region", Sql.string profile.region
"@isAvailableRemotely", Sql.bool profile.remoteWork
"@isAvailableFullTime", Sql.bool profile.fullTime
"@biography", Sql.markdown profile.biography
"@lastUpdatedOn" |>Sql.param<| profile.lastUpdatedOn
"@experience", Sql.stringOrNone (Option.map MarkdownString.toString profile.experience)
] ]
"INSERT INTO jjj.profile (
id, citizen_id, description, notes
) VALUES (
@id, @citizenId, @description, @notes
) ON CONFLICT (id) DO UPDATE
SET description = EXCLUDED.description,
notes = EXCLUDED.notes",
profile.skills
|> List.map (fun skill -> [
"@id", Sql.skillId skill.id
"@citizenId", Sql.citizenId profile.id
"@description", Sql.string skill.description
"@notes" , Sql.stringOrNone skill.notes
])
$"""DELETE FROM jjj.profile
WHERE id NOT IN ({profile.skills |> List.mapi (fun idx _ -> $"@id{idx}") |> String.concat ", "})""",
[ profile.skills |> List.mapi (fun idx skill -> $"@id{idx}", Sql.skillId skill.id) ]
]
()
}
/// Delete a citizen's profile /// Delete a citizen's profile
let delete citizenId conn = backgroundTask { let delete citizenId conn = backgroundTask {
@ -543,13 +482,13 @@ module Profile =
module Citizen = module Citizen =
/// Find a citizen by their ID /// Find a citizen by their ID
let findById citizenId conn = backgroundTask { let findById citizenId (session : IQuerySession) = backgroundTask {
let! citizen = let! citizen = session.LoadAsync<Citizen> (CitizenId.value citizenId)
Sql.existingConnection conn return
|> Sql.query "SELECT * FROM jjj.citizen WHERE id = @id AND is_legacy = FALSE" match optional citizen with
|> Sql.parameters [ "@id", Sql.citizenId citizenId ] | Some c when not c.isLegacy -> Some c
|> Sql.executeAsync Map.toCitizen | Some _
return List.tryHead citizen | None -> None
} }
/// Find a citizen by their e-mail address /// Find a citizen by their e-mail address

View File

@ -1,10 +1,10 @@
/// Route handlers for Giraffe endpoints /// Route handlers for Giraffe endpoints
module JobsJobsJobs.Api.Handlers module JobsJobsJobs.Api.Handlers
open System.Threading
open Giraffe open Giraffe
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain.Types
open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
@ -54,11 +54,13 @@ module Error =
[<AutoOpen>] [<AutoOpen>]
module Helpers = module Helpers =
open System.Security.Claims
open System.Threading.Tasks
open NodaTime open NodaTime
open Marten
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Options open Microsoft.Extensions.Options
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> ()
@ -74,6 +76,12 @@ module Helpers =
/// Get the RethinkDB connection from the request context /// Get the RethinkDB connection from the request context
let conn (ctx : HttpContext) = ctx.GetService<IConnection> () let conn (ctx : HttpContext) = ctx.GetService<IConnection> ()
/// Get a query session
let querySession (ctx : HttpContext) = ctx.GetService<IQuerySession> ()
/// Get a full document session
let docSession (ctx : HttpContext) = ctx.GetService<IDocumentSession> ()
/// `None` if a `string option` is `None`, whitespace, or empty /// `None` if a `string option` is `None`, whitespace, or empty
let noneIfBlank (s : string option) = let noneIfBlank (s : string option) =
@ -98,8 +106,19 @@ module Helpers =
/// Return an empty OK response /// Return an empty OK response
let ok : HttpHandler = Successful.OK "" let ok : HttpHandler = Successful.OK ""
/// Convert a potentially-null record type to an option
let opt<'T> (it : Task<'T>) = task {
match! it with
| x when isNull (box x) -> return None
| x -> return Some x
}
/// Shorthand for no cancellation token
let noCnx = CancellationToken.None
open System
/// Handlers for /api/citizen routes /// Handlers for /api/citizen routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
@ -152,15 +171,18 @@ module Citizen =
} }
// GET: /api/citizen/[id] // GET: /api/citizen/[id]
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task { let get (citizenId : Guid) : HttpHandler = authorize >=> fun next ctx -> task {
match! Data.Citizen.findById (CitizenId citizenId) (conn ctx) with use session = querySession ctx
match! session.LoadAsync<Citizen> citizenId |> opt with
| Some citizen -> return! json citizen next ctx | Some citizen -> return! json citizen next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// DELETE: /api/citizen // DELETE: /api/citizen
let delete : HttpHandler = authorize >=> fun next ctx -> task { let delete : HttpHandler = authorize >=> fun next ctx -> task {
do! Data.Citizen.delete (currentCitizenId ctx) (conn ctx) use session = docSession ctx
session.Delete<Citizen> (CitizenId.value (currentCitizenId ctx))
do! session.SaveChangesAsync ()
return! ok next ctx return! ok next ctx
} }
@ -171,7 +193,8 @@ module Continent =
// GET: /api/continent/all // GET: /api/continent/all
let all : HttpHandler = fun next ctx -> task { let all : HttpHandler = fun next ctx -> task {
let! continents = Data.Continent.all (conn ctx) use session = querySession ctx
let! continents = session.Query<Continent>().ToListAsync noCnx
return! json continents next ctx return! json continents next ctx
} }
@ -230,20 +253,23 @@ module Listing =
let add : HttpHandler = authorize >=> fun next ctx -> task { let add : HttpHandler = authorize >=> fun next ctx -> task {
let! form = ctx.BindJsonAsync<ListingForm> () let! form = ctx.BindJsonAsync<ListingForm> ()
let now = (clock ctx).GetCurrentInstant () let now = (clock ctx).GetCurrentInstant ()
do! Data.Listing.add use session = docSession ctx
{ id = ListingId.create () session.Store<Listing>({
citizenId = currentCitizenId ctx id = ListingId.create ()
createdOn = now citizenId = currentCitizenId ctx
title = form.title createdOn = now
continentId = ContinentId.ofString form.continentId title = form.title
region = form.region continentId = ContinentId.ofString form.continentId
remoteWork = form.remoteWork region = form.region
isExpired = false remoteWork = form.remoteWork
updatedOn = now isExpired = false
text = Text form.text updatedOn = now
neededBy = (form.neededBy |> Option.map parseDate) text = Text form.text
wasFilledHere = None neededBy = (form.neededBy |> Option.map parseDate)
} (conn ctx) wasFilledHere = None
isLegacy = false
})
do! session.SaveChangesAsync ()
return! ok next ctx return! ok next ctx
} }

View File

@ -24,6 +24,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Giraffe" Version="6.0.0" /> <PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Marten" Version="5.8.0" />
<PackageReference Include="Marten.NodaTime" Version="5.8.0" />
<PackageReference Include="Marten.PLv8" Version="5.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" /> <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />