F# API #18

Merged
danieljsummers merged 7 commits from fs-api into master 2018-08-07 02:21:29 +00:00
5 changed files with 143 additions and 148 deletions
Showing only changes of commit 07226f8cd8 - Show all commits

View File

@ -58,11 +58,14 @@ module Entities =
m.Property(fun e -> e.requestId).IsRequired () |> ignore m.Property(fun e -> e.requestId).IsRequired () |> ignore
m.Property(fun e -> e.asOf).IsRequired () |> ignore m.Property(fun e -> e.asOf).IsRequired () |> ignore
m.Property(fun e -> e.status).IsRequired() |> ignore m.Property(fun e -> e.status).IsRequired() |> ignore
m.Property(fun e -> e.text) |> ignore
m.HasOne(fun e -> e.request) m.HasOne(fun e -> e.request)
.WithMany(fun r -> r.history :> IEnumerable<History>) .WithMany(fun r -> r.history :> IEnumerable<History>)
.HasForeignKey(fun e -> e.requestId :> obj) .HasForeignKey(fun e -> e.requestId :> obj)
|> ignore) |> ignore)
|> ignore |> ignore
let typ = mb.Model.FindEntityType(typeof<History>)
let prop = typ.FindProperty("text")
mb.Model.FindEntityType(typeof<History>).FindProperty("text").SetValueConverter (OptionConverter<string> ()) mb.Model.FindEntityType(typeof<History>).FindProperty("text").SetValueConverter (OptionConverter<string> ())
/// Note is a note regarding a prayer request that does not result in an update to its text /// Note is a note regarding a prayer request that does not result in an update to its text
@ -171,13 +174,9 @@ open System.Linq
open System.Threading.Tasks open System.Threading.Tasks
/// Data context /// Data context
type AppDbContext (opts : DbContextOptions<AppDbContext>) as self = type AppDbContext (opts : DbContextOptions<AppDbContext>) =
inherit DbContext (opts) 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
[<DefaultValue>] [<DefaultValue>]
val mutable private history : DbSet<History> val mutable private history : DbSet<History>
[<DefaultValue>] [<DefaultValue>]
@ -209,13 +208,17 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) as self =
] ]
|> List.iter (fun x -> x mb) |> List.iter (fun x -> x mb)
/// Register a disconnected entity with the context, having the given state
member private this.RegisterAs<'TEntity when 'TEntity : not struct> state e =
this.Entry<'TEntity>(e).State <- state
/// Add an entity instance to the context /// Add an entity instance to the context
member __.AddEntry e = member this.AddEntry e =
registerAs EntityState.Added e this.RegisterAs EntityState.Added e
/// Update the entity instance's values /// Update the entity instance's values
member __.UpdateEntry e = member this.UpdateEntry e =
registerAs EntityState.Modified e this.RegisterAs EntityState.Modified e
/// Retrieve all answered requests for the given user /// Retrieve all answered requests for the given user
member this.AnsweredRequests userId : JournalRequest seq = member this.AnsweredRequests userId : JournalRequest seq =
@ -227,7 +230,7 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) as self =
member this.JournalByUserId userId : JournalRequest seq = member this.JournalByUserId userId : JournalRequest seq =
upcast this.Journal upcast this.Journal
.Where(fun r -> r.userId = userId && r.lastStatus <> "Answered") .Where(fun r -> r.userId = userId && r.lastStatus <> "Answered")
.OrderBy(fun r -> r.asOf) .OrderByDescending(fun r -> r.asOf)
/// Retrieve a request by its ID and user ID /// Retrieve a request by its ID and user ID
member this.TryRequestById reqId userId : Task<Request option> = member this.TryRequestById reqId userId : Task<Request option> =

View File

