diff --git a/src/main/java/demo/AbstractActionFrontEnd.java b/src/main/java/demo/AbstractActionFrontEnd.java index aec10c7..48ab22b 100644 --- a/src/main/java/demo/AbstractActionFrontEnd.java +++ b/src/main/java/demo/AbstractActionFrontEnd.java @@ -14,16 +14,29 @@ import org.jooq.impl.DSL; /** - * Base class for performing some action (on the DB) and redirecting back to the main view + * Executes a database action for the demo UI and redirects the browser to the table view. + * + *

Subclasses supply the database operation through {@link #performAction()}. + * {@link WebHandler} reuses these handlers within the shared UI Lambda. A successful + * action returns HTTP 307; exceptions from the action are logged and returned as HTTP 500 + * with the exception text. Both responses retain the shared redirect and cache headers. */ public abstract class AbstractActionFrontEnd implements RequestHandler { - // Initialize the Log4j logger. + /** + * Creates an action handler with its request logger; database work begins only when invoked. + */ + public AbstractActionFrontEnd() { + } + + /** Logger for incoming requests and database-action failures. */ Logger log = LogManager.getLogger(); + /** Unqualified demo address table, resolved through the database connection's search path. */ protected final static Table ADDRESS_TABLE = DSL.table("address"); + /** Shared response headers that disable caching and identify the root redirect target. */ final static Map headers = new HashMap<>(); static { @@ -33,8 +46,22 @@ public abstract class AbstractActionFrontEnd implements RequestHandlerImplementations establish their own database access and transaction boundaries. + * Exceptions are handled by {@link #handleRequest(APIGatewayProxyRequestEvent, Context)}. + */ protected abstract void performAction(); + /** + * Executes the action once and builds the existing redirect or error response. + * + * @param input API Gateway payload-format 1.0 request; logged but not used to select data + * @param context Lambda invocation metadata; unused by this implementation + * @return HTTP 307 with {@code Location: /} on success, or HTTP 500 with the exception + * text on failure; both responses include the shared headers + */ @Override public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { log.debug(input); diff --git a/src/main/java/demo/CloudFormationCustomResource.java b/src/main/java/demo/CloudFormationCustomResource.java index 2de3e48..13e85aa 100644 --- a/src/main/java/demo/CloudFormationCustomResource.java +++ b/src/main/java/demo/CloudFormationCustomResource.java @@ -1,7 +1,3 @@ -/* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template - */ package demo; import com.amazonaws.services.lambda.runtime.Context; @@ -18,19 +14,44 @@ import software.amazon.lambda.powertools.cloudformation.Response; /** - * Custom resource called from Cloud Formation after the RDS instance is done provisioning. This lambda is responsible - * for running all the SQL files in the correct order to initialize the DB. Care should be taken to never throw an - * Exception back or this will stall the stack operation and error the stack. + * Initializes the demo database through a CloudFormation custom resource after RDS provisioning. + * + *

The Powertools superclass dispatches lifecycle events and delivers responses to + * CloudFormation. CREATE executes the packaged SQL scripts in dependency order and inserts + * one sample address. UPDATE and DELETE acknowledge the event without modifying the database. + * Caught setup errors are only logged, so the returned CREATE response is not proof that + * every script or the sample insert succeeded. * * @author sjensen */ public class CloudFormationCustomResource extends AbstractCustomResourceHandler { - // Initialize the Log4j logger. + /** + * Creates the database-initialization lifecycle handler and its Powertools response support. + */ + public CloudFormationCustomResource() { + } + + /** Logger for CloudFormation lifecycle events and SQL initialization failures. */ Logger log = LogManager.getLogger(); + /** Stable physical resource ID returned across CREATE, UPDATE, and DELETE callbacks. */ private final static String RESOURCE_ID = "db_sql_initialize"; + /** + * Executes the extension, trigger-function, table, and trigger scripts, then seeds an address. + * + *

Scripts are read from {@code LAMBDA_TASK_ROOT/scripts}. Each script's I/O or SQL + * failure is logged and execution continues to the next script. An outer database exception + * is also logged before returning the normal response. The scripts and seed insert are + * not enclosed in a single transaction, and repeated CREATE processing can insert another + * sample address. Uncaught runtime failures are left to the Powertools superclass. + * + * @param cfcre CloudFormation CREATE event; supplied to debug logging but not used for SQL inputs + * @param cntxt Lambda invocation metadata; unused by this callback + * @return a response with the stable physical resource ID and the reason + * {@code SQL files applied to database}, even after the caught failures described above + */ @Override protected Response create(CloudFormationCustomResourceEvent cfcre, Context cntxt) { try { @@ -73,10 +94,11 @@ protected Response create(CloudFormationCustomResourceEvent cfcre, Context cntxt } /** - * We don't do anything on stack updates, just return null - * @param cfcre - * @param cntxt - * @return + * Acknowledges a stack update without rerunning initialization or applying schema changes. + * + * @param cfcre CloudFormation UPDATE event, including old/new properties; used only for logging + * @param cntxt Lambda invocation metadata; unused by this callback + * @return a response with the stable physical resource ID and the reason {@code UPDATE event ignored} */ @Override protected Response update(CloudFormationCustomResourceEvent cfcre, Context cntxt) { @@ -88,10 +110,14 @@ protected Response update(CloudFormationCustomResourceEvent cfcre, Context cntxt } /** - * We don't need to do anything on stack delete, just return null - * @param cfcre - * @param cntxt - * @return + * Acknowledges custom-resource deletion without issuing SQL or deleting database contents. + * + *

Deletion of the actual RDS resources is governed separately by their CloudFormation + * definitions and deletion policies. + * + * @param cfcre CloudFormation DELETE event; used only for debug logging + * @param cntxt Lambda invocation metadata; unused by this callback + * @return a response with the stable physical resource ID and the reason {@code DELETE event ignored} */ @Override protected Response delete(CloudFormationCustomResourceEvent cfcre, Context cntxt) { diff --git a/src/main/java/demo/CreateAddressFrontEnd.java b/src/main/java/demo/CreateAddressFrontEnd.java index 93c14a5..3bebc1d 100644 --- a/src/main/java/demo/CreateAddressFrontEnd.java +++ b/src/main/java/demo/CreateAddressFrontEnd.java @@ -3,10 +3,27 @@ import org.jooq.impl.DSL; /** - * Handler for requests to Lambda function. + * Handles the {@code /create} UI action by inserting the fixed Apple headquarters address. + * + *

The inherited request handler redirects to the root page after insertion. Database + * defaults initialize the geocoding flag, and the database's triggers initiate geocoding + * and audit processing asynchronously. */ public class CreateAddressFrontEnd extends AbstractActionFrontEnd { + /** + * Creates the single-address action without inserting data or opening a database connection. + */ + public CreateAddressFrontEnd() { + } + + /** + * Inserts one Apple headquarters row into {@code address} using the table defaults + * for unspecified fields, including identity and geocoding state. + * + * @throws org.jooq.exception.DataAccessException if the insert fails; the inherited + * request handler converts this exception to an HTTP 500 response + */ @Override protected void performAction() { // Just Insert One address and return diff --git a/src/main/java/demo/CreateMultipleAddressFrontEnd.java b/src/main/java/demo/CreateMultipleAddressFrontEnd.java index e9f0bd7..5c0532a 100644 --- a/src/main/java/demo/CreateMultipleAddressFrontEnd.java +++ b/src/main/java/demo/CreateMultipleAddressFrontEnd.java @@ -3,10 +3,30 @@ import org.jooq.impl.DSL; /** - * Handler for requests to Lambda function. + * Handles the {@code /multiple} UI action by inserting five fixed sample addresses. + * + *

All inserts share one transaction to demonstrate how asynchronous database-trigger + * invocations can begin before their source rows become visible to another connection. + * The inherited handler redirects only after the transaction call returns. */ public class CreateMultipleAddressFrontEnd extends AbstractActionFrontEnd { + /** + * Creates the batch-address action; the transaction starts only when the action is invoked. + */ + public CreateMultipleAddressFrontEnd() { + } + + /** + * Inserts the Microsoft, Tesla, SpaceX, State Farm, and Delta sample addresses in + * one transaction, with numbered notes identifying the five rows in the UI. + * + *

A failed insert rolls back the transaction. Lambda invocations initiated by the + * database triggers are external effects and are not undone by that database rollback. + * + * @throws org.jooq.exception.DataAccessException if database access or the transaction + * fails; the inherited handler converts the failure to an HTTP 500 response + */ @Override protected void performAction() { // Insert 5 addresses in one transaction so they all hit at once diff --git a/src/main/java/demo/DeleteAddressFrontEnd.java b/src/main/java/demo/DeleteAddressFrontEnd.java index 7951fc1..41bf281 100644 --- a/src/main/java/demo/DeleteAddressFrontEnd.java +++ b/src/main/java/demo/DeleteAddressFrontEnd.java @@ -3,10 +3,26 @@ import org.jooq.impl.DSL; /** - * Delete the last row from Address Table + * Handles the {@code /delete} UI action by deleting the address with the greatest ID. + * + *

"Last" refers to the maximum identity value, not the creation timestamp. The inherited + * handler redirects after the statement completes, including when the table is already empty. */ public class DeleteAddressFrontEnd extends AbstractActionFrontEnd { + /** + * Creates the address-deletion action without accessing the database. + */ + public DeleteAddressFrontEnd() { + } + + /** + * Deletes the row whose ID matches the maximum address ID in a single SQL statement. + * An empty table yields no matching row and is treated as a successful no-op. + * + * @throws org.jooq.exception.DataAccessException if the delete fails; the inherited + * request handler converts this exception to an HTTP 500 response + */ @Override protected void performAction() { // Delete the last row diff --git a/src/main/java/demo/DeleteAuditLogFrontEnd.java b/src/main/java/demo/DeleteAuditLogFrontEnd.java index a7650df..8381bfd 100644 --- a/src/main/java/demo/DeleteAuditLogFrontEnd.java +++ b/src/main/java/demo/DeleteAuditLogFrontEnd.java @@ -1,10 +1,28 @@ package demo; /** - * Delete the last row from Address Table + * Handles the {@code /audit} UI action by clearing both demo audit tables. + * + *

This action truncates {@code audit_log} and {@code audit_log_sqs}; it does not delete + * addresses or purge queued/in-flight events, which may subsequently add audit records. */ public class DeleteAuditLogFrontEnd extends AbstractActionFrontEnd { + /** + * Creates the audit-clear action without truncating either audit table. + */ + public DeleteAuditLogFrontEnd() { + } + + /** + * Truncates the direct audit table followed by the SQS audit table. + * + *

The statements have no enclosing transaction here, so the first truncation can + * succeed even if the second fails. Neither statement requests an identity reset. + * + * @throws org.jooq.exception.DataAccessException if either truncate fails; the inherited + * request handler converts this exception to an HTTP 500 response + */ @Override protected void performAction() { // Clear out the audit log diff --git a/src/main/java/demo/FrontEnd.java b/src/main/java/demo/FrontEnd.java index 6d8a09f..844cce4 100644 --- a/src/main/java/demo/FrontEnd.java +++ b/src/main/java/demo/FrontEnd.java @@ -13,13 +13,25 @@ import org.jooq.impl.DSL; /** - * Handler for requests to Lambda function. + * Renders the demo's root page with address-action links and the contents of public tables. + * + *

{@link WebHandler} delegates {@code GET /} requests here. Tables are discovered from + * {@code information_schema.tables} in name order, and their rows are displayed by descending + * {@code id}. The view therefore assumes every discovered table has an {@code id} column. + * It reads all rows without pagination and formats them using jOOQ's HTML renderer. */ public class FrontEnd implements RequestHandler { - // Initialize the Log4j logger. + /** + * Creates the table-view renderer; database access is deferred until a page request is handled. + */ + public FrontEnd() { + } + + /** Logger for page requests and database query failures. */ Logger log = LogManager.getLogger(); + /** Shared response headers declaring the generated page as HTML. */ final static Map headers = new HashMap<>(); static { @@ -27,6 +39,18 @@ public class FrontEnd implements RequestHandlerDatabase query failures inside the rendering block are logged and converted to an + * HTTP 500 response. Database-context acquisition occurs before that block, so initialization + * failures are not converted by this method's exception handler. + * + * @param input API Gateway payload-format 1.0 request; used only for debug logging + * @param context Lambda invocation metadata; unused by this implementation + * @return HTTP 200 containing the generated HTML, or HTTP 500 containing a caught + * database exception's text; both responses have an HTML content type + */ @Override public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { log.debug(input); @@ -77,6 +101,13 @@ public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEv } } + /** + * Creates the opening Bootstrap panel markup for one database table. + * + * @param title panel heading inserted verbatim into HTML; callers must supply trusted + * or already escaped text because this method does not escape it + * @return a new builder containing the panel heading and opening responsive body element + */ public StringBuilder appendPanelStart(String title) { var sb = new StringBuilder(); sb.append("

\n"); @@ -86,10 +117,20 @@ public StringBuilder appendPanelStart(String title) { return sb; } + /** + * Creates the closing markup matching {@link #appendPanelStart(String)}. + * + * @return a new builder that closes the panel body and outer panel elements + */ public StringBuilder appendPanelEnd() { return new StringBuilder("
\n"); } + /** + * Creates the page prologue, title, Bootstrap stylesheet link, and main container. + * + * @return a new builder with the document header and opening body/container markup + */ private StringBuilder renderHTMLStart() { var sb = new StringBuilder(); sb.append("\n"); @@ -101,6 +142,11 @@ private StringBuilder renderHTMLStart() { return sb; } + /** + * Creates the page epilogue matching {@link #renderHTMLStart()}. + * + * @return a new builder that closes the main container, body, and HTML document + */ private StringBuilder renderHTMLEnd() { var sb = new StringBuilder(); sb.append("\n"); diff --git a/src/main/java/demo/PostgresAbstractTrigger.java b/src/main/java/demo/PostgresAbstractTrigger.java index 5528da1..951235e 100644 --- a/src/main/java/demo/PostgresAbstractTrigger.java +++ b/src/main/java/demo/PostgresAbstractTrigger.java @@ -1,7 +1,3 @@ -/* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template - */ package demo; import com.amazonaws.services.lambda.runtime.Context; @@ -17,17 +13,40 @@ import org.apache.logging.log4j.Logger; /** - * Base class for handling triggers from Postgres - * + * Decodes row-change payloads produced by the PostgreSQL {@code record_change_lambda} function. + * + *

Payloads contain {@code TG_OP}, {@code TG_TABLE_NAME}, and the {@code old}/{@code new} + * row images. Subclasses implement the geocoding or audit side effect. The database invokes + * these handlers asynchronously, so the source transaction may not yet be committed. + * * @author sjensen */ public abstract class PostgresAbstractTrigger implements RequestStreamHandler { - // Initialize the Log4j logger. + /** + * Creates a trigger handler with its logger and the shared JSON mapper. + */ + public PostgresAbstractTrigger() { + } + /** Logger for decoded trigger payloads and processing failures. */ final Logger log = LogManager.getLogger(); + /** Shared JSON mapper used to decode trigger payloads and encode response/database JSON. */ final static ObjectMapper mapper = new ObjectMapper(); + /** + * Reads a trigger payload, invokes the subclass, and writes a JSON acknowledgement. + * + *

Exceptions from operation/row extraction or {@link #processEvent(TG_OP, String, JsonNode, JsonNode)} + * are logged and suppressed; the method still writes {@code {"status":"OK"}}. Such failures + * are therefore not reported to Lambda for retries. Initial JSON parsing and response I/O + * occur outside that catch block. Closing the response writer also closes {@code out}. + * + * @param in input stream containing the PostgreSQL row-change JSON document + * @param out output stream receiving the UTF-8 JSON acknowledgement + * @param cntxt Lambda invocation metadata; unused by this implementation + * @throws IOException if reading/parsing the input or writing/closing the response fails + */ @Override public final void handleRequest(InputStream in, OutputStream out, Context cntxt) throws IOException { // Read in JSON Tree @@ -51,20 +70,28 @@ public final void handleRequest(InputStream in, OutputStream out, Context cntxt) } /** + * Applies a subclass-specific side effect for one decoded row-change event. + * + *

The normal payload uses JSON null for the row image that does not exist for an + * INSERT or DELETE. Missing fields can also reach this method as Java {@code null}; + * the base handler does not validate row shapes before dispatch. * - * @param operation - * @param table_name - * @param old_record - * @param new_record + * @param operation PostgreSQL operation decoded from {@code TG_OP} + * @param table_name source table name from {@code TG_TABLE_NAME} + * @param old_record pre-change row image, JSON null for INSERT, or {@code null} if absent + * @param new_record post-change row image, JSON null for DELETE, or {@code null} if absent */ protected abstract void processEvent(TG_OP operation, String table_name, JsonNode old_record, JsonNode new_record); /** - * Trigger Operations + * Supported PostgreSQL row-change operations, matching the database's uppercase {@code TG_OP} values. */ public enum TG_OP { + /** A new row was inserted; only the new row image is populated. */ INSERT, + /** An existing row was changed; both old and new row images are populated. */ UPDATE, + /** A row was removed; only the old row image is populated. */ DELETE } } diff --git a/src/main/java/demo/PostgresAddressTrigger.java b/src/main/java/demo/PostgresAddressTrigger.java index 71bb707..c55f5a3 100644 --- a/src/main/java/demo/PostgresAddressTrigger.java +++ b/src/main/java/demo/PostgresAddressTrigger.java @@ -1,7 +1,3 @@ -/* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template - */ package demo; import com.fasterxml.jackson.databind.JsonNode; @@ -14,13 +10,26 @@ import software.amazon.awssdk.services.location.model.SearchPlaceIndexForTextResponse; /** - * Geo Code Addresses that are added to the address table + * Geocodes address row changes with Amazon Location Service and persists the best match. + * + *

The database trigger selects INSERT/UPDATE rows whose {@code requires_geo_coding} flag + * is true. This handler geocodes inserts and updates that change {@code address_1}; changing + * only the city, district, or postal code does not cause a lookup here. A successful lookup + * clears the flag to prevent the geocoding update from invoking this handler recursively. * * @author sjensen */ public class PostgresAddressTrigger extends PostgresAbstractTrigger { + /** + * Creates the geocoding handler after class initialization has prepared the shared Location client. + */ + public PostgresAddressTrigger() { + } + + /** Amazon Location client initialized once per execution environment and reused for lookups. */ static final LocationClient locationClient; + /** Place-index name read from the SAM-provided {@code PLACE_INDEX} environment variable. */ static final String PLACE_INDEX; static { @@ -31,6 +40,21 @@ public class PostgresAddressTrigger extends PostgresAbstractTrigger { PLACE_INDEX = System.getenv("PLACE_INDEX"); } + /** + * Determines whether an address event requires a lookup and geocodes it when needed. + * + *

INSERT always triggers a lookup. Other operations compare {@code address_1} in the + * two images; callers are expected to send UPDATE in that branch. DELETE is not supported + * by this address handler and is excluded by its database trigger. The geocoding flag is + * checked by the SQL trigger, not by this method. + * + * @param operation expected INSERT or UPDATE operation from the address trigger + * @param table_name source table name; unused because this handler always updates {@code address} + * @param old_addr previous address image for UPDATE; unused for INSERT + * @param new_addr current address image containing the ID and address fields to geocode + * @throws org.jooq.exception.DataAccessException if persisting a geocoding result fails + * @throws software.amazon.awssdk.core.exception.SdkException if the Location lookup fails + */ @Override protected void processEvent(TG_OP operation, String table_name, JsonNode old_addr, JsonNode new_addr) { @@ -59,6 +83,24 @@ protected void processEvent(TG_OP operation, String table_name, JsonNode old_add } + /** + * Looks up one address and writes the first Location result back to its source row. + * + *

The search uses street, city, district, and an optional postal code. A match stores + * the formatted address, timestamp, longitude/latitude, and response JSON, and clears + * {@code requires_geo_coding}. With no match, the row and flag remain unchanged. + * + *

An update affecting zero rows is retried with linearly increasing sleeps of + * 100 milliseconds, allowing time for the originating transaction to commit. This loop + * has no explicit attempt/deadline limit and ignores interruption; a missing or rolled-back + * row can keep it running until the Lambda timeout or another failure terminates execution. + * + * @param addr address row image with {@code id}, {@code address_1}, {@code city}, + * {@code district}, and a {@code postal_code} field that may contain JSON null + * @throws org.jooq.exception.DataAccessException if the database update fails + * @throws software.amazon.awssdk.core.exception.SdkException if the Location lookup fails + * @throws NullPointerException if a required JSON field is absent + */ private void geoCodeAddress(JsonNode addr) { // Build address String final var address = new StringBuilder(addr.findValue("address_1").asText()); diff --git a/src/main/java/demo/PostgresAuditLogTrigger.java b/src/main/java/demo/PostgresAuditLogTrigger.java index 3182048..b40bdae 100644 --- a/src/main/java/demo/PostgresAuditLogTrigger.java +++ b/src/main/java/demo/PostgresAuditLogTrigger.java @@ -1,35 +1,35 @@ -/* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template - */ package demo; import com.fasterxml.jackson.databind.JsonNode; import org.jooq.JSONB; import org.jooq.impl.DSL; -/* - * Audit log trigger that takes incoming event and writes to audit log table - - CREATE TABLE IF NOT EXISTS audit_log ( - id BIGSERIAL PRIMARY KEY, - created timestamp without time zone NOT NULL DEFAULT timezone('utc'::text, now()), - operation tg_op NOT NULL, - table text NOT NULL, - old_record jsonb, - new_record jsonb -); - * +/** + * Records directly delivered PostgreSQL row-change events in {@code audit_log}. + * + *

Each invocation stores the operation, source table name, and available before/after + * row images. Identity and creation time come from table defaults. Inserts are not deduplicated, + * so repeated delivery can create repeated audit entries. The source address row is not modified. + * * @author sjensen */ public class PostgresAuditLogTrigger extends PostgresAbstractTrigger { /** + * Creates the direct-audit handler; an audit connection is opened when an event is processed. + */ + public PostgresAuditLogTrigger() { + } + + /** + * Inserts one audit row, converting missing or JSON-null row images to SQL NULL. * - * @param operation - * @param table_name - * @param old_record - * @param new_record + * @param operation PostgreSQL INSERT, UPDATE, or DELETE operation stored as text + * @param table_name source table name stored in the {@code table_name} column + * @param old_record pre-change image, or Java/JSON null when no old image exists + * @param new_record post-change image, or Java/JSON null when no new image exists + * @throws org.jooq.exception.DataAccessException if the insert fails; the base stream + * handler logs and suppresses this failure under its current acknowledgement policy */ @Override protected void processEvent(TG_OP operation, String table_name, JsonNode old_record, JsonNode new_record) { diff --git a/src/main/java/demo/PostgresAuditLogTriggerSQS.java b/src/main/java/demo/PostgresAuditLogTriggerSQS.java index db5a2a8..3c6f495 100644 --- a/src/main/java/demo/PostgresAuditLogTriggerSQS.java +++ b/src/main/java/demo/PostgresAuditLogTriggerSQS.java @@ -1,7 +1,3 @@ -/* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template - */ package demo; import com.amazonaws.services.lambda.runtime.Context; @@ -14,28 +10,44 @@ import org.jooq.JSONB; import org.jooq.impl.DSL; -/* - * Audit log trigger that takes incoming event and writes to audit log table - - CREATE TABLE IF NOT EXISTS audit_log_sqs ( - id BIGSERIAL PRIMARY KEY, - created timestamp without time zone NOT NULL DEFAULT timezone('utc'::text, now()), - operation tg_op NOT NULL, - table text NOT NULL, - old_record jsonb, - new_record jsonb -); - * - * Receive the SQS payload for Audit log entry +/** + * Records PostgreSQL row-change payloads delivered through SQS in {@code audit_log_sqs}. + * + *

The forwarding Lambda places the original trigger JSON in each message body. This + * consumer processes only the first record, matching the SAM event source's batch size of one. + * It holds a database context for the execution environment's lifetime and does not deduplicate + * messages. Processing exceptions are logged and suppressed, so returning normally can acknowledge + * a message whose audit insert failed. */ public class PostgresAuditLogTriggerSQS implements RequestHandler { + /** + * Creates the queue consumer after class initialization has opened the shared database context. + */ + public PostgresAuditLogTriggerSQS() { + } + + /** Logger for queue payloads, empty message bodies, and processing failures. */ final Logger log = LogManager.getLogger(); + /** Database context opened during class initialization and reused across invocations. */ final static DSLContext dsl = PostgresDataSource.getDSL(); + /** Shared mapper used to parse the original PostgreSQL trigger JSON from message bodies. */ final static ObjectMapper mapper = new ObjectMapper(); + /** + * Parses the first SQS message and inserts its operation and row images into the audit table. + * + *

Empty bodies are logged and skipped. Other parsing or database exceptions inside + * the method are logged and suppressed. Additional records in a larger batch would be + * ignored, so the event source must retain its batch size of one. This handler does not + * return partial batch failures or throw processing errors for SQS retries. + * + * @param event SQS event expected to contain exactly one record with PostgreSQL trigger JSON + * @param context Lambda invocation metadata; unused by this implementation + * @return always {@code null}, including when a message is skipped or processing fails + */ @Override public Void handleRequest(SQSEvent event, Context context) { try { diff --git a/src/main/java/demo/PostgresDataSource.java b/src/main/java/demo/PostgresDataSource.java index 91a73a7..e976f9b 100644 --- a/src/main/java/demo/PostgresDataSource.java +++ b/src/main/java/demo/PostgresDataSource.java @@ -1,8 +1,3 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ package demo; import com.amazonaws.secretsmanager.sql.AWSSecretsManagerPostgreSQLDriver; @@ -16,22 +11,33 @@ import org.postgresql.PGConnection; /** - * Connection to the DB with secret managed by RDS. - * - * https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_jdbc.html - * + * Creates PostgreSQL connections using credentials resolved by the AWS Secrets Manager JDBC driver. + * + *

The SAM environment provides {@code DB_MASTER_SECRET_ARN}, {@code DB_ENDPOINT}, and + * {@code DB_NAME}. Class initialization registers the Secrets Manager PostgreSQL driver and + * selects the AWS URL-connection HTTP implementation. Each factory call opens a separate + * connection; this class provides neither pooling nor automatic connection cleanup. + * * @author jensen */ public class PostgresDataSource { /** - * These env vars are passed to all Lambdas via the global section in SAM + * Creates an instance with no per-instance state; database access is provided by the static factories. + */ + public PostgresDataSource() { + } + + /** + * Secret ARN passed as the JDBC user property so the driver can resolve database credentials. */ static final String SECRET_ARN = System.getenv("DB_MASTER_SECRET_ARN"); + /** Database hostname supplied by the nested RDS stack through the SAM environment. */ static final String DB_ENDPOINT = System.getenv("DB_ENDPOINT"); + /** Database name appended to the Secrets Manager JDBC URL. */ static final String DB_NAME = System.getenv("DB_NAME"); - // Additional properties passed to the driver + /** Shared JDBC connection properties containing the secret ARN in the user field. */ static final Properties info; static { @@ -47,6 +53,15 @@ public class PostgresDataSource { info.put("user", SECRET_ARN); } + /** + * Opens a new connection to the configured database using the Secrets Manager driver. + * + *

SQL connection failures are printed to standard error and converted to {@code null}. + * The factory does not retry, cache, or close a successfully opened connection. + * + * @return a newly opened JDBC connection, or {@code null} when the driver throws + * {@link SQLException}; the caller is responsible for the connection's lifetime + */ private static Connection getConnection() { try { // Establish the connection @@ -59,10 +74,29 @@ private static Connection getConnection() { } } + /** + * Opens a connection and exposes PostgreSQL-specific driver operations through unwrapping. + * + *

Only the {@link PGConnection} view is returned; the underlying JDBC connection is + * not closed by this method or retained by this class for later cleanup. + * + * @return the PostgreSQL-specific view of the newly opened JDBC connection + * @throws SQLException if the opened connection cannot be unwrapped as a PostgreSQL connection + * @throws NullPointerException if connection acquisition failed and returned {@code null} + */ public static PGConnection getPGConnection() throws SQLException { return (getConnection().unwrap(PGConnection.class)); } + /** + * Creates a jOOQ context configured with the PostgreSQL dialect and a fresh JDBC connection. + * + *

The context wraps the connection supplied by {@link #getConnection()}; this factory + * does not provide pooling, transaction boundaries, or automatic connection cleanup. + * If acquisition returns {@code null}, the context has no usable connection for execution. + * + * @return a PostgreSQL jOOQ context for constructing and executing the demo's SQL statements + */ public static DSLContext getDSL() { return (DSL.using(getConnection(), SQLDialect.POSTGRES)); } diff --git a/src/main/java/demo/WebHandler.java b/src/main/java/demo/WebHandler.java index af5a3cf..b9517f3 100644 --- a/src/main/java/demo/WebHandler.java +++ b/src/main/java/demo/WebHandler.java @@ -6,11 +6,23 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import java.util.Map; -/** Routes all UI requests within one Lambda execution environment. */ +/** + * Routes the demo's five HTTP endpoints inside one Lambda execution environment. + * + *

The SAM template uses HTTP API payload format 1.0 to populate + * {@link APIGatewayProxyRequestEvent}. Exact, case-sensitive paths select reusable handlers + * for the table view and address/audit mutations. Dispatch is an ordinary Java call and + * preserves the selected handler's response without invoking another Lambda. + */ public class WebHandler implements RequestHandler { + /** Immutable mapping of exact URL paths to handlers reused across warm invocations. */ private final Map> routes; + /** + * Creates the production router for {@code /}, {@code /create}, {@code /multiple}, + * {@code /delete}, and {@code /audit} without opening a database connection. + */ public WebHandler() { this(Map.of( "/", new FrontEnd(), @@ -20,11 +32,30 @@ public WebHandler() { "/audit", new DeleteAuditLogFrontEnd())); } + /** + * Creates a router with supplied handlers for deterministic, offline routing tests. + * + * @param routes exact request paths and their handlers; copied so later map mutations + * cannot alter routing, although handler instances themselves are shared + * @throws NullPointerException if the map, any path, or any handler is {@code null} + */ WebHandler(Map> routes) { // Reuse stateless handlers across warm requests; never retain request data here. this.routes = Map.copyOf(routes); } + /** + * Validates a request, selects its exact path, and dispatches supported GET requests. + * + *

Validation precedes handler execution: missing request fields produce HTTP 400, + * unknown paths produce HTTP 404, and non-GET methods on known paths produce HTTP 405 + * with {@code Allow: GET}. Query parameters do not participate in path selection. + * Uncaught exceptions from the selected handler propagate to the Lambda runtime. + * + * @param input API Gateway payload-format 1.0 request; {@code null} is treated as invalid + * @param context invocation metadata forwarded unchanged to the selected handler + * @return the selected handler's original response, or a plain-text validation response + */ @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { // SAM explicitly selects payload version 1.0, matching the existing UI handlers. @@ -44,6 +75,13 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent in return handler.handleRequest(input, context); } + /** + * Builds a fresh plain-text response for a request rejected before dispatch. + * + * @param status HTTP response status code + * @param message response body describing the request error + * @return a response containing the supplied status and message with a UTF-8 text content type + */ private static APIGatewayProxyResponseEvent error(int status, String message) { return new APIGatewayProxyResponseEvent() .withStatusCode(status) diff --git a/src/main/java/demo/package-info.java b/src/main/java/demo/package-info.java new file mode 100644 index 0000000..ab6714f --- /dev/null +++ b/src/main/java/demo/package-info.java @@ -0,0 +1,15 @@ +/** + * Demonstrates PostgreSQL row-change triggers invoking AWS Lambda for geocoding and audit logging. + * + *

{@link demo.WebHandler} serves the UI through a single Lambda and delegates database + * actions to Java handlers. PostgreSQL asynchronously invokes {@link demo.PostgresAddressTrigger} + * and {@link demo.PostgresAuditLogTrigger}; a separate Node.js forwarder sends the same audit + * payload to SQS for {@link demo.PostgresAuditLogTriggerSQS}. Database initialization is managed + * by {@link demo.CloudFormationCustomResource}, and {@link demo.PostgresDataSource} supplies + * Secrets Manager-backed JDBC connections. + * + *

The implementation is a demonstration: individual handlers document their current + * connection lifetime, error acknowledgement, and retry behavior. Trigger delivery does not + * establish that the originating database transaction has committed. + */ +package demo; diff --git a/src/test/java/demo/WebHandlerTest.java b/src/test/java/demo/WebHandlerTest.java index b455f85..afcad40 100644 --- a/src/test/java/demo/WebHandlerTest.java +++ b/src/test/java/demo/WebHandlerTest.java @@ -11,8 +11,26 @@ import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.*; +/** + * Exercises UI routing and response contracts without connecting to AWS or PostgreSQL. + * + *

Injected handlers isolate dispatch behavior, while anonymous action implementations + * exercise the shared redirect/error handling. Production-router rejection tests ensure + * invalid requests can be handled without initializing database access. + */ class WebHandlerTest { + /** + * Creates an offline routing test instance without contacting AWS or PostgreSQL. + */ + WebHandlerTest() { + } + + /** + * Verifies that an exact route receives the original request once and returns its response unchanged. + * + * @param path one of the five supported UI paths supplied by the parameterized test + */ @ParameterizedTest @ValueSource(strings = {"/", "/create", "/multiple", "/delete", "/audit"}) void dispatchesOnlyTheMatchingRouteAndPreservesTheResponse(String path) { @@ -36,6 +54,11 @@ void dispatchesOnlyTheMatchingRouteAndPreservesTheResponse(String path) { assertEquals(1, calls.get()); } + /** + * Verifies that unknown paths and path variants return HTTP 404 before database work can begin. + * + * @param path unsupported path, including case and trailing-slash variants + */ @ParameterizedTest @ValueSource(strings = {"/missing", "/create/extra", "/CREATE", "/create/"}) void unknownPathsNeverReachAnAction(String path) { @@ -43,6 +66,11 @@ void unknownPathsNeverReachAnAction(String path) { assertEquals(404, new WebHandler().handleRequest(request(path, "GET"), null).getStatusCode()); } + /** + * Verifies that a known mutation route rejects non-GET requests and advertises GET as allowed. + * + * @param method unsupported HTTP method supplied by the parameterized test + */ @ParameterizedTest @ValueSource(strings = {"POST", "PUT", "DELETE", "HEAD", "OPTIONS"}) void rejectsOtherMethodsBeforeAnActionCanWrite(String method) { @@ -51,6 +79,9 @@ void rejectsOtherMethodsBeforeAnActionCanWrite(String method) { assertEquals("GET", response.getHeaders().get("Allow")); } + /** + * Verifies HTTP 400 responses for a null event, missing path, and missing HTTP method. + */ @Test void rejectsIncompleteRequests() { var handler = new WebHandler(); @@ -59,6 +90,10 @@ void rejectsIncompleteRequests() { assertEquals(400, handler.handleRequest(request("/create", null), null).getStatusCode()); } + /** + * Reuses one router across view/action/view requests to check that route selection, + * response bodies, and redirect headers do not leak from one invocation to the next. + */ @Test void warmRequestsDoNotReuseThePreviousRouteOrResponse() { var handler = new WebHandler(Map.of( @@ -73,10 +108,15 @@ void warmRequestsDoNotReuseThePreviousRouteOrResponse() { assertNull(homeAgain.getHeaders()); } + /** + * Verifies that the shared action handler runs its mutation once and preserves the + * HTTP 307 redirect, root location, and no-cache header when invoked through the router. + */ @Test void existingActionStillRedirectsAfterExactlyOneWrite() { var calls = new AtomicInteger(); var action = new AbstractActionFrontEnd() { + /** Records one simulated database mutation without opening a connection. */ @Override protected void performAction() { calls.incrementAndGet(); @@ -90,9 +130,17 @@ protected void performAction() { assertEquals("no-cache", response.getHeaders().get("Cache-Control")); } + /** + * Verifies that an exception from an action remains an HTTP 500 response through routing. + */ @Test void existingActionFailureRemainsAnErrorResponse() { var action = new AbstractActionFrontEnd() { + /** + * Simulates a failing database mutation for the error-response assertion. + * + * @throws IllegalStateException always, to exercise the shared action handler's catch block + */ @Override protected void performAction() { throw new IllegalStateException("test database failure"); @@ -104,6 +152,13 @@ protected void performAction() { assertTrue(response.getBody().contains("test database failure")); } + /** + * Builds a minimal payload-format 1.0 request for offline router tests. + * + * @param path request path, or {@code null} when testing a missing path + * @param method HTTP method, or {@code null} when testing a missing method + * @return a new event containing the supplied path and method + */ private static APIGatewayProxyRequestEvent request(String path, String method) { return new APIGatewayProxyRequestEvent().withPath(path).withHttpMethod(method); }