From 98d174a7277b7141ac45e7f409ab688c24065791 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Thu, 15 Oct 2026 19:34:59 +0000 Subject: [PATCH 1/2] Port the static interactor endpoints the site calls to node The entity popup's list (GET /interactors/static/molecule/{acc}/details, paged or not), the interactor tables' rows (molecule/enhanced/{acc}/ details), the interactor page's rows with their Reactome entities (molecule/{acc}/withEntities), and the POST the diagram sends for every protein in it (molecules/details) now come from node. Each runs Java's Cypher as written -- inside a subquery where a result is reshaped -- because the order of equal scores is the plan's: in TP53's 249 interactors, all 39 groups of ties come in descending dbId, from no ORDER BY. Every interaction has two interactors (checked over all 78,872), so an Interactor is the other one. Written as the content service writes them, all measured: - NON_EMPTY: a missing or empty alias, list or name is left out; an entry with no interactors keeps its count and loses its list. - a whole score is 1.0; an isoform is named by its variant; the alias is the first gene name of a ReferenceSequence only. - page and pageSize are Integers defaulting to -1, paging only when neither is: a blank one is the service's NPE (Java's generic 500), a negative SKIP or LIMIT Neo4j's message as Spring wraps it. - the POST: 415, paging, 422, then the body split and trimmed into a HashSet whose order is the answer's, one query at a time; none found is a 404 naming the body as sent. - withEntities sorts each row's entities as String.CASE_INSENSITIVE_ORDER does: per unit, the lower case of the upper case, by the JVM's own Character mappings (case-snapshot.json, from snapshot-case.mjs) -- JavaScript's are the full mappings of a newer Unicode, and differ on 168 characters. 80,000 random lists against the JDK: identical. Against Java on a local node: 54 requests over twelve accessions, the three GET forms, paging, every error and the POSTs -- identical bar a path no node route takes (details.pdf, Spring's 404, which nginx keeps on Java). PSICQUIC, uploads, tokens and summaries stay with Java. Co-Authored-By: Claude Opus 5.5 --- deploy/nginx/common/routes-core.conf | 16 + .../src/scripts/proxy-order.spec.ts | 20 + proxy.conf.js | 5 + tools/content-node/case-snapshot.json | 292 +++++++++++++++ tools/content-node/interactors.spec.mjs | 230 ++++++++++++ tools/content-node/service.mjs | 342 +++++++++++++++++- tools/content-node/snapshot-case.mjs | 55 +++ 7 files changed, 957 insertions(+), 3 deletions(-) create mode 100644 tools/content-node/case-snapshot.json create mode 100644 tools/content-node/interactors.spec.mjs create mode 100644 tools/content-node/snapshot-case.mjs diff --git a/deploy/nginx/common/routes-core.conf b/deploy/nginx/common/routes-core.conf index e3b53f78..b1bb6ad7 100644 --- a/deploy/nginx/common/routes-core.conf +++ b/deploy/nginx/common/routes-core.conf @@ -117,6 +117,22 @@ location ~ ^/ContentService/data/(orthology/[^/]+/species|orthologies/ids/specie proxy_pass http://content_node; } +# An accession's interactors from IntAct: the entity popup's list, the +# interactor tables' enhanced rows, the interactor page's rows with their +# Reactome entities, and the POST the diagram sends for every protein in it. +# Java's Cypher as written, so equal scores keep Java's order; byte-identical +# over 54 requests, errors and paging included. The other interactor +# endpoints -- PSICQUIC, uploads, tokens, summaries -- stay with Java. +location ~ ^/ContentService/interactors/static/molecule/((enhanced/)?[^/]+/details|[^/]+/withEntities)$ { + include /etc/nginx/common/upstream-proxy.conf; + proxy_pass http://content_node; +} + +location = /ContentService/interactors/static/molecules/details { + include /etc/nginx/common/upstream-proxy.conf; + proxy_pass http://content_node; +} + # A person, by dbId or ORCID, and the publications they authored (by dbId, # ORCID or e-mail address), written by the domain serialiser in each of Java's # views. Java matches the person by a property no index serves -- 1.1s by dbId; diff --git a/projects/website-angular/src/scripts/proxy-order.spec.ts b/projects/website-angular/src/scripts/proxy-order.spec.ts index bbb3c4ef..bf471c37 100644 --- a/projects/website-angular/src/scripts/proxy-order.spec.ts +++ b/projects/website-angular/src/scripts/proxy-order.spec.ts @@ -191,6 +191,19 @@ describe('the dev server proxy', () => { }); } + for (const url of [ + '/ContentService/interactors/static/molecule/P04637/details', + '/ContentService/interactors/static/molecule/P04637/details?page=1&pageSize=3', + '/ContentService/interactors/static/molecule/enhanced/Q9BXM7-1/details', + '/ContentService/interactors/static/molecule/P04637/withEntities', + // The diagram's POST, for every protein in it. + '/ContentService/interactors/static/molecules/details', + ]) { + it(`sends ${url} to the content service`, async () => { + expect(await targetOf(url)).toBe(contentNode); + }); + } + for (const other of [ // Other attributes of the same Java endpoint are not ported. '/ContentService/data/query/70486/displayName', @@ -207,6 +220,13 @@ describe('the dev server proxy', () => { // Java's POST /query/ids (a batch of objects), which node does not serve. '/ContentService/data/query/ids', '/ContentService/data/query/abridged/70486', + // The interactor endpoints node does not serve. + '/ContentService/interactors/static/molecule/P04637/summary', + '/ContentService/interactors/static/molecules/summary', + '/ContentService/interactors/static/molecule/P04637/pathways', + '/ContentService/interactors/psicquic/molecules/IntAct/details', + '/ContentService/interactors/upload/tuple/content', + '/ContentService/interactors/static/molecule/P04637/details.pdf', // Spring's own 404s: no species, or more after it. '/ContentService/data/orthology/R-HSA-69620/species/', '/ContentService/data/orthologies/ids/species/48892/x', diff --git a/proxy.conf.js b/proxy.conf.js index 7b231143..a1e49efa 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -113,6 +113,11 @@ module.exports = { // up to twenty (POST), for the species selector. '^/ContentService/data/(orthology/[^/?]+/species|orthologies/ids/species)/[^/?]+(\\?|$)': contentNode, + // An accession's interactors (details, enhanced details, with entities), and + // the diagram's POST for many. PSICQUIC, uploads, tokens and summaries stay + // with Java. + '^/ContentService/interactors/static/(molecule/((enhanced/)?[^/?]+/details|[^/?]+/withEntities)|molecules/details)(\\?|$)': + contentNode, // A person, and the publications they authored. Not the other attributes // under /person/{id}/, which Java serves. '^/ContentService/data/person/[^/?]+(/publications)?(\\?|$)': contentNode, diff --git a/tools/content-node/case-snapshot.json b/tools/content-node/case-snapshot.json new file mode 100644 index 00000000..382b13e5 --- /dev/null +++ b/tools/content-node/case-snapshot.json @@ -0,0 +1,292 @@ +{ + "upper": [ + 97, 65, 98, 66, 99, 67, 100, 68, 101, 69, 102, 70, 103, 71, 104, 72, 105, 73, 106, 74, 107, 75, + 108, 76, 109, 77, 110, 78, 111, 79, 112, 80, 113, 81, 114, 82, 115, 83, 116, 84, 117, 85, 118, + 86, 119, 87, 120, 88, 121, 89, 122, 90, 181, 924, 224, 192, 225, 193, 226, 194, 227, 195, 228, + 196, 229, 197, 230, 198, 231, 199, 232, 200, 233, 201, 234, 202, 235, 203, 236, 204, 237, 205, + 238, 206, 239, 207, 240, 208, 241, 209, 242, 210, 243, 211, 244, 212, 245, 213, 246, 214, 248, + 216, 249, 217, 250, 218, 251, 219, 252, 220, 253, 221, 254, 222, 255, 376, 257, 256, 259, 258, + 261, 260, 263, 262, 265, 264, 267, 266, 269, 268, 271, 270, 273, 272, 275, 274, 277, 276, 279, + 278, 281, 280, 283, 282, 285, 284, 287, 286, 289, 288, 291, 290, 293, 292, 295, 294, 297, 296, + 299, 298, 301, 300, 303, 302, 305, 73, 307, 306, 309, 308, 311, 310, 314, 313, 316, 315, 318, + 317, 320, 319, 322, 321, 324, 323, 326, 325, 328, 327, 331, 330, 333, 332, 335, 334, 337, 336, + 339, 338, 341, 340, 343, 342, 345, 344, 347, 346, 349, 348, 351, 350, 353, 352, 355, 354, 357, + 356, 359, 358, 361, 360, 363, 362, 365, 364, 367, 366, 369, 368, 371, 370, 373, 372, 375, 374, + 378, 377, 380, 379, 382, 381, 383, 83, 384, 579, 387, 386, 389, 388, 392, 391, 396, 395, 402, + 401, 405, 502, 409, 408, 410, 573, 414, 544, 417, 416, 419, 418, 421, 420, 424, 423, 429, 428, + 432, 431, 436, 435, 438, 437, 441, 440, 445, 444, 447, 503, 453, 452, 454, 452, 456, 455, 457, + 455, 459, 458, 460, 458, 462, 461, 464, 463, 466, 465, 468, 467, 470, 469, 472, 471, 474, 473, + 476, 475, 477, 398, 479, 478, 481, 480, 483, 482, 485, 484, 487, 486, 489, 488, 491, 490, 493, + 492, 495, 494, 498, 497, 499, 497, 501, 500, 505, 504, 507, 506, 509, 508, 511, 510, 513, 512, + 515, 514, 517, 516, 519, 518, 521, 520, 523, 522, 525, 524, 527, 526, 529, 528, 531, 530, 533, + 532, 535, 534, 537, 536, 539, 538, 541, 540, 543, 542, 547, 546, 549, 548, 551, 550, 553, 552, + 555, 554, 557, 556, 559, 558, 561, 560, 563, 562, 572, 571, 575, 11390, 576, 11391, 578, 577, + 583, 582, 585, 584, 587, 586, 589, 588, 591, 590, 592, 11375, 593, 11373, 594, 11376, 595, 385, + 596, 390, 598, 393, 599, 394, 601, 399, 603, 400, 604, 42923, 608, 403, 609, 42924, 611, 404, + 613, 42893, 614, 42922, 616, 407, 617, 406, 618, 42926, 619, 11362, 620, 42925, 623, 412, 625, + 11374, 626, 413, 629, 415, 637, 11364, 640, 422, 643, 425, 647, 42929, 648, 430, 649, 580, 650, + 433, 651, 434, 652, 581, 658, 439, 669, 42930, 670, 42928, 837, 921, 881, 880, 883, 882, 887, + 886, 891, 1021, 892, 1022, 893, 1023, 940, 902, 941, 904, 942, 905, 943, 906, 945, 913, 946, + 914, 947, 915, 948, 916, 949, 917, 950, 918, 951, 919, 952, 920, 953, 921, 954, 922, 955, 923, + 956, 924, 957, 925, 958, 926, 959, 927, 960, 928, 961, 929, 962, 931, 963, 931, 964, 932, 965, + 933, 966, 934, 967, 935, 968, 936, 969, 937, 970, 938, 971, 939, 972, 908, 973, 910, 974, 911, + 976, 914, 977, 920, 981, 934, 982, 928, 983, 975, 985, 984, 987, 986, 989, 988, 991, 990, 993, + 992, 995, 994, 997, 996, 999, 998, 1001, 1000, 1003, 1002, 1005, 1004, 1007, 1006, 1008, 922, + 1009, 929, 1010, 1017, 1011, 895, 1013, 917, 1016, 1015, 1019, 1018, 1072, 1040, 1073, 1041, + 1074, 1042, 1075, 1043, 1076, 1044, 1077, 1045, 1078, 1046, 1079, 1047, 1080, 1048, 1081, 1049, + 1082, 1050, 1083, 1051, 1084, 1052, 1085, 1053, 1086, 1054, 1087, 1055, 1088, 1056, 1089, 1057, + 1090, 1058, 1091, 1059, 1092, 1060, 1093, 1061, 1094, 1062, 1095, 1063, 1096, 1064, 1097, 1065, + 1098, 1066, 1099, 1067, 1100, 1068, 1101, 1069, 1102, 1070, 1103, 1071, 1104, 1024, 1105, 1025, + 1106, 1026, 1107, 1027, 1108, 1028, 1109, 1029, 1110, 1030, 1111, 1031, 1112, 1032, 1113, 1033, + 1114, 1034, 1115, 1035, 1116, 1036, 1117, 1037, 1118, 1038, 1119, 1039, 1121, 1120, 1123, 1122, + 1125, 1124, 1127, 1126, 1129, 1128, 1131, 1130, 1133, 1132, 1135, 1134, 1137, 1136, 1139, 1138, + 1141, 1140, 1143, 1142, 1145, 1144, 1147, 1146, 1149, 1148, 1151, 1150, 1153, 1152, 1163, 1162, + 1165, 1164, 1167, 1166, 1169, 1168, 1171, 1170, 1173, 1172, 1175, 1174, 1177, 1176, 1179, 1178, + 1181, 1180, 1183, 1182, 1185, 1184, 1187, 1186, 1189, 1188, 1191, 1190, 1193, 1192, 1195, 1194, + 1197, 1196, 1199, 1198, 1201, 1200, 1203, 1202, 1205, 1204, 1207, 1206, 1209, 1208, 1211, 1210, + 1213, 1212, 1215, 1214, 1218, 1217, 1220, 1219, 1222, 1221, 1224, 1223, 1226, 1225, 1228, 1227, + 1230, 1229, 1231, 1216, 1233, 1232, 1235, 1234, 1237, 1236, 1239, 1238, 1241, 1240, 1243, 1242, + 1245, 1244, 1247, 1246, 1249, 1248, 1251, 1250, 1253, 1252, 1255, 1254, 1257, 1256, 1259, 1258, + 1261, 1260, 1263, 1262, 1265, 1264, 1267, 1266, 1269, 1268, 1271, 1270, 1273, 1272, 1275, 1274, + 1277, 1276, 1279, 1278, 1281, 1280, 1283, 1282, 1285, 1284, 1287, 1286, 1289, 1288, 1291, 1290, + 1293, 1292, 1295, 1294, 1297, 1296, 1299, 1298, 1301, 1300, 1303, 1302, 1305, 1304, 1307, 1306, + 1309, 1308, 1311, 1310, 1313, 1312, 1315, 1314, 1317, 1316, 1319, 1318, 1321, 1320, 1323, 1322, + 1325, 1324, 1327, 1326, 1377, 1329, 1378, 1330, 1379, 1331, 1380, 1332, 1381, 1333, 1382, 1334, + 1383, 1335, 1384, 1336, 1385, 1337, 1386, 1338, 1387, 1339, 1388, 1340, 1389, 1341, 1390, 1342, + 1391, 1343, 1392, 1344, 1393, 1345, 1394, 1346, 1395, 1347, 1396, 1348, 1397, 1349, 1398, 1350, + 1399, 1351, 1400, 1352, 1401, 1353, 1402, 1354, 1403, 1355, 1404, 1356, 1405, 1357, 1406, 1358, + 1407, 1359, 1408, 1360, 1409, 1361, 1410, 1362, 1411, 1363, 1412, 1364, 1413, 1365, 1414, 1366, + 5112, 5104, 5113, 5105, 5114, 5106, 5115, 5107, 5116, 5108, 5117, 5109, 7296, 1042, 7297, 1044, + 7298, 1054, 7299, 1057, 7300, 1058, 7301, 1058, 7302, 1066, 7303, 1122, 7304, 42570, 7545, + 42877, 7549, 11363, 7681, 7680, 7683, 7682, 7685, 7684, 7687, 7686, 7689, 7688, 7691, 7690, + 7693, 7692, 7695, 7694, 7697, 7696, 7699, 7698, 7701, 7700, 7703, 7702, 7705, 7704, 7707, 7706, + 7709, 7708, 7711, 7710, 7713, 7712, 7715, 7714, 7717, 7716, 7719, 7718, 7721, 7720, 7723, 7722, + 7725, 7724, 7727, 7726, 7729, 7728, 7731, 7730, 7733, 7732, 7735, 7734, 7737, 7736, 7739, 7738, + 7741, 7740, 7743, 7742, 7745, 7744, 7747, 7746, 7749, 7748, 7751, 7750, 7753, 7752, 7755, 7754, + 7757, 7756, 7759, 7758, 7761, 7760, 7763, 7762, 7765, 7764, 7767, 7766, 7769, 7768, 7771, 7770, + 7773, 7772, 7775, 7774, 7777, 7776, 7779, 7778, 7781, 7780, 7783, 7782, 7785, 7784, 7787, 7786, + 7789, 7788, 7791, 7790, 7793, 7792, 7795, 7794, 7797, 7796, 7799, 7798, 7801, 7800, 7803, 7802, + 7805, 7804, 7807, 7806, 7809, 7808, 7811, 7810, 7813, 7812, 7815, 7814, 7817, 7816, 7819, 7818, + 7821, 7820, 7823, 7822, 7825, 7824, 7827, 7826, 7829, 7828, 7835, 7776, 7841, 7840, 7843, 7842, + 7845, 7844, 7847, 7846, 7849, 7848, 7851, 7850, 7853, 7852, 7855, 7854, 7857, 7856, 7859, 7858, + 7861, 7860, 7863, 7862, 7865, 7864, 7867, 7866, 7869, 7868, 7871, 7870, 7873, 7872, 7875, 7874, + 7877, 7876, 7879, 7878, 7881, 7880, 7883, 7882, 7885, 7884, 7887, 7886, 7889, 7888, 7891, 7890, + 7893, 7892, 7895, 7894, 7897, 7896, 7899, 7898, 7901, 7900, 7903, 7902, 7905, 7904, 7907, 7906, + 7909, 7908, 7911, 7910, 7913, 7912, 7915, 7914, 7917, 7916, 7919, 7918, 7921, 7920, 7923, 7922, + 7925, 7924, 7927, 7926, 7929, 7928, 7931, 7930, 7933, 7932, 7935, 7934, 7936, 7944, 7937, 7945, + 7938, 7946, 7939, 7947, 7940, 7948, 7941, 7949, 7942, 7950, 7943, 7951, 7952, 7960, 7953, 7961, + 7954, 7962, 7955, 7963, 7956, 7964, 7957, 7965, 7968, 7976, 7969, 7977, 7970, 7978, 7971, 7979, + 7972, 7980, 7973, 7981, 7974, 7982, 7975, 7983, 7984, 7992, 7985, 7993, 7986, 7994, 7987, 7995, + 7988, 7996, 7989, 7997, 7990, 7998, 7991, 7999, 8000, 8008, 8001, 8009, 8002, 8010, 8003, 8011, + 8004, 8012, 8005, 8013, 8017, 8025, 8019, 8027, 8021, 8029, 8023, 8031, 8032, 8040, 8033, 8041, + 8034, 8042, 8035, 8043, 8036, 8044, 8037, 8045, 8038, 8046, 8039, 8047, 8048, 8122, 8049, 8123, + 8050, 8136, 8051, 8137, 8052, 8138, 8053, 8139, 8054, 8154, 8055, 8155, 8056, 8184, 8057, 8185, + 8058, 8170, 8059, 8171, 8060, 8186, 8061, 8187, 8064, 8072, 8065, 8073, 8066, 8074, 8067, 8075, + 8068, 8076, 8069, 8077, 8070, 8078, 8071, 8079, 8080, 8088, 8081, 8089, 8082, 8090, 8083, 8091, + 8084, 8092, 8085, 8093, 8086, 8094, 8087, 8095, 8096, 8104, 8097, 8105, 8098, 8106, 8099, 8107, + 8100, 8108, 8101, 8109, 8102, 8110, 8103, 8111, 8112, 8120, 8113, 8121, 8115, 8124, 8126, 921, + 8131, 8140, 8144, 8152, 8145, 8153, 8160, 8168, 8161, 8169, 8165, 8172, 8179, 8188, 8526, 8498, + 8560, 8544, 8561, 8545, 8562, 8546, 8563, 8547, 8564, 8548, 8565, 8549, 8566, 8550, 8567, 8551, + 8568, 8552, 8569, 8553, 8570, 8554, 8571, 8555, 8572, 8556, 8573, 8557, 8574, 8558, 8575, 8559, + 8580, 8579, 9424, 9398, 9425, 9399, 9426, 9400, 9427, 9401, 9428, 9402, 9429, 9403, 9430, 9404, + 9431, 9405, 9432, 9406, 9433, 9407, 9434, 9408, 9435, 9409, 9436, 9410, 9437, 9411, 9438, 9412, + 9439, 9413, 9440, 9414, 9441, 9415, 9442, 9416, 9443, 9417, 9444, 9418, 9445, 9419, 9446, 9420, + 9447, 9421, 9448, 9422, 9449, 9423, 11312, 11264, 11313, 11265, 11314, 11266, 11315, 11267, + 11316, 11268, 11317, 11269, 11318, 11270, 11319, 11271, 11320, 11272, 11321, 11273, 11322, + 11274, 11323, 11275, 11324, 11276, 11325, 11277, 11326, 11278, 11327, 11279, 11328, 11280, + 11329, 11281, 11330, 11282, 11331, 11283, 11332, 11284, 11333, 11285, 11334, 11286, 11335, + 11287, 11336, 11288, 11337, 11289, 11338, 11290, 11339, 11291, 11340, 11292, 11341, 11293, + 11342, 11294, 11343, 11295, 11344, 11296, 11345, 11297, 11346, 11298, 11347, 11299, 11348, + 11300, 11349, 11301, 11350, 11302, 11351, 11303, 11352, 11304, 11353, 11305, 11354, 11306, + 11355, 11307, 11356, 11308, 11357, 11309, 11358, 11310, 11361, 11360, 11365, 570, 11366, 574, + 11368, 11367, 11370, 11369, 11372, 11371, 11379, 11378, 11382, 11381, 11393, 11392, 11395, + 11394, 11397, 11396, 11399, 11398, 11401, 11400, 11403, 11402, 11405, 11404, 11407, 11406, + 11409, 11408, 11411, 11410, 11413, 11412, 11415, 11414, 11417, 11416, 11419, 11418, 11421, + 11420, 11423, 11422, 11425, 11424, 11427, 11426, 11429, 11428, 11431, 11430, 11433, 11432, + 11435, 11434, 11437, 11436, 11439, 11438, 11441, 11440, 11443, 11442, 11445, 11444, 11447, + 11446, 11449, 11448, 11451, 11450, 11453, 11452, 11455, 11454, 11457, 11456, 11459, 11458, + 11461, 11460, 11463, 11462, 11465, 11464, 11467, 11466, 11469, 11468, 11471, 11470, 11473, + 11472, 11475, 11474, 11477, 11476, 11479, 11478, 11481, 11480, 11483, 11482, 11485, 11484, + 11487, 11486, 11489, 11488, 11491, 11490, 11500, 11499, 11502, 11501, 11507, 11506, 11520, 4256, + 11521, 4257, 11522, 4258, 11523, 4259, 11524, 4260, 11525, 4261, 11526, 4262, 11527, 4263, + 11528, 4264, 11529, 4265, 11530, 4266, 11531, 4267, 11532, 4268, 11533, 4269, 11534, 4270, + 11535, 4271, 11536, 4272, 11537, 4273, 11538, 4274, 11539, 4275, 11540, 4276, 11541, 4277, + 11542, 4278, 11543, 4279, 11544, 4280, 11545, 4281, 11546, 4282, 11547, 4283, 11548, 4284, + 11549, 4285, 11550, 4286, 11551, 4287, 11552, 4288, 11553, 4289, 11554, 4290, 11555, 4291, + 11556, 4292, 11557, 4293, 11559, 4295, 11565, 4301, 42561, 42560, 42563, 42562, 42565, 42564, + 42567, 42566, 42569, 42568, 42571, 42570, 42573, 42572, 42575, 42574, 42577, 42576, 42579, + 42578, 42581, 42580, 42583, 42582, 42585, 42584, 42587, 42586, 42589, 42588, 42591, 42590, + 42593, 42592, 42595, 42594, 42597, 42596, 42599, 42598, 42601, 42600, 42603, 42602, 42605, + 42604, 42625, 42624, 42627, 42626, 42629, 42628, 42631, 42630, 42633, 42632, 42635, 42634, + 42637, 42636, 42639, 42638, 42641, 42640, 42643, 42642, 42645, 42644, 42647, 42646, 42649, + 42648, 42651, 42650, 42787, 42786, 42789, 42788, 42791, 42790, 42793, 42792, 42795, 42794, + 42797, 42796, 42799, 42798, 42803, 42802, 42805, 42804, 42807, 42806, 42809, 42808, 42811, + 42810, 42813, 42812, 42815, 42814, 42817, 42816, 42819, 42818, 42821, 42820, 42823, 42822, + 42825, 42824, 42827, 42826, 42829, 42828, 42831, 42830, 42833, 42832, 42835, 42834, 42837, + 42836, 42839, 42838, 42841, 42840, 42843, 42842, 42845, 42844, 42847, 42846, 42849, 42848, + 42851, 42850, 42853, 42852, 42855, 42854, 42857, 42856, 42859, 42858, 42861, 42860, 42863, + 42862, 42874, 42873, 42876, 42875, 42879, 42878, 42881, 42880, 42883, 42882, 42885, 42884, + 42887, 42886, 42892, 42891, 42897, 42896, 42899, 42898, 42903, 42902, 42905, 42904, 42907, + 42906, 42909, 42908, 42911, 42910, 42913, 42912, 42915, 42914, 42917, 42916, 42919, 42918, + 42921, 42920, 42933, 42932, 42935, 42934, 43859, 42931, 43888, 5024, 43889, 5025, 43890, 5026, + 43891, 5027, 43892, 5028, 43893, 5029, 43894, 5030, 43895, 5031, 43896, 5032, 43897, 5033, + 43898, 5034, 43899, 5035, 43900, 5036, 43901, 5037, 43902, 5038, 43903, 5039, 43904, 5040, + 43905, 5041, 43906, 5042, 43907, 5043, 43908, 5044, 43909, 5045, 43910, 5046, 43911, 5047, + 43912, 5048, 43913, 5049, 43914, 5050, 43915, 5051, 43916, 5052, 43917, 5053, 43918, 5054, + 43919, 5055, 43920, 5056, 43921, 5057, 43922, 5058, 43923, 5059, 43924, 5060, 43925, 5061, + 43926, 5062, 43927, 5063, 43928, 5064, 43929, 5065, 43930, 5066, 43931, 5067, 43932, 5068, + 43933, 5069, 43934, 5070, 43935, 5071, 43936, 5072, 43937, 5073, 43938, 5074, 43939, 5075, + 43940, 5076, 43941, 5077, 43942, 5078, 43943, 5079, 43944, 5080, 43945, 5081, 43946, 5082, + 43947, 5083, 43948, 5084, 43949, 5085, 43950, 5086, 43951, 5087, 43952, 5088, 43953, 5089, + 43954, 5090, 43955, 5091, 43956, 5092, 43957, 5093, 43958, 5094, 43959, 5095, 43960, 5096, + 43961, 5097, 43962, 5098, 43963, 5099, 43964, 5100, 43965, 5101, 43966, 5102, 43967, 5103, + 65345, 65313, 65346, 65314, 65347, 65315, 65348, 65316, 65349, 65317, 65350, 65318, 65351, + 65319, 65352, 65320, 65353, 65321, 65354, 65322, 65355, 65323, 65356, 65324, 65357, 65325, + 65358, 65326, 65359, 65327, 65360, 65328, 65361, 65329, 65362, 65330, 65363, 65331, 65364, + 65332, 65365, 65333, 65366, 65334, 65367, 65335, 65368, 65336, 65369, 65337, 65370, 65338 + ], + "lower": [ + 65, 97, 66, 98, 67, 99, 68, 100, 69, 101, 70, 102, 71, 103, 72, 104, 73, 105, 74, 106, 75, 107, + 76, 108, 77, 109, 78, 110, 79, 111, 80, 112, 81, 113, 82, 114, 83, 115, 84, 116, 85, 117, 86, + 118, 87, 119, 88, 120, 89, 121, 90, 122, 192, 224, 193, 225, 194, 226, 195, 227, 196, 228, 197, + 229, 198, 230, 199, 231, 200, 232, 201, 233, 202, 234, 203, 235, 204, 236, 205, 237, 206, 238, + 207, 239, 208, 240, 209, 241, 210, 242, 211, 243, 212, 244, 213, 245, 214, 246, 216, 248, 217, + 249, 218, 250, 219, 251, 220, 252, 221, 253, 222, 254, 256, 257, 258, 259, 260, 261, 262, 263, + 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, + 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, + 302, 303, 304, 105, 306, 307, 308, 309, 310, 311, 313, 314, 315, 316, 317, 318, 319, 320, 321, + 322, 323, 324, 325, 326, 327, 328, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, + 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, + 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 255, 377, 378, + 379, 380, 381, 382, 385, 595, 386, 387, 388, 389, 390, 596, 391, 392, 393, 598, 394, 599, 395, + 396, 398, 477, 399, 601, 400, 603, 401, 402, 403, 608, 404, 611, 406, 617, 407, 616, 408, 409, + 412, 623, 413, 626, 415, 629, 416, 417, 418, 419, 420, 421, 422, 640, 423, 424, 425, 643, 428, + 429, 430, 648, 431, 432, 433, 650, 434, 651, 435, 436, 437, 438, 439, 658, 440, 441, 444, 445, + 452, 454, 453, 454, 455, 457, 456, 457, 458, 460, 459, 460, 461, 462, 463, 464, 465, 466, 467, + 468, 469, 470, 471, 472, 473, 474, 475, 476, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, + 488, 489, 490, 491, 492, 493, 494, 495, 497, 499, 498, 499, 500, 501, 502, 405, 503, 447, 504, + 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, + 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, + 543, 544, 414, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, + 562, 563, 570, 11365, 571, 572, 573, 410, 574, 11366, 577, 578, 579, 384, 580, 649, 581, 652, + 582, 583, 584, 585, 586, 587, 588, 589, 590, 591, 880, 881, 882, 883, 886, 887, 895, 1011, 902, + 940, 904, 941, 905, 942, 906, 943, 908, 972, 910, 973, 911, 974, 913, 945, 914, 946, 915, 947, + 916, 948, 917, 949, 918, 950, 919, 951, 920, 952, 921, 953, 922, 954, 923, 955, 924, 956, 925, + 957, 926, 958, 927, 959, 928, 960, 929, 961, 931, 963, 932, 964, 933, 965, 934, 966, 935, 967, + 936, 968, 937, 969, 938, 970, 939, 971, 975, 983, 984, 985, 986, 987, 988, 989, 990, 991, 992, + 993, 994, 995, 996, 997, 998, 999, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1012, 952, + 1015, 1016, 1017, 1010, 1018, 1019, 1021, 891, 1022, 892, 1023, 893, 1024, 1104, 1025, 1105, + 1026, 1106, 1027, 1107, 1028, 1108, 1029, 1109, 1030, 1110, 1031, 1111, 1032, 1112, 1033, 1113, + 1034, 1114, 1035, 1115, 1036, 1116, 1037, 1117, 1038, 1118, 1039, 1119, 1040, 1072, 1041, 1073, + 1042, 1074, 1043, 1075, 1044, 1076, 1045, 1077, 1046, 1078, 1047, 1079, 1048, 1080, 1049, 1081, + 1050, 1082, 1051, 1083, 1052, 1084, 1053, 1085, 1054, 1086, 1055, 1087, 1056, 1088, 1057, 1089, + 1058, 1090, 1059, 1091, 1060, 1092, 1061, 1093, 1062, 1094, 1063, 1095, 1064, 1096, 1065, 1097, + 1066, 1098, 1067, 1099, 1068, 1100, 1069, 1101, 1070, 1102, 1071, 1103, 1120, 1121, 1122, 1123, + 1124, 1125, 1126, 1127, 1128, 1129, 1130, 1131, 1132, 1133, 1134, 1135, 1136, 1137, 1138, 1139, + 1140, 1141, 1142, 1143, 1144, 1145, 1146, 1147, 1148, 1149, 1150, 1151, 1152, 1153, 1162, 1163, + 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, + 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, + 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, + 1212, 1213, 1214, 1215, 1216, 1231, 1217, 1218, 1219, 1220, 1221, 1222, 1223, 1224, 1225, 1226, + 1227, 1228, 1229, 1230, 1232, 1233, 1234, 1235, 1236, 1237, 1238, 1239, 1240, 1241, 1242, 1243, + 1244, 1245, 1246, 1247, 1248, 1249, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258, 1259, + 1260, 1261, 1262, 1263, 1264, 1265, 1266, 1267, 1268, 1269, 1270, 1271, 1272, 1273, 1274, 1275, + 1276, 1277, 1278, 1279, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, + 1292, 1293, 1294, 1295, 1296, 1297, 1298, 1299, 1300, 1301, 1302, 1303, 1304, 1305, 1306, 1307, + 1308, 1309, 1310, 1311, 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321, 1322, 1323, + 1324, 1325, 1326, 1327, 1329, 1377, 1330, 1378, 1331, 1379, 1332, 1380, 1333, 1381, 1334, 1382, + 1335, 1383, 1336, 1384, 1337, 1385, 1338, 1386, 1339, 1387, 1340, 1388, 1341, 1389, 1342, 1390, + 1343, 1391, 1344, 1392, 1345, 1393, 1346, 1394, 1347, 1395, 1348, 1396, 1349, 1397, 1350, 1398, + 1351, 1399, 1352, 1400, 1353, 1401, 1354, 1402, 1355, 1403, 1356, 1404, 1357, 1405, 1358, 1406, + 1359, 1407, 1360, 1408, 1361, 1409, 1362, 1410, 1363, 1411, 1364, 1412, 1365, 1413, 1366, 1414, + 4256, 11520, 4257, 11521, 4258, 11522, 4259, 11523, 4260, 11524, 4261, 11525, 4262, 11526, 4263, + 11527, 4264, 11528, 4265, 11529, 4266, 11530, 4267, 11531, 4268, 11532, 4269, 11533, 4270, + 11534, 4271, 11535, 4272, 11536, 4273, 11537, 4274, 11538, 4275, 11539, 4276, 11540, 4277, + 11541, 4278, 11542, 4279, 11543, 4280, 11544, 4281, 11545, 4282, 11546, 4283, 11547, 4284, + 11548, 4285, 11549, 4286, 11550, 4287, 11551, 4288, 11552, 4289, 11553, 4290, 11554, 4291, + 11555, 4292, 11556, 4293, 11557, 4295, 11559, 4301, 11565, 5024, 43888, 5025, 43889, 5026, + 43890, 5027, 43891, 5028, 43892, 5029, 43893, 5030, 43894, 5031, 43895, 5032, 43896, 5033, + 43897, 5034, 43898, 5035, 43899, 5036, 43900, 5037, 43901, 5038, 43902, 5039, 43903, 5040, + 43904, 5041, 43905, 5042, 43906, 5043, 43907, 5044, 43908, 5045, 43909, 5046, 43910, 5047, + 43911, 5048, 43912, 5049, 43913, 5050, 43914, 5051, 43915, 5052, 43916, 5053, 43917, 5054, + 43918, 5055, 43919, 5056, 43920, 5057, 43921, 5058, 43922, 5059, 43923, 5060, 43924, 5061, + 43925, 5062, 43926, 5063, 43927, 5064, 43928, 5065, 43929, 5066, 43930, 5067, 43931, 5068, + 43932, 5069, 43933, 5070, 43934, 5071, 43935, 5072, 43936, 5073, 43937, 5074, 43938, 5075, + 43939, 5076, 43940, 5077, 43941, 5078, 43942, 5079, 43943, 5080, 43944, 5081, 43945, 5082, + 43946, 5083, 43947, 5084, 43948, 5085, 43949, 5086, 43950, 5087, 43951, 5088, 43952, 5089, + 43953, 5090, 43954, 5091, 43955, 5092, 43956, 5093, 43957, 5094, 43958, 5095, 43959, 5096, + 43960, 5097, 43961, 5098, 43962, 5099, 43963, 5100, 43964, 5101, 43965, 5102, 43966, 5103, + 43967, 5104, 5112, 5105, 5113, 5106, 5114, 5107, 5115, 5108, 5116, 5109, 5117, 7680, 7681, 7682, + 7683, 7684, 7685, 7686, 7687, 7688, 7689, 7690, 7691, 7692, 7693, 7694, 7695, 7696, 7697, 7698, + 7699, 7700, 7701, 7702, 7703, 7704, 7705, 7706, 7707, 7708, 7709, 7710, 7711, 7712, 7713, 7714, + 7715, 7716, 7717, 7718, 7719, 7720, 7721, 7722, 7723, 7724, 7725, 7726, 7727, 7728, 7729, 7730, + 7731, 7732, 7733, 7734, 7735, 7736, 7737, 7738, 7739, 7740, 7741, 7742, 7743, 7744, 7745, 7746, + 7747, 7748, 7749, 7750, 7751, 7752, 7753, 7754, 7755, 7756, 7757, 7758, 7759, 7760, 7761, 7762, + 7763, 7764, 7765, 7766, 7767, 7768, 7769, 7770, 7771, 7772, 7773, 7774, 7775, 7776, 7777, 7778, + 7779, 7780, 7781, 7782, 7783, 7784, 7785, 7786, 7787, 7788, 7789, 7790, 7791, 7792, 7793, 7794, + 7795, 7796, 7797, 7798, 7799, 7800, 7801, 7802, 7803, 7804, 7805, 7806, 7807, 7808, 7809, 7810, + 7811, 7812, 7813, 7814, 7815, 7816, 7817, 7818, 7819, 7820, 7821, 7822, 7823, 7824, 7825, 7826, + 7827, 7828, 7829, 7838, 223, 7840, 7841, 7842, 7843, 7844, 7845, 7846, 7847, 7848, 7849, 7850, + 7851, 7852, 7853, 7854, 7855, 7856, 7857, 7858, 7859, 7860, 7861, 7862, 7863, 7864, 7865, 7866, + 7867, 7868, 7869, 7870, 7871, 7872, 7873, 7874, 7875, 7876, 7877, 7878, 7879, 7880, 7881, 7882, + 7883, 7884, 7885, 7886, 7887, 7888, 7889, 7890, 7891, 7892, 7893, 7894, 7895, 7896, 7897, 7898, + 7899, 7900, 7901, 7902, 7903, 7904, 7905, 7906, 7907, 7908, 7909, 7910, 7911, 7912, 7913, 7914, + 7915, 7916, 7917, 7918, 7919, 7920, 7921, 7922, 7923, 7924, 7925, 7926, 7927, 7928, 7929, 7930, + 7931, 7932, 7933, 7934, 7935, 7944, 7936, 7945, 7937, 7946, 7938, 7947, 7939, 7948, 7940, 7949, + 7941, 7950, 7942, 7951, 7943, 7960, 7952, 7961, 7953, 7962, 7954, 7963, 7955, 7964, 7956, 7965, + 7957, 7976, 7968, 7977, 7969, 7978, 7970, 7979, 7971, 7980, 7972, 7981, 7973, 7982, 7974, 7983, + 7975, 7992, 7984, 7993, 7985, 7994, 7986, 7995, 7987, 7996, 7988, 7997, 7989, 7998, 7990, 7999, + 7991, 8008, 8000, 8009, 8001, 8010, 8002, 8011, 8003, 8012, 8004, 8013, 8005, 8025, 8017, 8027, + 8019, 8029, 8021, 8031, 8023, 8040, 8032, 8041, 8033, 8042, 8034, 8043, 8035, 8044, 8036, 8045, + 8037, 8046, 8038, 8047, 8039, 8072, 8064, 8073, 8065, 8074, 8066, 8075, 8067, 8076, 8068, 8077, + 8069, 8078, 8070, 8079, 8071, 8088, 8080, 8089, 8081, 8090, 8082, 8091, 8083, 8092, 8084, 8093, + 8085, 8094, 8086, 8095, 8087, 8104, 8096, 8105, 8097, 8106, 8098, 8107, 8099, 8108, 8100, 8109, + 8101, 8110, 8102, 8111, 8103, 8120, 8112, 8121, 8113, 8122, 8048, 8123, 8049, 8124, 8115, 8136, + 8050, 8137, 8051, 8138, 8052, 8139, 8053, 8140, 8131, 8152, 8144, 8153, 8145, 8154, 8054, 8155, + 8055, 8168, 8160, 8169, 8161, 8170, 8058, 8171, 8059, 8172, 8165, 8184, 8056, 8185, 8057, 8186, + 8060, 8187, 8061, 8188, 8179, 8486, 969, 8490, 107, 8491, 229, 8498, 8526, 8544, 8560, 8545, + 8561, 8546, 8562, 8547, 8563, 8548, 8564, 8549, 8565, 8550, 8566, 8551, 8567, 8552, 8568, 8553, + 8569, 8554, 8570, 8555, 8571, 8556, 8572, 8557, 8573, 8558, 8574, 8559, 8575, 8579, 8580, 9398, + 9424, 9399, 9425, 9400, 9426, 9401, 9427, 9402, 9428, 9403, 9429, 9404, 9430, 9405, 9431, 9406, + 9432, 9407, 9433, 9408, 9434, 9409, 9435, 9410, 9436, 9411, 9437, 9412, 9438, 9413, 9439, 9414, + 9440, 9415, 9441, 9416, 9442, 9417, 9443, 9418, 9444, 9419, 9445, 9420, 9446, 9421, 9447, 9422, + 9448, 9423, 9449, 11264, 11312, 11265, 11313, 11266, 11314, 11267, 11315, 11268, 11316, 11269, + 11317, 11270, 11318, 11271, 11319, 11272, 11320, 11273, 11321, 11274, 11322, 11275, 11323, + 11276, 11324, 11277, 11325, 11278, 11326, 11279, 11327, 11280, 11328, 11281, 11329, 11282, + 11330, 11283, 11331, 11284, 11332, 11285, 11333, 11286, 11334, 11287, 11335, 11288, 11336, + 11289, 11337, 11290, 11338, 11291, 11339, 11292, 11340, 11293, 11341, 11294, 11342, 11295, + 11343, 11296, 11344, 11297, 11345, 11298, 11346, 11299, 11347, 11300, 11348, 11301, 11349, + 11302, 11350, 11303, 11351, 11304, 11352, 11305, 11353, 11306, 11354, 11307, 11355, 11308, + 11356, 11309, 11357, 11310, 11358, 11360, 11361, 11362, 619, 11363, 7549, 11364, 637, 11367, + 11368, 11369, 11370, 11371, 11372, 11373, 593, 11374, 625, 11375, 592, 11376, 594, 11378, 11379, + 11381, 11382, 11390, 575, 11391, 576, 11392, 11393, 11394, 11395, 11396, 11397, 11398, 11399, + 11400, 11401, 11402, 11403, 11404, 11405, 11406, 11407, 11408, 11409, 11410, 11411, 11412, + 11413, 11414, 11415, 11416, 11417, 11418, 11419, 11420, 11421, 11422, 11423, 11424, 11425, + 11426, 11427, 11428, 11429, 11430, 11431, 11432, 11433, 11434, 11435, 11436, 11437, 11438, + 11439, 11440, 11441, 11442, 11443, 11444, 11445, 11446, 11447, 11448, 11449, 11450, 11451, + 11452, 11453, 11454, 11455, 11456, 11457, 11458, 11459, 11460, 11461, 11462, 11463, 11464, + 11465, 11466, 11467, 11468, 11469, 11470, 11471, 11472, 11473, 11474, 11475, 11476, 11477, + 11478, 11479, 11480, 11481, 11482, 11483, 11484, 11485, 11486, 11487, 11488, 11489, 11490, + 11491, 11499, 11500, 11501, 11502, 11506, 11507, 42560, 42561, 42562, 42563, 42564, 42565, + 42566, 42567, 42568, 42569, 42570, 42571, 42572, 42573, 42574, 42575, 42576, 42577, 42578, + 42579, 42580, 42581, 42582, 42583, 42584, 42585, 42586, 42587, 42588, 42589, 42590, 42591, + 42592, 42593, 42594, 42595, 42596, 42597, 42598, 42599, 42600, 42601, 42602, 42603, 42604, + 42605, 42624, 42625, 42626, 42627, 42628, 42629, 42630, 42631, 42632, 42633, 42634, 42635, + 42636, 42637, 42638, 42639, 42640, 42641, 42642, 42643, 42644, 42645, 42646, 42647, 42648, + 42649, 42650, 42651, 42786, 42787, 42788, 42789, 42790, 42791, 42792, 42793, 42794, 42795, + 42796, 42797, 42798, 42799, 42802, 42803, 42804, 42805, 42806, 42807, 42808, 42809, 42810, + 42811, 42812, 42813, 42814, 42815, 42816, 42817, 42818, 42819, 42820, 42821, 42822, 42823, + 42824, 42825, 42826, 42827, 42828, 42829, 42830, 42831, 42832, 42833, 42834, 42835, 42836, + 42837, 42838, 42839, 42840, 42841, 42842, 42843, 42844, 42845, 42846, 42847, 42848, 42849, + 42850, 42851, 42852, 42853, 42854, 42855, 42856, 42857, 42858, 42859, 42860, 42861, 42862, + 42863, 42873, 42874, 42875, 42876, 42877, 7545, 42878, 42879, 42880, 42881, 42882, 42883, 42884, + 42885, 42886, 42887, 42891, 42892, 42893, 613, 42896, 42897, 42898, 42899, 42902, 42903, 42904, + 42905, 42906, 42907, 42908, 42909, 42910, 42911, 42912, 42913, 42914, 42915, 42916, 42917, + 42918, 42919, 42920, 42921, 42922, 614, 42923, 604, 42924, 609, 42925, 620, 42926, 618, 42928, + 670, 42929, 647, 42930, 669, 42931, 43859, 42932, 42933, 42934, 42935, 65313, 65345, 65314, + 65346, 65315, 65347, 65316, 65348, 65317, 65349, 65318, 65350, 65319, 65351, 65320, 65352, + 65321, 65353, 65322, 65354, 65323, 65355, 65324, 65356, 65325, 65357, 65326, 65358, 65327, + 65359, 65328, 65360, 65329, 65361, 65330, 65362, 65331, 65363, 65332, 65364, 65333, 65365, + 65334, 65366, 65335, 65367, 65336, 65368, 65337, 65369, 65338, 65370 + ] +} diff --git a/tools/content-node/interactors.spec.mjs b/tools/content-node/interactors.spec.mjs new file mode 100644 index 00000000..4d51ce41 --- /dev/null +++ b/tools/content-node/interactors.spec.mjs @@ -0,0 +1,230 @@ +// @vitest-environment node +// +// The static interactor endpoints, as far as no database is needed: how each +// answer is written from its rows, the paging parameters, and the checks the +// POST makes before any query. Every expected value was measured against +// Java's /interactors/static on 15 Oct 2026. +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { + app, + customInteractionJson, + interactionWithEntitiesJson, + interactorJson, + interactorsJson, + springIntegerWithDefault, +} from './service.mjs'; + +let base; +let server; + +beforeAll(async () => { + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', resolve); + }); + base = `http://127.0.0.1:${server.address().port}`; +}); + +afterAll(() => new Promise((resolve) => server.close(resolve))); + +const MDM2 = { + id: 11148815, + score: 0.995, + accession: ['EBI-3892574', 'EBI-34584959'], + url: 'https://www.ebi.ac.uk/intact/x', + partners: [ + { + labels: ['ReferenceGeneProduct', 'ReferenceSequence', 'ReferenceEntity', 'DatabaseObject'], + identifier: 'Q00987', + variantIdentifier: null, + url: 'http://purl.uniprot.org/uniprot/Q00987', + geneName: ['MDM2', 'other'], + }, + ], +}; + +describe("interactorJson, the content service's Interactor", () => { + it('writes the partner in the field order Java does', () => { + expect(interactorJson(MDM2)).toBe( + '{"acc":"Q00987","alias":"MDM2","id":11148815,"evidences":2,"score":0.995,' + + '"accURL":"http://purl.uniprot.org/uniprot/Q00987","evidencesURL":"https://www.ebi.ac.uk/intact/x"}' + ); + }); + + it("names an isoform by its variant, and writes a whole score as Java's 1.0", () => { + const isoform = { + ...MDM2, + score: 1, + partners: [ + { + ...MDM2.partners[0], + labels: ['ReferenceIsoform', 'ReferenceGeneProduct', 'ReferenceSequence'], + variantIdentifier: 'Q00987-2', + }, + ], + }; + expect(interactorJson(isoform)).toContain('"acc":"Q00987-2"'); + expect(interactorJson(isoform)).toContain('"score":1.0'); + }); + + it('leaves out an empty alias, as NON_EMPTY does: no gene name, or a molecule', () => { + const molecule = { + ...MDM2, + partners: [ + { + labels: ['ReferenceMolecule', 'ReferenceEntity'], + identifier: '15422', + url: 'u', + geneName: ['x'], + }, + ], + }; + expect(interactorJson(molecule)).not.toContain('alias'); + const unnamed = { ...MDM2, partners: [{ ...MDM2.partners[0], geneName: [''] }] }; + expect(interactorJson(unnamed)).not.toContain('alias'); + }); +}); + +describe('interactorsJson, the answer around them', () => { + it('leaves out the list of an entity with none, keeping its count', () => { + expect( + interactorsJson([ + ['P38398', [MDM2]], + ['XYZ', []], + ]) + ).toBe( + `{"resource":"static","entities":[{"acc":"P38398","count":1,"interactors":[${interactorJson(MDM2)}]},` + + '{"acc":"XYZ","count":0}]}' + ); + }); +}); + +describe('customInteractionJson, graph-core’s CustomInteraction', () => { + it('writes its columns in field order, leaving out the empty ones', () => { + expect( + customInteractionJson({ + dbId: 1, + score: 0.5, + evidenceCount: 2, + identifier: 'Q1', + databaseName: 'UniProt', + displayName: 'UniProt:Q1', + variantIdentifier: null, + geneName: [], + url: 'u', + evidenceURL: 'e', + speciesName: null, + entitiesCount: 0, + }) + ).toBe( + '{"dbId":1,"identifier":"Q1","score":0.5,"evidenceCount":2,"url":"u","evidenceURL":"e",' + + '"databaseName":"UniProt","entitiesCount":0,"displayName":"UniProt:Q1"}' + ); + }); +}); + +describe('interactionWithEntitiesJson', () => { + it('sorts the entities by name, ignoring case, as String.CASE_INSENSITIVE_ORDER does', () => { + const written = interactionWithEntitiesJson({ + score: 1, + accession: 'Q1', + accessionURL: 'u', + evidences: null, + url: null, + physicalEntity: [ + { dbId: 2, stId: 'R-HSA-2', displayName: 'b', schemaClass: 'Complex' }, + { dbId: 1, stId: null, displayName: 'B_', schemaClass: null }, + { dbId: 3, stId: 'R-HSA-3', displayName: 'A', schemaClass: 'Complex' }, + ], + }); + // asDouble(0) and asInt(0): never left out. A missing schemaClass is + // asString()'s "null". + expect(written).toBe( + '{"score":1.0,"accession":"Q1","accessionURL":"u","physicalEntity":[' + + '{"dbId":3,"stId":"R-HSA-3","displayName":"A","schemaClass":"Complex"},' + + '{"dbId":2,"stId":"R-HSA-2","displayName":"b","schemaClass":"Complex"},' + + '{"dbId":1,"displayName":"B_","schemaClass":"null"}],"evidences":0}' + ); + }); +}); + +describe('the entities’ order, as String.CASE_INSENSITIVE_ORDER has it', () => { + const order = (...names) => + JSON.parse( + interactionWithEntitiesJson({ + physicalEntity: names.map((displayName, dbId) => ({ dbId, displayName, schemaClass: 'X' })), + }) + ).physicalEntity.map((pe) => pe.displayName); + + it('takes characters whose upper cases match as equal', () => { + // 'ı' and 'i' are both 'I' upper-cased, so the next character decides. + expect(order('ib', 'ıa')).toEqual(['ıa', 'ib']); + }); + + it('compares the lower cases of the upper cases: the long s sorts as s', () => { + expect(order('ſb', 'z', 'sa')).toEqual(['sa', 'ſb', 'z']); + }); + + it("keeps ß, which has no one-character upper case, as Java's Character does", () => { + // Java orders by the lower cases: 'ß' (U+00DF) after 's'. + expect(order('ß', 'st')).toEqual(['st', 'ß']); + }); +}); + +describe('springIntegerWithDefault, an Integer parameter with a default', () => { + it('takes the default only for none or one empty value', () => { + expect(springIntegerWithDefault([], -1)).toEqual({ value: -1 }); + expect(springIntegerWithDefault([''], -1)).toEqual({ value: -1 }); + }); + + it('reads blank as null, which the service then fails on', () => { + expect(springIntegerWithDefault([' '], -1)).toEqual({ value: null }); + expect(springIntegerWithDefault(['', ''], -1)).toEqual({ value: null }); + }); + + it('reads the first value, hex included, else names the text in a 400', () => { + expect(springIntegerWithDefault(['1', '2'], -1)).toEqual({ value: 1 }); + expect(springIntegerWithDefault(['0x1'], -1)).toEqual({ value: 1 }); + expect(springIntegerWithDefault(['abc'], -1)).toEqual({ + error: + "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; " + + 'nested exception is java.lang.NumberFormatException: For input string: "abc"', + }); + }); +}); + +describe('the interactor endpoints, before any query', () => { + const at = (path, init) => fetch(`${base}/ContentService/interactors/static${path}`, init); + const envelope = async (response) => { + const body = await response.json(); + return [response.status, body.reason, body.messages]; + }; + + it("is the 400 for a page that is no number, and Java's 500 for a blank one", async () => { + expect((await at('/molecule/P04637/details?page=abc')).status).toBe(400); + expect(await envelope(await at('/molecule/P04637/details?page=%20'))).toEqual([ + 500, + 'INTERNAL_SERVER_ERROR', + ['Something unexpected happened and the error has been reported.'], + ]); + }); + + const post = (body, type = 'text/plain') => + at('/molecules/details', { + method: 'POST', + headers: type ? { 'Content-Type': type } : {}, + body, + }); + + it('checks the content type, then the paging, then the body', async () => { + expect((await post('P04637', 'application/json')).status).toBe(415); + const [status, reason, [message]] = await envelope(await post(undefined)); + expect([status, reason]).toEqual([422, 'UNPROCESSABLE_ENTITY']); + expect(message).toBe( + 'Required request body is missing: public org.reactome.server.service.model.interactors.Interactors org.reactome.server.service.controller.interactors.StaticInteractionsController.getProteinsDetailsByAccs(java.lang.Integer,java.lang.Integer,java.lang.String)' + ); + }); + + it('is a 405 for a GET of the POST', async () => { + expect((await at('/molecules/details')).status).toBe(405); + }); +}); diff --git a/tools/content-node/service.mjs b/tools/content-node/service.mjs index d7c05d09..f35a70b2 100644 --- a/tools/content-node/service.mjs +++ b/tools/content-node/service.mjs @@ -29,6 +29,7 @@ import { findByIdQuery, inDepthQuery, isA, + javaDouble, JsonMap, mapNodes, oldEnhancedQuery, @@ -1561,8 +1562,303 @@ export const endpoints = [ }; }, }, + { + path: '/ContentService/interactors/static/molecule/{id}/details', + sample: 'P04637', + differs: [/: \.url: /], + /** + * An accession's interactors from IntAct, as the Pathway Browser's entity + * popup lists them: graph-core's InteractionsRepository.getByAcc, paged + * when `page` and `pageSize` are both given, each interaction written as + * the content service's Interactor (interactorsJson). + */ + handler: async (request) => { + const acc = pathId(rawSegment(request, 5)); + const window = interactorPaging(request); + if (window.answer) return window.answer; + const rows = await interactionsOf(request, acc, window); + if (rows.answer) return rows.answer; + if (rows.length === 0) return notFound(request, `No interactors found for accession: ${acc}`); + return { status: 200, body: interactorsJson([[acc, rows]]), type: 'application/json' }; + }, + }, + { + path: '/ContentService/interactors/static/molecules/details', + method: 'post', + sampleBody: 'P04637,Q00987, P38398\nXYZ', + differs: [/: \.url: /], + /** + * Interactors for many accessions, which the Pathway Browser asks for every + * protein in a diagram when the static resource is chosen: the body split + * and trimmed into a HashSet (its order is the answer's), each accession + * as the GET above, one at a time as Java asks. One with none is still an + * entry, with its count; none for any is a 404 naming the body as sent. + */ + handler: async (request) => { + const contentType = request.get?.('content-type'); + const problem = plainTextProblem(contentType); + if (problem) return javaError(request, 415, 'UNSUPPORTED_MEDIA_TYPE', problem); + const window = interactorPaging(request); + if (window.answer) return window.answer; + if (request.tooLarge) + return javaError(request, 413, 'PAYLOAD_TOO_LARGE', 'More than 10MB of accessions'); + if (!Buffer.isBuffer(request.body) || request.body.length === 0) + return javaError( + request, + 422, + 'UNPROCESSABLE_ENTITY', + 'Required request body is missing: public org.reactome.server.service.model.interactors.Interactors org.reactome.server.service.controller.interactors.StaticInteractionsController.getProteinsDetailsByAccs(java.lang.Integer,java.lang.Integer,java.lang.String)' + ); + const proteins = plainText(request.body, contentType); + const accs = new JavaHashMap(); + for (const acc of plainIds(proteins)) accs.set(acc, true); + const entities = []; + for (const acc of accs.keys()) { + const rows = await interactionsOf(request, acc, window); + if (rows.answer) return rows.answer; + entities.push([acc, rows]); + } + if (entities.every(([, rows]) => rows.length === 0)) + return notFound(request, `No interactors found for accession: ${proteins}`); + return { status: 200, body: interactorsJson(entities), type: 'application/json' }; + }, + }, + { + path: '/ContentService/interactors/static/molecule/enhanced/{id}/details', + sample: 'P04637', + /** + * The same interactions with more about each partner, for the website's + * and the Pathway Browser's interactor tables: graph-core's + * CustomInteractionsRepository, as written, each row a CustomInteraction. + * None is `[]`, not a 404: the controller only refuses a null. + */ + handler: async (request) => { + const acc = pathId(rawSegment(request, 6)); + const rows = await read( + `MATCH (t:ReferenceEntity)<-[:interactor]-(in:Interaction)-[ir:interactor]->(re:ReferenceEntity) WHERE t.variantIdentifier = $identifier OR (t.variantIdentifier IS NULL AND t.identifier = $identifier) OPTIONAL MATCH (pe:PhysicalEntity)-[r:referenceEntity]->(re) OPTIONAL MATCH (se:Species)<-[s:species]-(re) WITH in, re, se, pe, t ORDER BY in.score DESC RETURN DISTINCT in.dbId AS dbId, in.score AS score, SIZE(in.accession) AS evidenceCount, re.identifier AS identifier, re.databaseName AS databaseName, re.displayName AS displayName, re.variantIdentifier as variantIdentifier, re.geneName AS geneName, re.url as url, in.url AS evidenceURL, se.displayName AS speciesName, COUNT(pe) AS entitiesCount`, + { identifier: acc } + ); + return { + status: 200, + body: `[${rows.map(customInteractionJson).join(',')}]`, + type: 'application/json', + }; + }, + }, + { + path: '/ContentService/interactors/static/molecule/{id}/withEntities', + sample: 'P04637', + differs: [/: \.url: /], + /** + * Each interaction with the Reactome entities its partner is, for the + * website's interactor page: the content service's own query, as written, + * each row an InteractionWithEntities. + */ + handler: async (request) => { + const acc = pathId(rawSegment(request, 5)); + const rows = await read( + 'MATCH (s:ReferenceEntity)<-[:interactor]-(it:Interaction), (it)-[ir:interactor]->(in:ReferenceEntity)<-[re:referenceEntity]-(pe:PhysicalEntity), (:ReactionLikeEvent)-[:input|output|catalystActivity|physicalEntity|entityFunctionalStatus|diseaseEntity|regulatedBy|regulator*]->(pe) WHERE s.variantIdentifier = $accession OR (s.variantIdentifier IS NULL AND s.identifier = $accession) RETURN DISTINCT it.score AS score, in.identifier AS accession, in.url AS accessionURL, COLLECT(DISTINCT{ dbId: pe.dbId, stId: pe.stId, displayName: pe.displayName, schemaClass: pe.schemaClass }) AS physicalEntity, SIZE(it.accession) AS evidences, it.url AS url ORDER BY score DESC, accession', + { accession: acc } + ); + if (rows.length === 0) return notFound(request, `No interactors found for accession: ${acc}`); + return { + status: 200, + body: `[${rows.map(interactionWithEntitiesJson).join(',')}]`, + type: 'application/json', + }; + }, + }, ]; +/** + * The `page` and `pageSize` of the static details endpoints: Integers that + * default to -1, paging only when neither is -1. One read as null (a blank + * value) is the service's NullPointerException: Java's generic 500. + */ +function interactorPaging(request) { + const page = springIntegerWithDefault(queryValues(request, 'page'), -1); + if (page.error) return { answer: javaError(request, 400, 'BAD_REQUEST', page.error) }; + const pageSize = springIntegerWithDefault(queryValues(request, 'pageSize'), -1); + if (pageSize.error) return { answer: javaError(request, 400, 'BAD_REQUEST', pageSize.error) }; + if (page.value === null || pageSize.value === null) + return { + answer: javaError( + request, + 500, + 'INTERNAL_SERVER_ERROR', + 'Something unexpected happened and the error has been reported.' + ), + }; + return { page: page.value, pageSize: pageSize.value }; +} + +/** + * graph-core's InteractionsRepository.getByAcc, as written -- the plan, and so + * the order of equal scores, is the query's -- with each interaction's partner + * taken from its result. Every interaction has two interactors, the accession + * and its partner, so the partner list holds one (the accession itself for a + * homodimer). A negative SKIP or LIMIT is Java's 500 with Neo4j's message, + * returned as `answer`. + */ +async function interactionsOf(request, acc, { page, pageSize }) { + const paged = page !== -1 && pageSize !== -1; + try { + return await read( + `CALL { + MATCH (t:ReferenceEntity)<-[:interactor]-(in:Interaction)-[ir:interactor]->(re:ReferenceEntity) WHERE t.variantIdentifier = $acc OR (t.variantIdentifier IS NULL AND t.identifier = $acc) OPTIONAL MATCH (pe:PhysicalEntity)-[r:referenceEntity]->(re) RETURN DISTINCT in, COLLECT(DISTINCT ir) AS irs, COLLECT(DISTINCT re) AS res, COLLECT(DISTINCT r) AS rs, COLLECT(DISTINCT pe)[..20] AS pe ORDER BY in.score DESC${paged ? ' SKIP ($page - 1) * $pageSize LIMIT $pageSize' : ''} + } + RETURN in.dbId AS id, in.score AS score, in.accession AS accession, in.url AS url, + [x IN res | {labels: labels(x), identifier: x.identifier, + variantIdentifier: x.variantIdentifier, url: x.url, + geneName: x.geneName}] AS partners`, + { acc, ...(paged && { page: neo4j.int(page), pageSize: neo4j.int(pageSize) }) } + ); + } catch (failure) { + const message = neo4jFailureMessage(failure); + if (message === undefined) throw failure; + return { answer: javaError(request, 500, 'INTERNAL_SERVER_ERROR', message) }; + } +} + +/** + * A Java object as the content service's NON_EMPTY mapper writes it: the + * members in order, any null, empty text or empty list left out. A `double` + * member is written as Java writes a Double (1.0, not 1). + */ +function nonEmptyJson(members) { + const out = []; + for (const [name, value, double] of members) { + if (value === null || value === undefined || value === '') continue; + if (Array.isArray(value) && value.length === 0) continue; + const written = + value instanceof RawJson ? value.text : double ? javaDouble(value) : javaJson(value); + out.push(`${javaJson(name)}:${written}`); + } + return `{${out.join(',')}}`; +} + +/** Text already written as JSON, for nonEmptyJson. */ +class RawJson { + constructor(text) { + this.text = text; + } +} + +/** + * The content service's Interactor, from one interactionsOf row: the partner's + * variant identifier if it is an isoform with one, else its identifier; its + * first gene name as the alias; the evidence count, score and URLs. + */ +export function interactorJson(row) { + const [partner = {}] = row.partners; + const isoform = partner.labels?.includes('ReferenceIsoform'); + const acc = isoform && partner.variantIdentifier ? partner.variantIdentifier : partner.identifier; + const alias = partner.labels?.includes('ReferenceSequence') ? partner.geneName?.[0] : undefined; + return nonEmptyJson([ + ['acc', acc], + ['alias', alias], + ['id', row.id], + ['evidences', row.accession?.length], + ['score', row.score, true], + ['accURL', partner.url], + ['evidencesURL', row.url], + ]); +} + +/** + * The content service's Interactors: `static`, and an entry for each + * accession -- its interactors' count, and the list, left out when empty. + */ +export function interactorsJson(entities) { + const written = entities.map(([acc, rows]) => + nonEmptyJson([ + ['acc', acc], + ['count', rows.length], + ['interactors', rows.length ? new RawJson(`[${rows.map(interactorJson).join(',')}]`) : []], + ]) + ); + return `{"resource":"static","entities":[${written.join(',')}]}`; +} + +/** graph-core's CustomInteraction, a row of its query, in field order. */ +export function customInteractionJson(row) { + return nonEmptyJson([ + ['dbId', row.dbId], + ['identifier', row.identifier], + ['score', row.score, true], + ['evidenceCount', row.evidenceCount], + ['url', row.url], + ['evidenceURL', row.evidenceURL], + ['geneName', row.geneName], + ['databaseName', row.databaseName], + ['entitiesCount', row.entitiesCount], + ['speciesName', row.speciesName], + ['displayName', row.displayName], + ['variantIdentifier', row.variantIdentifier], + ]); +} + +/** + * The content service's InteractionWithEntities, a row of its query, as + * build() reads it -- score and evidences as 0 when missing -- with the + * entities sorted by name as String.CASE_INSENSITIVE_ORDER sorts (stably). + * Each entity is a SimpleDatabaseObject built from a map: stId asString(null), + * displayName and schemaClass asString(), which writes a missing one "null". + */ +export function interactionWithEntitiesJson(row) { + const entities = (row.physicalEntity ?? []) + .map((pe) => ({ ...pe, displayName: pe.displayName ?? 'null' })) + .sort((a, b) => caseInsensitive(a.displayName, b.displayName)); + const written = entities.map((pe) => + nonEmptyJson([ + ['dbId', pe.dbId], + ['stId', pe.stId], + ['displayName', pe.displayName], + ['schemaClass', pe.schemaClass ?? 'null'], + ]) + ); + return nonEmptyJson([ + ['score', row.score ?? 0, true], + ['accession', row.accession], + ['accessionURL', row.accessionURL], + ['physicalEntity', written.length ? new RawJson(`[${written.join(',')}]`) : []], + ['evidences', row.evidences ?? 0], + ['url', row.url], + ]); +} + +/** case-snapshot.json, as two maps from a UTF-16 unit to its case, read on first use. */ +let javaCase; + +/** + * String.CASE_INSENSITIVE_ORDER: per UTF-16 unit, equal if their upper cases + * are, else ordered by the lower cases of those upper cases (so the long s, ſ, + * sorts as s) -- each the JVM's Character mapping + * (case-snapshot.json; snapshot-case.mjs says why not JavaScript's). + */ +function caseInsensitive(a, b) { + if (!javaCase) { + const { upper, lower } = JSON.parse( + readFileSync(new URL('./case-snapshot.json', import.meta.url), 'utf8') + ); + const pairs = (flat) => + new Map(flat.flatMap((unit, at) => (at % 2 ? [] : [[unit, flat[at + 1]]]))); + javaCase = { upper: pairs(upper), lower: pairs(lower) }; + } + const length = Math.min(a.length, b.length); + for (let at = 0; at < length; at += 1) { + const x = a.charCodeAt(at); + const y = b.charCodeAt(at); + if (x === y) continue; + const upperX = javaCase.upper.get(x) ?? x; + const upperY = javaCase.upper.get(y) ?? y; + const lowerX = javaCase.lower.get(upperX) ?? upperX; + const lowerY = javaCase.lower.get(upperY) ?? upperY; + if (lowerX !== lowerY) return lowerX - lowerY; + } + return a.length - b.length; +} + /** * getOrthologies' two HashMaps, given each id's orthologyOf: the service puts * every id that is an id (not null) -- found or not -- and the controller @@ -1636,15 +1932,24 @@ function longPastRange(ids) { * more than twenty, the first twenty collected into a HashSet, in its order. */ export function orthologyIds(body) { - const parts = body.split(/,|;|\n|\t/); - if (parts.length > 1) while (parts.length && parts.at(-1) === '') parts.pop(); - const ids = parts.map((part) => javaTrim(part)); + const ids = plainIds(body); if (ids.length <= 20) return ids; const set = new JavaHashMap(); for (const id of ids.slice(0, 20)) set.set(id, true); return [...set.keys()]; } +/** + * A text/plain body of ids as the controllers split it: String.split on , ; + * newline or tab -- trailing empty strings dropped, as Java's split drops + * them -- each trimmed. + */ +function plainIds(body) { + const parts = body.split(/,|;|\n|\t/); + if (parts.length > 1) while (parts.length && parts.at(-1) === '') parts.pop(); + return parts.map((part) => javaTrim(part)); +} + /** * Whether Spring takes a Content-Type for `consumes = "text/plain"`: undefined * if so, else the 415's message. It parses the header as MimeTypeUtils does @@ -3536,6 +3841,37 @@ export function downloadGuard(request) { return !trusted(name) || !trusted(parameters); } +/** + * An `Integer` request parameter with a default, as Spring binds it: none, or + * one empty value, is the default; otherwise the first value converts -- an + * empty one to null -- and failing that, all of them joined, which with no + * text is null too. A null is the caller's to answer. + */ +export function springIntegerWithDefault(values, fallback) { + if (values.length === 0 || (values.length === 1 && values[0] === '')) return { value: fallback }; + if (values[0] === '') return { value: null }; + const attempt = (text) => { + try { + return { value: javaParseInteger(text) }; + } catch (failure) { + if (failure instanceof JavaNumberFormat) return { failure: failure.message }; + throw failure; + } + }; + const converted = attempt(values[0]); + if ('value' in converted) return converted; + const text = values.join(','); + if (javaTrimAll(text) === '') return { value: null }; + const retried = attempt(text); + if ('value' in retried) return retried; + const type = values.length > 1 ? 'java.lang.String[]' : 'java.lang.String'; + return { + error: + `Failed to convert value of type '${type}' to required type 'java.lang.Integer'; ` + + `nested exception is java.lang.NumberFormatException: ${retried.failure}`, + }; +} + /** * A `Long` path variable as Spring binds it: the ConversionService's * NumberUtils.parseNumber (see springInteger), then the CustomNumberEditor on diff --git a/tools/content-node/snapshot-case.mjs b/tools/content-node/snapshot-case.mjs new file mode 100644 index 00000000..f4440ac8 --- /dev/null +++ b/tools/content-node/snapshot-case.mjs @@ -0,0 +1,55 @@ +/** + * Write case-snapshot.json: Character.toUpperCase and toLowerCase for every + * UTF-16 unit that either changes, as the content service's JVM has them -- + * what String.CASE_INSENSITIVE_ORDER compares by (service.mjs, + * caseInsensitive). + * + * JAVA= node tools/content-node/snapshot-case.mjs + * + * JavaScript's own case mapping is not used: it is the full mapping (ß is SS, + * İ is i plus a dot) and the Unicode version of Node's ICU, which maps + * characters the service's JVM leaves alone -- 168 of them on 15 Oct 2026. + * + * WHEN TO RUN IT: when the content service's JVM changes. + */ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'case-snapshot.json'); +const JAVA = process.env.JAVA; +if (!JAVA) { + console.error('JAVA: the java executable the content service runs on'); + process.exit(2); +} + +const dir = mkdtempSync(path.join(tmpdir(), 'case-')); +try { + const source = path.join(dir, 'Cases.java'); + writeFileSync( + source, + `public class Cases { public static void main(String[] a) { + StringBuilder b = new StringBuilder(); + for (int c = 0; c < 0x10000; c++) + b.append(Integer.toHexString(Character.toUpperCase((char) c))).append(' ') + .append(Integer.toHexString(Character.toLowerCase((char) c))).append('\\n'); + System.out.print(b); } }` + ); + // A single source file runs without compiling first (JDK 11+). + const lines = execFileSync(JAVA, [source], { encoding: 'utf8', maxBuffer: 1 << 24 }) + .trim() + .split('\n'); + const upper = []; + const lower = []; + lines.forEach((line, unit) => { + const [up, low] = line.split(' ').map((hex) => parseInt(hex, 16)); + if (up !== unit) upper.push(unit, up); + if (low !== unit) lower.push(unit, low); + }); + writeFileSync(OUT, `${JSON.stringify({ upper, lower })}\n`); + console.log(`${OUT}: ${upper.length / 2} upper and ${lower.length / 2} lower mappings`); +} finally { + rmSync(dir, { recursive: true, force: true }); +} From cb9bcad140f2cfa0501444e2c9355d9d238b2c24 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Thu, 15 Oct 2026 19:52:45 +0000 Subject: [PATCH 2/2] Review: null paging and CustomInteraction's nulls, as Java reads them Review of #478, both now matching Java: - graph-core unboxes page first and pageSize only when page is not -1, once per accession. A blank pageSize with the default page is Java's unpaged answer (or its 404), not a 500; a POST whose body is missing (422) or splits to nothing (404 naming the body) never unboxes either. pagingUnboxFails says where it fails. - CustomInteraction's Record constructor reads identifier, url, evidenceURL, databaseName and displayName with asString(), which writes a missing one as the text "null", and fails on a missing dbId, score or evidenceCount; the details' Interactor fails on an interaction with no accession list. Node had left those out. None of these is missing in today's data (78,872 interactions, 22,792 partners counted), so this is the next release's safety. Against Java: the review's three cases, a blank pageSize on a real accession, and a blank page with one -- identical. Each new test fails under a mutation of the code it covers. Co-Authored-By: Claude Opus 5.5 --- tools/content-node/interactors.spec.mjs | 55 ++++++++++++++++++++- tools/content-node/service.mjs | 64 ++++++++++++++++--------- 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/tools/content-node/interactors.spec.mjs b/tools/content-node/interactors.spec.mjs index 4d51ce41..ffa5f012 100644 --- a/tools/content-node/interactors.spec.mjs +++ b/tools/content-node/interactors.spec.mjs @@ -11,6 +11,7 @@ import { interactionWithEntitiesJson, interactorJson, interactorsJson, + pagingUnboxFails, springIntegerWithDefault, } from './service.mjs'; @@ -122,6 +123,44 @@ describe('customInteractionJson, graph-core’s CustomInteraction', () => { }); }); +describe("customInteractionJson's nulls, as its Record constructor reads them", () => { + const row = { + dbId: 1, + score: 0.5, + evidenceCount: 2, + identifier: null, + databaseName: null, + displayName: null, + variantIdentifier: null, + geneName: null, + url: null, + evidenceURL: null, + speciesName: null, + entitiesCount: 0, + }; + + it('writes the text "null" for the columns read with asString()', () => { + expect(customInteractionJson(row)).toBe( + '{"dbId":1,"identifier":"null","score":0.5,"evidenceCount":2,"url":"null","evidenceURL":"null",' + + '"databaseName":"null","entitiesCount":0,"displayName":"null"}' + ); + }); + + it('is undefined -- the service fails -- for a null asLong() or asDouble() reads', () => { + for (const column of ['dbId', 'score', 'evidenceCount']) + expect(customInteractionJson({ ...row, [column]: null }), column).toBe(undefined); + }); +}); + +describe('pagingUnboxFails, where the service unboxes a null page or pageSize', () => { + it('unboxes page always, and pageSize only when page is not -1', () => { + expect(pagingUnboxFails({ page: null, pageSize: 3 })).toBe(true); + expect(pagingUnboxFails({ page: -1, pageSize: null })).toBe(false); + expect(pagingUnboxFails({ page: 2, pageSize: null })).toBe(true); + expect(pagingUnboxFails({ page: 2, pageSize: 3 })).toBe(false); + }); +}); + describe('interactionWithEntitiesJson', () => { it('sorts the entities by name, ignoring case, as String.CASE_INSENSITIVE_ORDER does', () => { const written = interactionWithEntitiesJson({ @@ -208,8 +247,8 @@ describe('the interactor endpoints, before any query', () => { ]); }); - const post = (body, type = 'text/plain') => - at('/molecules/details', { + const post = (body, type = 'text/plain', query = '') => + at(`/molecules/details${query}`, { method: 'POST', headers: type ? { 'Content-Type': type } : {}, body, @@ -224,6 +263,18 @@ describe('the interactor endpoints, before any query', () => { ); }); + it('unboxes a null page only when there is an accession to ask about', async () => { + // The body is resolved before the method runs; an empty set never reaches + // the service. Both measured against Java. + const missing = await post(undefined, 'text/plain', '?page=%20'); + expect(missing.status).toBe(422); + expect(await envelope(await post(',', 'text/plain', '?page=%20'))).toEqual([ + 404, + 'NOT_FOUND', + ['No interactors found for accession: ,'], + ]); + }); + it('is a 405 for a GET of the POST', async () => { expect((await at('/molecules/details')).status).toBe(405); }); diff --git a/tools/content-node/service.mjs b/tools/content-node/service.mjs index f35a70b2..92fda97a 100644 --- a/tools/content-node/service.mjs +++ b/tools/content-node/service.mjs @@ -1578,6 +1578,8 @@ export const endpoints = [ if (window.answer) return window.answer; const rows = await interactionsOf(request, acc, window); if (rows.answer) return rows.answer; + // getAccession().size() on an interaction with none. + if (rows.some((row) => row.accession === null)) return unexpected(request); if (rows.length === 0) return notFound(request, `No interactors found for accession: ${acc}`); return { status: 200, body: interactorsJson([[acc, rows]]), type: 'application/json' }; }, @@ -1616,6 +1618,7 @@ export const endpoints = [ for (const acc of accs.keys()) { const rows = await interactionsOf(request, acc, window); if (rows.answer) return rows.answer; + if (rows.some((row) => row.accession === null)) return unexpected(request); entities.push([acc, rows]); } if (entities.every(([, rows]) => rows.length === 0)) @@ -1638,11 +1641,9 @@ export const endpoints = [ `MATCH (t:ReferenceEntity)<-[:interactor]-(in:Interaction)-[ir:interactor]->(re:ReferenceEntity) WHERE t.variantIdentifier = $identifier OR (t.variantIdentifier IS NULL AND t.identifier = $identifier) OPTIONAL MATCH (pe:PhysicalEntity)-[r:referenceEntity]->(re) OPTIONAL MATCH (se:Species)<-[s:species]-(re) WITH in, re, se, pe, t ORDER BY in.score DESC RETURN DISTINCT in.dbId AS dbId, in.score AS score, SIZE(in.accession) AS evidenceCount, re.identifier AS identifier, re.databaseName AS databaseName, re.displayName AS displayName, re.variantIdentifier as variantIdentifier, re.geneName AS geneName, re.url as url, in.url AS evidenceURL, se.displayName AS speciesName, COUNT(pe) AS entitiesCount`, { identifier: acc } ); - return { - status: 200, - body: `[${rows.map(customInteractionJson).join(',')}]`, - type: 'application/json', - }; + const written = rows.map(customInteractionJson); + if (written.includes(undefined)) return unexpected(request); + return { status: 200, body: `[${written.join(',')}]`, type: 'application/json' }; }, }, { @@ -1672,26 +1673,36 @@ export const endpoints = [ /** * The `page` and `pageSize` of the static details endpoints: Integers that - * default to -1, paging only when neither is -1. One read as null (a blank - * value) is the service's NullPointerException: Java's generic 500. + * default to -1, paging only when neither is -1. A blank one is null, which + * the service may fail on (pagingUnboxFails). */ function interactorPaging(request) { const page = springIntegerWithDefault(queryValues(request, 'page'), -1); if (page.error) return { answer: javaError(request, 400, 'BAD_REQUEST', page.error) }; const pageSize = springIntegerWithDefault(queryValues(request, 'pageSize'), -1); if (pageSize.error) return { answer: javaError(request, 400, 'BAD_REQUEST', pageSize.error) }; - if (page.value === null || pageSize.value === null) - return { - answer: javaError( - request, - 500, - 'INTERNAL_SERVER_ERROR', - 'Something unexpected happened and the error has been reported.' - ), - }; return { page: page.value, pageSize: pageSize.value }; } +/** + * Whether graph-core's getInteractions fails unboxing a null page or + * pageSize: it reads page first, and pageSize only when page is not -1. It + * runs once per accession, so a request with none never fails here. + */ +export function pagingUnboxFails({ page, pageSize }) { + return page === null || (page !== -1 && pageSize === null); +} + +/** Java's answer to an exception its handler has no mapping for: a NullPointerException. */ +function unexpected(request) { + return javaError( + request, + 500, + 'INTERNAL_SERVER_ERROR', + 'Something unexpected happened and the error has been reported.' + ); +} + /** * graph-core's InteractionsRepository.getByAcc, as written -- the plan, and so * the order of equal scores, is the query's -- with each interaction's partner @@ -1701,6 +1712,7 @@ function interactorPaging(request) { * returned as `answer`. */ async function interactionsOf(request, acc, { page, pageSize }) { + if (pagingUnboxFails({ page, pageSize })) return { answer: unexpected(request) }; const paged = page !== -1 && pageSize !== -1; try { return await read( @@ -1780,20 +1792,28 @@ export function interactorsJson(entities) { return `{"resource":"static","entities":[${written.join(',')}]}`; } -/** graph-core's CustomInteraction, a row of its query, in field order. */ +/** + * graph-core's CustomInteraction, a row of its query, in field order, as its + * Record constructor reads it: asString() writes a missing value as the text + * "null"; speciesName and variantIdentifier are asString(null); a missing + * geneName is an empty list. A missing dbId, score or evidenceCount fails + * asLong() or asDouble(): undefined, the service's failure. + */ export function customInteractionJson(row) { + if ([row.dbId, row.score, row.evidenceCount].some((value) => value === null)) return undefined; + const text = (value) => value ?? 'null'; return nonEmptyJson([ ['dbId', row.dbId], - ['identifier', row.identifier], + ['identifier', text(row.identifier)], ['score', row.score, true], ['evidenceCount', row.evidenceCount], - ['url', row.url], - ['evidenceURL', row.evidenceURL], + ['url', text(row.url)], + ['evidenceURL', text(row.evidenceURL)], ['geneName', row.geneName], - ['databaseName', row.databaseName], + ['databaseName', text(row.databaseName)], ['entitiesCount', row.entitiesCount], ['speciesName', row.speciesName], - ['displayName', row.displayName], + ['displayName', text(row.displayName)], ['variantIdentifier', row.variantIdentifier], ]); }