Skip to content

Commit

Permalink
Expose RPC to REST API
Browse files Browse the repository at this point in the history
  • Loading branch information
sistracia committed Apr 14, 2024
1 parent 68c6c7a commit 2ee918e
Show file tree
Hide file tree
Showing 12 changed files with 327 additions and 74 deletions.
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,6 @@ MailSettings__SenderEmail=<YOUR@EMAIL>
MailSettings__UserName=<YOUR MAIL SERVER USERNAME>
MailSettings__Password=<YOUR MAIL SERVER PASWORD>
PUBLIC_HOST=<YOUR PUBLIC HOST>
PORT=<PUBLISHED PORT FOR THE SERVER APP INSIDE Docker> # Used for docker-compose
ASPNETCORE_URLS_PORT=<SERVER APP PORT INSIDE Docker>
ASPNETCORE_URLS=<SERVER APP HOST AND PORT INSIDE Docker>
```

Expand All @@ -97,7 +95,7 @@ ASPNETCORE_URLS=<SERVER APP HOST AND PORT INSIDE Docker>
See the [docker-compose.yaml here](./web/docker-compose.yaml).

```bash
PORT=<PUBLISHD PORT> ASPNETCORE_URLS_PORT=<SERVER APP PORT INSIDE Docker> docker compose up
docker compose up -f docker-compose.yaml
```

#### Using `docker build` and `docker run`
Expand All @@ -107,7 +105,7 @@ PORT=<PUBLISHD PORT> ASPNETCORE_URLS_PORT=<SERVER APP PORT INSIDE Docker> docker
docker build -t rss-bookmarkr -f ./Dockerfile .

# Run
docker run --env-file ./.env -p <PUBLISHD PORT>:<SERVER APP PORT INSIDE Docker> rss-bookmarkr
docker run --env-file ./.env rss-bookmarkr
## or
docker run \
-e ConnectionStrings__RssDb="POSTGRES CONNECTION STRING" \
Expand All @@ -119,6 +117,24 @@ docker run \
-e MailSettings__Password=<YOUR MAIL SERVER PASWORD> \
-e PUBLIC_HOST=<YOUR PUBLIC HOST> \
-e ASPNETCORE_URLS="<SERVER APP HOST AND PORT INSIDE Docker>" \
-p <PUBLISHD PORT>:<SERVER APP PORT INSIDE Docker> \
rss-bookmarkr
```

## Testing

Prepare the app in isolated Docker environment.

```bash
make test_e2e_setup
```
Wait until the app's container healthy, then run the migration.

```bash
make test_e2e_migration
```

Cleanup the app's container

```bash
make test_e2e_teardown
```
1 change: 0 additions & 1 deletion web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ MailSettings__Password=YOUR_PASWORD
PUBLIC_HOST=YOUR_PUBLIC_HOST

# PORT where app run
PORT=8080
ASPNETCORE_URLS_PORT=8080
ASPNETCORE_URLS=http://[::]:${ASPNETCORE_URLS_PORT}

Expand Down
10 changes: 10 additions & 0 deletions web/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,15 @@ publish_project: publish_client publish_server copy_client copy_email
test_e2e_setup:
docker compose -p rss-bookmarkr -f docker-compose.e2e.yaml up --build -d

test_e2e_migration_backup:
mv migrondi.json migrondi.bak.json ;\
mv migrondi.e2e.json migrondi.json

test_e2e_migration_restore:
mv migrondi.json migrondi.e2e.json ;\
mv migrondi.bak.json migrondi.json

test_e2e_migration: test_e2e_migration_backup migrate_up test_e2e_migration_restore

test_e2e_teardown:
docker compose -p rss-bookmarkr -f docker-compose.e2e.yaml down
3 changes: 1 addition & 2 deletions web/docker-compose.registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ services:
image: localhost:5000/rss-bookmarkr
container_name: rss-bookmarkr
restart: unless-stopped
ports:
- "${PORT}:${ASPNETCORE_URLS_PORT}"
network_mode: "host"
env_file:
- .env
3 changes: 1 addition & 2 deletions web/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ services:
build: .
container_name: rss-bookmarkr
restart: unless-stopped
ports:
- "${PORT}:${ASPNETCORE_URLS_PORT}"
network_mode: "host"
env_file:
- .env
24 changes: 18 additions & 6 deletions web/src/Client/Client.fs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module App
module App

open System
open Feliz
Expand Down Expand Up @@ -54,15 +54,27 @@ module RPC =
async { return! store.loginOrRegister loginForm }

let saveRSSUrlssss (userId: string, rssUrls: string array) : unit Async =
async { do! store.saveRSSUrls (userId, rssUrls) }
async {
do!
store.saveRSSUrls (
{ SaveRSSUrlReq.Urls = rssUrls
SaveRSSUrlReq.UserId = userId }
)
}

