API migration complete

(at least the first cut)
This commit is contained in:
Daniel J. Summers 2021-07-10 22:21:01 -04:00
parent bd8cebbbe2
commit 0f63a673f4
4 changed files with 295 additions and 24 deletions

View File

@ -8,34 +8,12 @@ open Microsoft.Extensions.Hosting
open Giraffe
open Giraffe.EndpointRouting
/// All available routes for the application
let webApp = [
subRoute "/api" [
subRoute "/citizen" [
GET_HEAD [
routef "/log-on/%s" Handlers.Citizen.logOn
routef "/get/%O" Handlers.Citizen.get
]
DELETE [ route "" Handlers.Citizen.delete ]
]
GET_HEAD [ route "/continent/all" Handlers.Continent.all ]
subRoute "/profile" [
GET_HEAD [
route "" Handlers.Profile.current
route "/count" Handlers.Profile.count
routef "/get/%O" Handlers.Profile.get
]
PATCH [ route "/employment-found" Handlers.Profile.employmentFound ]
POST [ route "/save" Handlers.Profile.save ]
]
]
]
/// Configure the ASP.NET Core pipeline to use Giraffe
let configureApp (app : IApplicationBuilder) =
app
.UseRouting()
.UseEndpoints(fun e -> e.MapGiraffeEndpoints webApp)
.UseEndpoints(fun e -> e.MapGiraffeEndpoints Handlers.allEndpoints)
|> ignore
open NodaTime

View File