@ -32,8 +32,8 @@ module Error =
module private Helpers = module private Helpers =
open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Authorization
open System.Threading.Tasks open System.Threading.Tasks
open System.Security.Claims
/// Get the database context from DI /// Get the database context from DI
let db (ctx : HttpContext) = let db (ctx : HttpContext) =
@ -41,7 +41,12 @@ module private Helpers =
/// Get the user's "sub" claim /// Get the user's "sub" claim
let user (ctx : HttpContext) = let user (ctx : HttpContext) =
ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = "sub") 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
/// Return a 201 CREATED response /// Return a 201 CREATED response
let created next ctx = let created next ctx =
@ -51,20 +56,17 @@ module private Helpers =
let jsNow () = let jsNow () =
DateTime.Now.Subtract(DateTime (1970, 1, 1)).TotalSeconds |> int64 |> (*) 1000L DateTime.Now.Subtract(DateTime (1970, 1, 1)).TotalSeconds |> int64 |> (*) 1000L
/// Handler to return a 403 Not Authorized reponse
let notAuthorized : HttpHandler = let notAuthorized : HttpHandler =
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
/// Handler to require authorization /// Handler to require authorization
let authorize : HttpHandler = let authorize : HttpHandler =
fun next ctx -> fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx
task {
let auth = ctx.GetService<IAuthorizationService>() /// Flip JSON result so we can pipe into it
let! result = auth.AuthorizeAsync (ctx.User, "LoggedOn") let asJson<'T> next ctx (o : 'T) =
Console.WriteLine (sprintf "*** Auth succeeded = %b" result.Succeeded) json o next ctx
match result.Succeeded with
| true -> return! next ctx
| false -> return! notAuthorized next ctx
}
/// Strongly-typed models for post requests /// Strongly-typed models for post requests
@ -107,9 +109,9 @@ module Journal =
let journal : HttpHandler = let journal : HttpHandler =
authorize authorize
>=> fun next ctx -> >=> fun next ctx ->
match user ctx with userId ctx
| Some u -> json ((db ctx).JournalByUserId u.Value) next ctx |> (db ctx).JournalByUserId
| None -> Error.notFound next ctx |> asJson next ctx
/// /api/request URLs /// /api/request URLs
@ -119,155 +121,141 @@ module Request =
/// POST /api/request /// POST /api/request
let add : HttpHandler = let add : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let! r = ctx.BindJsonAsync<Models.Request> ()
| Some u -> let db = db ctx
let! r = ctx.BindJsonAsync<Models.Request> () let reqId = Cuid.Generate ()
let db = db ctx let usrId = userId ctx
let reqId = Cuid.Generate () let now = jsNow ()
let now = jsNow () { Request.empty with
{ Request.empty with requestId = reqId
requestId = reqId userId = usrId
userId = u.Value enteredOn = now
enteredOn = now snoozedUntil = 0L
snoozedUntil = 0L }
} |> db.AddEntry
|> db.AddEntry { History.empty with
{ History.empty with requestId = reqId
requestId = reqId asOf = now
asOf = now status = "Created"
status = "Created" text = Some r.requestText
text = Some r.requestText }
} |> db.AddEntry
|> db.AddEntry let! _ = db.SaveChangesAsync ()
let! _ = db.SaveChangesAsync () let! req = db.TryJournalById reqId usrId
let! req = db.TryJournalById reqId u.Value match req with
match req with | Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx
| Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// POST /api/request/[req-id]/history /// POST /api/request/[req-id]/history
let addHistory reqId : HttpHandler = let addHistory reqId : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let db = db ctx
| Some u -> let! req = db.TryRequestById reqId (userId ctx)
let db = db ctx match req with
let! req = db.TryRequestById reqId u.Value | Some _ ->
match req with let! hist = ctx.BindJsonAsync<Models.HistoryEntry> ()
| Some _ -> { History.empty with
let! hist = ctx.BindJsonAsync<Models.HistoryEntry> () requestId = reqId
{ History.empty with asOf = jsNow ()
requestId = reqId status = hist.status
asOf = jsNow () text = match hist.updateText with null | "" -> None | x -> Some x
status = hist.status }
text = match hist.updateText with null | "" -> None | x -> Some x |> db.AddEntry
} let! _ = db.SaveChangesAsync ()
|> db.AddEntry return! created next ctx
let! _ = db.SaveChangesAsync ()
return! created next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// POST /api/request/[req-id]/note /// POST /api/request/[req-id]/note
let addNote reqId : HttpHandler = let addNote reqId : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let db = db ctx
| Some u -> let! req = db.TryRequestById reqId (userId ctx)
let db = db ctx match req with
let! req = db.TryRequestById reqId u.Value | Some _ ->
match req with let! notes = ctx.BindJsonAsync<Models.NoteEntry> ()
| Some _ -> { Note.empty with
let! notes = ctx.BindJsonAsync<Models.NoteEntry> () requestId = reqId
{ Note.empty with asOf = jsNow ()
requestId = reqId notes = notes.notes
asOf = jsNow () }
notes = notes.notes |> db.AddEntry
} let! _ = db.SaveChangesAsync ()
|> db.AddEntry return! created next ctx
let! _ = db.SaveChangesAsync ()
return! created next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// GET /api/requests/answered /// GET /api/requests/answered
let answered : HttpHandler = let answered : HttpHandler =
fun next ctx -> authorize
match user ctx with >=> fun next ctx ->
| Some u -> json ((db ctx).AnsweredRequests u.Value) next ctx userId ctx
| None -> Error.notFound next ctx |> (db ctx).AnsweredRequests
|> asJson next ctx
/// GET /api/request/[req-id] /// GET /api/request/[req-id]
let get reqId : HttpHandler = let get reqId : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let! req = (db ctx).TryRequestById reqId (userId ctx)
| Some u -> match req with
let! req = (db ctx).TryRequestById reqId u.Value | Some r -> return! json r next ctx
match req with
| Some r -> return! json r next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// GET /api/request/[req-id]/complete /// GET /api/request/[req-id]/complete
let getComplete reqId : HttpHandler = let getComplete reqId : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let! req = (db ctx).TryCompleteRequestById reqId (userId ctx)
| Some u -> match req with
let! req = (db ctx).TryCompleteRequestById reqId u.Value | Some r -> return! json r next ctx
match req with
| Some r -> return! json r next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// GET /api/request/[req-id]/full /// GET /api/request/[req-id]/full
let getFull reqId : HttpHandler = let getFull reqId : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let! req = (db ctx).TryFullRequestById reqId (userId ctx)
| Some u -> match req with
let! req = (db ctx).TryFullRequestById reqId u.Value | Some r -> return! json r next ctx
match req with
| Some r -> return! json r next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// GET /api/request/[req-id]/notes /// GET /api/request/[req-id]/notes
let getNotes reqId : HttpHandler = let getNotes reqId : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let! notes = (db ctx).NotesById reqId (userId ctx)
| Some u -> return! json notes next ctx
let! notes = (db ctx).NotesById reqId u.Value
return! json notes next ctx
| None -> return! Error.notFound next ctx
} }
/// POST /api/request/[req-id]/snooze /// POST /api/request/[req-id]/snooze
let snooze reqId : HttpHandler = let snooze reqId : HttpHandler =
fun next ctx -> authorize
>=> fun next ctx ->
task { task {
match user ctx with let db = db ctx
| Some u -> let! req = db.TryRequestById reqId (userId ctx)
let db = db ctx match req with
let! req = db.TryRequestById reqId u.Value | Some r ->
match req with let! until = ctx.BindJsonAsync<Models.SnoozeUntil> ()
| Some r -> { r with snoozedUntil = until.until }
let! until = ctx.BindJsonAsync<Models.SnoozeUntil> () |> db.UpdateEntry
{ r with snoozedUntil = until.until } let! _ = db.SaveChangesAsync ()
|> db.UpdateEntry return! setStatusCode 204 next ctx
let! _ = db.SaveChangesAsync ()
return! setStatusCode 204 next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -16,6 +16,7 @@
<PackageReference Include="Giraffe.TokenRouter" Version="0.1.0-beta-110" /> <PackageReference Include="Giraffe.TokenRouter" Version="0.1.0-beta-110" />
<PackageReference Include="Microsoft.AspNetCore.App" /> <PackageReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NCuid.NetCore" Version="1.0.1" /> <PackageReference Include="NCuid.NetCore" Version="1.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.1.1.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -2,18 +2,21 @@ namespace MyPrayerJournal.Api
open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Hosting
open System
/// Configuration functions for the application /// Configuration functions for the application
module Configure = module Configure =
open Giraffe
open Giraffe.TokenRouter
open Microsoft.AspNetCore.Authentication.JwtBearer open Microsoft.AspNetCore.Authentication.JwtBearer
open Microsoft.AspNetCore.Server.Kestrel.Core open Microsoft.AspNetCore.Server.Kestrel.Core
open Microsoft.EntityFrameworkCore
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open Giraffe open MyPrayerJournal
open Giraffe.TokenRouter
/// Set up the configuration for the app /// Set up the configuration for the app
let configuration (ctx : WebHostBuilderContext) (cfg : IConfigurationBuilder) = let configuration (ctx : WebHostBuilderContext) (cfg : IConfigurationBuilder) =
@ -29,21 +32,22 @@ module Configure =
/// Configure dependency injection /// Configure dependency injection
let services (sc : IServiceCollection) = let services (sc : IServiceCollection) =
sc.AddGiraffe () |> ignore
// mad props to Andrea Chiarelli @ https://auth0.com/blog/securing-asp-dot-net-core-2-applications-with-jwts/
use sp = sc.BuildServiceProvider() use sp = sc.BuildServiceProvider()
let cfg = sp.GetRequiredService<IConfiguration>().GetSection "Auth0" let cfg = sp.GetRequiredService<IConfiguration> ()
sc.AddAuthentication( sc.AddGiraffe()
fun opts -> .AddAuthentication(
opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme /// Use HTTP "Bearer" authentication with JWTs
opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer (
fun opts -> fun opts ->
opts.Authority <- sprintf "https://%s/" cfg.["Domain"] opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
opts.Audience <- cfg.["Audience"] opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme)
opts.TokenValidationParameters.ValidateAudience <- false) .AddJwtBearer(
/// Configure JWT options with Auth0 options from configuration
fun opts ->
let jwtCfg = cfg.GetSection "Auth0"
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
opts.Audience <- jwtCfg.["Id"])
|> ignore |> ignore
sc.AddAuthorization (fun opts -> opts.AddPolicy ("LoggedOn", fun p -> p.RequireClaim "sub" |> ignore)) sc.AddDbContext<AppDbContext>(fun opts -> opts.UseNpgsql(cfg.GetConnectionString "mpj") |> ignore)
|> ignore |> ignore
/// Routes for the available URLs within myPrayerJournal /// Routes for the available URLs within myPrayerJournal
@ -96,12 +100,11 @@ module Configure =
module Program = module Program =
open System
open System.IO open System.IO
let exitCode = 0 let exitCode = 0
let CreateWebHostBuilder args = let CreateWebHostBuilder _ =
let contentRoot = Directory.GetCurrentDirectory () let contentRoot = Directory.GetCurrentDirectory ()
WebHostBuilder() WebHostBuilder()
.UseContentRoot(contentRoot) .UseContentRoot(contentRoot)

View File

@ -31,12 +31,12 @@ export default {
* Add a new prayer request * Add a new prayer request
* @param {string} requestText The text of the request to be added * @param {string} requestText The text of the request to be added
*/ */
addRequest: requestText => http.post('request/', { requestText }), addRequest: requestText => http.post('request', { requestText }),
/** /**
* Get all answered requests, along with the text they had when it was answered * Get all answered requests, along with the text they had when it was answered
*/ */
getAnsweredRequests: () => http.get('request/answered'), getAnsweredRequests: () => http.get('requests/answered'),
/** /**
* Get a prayer request (full; includes all history) * Get a prayer request (full; includes all history)
@ -64,7 +64,7 @@ export default {
/** /**
* Get all prayer requests and their most recent updates * Get all prayer requests and their most recent updates
*/ */
journal: () => http.get('journal/'), journal: () => http.get('journal'),
/** /**
* Update a prayer request * Update a prayer request