diff --git a/README.md b/README.md index 114f223..7d4237e 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python3 tests/test_native_image_script.py ``` These tests stub Maven and Docker to check packaging and failure handling. -They are not run by Maven or the current CI workflow. +They are not run by Maven. The source-build CI workflow runs them separately from the real native smoke. ### Test with Main @@ -127,7 +127,7 @@ software.amazon.awssdk.services.lambda.model.ResourceNotFoundException: Function This source line uses Jackson 3.2.3 and json-schema-validator 3.0.8. Build the Jackson 3 light-4j, http-client, REST and AWS Lambda prerequisites first; older snapshot binaries are not compatible. Jackson annotations retain their com.fasterxml.jackson.annotation package. -The runtime mapper uses Jackson2Defaults with its existing permissive unknown-property override. Mapper instances are immutable. Java time and Optional support are built in. Classic SnakeYAML compatibility pins are retained until the stack-wide cleanup. +The runtime uses the event-only `LambdaRuntimeMappers.mapper()` API from custom-runtime, built from `JacksonDefaults` with its permissive unknown-property override. Mapper instances are immutable. Java time and Optional support are built in. Classic SnakeYAML compatibility pins are retained until the stack-wide cleanup. Local qualification includes the full JVM suite, sanitized event/validation parity against the Jackson 2 baseline, and an offline GraalVM 25 native-image build. A native-image build does not establish deployed AWS behavior or complete reflection coverage for every middleware/configuration. The separate conformance worktree is deferred. @@ -138,7 +138,11 @@ MAVEN_REPO_LOCAL=/path/to/isolated/m2 bash native-image-linux.sh python3 tests/native_runtime_smoke.py target/custom-runtime/lambda-native ``` -The smoke check binds an API Gateway event, runs the local health handler, and verifies success and builder-based error serialization. It uses no AWS endpoint or credentials. `-H:+AddAllCharsets` is required by SnakeYAML Engine's UTF-32 input detection. The check does not qualify every middleware chain or deployed AWS behavior. +The smoke check binds API Gateway events with an unknown future field and runs specification, validator and health handlers. It checks valid referenced query/header/body schemas, invalid query/header/body values, an unknown path, a method mismatch, and builder-based error serialization. The OpenAPI document and handler/validator settings are controlled fixtures. The status catalog and injection settings are copied from the packaged application's `config/status.yml` and `config/config.yml`; their contents are not duplicated in the fixture. By default the script requires exactly one shaded `target/lambda-native-*.jar` (excluding sources and Javadoc JARs); use `--application-jar /path/to/runtime.jar` to select one explicitly. + +It uses no AWS endpoint or credentials. `-H:+AddAllCharsets` is required by SnakeYAML Engine's UTF-32 input detection. The check does not qualify every middleware chain, all default-resource packaging, or deployed AWS behavior. + +The native reflection file currently includes version-specific JSON overlay sideband accessors (`MapOverlay$1.getOverlay()` and `ListOverlay$1.getOverlay()`) and the OpenAPI path cache's Caffeine factories (`SSMS.FACTORY` and `PSMS()`), covered by this smoke. These remain application registrations until their owning libraries ship equivalent native metadata. Migrate native consumers together and remove these entries only after updated source pins and native smoke results establish equivalent coverage; moving them is separate from this mapper change. ### Reproducible Jackson 3 source build diff --git a/ci/jackson3-source-refs.json b/ci/jackson3-source-refs.json index ad31c77..d280a48 100644 --- a/ci/jackson3-source-refs.json +++ b/ci/jackson3-source-refs.json @@ -2,7 +2,7 @@ "http-client": "5ecee29dbb08d813512f5379c2e1b902d553c5d4", "json-overlay": "e309fd63f95317067c9b13a4bed7ac16727bfea5", "light-4j": "97b2359b92ed3e1d05cac04f2da8760aa954e49d", - "light-aws-lambda": "dddbbc9e7f6f690f8688ceac024728b6a84f4808", + "light-aws-lambda": "5913c7c25b01b6d384fd9c39014dc91d63dfc35e", "light-rest-4j": "a31955945036d9108b7e9bb72167415cc9cfc287", "openapi-parser": "9ae849056e49f7c796fe28bfe6093fbe4847cc5c", "yaml-rule": "c237cc3a25720f43e57bf571e25d5fa06687aa3b" diff --git a/reflect.json b/reflect.json index 2cd12b4..3c85cf3 100644 --- a/reflect.json +++ b/reflect.json @@ -1,4 +1,20 @@ [ + { + "name": "com.github.benmanes.caffeine.cache.SSMS", + "fields": [{ "name": "FACTORY" }] + }, + { + "name": "com.github.benmanes.caffeine.cache.PSMS", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "com.networknt.jsonoverlay.MapOverlay$1", + "methods": [{ "name": "getOverlay", "parameterTypes": [] }] + }, + { + "name": "com.networknt.jsonoverlay.ListOverlay$1", + "methods": [{ "name": "getOverlay", "parameterTypes": [] }] + }, { "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent", "methods": [ diff --git a/src/main/java/com/networknt/aws/lambda/Main.java b/src/main/java/com/networknt/aws/lambda/Main.java index 276910d..3aa0c7d 100644 --- a/src/main/java/com/networknt/aws/lambda/Main.java +++ b/src/main/java/com/networknt/aws/lambda/Main.java @@ -2,17 +2,14 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.ObjectMapper; -import com.networknt.jackson.JacksonDefaults; import com.networknt.aws.lambda.app.LambdaApp; import java.io.IOException; public class Main { - private static final ObjectMapper OBJECT_MAPPER = JacksonDefaults.builder() - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); + private static final ObjectMapper OBJECT_MAPPER = LambdaRuntimeMappers.mapper(); public static void main(String[] args) throws IOException { diff --git a/src/test/java/com/networknt/aws/lambda/Jackson3NativeCompatibilityTest.java b/src/test/java/com/networknt/aws/lambda/Jackson3NativeCompatibilityTest.java index f0266a3..c7ff9f4 100644 --- a/src/test/java/com/networknt/aws/lambda/Jackson3NativeCompatibilityTest.java +++ b/src/test/java/com/networknt/aws/lambda/Jackson3NativeCompatibilityTest.java @@ -5,20 +5,14 @@ import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; -import java.lang.reflect.Field; - -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; class Jackson3NativeCompatibilityTest { - private ObjectMapper mainMapper() throws Exception { - Field field = Main.class.getDeclaredField("OBJECT_MAPPER"); - field.setAccessible(true); - return (ObjectMapper) field.get(null); - } @Test void runtimeMapperKeepsPermissiveEventBinding() throws Exception { - APIGatewayProxyRequestEvent event = mainMapper().readValue( + APIGatewayProxyRequestEvent event = LambdaRuntimeMappers.mapper().readValue( "{\"path\":\"/v1/pets\",\"httpMethod\":\"POST\",\"futureField\":true} trailing", APIGatewayProxyRequestEvent.class); assertEquals("/v1/pets", event.getPath()); @@ -27,7 +21,7 @@ void runtimeMapperKeepsPermissiveEventBinding() throws Exception { @Test void responseRetainsJackson2ShapeAndRoundTrips() throws Exception { - ObjectMapper mapper = mainMapper(); + ObjectMapper mapper = LambdaRuntimeMappers.mapper(); APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent() .withStatusCode(200).withBody("{\"ok\":true}").withIsBase64Encoded(false); String json = mapper.writeValueAsString(response); @@ -39,6 +33,6 @@ void responseRetainsJackson2ShapeAndRoundTrips() throws Exception { @Test void runtimeMapperRetainsJackson2StreamDepth() throws Exception { String nested = "[".repeat(600) + "0" + "]".repeat(600); - assertDoesNotThrow(() -> mainMapper().readTree(nested)); + assertDoesNotThrow(() -> LambdaRuntimeMappers.mapper().readTree(nested)); } } diff --git a/src/test/java/com/networknt/aws/lambda/TestUtils.java b/src/test/java/com/networknt/aws/lambda/TestUtils.java index e36c1c9..55dc0c4 100644 --- a/src/test/java/com/networknt/aws/lambda/TestUtils.java +++ b/src/test/java/com/networknt/aws/lambda/TestUtils.java @@ -2,9 +2,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import tools.jackson.core.JacksonException; -import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.ObjectMapper; -import com.networknt.jackson.JacksonDefaults; public class TestUtils { @@ -133,8 +131,7 @@ public class TestUtils { " }\n" + "}"; - private static final ObjectMapper OBJECT_MAPPER = JacksonDefaults.builder() - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); + private static final ObjectMapper OBJECT_MAPPER = LambdaRuntimeMappers.mapper(); public static APIGatewayProxyRequestEvent createTestRequestEvent() { APIGatewayProxyRequestEvent apiGatewayProxyRequestEvent; diff --git a/tests/native_runtime_smoke.py b/tests/native_runtime_smoke.py index 74891cf..95d387b 100644 --- a/tests/native_runtime_smoke.py +++ b/tests/native_runtime_smoke.py @@ -9,13 +9,11 @@ import subprocess import tempfile import threading +import zipfile -def invoke(binary, handler, expected_endpoint): +def invoke(binary, status_catalog, handler, expected_endpoint, event, *, config_catalog): received = queue.Queue() - event = {"path": "/health", "httpMethod": "GET", "body": None, - "isBase64Encoded": False, "headers": {}, "multiValueHeaders": {}, - "requestContext": {"requestId": "native-smoke", "identity": {"sourceIp": "127.0.0.1"}}} class RuntimeApi(http.server.BaseHTTPRequestHandler): def log_message(self, *args): @@ -39,19 +37,73 @@ def do_POST(self): with tempfile.TemporaryDirectory(prefix="native-runtime-smoke-") as directory: config = Path(directory) + (config / "config.yml").write_bytes(config_catalog) (config / "handler.yml").write_text("""enabled: true handlers: + - com.networknt.aws.lambda.handler.middleware.specification.OpenApiMiddleware@spec + - com.networknt.aws.lambda.handler.middleware.validator.ValidatorMiddleware@validator - com.networknt.aws.lambda.handler.health.HealthCheckHandler@health chains: {} paths: - path: /health method: get - exec: [health] -defaultHandlers: [health] + exec: [spec, validator, health] +defaultHandlers: [spec, validator, health] """) (config / "health.yml").write_text("enabled: true\ndownstreamEnabled: false\n") (config / "lambda-app.yml").write_text("lambdaAppId: native-smoke\nencodeBase64Request: false\nencodeBase64Response: false\n") (config / "values.yml").write_text("{}\n") + (config / "service.yml").write_text("singletons: []\n") + (config / "status.yml").write_bytes(status_catalog) + # Controlled fixture settings exercise validation; this is not default-config qualification. + (config / "openapi-inject.yml").write_text("{}\n") + (config / "openapi-handler.yml").write_text("enabled: true\nmultipleSpec: false\n") + (config / "openapi-validator.yml").write_text("enabled: true\nskipPathPrefixes: []\n") + (config / "openapi.yaml").write_text("""openapi: 3.0.3 +info: + title: Native validation smoke + version: '1' +servers: + - url: / +paths: + /health: + get: + parameters: + - name: count + in: query + required: true + schema: + $ref: '#/components/schemas/Count' + - name: x-count + in: header + required: true + schema: + $ref: '#/components/schemas/Count' + responses: + '200': + description: OK + /body: + post: + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Payload' + responses: + '200': + description: OK +components: + schemas: + Count: + type: integer + Payload: + type: object + required: [value] + properties: + value: + type: integer +""") server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RuntimeApi) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -85,15 +137,48 @@ def do_POST(self): def main(): parser = argparse.ArgumentParser() parser.add_argument("binary", type=Path) + parser.add_argument("--application-jar", type=Path, + help="Shaded runtime JAR providing framework config and status catalog (default: target/lambda-native-*.jar)") args = parser.parse_args() binary = args.binary.resolve(strict=True) - response = invoke(binary, "com.networknt.aws.lambda.app.LambdaApp", "response") + jars = [args.application_jar] if args.application_jar else [ + jar for jar in (Path(__file__).resolve().parents[1] / "target").glob("lambda-native-*.jar") + if not jar.name.endswith(("-sources.jar", "-javadoc.jar"))] + if len(jars) != 1: + parser.error("Expected one shaded runtime JAR; specify --application-jar explicitly") + with zipfile.ZipFile(jars[0]) as application_jar: + status_catalog = application_jar.read("config/status.yml") + config_catalog = application_jar.read("config/config.yml") + event = {"path": "/health", "httpMethod": "GET", "body": None, + "isBase64Encoded": False, "headers": {"x-count": "7"}, "multiValueHeaders": {}, + "queryStringParameters": {"count": "7"}, "futureEventField": {"nested": True}, + "requestContext": {"requestId": "native-smoke", "identity": {"sourceIp": "127.0.0.1"}}} + handler = "com.networknt.aws.lambda.app.LambdaApp" + response = invoke(binary, status_catalog, handler, "response", event, config_catalog=config_catalog) assert response["statusCode"] == 200, response assert response["body"] == "OK", response assert response["isBase64Encoded"] is False, response - error = invoke(binary, "native.smoke.MissingHandler", "error") + body_event = event | {"path": "/body", "httpMethod": "POST", "body": '{"value":7}', + "headers": {"content-type": "application/json"}, "queryStringParameters": {}} + body_response = invoke(binary, status_catalog, handler, "response", body_event, config_catalog=config_catalog) + assert body_response["statusCode"] == 200, body_response + assert body_response["body"] == "OK", body_response + cases = [ + ("query", event | {"queryStringParameters": {"count": "invalid"}}, 400, "ERR11004"), + ("header", event | {"headers": {"x-count": "invalid"}}, 400, "ERR11004"), + ("body", body_event | {"body": '{"value":"invalid"}'}, 400, "ERR11004"), + ("unknown path", event | {"path": "/missing"}, 404, "ERR10007"), + ("method mismatch", event | {"httpMethod": "DELETE"}, 405, "ERR10008"), + ] + for name, invalid_event, status, code in cases: + invalid = invoke(binary, status_catalog, handler, "response", invalid_event, config_catalog=config_catalog) + assert invalid["statusCode"] == status, (name, invalid) + assert json.loads(invalid["body"])["code"] == code, (name, invalid) + error = invoke(binary, status_catalog, "native.smoke.MissingHandler", "error", event, config_catalog=config_catalog) assert "native.smoke.MissingHandler" in error["message"], error print("Native Runtime smoke passed: API Gateway binding, health response and builder error serialization") + print("Native Runtime validation passed: unknown event fields and referenced query-schema rejection") + print("Native Runtime validation passed: body and header schemas, unknown path and method mismatch") if __name__ == "__main__":