diff --git a/.github/workflows/dev-integration.yaml b/.github/workflows/dev-integration.yaml index 5b3f2ecc..64c83f3b 100644 --- a/.github/workflows/dev-integration.yaml +++ b/.github/workflows/dev-integration.yaml @@ -13,7 +13,7 @@ on: - 'services/transaction-by-id/**' - 'services/transactions-by-account/**' - 'migrations/schema/**' - - 'test/**' + - 'tests/**' branches-ignore: - 'master' diff --git a/Cargo.lock b/Cargo.lock index 8707c115..2bf3c514 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3182,6 +3182,21 @@ dependencies = [ "types", ] +[[package]] +name = "request-by-id" +version = "0.1.0" +dependencies = [ + "axum 0.7.4", + "openssl", + "pg", + "service", + "shutdown", + "tokio", + "tracing", + "tracing-subscriber", + "types", +] + [[package]] name = "request-create" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a684f5c7..bc462951 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/types", "services/graphql", "services/request-approve", + "services/request-by-id", "services/request-create", "services/rule", "tests", diff --git a/client/tests/test.ts b/client/tests/test.ts index 99db2aa5..0e0c50d3 100644 --- a/client/tests/test.ts +++ b/client/tests/test.ts @@ -12,37 +12,37 @@ test('request detail screen pairs transaction items with rule added items', asyn await page.getByText('Requests').click(); await page.locator('css=[data-id-req="1"]').click(); await expect(await getFirstNTransactionItems(page, 6)).toEqual( + // migrations/testseed/000003_request.up.sql [ - // migrations/testseed/000003_request.up.sql { item_id: "eggs", quantity: "1", - price: "3", + price: "3.000", }, { item_id: salesTax, quantity: "1", - price: "0.27", + price: "0.270", }, { item_id: "bread", quantity: "2", - price: "2", + price: "2.000", }, { item_id: salesTax, quantity: "2", - price: "0.18", + price: "0.180", }, { item_id: "milk", quantity: "1", - price: "2", + price: "2.000", }, { item_id: salesTax, quantity: "1", - price: "0.18", + price: "0.180", }, ] ); diff --git a/crates/service/src/lib.rs b/crates/service/src/lib.rs index 261a2e52..ee178d32 100644 --- a/crates/service/src/lib.rs +++ b/crates/service/src/lib.rs @@ -303,6 +303,15 @@ impl Service { pub async fn get_transaction_by_id( &self, transaction_id: i32, + ) -> Result> { + self.conn + .select_transaction_by_id_query(transaction_id) + .await + } + + pub async fn get_full_transaction_by_id( + &self, + transaction_id: i32, ) -> Result> { let transaction = self .get_transaction_with_transaction_items_and_approvals_by_id(transaction_id) diff --git a/crates/types/src/request_response.rs b/crates/types/src/request_response.rs index 4c711646..9156d2b2 100644 --- a/crates/types/src/request_response.rs +++ b/crates/types/src/request_response.rs @@ -61,3 +61,10 @@ impl RequestApprove { } } } + +#[derive(Debug, Deserialize)] +pub struct QueryById { + pub auth_account: String, + pub account_name: String, + pub id: String, +} diff --git a/docker/dev/request-by-id.Dockerfile b/docker/dev/request-by-id.Dockerfile index 99ea8b82..674dc6a9 100644 --- a/docker/dev/request-by-id.Dockerfile +++ b/docker/dev/request-by-id.Dockerfile @@ -1,17 +1,39 @@ -FROM mxfactorial/go-base:v1 as builder +FROM rust:latest as builder -COPY . . +WORKDIR /app -WORKDIR /app/services/request-by-id +COPY . ./ -RUN go build -o request-by-id ./cmd +RUN rustup target add x86_64-unknown-linux-musl +RUN apt update && \ + apt install -y musl-tools perl make +RUN update-ca-certificates -FROM golang:alpine +ENV USER=request-by-id +ENV UID=10005 -WORKDIR /app +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + "${USER}" + +RUN USER=root cargo build \ + --manifest-path=services/request-by-id/Cargo.toml \ + --target x86_64-unknown-linux-musl \ + --release -COPY --from=builder /app/services/request-by-id/request-by-id . +FROM alpine + +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/request-by-id /usr/local/bin EXPOSE 10005 -CMD ["/app/request-by-id"] \ No newline at end of file +USER request-by-id:request-by-id + +CMD [ "/usr/local/bin/request-by-id" ] \ No newline at end of file diff --git a/project.yaml b/project.yaml index 7345201b..c81893e7 100644 --- a/project.yaml +++ b/project.yaml @@ -302,7 +302,7 @@ scripts: ssm: null default: - 'go run ' - - 'debug/rule' + - 'target/debug' CLIENT_PROCESS: ssm: null default: 'bin/vite dev' @@ -444,13 +444,13 @@ services: - RUST_LOG - RUST_BACKTRACE request-by-id: - runtime: go1.x + runtime: rust1.x min_code_cov: null type: app local_dev: true params: [] deploy: true - build_src_path: cmd + build_src_path: null dependents: [] env_var: set: diff --git a/services/request-approve/src/main.rs b/services/request-approve/src/main.rs index 09f891ab..03e86060 100644 --- a/services/request-approve/src/main.rs +++ b/services/request-approve/src/main.rs @@ -24,10 +24,13 @@ async fn handle_event( let request_id = client_request.id.parse::().unwrap(); - let transaction_request = svc.get_transaction_by_id(request_id).await.map_err(|e| { - tracing::error!("error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let transaction_request = svc + .get_full_transaction_by_id(request_id) + .await + .map_err(|e| { + tracing::error!("error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; if transaction_request.equilibrium_time.is_some() { println!("transaction previously approved"); diff --git a/services/request-by-id/Cargo.toml b/services/request-by-id/Cargo.toml new file mode 100644 index 00000000..0db74168 --- /dev/null +++ b/services/request-by-id/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "request-by-id" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true + +[dependencies] +axum = "0.7.4" +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread"] } +shutdown = { path = "../../crates/shutdown" } +pg = { path = "../../crates/pg" } +service = { path = "../../crates/service" } +types = { path = "../../crates/types" } + +[target.x86_64-unknown-linux-musl.dependencies] +openssl = { version = "0.10", features = ["vendored"] } diff --git a/services/request-by-id/cmd/main.go b/services/request-by-id/cmd/main.go deleted file mode 100644 index 4cd7df87..00000000 --- a/services/request-by-id/cmd/main.go +++ /dev/null @@ -1,188 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "log" - "net/http" - "os" - - "github.com/gin-gonic/gin" - "github.com/jackc/pgconn" - "github.com/jackc/pgtype" - "github.com/jackc/pgx/v4" - "github.com/systemaccounting/mxfactorial/pkg/logger" - "github.com/systemaccounting/mxfactorial/pkg/postgres" - "github.com/systemaccounting/mxfactorial/pkg/service" - "github.com/systemaccounting/mxfactorial/pkg/types" -) - -var ( - pgConn string = fmt.Sprintf( - "host=%s port=%s user=%s password=%s dbname=%s", - os.Getenv("PGHOST"), - os.Getenv("PGPORT"), - os.Getenv("PGUSER"), - os.Getenv("PGPASSWORD"), - os.Getenv("PGDATABASE")) - readinessCheckPath = os.Getenv("READINESS_CHECK_PATH") - port = os.Getenv("REQUEST_BY_ID_PORT") -) - -type SQLDB interface { - Query(context.Context, string, ...interface{}) (pgx.Rows, error) - QueryRow(context.Context, string, ...interface{}) pgx.Row - Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) - Begin(context.Context) (pgx.Tx, error) - Close(context.Context) error - IsClosed() bool -} - -type ITransactionService interface { - GetTransactionByID(ID types.ID) (*types.Transaction, error) - InsertTransactionTx(ruleTestedTransaction *types.Transaction) (*types.ID, error) - GetTransactionWithTrItemsAndApprovalsByID(trID types.ID) (*types.Transaction, error) - GetTransactionsWithTrItemsAndApprovalsByID(trIDs types.IDs) (types.Transactions, error) - GetLastNTransactions(accountName string, recordLimit string) (types.Transactions, error) - GetLastNRequests(accountName string, recordLimit string) (types.Transactions, error) - GetTrItemsAndApprovalsByTransactionIDs(trIDs types.IDs) (types.TransactionItems, types.Approvals, error) - GetTrItemsByTransactionID(ID types.ID) (types.TransactionItems, error) - GetTrItemsByTrIDs(IDs types.IDs) (types.TransactionItems, error) - GetApprovalsByTransactionID(ID types.ID) (types.Approvals, error) - GetApprovalsByTransactionIDs(IDs types.IDs) (types.Approvals, error) - AddApprovalTimesByAccountAndRole(trID types.ID, accountName string, accountRole types.Role) (pgtype.Timestamptz, error) -} - -func run( - ctx context.Context, - e types.QueryByID, - dbConnector func(context.Context, string) (SQLDB, error), - tranactionServiceConstructor func(db SQLDB) (ITransactionService, error), -) (string, error) { - - if e.AuthAccount == "" { - return "", errors.New("missing auth_account. exiting") - } - - if e.AccountName == nil || *e.AccountName == "" { - return "", errors.New("missing account_name. exiting") - } - - if e.ID == nil || *e.ID == "" { - return "", errors.New("missing id. exiting") - } - - // connect to db - db, err := dbConnector(context.Background(), pgConn) - if err != nil { - logger.Log(logger.Trace(), err) - return "", err - } - defer db.Close(context.Background()) - - // create transaction service - ts, err := tranactionServiceConstructor(db) - if err != nil { - logger.Log(logger.Trace(), err) - return "", err - } - - // get approvals - apprvs, err := ts.GetApprovalsByTransactionID(*e.ID) - if err != nil { - logger.Log(logger.Trace(), err) - return "", err - } - - accountOccurrence := 0 - pendingApprovalCount := 0 - for _, v := range apprvs { - // count account occurrence in approvals - if *v.AccountName == e.AuthAccount { - accountOccurrence++ - } - // also, count pending approvals - if v.ApprovalTime == nil { - pendingApprovalCount++ - } - } - - // return empty response if account or - // pending approvals not found in approvals - if accountOccurrence == 0 || pendingApprovalCount == 0 { - return types.EmptyMarshaledIntraTransaction(e.AuthAccount) - } - - // get transaction items - trItems, err := ts.GetTrItemsByTransactionID(*e.ID) - if err != nil { - log.Print(err) - return "", err - } - - // get transaction request - tr, err := ts.GetTransactionByID(*e.ID) - if err != nil { - logger.Log(logger.Trace(), err) - return "", err - } - - trWithTrItemsAndApprvs := service.BuildTransaction(tr, trItems, apprvs) - - // create transaction for response to client - intraTr := trWithTrItemsAndApprvs.CreateIntraTransaction(e.AuthAccount) - - // send string response to client - return intraTr.MarshalIntraTransaction() -} - -// wraps run accepting db interface for testability -func handleEvent(ctx context.Context, e types.QueryByID) (string, error) { - return run( - ctx, - e, - newIDB, - newTransactionService, - ) -} - -// enables unit testing -func newTransactionService(idb SQLDB) (ITransactionService, error) { - db, ok := idb.(*postgres.DB) - if !ok { - return nil, errors.New("newTransactionService: failed to assert *postgres.DB") - } - return service.NewTransactionService(db), nil -} - -// enables unit testing -func newIDB(ctx context.Context, dsn string) (SQLDB, error) { - return postgres.NewDB(ctx, dsn) -} - -func main() { - - r := gin.Default() - - // aws-lambda-web-adapter READINESS_CHECK_* - r.GET(readinessCheckPath, func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - var queryByID types.QueryByID - - r.POST("/", func(c *gin.Context) { - - c.BindJSON(&queryByID) - - resp, err := handleEvent(c.Request.Context(), queryByID) - if err != nil { - c.Status(http.StatusBadRequest) - } - - c.String(http.StatusOK, resp) - }) - - r.Run(fmt.Sprintf(":%s", port)) -} diff --git a/services/request-by-id/makefile b/services/request-by-id/makefile index bef4ff66..0596c681 100644 --- a/services/request-by-id/makefile +++ b/services/request-by-id/makefile @@ -1,6 +1,6 @@ RELATIVE_PROJECT_ROOT_PATH=$(shell REL_PATH="."; while [ $$(ls "$$REL_PATH" | grep project.yaml | wc -l | xargs) -eq 0 ]; do REL_PATH="$$REL_PATH./.."; done; printf '%s' "$$REL_PATH") include $(RELATIVE_PROJECT_ROOT_PATH)/make/shared.mk -include $(RELATIVE_PROJECT_ROOT_PATH)/make/go.mk +include $(RELATIVE_PROJECT_ROOT_PATH)/make/rust.mk REQUEST_BY_ID_PORT=$(shell yq '.services["$(APP_NAME)"].env_var.set.REQUEST_BY_ID_PORT.default' $(PROJECT_CONF)) REQUEST_BY_ID_URL=$(HOST):$(REQUEST_BY_ID_PORT) @@ -11,13 +11,21 @@ TEST_TRANSACTION_ID=1 TEST_EVENT='{"auth_account":"$(TEST_AUTH_ACCOUNT)","account_name":"$(TEST_AUTH_ACCOUNT)","id":"$(TEST_TRANSACTION_ID)"}' TEST_SENDER_ACCOUNT=$(TEST_ACCOUNT) -run: - @$(DOCKER_ENV_VARS) \ - TEST_EVENT=$(TEST_EVENT) \ - go run ./cmd/main.go +start: + @$(MAKE) get-secrets ENV=local + nohup cargo watch --env-file $(ENV_FILE) -w src -w $(RELATIVE_PROJECT_ROOT_PATH)/crates -x run >> $(NOHUP_LOG) & + +start-alone: + rm -f $(NOHUP_LOG) + $(MAKE) -C $(MIGRATIONS_DIR) run + $(MAKE) start + tail -F $(NOHUP_LOG) + +stop: + $(MAKE) -C $(RELATIVE_PROJECT_ROOT_PATH) stop invoke-local: - @curl -s -d $(TEST_EVENT) $(REQUEST_BY_ID_URL) | yq -o=json + @curl -s -H 'Content-Type: application/json' -d $(TEST_EVENT) $(REQUEST_BY_ID_URL) | yq -o=json demo: @printf "*** request to %s at %s\n" $(SUB_PATH) $(REQUEST_BY_ID_URL) diff --git a/services/request-by-id/src/main.rs b/services/request-by-id/src/main.rs new file mode 100644 index 00000000..ed898c40 --- /dev/null +++ b/services/request-by-id/src/main.rs @@ -0,0 +1,109 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + routing::{get, post}, + Router, +}; +use pg::postgres::{ConnectionPool, DB}; +use service::Service; +use shutdown::shutdown_signal; +use std::{env, net::ToSocketAddrs}; +use tokio::net::TcpListener; +use types::request_response::{IntraTransaction, QueryById}; + +// used by lambda to test for service availability +const READINESS_CHECK_PATH: &str = "READINESS_CHECK_PATH"; + +async fn handle_event( + State(pool): State, + event: Json, +) -> Result, StatusCode> { + let client_request = event.0; + + let svc = Service::new(pool.get_conn().await); + + let request_id = client_request.id.parse::().unwrap(); + + let approvals = svc + .get_approvals_by_transaction_id(request_id) + .await + .map_err(|e| { + tracing::error!("error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if approvals + .clone() + .0 + .into_iter() + .filter(|a| a.account_name == client_request.auth_account && a.approval_time.is_none()) + .count() + == 0 + { + tracing::error!("transaction request not found"); + return Err(StatusCode::BAD_REQUEST); + } + + let transaction_items = svc + .get_transaction_items_by_transaction_id(request_id) + .await + .map_err(|e| { + tracing::error!("error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let mut transaction_request = svc.get_transaction_by_id(request_id).await.map_err(|e| { + tracing::error!("error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + transaction_request + .build(transaction_items, approvals) + .unwrap(); + + let intra_transaction_request = + IntraTransaction::new(client_request.auth_account, transaction_request); + + Ok(axum::Json(intra_transaction_request)) +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let readiness_check_path = env::var(READINESS_CHECK_PATH) + .unwrap_or_else(|_| panic!("{READINESS_CHECK_PATH} variable assignment")); + + let conn_uri = DB::create_conn_uri_from_env_vars(); + + let pool = DB::new_pool(&conn_uri).await; + + let app = Router::new() + .route("/", post(handle_event)) + .route( + readiness_check_path.as_str(), + get(|| async { StatusCode::OK }), + ) + .with_state(pool); + + let hostname_or_ip = env::var("HOSTNAME_OR_IP").unwrap_or("0.0.0.0".to_string()); + + let port = env::var("REQUEST_BY_ID_PORT").unwrap_or("10005".to_string()); + + let serve_addr = format!("{hostname_or_ip}:{port}"); + + let mut addrs_iter = serve_addr.to_socket_addrs().unwrap_or( + format!("{hostname_or_ip}:{port}") + .to_socket_addrs() + .unwrap(), + ); + + let addr = addrs_iter.next().unwrap(); + + tracing::info!("listening on {}", addr); + + axum::serve(TcpListener::bind(addr).await.unwrap(), app) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); +} diff --git a/services/request-create/src/main.rs b/services/request-create/src/main.rs index c6062bbc..70157f8f 100644 --- a/services/request-create/src/main.rs +++ b/services/request-create/src/main.rs @@ -125,7 +125,7 @@ async fn create_request( let id = transaction_id.parse::().unwrap(); - let inserted_transaction_request = svc.get_transaction_by_id(id).await.unwrap(); + let inserted_transaction_request = svc.get_full_transaction_by_id(id).await.unwrap(); Ok(inserted_transaction_request) } diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index ac770d81..1ac4373f 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -272,16 +272,19 @@ mod tests { let reader = BufReader::new(file); let test_intra_transaction: IntraTransaction = serde_json::from_reader(reader).unwrap(); + // create a test transaction request let create_request = r::create_request_http( test_intra_transaction.auth_account.unwrap(), test_intra_transaction.transaction.transaction_items.clone(), ) .await; + // get the debitor account from the transaction request let debitor = test_intra_transaction.transaction.transaction_items.0[0] .debitor .clone(); + // get the debitors transaction request by id let got = r::get_request_by_id_http(debitor.clone(), debitor, create_request.id.unwrap()).await;