let initLogin (sessionId: string) : LoginResponse Async =
async { return! store.initLogin sessionId }
async { return! store.initLogin { InitLoginReq.SessionId = sessionId } }

let subscribe (userId: string, email: string) : unit Async =
async { do! store.subscribe (userId, email) }

let unsubscribe (email: string) : unit Async = async { do! store.unsubscribe email }
async {
do!
store.subscribe
{ SubscribeReq.Email = email
SubscribeReq.UserId = userId }
}

let unsubscribe (email: string) : unit Async =
async { do! store.unsubscribe { UnsubscribeReq.Email = email } }

module Component =
let renderError (error: string option) : Fable.React.ReactElement =
Expand Down
155 changes: 128 additions & 27 deletions web/src/Server/Server.fs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ open Shared

let rssDbConnectionStringKey = "RssDb"

/// Ref: https://github.com/giraffe-fsharp/Giraffe/issues/323#issuecomment-777622090
let tryBindJson<'T> (parsingErrorHandler: string -> HttpHandler) (successHandler: 'T -> HttpHandler) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
try
let! model = ctx.BindJsonAsync<'T>()
return! successHandler model next ctx
with (ex: exn) ->
return! parsingErrorHandler ex.Message next ctx
}

/// A full background service using a dedicated type.
/// Ref: https://github.com/CompositionalIT/background-services
Expand Down Expand Up @@ -53,10 +63,37 @@ type HttpContext with
| v -> v

[<CLIMutable>]
type RSSQueryString = { Url: string array }
type RSSQueryString =
{ Url: string array }

override this.ToString() =
sprintf "Url: %s" (String.concat ", " this.Url)

member _.HasErrors() = None

interface IModelValidation<RSSQueryString> with
member this.Validate() =
match this.HasErrors() with
| Some msg -> Error(RequestErrors.badRequest (text msg))
| None -> Ok this

[<CLIMutable>]
type UnsubscribeQueryString = { Email: string }
type UnsubscribeQueryString =
{ Email: string }

override this.ToString() = sprintf "Email: %s" this.Email

member this.HasErrors() =
if this.Email.Length <= 0 then
Some "Email is required."
else
None

interface IModelValidation<UnsubscribeQueryString> with
member this.Validate() =
match this.HasErrors() with
| Some msg -> Error(RequestErrors.badRequest (text msg))
| None -> Ok this

type User =
{ Id: string
Expand Down Expand Up @@ -627,27 +664,30 @@ module Handler =
return loginResponse
}

let saveRSSUrls (connectionString: string) (userId: string, urls: string array) : unit Async =
let saveRSSUrls (connectionString: string) (saveRSSUrlReq: SaveRSSUrlReq) : unit Async =
async {
let existingUrls = (DataAccess.getRSSUrls connectionString userId) |> List.toArray
let existingUrls =
(DataAccess.getRSSUrls connectionString saveRSSUrlReq.UserId) |> List.toArray

let newUrls =
urls |> Array.filter (fun url -> not <| Array.contains url existingUrls)
saveRSSUrlReq.Urls
|> Array.filter (fun url -> not <| Array.contains url existingUrls)

let deletedUrls =
existingUrls |> Array.filter (fun url -> not <| Array.contains url urls)
existingUrls
|> Array.filter (fun url -> not <| Array.contains url saveRSSUrlReq.Urls)

if newUrls.Length <> 0 then
DataAccess.insertUrls connectionString userId newUrls
DataAccess.insertUrls connectionString saveRSSUrlReq.UserId newUrls

if deletedUrls.Length <> 0 then
DataAccess.deleteUrls connectionString userId deletedUrls
DataAccess.deleteUrls connectionString saveRSSUrlReq.UserId deletedUrls
}

let initLogin (connectionString: string) (sessionId: string) : LoginResponse Async =
let initLogin (connectionString: string) (initLoginReq: InitLoginReq) : LoginResponse Async =
async {
return
sessionId
initLoginReq.SessionId
|> DataAccess.getUserSession connectionString
|> (function
| None ->
Expand All @@ -657,43 +697,78 @@ module Handler =
let loginResult =
{ LoginResult.UserId = user.Id
LoginResult.RssUrls = (DataAccess.getRSSUrls connectionString user.Id) |> List.toArray
LoginResult.SessionId = sessionId
LoginResult.SessionId = initLoginReq.SessionId
LoginResult.Email = user.Email }

Success loginResult)
}

