diff --git a/async-config-poll/.gitignore b/async-config-poll/.gitignore new file mode 100644 index 00000000..673052ec --- /dev/null +++ b/async-config-poll/.gitignore @@ -0,0 +1,5 @@ +target/ +keploy/ +.local-logs/ +config-stub/config-stub +*.log diff --git a/async-config-poll/README.md b/async-config-poll/README.md new file mode 100644 index 00000000..85bfcf91 --- /dev/null +++ b/async-config-poll/README.md @@ -0,0 +1,78 @@ +# async-config-poll + +A Spring Boot 1.5 / Java 8 rule-engine sample that demonstrates Keploy's +**async-egress engine**. + +The app has two HTTP endpoints backed by MySQL, and it depends on a central +config service in two different ways: + +| Interaction | When | Keploy treats it as | +|-------------|------|---------------------| +| `GET /v1/buckets/app-common`, `app-features`, `app-config?watch=false` | once, at boot (blocking) | ordinary synchronous mocks — the app cannot boot without them | +| `GET /v1/buckets/app-config?watch=true&version=N` | forever, from a background daemon thread | **async egress** — fires on the app's own schedule, not tied to any ingress testcase | +| `SELECT ...` on MySQL | per request | ordinary synchronous mocks | + +The background watch poll is the interesting part. Because it runs on a timer in +its own thread, it does not line up one-to-one with the recorded testcases. A +naive replay would fail: the app polls at replay time too, and the request +(`?version=17`, `?version=18`, …) never matches a recorded one exactly. + +Keploy's async-egress engine handles this. The lane declared in `keploy.yml` +tells Keploy that this endpoint is async: + +```yaml +async: + lanes: + - name: config-watch + type: http + match: + pathRegex: "^/v1/buckets/app-config$" + matchQuery: + watch: "true" # only the background watch polls, not the boot call + volatileParams: ["version"] # the version query param varies every poll — treat as noise +``` + +At replay the engine serves the recorded watch responses back to the poller +independently of testcase ordering, treats the changing `version` param as +shape-noise, and keep-alives the poller when there is nothing left to serve — so +the app stays happy and the ingress tests still pass. At the end of replay Keploy +prints an `async egress verdict` line (served / shape_flags / not_exercised). + +## Endpoints + +- `GET /health` — small health payload; runs `SELECT 1` against MySQL. +- `GET /rules/{useCase}` — ordered rules for `(useCase, tenant)` read from MySQL. + Requires headers `X-Tenant-Id` and `X-Agent-Id`. + Example: `GET /rules/ORDER_FLOW` with `X-Tenant-Id: ACME`, `X-Agent-Id: 957`. + +## Run it locally + +Prerequisites: JDK 8, Maven, Docker, Go (for the config stub), and a Keploy +build that includes the async-egress engine. + +```bash +# 1. dependencies +docker compose up -d # MySQL 5.7 seeded from init.sql +go run ./config-stub & # config service stub on :9100 + +# 2. build the app +mvn -B clean package -Dmaven.test.skip=true + +# 3. record +sudo -E keploy record -c "java -jar target/async-config-poll.jar" +# drive traffic, then Ctrl-C keploy: +curl localhost:8080/health +curl -H "X-Tenant-Id: ACME" -H "X-Agent-Id: 957" localhost:8080/rules/ORDER_FLOW + +# 4. replay (deps down — Keploy serves everything from mocks) +docker compose down +sudo keploy test -c "java -jar target/async-config-poll.jar" --delay 20 +``` + +To make a watch poll land in the *middle* of a testcase at replay (so the async +lane is actively exercised rather than drained between tests), lower the poll +interval and widen the request window: + +```bash +WATCH_INTERVAL_MS=150 RULES_DELAY_MS=800 sudo -E keploy record -c "java -jar target/async-config-poll.jar" +``` diff --git a/async-config-poll/config-stub/go.mod b/async-config-poll/config-stub/go.mod new file mode 100644 index 00000000..8c9ffb36 --- /dev/null +++ b/async-config-poll/config-stub/go.mod @@ -0,0 +1,3 @@ +module config-stub + +go 1.21 diff --git a/async-config-poll/config-stub/main.go b/async-config-poll/config-stub/main.go new file mode 100644 index 00000000..20a4e652 --- /dev/null +++ b/async-config-poll/config-stub/main.go @@ -0,0 +1,43 @@ +// Command config-stub is a stand-in for a central config service. It backs the +// app's boot-blocking config fetch and its background watch long-poll: +// +// - GET /v1/buckets/{name} -> current config (version 1) +// - GET /v1/buckets/app-config?watch=true&version=N -> long-poll: returns the +// NEXT version (N+1), simulating a config change on each watch poll. +// +// It is hit only during `keploy record`. At replay time Keploy serves the +// recorded responses instead, so this stub does not need to be running. +package main + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "strings" +) + +func main() { + http.HandleFunc("/v1/buckets/", func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/v1/buckets/") + q := r.URL.Query() + + version := 1 + if q.Get("watch") == "true" { + cur, _ := strconv.Atoi(q.Get("version")) + version = cur + 1 // deliver the next version -> a "change" per poll + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "name": name, + "version": version, + "keys": map[string]string{ + "feature.enabled": "true", + }, + }) + }) + log.Println("config-stub listening on :9100") + log.Fatal(http.ListenAndServe(":9100", nil)) +} diff --git a/async-config-poll/docker-compose.yml b/async-config-poll/docker-compose.yml new file mode 100644 index 00000000..c99afaa1 --- /dev/null +++ b/async-config-poll/docker-compose.yml @@ -0,0 +1,21 @@ +services: + mysql: + # MySQL 5.7 (not 8.x): Spring Boot 1.5 manages MySQL Connector/J to 5.1.x, + # which cannot speak MySQL 8's default caching_sha2_password auth plugin. + image: mysql:5.7 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: ruledb + MYSQL_USER: app + MYSQL_PASSWORD: app + ports: + - "3306:3306" + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + # mysql:5.7 can trip an fd-limit config check on some Docker hosts; pin a + # sane nofile limit so mysqld starts and stays up under connection load. + ulimits: + nofile: + soft: 65535 + hard: 65535 diff --git a/async-config-poll/init.sql b/async-config-poll/init.sql new file mode 100644 index 00000000..a23e2a81 --- /dev/null +++ b/async-config-poll/init.sql @@ -0,0 +1,39 @@ +-- Rule-engine schema + seed for the (ORDER_FLOW, ACME) use case. +-- The app reads these rows over the MySQL wire; Keploy captures that traffic +-- as mocks and serves it back on replay. +CREATE DATABASE IF NOT EXISTS ruledb; +USE ruledb; + +CREATE TABLE IF NOT EXISTS rules ( + rule_id BIGINT PRIMARY KEY, + use_case VARCHAR(64) NOT NULL, + tenant VARCHAR(64) NOT NULL, + constraint_expr TEXT NOT NULL, + rule_type VARCHAR(16) NOT NULL, + INDEX idx_uc_tenant (use_case, tenant) +); + +CREATE TABLE IF NOT EXISTS rule_actions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + rule_id BIGINT NOT NULL, + basic_action VARCHAR(255) NOT NULL, + action_details TEXT NOT NULL, + seq INT NOT NULL, + INDEX idx_rule (rule_id) +); + +INSERT INTO rules (rule_id, use_case, tenant, constraint_expr, rule_type) VALUES + (14,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("CHECKOUT")','POST'), + (15,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("PAYMENT")','POST'), + (16,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("SHIPMENT")','POST'), + (17,'ORDER_FLOW','ACME','status == "IN_PROGRESS" && type.equals("REFUND")','POST'), + (18,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("FULFILLMENT")','PRE'); + +INSERT INTO rule_actions (rule_id, basic_action, action_details, seq) VALUES + (14,'com.example.rules.handlers.ForceSyncHandler','{}',1), + (15,'com.example.rules.handlers.PaymentTaskHandler','{}',1), + (15,'com.example.rules.handlers.NotifyHandler','{"optional":"true","channel":"email"}',2), + (16,'com.example.rules.handlers.ValidateHandler','{"optional":"true"}',1), + (16,'com.example.rules.handlers.ShipmentHandler','{}',2), + (17,'com.example.rules.handlers.RefundHandler','{}',1), + (18,'com.example.rules.handlers.ImageSyncHandler','{"optional":"true"}',1); diff --git a/async-config-poll/keploy.yml b/async-config-poll/keploy.yml new file mode 100644 index 00000000..b3dad905 --- /dev/null +++ b/async-config-poll/keploy.yml @@ -0,0 +1,22 @@ +# Keploy config for the async-config-poll sample. +# +# The only non-default section is `async.lanes`. It declares the config-service +# watch long-poll as async egress, so Keploy's async-egress engine records and +# replays it independently of the ingress testcase ordering. +# +# Lane "config-watch": +# - match.pathRegex : only the /v1/buckets/app-config endpoint +# - matchQuery.watch : "true" -> only the background watch polls (the +# one-time boot "get current version" call uses +# watch=false and stays an ordinary blocking mock) +# - volatileParams : ["version"] -> the version query param changes every +# poll, so it is treated as shape-noise, not a mismatch +async: + lanes: + - name: config-watch + type: http + match: + pathRegex: "^/v1/buckets/app-config$" + matchQuery: + watch: "true" + volatileParams: ["version"] diff --git a/async-config-poll/pom.xml b/async-config-poll/pom.xml new file mode 100644 index 00000000..b471347d --- /dev/null +++ b/async-config-poll/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + + com.example + async-config-poll + 1.0.0 + jar + + + org.springframework.boot + spring-boot-starter-parent + 1.5.22.RELEASE + + + + + 1.8 + UTF-8 + com.example.asyncconfig.Application + + + + + + org.springframework.boot + spring-boot-starter-jersey + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + org.springframework.boot + spring-boot-starter-jetty + + + + + org.springframework.boot + spring-boot-starter-jdbc + + + mysql + mysql-connector-java + + + + + async-config-poll + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/Application.java b/async-config-poll/src/main/java/com/example/asyncconfig/Application.java new file mode 100644 index 00000000..cbd3b4c3 --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/Application.java @@ -0,0 +1,17 @@ +package com.example.asyncconfig; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * async-config-poll — a small rule engine that serves /health and + * /rules/{useCase} on port 8080 (backed by MySQL), fetches boot-blocking + * config at startup, and then long-polls a config service in the background + * for version changes (see {@link com.example.asyncconfig.config.ConfigWatchService}). + */ +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/config/ConfigWatchService.java b/async-config-poll/src/main/java/com/example/asyncconfig/config/ConfigWatchService.java new file mode 100644 index 00000000..68efed1c --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/config/ConfigWatchService.java @@ -0,0 +1,139 @@ +package com.example.asyncconfig.config; + +import java.util.Map; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +/** + * Talks to a central config service in two distinct ways: + * + * 1. BOOT (blocking, one-time): on startup it fetches the required config + * buckets and the current app-config version + * (GET /v1/buckets/app-config?watch=false). If any of these fails the bean + * throws, so the Spring context fails to start — i.e. these are + * boot-blocking dependencies. Keploy records them as ordinary (synchronous) + * mocks and must serve them for the app to boot on replay. + * + * 2. WATCH (background long-poll): after boot, a daemon thread repeatedly + * long-polls the SAME endpoint with ?watch=true (carrying the last version) + * to pick up config changes. This egress fires from a background thread on + * its own schedule — i.e. it is async relative to the ingress testcases — + * so Keploy records and replays it through the async-egress engine (lane + * "config-watch", matched on watch=true; see keploy.yml). + */ +@Service +public class ConfigWatchService { + + private static final Logger log = LoggerFactory.getLogger(ConfigWatchService.class); + + private final String baseUrl; + private final long watchIntervalMs; + private final RestTemplate rt = new RestTemplate(); + + private volatile boolean featuresEnabled; + private volatile int appConfigVersion; + private volatile boolean watching = true; + + public ConfigWatchService(@Value("${app.config.baseUrl}") String baseUrl, + @Value("${app.config.watchIntervalMs:700}") long watchIntervalMs) { + this.baseUrl = baseUrl; + this.watchIntervalMs = watchIntervalMs; + } + + @PostConstruct + public void init() { + // (1) Boot-blocking, one-time. + fetchBucket("app-common"); + Map features = fetchBucket("app-features"); + Map appConfig = fetchBucket("app-config?watch=false"); // get current version + this.appConfigVersion = intFrom(appConfig, "version", 0); + // The config bucket carries flags under a nested "keys" map (see config-stub). + this.featuresEnabled = boolFromKeys(features, "feature.enabled", true); + log.info("ConfigWatchService initialized; featuresEnabled={} appConfigVersion={}", + featuresEnabled, appConfigVersion); + + // (2) Background watch long-poll. + startWatchPoller(); + } + + private void startWatchPoller() { + Thread t = new Thread(() -> { + while (watching) { + try { + Thread.sleep(watchIntervalMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + try { + String url = baseUrl + "/v1/buckets/app-config?watch=true&version=" + appConfigVersion; + @SuppressWarnings("unchecked") + Map resp = rt.getForObject(url, Map.class); + int v = intFrom(resp, "version", appConfigVersion); + if (v > appConfigVersion) { + appConfigVersion = v; + // Debug, not info: the poller runs forever and the version + // can advance on every poll, so info would flood normal runs. + log.debug("config watch: app-config advanced to version {}", v); + } + } catch (Exception e) { + // At replay the async engine keep-alives when nothing is + // armed; a failed poll is non-fatal to the running app. Pass + // the exception so a stack trace is available under DEBUG. + log.debug("config watch poll failed", e); + } + } + }, "config-watch-poller"); + t.setDaemon(true); + t.start(); + } + + @PreDestroy + public void stop() { + watching = false; + } + + @SuppressWarnings("unchecked") + private Map fetchBucket(String name) { + String url = baseUrl + "/v1/buckets/" + name; + try { + return rt.getForObject(url, Map.class); + } catch (Exception e) { + throw new IllegalStateException( + "ConfigWatchService: failed to fetch config bucket '" + name + "' from " + url + + " — application cannot boot. Ensure the config service is reachable and " + + "that app.config.baseUrl points at it.", e); + } + } + + private static int intFrom(Map m, String key, int dflt) { + if (m == null || m.get(key) == null) { + return dflt; + } + try { + return Integer.parseInt(String.valueOf(m.get(key))); + } catch (NumberFormatException e) { + return dflt; + } + } + + /** Reads a boolean flag from the bucket's nested "keys" map (its real shape). */ + @SuppressWarnings("unchecked") + private static boolean boolFromKeys(Map bucket, String key, boolean dflt) { + if (bucket == null || !(bucket.get("keys") instanceof Map)) { + return dflt; + } + Object v = ((Map) bucket.get("keys")).get(key); + return v == null ? dflt : Boolean.parseBoolean(String.valueOf(v)); + } + + public boolean isFeaturesEnabled() { + return featuresEnabled; + } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/rest/JerseyConfig.java b/async-config-poll/src/main/java/com/example/asyncconfig/rest/JerseyConfig.java new file mode 100644 index 00000000..cda80bd7 --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/rest/JerseyConfig.java @@ -0,0 +1,18 @@ +package com.example.asyncconfig.rest; + +import com.example.asyncconfig.rules.HealthResource; +import com.example.asyncconfig.rules.RulesResource; +import org.glassfish.jersey.server.ResourceConfig; +import org.springframework.stereotype.Component; + +/** + * Registers the JAX-RS resources with Jersey (the app's REST layer). Both + * /health and /rules are served here. + */ +@Component +public class JerseyConfig extends ResourceConfig { + public JerseyConfig() { + register(HealthResource.class); + register(RulesResource.class); + } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/rules/HealthResource.java b/async-config-poll/src/main/java/com/example/asyncconfig/rules/HealthResource.java new file mode 100644 index 00000000..af814812 --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/rules/HealthResource.java @@ -0,0 +1,52 @@ +package com.example.asyncconfig.rules; + +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * /health — a small, deterministic health payload that also runs a real MySQL + * SELECT so the datastore is exercised (and mocked) on this endpoint too. It is + * served as a Jersey resource rather than via live actuator so the body is + * stable across record/replay (actuator's diskSpace.free changes between runs + * and would break replay matching). + */ +@Component +@Path("/health") +public class HealthResource { + + private final JdbcTemplate jdbc; + private final ObjectMapper mapper; + + @Autowired + public HealthResource(JdbcTemplate jdbc, ObjectMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @GET + @Produces("application/json;charset=UTF-8") + public Response health() throws Exception { + Integer hello = jdbc.queryForObject("SELECT 1", Integer.class); // exercises MySQL + + Map db = new LinkedHashMap<>(); + db.put("status", "UP"); + db.put("database", "MySQL"); + db.put("hello", hello); + + Map body = new LinkedHashMap<>(); + body.put("status", "UP"); + body.put("db", db); + + return Response.ok(mapper.writeValueAsString(body)).build(); + } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/rules/Rule.java b/async-config-poll/src/main/java/com/example/asyncconfig/rules/Rule.java new file mode 100644 index 00000000..beb64fef --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/rules/Rule.java @@ -0,0 +1,27 @@ +package com.example.asyncconfig.rules; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A single rule: constraint expression + ordered action handlers. */ +public class Rule { + @JsonProperty("rule_id") + private long ruleId; + private String constraints; + private List actions; + @JsonProperty("rule_type") + private String ruleType; + + public Rule(long ruleId, String constraints, List actions, String ruleType) { + this.ruleId = ruleId; + this.constraints = constraints; + this.actions = actions; + this.ruleType = ruleType; + } + + public long getRuleId() { return ruleId; } + public String getConstraints() { return constraints; } + public List getActions() { return actions; } + public String getRuleType() { return ruleType; } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleAction.java b/async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleAction.java new file mode 100644 index 00000000..e434d3b1 --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleAction.java @@ -0,0 +1,22 @@ +package com.example.asyncconfig.rules; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** One action handler within a rule (handler class + details + order). */ +public class RuleAction { + @JsonProperty("basic_action") + private String basicAction; + @JsonProperty("action_details") + private String actionDetails; + private int sequence; + + public RuleAction(String basicAction, String actionDetails, int sequence) { + this.basicAction = basicAction; + this.actionDetails = actionDetails; + this.sequence = sequence; + } + + public String getBasicAction() { return basicAction; } + public String getActionDetails() { return actionDetails; } + public int getSequence() { return sequence; } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleDao.java b/async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleDao.java new file mode 100644 index 00000000..69204dc2 --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleDao.java @@ -0,0 +1,52 @@ +package com.example.asyncconfig.rules; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * Reads rules + their action handlers from MySQL for a (useCase, tenant). This + * is the primary datastore read the app performs — Keploy captures the MySQL + * wire traffic as mocks. + */ +@Repository +public class RuleDao { + + private final JdbcTemplate jdbc; + + public RuleDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** Returns a single (use_case, tenant) group, or empty if none match. */ + public List rulesFor(String useCase, String tenant) { + List rules = jdbc.query( + "SELECT rule_id, constraint_expr AS constraints, rule_type FROM rules " + + "WHERE use_case = ? AND tenant = ? ORDER BY rule_id", + (rs, i) -> { + long ruleId = rs.getLong("rule_id"); + return new Rule(ruleId, rs.getString("constraints"), + actionsFor(ruleId), rs.getString("rule_type")); + }, + useCase, tenant); + + if (rules.isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(1); + out.add(new UseCaseRules(useCase, tenant, rules)); + return out; + } + + private List actionsFor(long ruleId) { + return jdbc.query( + "SELECT basic_action, action_details, seq AS sequence FROM rule_actions " + + "WHERE rule_id = ? ORDER BY seq", + (rs, i) -> new RuleAction(rs.getString("basic_action"), + rs.getString("action_details"), rs.getInt("sequence")), + ruleId); + } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/rules/RulesResource.java b/async-config-poll/src/main/java/com/example/asyncconfig/rules/RulesResource.java new file mode 100644 index 00000000..c1ff52bf --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/rules/RulesResource.java @@ -0,0 +1,70 @@ +package com.example.asyncconfig.rules; + +import java.util.List; + +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Rule-engine endpoint: GET /rules/{useCase}. Requires the X-Tenant-Id and + * X-Agent-Id headers and returns the ordered rules for the (useCase, tenant), + * read from MySQL, e.g.: + *
+ * [{"use_case":"ORDER_FLOW","tenant":"ACME","rules":[
+ *   {"rule_id":14,"constraints":"...","rule_type":"POST","actions":[
+ *     {"basic_action":"...","action_details":"{}","sequence":1}]}]}]
+ * 
+ */ +@Component +@Path("/rules") +public class RulesResource { + + private final RuleDao ruleDao; + private final ObjectMapper mapper; + private final long delayMs; + + @Autowired + public RulesResource(RuleDao ruleDao, ObjectMapper mapper, + @Value("${app.rules.delayMs:0}") long delayMs) { + this.ruleDao = ruleDao; + this.mapper = mapper; + this.delayMs = delayMs; + } + + @GET + @Path("/{useCase}") + @Produces("application/json;charset=utf-8") + public Response rules(@PathParam("useCase") String useCase, + @HeaderParam("X-Tenant-Id") String tenantId, + @HeaderParam("X-Agent-Id") String agentId) throws Exception { + if (isBlank(tenantId) || isBlank(agentId)) { + return Response.status(Response.Status.BAD_REQUEST) + .type("application/json") + .entity("{\"error\":\"X-Tenant-Id and X-Agent-Id headers are required\"}") + .build(); + } + if (delayMs > 0) { + try { + Thread.sleep(delayMs); // widen the request window for the fast-poller demo + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + List result = ruleDao.rulesFor(useCase, tenantId); + String json = mapper.writeValueAsString(result); + return Response.ok(json).build(); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} diff --git a/async-config-poll/src/main/java/com/example/asyncconfig/rules/UseCaseRules.java b/async-config-poll/src/main/java/com/example/asyncconfig/rules/UseCaseRules.java new file mode 100644 index 00000000..8f86e233 --- /dev/null +++ b/async-config-poll/src/main/java/com/example/asyncconfig/rules/UseCaseRules.java @@ -0,0 +1,23 @@ +package com.example.asyncconfig.rules; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** One (use_case, tenant) group with its ordered rules. */ +public class UseCaseRules { + @JsonProperty("use_case") + private String useCase; + private String tenant; + private List rules; + + public UseCaseRules(String useCase, String tenant, List rules) { + this.useCase = useCase; + this.tenant = tenant; + this.rules = rules; + } + + public String getUseCase() { return useCase; } + public String getTenant() { return tenant; } + public List getRules() { return rules; } +} diff --git a/async-config-poll/src/main/resources/application.yml b/async-config-poll/src/main/resources/application.yml new file mode 100644 index 00000000..6c12965c --- /dev/null +++ b/async-config-poll/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:mysql://127.0.0.1:3306/ruledb?useSSL=false + username: app + password: app + # driver auto-detected: com.mysql.jdbc.Driver (Connector/J 5.1.x, managed by SB 1.5). + # Keep the pool tiny with no validation queries so MySQL traffic is lean and + # deterministic — important for a clean Keploy record/replay. + tomcat: + initial-size: 1 + max-active: 2 + max-idle: 1 + min-idle: 1 + test-on-borrow: false + test-on-return: false + test-while-idle: false + +app: + config: + # The config service the app boot-fetches from and then watches. + baseUrl: http://127.0.0.1:9100 + # Background watch long-poll interval. Lower it (e.g. WATCH_INTERVAL_MS=150) + # to make a poll land in the middle of a testcase during replay. + watchIntervalMs: ${WATCH_INTERVAL_MS:700} + rules: + # Artificial delay on GET /rules/{useCase}; widen it (RULES_DELAY_MS) so a + # fast watch poll can land while a testcase is still executing. + delayMs: ${RULES_DELAY_MS:0} + +logging: + level: + root: INFO