From cb77f36930e5de60e9b939222abb2659fbea2427 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Fri, 1 May 2026 14:47:35 +0800 Subject: [PATCH] feat(api): support int64_as_string parameter for GET requests (#6568) Add an opt-in `int64_as_string` query parameter on TRON HTTP GET endpoints. When set, int64/uint64 protobuf fields in the response are serialized as quoted JSON strings to avoid precision loss in clients whose native number type cannot safely represent integers above 2^53 - 1 (e.g. JavaScript). Scope: GET only. POST is intentionally unsupported because reading the request body in a centralized location (RateLimiterServlet.service or a Filter) would consume request.getReader() and break downstream servlets that read the body themselves. Most TRON query endpoints support both GET and POST, so clients that need precision can use the GET form. POST- only write endpoints return Transaction proto whose int64 fields would break round-trip JsonFormat.merge if quoted, so they should not enable this flag in the first place. - JsonFormat: add INT64_AS_STRING ThreadLocal + setInt64AsString / clearInt64AsString / isInt64AsString helpers; split printFieldValue INT64/SINT64/SFIXED64 and UINT64/FIXED64 branches so they emit quoted strings only when the flag is set. - Util: add INT64_AS_STRING constant + getInt64AsString (URL query, mirrors getVisible). - RateLimiterServlet.service: set ThreadLocal from URL query on GET only; clear in finally so reused Tomcat threads do not leak state across requests. - GetBurnTrx / GetPendingSize / GetTransactionCountByBlockNum: emit quoted int64 in their hand-built JSON responses when isInt64AsString is true. - JsonFormatInt64AsStringTest: covers default behavior, int64 / uint64 quoting, non-int64 fields unaffected, nested / map / boundary values (2^53 +/- 1, Long.MAX/MIN, -1), state cleanup (normal close, after exception, explicit clear), thread isolation, thread-reuse anti-pollution. Backward compatibility: requests without int64_as_string=true produce byte-identical responses to develop -- the new code paths are gated entirely on the new flag. Closes #6568. --- .../core/services/http/GetBurnTrxServlet.java | 5 +- .../services/http/GetPendingSizeServlet.java | 5 +- .../core/services/http/GetRewardServlet.java | 5 +- .../GetTransactionCountByBlockNumServlet.java | 5 +- .../tron/core/services/http/JsonFormat.java | 58 +++- .../services/http/RateLimiterServlet.java | 10 + .../org/tron/core/services/http/Util.java | 15 + .../http/JsonFormatInt64AsStringTest.java | 264 ++++++++++++++++++ .../http/RateLimiterServletInt64Test.java | 164 +++++++++++ 9 files changed, 523 insertions(+), 8 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/services/http/JsonFormatInt64AsStringTest.java create mode 100644 framework/src/test/java/org/tron/core/services/http/RateLimiterServletInt64Test.java diff --git a/framework/src/main/java/org/tron/core/services/http/GetBurnTrxServlet.java b/framework/src/main/java/org/tron/core/services/http/GetBurnTrxServlet.java index e574affff6b..ea066a6e98c 100644 --- a/framework/src/main/java/org/tron/core/services/http/GetBurnTrxServlet.java +++ b/framework/src/main/java/org/tron/core/services/http/GetBurnTrxServlet.java @@ -19,7 +19,10 @@ public class GetBurnTrxServlet extends RateLimiterServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) { try { long value = manager.getDynamicPropertiesStore().getBurnTrxAmount(); - response.getWriter().println("{\"burnTrxAmount\": " + value + "}"); + String out = JsonFormat.isInt64AsString() + ? "{\"burnTrxAmount\": \"" + value + "\"}" + : "{\"burnTrxAmount\": " + value + "}"; + response.getWriter().println(out); } catch (Exception e) { logger.error("", e); try { diff --git a/framework/src/main/java/org/tron/core/services/http/GetPendingSizeServlet.java b/framework/src/main/java/org/tron/core/services/http/GetPendingSizeServlet.java index 7e1a5f71841..9788c926586 100644 --- a/framework/src/main/java/org/tron/core/services/http/GetPendingSizeServlet.java +++ b/framework/src/main/java/org/tron/core/services/http/GetPendingSizeServlet.java @@ -19,7 +19,10 @@ public class GetPendingSizeServlet extends RateLimiterServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) { try { long value = manager.getPendingSize(); - response.getWriter().println("{\"pendingSize\": " + value + "}"); + String out = JsonFormat.isInt64AsString() + ? "{\"pendingSize\": \"" + value + "\"}" + : "{\"pendingSize\": " + value + "}"; + response.getWriter().println(out); } catch (Exception e) { logger.error("", e); try { diff --git a/framework/src/main/java/org/tron/core/services/http/GetRewardServlet.java b/framework/src/main/java/org/tron/core/services/http/GetRewardServlet.java index c4d97f46c57..61b88d1160f 100644 --- a/framework/src/main/java/org/tron/core/services/http/GetRewardServlet.java +++ b/framework/src/main/java/org/tron/core/services/http/GetRewardServlet.java @@ -24,7 +24,10 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) { if (address != null) { value = manager.getMortgageService().queryReward(address); } - response.getWriter().println("{\"reward\": " + value + "}"); + String out = JsonFormat.isInt64AsString() + ? "{\"reward\": \"" + value + "\"}" + : "{\"reward\": " + value + "}"; + response.getWriter().println(out); } catch (DecoderException | IllegalArgumentException e) { try { response.getWriter() diff --git a/framework/src/main/java/org/tron/core/services/http/GetTransactionCountByBlockNumServlet.java b/framework/src/main/java/org/tron/core/services/http/GetTransactionCountByBlockNumServlet.java index e096df507d7..4532dde8200 100644 --- a/framework/src/main/java/org/tron/core/services/http/GetTransactionCountByBlockNumServlet.java +++ b/framework/src/main/java/org/tron/core/services/http/GetTransactionCountByBlockNumServlet.java @@ -40,6 +40,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) private void fillResponse(long num, HttpServletResponse response) throws IOException { long count = wallet.getTransactionCountByBlockNum(num); - response.getWriter().println("{\"count\": " + count + "}"); + String out = JsonFormat.isInt64AsString() + ? "{\"count\": \"" + count + "\"}" + : "{\"count\": " + count + "}"; + response.getWriter().println(out); } } \ No newline at end of file diff --git a/framework/src/main/java/org/tron/core/services/http/JsonFormat.java b/framework/src/main/java/org/tron/core/services/http/JsonFormat.java index 96dedb1e20c..8a8c66fb371 100644 --- a/framework/src/main/java/org/tron/core/services/http/JsonFormat.java +++ b/framework/src/main/java/org/tron/core/services/http/JsonFormat.java @@ -90,6 +90,41 @@ public class JsonFormat { BalanceContract.TransactionBalanceTrace.class ); + /** + * Thread-local flag controlling whether int64/uint64 fields are serialized as JSON strings. + * Set via {@link #setInt64AsString(boolean)} early in request handling and cleared via + * {@link #clearInt64AsString()} in a finally block. Centralized in + * {@code RateLimiterServlet.service} for GET requests. Does not support nested scopes. + */ + private static final ThreadLocal INT64_AS_STRING = + ThreadLocal.withInitial(() -> false); + + /** + * Set whether int64/uint64 protobuf fields are serialized as quoted JSON strings to avoid + * precision loss in clients whose native number type cannot safely represent integers above + * 2^53 - 1 (e.g. JavaScript). Must be paired with {@link #clearInt64AsString()} in a + * finally block. + */ + public static void setInt64AsString(boolean enabled) { + INT64_AS_STRING.set(enabled); + } + + /** + * Clear the int64-as-string thread-local. Always call from a finally block to avoid + * polluting subsequent requests on the same (reused) thread. + */ + public static void clearInt64AsString() { + INT64_AS_STRING.remove(); + } + + /** + * Whether the current thread is in int64-as-string mode. Used by servlets that build + * JSON literals manually (i.e. do not go through {@link #printToString}). + */ + public static boolean isInt64AsString() { + return INT64_AS_STRING.get(); + } + /** * Outputs a textual representation of the Protocol Message supplied into the parameter output. * (This representation is the new version of the classic "ProtocolPrinter" output from the @@ -340,11 +375,8 @@ private static void printFieldValue(FieldDescriptor field, Object value, throws IOException { switch (field.getType()) { case INT32: - case INT64: case SINT32: - case SINT64: case SFIXED32: - case SFIXED64: case FLOAT: case DOUBLE: case BOOL: @@ -352,6 +384,18 @@ private static void printFieldValue(FieldDescriptor field, Object value, generator.print(value.toString()); break; + case INT64: + case SINT64: + case SFIXED64: + if (INT64_AS_STRING.get()) { + generator.print("\""); + generator.print(value.toString()); + generator.print("\""); + } else { + generator.print(value.toString()); + } + break; + case UINT32: case FIXED32: generator.print(unsignedToString((Integer) value)); @@ -359,7 +403,13 @@ private static void printFieldValue(FieldDescriptor field, Object value, case UINT64: case FIXED64: - generator.print(unsignedToString((Long) value)); + if (INT64_AS_STRING.get()) { + generator.print("\""); + generator.print(unsignedToString((Long) value)); + generator.print("\""); + } else { + generator.print(unsignedToString((Long) value)); + } break; case STRING: diff --git a/framework/src/main/java/org/tron/core/services/http/RateLimiterServlet.java b/framework/src/main/java/org/tron/core/services/http/RateLimiterServlet.java index 7a66aed34f6..0c97f715498 100644 --- a/framework/src/main/java/org/tron/core/services/http/RateLimiterServlet.java +++ b/framework/src/main/java/org/tron/core/services/http/RateLimiterServlet.java @@ -102,6 +102,12 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) String contextPath = req.getContextPath(); String url = Strings.isNullOrEmpty(req.getServletPath()) ? MetricLabels.UNDEFINED : contextPath + req.getServletPath(); + // int64_as_string is honored only on GET requests (URL query). POST is intentionally + // unsupported because reading the body here would consume request.getReader() and + // break downstream servlets that read it themselves. + if ("GET".equalsIgnoreCase(req.getMethod())) { + JsonFormat.setInt64AsString(Util.getInt64AsString(req)); + } try { resp.setContentType("application/json; charset=utf-8"); @@ -119,6 +125,10 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) } catch (Exception unexpected) { logger.error("Http Api {}, Method:{}. Error:", url, req.getMethod(), unexpected); } finally { + // CRITICAL: this clear pairs with the setInt64AsString call above. Removing it + // will leak int64_as_string state across requests on reused Tomcat threads, + // producing intermittent quoted/unquoted output that is very hard to debug. + JsonFormat.clearInt64AsString(); if (rateLimiter instanceof IPreemptibleRateLimiter && acquireResource) { ((IPreemptibleRateLimiter) rateLimiter).release(); } diff --git a/framework/src/main/java/org/tron/core/services/http/Util.java b/framework/src/main/java/org/tron/core/services/http/Util.java index 2b6b929d8a0..e7c7703cc81 100644 --- a/framework/src/main/java/org/tron/core/services/http/Util.java +++ b/framework/src/main/java/org/tron/core/services/http/Util.java @@ -66,6 +66,7 @@ public class Util { public static final String PERMISSION_ID = "Permission_id"; public static final String VISIBLE = "visible"; + public static final String INT64_AS_STRING = "int64_as_string"; public static final String TRANSACTION = "transaction"; public static final String TRANSACTION_EXTENSION = "transactionExtension"; public static final String VALUE = "value"; @@ -346,6 +347,20 @@ public static boolean existVisible(final HttpServletRequest request) { return Objects.nonNull(request.getParameter(VISIBLE)); } + /** + * Read int64_as_string from URL query parameter. Mirrors + * {@link #getVisible(HttpServletRequest)}. The flag is honored only on GET requests + * (read by {@link RateLimiterServlet#service}); POST requests do not support it + * because that would require caching the request body to allow re-reading by + * downstream servlets. + */ + public static boolean getInt64AsString(final HttpServletRequest request) { + if (StringUtil.isNotBlank(request.getParameter(INT64_AS_STRING))) { + return Boolean.parseBoolean(request.getParameter(INT64_AS_STRING)); + } + return false; + } + public static boolean getVisiblePost(final String input) { boolean visible = false; if (StringUtil.isNotBlank(input)) { diff --git a/framework/src/test/java/org/tron/core/services/http/JsonFormatInt64AsStringTest.java b/framework/src/test/java/org/tron/core/services/http/JsonFormatInt64AsStringTest.java new file mode 100644 index 00000000000..77ea73999d1 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/http/JsonFormatInt64AsStringTest.java @@ -0,0 +1,264 @@ +package org.tron.core.services.http; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import com.google.protobuf.UInt64Value; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Test; +import org.tron.protos.Protocol; + +/** + * Tests for {@link JsonFormat#setInt64AsString(boolean)} / + * {@link JsonFormat#clearInt64AsString()} / {@link JsonFormat#isInt64AsString()}. + * + *

Tron protos do not define uint64/fixed64 fields directly; all 64-bit values use int64. + * The uint64 branch is exercised using {@link com.google.protobuf.UInt64Value}, a protobuf + * well-known wrapper with a single {@code uint64 value} field. + */ +public class JsonFormatInt64AsStringTest { + + /** Defensive cleanup in case a test leaves the ThreadLocal dirty. */ + @After + public void clearState() { + JsonFormat.clearInt64AsString(); + } + + @Test + public void defaultBehaviorUnchangedWhenUnset() { + Protocol.Account account = Protocol.Account.newBuilder() + .setBalance(123456789012345L) + .build(); + String out = JsonFormat.printToString(account, true); + assertTrue("expected unquoted balance, got: " + out, + out.contains("\"balance\":123456789012345") + || out.contains("\"balance\": 123456789012345")); + assertFalse("balance should not be quoted by default, got: " + out, + out.contains("\"balance\":\"123456789012345\"") + || out.contains("\"balance\": \"123456789012345\"")); + } + + @Test + public void int64FieldQuotedWhenSet() { + Protocol.Account account = Protocol.Account.newBuilder() + .setBalance(123456789012345L) + .build(); + JsonFormat.setInt64AsString(true); + try { + String out = JsonFormat.printToString(account, true); + assertTrue("expected quoted balance, got: " + out, + out.contains("\"123456789012345\"")); + } finally { + JsonFormat.clearInt64AsString(); + } + } + + @Test + public void uint64FieldQuotedWhenSet() { + UInt64Value v = UInt64Value.of(9007199254740993L); // 2^53 + 1 + JsonFormat.setInt64AsString(true); + try { + String out = JsonFormat.printToString(v, true); + assertTrue("expected quoted uint64 value, got: " + out, + out.contains("\"9007199254740993\"")); + } finally { + JsonFormat.clearInt64AsString(); + } + } + + @Test + public void uint64DefaultUnquoted() { + UInt64Value v = UInt64Value.of(9007199254740993L); + String out = JsonFormat.printToString(v, true); + assertTrue("expected unquoted uint64 value, got: " + out, + out.contains("9007199254740993")); + assertFalse("uint64 should not be quoted by default, got: " + out, + out.contains("\"9007199254740993\"")); + } + + @Test + public void stringBytesEnumNotAffected() { + // Note: proto3 does not serialize default-valued fields, so enum/bytes fields are + // set to non-default values to verify they appear in the output. + Protocol.Account account = Protocol.Account.newBuilder() + .setAccountName(ByteString.copyFromUtf8("alice")) + .setType(Protocol.AccountType.AssetIssue) // non-default enum value + .setBalance(1L) + .build(); + JsonFormat.setInt64AsString(true); + try { + String out = JsonFormat.printToString(account, true); + // balance int64 should be quoted + assertTrue("balance should be quoted, got: " + out, out.contains("\"1\"")); + // enum type serialized by name (not a number), not affected by int64_as_string + assertTrue("enum type should appear as name, got: " + out, + out.contains("AssetIssue")); + // bytes account_name should still serialize normally + assertTrue("account_name should appear, got: " + out, out.contains("account_name")); + } finally { + JsonFormat.clearInt64AsString(); + } + } + + @Test + public void nestedInt64FieldsQuoted() { + Protocol.Block block = Protocol.Block.newBuilder() + .setBlockHeader(Protocol.BlockHeader.newBuilder() + .setRawData(Protocol.BlockHeader.raw.newBuilder() + .setNumber(9007199254740993L) // 2^53 + 1 + .setTimestamp(1700000000000L) + .build()) + .build()) + .build(); + JsonFormat.setInt64AsString(true); + try { + String out = JsonFormat.printToString(block, true); + assertTrue("nested number should be quoted, got: " + out, + out.contains("\"9007199254740993\"")); + assertTrue("nested timestamp should be quoted, got: " + out, + out.contains("\"1700000000000\"")); + } finally { + JsonFormat.clearInt64AsString(); + } + } + + @Test + public void mapStringInt64ValuesQuoted() { + Protocol.Account account = Protocol.Account.newBuilder() + .putAsset("USDT", 123456789012345L) + .build(); + JsonFormat.setInt64AsString(true); + try { + String out = JsonFormat.printToString(account, true); + assertTrue("map value should be quoted, got: " + out, + out.contains("\"123456789012345\"")); + } finally { + JsonFormat.clearInt64AsString(); + } + } + + @Test + public void boundaryValuesAllQuoted() { + // Note: proto3 does not serialize a field whose value equals its type default (0 for int64), + // so 0L is covered separately via defaultBehaviorUnchangedWhenUnset / uint64DefaultUnquoted + // (both use non-default values) and does not need an explicit quoted-output test. + long[] values = { + (1L << 53) - 1, // max safe JS integer + 1L << 53, // boundary + (1L << 53) + 1, // first unsafe + Long.MAX_VALUE, + Long.MIN_VALUE, + -1L + }; + for (long v : values) { + Protocol.Account account = Protocol.Account.newBuilder().setBalance(v).build(); + JsonFormat.setInt64AsString(true); + try { + String out = JsonFormat.printToString(account, true); + assertTrue("value=" + v + " expected quoted, got: " + out, + out.contains("\"" + v + "\"")); + } finally { + JsonFormat.clearInt64AsString(); + } + } + } + + @Test + public void clearResetsState() { + Protocol.Account account = Protocol.Account.newBuilder().setBalance(1L).build(); + JsonFormat.setInt64AsString(true); + JsonFormat.clearInt64AsString(); + String out = JsonFormat.printToString(account, true); + assertFalse("state should be cleared, got: " + out, out.contains("\"1\"")); + } + + @Test + public void clearInFinallySurvivesException() { + Protocol.Account account = Protocol.Account.newBuilder().setBalance(1L).build(); + JsonFormat.setInt64AsString(true); + try { + throw new RuntimeException("boom"); + } catch (RuntimeException expected) { + // expected + } finally { + JsonFormat.clearInt64AsString(); + } + String out = JsonFormat.printToString(account, true); + assertFalse("state leaked after exception, got: " + out, out.contains("\"1\"")); + } + + @Test + public void isInt64AsStringReflectsCurrentState() { + assertFalse(JsonFormat.isInt64AsString()); + JsonFormat.setInt64AsString(true); + try { + assertTrue(JsonFormat.isInt64AsString()); + } finally { + JsonFormat.clearInt64AsString(); + } + assertFalse(JsonFormat.isInt64AsString()); + } + + @Test + public void threadIsolation() throws Exception { + final Protocol.Account account = Protocol.Account.newBuilder().setBalance(1L).build(); + final CountDownLatch barrier = new CountDownLatch(2); + ExecutorService ex = Executors.newFixedThreadPool(2); + try { + Future trueThread = ex.submit(() -> { + JsonFormat.setInt64AsString(true); + try { + barrier.countDown(); + barrier.await(); + return JsonFormat.printToString(account, true); + } finally { + JsonFormat.clearInt64AsString(); + } + }); + Future falseThread = ex.submit(() -> { + barrier.countDown(); + barrier.await(); + return JsonFormat.printToString(account, true); + }); + String withSet = trueThread.get(5, TimeUnit.SECONDS); + String noSet = falseThread.get(5, TimeUnit.SECONDS); + assertTrue("trueThread should see quoted: " + withSet, + withSet.contains("\"1\"")); + assertFalse("falseThread should see unquoted: " + noSet, + noSet.contains("\"1\"")); + } finally { + ex.shutdownNow(); + } + } + + @Test + public void noPollutionOnThreadReuse() throws Exception { + final Protocol.Account account = Protocol.Account.newBuilder().setBalance(1L).build(); + ExecutorService single = Executors.newSingleThreadExecutor(); + try { + Future firstRun = single.submit(() -> { + JsonFormat.setInt64AsString(true); + try { + return JsonFormat.printToString(account, true); + } finally { + JsonFormat.clearInt64AsString(); + } + }); + assertTrue(firstRun.get(5, TimeUnit.SECONDS).contains("\"1\"")); + + // Reuse the same thread; without a new set, state must be cleared. + Future secondRun = single.submit(() -> JsonFormat.printToString(account, true)); + String second = secondRun.get(5, TimeUnit.SECONDS); + assertFalse("thread reuse leaked quoted state: " + second, + second.contains("\"1\"")); + } finally { + single.shutdownNow(); + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/http/RateLimiterServletInt64Test.java b/framework/src/test/java/org/tron/core/services/http/RateLimiterServletInt64Test.java new file mode 100644 index 00000000000..882c5f99833 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/http/RateLimiterServletInt64Test.java @@ -0,0 +1,164 @@ +package org.tron.core.services.http; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.UnsupportedEncodingException; +import javax.annotation.Resource; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.core.config.args.Args; + +/** + * End-to-end integration tests for {@link RateLimiterServlet#service} wiring of the + * {@code int64_as_string} flag. The single-class {@link JsonFormatInt64AsStringTest} verifies + * the {@code JsonFormat} ThreadLocal mechanism in isolation; this test verifies the full + * request-handling chain: URL query --> {@code service()} --> ThreadLocal --> output, and + * the {@code finally} clear that prevents state leakage across reused threads. + * + *

Pins four contracts: + *

    + *
  1. GET with {@code ?int64_as_string=true} produces quoted int64 fields.
  2. + *
  3. GET without the flag produces unquoted int64 fields (regression baseline).
  4. + *
  5. POST never honors the flag, regardless of source -- GET-only is the documented + * contract under issue #6568.
  6. + *
  7. {@code service()}'s {@code finally} block clears the ThreadLocal so reused Tomcat + * threads do not leak state between requests.
  8. + *
+ * + *

Uses {@link GetNowBlockServlet} as the fixture servlet because its response goes through + * {@code JsonFormat.printToString}, which is what the ThreadLocal actually controls. + */ +public class RateLimiterServletInt64Test extends BaseTest { + + @Resource(name = "getNowBlockServlet") + private GetNowBlockServlet servlet; + + @Resource(name = "getBurnTrxServlet") + private GetBurnTrxServlet handBuiltServlet; + + static { + Args.setParam( + new String[]{ + "--output-directory", dbPath(), + }, TestConstants.TEST_CONF + ); + } + + @Before + public void clearBefore() { + JsonFormat.clearInt64AsString(); + } + + @After + public void clearAfter() { + JsonFormat.clearInt64AsString(); + } + + /** Contract 1: GET with int64_as_string=true on URL query produces quoted int64 fields. */ + @Test + public void getWithUrlFlagQuotesInt64() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.addParameter("int64_as_string", "true"); + MockHttpServletResponse response = new MockHttpServletResponse(); + servlet.service(request, response); + String body = readBody(response); + if (body.contains("\"timestamp\"")) { + assertTrue("timestamp should be quoted when int64_as_string=true, got: " + body, + body.matches("(?s).*\"timestamp\"\\s*:\\s*\"\\d+\".*")); + } + } + + /** Contract 2: GET without flag produces unquoted int64 fields (default behavior). */ + @Test + public void getWithoutFlagKeepsUnquoted() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + servlet.service(request, response); + String body = readBody(response); + if (body.contains("\"timestamp\"")) { + assertTrue("timestamp should be unquoted when no flag, got: " + body, + body.matches("(?s).*\"timestamp\"\\s*:\\s*\\d+.*")); + } + } + + /** + * Contract 3: POST never honors int64_as_string, regardless of where the flag is placed. + * Pins the GET-only design contract for issue #6568. Any future PR that tries to extend + * support to POST will fail this test, forcing an explicit design review. + */ + @Test + public void postWithUrlFlagIgnored() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.addParameter("int64_as_string", "true"); + MockHttpServletResponse response = new MockHttpServletResponse(); + servlet.service(request, response); + String body = readBody(response); + if (body.contains("\"timestamp\"")) { + assertFalse("POST URL flag must be ignored under GET-only design, got: " + body, + body.matches("(?s).*\"timestamp\"\\s*:\\s*\"\\d+\".*")); + } + } + + /** + * Contract 4 (CRITICAL): service() must clear the ThreadLocal in finally. Without this + * clear, reused Tomcat threads leak the flag across requests, producing intermittent + * quoted/unquoted output that is extremely hard to debug in production. + */ + @Test + public void serviceClearsThreadLocalInFinally() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.addParameter("int64_as_string", "true"); + servlet.service(request, new MockHttpServletResponse()); + assertFalse( + "RateLimiterServlet.service must clear int64_as_string ThreadLocal in its finally " + + "block. Removing this clear will leak state across requests on reused threads.", + JsonFormat.isInt64AsString()); + } + + /** + * Contract 5: hand-built JSON servlets (the ones that emit JSON literals manually instead + * of going through {@link JsonFormat#printToString}) honor the flag. The previous tests use + * {@link GetNowBlockServlet} which goes through {@code printToString}; this test uses + * {@link GetBurnTrxServlet} as a representative of the four ternary-style servlets + * (GetBurnTrx / GetPendingSize / GetTransactionCountByBlockNum / GetReward) to lock down + * their {@code isInt64AsString() ? quoted : unquoted} branch -- so a future refactor that + * inverts the ternary or breaks the quote placement fails visibly here. + */ + @Test + public void handBuiltJsonServletQuotesInt64WhenFlagSet() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.addParameter("int64_as_string", "true"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handBuiltServlet.service(request, response); + String body = readBody(response); + assertTrue("burnTrxAmount should be quoted when int64_as_string=true, got: " + body, + body.matches("(?s).*\"burnTrxAmount\"\\s*:\\s*\"\\d+\".*")); + } + + /** Contract 6: hand-built JSON servlets default to unquoted output (regression baseline). */ + @Test + public void handBuiltJsonServletKeepsUnquotedByDefault() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handBuiltServlet.service(request, response); + String body = readBody(response); + assertTrue("burnTrxAmount should be unquoted by default, got: " + body, + body.matches("(?s).*\"burnTrxAmount\"\\s*:\\s*\\d+.*")); + } + + private String readBody(MockHttpServletResponse response) throws UnsupportedEncodingException { + return response.getContentAsString(); + } +}