@ -185,11 +185,15 @@ let withReconn (conn : IConnection) =
(conn :?> Connection).Reconnect()
| false -> ()))
open JobsJobsJobs.Domain.SharedTypes
/// Profile data access functions
[<RequireQualifiedAccess>]
module Profile =
open JobsJobsJobs.Domain
open RethinkDb.Driver.Ast
let count conn =
withReconn(conn).ExecuteAsync(fun () ->
r.Table(Table.Profile)
@ -228,6 +232,67 @@ module Profile =
()
})
/// Search profiles (logged-on users)
let search (srch : ProfileSearch) conn = task {
let results =
seq {
match srch.continentId with
| Some conId ->
yield (fun (q : ReqlExpr) ->
q.Filter(r.HashMap(nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
| None -> ()
match srch.remoteWork with
| "" -> ()
| _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
match srch.skill with
| Some skl ->
yield (fun q -> q.Filter(ReqlFunction1(fun it ->
upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr)
| None -> ()
match srch.bioExperience with
| Some text ->
let txt = text.ToLowerInvariant ()
yield (fun q -> q.Filter(ReqlFunction1(fun it ->
upcast it.G("biography" ).Downcase().Match(txt)
.Or(it.G("experience").Downcase().Match(txt)))) :> ReqlExpr)
| None -> ()
}
|> Seq.toList
|> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr)
// TODO: pluck fields, include display name
return! results.RunResultAsync<ProfileSearchResult list> conn
}
// Search profiles (public)
let publicSearch (srch : PublicSearch) conn = task {
let results =
seq {
match srch.continentId with
| Some conId ->
yield (fun (q : ReqlExpr) ->
q.Filter(r.HashMap(nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
| None -> ()
match srch.region with
| Some reg ->
yield (fun q ->
q.Filter(ReqlFunction1(fun it ->
upcast it.G("region").Downcase().Match(reg.ToLowerInvariant ()))) :> ReqlExpr)
| None -> ()
match srch.remoteWork with
| "" -> ()
| _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
match srch.skill with
| Some skl ->
yield (fun q -> q.Filter(ReqlFunction1(fun it ->
upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr)
| None -> ()
}
|> Seq.toList
|> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr)
// TODO: pluck fields, compile skills
return! results.RunResultAsync<PublicSearchResult list> conn
}
/// Citizen data access functions
[<RequireQualifiedAccess>]
@ -315,6 +380,18 @@ module Continent =
.RunResultAsync<Continent list> conn)
/// Job listing data access functions
[<RequireQualifiedAccess>]
module Listing =
/// Find all job listings posted by the given citizen
let findByCitizen (citizenId : CitizenId) conn =
withReconn(conn).ExecuteAsync(fun () ->
r.Table(Table.Listing)
.GetAll(citizenId).OptArg("index", nameof citizenId)
.RunResultAsync<Listing list> conn)
/// Success story data access functions
[<RequireQualifiedAccess>]
module Success =
@ -339,3 +416,10 @@ module Success =
.RunWriteAsync conn)
()
}
// Retrieve all success stories
let all conn =
// TODO: identify query and fields that will make StoryEntry meaningful
withReconn(conn).ExecuteAsync(fun () ->
r.Table(Table.Success)
.RunResultAsync<StoryEntry list> conn)

View File

@ -86,6 +86,7 @@ module Helpers =
/// Handlers for /api/citizen routes
[<RequireQualifiedAccess>]
module Citizen =
// GET: /api/citizen/log-on/[code]
@ -161,6 +162,18 @@ module Continent =
}
/// Handlers for /api/listing[s] routes
[<RequireQualifiedAccess>]
module Listing =
// GET: /api/listing/mine
let mine : HttpHandler =
authorize
>=> fun next ctx -> task {
let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx)
return! json listings next ctx
}
/// Handlers for /api/profile routes
[<RequireQualifiedAccess>]
module Profile =
@ -247,3 +260,117 @@ module Profile =
return! ok next ctx
}
// GET: /api/profile/search
let search : HttpHandler =
authorize
>=> fun next ctx -> task {
let search = ctx.BindQueryString<ProfileSearch> ()
let! results = Data.Profile.search search (conn ctx)
return! json results next ctx
}
// GET: /api/profile/public-search
let publicSearch : HttpHandler =
fun next ctx -> task {
let search = ctx.BindQueryString<PublicSearch> ()
let! results = Data.Profile.publicSearch search (conn ctx)
return! json results next ctx
}
/// Handlers for /api/success routes
[<RequireQualifiedAccess>]
module Success =
open System
// GET: /api/success/[id]
let get successId : HttpHandler =
authorize
>=> fun next ctx -> task {
match! Data.Success.findById (SuccessId successId) (conn ctx) with
| Some story -> return! json story next ctx
| None -> return! Error.notFound next ctx
}
// GET: /api/success/list
let all : HttpHandler =
authorize
>=> fun next ctx -> task {
let! stories = Data.Success.all (conn ctx)
return! json stories next ctx
}
// POST: /api/success/save
let save : HttpHandler =
authorize
>=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let dbConn = conn ctx
let now = (clock ctx).GetCurrentInstant ()
let! form = ctx.BindJsonAsync<StoryForm> ()
let! success = task {
match form.id with
| "new" ->
return Some { id = (Guid.NewGuid >> SuccessId) ()
citizenId = citizenId
recordedOn = now
fromHere = form.fromHere
source = "profile"
story = noneIfEmpty form.story |> Option.map Text
}
| successId ->
match! Data.Success.findById (SuccessId.ofString successId) dbConn with
| Some story when story.citizenId = citizenId ->
return Some { story with
fromHere = form.fromHere
story = noneIfEmpty form.story |> Option.map Text
}
| Some _ | None -> return None
}
match success with
| Some story ->
do! Data.Success.save story dbConn
return! ok next ctx
| None -> return! Error.notFound next ctx
}
open Giraffe.EndpointRouting
/// All available endpoints for the application
let allEndpoints = [
subRoute "/api" [
subRoute "/citizen" [
GET_HEAD [
routef "/log-on/%s" Citizen.logOn
routef "/get/%O" Citizen.get
]
DELETE [ route "" Citizen.delete ]
]
GET_HEAD [ route "/continent/all" Continent.all ]
subRoute "/listing" [
GET_HEAD [
route "s/mine" Listing.mine
]
]
subRoute "/profile" [
GET_HEAD [
route "" Profile.current
route "/count" Profile.count
routef "/get/%O" Profile.get
route "/public-search" Profile.publicSearch
route "/search" Profile.search
]
PATCH [ route "/employment-found" Profile.employmentFound ]
POST [ route "/save" Profile.save ]
]
subRoute "/success" [
GET_HEAD [
routef "/get/%O" Success.get
route "/list" Success.all
]
POST [ route "/save" Success.save ]
]
]
]

View File

@ -2,6 +2,7 @@
module JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain.Types
open NodaTime
// fsharplint:disable FieldNames
@ -102,3 +103,84 @@ module ProfileSearch =
match search.remoteWork with "" -> Some search.remoteWork | _ -> None
]
|> List.exists Option.isSome
/// A user matching the profile search
type ProfileSearchResult = {
// The ID of the citizen
citizenId : CitizenId
// The citizen's display name
displayName : string
// Whether this citizen is currently seeking employment
seekingEmployment : bool
// Whether this citizen is looking for remote work
remoteWork : bool
// Whether this citizen is looking for full-time work
fullTime : bool
// When this profile was last updated
lastUpdated : Instant
}
/// The parameters for a public job search
type PublicSearch = {
/// Retrieve citizens from this continent
continentId : string option
/// Retrieve citizens from this region
region : string option
/// Text for a search within a citizen's skills
skill : string option
/// Whether to retrieve citizens who do or do not want remote work
remoteWork : string
}
/// Support functions for pblic searches
module PublicSearch =
/// Is the search empty?
let isEmptySearch (srch : PublicSearch) =
[ srch.continentId
srch.skill
match srch.remoteWork with "" -> Some srch.remoteWork | _ -> None
]
|> List.exists Option.isSome
/// A public profile search result
type PublicSearchResult = {
/// The name of the continent on which the citizen resides
continent : string
/// The region in which the citizen resides
region : string
/// Whether this citizen is seeking remote work
remoteWork : bool
/// The skills this citizen has identified
skills : string list
}
/// The data required to provide a success story
type StoryForm = {
/// The ID of this story
id : string
/// Whether the employment was obtained from Jobs, Jobs, Jobs
fromHere : bool
/// The success story
story : string
}
/// An entry in the list of success stories
type StoryEntry = {
/// The ID of this success story
id : SuccessId
/// The ID of the citizen who recorded this story
citizenId : CitizenId
/// The name of the citizen who recorded this story
citizenName : string
/// When this story was recorded
RecordedOn : Instant
/// Whether this story involves an opportunity that arose due to Jobs, Jobs, Jobs
fromHere : bool
/// Whether this report has a further story, or if it is simply a "found work" entry
hasStory : bool
}