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 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 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 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 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);
}