diff --git a/.gitignore b/.gitignore index 205e5aed..9f9dafaf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ out/ morphir-hashes.json morphir-ir.json package-lock.json -*.semanticdb \ No newline at end of file +*.semanticdb + +.user/ +.out/ \ No newline at end of file diff --git a/README.md b/README.md index 2de9f808..0560bc7d 100644 --- a/README.md +++ b/README.md @@ -36,23 +36,34 @@ Purchase ('fdc3.purchase') ```ts interface Purchase { type: string; //'fdc3.purchase' - amount: number; - vendor: string; - timestamp: number; - purchaser: string; //is there a common identifier for the purchaser? do we even want to include this (or is this too much PII)? - merchant: string; //identifier for the merchant/point of purchase - is there a common identifier - category?: string; + data: { + amount: number; + vendor: string; + date: string; + time: string; + userID: string; //is there a common identifier for the purchaser? do we even want to include this (or is this too much PII)? + pointOfSale: string; //identifier for the merchant/point of purchase - is there a common identifier + category?: 'Groceries' + | 'Dining' + | 'Home' + | 'Shopping' + | 'Travel' + | 'Fuel'; + } } //example - { - type: 'fdc3.purchase', - amount: 30, - vendor: 'My Favorite Vendor', - timestamp: new Date().getDate(), - purchaser: 'me', - merchant: 'you', - category: 'stuff' +{ + type: 'fdc3.purchase', + data: { + amount: 30, + vendor: 'My Favorite Vendor', + date: '9/29/2024', + time: '3:28:10 PM', + userId: 'me@me.com', + pointOfSale: 'POS_ID', + category: 'Groceries' + } } ``` @@ -71,49 +82,54 @@ Terms ('fdc3.Terms') ```ts interface Terms { type: string; //'fdc3.terms - points: number; - rate: number; - provider: Provider; //identifiers and display information of bank providing terms -} -interface Provider { - id: string; - name: string; - logo?: string; + data: { + points: number; + rate: number; + provider: string; //display name of bank providing terms + providerId: string; //identifier of bank providing terms + } } //example { type: 'fdc3.terms', - points: 13, - rate: 1, - provider: { - name: 'E*TRADE', - id: 'testApp1', - logo: './images/etrade.png' - } + data: { + points: 13, + rate: 1, + provider: { + name: 'E*TRADE', + id: 'testApp1', + logo: './images/etrade.png' + } + } } - ``` intent: MakePurchase (result) -```ts - interface PurchaseConfirmation { - type: string; //fdc3.purchaseConfirmation - provider: Provider; - } - - //example - { - type: 'fdc3.purchaseConfirmation', - provider: { - name: 'E*TRADE', - id: 'testApp1', - logo: './images/etrade.png' - } + ```ts + interface PurchaseConfirmation { + type: string; //fdc3.purchaseConfirmation + data: { + provider: Provider; + } + } + + //example + { + type: 'fdc3.purchaseConfirmation', + data: { + provider: { + name: 'E*TRADE', + id: 'testApp1', + logo: './images/etrade.png' + } + } + } + + ``` -``` ## Roadmap diff --git a/api/ReadMe.md b/api/ReadMe.md new file mode 100644 index 00000000..8c19696c --- /dev/null +++ b/api/ReadMe.md @@ -0,0 +1,50 @@ +# BankerX API + +Contains the Morphir model for the BankerX project. + +The model includes the API for the simple banking application, that is setup to demonstrate the use of the FDC3 protocol to integrate with Morphir based services by speaking FDC3 over REST. + +## Developing + +### Building + +At the root of the project run the following command to build the api project: + +```sh +./mill api.build +``` + +
+ +Building for Windows users + +```sh +mill.bat api.build +``` + +or using Powershell + +```sh +.\mill.ps1 api.build +``` + +
+ +### Testing + +At the root of the project run the following command to test the api project: + +```sh +./mill api.test +``` + +### Morphir Code Generation + +The custom mill target called `morphirScalaGen` found in the `MorphirScalaModule` is responsible for generating the Scala code from the Morphir model. +You can find the various reused custom mill targets in [`util.mill`](../util.mill) at the root of the project. + +> NOTE: In order to keep incremental compilation running smoothly, the code is generated into the mill out folder, as according to mill's conventions. +> The generated [morphir-ir.json](../out/api/morphirMakeOutputDir.dest/morphir-ir.json) is found in the out folder if the `api.build` target is ran. +> The [generated Scala code](../out/api/morphirScalaGenOutputDir.dest/) is found in the out folder if the `morphirScalaGen` target is ran (it is automatically ran as a result of running `build`). + +Part of the benefit of using mill is its ability to heavily customize the build process using normal Scala code. diff --git a/api/package.json b/api/package.json index 503a394e..b516b38a 100644 --- a/api/package.json +++ b/api/package.json @@ -4,7 +4,7 @@ "description": "BankerX API definitions", "main": "index.js", "scripts": { - "build": "morphir-elm gen -c --target-version 3.5.0 --output src/generated/scala", + "build": "morphir-elm gen -c -s --target-version 3.5.0 --output src/generated/scala", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", diff --git a/api/package.mill b/api/package.mill index 48d8a085..7b8c00c6 100644 --- a/api/package.mill +++ b/api/package.mill @@ -13,4 +13,11 @@ object `package` extends RootModule with MorphirScalaModule with BankerXScalaMod ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-core:${V.Libs.`jsoniter-scala`}", ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:${V.Libs.`jsoniter-scala`}", ) + + object test extends ScalaTests with TestModule.ScalaTest{ + def ivyDeps = Agg( + ivy"org.scalatest::scalatest::${V.Libs.scalatest}", + ivy"com.lihaoyi::pprint::${V.Libs.pprint}" + ) + } } \ No newline at end of file diff --git a/api/src-elm/BankerX/API.elm b/api/src-elm/BankerX/API.elm index ed323c5d..57588a11 100644 --- a/api/src-elm/BankerX/API.elm +++ b/api/src-elm/BankerX/API.elm @@ -16,7 +16,8 @@ type alias Terms = , promotionalPeriod : PromotionalPeriod } - +type alias BankName = String +type alias BankID = String type alias Amount = Int type alias Vendor = String type alias Date = LocalDate @@ -41,8 +42,11 @@ type alias Purchase = , pointOfSale : PointOfSale } +type alias BankRegistration = + { bankName : BankName + , bankID : BankID + , getTerms : Purchase -> Terms + } -- getTerms : Purchase -> Terms -- getTerms purchase = todo "Implement getTerms" - - diff --git a/api/src-elm/BankerX/Banks/CapitalOne.elm b/api/src-elm/BankerX/Banks/CapitalOne.elm new file mode 100644 index 00000000..5edc5a10 --- /dev/null +++ b/api/src-elm/BankerX/Banks/CapitalOne.elm @@ -0,0 +1,41 @@ +module BankerX.Banks.CapitalOne exposing (..) + +import BankerX.API exposing (..) +import Morphir.SDK.LocalDate exposing (LocalDate) +import Morphir.SDK.LocalTime exposing (LocalTime) + +bankname = "Capital One Bank" +bankId = "CapitalOne" +registration: BankRegistration +registration = + { bankName = bankname + , bankID = bankId + , getTerms = getTerms + } + +preferredVendors : List Vendor +preferredVendors = + [ "Vendor.A" + , "Vendor.W" + , "Vendor.T" + ] + +getTerms : Purchase -> Terms +getTerms purchase = + let + points : Points + points = + getPoints purchase.vendor purchase.amount + in + { provider = bankname + , points = points + , interestRate = 0.5 + , promotionalPeriod = 30 + } + +getPoints : Vendor -> Amount -> Points +getPoints vendor amount = + if List.member vendor preferredVendors then + 4 * amount + else + amount diff --git a/api/src-elm/BankerX/Banks/Etrade.elm b/api/src-elm/BankerX/Banks/Etrade.elm new file mode 100644 index 00000000..3c2230e4 --- /dev/null +++ b/api/src-elm/BankerX/Banks/Etrade.elm @@ -0,0 +1,41 @@ +module BankerX.Banks.Etrade exposing (..) + +import BankerX.API exposing (..) +import Morphir.SDK.LocalDate exposing (LocalDate) +import Morphir.SDK.LocalTime exposing (LocalTime) + +bankname = "Etrade" +bankId = "Etrade" +registration: BankRegistration +registration = + { bankName = bankname + , bankID = bankId + , getTerms = getTerms + } + +preferredVendors : List Vendor +preferredVendors = + [ "Gamestop" + , "Morgan Stanley" + , "NYSE" + ] + +getTerms : Purchase -> Terms +getTerms purchase = + let + points : Points + points = + getPoints purchase.vendor purchase.amount + in + { provider = bankname + , points = points + , interestRate = 0.615 + , promotionalPeriod = 60 + } + +getPoints : Vendor -> Amount -> Points +getPoints vendor amount = + if List.member vendor preferredVendors then + 4 * amount + else + amount diff --git a/api/src-elm/BankerX/FirstBank.elm b/api/src-elm/BankerX/Banks/FirstBank.elm similarity index 93% rename from api/src-elm/BankerX/FirstBank.elm rename to api/src-elm/BankerX/Banks/FirstBank.elm index 0e87a7b2..08a23709 100644 --- a/api/src-elm/BankerX/FirstBank.elm +++ b/api/src-elm/BankerX/Banks/FirstBank.elm @@ -1,4 +1,4 @@ -module BankerX.FirstBank exposing (..) +module BankerX.Banks.FirstBank exposing (..) import BankerX.API exposing (..) import Morphir.SDK.LocalDate exposing (LocalDate) diff --git a/api/src-elm/BankerX/SecondBank.elm b/api/src-elm/BankerX/Banks/SecondBank.elm similarity index 93% rename from api/src-elm/BankerX/SecondBank.elm rename to api/src-elm/BankerX/Banks/SecondBank.elm index 8cb333fa..ada20962 100644 --- a/api/src-elm/BankerX/SecondBank.elm +++ b/api/src-elm/BankerX/Banks/SecondBank.elm @@ -1,4 +1,4 @@ -module BankerX.SecondBank exposing (..) +module BankerX.Banks.SecondBank exposing (..) import BankerX.API exposing (..) import Morphir.SDK.LocalDate exposing (LocalDate) diff --git a/api/src-elm/BankerX/SmartWallet.elm b/api/src-elm/BankerX/SmartWallet.elm new file mode 100644 index 00000000..3ded9f1d --- /dev/null +++ b/api/src-elm/BankerX/SmartWallet.elm @@ -0,0 +1,26 @@ +module BankerX.SmartWallet exposing (..) +import BankerX.API exposing (..) + +import BankerX.Banks.CapitalOne as CapitalOne +import BankerX.Banks.Etrade as Etrade +import BankerX.Banks.FirstBank as FirstBank +import BankerX.Banks.SecondBank as SecondBank + +{-| Service definition for the SmartWallet service -} +type alias Service = + { getTerms: BankName -> Purchase -> Maybe Terms} + +{-| Service implementation for the SmartWallet service -} +service : Service +service = + { getTerms = getTerms } + +{-| Get the terms for a purchase from a bank -} +getTerms : BankName -> Purchase -> Maybe Terms +getTerms bankName purchase = + case bankName of + "CapitalOne" -> Just(CapitalOne.getTerms purchase) + "Etrade" -> Just(Etrade.getTerms purchase) + "FirstBank" -> Just(FirstBank.getTerms purchase) + "SecondBank" -> Just(SecondBank.getTerms purchase) + _ -> Nothing \ No newline at end of file diff --git a/api/src/bankerx/api/Codecs.scala b/api/src/bankerx/api/Codecs.scala index 922f681c..c635f252 100644 --- a/api/src/bankerx/api/Codecs.scala +++ b/api/src/bankerx/api/Codecs.scala @@ -2,6 +2,11 @@ package bankerx.api import bankerx.API.* import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker -object Codecs: + +trait Codecs: + given categoryJsonValueCodec: JsonValueCodec[Category] = + JsonCodecMaker.makeWithoutDiscriminator given purchaseJsonValueCodec: JsonValueCodec[Purchase] = JsonCodecMaker.make given termsJsonValueCodec: JsonValueCodec[Terms] = JsonCodecMaker.make + +object Codecs extends Codecs diff --git a/api/src/bankerx/api/PublicEndpoints.scala b/api/src/bankerx/api/PublicEndpoints.scala index a33afb0f..7c861efb 100644 --- a/api/src/bankerx/api/PublicEndpoints.scala +++ b/api/src/bankerx/api/PublicEndpoints.scala @@ -11,10 +11,10 @@ import bankerx.API.* import bankerx.api.Codecs.{given, *} object PublicEndpoints: - val getTermsEndpoint: PublicEndpoint[Purchase, Unit, Terms, Any] = + val getTermsEndpoint + : PublicEndpoint[(BankName, Purchase), String, Terms, Any] = endpoint.post - .in("terms") + .in("api" / "bank" / path[BankName]("bankName") / "terms") .in(jsonBody[Purchase]) .out(jsonBody[Terms]) - - + .errorOut(stringBody) diff --git a/api/src/main/scala/FDC3Backend.scala b/api/src/bankerx/api/tools/FDC3Backend.scala similarity index 100% rename from api/src/main/scala/FDC3Backend.scala rename to api/src/bankerx/api/tools/FDC3Backend.scala diff --git a/api/src/main/scala/Main.scala b/api/src/bankerx/api/tools/Main.scala similarity index 92% rename from api/src/main/scala/Main.scala rename to api/src/bankerx/api/tools/Main.scala index a944cf4d..efae2407 100644 --- a/api/src/main/scala/Main.scala +++ b/api/src/bankerx/api/tools/Main.scala @@ -1,4 +1,4 @@ -import bankerx.FirstBank +import bankerx.banks.FirstBank import bankerx.API.Purchase import java.time.LocalDate import java.time.LocalTime diff --git a/api/test/src/bankerx/api/CodecsSpec.scala b/api/test/src/bankerx/api/CodecsSpec.scala new file mode 100644 index 00000000..13852788 --- /dev/null +++ b/api/test/src/bankerx/api/CodecsSpec.scala @@ -0,0 +1,42 @@ +package bankerx.api +import org.scalatest.* +import wordspec.* +import matchers.* +import bankerx.API.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* +import com.github.plokhotnyuk.jsoniter_scala.core.* + +class CodecsSpec extends AnyWordSpec with should.Matchers with Codecs: + "Codecs" when { + "given a Purchase" should { + "be able to encode and decode it" in { + val purchase = Purchase( + 100, + "Vender1", + java.time.LocalDate.now(), + java.time.LocalTime.now(), + "user 1", + Fuel, + "pointOfSale 1" + ) + val json = writeToString(purchase) + println(json) + val decodedPurchase = readFromString[Purchase](json) + decodedPurchase.shouldEqual(purchase) + } + } + + "given a Terms" should { + "be able to encode and decode it" in { + val terms = Terms( + "Provider1", + 100, + 0.1, + 12 + ) + val json = writeToString(terms) + val decodedTerms = readFromString[Terms](json) + decodedTerms.shouldEqual(terms) + } + } + } diff --git a/app/bank-app1.html b/app/bank-app1.html index 30b75699..571608ca 100644 --- a/app/bank-app1.html +++ b/app/bank-app1.html @@ -23,23 +23,28 @@ fdc3.addIntentListener('GetTerms', async (ctx) => { return { type: 'fdc3.terms', - points: 13, - rate: 1, - provider: { - name: 'E*TRADE', - id: 'testApp1', - logo: './images/etrade.png' - } + data: { + points: 13, + rate: 1, + provider: { + name: 'E*TRADE', + id: 'testApp1', + logo: './images/etrade.png' + } + } + }; }); fdc3.addIntentListener('MakePurchase', async (ctx) => { return { type: 'fdc3.purchaseConfirmation', - provider: { - name: 'E*TRADE', - id: 'testApp1', - logo: './images/etrade.png' - } + data: { + provider: { + name: 'E*TRADE', + id: 'testApp1', + logo: './images/etrade.png' + } + } }; }); } diff --git a/app/bank-app2.html b/app/bank-app2.html index 9923cd1f..edb9ef43 100644 --- a/app/bank-app2.html +++ b/app/bank-app2.html @@ -23,23 +23,27 @@ fdc3.addIntentListener('GetTerms', async (ctx) => { return { type: 'fdc3.terms', - points: 25, - rate: .8, - provider: { - name: 'Capital One', - id: 'testApp2', - logo: './images/capitalone.png' - } + data: { + points: 25, + rate: .8, + provider: { + name: 'Capital One', + id: 'testApp2', + logo: './images/capitalone.png' + } + } }; }); fdc3.addIntentListener('MakePurchase', async (ctx) => { return { type: 'fdc3.purchaseConfirmation', - provider: { - name: 'Capital One', - id: 'testApp2', - logo: './images/capitalone.png' - } + data: { + provider: { + name: 'Capital One', + id: 'testApp2', + logo: './images/capitalone.png' + } + } }; }); } diff --git a/app/bank-app3.html b/app/bank-app3.html index 2fa4ac0c..82c60333 100644 --- a/app/bank-app3.html +++ b/app/bank-app3.html @@ -23,23 +23,27 @@ fdc3.addIntentListener('GetTerms', async (ctx) => { return { type: 'fdc3.terms', - points: 11, - rate: .6, - provider: { - name: 'Klarna', - id: 'testApp3', - logo: './images/klarna.png' - } + data: { + points: 11, + rate: .6, + provider: { + name: 'Klarna', + id: 'testApp3', + logo: './images/klarna.png' + } + } }; }); fdc3.addIntentListener('MakePurchase', async (ctx) => { return { type: 'fdc3.purchaseConfirmation', - provider: { - name: 'Klarna', - id: 'testApp3', - logo: './images/klarna.png' - } + data: { + provider: { + name: 'Klarna', + id: 'testApp3', + logo: './images/klarna.png' + } + } }; }); } diff --git a/app/index.html b/app/index.html index f2d78777..225960c6 100644 --- a/app/index.html +++ b/app/index.html @@ -22,8 +22,6 @@ onChannelJoined: (evt) => console.log('channel joined', evt), onChannelLeft: () => console.log('channel left'), onFDC3Ready: async (fdc3) => { - const info = await fdc3.getInfo(); - console.log('onFDC3Ready - info', info); window.fdc3 = fdc3; const purchaseButton = document.getElementById('initPurchaseButton'); if (purchaseButton){ diff --git a/app/main.js b/app/main.js index f12c910f..437c4ee7 100644 --- a/app/main.js +++ b/app/main.js @@ -1,14 +1,18 @@ let selected = null; const purchase = { type: 'fdc3.purchase', - amount: 30, - vendor: 'My Favorite Vendor', - timestamp: new Date().getDate(), - purchaser: 'me', - merchant: 'you', - category: 'stuff' + data: { + amount: 30, + vendor: 'My Favorite Vendor', + time: new Date().toLocaleTimeString(), + date: new Date().toLocaleDateString(), + userID: 'nick@connectifi.co', + pointOfSale: 'POS1', + category: 'Groceries' + } }; + const selectCard = (id) => { const modal = document.getElementById('modal'); const cards = modal.querySelectorAll('.card'); @@ -29,23 +33,24 @@ const launchBankApp = (index) => { }; const showSuccessModal = (message, purchaseResult) => { - const modal = document.getElementById('successModal'); - const modalCTA = document.getElementById('successCTA'); - modalCTA.addEventListener('click', () => { hideModal('successModal');}); - if (purchaseResult.provider.logo){ - const logoContainer = modal.querySelector('.logo'); - if (logoContainer){ - let target = logoContainer.querySelector('img'); - if (!target){ - target = document.createElement('img'); - logoContainer.appendChild(target); - } - target.src = purchaseResult.provider.logo; + + const modal = document.getElementById('successModal'); + const modalCTA = document.getElementById('successCTA'); + modalCTA.addEventListener('click', () => { hideModal('successModal');}); + if (purchaseResult.provider.logo){ + const logoContainer = modal.querySelector('.logo'); + if (logoContainer){ + let target = logoContainer.querySelector('img'); + if (!target){ + target = document.createElement('img'); + logoContainer.appendChild(target); + } + target.src = purchaseResult.provider.logo; + } } - } - const modalText = modal.querySelector('.textContainer .text'); - modalText.textContent = message; - showModal('successModal'); + const modalText = modal.querySelector('.textContainer .text'); + modalText.textContent = message; + showModal('successModal'); } const initializeModal = () => { @@ -86,7 +91,7 @@ const initializeModal = () => { const purchaseResponse = await fdc3?.raiseIntent('MakePurchase', purchase, selected); const purchaseResult = await purchaseResponse.getResult(); hideModal(); - showSuccessModal('Purchase Successful', purchaseResult); + showSuccessModal('Purchase Successful', purchaseResult.data); }); actionRow.appendChild(purchaseButton); modal.appendChild(actionRow); @@ -164,8 +169,8 @@ const getTerms = async () => { initializeModal(); appIntents.apps.forEach(async (app) => { const result = await fdc3.raiseIntent('GetTerms', purchase, {appId: app.appId}); - const data = await result.getResult(); - renderBankResult(data); + const contextData = await result.getResult(); + renderBankResult(contextData.data); }); showModal(); }; \ No newline at end of file diff --git a/app/styles.css b/app/styles.css index 91fafec4..2112c54d 100644 --- a/app/styles.css +++ b/app/styles.css @@ -194,6 +194,10 @@ body { font-size: 24px; } + .card .header .logo img { + max-height: 60px; + } + .card .content { display: flex; flex-flow: column; @@ -234,6 +238,17 @@ body { font-size: 24px; font-weight: 700; } + + #succesModal .row { + display: flex; + flex-flow: row; + justify-content: center; + } + + + #successModal .logo img { + height: 60px; + } #successModal .cta { display: flex; diff --git a/build.sc b/build.sc deleted file mode 100644 index 5b217119..00000000 --- a/build.sc +++ /dev/null @@ -1,15 +0,0 @@ -import mill._, scalalib._ - -object api extends ScalaModule { - def scalaVersion = "3.5.1" - - def ivyDeps = Agg( - ivy"org.morphir:morphir-sdk-core:0.1.0" - ) - - object test extends Tests { - def ivyDeps = Agg( - ivy"org.scalatest::scalatest:3.2.10" - ) - } -} \ No newline at end of file diff --git a/cdk/bin/bankerx-cdk-stack.ts b/cdk/bin/bankerx-cdk-stack.ts index b3c4b560..08325679 100755 --- a/cdk/bin/bankerx-cdk-stack.ts +++ b/cdk/bin/bankerx-cdk-stack.ts @@ -1,24 +1,9 @@ #!/usr/bin/env node -import * as cdk from 'aws-cdk-lib'; -import { TapirCdkStack } from '../lib/bankerx-cdk-stack'; -import * as process from 'node:process'; -import * as path from 'node:path'; -import { existsSync } from 'node:fs'; - -const codeAssetPath = process.env["BANKERX_LAMBDA_CODE_ASSET"]; -if (!codeAssetPath) { - const errorMessage = 'BANKERX_LAMBDA_CODE_ASSET environment variable is not set, this is required to deploy the BankerX Lambda. Set it to the path of the BankerX Lambda code asset.'; - console.error(errorMessage); - throw new Error(errorMessage); -} - -const normalizedCodeAssetPath = path.normalize(codeAssetPath); - -if(!existsSync(normalizedCodeAssetPath)) { - const errorMessage = `The BANKERX_LAMBDA_CODE_ASSET environment variable points to a path that does not exist: ${normalizedCodeAssetPath}`; - console.error(errorMessage); - throw new Error(errorMessage); -} +import * as cdk from "aws-cdk-lib"; +import { BankerXCdkStack } from "../lib/bankerx-cdk-stack"; +import * as process from "node:process"; +import * as path from "node:path"; +import { existsSync } from "node:fs"; const app = new cdk.App(); -new TapirCdkStack(app, 'TapirCdkStack', normalizedCodeAssetPath); \ No newline at end of file +new BankerXCdkStack(app, "BankerXCDK"); diff --git a/cdk/lib/bankerx-cdk-stack.ts b/cdk/lib/bankerx-cdk-stack.ts index 36b814af..e2e6c9ea 100644 --- a/cdk/lib/bankerx-cdk-stack.ts +++ b/cdk/lib/bankerx-cdk-stack.ts @@ -1,16 +1,34 @@ import * as cdk from "aws-cdk-lib"; import * as lambda from "aws-cdk-lib/aws-lambda"; import * as apigw from "aws-cdk-lib/aws-apigateway"; +import * as process from "node:process"; +import * as path from "node:path"; +import { existsSync } from "node:fs"; -export class TapirCdkStack extends cdk.Stack { +export class BankerXCdkStack extends cdk.Stack { constructor( scope: cdk.App, id: string, - codeAssetPath: string, props?: cdk.StackProps ) { super(scope, id, props); + const codeAssetPath = process.env["BANKERX_LAMBDA_CODE_ASSET"]; + if (!codeAssetPath) { + const errorMessage = + "BANKERX_LAMBDA_CODE_ASSET environment variable is not set, this is required to deploy the BankerX Lambda. Set it to the path of the BankerX Lambda code asset."; + console.error(errorMessage); + throw new Error(errorMessage); + } + + const normalizedCodeAssetPath = path.normalize(codeAssetPath); + + if (!existsSync(normalizedCodeAssetPath)) { + const errorMessage = `The BANKERX_LAMBDA_CODE_ASSET environment variable points to a path that does not exist: ${normalizedCodeAssetPath}`; + console.error(errorMessage); + throw new Error(errorMessage); + } + console.info(`codeAssetPath: ${codeAssetPath}`); const lambdaJar = new lambda.Function(this, "BankerX", { @@ -28,8 +46,12 @@ export class TapirCdkStack extends cdk.Stack { const rootApi = api.root.addResource("api"); - // GET /api/hello - const rootApiHello = rootApi.addResource("hello"); - rootApiHello.addMethod("GET"); + // Create a resource for performing a post to bank/{bankName}/terms + const rootApiBankerTerms = rootApi.addResource("bank"); + const rootApiBankerTermsBankName = + rootApiBankerTerms.addResource("{bankName}"); + const rootApiBankerTermsBankNameTerms = + rootApiBankerTermsBankName.addResource("terms"); + rootApiBankerTermsBankNameTerms.addMethod("POST"); } } diff --git a/cdk/package.mill b/cdk/package.mill index 56fb2a87..a8f52a38 100644 --- a/cdk/package.mill +++ b/cdk/package.mill @@ -9,6 +9,24 @@ object `package` extends RootModule with NodeModule { def sourceRoot = T { PathRef(millSourcePath)} + def cdkBootstrap = T { + val workingDir = prepareCdkForSynth().path + val _ = cdkSynth() + val assetJar = build.serverless.assembly().path + val env = T.env ++ Map("BANKERX_LAMBDA_CODE_ASSET"-> assetJar.toString) + os.proc("npx", "cdk", "bootstrap").call(cwd = workingDir, env = env) + PathRef(workingDir) + } + + def cdkDeploy = T { + val workingDir = prepareCdkForSynth().path + val _ = cdkSynth() + val assetJar = build.serverless.assembly().path + val env = T.env ++ Map("BANKERX_LAMBDA_CODE_ASSET"-> assetJar.toString) + os.proc("npx", "cdk", "deploy", "--require-approval=never").call(cwd = workingDir, env = env) + PathRef(workingDir) + } + def cdkSynth = T { val workingDir = prepareCdkForSynth().path val assetJar = build.serverless.assembly().path @@ -38,7 +56,7 @@ object `package` extends RootModule with NodeModule { PathRef(cdkSynth().path) } - def templateFilename = T {"TapirCdkStack.template.json"} + def templateFilename = T {"BankerXCdkStack.template.json"} def npmInstallMirror = T { npmInstallAction(cdkSources().path) diff --git a/package.json b/package.json index 83a4734a..bd3e636e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { - "private": true, - "workspaces": [ - "cdk" - ], + "private": true, + "workspaces": [ + "cdk" + ], "scripts": { "build": "tsc", "watch": "tsc -w", "test": "jest", - "cdk": "cdk" + "cdk": "cdk", + "setup-ide": "./mill mill.bsp.BSP/install" }, "devDependencies": { "@types/jest": "^27.5.2", @@ -23,4 +24,4 @@ "aws-cdk-lib": "2.160.0", "constructs": "^10.0.0" } -} \ No newline at end of file +} diff --git a/server/src/bankerx/server/Main.scala b/server/src/bankerx/server/Main.scala index 5bca0948..fea4b754 100644 --- a/server/src/bankerx/server/Main.scala +++ b/server/src/bankerx/server/Main.scala @@ -8,6 +8,6 @@ object Main extends OxApp.Simple: def run(using Ox): Unit = val port = sys.env.get("HTTP_PORT").flatMap(_.toIntOption).getOrElse(8080) - val binding = useInScope(NettySyncServer().port(port).addEndpoints(Endpoints.all).start())(_.stop()) + val binding = useInScope(NettySyncServer().port(port).addEndpoints(ServerEndpoints.all).start())(_.stop()) println(s"Server started at http://localhost:${binding.port}. ") never \ No newline at end of file diff --git a/server/src/bankerx/server/Endpoints.scala b/server/src/bankerx/server/ServerEndpoints.scala similarity index 72% rename from server/src/bankerx/server/Endpoints.scala rename to server/src/bankerx/server/ServerEndpoints.scala index fb6eb4d9..d5198a7f 100644 --- a/server/src/bankerx/server/Endpoints.scala +++ b/server/src/bankerx/server/ServerEndpoints.scala @@ -9,9 +9,11 @@ import bankerx.* import bankerx.api.* -object Endpoints: +object ServerEndpoints: val getTermsServerEndpoint = - PublicEndpoints.getTermsEndpoint.handleSuccess(FirstBank.getTerms) + PublicEndpoints.getTermsEndpoint.handle{ + case (bankName, purchase) => SmartWallet.getTerms(bankName)(purchase).toRight(s"Terms unavailable for bank: $bankName") + } val apiEndpoints = List(getTermsServerEndpoint) val docEndpoints: List[ServerEndpoint[Any, Identity]] = SwaggerInterpreter() diff --git a/serverless/package.mill b/serverless/package.mill index bf8698ae..88f41a33 100644 --- a/serverless/package.mill +++ b/serverless/package.mill @@ -13,38 +13,5 @@ object `package` extends RootModule with BankerXScalaModule { ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-core:${V.Libs.`jsoniter-scala`}", ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:${V.Libs.`jsoniter-scala`}", ) - - def cdkSynth = T { - val workingDir = prepareCdkForSynth().path - os.proc("npx", "cdk", "synth").call(cwd = workingDir) - PathRef(workingDir / "cdk.out") - } - - def cdk = T{T.dest} - - def generateCdk = T { - val workingDir = cdk() - val outputDir = workingDir / "cdk" - val codeArtifact = assembly().path - val _ = allSourceFiles() - val args = Seq("--code-location", codeArtifact.toString) - runner().run(args = args, workingDir = workingDir) - PathRef(outputDir) - } - - def generatedCdkSources = T.source { generateCdk() } - - def npmInstallCdk = T { - val workingDir = generatedCdkSources().path - os.proc("npm", "install").call(cwd = workingDir) - Map( - "node_modules" -> PathRef(workingDir / "node_modules"), - "cdk" -> PathRef(workingDir) - ) - } - - def prepareCdkForSynth = T { - val _ = npmInstallCdk() - PathRef(generatedCdkSources().path) - } + def moduleDeps = Seq(build.api) } \ No newline at end of file diff --git a/serverless/src/bankerx/serverless/BankerXLambdaHandler.scala b/serverless/src/bankerx/serverless/BankerXLambdaHandler.scala index a4f51bdb..bb8658e8 100644 --- a/serverless/src/bankerx/serverless/BankerXLambdaHandler.scala +++ b/serverless/src/bankerx/serverless/BankerXLambdaHandler.scala @@ -13,7 +13,7 @@ import zio.* object BankerXLambdaHandler extends RequestStreamHandler: private given RIOMonadError[Any] = new RIOMonadError[Any] private val handler = - ZioLambdaHandler.default[Any](endpoints.allEndpoints.toList) + ZioLambdaHandler.default[Any](ServerlessEndpoints.allEndpoints.toList) override def handleRequest( input: InputStream, diff --git a/serverless/src/bankerx/serverless/ServerlessEndpoints.scala b/serverless/src/bankerx/serverless/ServerlessEndpoints.scala new file mode 100644 index 00000000..eb48001a --- /dev/null +++ b/serverless/src/bankerx/serverless/ServerlessEndpoints.scala @@ -0,0 +1,27 @@ +package bankerx.serverless +import sttp.model.{Header, MediaType} +import sttp.tapir.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.jsoniter.* +import sttp.tapir.serverless.aws.lambda.{given, *} +import sttp.tapir.serverless.aws.ziolambda.{given, *} +import sttp.tapir.ztapir.ZTapir +import java.io.{InputStream, OutputStream} +import zio.{given, *} +import sttp.tapir.json.circe.{given, *} +import sttp.tapir.EndpointIO.annotations.jsonbody +import bankerx.* +import bankerx.api.* + +object ServerlessEndpoints extends ZTapir: + type ZioEndpoint = ZServerEndpoint[Any, Any] + val getTermsServerEndpoint: ZioEndpoint = + PublicEndpoints.getTermsEndpoint + .zServerLogic { case (bankName, purchase) => + val result = SmartWallet + .getTerms(bankName)(purchase) + .toRight(s"Terms unavailable for bank: $bankName") + ZIO.fromEither(result) + } + + val allEndpoints: Set[ZioEndpoint] = Set(getTermsServerEndpoint) diff --git a/serverless/src/bankerx/serverless/endpoints.scala b/serverless/src/bankerx/serverless/endpoints.scala deleted file mode 100644 index e2a9e2bb..00000000 --- a/serverless/src/bankerx/serverless/endpoints.scala +++ /dev/null @@ -1,20 +0,0 @@ -package bankerx.serverless -import sttp.model.{Header, MediaType} -import sttp.tapir.* -import sttp.tapir.serverless.aws.lambda.{given, *} -import sttp.tapir.serverless.aws.ziolambda.{given, *} -import sttp.tapir.ztapir.ZTapir -import java.io.{InputStream, OutputStream} -import zio.{given, *} -import sttp.tapir.json.circe.{given, *} -import sttp.tapir.EndpointIO.annotations.jsonbody - -object endpoints extends ZTapir: - type ZioEndpoint = ZServerEndpoint[Any, Any] - val hello: ZioEndpoint = - endpoint.get - .in("api" / "hello") - .out(jsonBody[String]) - .zServerLogic(_ => ZIO.attempt("Hello from ZIO!").mapError(_ => ())) - - val allEndpoints: Set[ZioEndpoint] = Set(hello) diff --git a/util.mill b/util.mill index cab1a025..ab11b0b2 100644 --- a/util.mill +++ b/util.mill @@ -8,6 +8,14 @@ trait BankerXScalaModule extends ScalaModule with CommonModule { } trait MorphirScalaModule extends MorphirModule with ScalaModule { + def build = T { + compile() + } + + def resources = T.sources { + super.resources() ++ Seq(morphirResources()) + } + def morphirScalaGenOutputDir = T { T.dest } def morphirGeneratedScalaSources = T.source { morphirScalaGen()} def morphirAllGeneratedScalaSourceFiles = T { @@ -17,34 +25,78 @@ trait MorphirScalaModule extends MorphirModule with ScalaModule { .toSeq } + /// Generates Scala sources from Morphir IR def morphirScalaGen = T { val irPath = morphirMakeGeneratedIR().path val outputDir = morphirScalaGenOutputDir() - os.proc("npx", "morphir", "scala-gen", "--input", irPath.toString, "--output", outputDir.toString, "--target", "Scala").call(cwd = millSourcePath) + os.proc("npx", "morphir", "scala-gen", + "--input", irPath.toString, + "--output", outputDir.toString, + "--target", "Scala" + ).call(cwd = millSourcePath) PathRef(outputDir) } + /// The location of generated sources override def generatedSources = T { super.generatedSources() ++ Seq(morphirGeneratedScalaSources()) } } trait MorphirModule extends NodeModule { + def morphirProjectFilename = T { "morphir.json" } def morphirProjectDir = T { millSourcePath } - def morphirProjectSource = T { PathRef(morphirProjectDir() / "morphir.json") } + def morphirProjectSource = T { PathRef(morphirProjectDir() / morphirProjectFilename()) } def morphirMakeOutputDir = T {T.dest} def morphirIRFileName = "morphir-ir.json" def morphirMakeGeneratedIR = T { val makeResult = morphirMake() - val ir = makeResult("MorphirIR").path + val ir = makeResult("MorphirIR").path PathRef(ir) } + + def morphirResources = T.source { + val dest = T.dest + val makeResult = morphirMakeGeneratedIR() + val outputDir = dest / ".morphir" + os.makeDir.all(outputDir) + if(os.exists(makeResult.path)) + os.copy.into(makeResult.path, outputDir) + PathRef(dest) + } + def morphirSources = T.sources { + val project = morphirProjectSource().path + val projectDir = morphirProjectDir() + if(os.exists(project)){ + val projectJson = ujson.read(os.read(project)) + val sourceDirectory = projectJson("sourceDirectory").str + val sourceDir = os.FilePath(sourceDirectory) + //T.log.info(s"Source directory: ${sourceDir}") + val resolvedPath = sourceDir match { + case rel: os.RelPath => projectDir / rel + case sub: os.SubPath => projectDir / sub + case abs: os.Path => abs + case _ => throw new Exception(s"Invalid source directory path: ${sourceDir}") + } + Seq(PathRef(resolvedPath)) + } else { + throw new Exception(s"Missing Morphir project file: the project was not found at the expected location ${project}.") + } + } + + def allMorphirSourceFiles = T { + Lib.findSourceFiles(morphirSources(), Seq("elm")).map(PathRef(_)) + } def morphirMake = T { val _ = npmInstall() //shell("npx", "morphir", "make").call(osName = osName(), cwd = millSourcePath) + + // Needed for incremental build/input tracking val _ = morphirProjectSource().path + val _ = allMorphirSourceFiles() + val projectDir = morphirProjectDir() val outputDir = morphirMakeOutputDir() val output = outputDir / morphirIRFileName diff --git a/versions.mill b/versions.mill index 96f628bc..2db4efad 100644 --- a/versions.mill +++ b/versions.mill @@ -7,6 +7,8 @@ object V { val `cats-effect` = "3.5.4" val `jsoniter-scala` = "2.30.11" val `morphir-jvm` = "0.18.6" + val pprint = "0.9.0" + val scalatest = "3.2.19" val tapir = "1.11.5" val ox = "0.4.0" val zio = "2.1.9"