let subscribe (connectionString: string) (userId: string, email: string) : unit Async =
let subscribe (connectionString: string) (subscribeReq: SubscribeReq) : unit Async =
async {
(DataAccess.getRSSUrls connectionString userId)
(DataAccess.getRSSUrls connectionString subscribeReq.UserId)
|> List.map (fun (rssURL: string) ->
{ RSSHistory.Url = rssURL
RSSHistory.LatestUpdated = DateTime.Now })
|> (DataAccess.setUserEmail connectionString (userId, email))
|> (DataAccess.setUserEmail connectionString (subscribeReq.UserId, subscribeReq.Email))
|> ignore
}

let unsubscribe (connectionString: string) (email: string) : unit Async =
async { (DataAccess.unsetUserEmail connectionString email) |> ignore }
let unsubscribe (connectionString: string) (unsubscribeReq: UnsubscribeReq) : unit Async =
async { (DataAccess.unsetUserEmail connectionString unsubscribeReq.Email) |> ignore }

module ApiHandler =
let rssListAction (next: HttpFunc) (ctx: HttpContext) =
match ctx.TryBindQueryString<RSSQueryString>() with
| Error(err: string) -> RequestErrors.BAD_REQUEST err next ctx
| Ok(rssQueryString: RSSQueryString) ->
let rssListAction (rssQueryString: RSSQueryString) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let! (rssList: RSS seq) = rssQueryString.Url |> Handler.getRSSList
return! json rssList next ctx
}

let loginOrRegisterAction (loginForm: LoginForm) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let! (loginResponse: LoginResponse) = (Handler.loginOrRegister ctx.RssDbConnectionString loginForm)
return! json loginResponse next ctx
}

let saveRSSUrlsAction (saveRSSUrlReq: SaveRSSUrlReq) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
do! (Handler.saveRSSUrls ctx.RssDbConnectionString saveRSSUrlReq)
return! Successful.OK "ok" next ctx
}

let initLoginAction (initLoginReq: InitLoginReq) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let! (loginResponse: LoginResponse) = (Handler.initLogin ctx.RssDbConnectionString initLoginReq)
return! json loginResponse next ctx
}

let subscribeAction (subscribeReq: SubscribeReq) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
do! (Handler.subscribe ctx.RssDbConnectionString subscribeReq)
return! Successful.OK "ok" next ctx
}

let unsubscribeAction (unsubscribeReq: UnsubscribeReq) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
do! (Handler.unsubscribe ctx.RssDbConnectionString unsubscribeReq)
return! Successful.OK "ok" next ctx
}

module ViewHandler =

let unsubsribePageAction (next: HttpFunc) (ctx: HttpContext) =
match ctx.TryBindQueryString<UnsubscribeQueryString>() with
| Error(err: string) -> RequestErrors.BAD_REQUEST err next ctx
| Ok(unsubscribeQueryString: UnsubscribeQueryString) ->
let unsubsribePageAction (unsubscribeQueryString: UnsubscribeQueryString) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
do! Handler.unsubscribe ctx.RssDbConnectionString unsubscribeQueryString.Email
do!
Handler.unsubscribe
ctx.RssDbConnectionString
{ UnsubscribeReq.Email = unsubscribeQueryString.Email }

return! htmlView Views.unsubsribePage next ctx
}

Expand All @@ -712,13 +787,39 @@ let webApp =
|> Remoting.buildHttpHandler

module Router =
let apiRouter = router { get "/rss" ApiHandler.rssListAction }
let apiRouter =
router {
get "/rss" (tryBindQuery<RSSQueryString> RequestErrors.BAD_REQUEST None (ApiHandler.rssListAction))

post
"/login"
(tryBindJson<LoginForm> RequestErrors.BAD_REQUEST (validateModel ApiHandler.loginOrRegisterAction))

post
"/save-urls"
(tryBindJson<SaveRSSUrlReq> RequestErrors.BAD_REQUEST (validateModel ApiHandler.saveRSSUrlsAction))

post
"/init-login"
(tryBindJson<InitLoginReq> RequestErrors.BAD_REQUEST (validateModel ApiHandler.initLoginAction))

post
"/subscribe"
(tryBindJson<SubscribeReq> RequestErrors.BAD_REQUEST (validateModel ApiHandler.subscribeAction))

post
"/unsubscribe"
(tryBindJson<UnsubscribeReq> RequestErrors.BAD_REQUEST (validateModel ApiHandler.unsubscribeAction))
}

let defaultView =
router {
forward "" webApp
forward "/api" apiRouter
get "/unsubscribe" ViewHandler.unsubsribePageAction

get
"/unsubscribe"
(tryBindQuery<UnsubscribeQueryString> RequestErrors.BAD_REQUEST None (ViewHandler.unsubsribePageAction))
}

let app =
Expand Down
Loading

0 comments on commit 2ee918e

Please sign in to comment.