From 7622163707515f8d086baccacc519f00aa6a6819 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 2 Aug 2018 21:42:34 -0500 Subject: [PATCH] First cut A wild swing at translating the API; committing at this point just so I don't lose work --- src/api/MyPrayerJournal.Api/Data.fs | 286 +++++++++++++ src/api/MyPrayerJournal.Api/Handlers.fs | 244 +++++++++++ .../MyPrayerJournal.Api.fsproj | 25 ++ src/api/MyPrayerJournal.Api/Program.fs | 85 ++++ .../Properties/launchSettings.json | 27 ++ src/api/MyPrayerJournal.sln | 25 ++ src/api/data/data.go | 392 ------------------ src/api/data/entities.go | 36 -- src/api/routes/handlers.go | 192 --------- src/api/routes/router.go | 119 ------ src/api/routes/routes.go | 106 ----- 11 files changed, 692 insertions(+), 845 deletions(-) create mode 100644 src/api/MyPrayerJournal.Api/Data.fs create mode 100644 src/api/MyPrayerJournal.Api/Handlers.fs create mode 100644 src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj create mode 100644 src/api/MyPrayerJournal.Api/Program.fs create mode 100644 src/api/MyPrayerJournal.Api/Properties/launchSettings.json create mode 100644 src/api/MyPrayerJournal.sln delete mode 100644 src/api/data/data.go delete mode 100644 src/api/data/entities.go delete mode 100644 src/api/routes/handlers.go delete mode 100644 src/api/routes/router.go delete mode 100644 src/api/routes/routes.go diff --git a/src/api/MyPrayerJournal.Api/Data.fs b/src/api/MyPrayerJournal.Api/Data.fs new file mode 100644 index 0000000..eba57ec --- /dev/null +++ b/src/api/MyPrayerJournal.Api/Data.fs @@ -0,0 +1,286 @@ +namespace MyPrayerJournal + +open FSharp.Control.Tasks.ContextInsensitive +open Microsoft.EntityFrameworkCore + +/// Helpers for this file +[] +module private Helpers = + + /// Convert any item to an option (Option.ofObj does not work for non-nullable types) + let toOption<'T> (x : 'T) = match box x with null -> None | _ -> Some x + + +/// Entities for use in the data model for myPrayerJournal +[] +module Entities = + + open FSharp.EFCore.OptionConverter + open System.Collections.Generic + + /// Type alias for a Collision-resistant Unique IDentifier + type Cuid = string + + /// Request ID is a CUID + type RequestId = Cuid + + /// User ID is a string (the "sub" part of the JWT) + type UserId = string + + /// History is a record of action taken on a prayer request, including updates to its text + type [] History = + { /// The ID of the request to which this history entry applies + requestId : RequestId + /// The time when this history entry was made + asOf : int64 + /// The status for this history entry + status : string + /// The text of the update, if applicable + text : string option + /// The request to which this history entry applies + request : Request + } + with + /// An empty history entry + static member empty = + { requestId = "" + asOf = 0L + status = "" + text = None + request = Request.empty + } + + static member configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "history" |> ignore + m.HasKey ("requestId", "asOf") |> ignore + m.Property(fun e -> e.requestId).IsRequired () |> ignore + m.Property(fun e -> e.asOf).IsRequired () |> ignore + m.Property(fun e -> e.status).IsRequired() |> ignore + m.HasOne(fun e -> e.request) + .WithMany(fun r -> r.history :> IEnumerable) + .HasForeignKey(fun e -> e.requestId :> obj) + |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("text").SetValueConverter (OptionConverter ()) + + /// Note is a note regarding a prayer request that does not result in an update to its text + and [] Note = + { /// The ID of the request to which this note applies + requestId : RequestId + /// The time when this note was made + asOf : int64 + /// The text of the notes + notes : string + /// The request to which this note applies + request : Request + } + with + /// An empty note + static member empty = + { requestId = "" + asOf = 0L + notes = "" + request = Request.empty + } + + static member configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "note" |> ignore + m.HasKey ("requestId", "asOf") |> ignore + m.Property(fun e -> e.requestId).IsRequired () |> ignore + m.Property(fun e -> e.asOf).IsRequired () |> ignore + m.Property(fun e -> e.notes).IsRequired () |> ignore + m.HasOne(fun e -> e.request) + .WithMany(fun r -> r.notes :> IEnumerable) + .HasForeignKey(fun e -> e.requestId :> obj) + |> ignore) + |> ignore + + // Request is the identifying record for a prayer request. + and [] Request = + { /// The ID of the request + requestId : RequestId + /// The time this request was initially entered + enteredOn : int64 + /// The ID of the user to whom this request belongs ("sub" from the JWT) + userId : string + /// The time that this request should reappear in the user's journal + snoozedUntil : int64 + /// The history entries for this request + history : ICollection + /// The notes for this request + notes : ICollection + } + with + /// An empty request + static member empty = + { requestId = "" + enteredOn = 0L + userId = "" + snoozedUntil = 0L + history = List () + notes = List () + } + + static member configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "request" |> ignore + m.HasKey(fun e -> e.requestId :> obj) |> ignore + m.Property(fun e -> e.requestId).IsRequired () |> ignore + m.Property(fun e -> e.enteredOn).IsRequired () |> ignore + m.Property(fun e -> e.userId).IsRequired () |> ignore + m.Property(fun e -> e.snoozedUntil).IsRequired () |> ignore) + |> ignore + + /// JournalRequest is the form of a prayer request returned for the request journal display. It also contains + /// properties that may be filled for history and notes + [] + type JournalRequest = + { /// The ID of the request + requestId : RequestId + /// The ID of the user to whom the request belongs + userId : string + /// The current text of the request + text : string + /// The last time action was taken on the request + asOf : int64 + /// The last status for the request + lastStatus : string + /// The time that this request should reappear in the user's journal + snoozedUntil : int64 + /// History entries for the request + history : History list + /// Note entries for the request + notes : Note list + } + with + static member configureEF (mb : ModelBuilder) = + mb.Query ( + fun m -> + m.ToView "journal" |> ignore + m.Ignore(fun e -> e.history :> obj) |> ignore + m.Ignore(fun e -> e.notes :> obj) |> ignore) + |> ignore + + +open System.Linq +open System.Threading.Tasks + +/// Data context +type AppDbContext (opts : DbContextOptions) as self = + inherit DbContext (opts) + + /// Register a disconnected entity with the context, having the given state + let registerAs state (e : 'TEntity when 'TEntity : not struct) = + self.Entry<'TEntity>(e).State <- state + + [] + val mutable private history : DbSet + [] + val mutable private notes : DbSet + [] + val mutable private requests : DbSet + [] + val mutable private journal : DbQuery + + member this.History + with get () = this.history + and set v = this.history <- v + member this.Notes + with get () = this.notes + and set v = this.notes <- v + member this.Requests + with get () = this.requests + and set v = this.requests <- v + member this.Journal + with get () = this.journal + and set v = this.journal <- v + + override __.OnModelCreating (mb : ModelBuilder) = + base.OnModelCreating mb + [ History.configureEF + Note.configureEF + Request.configureEF + JournalRequest.configureEF + ] + |> List.iter (fun x -> x mb) + + /// Add an entity instance to the context + member __.AddEntry e = + registerAs EntityState.Added e + + /// Update the entity instance's values + member __.UpdateEntry e = + registerAs EntityState.Modified e + + /// Retrieve all answered requests for the given user + member this.AnsweredRequests userId : JournalRequest seq = + upcast this.Journal + .Where(fun r -> r.userId = userId && r.lastStatus = "Answered") + .OrderByDescending(fun r -> r.asOf) + + /// Retrieve the user's current journal + member this.JournalByUserId userId : JournalRequest seq = + upcast this.Journal + .Where(fun r -> r.userId = userId && r.lastStatus <> "Answered") + .OrderBy(fun r -> r.asOf) + + /// Retrieve a request by its ID and user ID + member this.TryRequestById reqId userId : Task = + task { + let! req = this.Requests.AsNoTracking().FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId) + return toOption req + } + + /// Retrieve notes for a request by its ID and user ID + member this.NotesById reqId userId = + task { + let! req = this.TryRequestById reqId userId + match req with + | Some _ -> return this.Notes.AsNoTracking().Where(fun n -> n.requestId = reqId) |> List.ofSeq + | None -> return [] + } + + /// Retrieve a journal request by its ID and user ID + member this.TryJournalById reqId userId = + task { + let! req = this.Journal.FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId) + return toOption req + } + + /// Retrieve a request, including its history and notes, by its ID and user ID + member this.TryCompleteRequestById requestId userId = + task { + let! req = this.TryJournalById requestId userId + match req with + | Some r -> + let! fullReq = + this.Requests.AsNoTracking() + .Include(fun r -> r.history) + .Include(fun r -> r.notes) + .FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId) + match toOption fullReq with + | Some _ -> return Some { r with history = List.ofSeq fullReq.history; notes = List.ofSeq fullReq.notes } + | None -> return None + | None -> return None + } + + /// Retrieve a request, including its history, by its ID and user ID + member this.TryFullRequestById requestId userId = + task { + let! req = this.TryJournalById requestId userId + match req with + | Some r -> + let! fullReq = + this.Requests.AsNoTracking() + .Include(fun r -> r.history) + .FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId) + match toOption fullReq with + | Some _ -> return Some { r with history = List.ofSeq fullReq.history } + | None -> return None + | None -> return None + } diff --git a/src/api/MyPrayerJournal.Api/Handlers.fs b/src/api/MyPrayerJournal.Api/Handlers.fs new file mode 100644 index 0000000..3fa60df --- /dev/null +++ b/src/api/MyPrayerJournal.Api/Handlers.fs @@ -0,0 +1,244 @@ +/// HTTP handlers for the myPrayerJournal API +[] +module MyPrayerJournal.Handlers + +open Giraffe + +/// 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 -> + let vueApp () = htmlFile "/index.html" next ctx + match true with + | _ when ctx.Request.Path.Value.StartsWith "/answered" -> vueApp () + | _ when ctx.Request.Path.Value.StartsWith "/journal" -> vueApp () + | _ when ctx.Request.Path.Value.StartsWith "/user" -> vueApp () + | _ -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx + + +/// Handler helpers +[] +module private Helpers = + + open Microsoft.AspNetCore.Http + open System + + /// Get the database context from DI + let db (ctx : HttpContext) = + ctx.GetService () + + /// Get the user's "sub" claim + let user (ctx : HttpContext) = + ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = "sub") + + /// Return a 201 CREATED response + let created next ctx = + setStatusCode 201 next ctx + + /// The "now" time in JavaScript + let jsNow () = + DateTime.Now.Subtract(DateTime (1970, 1, 1)).TotalSeconds |> int64 |> (*) 1000L + + +/// Strongly-typed models for post requests +module Models = + + /// A history entry addition (AKA request update) + [] + type HistoryEntry = + { /// The status of the history update + status : string + /// The text of the update + updateText : string + } + + /// An additional note + [] + type NoteEntry = + { /// The notes being added + notes : string + } + + /// A prayer request + [] + type Request = + { /// The text of the request + requestText : string + } + + /// The time until which a request should not appear in the journal + [] + type SnoozeUntil = + { /// The time at which the request should reappear + until : int64 + } + +/// /api/journal URLs +module Journal = + + /// GET /api/journal + let journal : HttpHandler = + fun next ctx -> + match user ctx with + | Some u -> json ((db ctx).JournalByUserId u.Value) next ctx + | None -> notFound next ctx + + +/// /api/request URLs +module Request = + + open NCuid + + /// POST /api/request + let add : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let! r = ctx.BindJsonAsync () + let db = db ctx + let reqId = Cuid.Generate () + let now = jsNow () + { Request.empty with + requestId = reqId + userId = u.Value + enteredOn = now + snoozedUntil = 0L + } + |> db.AddEntry + { History.empty with + requestId = reqId + asOf = now + status = "Created" + text = Some r.requestText + } + |> db.AddEntry + let! _ = db.SaveChangesAsync () + let! req = db.TryJournalById reqId u.Value + match req with + | Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx + | None -> return! notFound next ctx + | None -> return! notFound next ctx + } + + /// POST /api/request/[req-id]/history + let addHistory reqId : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let db = db ctx + let! req = db.TryRequestById reqId u.Value + match req with + | Some _ -> + let! hist = ctx.BindJsonAsync () + { History.empty with + requestId = reqId + asOf = jsNow () + status = hist.status + text = match hist.updateText with null | "" -> None | x -> Some x + } + |> db.AddEntry + let! _ = db.SaveChangesAsync () + return! created next ctx + | None -> return! notFound next ctx + | None -> return! notFound next ctx + } + + /// POST /api/request/[req-id]/note + let addNote reqId : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let db = db ctx + let! req = db.TryRequestById reqId u.Value + match req with + | Some _ -> + let! notes = ctx.BindJsonAsync () + { Note.empty with + requestId = reqId + asOf = jsNow () + notes = notes.notes + } + |> db.AddEntry + let! _ = db.SaveChangesAsync () + return! created next ctx + | None -> return! notFound next ctx + | None -> return! notFound next ctx + } + + /// GET /api/requests/answered + let answered : HttpHandler = + fun next ctx -> + match user ctx with + | Some u -> json ((db ctx).AnsweredRequests u.Value) next ctx + | None -> notFound next ctx + + /// GET /api/request/[req-id] + let get reqId : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let! req = (db ctx).TryRequestById reqId u.Value + match req with + | Some r -> return! json r next ctx + | None -> return! notFound next ctx + | None -> return! notFound next ctx + } + + /// GET /api/request/[req-id]/complete + let getComplete reqId : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let! req = (db ctx).TryCompleteRequestById reqId u.Value + match req with + | Some r -> return! json r next ctx + | None -> return! notFound next ctx + | None -> return! notFound next ctx + } + + /// GET /api/request/[req-id]/full + let getFull reqId : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let! req = (db ctx).TryFullRequestById reqId u.Value + match req with + | Some r -> return! json r next ctx + | None -> return! notFound next ctx + | None -> return! notFound next ctx + } + + /// GET /api/request/[req-id]/notes + let getNotes reqId : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let! notes = (db ctx).NotesById reqId u.Value + return! json notes next ctx + | None -> return! notFound next ctx + } + + /// POST /api/request/[req-id]/snooze + let snooze reqId : HttpHandler = + fun next ctx -> + task { + match user ctx with + | Some u -> + let db = db ctx + let! req = db.TryRequestById reqId u.Value + match req with + | Some r -> + let! until = ctx.BindJsonAsync () + { r with snoozedUntil = until.until } + |> db.UpdateEntry + let! _ = db.SaveChangesAsync () + return! setStatusCode 204 next ctx + | None -> return! notFound next ctx + | None -> return! notFound next ctx + } diff --git a/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj b/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj new file mode 100644 index 0000000..57ea803 --- /dev/null +++ b/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj @@ -0,0 +1,25 @@ + + + + netcoreapp2.1 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/api/MyPrayerJournal.Api/Program.fs b/src/api/MyPrayerJournal.Api/Program.fs new file mode 100644 index 0000000..974a013 --- /dev/null +++ b/src/api/MyPrayerJournal.Api/Program.fs @@ -0,0 +1,85 @@ +namespace MyPrayerJournal.Api + +open System +open Microsoft.AspNetCore +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.Extensions.Configuration +open Microsoft.Extensions.Logging + +/// Configuration functions for the application +module Configure = + + open Microsoft.Extensions.DependencyInjection + open Giraffe + open Giraffe.TokenRouter + open MyPrayerJournal + + /// Configure dependency injection + let services (sc : IServiceCollection) = + sc.AddAuthentication() + .AddJwtBearer ("Auth0", + fun opt -> + opt.Audience <- "") + |> ignore + () + + /// Response that will load the Vue application to handle the given URL + let vueApp = fun next ctx -> htmlFile "/index.html" next ctx + + /// Routes for the available URLs within myPrayerJournal + let webApp = + router Handlers.notFound [ + subRoute "/api/" [ + GET [ + route "journal" Handlers.Journal.journal + subRoute "request" [ + route "s/answered" Handlers.Request.answered + routef "/%s/complete" Handlers.Request.getComplete + routef "/%s/full" Handlers.Request.getFull + routef "/%s/notes" Handlers.Request.getNotes + routef "/%s" Handlers.Request.get + ] + ] + POST [ + subRoute "request" [ + route "" Handlers.Request.add + routef "/%s/history" Handlers.Request.addHistory + routef "/%s/note" Handlers.Request.addNote + routef "/%s/snooze" Handlers.Request.snooze + ] + ] + ] + ] + + /// Configure the web application + let application (app : IApplicationBuilder) = + let env = app.ApplicationServices.GetService () + let log = app.ApplicationServices.GetService () + match env.IsDevelopment () with + | true -> + app.UseDeveloperExceptionPage () |> ignore + | false -> + () + + app.UseAuthentication() + .UseStaticFiles() + .UseGiraffe webApp + |> ignore + + +module Program = + + let exitCode = 0 + + let CreateWebHostBuilder args = + WebHost + .CreateDefaultBuilder(args) + .ConfigureServices(Configure.services) + .Configure(Action Configure.application) + + [] + let main args = + CreateWebHostBuilder(args).Build().Run() + + exitCode diff --git a/src/api/MyPrayerJournal.Api/Properties/launchSettings.json b/src/api/MyPrayerJournal.Api/Properties/launchSettings.json new file mode 100644 index 0000000..c0fd2d3 --- /dev/null +++ b/src/api/MyPrayerJournal.Api/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61905", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "MyPrayerJournal.Api": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/src/api/MyPrayerJournal.sln b/src/api/MyPrayerJournal.sln new file mode 100644 index 0000000..f2ff85a --- /dev/null +++ b/src/api/MyPrayerJournal.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27703.2035 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{E0E5240C-00DC-428A-899A-DA4F06625B8A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7EAB6243-94B3-49A5-BA64-7F01B8BE7CB9} + EndGlobalSection +EndGlobal diff --git a/src/api/data/data.go b/src/api/data/data.go deleted file mode 100644 index 5ca71d5..0000000 --- a/src/api/data/data.go +++ /dev/null @@ -1,392 +0,0 @@ -// Package data contains data access functions for myPrayerJournal. -package data - -import ( - "database/sql" - "fmt" - "log" - "time" - - // Register the PostgreSQL driver. - _ "github.com/lib/pq" - "github.com/lucsky/cuid" -) - -const ( - currentRequestSQL = ` - SELECT "requestId", "text", "asOf", "lastStatus", "snoozedUntil" - FROM mpj.journal` - journalSQL = ` - SELECT "requestId", "text", "asOf", "lastStatus", "snoozedUntil" - FROM mpj.journal - WHERE "userId" = $1 - AND "lastStatus" <> 'Answered'` -) - -// db is a connection to the database for the entire application. -var db *sql.DB - -// Settings holds the PostgreSQL configuration for myPrayerJournal. -type Settings struct { - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - DbName string `json:"dbname"` -} - -/* Data Access */ - -// Retrieve a basic request -func retrieveRequest(reqID, userID string) (*Request, bool) { - req := Request{} - err := db.QueryRow(` - SELECT "requestId", "enteredOn", "snoozedUntil" - FROM mpj.request - WHERE "requestId" = $1 - AND "userId" = $2`, reqID, userID).Scan( - &req.ID, &req.EnteredOn, &req.SnoozedUntil, - ) - if err != nil { - if err != sql.ErrNoRows { - log.Print(err) - } - return nil, false - } - req.UserID = userID - return &req, true -} - -// Unix time in JavaScript Date.now() precision. -func jsNow() int64 { - return time.Now().UnixNano() / int64(1000000) -} - -// Loop through rows and create journal requests from them. -func makeJournal(rows *sql.Rows, userID string) []JournalRequest { - var out []JournalRequest - for rows.Next() { - req := JournalRequest{} - err := rows.Scan(&req.RequestID, &req.Text, &req.AsOf, &req.LastStatus, &req.SnoozedUntil) - if err != nil { - log.Print(err) - continue - } - out = append(out, req) - } - if rows.Err() != nil { - log.Print(rows.Err()) - return nil - } - return out -} - -// AddHistory creates a history entry for a prayer request, given the status and updated text. -func AddHistory(userID, reqID, status, text string) int { - if _, ok := retrieveRequest(reqID, userID); !ok { - return 404 - } - _, err := db.Exec(` - INSERT INTO mpj.history - ("requestId", "asOf", "status", "text") - VALUES - ($1, $2, $3, NULLIF($4, ''))`, - reqID, jsNow(), status, text) - if err != nil { - log.Print(err) - return 500 - } - return 204 -} - -// AddNew stores a new prayer request and its initial history record. -func AddNew(userID, text string) (*JournalRequest, bool) { - id := cuid.New() - now := jsNow() - tx, err := db.Begin() - if err != nil { - log.Print(err) - return nil, false - } - defer func() { - if err != nil { - log.Print(err) - tx.Rollback() - } else { - tx.Commit() - } - }() - _, err = tx.Exec( - `INSERT INTO mpj.request ("requestId", "enteredOn", "userId", "snoozedUntil") VALUES ($1, $2, $3, 0)`, - id, now, userID) - if err != nil { - return nil, false - } - _, err = tx.Exec( - `INSERT INTO mpj.history ("requestId", "asOf", "status", "text") VALUES ($1, $2, 'Created', $3)`, - id, now, text) - if err != nil { - return nil, false - } - return &JournalRequest{RequestID: id, Text: text, AsOf: now, LastStatus: `Created`, SnoozedUntil: 0}, true -} - -// AddNote adds a note to a prayer request. -func AddNote(userID, reqID, note string) int { - if _, ok := retrieveRequest(reqID, userID); !ok { - return 404 - } - _, err := db.Exec(` - INSERT INTO mpj.note - ("requestId", "asOf", "notes") - VALUES - ($1, $2, $3)`, - reqID, jsNow(), note) - if err != nil { - log.Print(err) - return 500 - } - return 204 -} - -// Answered retrieves all answered requests for the given user. -func Answered(userID string) []JournalRequest { - rows, err := db.Query(currentRequestSQL+ - ` WHERE "userId" = $1 - AND "lastStatus" = 'Answered' - ORDER BY "asOf" DESC`, - userID) - if err != nil { - log.Print(err) - return nil - } - defer rows.Close() - return makeJournal(rows, userID) -} - -// ByID retrieves a journal request by its ID. -func ByID(userID, reqID string) (*JournalRequest, bool) { - req := JournalRequest{} - err := db.QueryRow(currentRequestSQL+ - ` WHERE "requestId" = $1 - AND "userId" = $2`, - reqID, userID).Scan( - &req.RequestID, &req.Text, &req.AsOf, &req.LastStatus, &req.SnoozedUntil, - ) - if err != nil { - if err == sql.ErrNoRows { - return nil, true - } - log.Print(err) - return nil, false - } - return &req, true -} - -// Connect establishes a connection to the database. -func Connect(s *Settings) bool { - connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", - s.Host, s.Port, s.User, s.Password, s.DbName) - var err error - db, err = sql.Open("postgres", connStr) - if err != nil { - log.Print(err) - return false - } - err = db.Ping() - if err != nil { - log.Print(err) - return false - } - log.Printf("Connected to postgres://%s@%s:%d/%s\n", s.User, s.Host, s.Port, s.DbName) - return true -} - -// FullByID retrieves a journal request, including its full history and notes. -func FullByID(userID, reqID string) (*JournalRequest, bool) { - req, ok := ByID(userID, reqID) - if !ok { - return nil, false - } - hRows, err := db.Query(` - SELECT "asOf", "status", COALESCE("text", '') AS "text" - FROM mpj.history - WHERE "requestId" = $1 - ORDER BY "asOf"`, - reqID) - if err != nil { - log.Print(err) - return nil, false - } - defer hRows.Close() - for hRows.Next() { - hist := History{} - err = hRows.Scan(&hist.AsOf, &hist.Status, &hist.Text) - if err != nil { - log.Print(err) - continue - } - req.History = append(req.History, hist) - } - if hRows.Err() != nil { - log.Print(hRows.Err()) - return nil, false - } - req.Notes, err = NotesByID(userID, reqID) - if err != nil { - log.Print(err) - return nil, false - } - return req, true -} - -// Journal retrieves the current user's active prayer journal. -func Journal(userID string) []JournalRequest { - rows, err := db.Query(journalSQL+` ORDER BY "asOf"`, userID) - if err != nil { - log.Print(err) - return nil - } - defer rows.Close() - return makeJournal(rows, userID) -} - -// NotesByID retrieves the notes for a given prayer request -func NotesByID(userID, reqID string) ([]Note, error) { - if _, ok := retrieveRequest(reqID, userID); !ok { - return nil, sql.ErrNoRows - } - rows, err := db.Query(` - SELECT "asOf", "notes" - FROM mpj.note - WHERE "requestId" = $1 - ORDER BY "asOf" DESC`, - reqID) - if err != nil { - log.Print(err) - return nil, err - } - defer rows.Close() - var notes []Note - for rows.Next() { - note := Note{} - err = rows.Scan(¬e.AsOf, ¬e.Notes) - if err != nil { - log.Print(err) - continue - } - notes = append(notes, note) - } - if rows.Err() != nil { - log.Print(rows.Err()) - return nil, err - } - return notes, nil -} - -// SnoozeByID sets a request to not show until a specified time -func SnoozeByID(userID, reqID string, until int64) int { - if _, ok := retrieveRequest(reqID, userID); !ok { - return 404 - } - _, err := db.Exec(` - UPDATE mpj.request - SET "snoozedUntil" = $2 - WHERE "requestId" = $1`, - reqID, until) - if err != nil { - log.Print(err) - return 500 - } - return 204 -} - -/* DDL */ - -// EnsureDB makes sure we have a known state of data structures. -func EnsureDB() { - tableSQL := func(table string) string { - return fmt.Sprintf(`SELECT 1 FROM pg_tables WHERE schemaname='mpj' AND tablename='%s'`, table) - } - columnSQL := func(table, column string) string { - return fmt.Sprintf( - `SELECT 1 FROM information_schema.columns WHERE table_schema='mpj' AND table_name='%s' AND column_name='%s'`, - table, column) - } - indexSQL := func(table, index string) string { - return fmt.Sprintf(`SELECT 1 FROM pg_indexes WHERE schemaname='mpj' AND tablename='%s' AND indexname='%s'`, - table, index) - } - check := func(name, test, fix string) { - count := 0 - err := db.QueryRow(test).Scan(&count) - if err != nil { - if err == sql.ErrNoRows { - log.Printf("Fixing up %s...\n", name) - _, err = db.Exec(fix) - if err != nil { - log.Fatal(err) - } - } else { - log.Fatal(err) - } - } - } - check(`myPrayerJournal Schema`, `SELECT 1 FROM pg_namespace WHERE nspname='mpj'`, - `CREATE SCHEMA mpj; - COMMENT ON SCHEMA mpj IS 'myPrayerJournal data'`) - if _, err := db.Exec(`SET search_path TO mpj`); err != nil { - log.Fatal(err) - } - check(`request Table`, tableSQL(`request`), - `CREATE TABLE mpj.request ( - "requestId" varchar(25) PRIMARY KEY, - "enteredOn" bigint NOT NULL, - "userId" varchar(100) NOT NULL); - COMMENT ON TABLE mpj.request IS 'Requests'`) - check(`request.snoozedUntil Column`, columnSQL(`request`, `snoozedUntil`), - `ALTER TABLE mpj.request - ADD COLUMN "snoozedUntil" bigint NOT NULL DEFAULT 0`) - check(`history Table`, tableSQL(`history`), - `CREATE TABLE mpj.history ( - "requestId" varchar(25) NOT NULL REFERENCES mpj.request, - "asOf" bigint NOT NULL, - "status" varchar(25), - "text" text, - PRIMARY KEY ("requestId", "asOf")); - COMMENT ON TABLE mpj.history IS 'Request update history'`) - check(`note Table`, tableSQL(`note`), - `CREATE TABLE mpj.note ( - "requestId" varchar(25) NOT NULL REFERENCES mpj.request, - "asOf" bigint NOT NULL, - "notes" text NOT NULL, - PRIMARY KEY ("requestId", "asOf")); - COMMENT ON TABLE mpj.note IS 'Notes regarding a request'`) - check(`request.userId Index`, indexSQL(`request`, `idx_request_userId`), - `CREATE INDEX "idx_request_userId" ON mpj.request ("userId"); - COMMENT ON INDEX "idx_request_userId" IS 'Requests are retrieved by user'`) - check(`journal View`, `SELECT 1 FROM pg_views WHERE schemaname='mpj' AND viewname='journal'`, - `CREATE VIEW mpj.journal AS - SELECT - request."requestId", - request."userId", - (SELECT "text" - FROM mpj.history - WHERE history."requestId" = request."requestId" - AND "text" IS NOT NULL - ORDER BY "asOf" DESC - LIMIT 1) AS "text", - (SELECT "asOf" - FROM mpj.history - WHERE history."requestId" = request."requestId" - ORDER BY "asOf" DESC - LIMIT 1) AS "asOf", - (SELECT "status" - FROM mpj.history - WHERE history."requestId" = request."requestId" - ORDER BY "asOf" DESC - LIMIT 1) AS "lastStatus", - request."snoozedUntil" - FROM mpj.request; - COMMENT ON VIEW mpj.journal IS 'Requests with latest text'`) -} diff --git a/src/api/data/entities.go b/src/api/data/entities.go deleted file mode 100644 index d85aa60..0000000 --- a/src/api/data/entities.go +++ /dev/null @@ -1,36 +0,0 @@ -package data - -// History is a record of action taken on a prayer request, including updates to its text. -type History struct { - RequestID string `json:"requestId"` - AsOf int64 `json:"asOf"` - Status string `json:"status"` - Text string `json:"text"` -} - -// Note is a note regarding a prayer request that does not result in an update to its text. -type Note struct { - RequestID string `json:"requestId"` - AsOf int64 `json:"asOf"` - Notes string `json:"notes"` -} - -// Request is the identifying record for a prayer request. -type Request struct { - ID string `json:"requestId"` - EnteredOn int64 `json:"enteredOn"` - UserID string `json:"userId"` - SnoozedUntil int64 `json:"snoozedUntil"` -} - -// JournalRequest is the form of a prayer request returned for the request journal display. It also contains -// properties that may be filled for history and notes. -type JournalRequest struct { - RequestID string `json:"requestId"` - Text string `json:"text"` - AsOf int64 `json:"asOf"` - LastStatus string `json:"lastStatus"` - SnoozedUntil int64 `json:"snoozedUntil"` - History []History `json:"history,omitempty"` - Notes []Note `json:"notes,omitempty"` -} diff --git a/src/api/routes/handlers.go b/src/api/routes/handlers.go deleted file mode 100644 index bd05b3d..0000000 --- a/src/api/routes/handlers.go +++ /dev/null @@ -1,192 +0,0 @@ -package routes - -import ( - "database/sql" - "encoding/json" - "errors" - "log" - "net/http" - "strings" - - "github.com/danieljsummers/myPrayerJournal/src/api/data" - jwt "github.com/dgrijalva/jwt-go" - routing "github.com/go-ozzo/ozzo-routing" -) - -/* Support */ - -// Set the content type, the HTTP error code, and return the error message. -func sendError(c *routing.Context, err error) error { - w := c.Response - w.Header().Set("Content-Type", "application/json; encoding=UTF-8") - w.WriteHeader(http.StatusInternalServerError) - if err := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}); err != nil { - log.Print("Error creating error JSON: " + err.Error()) - } - return err -} - -// Set the content type and return the JSON to the user. -func sendJSON(c *routing.Context, result interface{}) error { - w := c.Response - w.Header().Set("Content-Type", "application/json; encoding=UTF-8") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(result); err != nil { - return sendError(c, err) - } - return nil -} - -// Send an HTTP 404 response. -func notFound(c *routing.Context) error { - c.Response.WriteHeader(404) - return nil -} - -// Parse the request body as JSON. -func parseJSON(c *routing.Context) (map[string]interface{}, error) { - payload := make(map[string]interface{}) - if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil { - log.Println("Error decoding JSON:", err) - return payload, err - } - return payload, nil -} - -// userID is a convenience function to extract the subscriber ID from the user's JWT. -// NOTE: Do not call this from public routes; there are a lot of type assertions that won't be true if the request -// hasn't gone through the authorization process. -func userID(c *routing.Context) string { - return c.Request.Context().Value("user").(*jwt.Token).Claims.(jwt.MapClaims)["sub"].(string) -} - -/* Handlers */ - -// GET: /api/journal/ -func journal(c *routing.Context) error { - reqs := data.Journal(userID(c)) - if reqs == nil { - reqs = []data.JournalRequest{} - } - return sendJSON(c, reqs) -} - -// POST: /api/request/ -func requestAdd(c *routing.Context) error { - payload, err := parseJSON(c) - if err != nil { - return sendError(c, err) - } - result, ok := data.AddNew(userID(c), payload["requestText"].(string)) - if !ok { - return sendError(c, errors.New("error adding request")) - } - return sendJSON(c, result) -} - -// GET: /api/request/ -func requestGet(c *routing.Context) error { - request, ok := data.ByID(userID(c), c.Param("id")) - if !ok { - return sendError(c, errors.New("error retrieving request")) - } - if request == nil { - return notFound(c) - } - return sendJSON(c, request) -} - -// GET: /api/request//complete -func requestGetComplete(c *routing.Context) error { - request, ok := data.FullByID(userID(c), c.Param("id")) - if !ok { - return sendError(c, errors.New("error retrieving request")) - } - var err error - request.Notes, err = data.NotesByID(userID(c), c.Param("id")) - if err != nil { - return sendError(c, err) - } - return sendJSON(c, request) -} - -// GET: /api/request//full -func requestGetFull(c *routing.Context) error { - request, ok := data.FullByID(userID(c), c.Param("id")) - if !ok { - return sendError(c, errors.New("error retrieving request")) - } - return sendJSON(c, request) -} - -// POST: /api/request//history -func requestAddHistory(c *routing.Context) error { - payload, err := parseJSON(c) - if err != nil { - return sendError(c, err) - } - c.Response.WriteHeader( - data.AddHistory(userID(c), c.Param("id"), payload["status"].(string), payload["updateText"].(string))) - return nil -} - -// POST: /api/request//note -func requestAddNote(c *routing.Context) error { - payload, err := parseJSON(c) - if err != nil { - return sendError(c, err) - } - c.Response.WriteHeader(data.AddNote(userID(c), c.Param("id"), payload["notes"].(string))) - return nil -} - -// GET: /api/request//notes -func requestGetNotes(c *routing.Context) error { - notes, err := data.NotesByID(userID(c), c.Param("id")) - if err != nil { - if err == sql.ErrNoRows { - return notFound(c) - } - return sendError(c, err) - } - if notes == nil { - notes = []data.Note{} - } - return sendJSON(c, notes) -} - -// POST: /api/request//snooze -func requestSnooze(c *routing.Context) error { - payload, err := parseJSON(c) - if err != nil { - return sendError(c, err) - } - c.Response.WriteHeader(data.SnoozeByID(userID(c), c.Param("id"), payload["until"].(int64))) - return nil -} - -// GET: /api/request/answered -func requestsAnswered(c *routing.Context) error { - reqs := data.Answered(userID(c)) - if reqs == nil { - reqs = []data.JournalRequest{} - } - return sendJSON(c, reqs) -} - -// GET: /* -func staticFiles(c *routing.Context) error { - // serve index for known routes handled client-side by the app - r := c.Request - w := c.Response - for _, prefix := range ClientPrefixes { - if strings.HasPrefix(r.URL.Path, prefix) { - w.Header().Add("Content-Type", "text/html") - http.ServeFile(w, r, "./public/index.html") - return nil - } - } - // 404 here is fine; quit hacking, y'all... - http.ServeFile(w, r, "./public"+r.URL.Path) - return nil -} diff --git a/src/api/routes/router.go b/src/api/routes/router.go deleted file mode 100644 index ea249b4..0000000 --- a/src/api/routes/router.go +++ /dev/null @@ -1,119 +0,0 @@ -package routes - -import ( - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "log" - "net/http" - - "github.com/auth0/go-jwt-middleware" - jwt "github.com/dgrijalva/jwt-go" - "github.com/go-ozzo/ozzo-routing" - "github.com/go-ozzo/ozzo-routing/fault" -) - -// AuthConfig contains the Auth0 configuration passed from the "auth" JSON object. -type AuthConfig struct { - Domain string `json:"domain"` - ClientID string `json:"id"` - ClientSecret string `json:"secret"` -} - -// JWKS is a structure into which the JSON Web Key Set is unmarshaled. -type JWKS struct { - Keys []JWK `json:"keys"` -} - -// JWK is a structure into which a single JSON Web Key is unmarshaled. -type JWK struct { - Kty string `json:"kty"` - Kid string `json:"kid"` - Use string `json:"use"` - N string `json:"n"` - E string `json:"e"` - X5c []string `json:"x5c"` -} - -// authCfg is the Auth0 configuration provided at application startup. -var authCfg *AuthConfig - -// jwksBytes is a cache of the JSON Web Key Set for this domain. -var jwksBytes = make([]byte, 0) - -// getPEMCert is a function to get the applicable certificate for a JSON Web Token. -func getPEMCert(token *jwt.Token) (string, error) { - cert := "" - - if len(jwksBytes) == 0 { - resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/jwks.json", authCfg.Domain)) - if err != nil { - return cert, err - } - defer resp.Body.Close() - - if jwksBytes, err = ioutil.ReadAll(resp.Body); err != nil { - return cert, err - } - } - - jwks := JWKS{} - if err := json.Unmarshal(jwksBytes, &jwks); err != nil { - return cert, err - } - for k, v := range jwks.Keys[0].X5c { - if token.Header["kid"] == jwks.Keys[k].Kid { - cert = fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", v) - } - } - if cert == "" { - err := errors.New("unable to find appropriate key") - return cert, err - } - - return cert, nil -} - -// authZero is an instance of Auth0's JWT middlware. Since it doesn't support the http.HandlerFunc sig, it is wrapped -// below; it's defined outside that function, though, so it does not get recreated every time. -var authZero = jwtmiddleware.New(jwtmiddleware.Options{ - ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { - if checkAud := token.Claims.(jwt.MapClaims).VerifyAudience(authCfg.ClientID, false); !checkAud { - return token, errors.New("invalid audience") - } - iss := fmt.Sprintf("https://%s/", authCfg.Domain) - if checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(iss, false); !checkIss { - return token, errors.New("invalid issuer") - } - - cert, err := getPEMCert(token) - if err != nil { - panic(err.Error()) - } - - result, _ := jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) - return result, nil - }, - SigningMethod: jwt.SigningMethodRS256, -}) - -// authMiddleware is a wrapper for the Auth0 middleware above with a signature ozzo-routing recognizes. -func authMiddleware(c *routing.Context) error { - return authZero.CheckJWT(c.Response, c.Request) -} - -// NewRouter returns a configured router to handle all incoming requests. -func NewRouter(cfg *AuthConfig) *routing.Router { - authCfg = cfg - router := routing.New() - router.Use(fault.Recovery(log.Printf)) - for _, route := range routes { - if route.IsPublic { - router.To(route.Method, route.Pattern, route.Func) - } else { - router.To(route.Method, route.Pattern, authMiddleware, route.Func) - } - } - return router -} diff --git a/src/api/routes/routes.go b/src/api/routes/routes.go deleted file mode 100644 index 27dd92b..0000000 --- a/src/api/routes/routes.go +++ /dev/null @@ -1,106 +0,0 @@ -// Package routes contains endpoint handlers for the myPrayerJournal API. -package routes - -import ( - "net/http" - - routing "github.com/go-ozzo/ozzo-routing" -) - -// Route is a route served in the application. -type Route struct { - Name string - Method string - Pattern string - Func routing.Handler - IsPublic bool -} - -// Routes is the collection of all routes served in the application. -type Routes []Route - -// routes is the actual list of routes for the application. -var routes = Routes{ - Route{ - "Journal", - http.MethodGet, - "/api/journal/", - journal, - false, - }, - Route{ - "AddNewRequest", - http.MethodPost, - "/api/request/", - requestAdd, - false, - }, - // Must be above GetRequestByID - Route{ - "GetAnsweredRequests", - http.MethodGet, - "/api/request/answered", - requestsAnswered, - false, - }, - Route{ - "GetRequestByID", - http.MethodGet, - "/api/request/", - requestGet, - false, - }, - Route{ - "GetCompleteRequestByID", - http.MethodGet, - "/api/request//complete", - requestGetComplete, - false, - }, - Route{ - "GetFullRequestByID", - http.MethodGet, - "/api/request//full", - requestGetFull, - false, - }, - Route{ - "AddNewHistoryEntry", - http.MethodPost, - "/api/request//history", - requestAddHistory, - false, - }, - Route{ - "AddNewNote", - http.MethodPost, - "/api/request//note", - requestAddNote, - false, - }, - Route{ - "GetNotesForRequest", - http.MethodGet, - "/api/request//notes", - requestGetNotes, - false, - }, - Route{ - "SnoozeRequest", - http.MethodPost, - "/api/request//snooze", - requestSnooze, - false, - }, - // keep this route last - Route{ - "StaticFiles", - http.MethodGet, - "/*", - staticFiles, - true, - }, -} - -// ClientPrefixes is a list of known route prefixes handled by the Vue app. -var ClientPrefixes = []string{"/answered", "/journal", "/user"}