/// HTTP handlers for the myPrayerJournal API [<RequireQualifiedAccess>] module MyPrayerJournal.Handlers // fsharplint:disable RecordFieldNames open Giraffe open MyPrayerJournal.Data.Extensions /// Handler to return Vue files module Vue = /// The application index page let app : HttpHandler = htmlFile "wwwroot/index.html" open System /// Handlers for error conditions module Error = open Microsoft.Extensions.Logging /// Handle errors let error (ex : Exception) (log : ILogger) = log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.") clearResponse >=> setStatusCode 500 >=> json ex.Message /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there let notFound : HttpHandler = fun next ctx -> [ "/journal"; "/legal"; "/request"; "/user" ] |> List.filter ctx.Request.Path.Value.StartsWith |> List.length |> function | 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx | _ -> Vue.app next ctx open Cuid open LiteDB /// Handler helpers [<AutoOpen>] module private Helpers = open Microsoft.AspNetCore.Http open System.Threading.Tasks open System.Security.Claims /// Get the LiteDB database let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>() /// Get the user's "sub" claim let user (ctx : HttpContext) = ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier) /// Get the current user's ID // NOTE: this may raise if you don't run the request through the authorize handler first let userId ctx = ((user >> Option.get) ctx).Value |> UserId /// Create a request ID from a string let toReqId x = match Cuid.ofString x with | Ok cuid -> cuid | Error msg -> invalidOp msg |> RequestId /// Return a 201 CREATED response let created next ctx = setStatusCode 201 next ctx /// The "now" time in JavaScript as Ticks let jsNow () = DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> (int64 >> ( * ) 1_000L >> Ticks) /// Handler to return a 403 Not Authorized reponse let notAuthorized : HttpHandler = setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None /// Handler to require authorization let authorize : HttpHandler = fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx /// Flip JSON result so we can pipe into it let asJson<'T> next ctx (o : 'T) = json o next ctx /// Strongly-typed models for post requests module Models = /// A history entry addition (AKA request update) [<CLIMutable>] type HistoryEntry = { /// The status of the history update status : string /// The text of the update updateText : string } /// An additional note [<CLIMutable>] type NoteEntry = { /// The notes being added notes : string } /// Recurrence update [<CLIMutable>] type Recurrence = { /// The recurrence type recurType : string /// The recurrence cound recurCount : int16 } /// A prayer request [<CLIMutable>] type Request = { /// The text of the request requestText : string /// The recurrence type recurType : string /// The recurrence count recurCount : int16 } /// The time until which a request should not appear in the journal [<CLIMutable>] type SnoozeUntil = { /// The time at which the request should reappear until : int64 } /// /api/journal URLs module Journal = /// GET /api/journal let journal : HttpHandler = authorize >=> fun next ctx -> task { let! jrnl = Data.journalByUserId (userId ctx) (db ctx) return! json jrnl next ctx } /// /api/request URLs module Request = /// POST /api/request let add : HttpHandler = authorize >=> fun next ctx -> task { let! r = ctx.BindJsonAsync<Models.Request> () let db = db ctx let usrId = userId ctx let now = jsNow () let req = { Request.empty with userId = usrId enteredOn = now showAfter = Ticks 0L recurType = Recurrence.fromString r.recurType recurCount = r.recurCount history = [ { asOf = now status = Created text = Some r.requestText } ] } Data.addRequest req db do! db.saveChanges () match! Data.tryJournalById req.id usrId db with | Some req -> return! (setStatusCode 201 >=> json req) next ctx | None -> return! Error.notFound next ctx } /// POST /api/request/[req-id]/history let addHistory requestId : HttpHandler = authorize >=> fun next ctx -> FSharp.Control.Tasks.Affine.task { let db = db ctx let usrId = userId ctx let reqId = toReqId requestId match! Data.tryRequestById reqId usrId db with | Some req -> let! hist = ctx.BindJsonAsync<Models.HistoryEntry> () let now = jsNow () let act = RequestAction.fromString hist.status do! Data.addHistory reqId usrId { asOf = now status = act text = match hist.updateText with null | "" -> None | x -> Some x } db match act with | Prayed -> let nextShow = match Recurrence.duration req.recurType with | 0L -> 0L | duration -> (Ticks.toLong now) + (duration * int64 req.recurCount) do! Data.updateShowAfter reqId usrId (Ticks nextShow) db | _ -> () do! db.saveChanges () return! created next ctx | None -> return! Error.notFound next ctx } /// POST /api/request/[req-id]/note let addNote requestId : HttpHandler = authorize // >=> allowSyncIO >=> fun next ctx -> task { let db = db ctx let usrId = userId ctx let reqId = toReqId requestId match! Data.tryRequestById reqId usrId db with | Some _ -> let! notes = ctx.BindJsonAsync<Models.NoteEntry> () do! Data.addNote reqId usrId { asOf = jsNow (); notes = notes.notes } db do! db.saveChanges () return! created next ctx | None -> return! Error.notFound next ctx } /// GET /api/requests/answered let answered : HttpHandler = authorize >=> fun next ctx -> task { let! reqs = Data.answeredRequests (userId ctx) (db ctx) return! json reqs next ctx } /// GET /api/request/[req-id] let get requestId : HttpHandler = authorize >=> fun next ctx -> task { match! Data.tryJournalById (toReqId requestId) (userId ctx) (db ctx) with | Some req -> return! json req next ctx | None -> return! Error.notFound next ctx } /// GET /api/request/[req-id]/full let getFull requestId : HttpHandler = authorize >=> fun next ctx -> task { match! Data.tryFullRequestById (toReqId requestId) (userId ctx) (db ctx) with | Some req -> return! json req next ctx | None -> return! Error.notFound next ctx } /// GET /api/request/[req-id]/notes let getNotes requestId : HttpHandler = authorize >=> fun next ctx -> task { let! notes = Data.notesById (toReqId requestId) (userId ctx) (db ctx) return! json notes next ctx } /// PATCH /api/request/[req-id]/show let show requestId : HttpHandler = authorize >=> fun next ctx -> task { let db = db ctx let usrId = userId ctx let reqId = toReqId requestId match! Data.tryRequestById reqId usrId db with | Some _ -> do! Data.updateShowAfter reqId usrId (Ticks 0L) db do! db.saveChanges () return! setStatusCode 204 next ctx | None -> return! Error.notFound next ctx } /// PATCH /api/request/[req-id]/snooze let snooze requestId : HttpHandler = authorize >=> fun next ctx -> task { let db = db ctx let usrId = userId ctx let reqId = toReqId requestId match! Data.tryRequestById reqId usrId db with | Some _ -> let! until = ctx.BindJsonAsync<Models.SnoozeUntil> () do! Data.updateSnoozed reqId usrId (Ticks until.until) db do! db.saveChanges () return! setStatusCode 204 next ctx | None -> return! Error.notFound next ctx } /// PATCH /api/request/[req-id]/recurrence let updateRecurrence requestId : HttpHandler = authorize >=> fun next ctx -> FSharp.Control.Tasks.Affine.task { let db = db ctx let usrId = userId ctx let reqId = toReqId requestId match! Data.tryRequestById reqId usrId db with | Some _ -> let! recur = ctx.BindJsonAsync<Models.Recurrence> () let recurrence = Recurrence.fromString recur.recurType do! Data.updateRecurrence reqId usrId recurrence recur.recurCount db match recurrence with | Immediate -> do! Data.updateShowAfter reqId usrId (Ticks 0L) db | _ -> () do! db.saveChanges () return! setStatusCode 204 next ctx | None -> return! Error.notFound next ctx } open Giraffe.EndpointRouting /// The routes for myPrayerJournal let routes = [ route "/" Vue.app subRoute "/api/" [ GET [ route "journal" Journal.journal subRoute "request" [ route "s/answered" Request.answered routef "/%s/full" Request.getFull routef "/%s/notes" Request.getNotes routef "/%s" Request.get ] ] PATCH [ subRoute "request" [ routef "/%s/recurrence" Request.updateRecurrence routef "/%s/show" Request.show routef "/%s/snooze" Request.snooze ] ] POST [ subRoute "request" [ route "" Request.add routef "/%s/history" Request.addHistory routef "/%s/note" Request.addNote ] ] ] ]