mirror of
https://github.com/JKorf/CryptoExchange.Net.git
synced 2026-08-11 16:32:57 +00:00
Compare commits
662 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 860d753ad6 | |||
| e16c91792c | |||
| a3d95da9fa | |||
| 907399b878 | |||
| 9eab0d967e | |||
| 0dee68e8ae | |||
| d838d3377f | |||
| 2141ad9061 | |||
| 9be8798ccf | |||
| 4803ed91cd | |||
| 20bddd5c37 | |||
| 0e75ddb3d0 | |||
| 0e5b46002c | |||
| 8f7c71f9ce | |||
| 73377fbb87 | |||
| 3a00d6371a | |||
| caf6d36bcd | |||
| e078a373da | |||
| 007743f5a1 | |||
| d06f891cee | |||
| 8dcbb687f5 | |||
| fcb36f7ee0 | |||
| 3cffd67518 | |||
| ecd00ea707 | |||
| 38a7b981ce | |||
| c41cc3c4c7 | |||
| 4accc8039b | |||
| 87c86ec0c0 | |||
| d9850da282 | |||
| c4a8b02054 | |||
| 34b7258496 | |||
| 69099922c9 | |||
| 64c1cd5fa8 | |||
| 936ac6640b | |||
| 68ad9ae114 | |||
| 6238c17471 | |||
| 52e6fbfe47 | |||
| 4129622d71 | |||
| 95e0aefb9f | |||
| dd1cefdc90 | |||
| f4736bcf49 | |||
| e823114623 | |||
| afb84a1bf0 | |||
| 89cbd85875 | |||
| cc25a405c6 | |||
| d64c1171a3 | |||
| d4aded1ee2 | |||
| 271503c426 | |||
| f0ece589f7 | |||
| 3a9382ca0f | |||
| 1e1a02324a | |||
| 95dd050c73 | |||
| 504836924c | |||
| f176fb5db5 | |||
| 6b575be1ac | |||
| b637d5cdc4 | |||
| a46b018c50 | |||
| 562d1d76c1 | |||
| 7dcb2241c6 | |||
| 8c4cf62d9f | |||
| 6e4dbcf7b1 | |||
| 7853834286 | |||
| 9ae1263662 | |||
| ee30a6716e | |||
| a4b7b273dc | |||
| c92eeb2ec8 | |||
| 4d4b0576ee | |||
| 9add5e0adc | |||
| 93d92beea6 | |||
| a955ccbc5c | |||
| 4e2dc564dd | |||
| 93034e8af8 | |||
| cdd0bd83ab | |||
| 61d371682c | |||
| 8aae769e54 | |||
| 1b4f6926df | |||
| acca3468f3 | |||
| d6b680d42e | |||
| 24123261e5 | |||
| aab30e05f0 | |||
| 54b15b75d3 | |||
| d715c7df59 | |||
| e1d33f252f | |||
| b2ba9e251f | |||
| 2f9f97f4e8 | |||
| 875696a73a | |||
| 90c61715d0 | |||
| 12ca45050f | |||
| eaf092a334 | |||
| 33c0fb26a7 | |||
| dabbb5c868 | |||
| 204bda8622 | |||
| 78e3523a4f | |||
| 89a73747b0 | |||
| 02b70398b3 | |||
| 73fcb47b17 | |||
| d41ca3459e | |||
| bea2b2bd7b | |||
| b29cdc41f3 | |||
| 0ce2e778f4 | |||
| 36c2411d46 | |||
| 6d3e72745a | |||
| 51c74baa26 | |||
| 419e01d009 | |||
| 297eee0e1f | |||
| 6a9231b1e3 | |||
| e40f2a15b6 | |||
| b94085a27a | |||
| 5e083811df | |||
| 7dcf5cd6ea | |||
| 1471a4733f | |||
| f39d9f7cfb | |||
| 9fab8faa45 | |||
| 226f175343 | |||
| 813bd9f5a1 | |||
| c8d2b4f09d | |||
| 6560b82a3e | |||
| e151af8f37 | |||
| bdf7a07c6f | |||
| a8ffe90bf2 | |||
| 3372b9eb44 | |||
| df25221960 | |||
| 7c67a014f5 | |||
| 65291b3195 | |||
| ece6a9d27d | |||
| 1ed29b0474 | |||
| be2eb01353 | |||
| 39cd66596d | |||
| 63f51811a9 | |||
| abda065237 | |||
| 759c8b9a58 | |||
| 8e18482781 | |||
| 87dd2d9d40 | |||
| f2b0cb2f0d | |||
| de0a954a91 | |||
| 4a79ce22ec | |||
| 40d480e1fc | |||
| 74e5cf6fc9 | |||
| 2fd3912795 | |||
| ec44307a0c | |||
| ba55705385 | |||
| 2c63a83117 | |||
| 71b1e5e906 | |||
| eaeba6f27e | |||
| 913bdaa855 | |||
| 5aa5790d0a | |||
| a8321e083e | |||
| ce3fa5f186 | |||
| fff70a9c65 | |||
| cff33bb5ac | |||
| 21c8133292 | |||
| 76772e91ba | |||
| 218e0260ce | |||
| 96b3904266 | |||
| bc8faf9822 | |||
| 90c1b89ceb | |||
| 21206ffb25 | |||
| 5942423bfb | |||
| dc4abc42a7 | |||
| c71a81e686 | |||
| 550c0eabf1 | |||
| 28a2a0c7fd | |||
| 7dd1cd5bbd | |||
| a7ff4416bd | |||
| 669d1f7c9e | |||
| 34ee2d3690 | |||
| 005fb7875d | |||
| fa9300ce97 | |||
| fc2d3fc2d2 | |||
| 187ca6a4ef | |||
| 3b2a85d210 | |||
| c512bee825 | |||
| 0943b052b9 | |||
| a896fffdb3 | |||
| 177daf903b | |||
| aa1ebdc4ed | |||
| 38058c4a70 | |||
| a7eb483479 | |||
| c76931a3b4 | |||
| b90b7e9e0c | |||
| beda53d36d | |||
| 0668f669c1 | |||
| 64250e13db | |||
| 451d38d5e7 | |||
| e11e437bbb | |||
| b8a1ad798d | |||
| 4a851c44f2 | |||
| d079796020 | |||
| f125bc88b0 | |||
| f3d535f286 | |||
| 6e8c6feec2 | |||
| 84b0444caf | |||
| 4be986ebe7 | |||
| 21872f818a | |||
| 4e7c45d758 | |||
| 0e55e5f065 | |||
| b1c5cf318a | |||
| 9d94a24862 | |||
| 6cf9684f55 | |||
| 8043a48c49 | |||
| 7d657dd533 | |||
| 1bfdec1484 | |||
| 8d5b6a53f3 | |||
| ad97102e7c | |||
| d8dc121386 | |||
| 10c3868c00 | |||
| 411ba00a82 | |||
| c1b2b62dbc | |||
| 3960cab7a7 | |||
| 34e9447e55 | |||
| 9d3295acc7 | |||
| 995cd3d84c | |||
| 919cdf0075 | |||
| d181c9cfc1 | |||
| 5943142c44 | |||
| dbc430e838 | |||
| 7413d03d31 | |||
| dd60067684 | |||
| 04e4ddf525 | |||
| 99bf6d7c75 | |||
| 99a203933c | |||
| b43d2a2040 | |||
| ba9c406def | |||
| f5f4d50cc9 | |||
| f87506b490 | |||
| f6f9a53ce5 | |||
| 61130ef54e | |||
| e8bcbd59be | |||
| d433ff7475 | |||
| 71957037d0 | |||
| bcdcdbbd4e | |||
| 1ece13f5bc | |||
| da70ba6ec7 | |||
| a832f0e4d4 | |||
| 6ab4d005a0 | |||
| 18dc935038 | |||
| bb3a534f75 | |||
| 649ba370c6 | |||
| 51732c5ce6 | |||
| 0ba7b46680 | |||
| 94dfbb7b9e | |||
| b8b7512b35 | |||
| aba6b773ce | |||
| d88fb0d356 | |||
| b8c6d55156 | |||
| d9a5481db2 | |||
| 6a8bb42c0e | |||
| 2445f001ab | |||
| c84fa9ac32 | |||
| d44a11c44e | |||
| b215cccda4 | |||
| 3eda488361 | |||
| 993a44de35 | |||
| 99465f99a1 | |||
| d42de1fe90 | |||
| d0284c62c0 | |||
| d92f3b7904 | |||
| 3e1b5ada69 | |||
| 6156fb8154 | |||
| f2753aed1e | |||
| e33d826381 | |||
| 364aa4d324 | |||
| 455b332757 | |||
| 876b895645 | |||
| daf7ed9fe6 | |||
| 3e365f83c9 | |||
| 40977ebdbe | |||
| 4a9058fc1c | |||
| dab9a21608 | |||
| a89c222399 | |||
| 1e356d2a45 | |||
| eed794c2cf | |||
| 2f82e2015b | |||
| ad599badb2 | |||
| 1e45c73f1d | |||
| 49c1fda2c1 | |||
| 32a31e464b | |||
| cddb4167e4 | |||
| 65457d8df2 | |||
| 122a6cad43 | |||
| 4c0e841425 | |||
| 92f5839aec | |||
| 30475dae67 | |||
| 3d942bd503 | |||
| f739520e52 | |||
| 0152603ddb | |||
| aa06e0eead | |||
| 2fde9a285e | |||
| b9f6eb6abb | |||
| d77c4354a6 | |||
| 21860ddf85 | |||
| 2cffa22cc2 | |||
| 985ba9bb29 | |||
| 96f23f163d | |||
| 0e7d49991a | |||
| 3e635cf0fe | |||
| 1425c66c69 | |||
| fc3b7cc75b | |||
| 2cc2dc6ceb | |||
| 7da8cedf66 | |||
| 2cf10668dd | |||
| f1342b5ff2 | |||
| a04b636a11 | |||
| e4637ad295 | |||
| 3a1e43dabe | |||
| 10da1a7bfe | |||
| 37320ca862 | |||
| 2074a5e26f | |||
| 6b14cdbf06 | |||
| 3d6267da93 | |||
| 8def7f32af | |||
| ac295de9f6 | |||
| d412e0895e | |||
| 1f9e2b4fcb | |||
| b13cff5a95 | |||
| 4c050744ad | |||
| 3b15c35a02 | |||
| cd78dbf575 | |||
| a258532d6a | |||
| d2a87a1069 | |||
| e07f24ea0a | |||
| 024e8dcfe2 | |||
| 4bb5aae40a | |||
| dec94678ec | |||
| 1a49fc8251 | |||
| 29b0875960 | |||
| 976ccab1da | |||
| 02bbd37bb6 | |||
| 1bbbec7f2b | |||
| 0262f04913 | |||
| fd1ec17d72 | |||
| 4bdad7fe0c | |||
| 74f73dc790 | |||
| 0527a8a76e | |||
| c693eb8c02 | |||
| 3eb28c7fed | |||
| 618c4922b9 | |||
| c81b15861d | |||
| 4a5832cccd | |||
| 4e47c4cbdf | |||
| 2af1520ecc | |||
| cf397af3ab | |||
| a1479705e2 | |||
| 175e23f110 | |||
| 9b7019ded2 | |||
| 7904aa9ba7 | |||
| 3fe6db589f | |||
| 625dccbbe4 | |||
| e650771d16 | |||
| 3dad28b19d | |||
| 2b9fda985e | |||
| ff8759409b | |||
| 0d9627c13f | |||
| 0179fd7e2a | |||
| b8d0b0cf95 | |||
| 73c42bd452 | |||
| 290be7f5e0 | |||
| 0be1bb16e3 | |||
| 8605196390 | |||
| 460dd97537 | |||
| 1ec5984fad | |||
| 8260c2661d | |||
| 591c1dd405 | |||
| 0164cdfcc4 | |||
| 23a6cfff87 | |||
| fdcdb90a5f | |||
| 0b7107401f | |||
| 06add65354 | |||
| 773d288497 | |||
| fd4e8da938 | |||
| 271743b669 | |||
| f4797caf37 | |||
| 62c9769c72 | |||
| 92d7bc1e2e | |||
| 99e4f96f63 | |||
| 94d8afe149 | |||
| 90ad59c63a | |||
| c2273edfaa | |||
| 236283f4dd | |||
| b66f12ff75 | |||
| 0403384beb | |||
| 7d7bc35869 | |||
| 48797038be | |||
| d21792d04c | |||
| 8414e9d94f | |||
| ab0243445d | |||
| f2cf70b02f | |||
| 9ff417bba8 | |||
| 6b43d08a4d | |||
| 39bf7fe9b9 | |||
| b5893c3b60 | |||
| 15657ba683 | |||
| 1aed9f0c67 | |||
| 17f1560310 | |||
| 41de0a3150 | |||
| 3e410be611 | |||
| be75449e4a | |||
| b1b05c8f6b | |||
| a0e588c3de | |||
| 9e86a08327 | |||
| ed007b5272 | |||
| bdd5526244 | |||
| ce35e30688 | |||
| b40f72b1b0 | |||
| 31a6cf285b | |||
| 1842f4fda0 | |||
| 7a58902ab6 | |||
| 3cb91296ca | |||
| 130ed40580 | |||
| 94cb2caf0b | |||
| 917d060827 | |||
| c58bc2be07 | |||
| ff3356e2b4 | |||
| 79434c7be5 | |||
| 168dabc11f | |||
| 71ee263683 | |||
| 7239b9c289 | |||
| 84d36544e4 | |||
| a71f57ae7f | |||
| 6e5bcd5e9a | |||
| 4131e563c3 | |||
| 613766dbca | |||
| 23b07d709e | |||
| bbbdac2fd3 | |||
| c614b7869c | |||
| 1f31e4a9d7 | |||
| 6cb6cd6b11 | |||
| 17ffec329f | |||
| 7a3927ef49 | |||
| c1b0437c93 | |||
| 23e947f258 | |||
| b8686d60b9 | |||
| 5d3de52da6 | |||
| fee18fd183 | |||
| 3a43d461a3 | |||
| 5f409efad3 | |||
| cc1f0796fe | |||
| b1cd9b5412 | |||
| 42003a0247 | |||
| d89c2bde94 | |||
| 3e6bdaafc6 | |||
| 93e4722a81 | |||
| 355ecb03da | |||
| 994c527c1d | |||
| 69b2e2045e | |||
| 7fde8bf5da | |||
| 637070a7ae | |||
| 7be75f72a7 | |||
| e3fece41f3 | |||
| 87b0c8d7a2 | |||
| ca9a711f22 | |||
| 27597bc994 | |||
| 776d75170d | |||
| 949780a9ad | |||
| 2f64cd9f05 | |||
| 185dfeb6fb | |||
| 68067d6258 | |||
| b309deb0c4 | |||
| e1dafdf0dd | |||
| fd7b5f0f0f | |||
| 3b735d66fd | |||
| 3cd505ac8b | |||
| 81d856d78d | |||
| 11c1ad871a | |||
| ffcb7db8ff | |||
| 02432e5109 | |||
| a85bfb4432 | |||
| 17d85fdd85 | |||
| 5e0733d7f4 | |||
| 28d5287bd4 | |||
| ef5097589a | |||
| f287ec1fa4 | |||
| 28da93af9d | |||
| 0d5bdf5095 | |||
| 8dac3d7aa6 | |||
| 6951f31be7 | |||
| 630f85ec49 | |||
| 9ec4f2276f | |||
| 0a0c66541e | |||
| bb4199620e | |||
| 8a83cd2cb8 | |||
| fcfeaf568f | |||
| 25567ea434 | |||
| 1ab85d4c26 | |||
| be68115099 | |||
| ff0550b0fb | |||
| 1ab1e008fc | |||
| 6f30c72608 | |||
| e927bc3d20 | |||
| 09ed7d1436 | |||
| 6fed657ea6 | |||
| 1555f8da0c | |||
| 68b28fc875 | |||
| 5d50d8cde8 | |||
| 9ff673d8be | |||
| 3e5a34fb56 | |||
| 64ee50d98c | |||
| 6a105c6f8f | |||
| 287aadc720 | |||
| 7229438a0b | |||
| 444af98a15 | |||
| 70c6fa1bbb | |||
| d27f394b46 | |||
| c8c98e13d0 | |||
| 9fcd722991 | |||
| 8080ecccc0 | |||
| 4b6fa9a1b1 | |||
| 0b6dbde7d4 | |||
| fe4d63ba75 | |||
| 04bd3727ca | |||
| 7e6fcd03c2 | |||
| fde8d6353b | |||
| 41b996168a | |||
| b26f8fb900 | |||
| bdbbc61d86 | |||
| d64e200f2f | |||
| 71d54e2f9a | |||
| ba3975993f | |||
| 050286ecd1 | |||
| 96c9a55c48 | |||
| a20cbb2f1c | |||
| 2e957d7d9e | |||
| 18d0341056 | |||
| a2bfed2433 | |||
| 67299338a8 | |||
| 971c049c5f | |||
| bb7ba5ea49 | |||
| 747c986644 | |||
| d88087c8ac | |||
| 968bdc330e | |||
| 7b49562c1d | |||
| 24ba60da47 | |||
| ed5a07fbdb | |||
| 3d3a9b88e7 | |||
| b2f9d5753e | |||
| de46c7bd1d | |||
| 5b11d94f73 | |||
| 6f915a3739 | |||
| d5c4b1bd01 | |||
| 1b1961db00 | |||
| 2dbd5be924 | |||
| 24c40d2dc6 | |||
| 5ef6feb996 | |||
| 85dad6f6f0 | |||
| 3cdcf0d9be | |||
| b90a0a71e9 | |||
| e62786a70f | |||
| 87722f2d28 | |||
| a86276f18d | |||
| f432a66016 | |||
| 9e2910d2ec | |||
| 46fbc1eb85 | |||
| f397d3ab94 | |||
| 81a2da1f3f | |||
| af3303c7b8 | |||
| 8ddd9ecf22 | |||
| de72fe4fb9 | |||
| 108c8fc183 | |||
| e86713e949 | |||
| db9fba4cf2 | |||
| 926802d953 | |||
| 87f5e12b60 | |||
| 034eb83bae | |||
| 7f29275851 | |||
| 2fb3442800 | |||
| 462c857bba | |||
| 61aa589cda | |||
| 27704bf090 | |||
| 4c899861b1 | |||
| 0cff678c2d | |||
| d18514d73c | |||
| 84736cac3f | |||
| cda1cce495 | |||
| fe3a0afd6c | |||
| d533557324 | |||
| d91755dff5 | |||
| 27d49a6093 | |||
| ad1bdd9a3f | |||
| 4f2d7abc7e | |||
| ef9de5e338 | |||
| 8b513e51b9 | |||
| d43b38a23a | |||
| 0987c0f9d1 | |||
| e2dde77023 | |||
| 104ac7caad | |||
| 8788dd3deb | |||
| f64cc5e9cf | |||
| 75d1bbc6e8 | |||
| b621aa7e65 | |||
| 9783108695 | |||
| 6ba32fe280 | |||
| f75cc75bbc | |||
| 2109b65a8e | |||
| a472751638 | |||
| f08ed16f2a | |||
| 212d457a6a | |||
| ac5f333766 | |||
| 640e4387c1 | |||
| a16b19019f | |||
| 2443f576ac | |||
| 4fd7e44015 | |||
| a0a3bda1c5 | |||
| 6bda7a3c73 | |||
| 69a7a714cd | |||
| 48e2e6468e | |||
| 4017ac780f | |||
| a55cd1bb13 | |||
| b34129e148 | |||
| be25a68c9c | |||
| 468cd5e48e | |||
| 262c4e4aa5 | |||
| 4ccff6461f | |||
| 3bfa3ef389 | |||
| 5238971bcc | |||
| 2f5c904faf | |||
| c62775813f | |||
| f11b3754f0 | |||
| 3cbe0465e9 | |||
| 5048aea722 | |||
| 8d35339ab2 | |||
| c3316a51e7 | |||
| 7ecf37064b | |||
| 18954f4f53 | |||
| 00bc245102 | |||
| 273cab9fdb | |||
| 84d0f0ec9e | |||
| 690f2a63e5 | |||
| 19cc020852 | |||
| 1d4353e6d1 | |||
| a02b3f88d7 | |||
| af44ca4c9f | |||
| cf2b57bb96 | |||
| d0a2288910 | |||
| 89c11afc21 | |||
| da6ed580f1 | |||
| 1c33e297e7 | |||
| 0005534a95 | |||
| 11650f7c1a | |||
| a222bb3f02 | |||
| 6361c5ef25 | |||
| 892e8a4508 | |||
| 8336d373f3 | |||
| 71072680a8 | |||
| e13f105019 | |||
| 401577451e | |||
| 5c41ef1ee4 | |||
| ad614830d1 | |||
| 3365837338 | |||
| 66ac2972d6 | |||
| 0d3e05880a | |||
| 997e71f3b7 | |||
| b0fca4587d | |||
| c10671768d | |||
| 91e8123679 | |||
| 417cf2f9ac | |||
| 277be7ab9b | |||
| 45f3459f59 | |||
| 98dad4a8ed | |||
| 1e5f19271b | |||
| 8abeeb4cf0 | |||
| cae0cd9ead | |||
| 811574ae01 |
@@ -0,0 +1,83 @@
|
||||
---
|
||||
description: Conventions for cross-exchange code using CryptoExchange.Net SharedApis abstractions. Apply when generating C# code that interacts with multiple cryptocurrency exchanges through a unified interface.
|
||||
globs:
|
||||
- "**/*.cs"
|
||||
- "**/*.csproj"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# CryptoExchange.Net Conventions
|
||||
|
||||
This codebase uses **CryptoExchange.Net** abstractions for multi-exchange access. Each exchange has its own library (Binance.Net, Bybit.Net, OKX.Net, ...). Use `CryptoExchange.Net.SharedApis` for code that should work across exchanges.
|
||||
|
||||
## Multi-exchange pattern
|
||||
|
||||
```csharp
|
||||
using Binance.Net.Clients;
|
||||
using OKX.Net.Clients;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
|
||||
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
|
||||
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
|
||||
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
// ticker.Data.LastPrice — same model regardless of exchange
|
||||
```
|
||||
|
||||
## Symbol normalization
|
||||
|
||||
`SharedSymbol(TradingMode.Spot, "BTC", "USDT")` is portable. Each library translates to its native format internally. Don't pass raw strings like `"BTCUSDT"` to shared methods.
|
||||
|
||||
## Symbol metadata and catalogs
|
||||
|
||||
In 12.2.0, `SharedSpotSymbol` and `SharedFuturesSymbol` include `DisplayName` and base/quote asset classification through `SharedAssetType` (`Crypto`, `Fiat`, `TradFi`) and `SharedAssetSubType` (`StableCoin`, `Equity`, `Commodity`). Pass the matching base/quote filters to `GetSymbolsRequest` when discovery should return only a class of markets.
|
||||
|
||||
After calling `GetSpotSymbolsAsync`, `ISpotSymbolRestClient.SpotSymbolCatalog` maps asset and symbol names to shared metadata. `IFuturesSymbolRestClient.FuturesSymbolCatalog` works the same way after `GetFuturesSymbolsAsync`. Treat either property as unavailable before its corresponding request has populated the cache.
|
||||
|
||||
When implementing an exchange library, use `LibraryHelpers.IsStableCoin`, `IsCommodity`, and `IsEquity` only as best-effort classifiers and supply exchange-specific additions where needed.
|
||||
|
||||
## Shared market-data quantities
|
||||
|
||||
In 12.4.0, use `SharedOrderQuantity`-valued `Volumes` on shared spot/futures tickers and klines, and `Quantities` on shared trades. The scalar `Volume`, `QuoteVolume`, and `Quantity` members are obsolete.
|
||||
|
||||
## Result pattern
|
||||
|
||||
REST methods return `HttpResult<T>` and websocket subscription methods return `WebSocketResult<UpdateSubscription>`. Always check `.Success`. `.Exchange` property identifies which exchange responded — useful for logging.
|
||||
|
||||
## Available shared interfaces
|
||||
|
||||
- REST tickers/symbols/orderbook/klines/trades, orders (spot/futures, regular/trigger/TP-SL), balances, positions, fees, deposits/withdrawals, transfers
|
||||
- WebSocket tickers, book tickers, order book, trades, klines, user data
|
||||
|
||||
Each exchange documents which it implements. Not every exchange supports every operation.
|
||||
|
||||
## Multi-exchange aggregation
|
||||
|
||||
Run requests across exchanges concurrently via `Task.WhenAll` — the library is async-safe and concurrent requests are the norm.
|
||||
|
||||
```csharp
|
||||
var clients = new ISpotTickerRestClient[] { binance, okx, bybit };
|
||||
var tasks = clients.Select(c => c.GetSpotTickerAsync(new GetTickerRequest(symbol)));
|
||||
var results = await Task.WhenAll(tasks);
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
- ❌ Never install `CryptoExchange.Net` alone and expect to call exchanges — it's a base library; you need exchange-specific packages
|
||||
- ❌ Never mix exchange-specific models in cross-exchange code (use `SharedSymbol`, `SharedSpotTicker`, etc.)
|
||||
- ❌ Never use `.Result` / `.Wait()` — async-only
|
||||
- ❌ Never iterate sequentially when concurrency is fine — `Task.WhenAll` is your friend
|
||||
- ❌ Never instantiate clients per-request — reuse via DI
|
||||
- ✅ Always use `.SharedClient` for cross-exchange code
|
||||
- ✅ Always check `.Success` before reading `.Data`
|
||||
- ✅ Always log with `.Exchange` so multi-exchange logs are decipherable
|
||||
- ✅ Always handle "not supported on this exchange" errors gracefully
|
||||
|
||||
## Reference
|
||||
|
||||
- `AGENTS.md` in repo root has fuller examples
|
||||
- `llms.txt` for AI context
|
||||
- `Examples/ai-friendly/` for compilable examples
|
||||
- For single-exchange code, see that exchange's library (e.g., Binance.Net `AGENTS.md`)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copilot Instructions for CryptoExchange.Net
|
||||
|
||||
This repository is **CryptoExchange.Net** — the base library powering 28+ cryptocurrency exchange wrappers in C#/.NET (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.).
|
||||
|
||||
When generating code in this ecosystem, follow these conventions:
|
||||
|
||||
## You don't install CryptoExchange.Net directly
|
||||
|
||||
Install the exchange-specific library you need (`Binance.Net`, `JK.OKX.Net`, `Bybit.Net`, ...) or `CryptoClients.Net` for the bundle. CryptoExchange.Net is pulled in as a dependency.
|
||||
|
||||
## Multi-exchange code uses SharedApis
|
||||
|
||||
For code that must work against multiple exchanges, use `CryptoExchange.Net.SharedApis` interfaces accessed via `.SharedClient` properties on each exchange's API surface:
|
||||
|
||||
```csharp
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
|
||||
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
|
||||
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
|
||||
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
```
|
||||
|
||||
Same code works on every exchange that implements the interface. Use `Task.WhenAll` for concurrent multi-exchange calls.
|
||||
|
||||
## Shared symbol metadata
|
||||
|
||||
CryptoExchange.Net 12.2.0 classifies the base and quote sides of `SharedSpotSymbol` and `SharedFuturesSymbol` with `SharedAssetType` (`Crypto`, `Fiat`, `TradFi`) and optional `SharedAssetSubType` (`StableCoin`, `Equity`, `Commodity`). The models also expose `DisplayName`. Use the corresponding base/quote fields on `GetSymbolsRequest` to filter symbol discovery.
|
||||
|
||||
`ISpotSymbolRestClient.SpotSymbolCatalog` is populated by `GetSpotSymbolsAsync`; `IFuturesSymbolRestClient.FuturesSymbolCatalog` is populated by `GetFuturesSymbolsAsync`. Do not assume a catalog is available before that request. For exchange-library implementations, `LibraryHelpers.IsStableCoin`, `IsCommodity`, and `IsEquity` offer best-effort classification and can be extended with exchange-specific values.
|
||||
|
||||
## Shared market-data quantities
|
||||
|
||||
CryptoExchange.Net 12.4.0 uses `SharedOrderQuantity` for market-data quantities. Prefer `Volumes` on `SharedSpotTicker`, `SharedFuturesTicker`, and `SharedKline`, and `Quantities` on `SharedTrade`; the scalar `Volume`, `QuoteVolume`, and `Quantity` members are obsolete.
|
||||
|
||||
## Single-exchange code uses the exchange's own client
|
||||
|
||||
For Binance-only code, use `BinanceRestClient` directly (see Binance.Net repo `AGENTS.md`). SharedApis is for portability — use it when you need that.
|
||||
|
||||
## Result pattern
|
||||
|
||||
REST methods return `HttpResult<T>` and websocket subscription methods return `WebSocketResult<UpdateSubscription>`. Check `.Success` before `.Data`. `.Error` has structured info. `.Exchange` on shared clients identifies which exchange responded.
|
||||
|
||||
## Available shared interfaces
|
||||
|
||||
REST: tickers, symbols, orderbook, klines, trades, orders (spot/futures, trigger, TP-SL), balances, positions, fees, deposits/withdrawals, transfers.
|
||||
WebSocket: tickers, book tickers, orderbook, trades, klines, user data.
|
||||
|
||||
Each exchange library implements a subset. Check exchange docs for support matrix.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Installing `CryptoExchange.Net` alone and trying to call exchange APIs (need exchange-specific packages)
|
||||
- Mixing exchange-native models in cross-exchange code (use Shared* types)
|
||||
- Synchronous `.Result` / `.Wait()` (use `await`)
|
||||
- Instantiating clients per-request (use DI, reuse instances)
|
||||
- Sequential per-exchange calls when parallel is fine (`Task.WhenAll`)
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed patterns see `AGENTS.md` and `llms.txt` in repo root, `examples/ai-friendly/` for compilable examples.
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 10.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
|
||||
@@ -287,5 +287,3 @@ __pycache__/
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
CryptoExchange.Net/CryptoExchange.Net.xml
|
||||
/Docs/*
|
||||
Docs/
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
name: cryptoexchange-net
|
||||
description: Use CryptoExchange.Net abstractions when generating C#/.NET code that needs to work across MULTIPLE cryptocurrency exchanges (Binance + Bybit + OKX + Kraken + Coinbase + ...) — for arbitrage, best-execution routing, multi-exchange portfolio dashboards, exchange-agnostic trading bots, or comparison tools. Triggers on requests mentioning multi-exchange, cross-exchange, arbitrage, exchange-agnostic, or unified crypto API in C#. Also triggers when the user is implementing a new exchange library following the CryptoExchange.Net pattern.
|
||||
---
|
||||
|
||||
# CryptoExchange.Net Skill
|
||||
|
||||
## When to use
|
||||
|
||||
CryptoExchange.Net is the **base library** powering 28+ exchange-specific libraries (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.). You don't install it directly — you install the exchange libraries, which depend on it.
|
||||
|
||||
**Three usage modes:**
|
||||
|
||||
1. **You target ONE exchange** → use that exchange's library directly (e.g., Binance.Net), see its CLAUDE.md.
|
||||
2. **You target MULTIPLE exchanges** → install each library you need + use `CryptoExchange.Net.SharedApis` interfaces — write code once, runs against any exchange. **This is the main use case for this skill.**
|
||||
3. **You want ALL exchanges in one package** → install `CryptoClients.Net`, get `ExchangeRestClient` and `ExchangeSocketClient` with everything bundled.
|
||||
|
||||
## Installation
|
||||
|
||||
For a multi-exchange project:
|
||||
|
||||
```bash
|
||||
dotnet add package Binance.Net
|
||||
dotnet add package JK.OKX.Net
|
||||
dotnet add package Bybit.Net
|
||||
# ... etc
|
||||
```
|
||||
|
||||
Or the bundle:
|
||||
|
||||
```bash
|
||||
dotnet add package CryptoClients.Net
|
||||
```
|
||||
|
||||
## Core Pattern: Shared Interfaces
|
||||
|
||||
Every exchange library exposes `.SharedClient` properties on its API surfaces. These implement the same interfaces from `CryptoExchange.Net.SharedApis`.
|
||||
|
||||
```csharp
|
||||
using Binance.Net.Clients;
|
||||
using OKX.Net.Clients;
|
||||
using Bybit.Net.Clients;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
|
||||
// All three implement ISpotTickerRestClient
|
||||
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
|
||||
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
|
||||
ISpotTickerRestClient bybit = new BybitRestClient().V5Api.SharedClient;
|
||||
|
||||
// Single agnostic call — works against any of them
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
// ticker.Data.LastPrice, ticker.Data.HighPrice, etc. — same model regardless of exchange
|
||||
```
|
||||
|
||||
## Core Pattern: SharedSymbol
|
||||
|
||||
Different exchanges format symbols differently — Binance uses `BTCUSDT`, OKX uses `BTC-USDT`, others may have other formats. `SharedSymbol` normalizes this:
|
||||
|
||||
```csharp
|
||||
var btcusdt = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
// Each exchange library translates SharedSymbol → its native format internally.
|
||||
|
||||
// For futures:
|
||||
var btcusdtPerp = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
```
|
||||
|
||||
For exchanges that use exotic asset names, see the AssetAliases configuration.
|
||||
|
||||
## Symbol Metadata and Asset Classification
|
||||
|
||||
Since CryptoExchange.Net 12.2.0, shared symbol responses describe both sides of a market with `BaseAssetType`, `BaseAssetSubType`, `QuoteAssetType`, and `QuoteAssetSubType`. `SharedAssetType` distinguishes `Crypto`, `Fiat`, and `TradFi`; `SharedAssetSubType` distinguishes `StableCoin`, `Equity`, and `Commodity`. `SharedSpotSymbol` and `SharedFuturesSymbol` also expose `DisplayName`.
|
||||
|
||||
The same fields on `GetSymbolsRequest` filter spot or futures symbol discovery:
|
||||
|
||||
```csharp
|
||||
var request = new GetSymbolsRequest(
|
||||
baseAssetType: SharedAssetType.Crypto,
|
||||
quoteAssetSubType: SharedAssetSubType.StableCoin);
|
||||
|
||||
var result = await symbolClient.GetSpotSymbolsAsync(request);
|
||||
```
|
||||
|
||||
After calling `GetSpotSymbolsAsync` or `GetFuturesSymbolsAsync`, use the client's `SpotSymbolCatalog` or `FuturesSymbolCatalog` to look up normalized asset and symbol metadata by name. The catalog is unavailable until the corresponding symbol request has populated the cache.
|
||||
|
||||
For exchange-library implementations, `LibraryHelpers.IsStableCoin`, `IsCommodity`, and `IsEquity` provide best-effort classification of known assets and accept exchange-specific additions. These helpers are heuristics, not an exhaustive source of truth.
|
||||
|
||||
## Shared Market-Data Quantities
|
||||
|
||||
Since CryptoExchange.Net 12.4.0, shared market-data models use `SharedOrderQuantity` so base-asset, quote-asset, and contract quantities remain explicit. Read `SharedSpotTicker.Volumes`, `SharedFuturesTicker.Volumes`, and `SharedKline.Volumes`; read `SharedTrade.Quantities`. The former scalar `Volume`, `QuoteVolume`, and `Quantity` members are obsolete.
|
||||
|
||||
## Available Shared Interfaces
|
||||
|
||||
**REST:**
|
||||
|
||||
- Market data: `ISpotTickerRestClient`, `IBookTickerRestClient`, `ISpotSymbolRestClient`, `IFuturesSymbolRestClient`, `IOrderBookRestClient`, `IRecentTradeRestClient`, `IKlineRestClient`
|
||||
- Orders: `ISpotOrderRestClient`, `IFuturesOrderRestClient`, `ISpotOrderClientIdRestClient`, `IFuturesOrderClientIdRestClient`, `ISpotTriggerOrderRestClient`, `IFuturesTriggerOrderRestClient`, `IFuturesTpSlRestClient`
|
||||
- Account: `IBalanceRestClient`, `IPositionRestClient`, `IFeeRestClient`, `ITransferRestClient`, `IDepositRestClient`, `IWithdrawalRestClient`
|
||||
|
||||
**WebSocket:**
|
||||
|
||||
- `ITickerSocketClient`, `IBookTickerSocketClient`
|
||||
- `IOrderBookSocketClient`, `ITradeSocketClient`, `IKlineSocketClient`
|
||||
- `IUserTradeSocketClient`, `ISpotOrderSocketClient`, `IFuturesOrderSocketClient`, `IPositionSocketClient`, `IBalanceSocketClient`
|
||||
|
||||
Each exchange documents which interfaces it implements (some exchanges don't support every operation).
|
||||
|
||||
## Core Pattern: Result Handling
|
||||
|
||||
Same as exchange-specific libraries: REST calls return `HttpResult<T>` and websocket subscription calls return `WebSocketResult<UpdateSubscription>`, both with `.Success`, `.Data`, and `.Error`. Always check `.Success` first.
|
||||
|
||||
```csharp
|
||||
var result = await sharedClient.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.WriteLine($"[{sharedClient.Exchange}] Error: {result.Error}");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine($"[{sharedClient.Exchange}] {result.Data.LastPrice}");
|
||||
```
|
||||
|
||||
`.Exchange` property on every shared client tells you which exchange you're talking to — useful for logging.
|
||||
|
||||
## Core Pattern: Multi-Exchange Aggregation
|
||||
|
||||
```csharp
|
||||
var clients = new ISpotTickerRestClient[]
|
||||
{
|
||||
new BinanceRestClient().SpotApi.SharedClient,
|
||||
new OKXRestClient().UnifiedApi.SharedClient,
|
||||
new BybitRestClient().V5Api.SharedClient,
|
||||
};
|
||||
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// Fetch concurrently from all exchanges
|
||||
var tasks = clients.Select(c => c.GetSpotTickerAsync(new GetTickerRequest(symbol))).ToArray();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
if (results[i].Success)
|
||||
Console.WriteLine($"{clients[i].Exchange}: {results[i].Data!.LastPrice}");
|
||||
}
|
||||
```
|
||||
|
||||
## Per-Exchange Setup
|
||||
|
||||
Each exchange library has its own credentials class and options. See each library's CLAUDE.md for specifics. The pattern is consistent: `XxxRestClient(options => { options.ApiCredentials = new XxxCredentials(...); })`.
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Each exchange library has its own `services.AddXxx(...)` extension. They all share the same option-builder pattern. Register only the ones you use:
|
||||
|
||||
```csharp
|
||||
services.AddBinance(restOpts => { /*...*/ }, socketOpts => { /*...*/ });
|
||||
services.AddOKX(restOpts => { /*...*/ }, socketOpts => { /*...*/ });
|
||||
// Inject IBinanceRestClient, IOKXRestClient, etc.
|
||||
```
|
||||
|
||||
For one-package access: `services.AddCryptoClients(...)` from `CryptoClients.Net`.
|
||||
|
||||
## Common Pitfalls — AVOID
|
||||
|
||||
- **Do NOT install `CryptoExchange.Net` and try to call exchange APIs directly** — it's a base abstraction; you need an exchange library.
|
||||
- **Do NOT try to use one exchange's models with another's client** — use the SharedApis types (`SharedSymbol`, `SharedSpotTicker`, `SharedSpotOrder`, etc.) for cross-exchange code.
|
||||
- **Do NOT block on async operations** — use `await` throughout. `Task.WhenAll` for parallelism across exchanges.
|
||||
- **Do NOT assume every exchange supports every operation** — check exchange docs or the library's implementation. Operations may return errors like "not supported on this exchange".
|
||||
- **Do NOT instantiate clients per-request** — reuse via DI.
|
||||
- **Do NOT iterate exchanges sequentially when concurrency is fine** — use `Task.WhenAll` for ~Nx speedup.
|
||||
|
||||
## Implementing a New Exchange Library
|
||||
|
||||
If you're building a NEW exchange wrapper following the CryptoExchange.Net pattern (rare but valuable):
|
||||
|
||||
- Inherit from `RestApiClient` and `SocketApiClient` base classes
|
||||
- Define your own `XxxCredentials` extending `ApiCredentials` (or use `ApiCredentials` directly)
|
||||
- Implement `AuthenticationProvider` for the exchange's signing scheme
|
||||
- Implement the relevant `Shared*` interfaces on your API client classes for cross-exchange support
|
||||
- Follow the same `XxxRestOptions` / `XxxSocketOptions` pattern
|
||||
|
||||
See existing libraries (Binance.Net, Bybit.Net) as reference implementations.
|
||||
|
||||
## Reference
|
||||
|
||||
- Source: https://github.com/JKorf/CryptoExchange.Net
|
||||
- Documentation: https://cryptoexchange.jkorf.dev/
|
||||
- SharedApis docs: https://cryptoexchange.jkorf.dev/CryptoExchange.Net/idocs_shared.html
|
||||
- Bundle (all exchanges): https://github.com/JKorf/CryptoClients.Net
|
||||
- Demo app: https://github.com/JKorf/CryptoManager.Net
|
||||
- Discord: https://discord.gg/MSpeEtSY8t
|
||||
@@ -1,9 +1,10 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
@@ -24,8 +25,8 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var result1 = await waiter1;
|
||||
var result2 = await waiter2;
|
||||
|
||||
Assert.True(result1);
|
||||
Assert.True(result2);
|
||||
Assert.That(result1);
|
||||
Assert.That(result2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -39,8 +40,8 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var result1 = await waiter1;
|
||||
var result2 = await waiter2;
|
||||
|
||||
Assert.True(result1);
|
||||
Assert.True(result2);
|
||||
Assert.That(result1);
|
||||
Assert.That(result2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -55,14 +56,14 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
var result1 = await waiter1;
|
||||
|
||||
Assert.True(result1);
|
||||
Assert.True(waiter2.Status != TaskStatus.RanToCompletion);
|
||||
Assert.That(result1);
|
||||
Assert.That(waiter2.Status != TaskStatus.RanToCompletion);
|
||||
|
||||
evnt.Set();
|
||||
|
||||
var result2 = await waiter2;
|
||||
|
||||
Assert.True(result2);
|
||||
Assert.That(result2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -75,13 +76,13 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
var result1 = await waiter1;
|
||||
|
||||
Assert.True(result1);
|
||||
Assert.True(waiter2.Status != TaskStatus.RanToCompletion);
|
||||
Assert.That(result1);
|
||||
Assert.That(waiter2.Status != TaskStatus.RanToCompletion);
|
||||
evnt.Set();
|
||||
|
||||
var result2 = await waiter2;
|
||||
|
||||
Assert.True(result2);
|
||||
Assert.That(result2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -90,27 +91,25 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var evnt = new AsyncResetEvent(false, true);
|
||||
|
||||
var waiters = new List<Task<bool>>();
|
||||
for(var i = 0; i < 10; i++)
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
waiters.Add(evnt.WaitAsync());
|
||||
}
|
||||
|
||||
List<bool> results = null;
|
||||
var resultsWaiter = Task.Run(async () =>
|
||||
{
|
||||
await Task.WhenAll(waiters);
|
||||
results = waiters.Select(w => w.Result).ToList();
|
||||
});
|
||||
var remaining = waiters.ToList();
|
||||
|
||||
for(var i = 1; i <= 10; i++)
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
evnt.Set();
|
||||
Assert.AreEqual(10 - i, waiters.Count(w => w.Status != TaskStatus.RanToCompletion));
|
||||
|
||||
var completed = await Task.WhenAny(remaining);
|
||||
Assert.That(await completed, Is.True);
|
||||
|
||||
remaining.Remove(completed);
|
||||
Assert.That(remaining.Count(w => w.IsCompleted), Is.Zero);
|
||||
}
|
||||
|
||||
await resultsWaiter;
|
||||
|
||||
Assert.AreEqual(10, results.Count(r => r));
|
||||
Assert.That(remaining, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -124,7 +123,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
var result1 = await waiter1;
|
||||
|
||||
Assert.True(result1);
|
||||
Assert.That(result1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -134,9 +133,21 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
var waiter1 = evnt.WaitAsync(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
var result1 = await waiter1;
|
||||
var result1 = await waiter1;
|
||||
|
||||
Assert.False(result1);
|
||||
ClassicAssert.False(result1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CancellingWait_Should_ReturnFalse()
|
||||
{
|
||||
var evnt = new AsyncResetEvent(false, true);
|
||||
|
||||
var waiter1 = evnt.WaitAsync(ct: new CancellationTokenSource(50).Token);
|
||||
|
||||
var result1 = await waiter1;
|
||||
|
||||
ClassicAssert.False(result1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class BaseClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public void SettingLogOutput_Should_RedirectLogOutput()
|
||||
{
|
||||
// arrange
|
||||
var logger = new TestStringLogger();
|
||||
var client = new TestBaseClient(new BaseRestClientOptions()
|
||||
{
|
||||
LogWriters = new List<ILogger> { logger }
|
||||
});
|
||||
|
||||
// act
|
||||
client.Log(LogLevel.Information, "Test");
|
||||
|
||||
// assert
|
||||
Assert.IsFalse(string.IsNullOrEmpty(logger.GetLogs()));
|
||||
}
|
||||
|
||||
[TestCase(LogLevel.None, LogLevel.Error, false)]
|
||||
[TestCase(LogLevel.None, LogLevel.Warning, false)]
|
||||
[TestCase(LogLevel.None, LogLevel.Information, false)]
|
||||
[TestCase(LogLevel.None, LogLevel.Debug, false)]
|
||||
[TestCase(LogLevel.Error, LogLevel.Error, true)]
|
||||
[TestCase(LogLevel.Error, LogLevel.Warning, false)]
|
||||
[TestCase(LogLevel.Error, LogLevel.Information, false)]
|
||||
[TestCase(LogLevel.Error, LogLevel.Debug, false)]
|
||||
[TestCase(LogLevel.Warning, LogLevel.Error, true)]
|
||||
[TestCase(LogLevel.Warning, LogLevel.Warning, true)]
|
||||
[TestCase(LogLevel.Warning, LogLevel.Information, false)]
|
||||
[TestCase(LogLevel.Warning, LogLevel.Debug, false)]
|
||||
[TestCase(LogLevel.Information, LogLevel.Error, true)]
|
||||
[TestCase(LogLevel.Information, LogLevel.Warning, true)]
|
||||
[TestCase(LogLevel.Information, LogLevel.Information, true)]
|
||||
[TestCase(LogLevel.Information, LogLevel.Debug, false)]
|
||||
[TestCase(LogLevel.Debug, LogLevel.Error, true)]
|
||||
[TestCase(LogLevel.Debug, LogLevel.Warning, true)]
|
||||
[TestCase(LogLevel.Debug, LogLevel.Information, true)]
|
||||
[TestCase(LogLevel.Debug, LogLevel.Debug, true)]
|
||||
[TestCase(null, LogLevel.Error, true)]
|
||||
[TestCase(null, LogLevel.Warning, true)]
|
||||
[TestCase(null, LogLevel.Information, true)]
|
||||
[TestCase(null, LogLevel.Debug, false)]
|
||||
public void SettingLogLevel_Should_RestrictLogging(LogLevel? verbosity, LogLevel testVerbosity, bool expected)
|
||||
{
|
||||
// arrange
|
||||
var logger = new TestStringLogger();
|
||||
var options = new BaseRestClientOptions()
|
||||
{
|
||||
LogWriters = new List<ILogger> { logger }
|
||||
};
|
||||
if (verbosity != null)
|
||||
options.LogLevel = verbosity.Value;
|
||||
var client = new TestBaseClient(options);
|
||||
|
||||
// act
|
||||
client.Log(testVerbosity, "Test");
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(!string.IsNullOrEmpty(logger.GetLogs()), expected);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void DeserializingValidJson_Should_GiveSuccessfulResult()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestBaseClient();
|
||||
|
||||
// act
|
||||
var result = client.Deserialize<object>("{\"testProperty\": 123}");
|
||||
|
||||
// assert
|
||||
Assert.IsTrue(result.Success);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void DeserializingInvalidJson_Should_GiveErrorResult()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestBaseClient();
|
||||
|
||||
// act
|
||||
var result = client.Deserialize<object>("{\"testProperty\": 123");
|
||||
|
||||
// assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.IsTrue(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "/path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1/", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1/", "/path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api/", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com", new[] { "test-path/test-path" }, "https://api.test.com/test-path/test-path")]
|
||||
[TestCase("https://api.test.com/", new[] { "test-path/test-path" }, "https://api.test.com/test-path/test-path")]
|
||||
public void AppendPathTests(string baseUrl, string[] path, string expected)
|
||||
{
|
||||
var result = baseUrl.AppendPath(path);
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using CryptoExchange.Net;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
internal class BodySerializationTests
|
||||
{
|
||||
[Test]
|
||||
public void ToFormData_SerializesBasicValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", "1" },
|
||||
{ "b", 2 },
|
||||
{ "c", true }
|
||||
};
|
||||
|
||||
var parameterString = parameters.ToFormData();
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JsonSerializer_SerializesBasicValuesCorrectly()
|
||||
{
|
||||
var serializer = new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", "1" },
|
||||
{ "b", 2 },
|
||||
{ "c", true }
|
||||
};
|
||||
|
||||
var parameterString = serializer.Serialize(parameters);
|
||||
Assert.That(parameterString, Is.EqualTo("{\"a\":\"1\",\"b\":2,\"c\":true}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
@@ -15,149 +14,41 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[Test]
|
||||
public void TestBasicErrorCallResult()
|
||||
{
|
||||
var result = new CallResult(new ServerError("TestError"));
|
||||
var result = CallResult.Fail(new ServerError("TestError", ErrorInfo.Unknown));
|
||||
|
||||
Assert.AreEqual(result.Error.Message, "TestError");
|
||||
Assert.IsFalse(result);
|
||||
Assert.IsFalse(result.Success);
|
||||
ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError");
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestBasicSuccessCallResult()
|
||||
{
|
||||
var result = new CallResult(null);
|
||||
var result = CallResult.Ok();
|
||||
|
||||
Assert.IsNull(result.Error);
|
||||
Assert.IsTrue(result);
|
||||
Assert.IsTrue(result.Success);
|
||||
ClassicAssert.IsNull(result.Error);
|
||||
Assert.That(result.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestCallResultError()
|
||||
{
|
||||
var result = new CallResult<object>(new ServerError("TestError"));
|
||||
var result = CallResult.Fail<object>(new ServerError("TestError", ErrorInfo.Unknown));
|
||||
|
||||
Assert.AreEqual(result.Error.Message, "TestError");
|
||||
Assert.IsNull(result.Data);
|
||||
Assert.IsFalse(result);
|
||||
Assert.IsFalse(result.Success);
|
||||
ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError");
|
||||
ClassicAssert.IsNull(result.Data);
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestCallResultSuccess()
|
||||
{
|
||||
var result = new CallResult<object>(new object());
|
||||
var result = CallResult.Ok<object>(new object());
|
||||
|
||||
Assert.IsNull(result.Error);
|
||||
Assert.IsNotNull(result.Data);
|
||||
Assert.IsTrue(result);
|
||||
Assert.IsTrue(result.Success);
|
||||
ClassicAssert.IsNull(result.Error);
|
||||
ClassicAssert.IsNotNull(result.Data);
|
||||
Assert.That(result.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestCallResultSuccessAs()
|
||||
{
|
||||
var result = new CallResult<TestObjectResult>(new TestObjectResult());
|
||||
var asResult = result.As<TestObject2>(result.Data.InnerData);
|
||||
|
||||
Assert.IsNull(asResult.Error);
|
||||
Assert.IsNotNull(asResult.Data);
|
||||
Assert.IsTrue(asResult.Data is TestObject2);
|
||||
Assert.IsTrue(asResult);
|
||||
Assert.IsTrue(asResult.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestCallResultErrorAs()
|
||||
{
|
||||
var result = new CallResult<TestObjectResult>(new ServerError("TestError"));
|
||||
var asResult = result.As<TestObject2>(default);
|
||||
|
||||
Assert.IsNotNull(asResult.Error);
|
||||
Assert.AreEqual(asResult.Error.Message, "TestError");
|
||||
Assert.IsNull(asResult.Data);
|
||||
Assert.IsFalse(asResult);
|
||||
Assert.IsFalse(asResult.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestCallResultErrorAsError()
|
||||
{
|
||||
var result = new CallResult<TestObjectResult>(new ServerError("TestError"));
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2"));
|
||||
|
||||
Assert.IsNotNull(asResult.Error);
|
||||
Assert.AreEqual(asResult.Error.Message, "TestError2");
|
||||
Assert.IsNull(asResult.Data);
|
||||
Assert.IsFalse(asResult);
|
||||
Assert.IsFalse(asResult.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestWebCallResultErrorAsError()
|
||||
{
|
||||
var result = new WebCallResult<TestObjectResult>(new ServerError("TestError"));
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2"));
|
||||
|
||||
Assert.IsNotNull(asResult.Error);
|
||||
Assert.AreEqual(asResult.Error.Message, "TestError2");
|
||||
Assert.IsNull(asResult.Data);
|
||||
Assert.IsFalse(asResult);
|
||||
Assert.IsFalse(asResult.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestWebCallResultSuccessAsError()
|
||||
{
|
||||
var result = new WebCallResult<TestObjectResult>(
|
||||
System.Net.HttpStatusCode.OK,
|
||||
new List<KeyValuePair<string, IEnumerable<string>>>(),
|
||||
TimeSpan.FromSeconds(1),
|
||||
"{}",
|
||||
"https://test.com/api",
|
||||
null,
|
||||
HttpMethod.Get,
|
||||
new List<KeyValuePair<string, IEnumerable<string>>>(),
|
||||
new TestObjectResult(),
|
||||
null);
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2"));
|
||||
|
||||
Assert.IsNotNull(asResult.Error);
|
||||
Assert.AreEqual(asResult.Error.Message, "TestError2");
|
||||
Assert.AreEqual(asResult.ResponseStatusCode, System.Net.HttpStatusCode.OK);
|
||||
Assert.AreEqual(asResult.ResponseTime, TimeSpan.FromSeconds(1));
|
||||
Assert.AreEqual(asResult.RequestUrl, "https://test.com/api");
|
||||
Assert.AreEqual(asResult.RequestMethod, HttpMethod.Get);
|
||||
Assert.IsNull(asResult.Data);
|
||||
Assert.IsFalse(asResult);
|
||||
Assert.IsFalse(asResult.Success);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestWebCallResultSuccessAsSuccess()
|
||||
{
|
||||
var result = new WebCallResult<TestObjectResult>(
|
||||
System.Net.HttpStatusCode.OK,
|
||||
new List<KeyValuePair<string, IEnumerable<string>>>(),
|
||||
TimeSpan.FromSeconds(1),
|
||||
"{}",
|
||||
"https://test.com/api",
|
||||
null,
|
||||
HttpMethod.Get,
|
||||
new List<KeyValuePair<string, IEnumerable<string>>>(),
|
||||
new TestObjectResult(),
|
||||
null);
|
||||
var asResult = result.As<TestObject2>(result.Data.InnerData);
|
||||
|
||||
Assert.IsNull(asResult.Error);
|
||||
Assert.AreEqual(asResult.ResponseStatusCode, System.Net.HttpStatusCode.OK);
|
||||
Assert.AreEqual(asResult.ResponseTime, TimeSpan.FromSeconds(1));
|
||||
Assert.AreEqual(asResult.RequestUrl, "https://test.com/api");
|
||||
Assert.AreEqual(asResult.RequestMethod, HttpMethod.Get);
|
||||
Assert.IsNotNull(asResult.Data);
|
||||
Assert.IsTrue(asResult);
|
||||
Assert.IsTrue(asResult.Success);
|
||||
}
|
||||
}
|
||||
|
||||
public class TestObjectResult
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ClientTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class BaseClientTests
|
||||
{
|
||||
//[TestCase]
|
||||
//public void DeserializingValidJson_Should_GiveSuccessfulResult()
|
||||
//{
|
||||
// // arrange
|
||||
// var client = new TestBaseClient();
|
||||
|
||||
// // act
|
||||
// var result = client.SubClient.Deserialize<object>("{\"testProperty\": 123}");
|
||||
|
||||
// // assert
|
||||
// Assert.That(result.Success);
|
||||
//}
|
||||
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "/path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1/", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1/", "/path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api/", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com", new[] { "test-path/test-path" }, "https://api.test.com/test-path/test-path")]
|
||||
[TestCase("https://api.test.com/", new[] { "test-path/test-path" }, "https://api.test.com/test-path/test-path")]
|
||||
public void AppendPathTests(string baseUrl, string[] path, string expected)
|
||||
{
|
||||
var result = baseUrl.AppendPath(path);
|
||||
Assert.That(expected == result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using NUnit.Framework.Legacy;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Guards;
|
||||
using CryptoExchange.Net.RateLimiting.Filters;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using System.Text.Json;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using CryptoExchange.Net.Testing;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ClientTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class RestClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public async Task RequestingData_Should_ResultInData()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() });
|
||||
client.ApiClient1.SetNextResponse(strData, System.Net.HttpStatusCode.OK);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
Assert.That(result.Success);
|
||||
Assert.That(TestHelpers.AreEqual(expected, result.Data));
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingInvalidData_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("{\"property\": 123", System.Net.HttpStatusCode.OK);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorCode_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("Invalid request", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndNotParsingError_Should_ResultInFlatError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is ServerError);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndNotParsingErrorAndInvalidJson_Should_ContainData()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
var response = "<html>...</html>";
|
||||
client.ApiClient1.SetNextResponse(response, System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is DeserializeError);
|
||||
Assert.That(result.Error!.Message!.Contains(response));
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndParsingError_Should_ResultInParsedError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is ServerError);
|
||||
Assert.That(result.Error!.ErrorCode == "123");
|
||||
Assert.That(result.Error.Message == "Invalid request");
|
||||
}
|
||||
|
||||
[TestCase("GET", HttpMethodParameterPosition.InUri)] // No need to test InBody for GET since thats not valid
|
||||
[TestCase("POST", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("POST", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InBody)]
|
||||
public async Task Setting_Should_ResultInOptionsSet(string method, HttpMethodParameterPosition pos)
|
||||
{
|
||||
// arrange
|
||||
// act
|
||||
var client = new TestRestClient();
|
||||
|
||||
var httpMethod = new HttpMethod(method);
|
||||
client.ApiClient1.SetParameterPosition(httpMethod, pos);
|
||||
client.ApiClient1.SetNextResponse("{}", System.Net.HttpStatusCode.OK);
|
||||
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>(httpMethod, new Parameters(new ParameterSerializationSettings())
|
||||
{
|
||||
{ "TestParam1", "Value1" },
|
||||
{ "TestParam2", 2 },
|
||||
});
|
||||
|
||||
// assert
|
||||
Assert.That(result.RequestMethod == new HttpMethod(method));
|
||||
Assert.That(result.RequestBody?.Contains("TestParam1") == true == (pos == HttpMethodParameterPosition.InBody));
|
||||
Assert.That((result.RequestUrl?.ToString().Contains("TestParam1")) == (pos == HttpMethodParameterPosition.InUri));
|
||||
Assert.That(result.RequestBody?.Contains("TestParam2") == true == (pos == HttpMethodParameterPosition.InBody));
|
||||
Assert.That((result.RequestUrl?.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Testing;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ClientTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SocketClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public void SettingOptions_Should_ResultInOptionsSet()
|
||||
{
|
||||
//arrange
|
||||
//act
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ExchangeOptions.MaxSocketConnections = 1;
|
||||
});
|
||||
|
||||
//assert
|
||||
Assert.That(1 == client.ApiClient1.ApiOptions.MaxSocketConnections);
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public async Task ConnectSocket_Should_ReturnConnectionResult(bool canConnect)
|
||||
{
|
||||
//arrange
|
||||
var client = new TestSocketClient();
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
socket.CanConnect = canConnect;
|
||||
|
||||
//act
|
||||
var connectResult = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
//assert
|
||||
Assert.That(connectResult.Success == canConnect);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task SocketMessages_Should_BeProcessedInDataHandlers()
|
||||
{
|
||||
var client = new TestSocketClient();
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() });
|
||||
|
||||
TestObject? received = null;
|
||||
var resetEvent = new AsyncResetEvent(false);
|
||||
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x =>
|
||||
{
|
||||
received = x.Data;
|
||||
resetEvent.Set();
|
||||
}, false, default);
|
||||
|
||||
socket.InvokeMessage(strData);
|
||||
await resetEvent.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.That(received != null);
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled)
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
options.ExchangeOptions.OutputOriginalData = enabled;
|
||||
});
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() });
|
||||
|
||||
string? originalData = null;
|
||||
var resetEvent = new AsyncResetEvent(false);
|
||||
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x =>
|
||||
{
|
||||
originalData = x.OriginalData;
|
||||
resetEvent.Set();
|
||||
}, false, default);
|
||||
|
||||
socket.InvokeMessage(strData);
|
||||
await resetEvent.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
|
||||
// assert
|
||||
Assert.That(originalData == (enabled ? strData : null));
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task UnsubscribingStream_Should_CloseTheSocket()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
});
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
|
||||
var result = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => {}, false, default);
|
||||
|
||||
// act
|
||||
await client.UnsubscribeAsync(result.Data!);
|
||||
|
||||
// assert
|
||||
Assert.That(socket.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task UnsubscribingAll_Should_CloseAllSockets()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
});
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var result = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
var socket2 = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var result2 = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
// act
|
||||
await client.UnsubscribeAllAsync();
|
||||
|
||||
// assert
|
||||
Assert.That(socket.Connected == false);
|
||||
Assert.That(socket2.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task BatchedSubscription_Should_NotExceedIndividualCombineTarget()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.SocketSubscriptionsCombineTarget = 10;
|
||||
options.SocketIndividualSubscriptionCombineTarget = 10;
|
||||
});
|
||||
TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
|
||||
// act
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default, individualSubscriptionCount: 6);
|
||||
TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default, individualSubscriptionCount: 6);
|
||||
|
||||
// assert
|
||||
Assert.That(client.ApiClient1._socketConnections.Count == 2);
|
||||
Assert.That(client.ApiClient1._socketConnections.Values.All(connection => connection.Subscriptions.Sum(subscription => subscription.IndividualSubscriptionCount) <= 10));
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task BatchedSubscription_FullIndividualConnection_Should_NotPreventEligibleConnectionReuse()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.SocketSubscriptionsCombineTarget = 5;
|
||||
options.SocketIndividualSubscriptionCombineTarget = 10;
|
||||
});
|
||||
TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default, individualSubscriptionCount: 10);
|
||||
|
||||
TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
|
||||
// act
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
// assert
|
||||
Assert.That(
|
||||
client.ApiClient1._socketConnections.Count,
|
||||
Is.EqualTo(2),
|
||||
"The eligible connection should be reused instead of opening a new connection after selecting a full individual-subscription connection");
|
||||
|
||||
var fullConnection = client.ApiClient1._socketConnections.Values
|
||||
.Single(connection => connection.Subscriptions.Sum(subscription => subscription.IndividualSubscriptionCount) == 10);
|
||||
Assert.That(
|
||||
fullConnection.UserSubscriptionCount,
|
||||
Is.EqualTo(1),
|
||||
"The full connection should not receive the normal subscription");
|
||||
|
||||
var eligibleConnection = client.ApiClient1._socketConnections.Values.Single(connection => connection != fullConnection);
|
||||
Assert.That(
|
||||
eligibleConnection.UserSubscriptionCount,
|
||||
Is.EqualTo(3),
|
||||
"The existing eligible connection should receive the normal subscription");
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task ErrorResponse_ShouldNot_ConfirmSubscription()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(opt =>
|
||||
{
|
||||
opt.OutputOriginalData = true;
|
||||
});
|
||||
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var subTask = client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, true, default);
|
||||
|
||||
socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "ErrorWithSub" }));
|
||||
|
||||
var result = await subTask;
|
||||
|
||||
// assert
|
||||
Assert.That(result.Success == false);
|
||||
Assert.That(result.Error!.Message!.Contains("ErrorWithSub"));
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task SuccessResponse_Should_ConfirmSubscription()
|
||||
{
|
||||
var client = new TestSocketClient();
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var subTask = client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, true, default);
|
||||
|
||||
socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "OK" }));
|
||||
|
||||
var result = await subTask;
|
||||
|
||||
var subscription = client.ApiClient1._socketConnections.Single().Value.Subscriptions.Single();
|
||||
Assert.That(subscription.Status == SubscriptionStatus.Subscribed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using CryptoExchange.Net.Converters;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class ConverterTests
|
||||
{
|
||||
[TestCase("2021-05-12")]
|
||||
[TestCase("20210512")]
|
||||
[TestCase("210512")]
|
||||
[TestCase("1620777600.000")]
|
||||
[TestCase("1620777600000")]
|
||||
[TestCase("2021-05-12T00:00:00.000Z")]
|
||||
[TestCase("2021-05-12T00:00:00.000000000Z")]
|
||||
[TestCase("", true)]
|
||||
[TestCase(" ", true)]
|
||||
public void TestDateTimeConverterString(string input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonConvert.DeserializeObject<TimeObject>($"{{ \"time\": \"{input}\" }}");
|
||||
Assert.AreEqual(output.Time, expectNull ? null: new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[TestCase(1620777600.000)]
|
||||
[TestCase(1620777600000d)]
|
||||
public void TestDateTimeConverterDouble(double input)
|
||||
{
|
||||
var output = JsonConvert.DeserializeObject<TimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.AreEqual(output.Time, new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000000)]
|
||||
[TestCase(1620777600000000000)]
|
||||
[TestCase(0, true)]
|
||||
public void TestDateTimeConverterLong(long input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonConvert.DeserializeObject<TimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.AreEqual(output.Time, expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600.000)]
|
||||
public void TestDateTimeConverterFromSeconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromSeconds(input);
|
||||
Assert.AreEqual(output, new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToSeconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.AreEqual(output, 1620777600);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000.000)]
|
||||
public void TestDateTimeConverterFromMilliseconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMilliseconds(input);
|
||||
Assert.AreEqual(output, new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMilliseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.AreEqual(output, 1620777600000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000)]
|
||||
public void TestDateTimeConverterFromMicroseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMicroseconds(input);
|
||||
Assert.AreEqual(output, new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMicroseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.AreEqual(output, 1620777600000000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000000)]
|
||||
public void TestDateTimeConverterFromNanoseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromNanoseconds(input);
|
||||
Assert.AreEqual(output, new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToNanoseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.AreEqual(output, 1620777600000000000);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void TestDateTimeConverterNull()
|
||||
{
|
||||
var output = JsonConvert.DeserializeObject<TimeObject>($"{{ \"time\": null }}");
|
||||
Assert.AreEqual(output.Time, null);
|
||||
}
|
||||
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.AreEqual(output, expected);
|
||||
}
|
||||
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
public void TestEnumConverterGetStringTests(TestEnum value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.AreEqual(output, expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", null)]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonConvert.DeserializeObject<EnumObject>($"{{ \"Value\": {val} }}");
|
||||
Assert.AreEqual(output.Value, expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", TestEnum.One)]
|
||||
[TestCase(null, TestEnum.One)]
|
||||
public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonConvert.DeserializeObject<NotNullableEnumObject>($"{{ \"Value\": {val} }}");
|
||||
Assert.AreEqual(output.Value, expected);
|
||||
}
|
||||
}
|
||||
|
||||
public class TimeObject
|
||||
{
|
||||
[JsonConverter(typeof(DateTimeConverter))]
|
||||
public DateTime? Time { get; set; }
|
||||
}
|
||||
|
||||
public class EnumObject
|
||||
{
|
||||
public TestEnum? Value { get; set; }
|
||||
}
|
||||
|
||||
public class NotNullableEnumObject
|
||||
{
|
||||
public TestEnum Value { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(EnumConverter))]
|
||||
public enum TestEnum
|
||||
{
|
||||
[Map("1")]
|
||||
One,
|
||||
[Map("2")]
|
||||
Two,
|
||||
[Map("three", "3")]
|
||||
Three,
|
||||
Four
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using CryptoExchange.Net.Converters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class ArrayConverterTests
|
||||
{
|
||||
[Test()]
|
||||
public void TestArrayConverter()
|
||||
{
|
||||
var data = new Test()
|
||||
{
|
||||
Prop1 = 2,
|
||||
Prop2 = null,
|
||||
Prop3 = "123",
|
||||
Prop3Again = "123",
|
||||
Prop4 = null,
|
||||
Prop5 = new Test2
|
||||
{
|
||||
Prop21 = 3,
|
||||
Prop22 = "456"
|
||||
},
|
||||
Prop6 = new Test3
|
||||
{
|
||||
Prop31 = 4,
|
||||
Prop32 = "789"
|
||||
},
|
||||
Prop7 = TestEnum.Two,
|
||||
TestInternal = new Test
|
||||
{
|
||||
Prop1 = 10
|
||||
},
|
||||
Prop8 = new Test3
|
||||
{
|
||||
Prop31 = 5,
|
||||
Prop32 = "101"
|
||||
},
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions()
|
||||
{
|
||||
TypeInfoResolver = new TestSerializerContext()
|
||||
};
|
||||
var serialized = JsonSerializer.Serialize(data);
|
||||
var deserialized = JsonSerializer.Deserialize<Test>(serialized);
|
||||
|
||||
Assert.That(deserialized!.Prop1, Is.EqualTo(2));
|
||||
Assert.That(deserialized.Prop2, Is.Null);
|
||||
Assert.That(deserialized.Prop3, Is.EqualTo("123"));
|
||||
Assert.That(deserialized.Prop3Again, Is.EqualTo("123"));
|
||||
Assert.That(deserialized.Prop4, Is.Null);
|
||||
Assert.That(deserialized.Prop5!.Prop21, Is.EqualTo(3));
|
||||
Assert.That(deserialized.Prop5!.Prop22, Is.EqualTo("456"));
|
||||
Assert.That(deserialized.Prop6!.Prop31, Is.EqualTo(4));
|
||||
Assert.That(deserialized.Prop6.Prop32, Is.EqualTo("789"));
|
||||
Assert.That(deserialized.Prop7, Is.EqualTo(TestEnum.Two));
|
||||
Assert.That(deserialized.TestInternal!.Prop1, Is.EqualTo(10));
|
||||
Assert.That(deserialized.Prop8!.Prop31, Is.EqualTo(5));
|
||||
Assert.That(deserialized.Prop8.Prop32, Is.EqualTo("101"));
|
||||
}
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ArrayConverter<Test>))]
|
||||
public record Test
|
||||
{
|
||||
[ArrayProperty(0)]
|
||||
public int Prop1 { get; set; }
|
||||
[ArrayProperty(1)]
|
||||
public int? Prop2 { get; set; }
|
||||
[ArrayProperty(2)]
|
||||
public string? Prop3 { get; set; }
|
||||
[ArrayProperty(2)]
|
||||
public string? Prop3Again { get; set; }
|
||||
[ArrayProperty(3)]
|
||||
public string? Prop4 { get; set; }
|
||||
[ArrayProperty(4)]
|
||||
public Test2? Prop5 { get; set; }
|
||||
[ArrayProperty(5)]
|
||||
public Test3? Prop6 { get; set; }
|
||||
[ArrayProperty(6), JsonConverter(typeof(EnumConverter<TestEnum>))]
|
||||
public TestEnum? Prop7 { get; set; }
|
||||
[ArrayProperty(7)]
|
||||
public Test? TestInternal { get; set; }
|
||||
[ArrayProperty(8), JsonConversion]
|
||||
public Test3? Prop8 { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ArrayConverter<Test2>))]
|
||||
public record Test2
|
||||
{
|
||||
[ArrayProperty(0)]
|
||||
public int Prop21 { get; set; }
|
||||
[ArrayProperty(1)]
|
||||
public string? Prop22 { get; set; }
|
||||
}
|
||||
|
||||
public record Test3
|
||||
{
|
||||
[JsonPropertyName("prop31")]
|
||||
public int Prop31 { get; set; }
|
||||
[JsonPropertyName("prop32")]
|
||||
public string? Prop32 { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class BoolConverterTests
|
||||
{
|
||||
[TestCase("1", true)]
|
||||
[TestCase("true", true)]
|
||||
[TestCase("yes", true)]
|
||||
[TestCase("y", true)]
|
||||
[TestCase("on", true)]
|
||||
[TestCase("-1", false)]
|
||||
[TestCase("0", false)]
|
||||
[TestCase("n", false)]
|
||||
[TestCase("no", false)]
|
||||
[TestCase("false", false)]
|
||||
[TestCase("off", false)]
|
||||
[TestCase("", null)]
|
||||
public void TestBoolConverter(string value, bool? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<STJBoolObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase(1, true)]
|
||||
[TestCase(2, true)]
|
||||
[TestCase(0, false)]
|
||||
[TestCase(-1, false)]
|
||||
public void TestBoolConverterInts(int value, bool? expected)
|
||||
{
|
||||
var val = $"{value}";
|
||||
var output = JsonSerializer.Deserialize<STJBoolObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", true)]
|
||||
[TestCase("true", true)]
|
||||
[TestCase("yes", true)]
|
||||
[TestCase("y", true)]
|
||||
[TestCase("on", true)]
|
||||
[TestCase("-1", false)]
|
||||
[TestCase("0", false)]
|
||||
[TestCase("n", false)]
|
||||
[TestCase("no", false)]
|
||||
[TestCase("false", false)]
|
||||
[TestCase("off", false)]
|
||||
[TestCase("", false)]
|
||||
public void TestBoolConverterNotNullable(string value, bool expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<NotNullableSTJBoolObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
}
|
||||
|
||||
public class STJBoolObject
|
||||
{
|
||||
public bool? Value { get; set; }
|
||||
}
|
||||
|
||||
public class NotNullableSTJBoolObject
|
||||
{
|
||||
public bool Value { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class DateTimeConverterTests
|
||||
{
|
||||
[TestCase("2021-05-12")]
|
||||
[TestCase("20210512")]
|
||||
[TestCase("210512")]
|
||||
[TestCase("1620777600.000")]
|
||||
[TestCase("1620777600000")]
|
||||
[TestCase("2021-05-12T00:00:00.000Z")]
|
||||
[TestCase("2021-05-12T00:00:00.000000000Z")]
|
||||
[TestCase("2021-05-12 00:00:00.000000+00:00:00")]
|
||||
[TestCase("0.000000", true)]
|
||||
[TestCase("0", true)]
|
||||
[TestCase("", true)]
|
||||
[TestCase(" ", true)]
|
||||
public void TestDateTimeConverterString(string input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": \"{input}\" }}");
|
||||
Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[TestCase(1620777600.000)]
|
||||
[TestCase(1620777600000d)]
|
||||
public void TestDateTimeConverterDouble(double input)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.That(output!.Time == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000000)]
|
||||
[TestCase(1620777600000000000)]
|
||||
[TestCase(0, true)]
|
||||
public void TestDateTimeConverterLong(long input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600.000)]
|
||||
public void TestDateTimeConverterFromSeconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromSeconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToSeconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000.000)]
|
||||
public void TestDateTimeConverterFromMilliseconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMilliseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMilliseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000)]
|
||||
public void TestDateTimeConverterFromMicroseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMicroseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMicroseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000000)]
|
||||
public void TestDateTimeConverterFromNanoseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromNanoseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToNanoseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000000000);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void TestDateTimeConverterNull()
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": null }}");
|
||||
Assert.That(output!.Time == null);
|
||||
}
|
||||
}
|
||||
|
||||
public class STJTimeObject
|
||||
{
|
||||
[JsonConverter(typeof(DateTimeConverter))]
|
||||
[JsonPropertyName("time")]
|
||||
public DateTime? Time { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class DecimalConverterTests
|
||||
{
|
||||
[TestCase("1", 1)]
|
||||
[TestCase("1.1", 1.1)]
|
||||
[TestCase("-1.1", -1.1)]
|
||||
[TestCase(null, null)]
|
||||
[TestCase("", null)]
|
||||
[TestCase("null", null)]
|
||||
[TestCase("nan", null)]
|
||||
[TestCase("1E+2", 100)]
|
||||
[TestCase("1E-2", 0.01)]
|
||||
[TestCase("Infinity", 999)] // 999 is workaround for not being able to specify decimal.MinValue
|
||||
[TestCase("-Infinity", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
[TestCase("80228162514264337593543950335", 999)] // 999 is workaround for not being able to specify decimal.MaxValue
|
||||
[TestCase("-80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
public void TestDecimalConverterString(string value, decimal? expected)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<STJDecimalObject>("{ \"test\": \"" + value + "\"}");
|
||||
Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MinValue : expected == 999 ? decimal.MaxValue : expected));
|
||||
}
|
||||
|
||||
[TestCase("1", 1)]
|
||||
[TestCase("1.1", 1.1)]
|
||||
[TestCase("-1.1", -1.1)]
|
||||
[TestCase("null", null)]
|
||||
[TestCase("1E+2", 100)]
|
||||
[TestCase("1E-2", 0.01)]
|
||||
[TestCase("80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
public void TestDecimalConverterNumber(string value, decimal? expected)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<STJDecimalObject>("{ \"test\": " + value + "}");
|
||||
Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MaxValue : expected));
|
||||
}
|
||||
}
|
||||
|
||||
public class STJDecimalObject
|
||||
{
|
||||
[JsonConverter(typeof(DecimalConverter))]
|
||||
[JsonPropertyName("test")]
|
||||
public decimal? Test { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Testing;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class EnumConverterTests
|
||||
{
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.That(output == expected);
|
||||
}
|
||||
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
public void TestEnumConverterGetStringTests(TestEnum value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.That(output == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", null)]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<STJEnumObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", (TestEnum)(-9))]
|
||||
[TestCase(null, (TestEnum)(-9))]
|
||||
public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<NotNullableSTJEnumObject>($"{{ \"Value\": {val} }}");
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestEnumConverterMapsUndefinedValueCorrectlyIfDefaultIsDefined()
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<TestEnum2>($"\"TestUndefined\"");
|
||||
Assert.That((int)output == -99);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", null)]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterParseStringTests(string value, TestEnum? expected)
|
||||
{
|
||||
var result = EnumConverter.ParseString<TestEnum>(value);
|
||||
Assert.That(result == expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestEnumConverterParseNullOnNonNullableOnlyLogsOnce()
|
||||
{
|
||||
LibraryHelpers.StaticLogger = new TraceLogger();
|
||||
var listener = new EnumValueTraceListener();
|
||||
Trace.Listeners.Add(listener);
|
||||
EnumConverter<TestEnum>.Reset();
|
||||
try
|
||||
{
|
||||
Assert.Throws<Exception>(() =>
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<NotNullableSTJEnumObject>("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
});
|
||||
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
var result2 = JsonSerializer.Deserialize<NotNullableSTJEnumObject>("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Trace.Listeners.Remove(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
public class STJEnumObject
|
||||
{
|
||||
public TestEnum? Value { get; set; }
|
||||
}
|
||||
|
||||
public class NotNullableSTJEnumObject
|
||||
{
|
||||
public TestEnum Value { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(EnumConverter<TestEnum>))]
|
||||
public enum TestEnum
|
||||
{
|
||||
[Map("1")]
|
||||
One,
|
||||
[Map("2")]
|
||||
Two,
|
||||
[Map("three", "3")]
|
||||
Three,
|
||||
Four
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(EnumConverter<TestEnum2>))]
|
||||
public enum TestEnum2
|
||||
{
|
||||
[Map("-9")]
|
||||
Minus9 = -9,
|
||||
[Map("1")]
|
||||
One,
|
||||
[Map("2")]
|
||||
Two,
|
||||
[Map("three", "3")]
|
||||
Three,
|
||||
Four
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class SharedModelConversionTests
|
||||
{
|
||||
[TestCase(TradingMode.Spot, "ETH", "USDT", null)]
|
||||
[TestCase(TradingMode.PerpetualLinear, "ETH", "USDT", null)]
|
||||
[TestCase(TradingMode.DeliveryLinear, "ETH", "USDT", 1748432430)]
|
||||
public void TestSharedSymbolConversion(TradingMode tradingMode, string baseAsset, string quoteAsset, int? deliverTime)
|
||||
{
|
||||
DateTime? time = deliverTime == null ? null : DateTimeConverter.ParseFromDouble(deliverTime.Value);
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, time);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(symbol);
|
||||
var restored = JsonSerializer.Deserialize<SharedSymbol>(serialized);
|
||||
|
||||
Assert.That(restored!.TradingMode, Is.EqualTo(symbol.TradingMode));
|
||||
Assert.That(restored.BaseAsset, Is.EqualTo(symbol.BaseAsset));
|
||||
Assert.That(restored.QuoteAsset, Is.EqualTo(symbol.QuoteAsset));
|
||||
Assert.That(restored.DeliverTime, Is.EqualTo(symbol.DeliverTime));
|
||||
}
|
||||
|
||||
[TestCase(0.1, null, null)]
|
||||
[TestCase(0.1, 0.1, null)]
|
||||
[TestCase(0.1, 0.1, 0.1)]
|
||||
[TestCase(null, 0.1, null)]
|
||||
[TestCase(null, 0.1, 0.1)]
|
||||
public void TestSharedQuantityConversion(double? baseQuantity, double? quoteQuantity, double? contractQuantity)
|
||||
{
|
||||
var symbol = new SharedOrderQuantity((decimal?)baseQuantity, (decimal?)quoteQuantity, (decimal?)contractQuantity);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(symbol);
|
||||
var restored = JsonSerializer.Deserialize<SharedOrderQuantity>(serialized);
|
||||
|
||||
Assert.That(restored!.QuantityInBaseAsset, Is.EqualTo(symbol.QuantityInBaseAsset));
|
||||
Assert.That(restored.QuantityInQuoteAsset, Is.EqualTo(symbol.QuantityInQuoteAsset));
|
||||
Assert.That(restored.QuantityInContracts, Is.EqualTo(symbol.QuantityInContracts));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0-preview-20211130-02"></PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2"></PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.0"></PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"></PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0"></PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1"></PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public void ClampValueTests(decimal min, decimal max, decimal input, decimal expected)
|
||||
{
|
||||
var result = ExchangeHelpers.ClampValue(min, max, input);
|
||||
Assert.AreEqual(expected, result);
|
||||
Assert.That(expected == result);
|
||||
}
|
||||
|
||||
[TestCase(0.1, 1, 0.1, RoundingType.Down, 0.4, 0.4)]
|
||||
@@ -30,10 +30,11 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[TestCase(0.1, 1, 0.0001, RoundingType.Closest, 0.532, 0.532)]
|
||||
[TestCase(0.1, 1, 0.0001, RoundingType.Down, 0.5516592, 0.5516)]
|
||||
[TestCase(0.1, 1, 0.0001, RoundingType.Closest, 0.5516592, 0.5517)]
|
||||
[TestCase(0, 1, 0.000000001, RoundingType.Closest, 0.0000097232, 0.000009723)]
|
||||
public void AdjustValueStepTests(decimal min, decimal max, decimal? step, RoundingType roundingType, decimal input, decimal expected)
|
||||
{
|
||||
var result = ExchangeHelpers.AdjustValueStep(min, max, step, roundingType, input);
|
||||
Assert.AreEqual(expected, result);
|
||||
Assert.That(expected == result);
|
||||
}
|
||||
|
||||
[TestCase(0.1, 1, 2, RoundingType.Closest, 0.4, 0.4)]
|
||||
@@ -48,7 +49,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public void AdjustValuePrecisionTests(decimal min, decimal max, int? precision, RoundingType roundingType, decimal input, decimal expected)
|
||||
{
|
||||
var result = ExchangeHelpers.AdjustValuePrecision(min, max, precision, roundingType, input);
|
||||
Assert.AreEqual(expected, result);
|
||||
Assert.That(expected == result);
|
||||
}
|
||||
|
||||
[TestCase(5, 0.1563158, 0.15631)]
|
||||
@@ -59,7 +60,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public void RoundDownTests(int decimalPlaces, decimal input, decimal expected)
|
||||
{
|
||||
var result = ExchangeHelpers.RoundDown(input, decimalPlaces);
|
||||
Assert.AreEqual(expected, result);
|
||||
Assert.That(expected == result);
|
||||
}
|
||||
|
||||
[TestCase(0.1234560000, "0.123456")]
|
||||
@@ -67,7 +68,22 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public void NormalizeTests(decimal input, string expected)
|
||||
{
|
||||
var result = ExchangeHelpers.Normalize(input);
|
||||
Assert.AreEqual(expected, result.ToString(CultureInfo.InvariantCulture));
|
||||
Assert.That(expected == result.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("123", "BKR", 32, true, "BKRJK123")]
|
||||
[TestCase("123", "BKR", 32, false, "123")]
|
||||
[TestCase("123123123123123123123123123123", "BKR", 32, true, "123123123123123123123123123123")] // 30
|
||||
[TestCase("12312312312312312312312312312", "BKR", 32, true, "12312312312312312312312312312")] // 27
|
||||
[TestCase("123123123123123123123123123", "BKR", 32, true, "BKRJK123123123123123123123123123")] // 25
|
||||
[TestCase(null, "BKR", 32, true, null)]
|
||||
public void ApplyBrokerIdTests(string clientOrderId, string brokerId, int maxLength, bool allowValueAdjustement, string expected)
|
||||
{
|
||||
var result = LibraryHelpers.ApplyBrokerId(clientOrderId, brokerId, maxLength, allowValueAdjustement);
|
||||
|
||||
if (expected != null)
|
||||
Assert.That(result, Is.EqualTo(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class ExchangeSymbolCacheTests
|
||||
{
|
||||
private SharedSpotSymbol[] CreateTestSymbols()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("BTC", "EUR", "BTCEUR", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "BTC", "ETHBTC", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("XRP", "USDT", "XRPUSDT", false, TradingMode.Spot)
|
||||
};
|
||||
}
|
||||
|
||||
private SharedSpotSymbol[] CreateFuturesSymbols()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT-PERP", true, TradingMode.PerpetualLinear),
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT-PERP", true, TradingMode.PerpetualLinear)
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_NewTopic_Should_AddToCache()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "NewExchange";
|
||||
var symbols = CreateTestSymbols();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
var hasCached = ExchangeSymbolCache.HasCached(topicId, "Env", null);
|
||||
|
||||
// assert
|
||||
Assert.That(hasCached, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_Should_StoreAllSymbols()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeWithSymbols";
|
||||
var symbols = CreateTestSymbols();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// assert
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "BTCUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "ETHUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "BTCEUR"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "ETHBTC"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "XRPUSDT"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_CalledTwiceWithinAnHour_Should_NotUpdate()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoUpdate";
|
||||
var initialSymbols = new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
var updatedSymbols = new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, initialSymbols);
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, updatedSymbols);
|
||||
|
||||
// assert - should still have only the initial symbol since less than 60 minutes passed
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "BTCUSDT"), Is.True);
|
||||
// The second update should not have been applied
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "ETHUSDT"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_WithEmptyArray_Should_CreateEmptyCache()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "EmptyExchange";
|
||||
var symbols = Array.Empty<SharedSpotSymbol>();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
var hasCached = ExchangeSymbolCache.HasCached(topicId, "Env", null);
|
||||
|
||||
// assert
|
||||
Assert.That(hasCached, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasCached_NonExistentTopic_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.HasCached(nonExistentTopic, "Env", null);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasCached_ExistingTopicWithSymbols_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeWithData";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.HasCached(topicId, "Env", null);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasCached_ExistingTopicWithNoSymbols_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoData";
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, Array.Empty<SharedSpotSymbol>());
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.HasCached(topicId, "Env", null);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_ByName_ExistingSymbol_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeSupports";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_ByName_NonExistingSymbol_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoSupport";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, "LINKUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_ByName_NonExistentTopic_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(nonExistentTopic, "Env", null, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_ExistingSymbol_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeSharedSymbol";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_NonExistingSymbol_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoSharedSymbol";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "LINK", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_DifferentTradingMode_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeDifferentMode";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_NonExistentTopic_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(nonExistentTopic, "Env", null, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_ExistingBaseAsset_Should_ReturnMatchingSymbols()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeBaseAsset";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Env", null, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(2));
|
||||
Assert.That(result.Any(x => x.QuoteAsset == "USDT"), Is.True);
|
||||
Assert.That(result.Any(x => x.QuoteAsset == "EUR"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_CaseInsensitive_Should_ReturnMatchingSymbols()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeCaseInsensitive";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Env", null, "btc");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_NonExistingBaseAsset_Should_ReturnEmptyArray()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoBaseAsset";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Env", null, "LINK");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_NonExistentTopic_Should_ReturnEmptyArray()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(nonExistentTopic, "Env", null, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_ExistingSymbol_Should_ReturnSharedSymbol()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeParse";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "Env", null, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result!.BaseAsset, Is.EqualTo("BTC"));
|
||||
Assert.That(result.QuoteAsset, Is.EqualTo("USDT"));
|
||||
Assert.That(result.TradingMode, Is.EqualTo(TradingMode.Spot));
|
||||
Assert.That(result.SymbolName, Is.EqualTo("BTCUSDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_NonExistingSymbol_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoParse";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "Env", null, "LINKUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_NullSymbolName_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNullSymbol";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "Env", null, null);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_NonExistentTopic_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(nonExistentTopic, "Env", null, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleTopics_Should_MaintainSeparateData()
|
||||
{
|
||||
// arrange
|
||||
var topic1 = "Exchange1";
|
||||
var topic2 = "Exchange2";
|
||||
var symbols1 = new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
var symbols2 = new[]
|
||||
{
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topic1, "Env", null, symbols1);
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topic2, "Env", null, symbols2);
|
||||
|
||||
// assert
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic1, "Env", null, "BTCUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic1, "Env", null, "ETHUSDT"), Is.False);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic2, "Env", null, "ETHUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic2, "Env", null, "BTCUSDT"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_WithDifferentTradingModes_Should_StoreCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeMixedModes";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
var futuresSymbols = CreateFuturesSymbols();
|
||||
var allSymbols = spotSymbols.Concat(futuresSymbols).ToArray();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, allSymbols);
|
||||
|
||||
// assert
|
||||
var spotSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var futuresSymbol = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, spotSymbol), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "Env", null, futuresSymbol), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_Should_ReturnAllTradingModes()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeAllModes";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
var futuresSymbols = CreateFuturesSymbols();
|
||||
var allSymbols = spotSymbols.Concat(futuresSymbols).ToArray();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, allSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Env", null, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(result.Any(x => x.TradingMode == TradingMode.Spot), Is.True);
|
||||
Assert.That(result.Any(x => x.TradingMode == TradingMode.PerpetualLinear), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_WithMultipleMatchingSymbols_Should_ReturnAll()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeMultiple";
|
||||
var symbols = new[]
|
||||
{
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "BTC", "ETHBTC", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "EUR", "ETHEUR", true, TradingMode.Spot)
|
||||
};
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Env", null, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Env", null, "ETH");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.EqualTo(3));
|
||||
Assert.That(result.All(x => x.BaseAsset == "ETH"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_WithDifferentEnvironments_Should_ReturnNone()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "Topic1";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Live", null, spotSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Test", null, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_WithDifferentKey_Should_ReturnNone()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "Topic2";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Live", "1", spotSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Live", "2", "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_WithSetKey_Should_ReturnNone()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "Topic3";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Live", null, spotSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Live", "2", "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_WithNotSetKey_Should_ReturnNone()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "Topic4";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Live", "2", spotSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "Live", null, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_WithDifferentKey_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "Topic5";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Live", "1", spotSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "Live", "2", "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_WithSetKey_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "Topic6";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Live", null, spotSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "Live", "2", "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_WithNotSetKey_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "Topic7";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, "Live", "1", spotSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "Live", null, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestAuthenticationProvider : AuthenticationProvider<TestCredentials, TestCredentials>
|
||||
{
|
||||
public TestAuthenticationProvider(TestCredentials credentials) : base(credentials, credentials)
|
||||
{
|
||||
}
|
||||
|
||||
public override void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig)
|
||||
{
|
||||
requestConfig.Headers ??= new Dictionary<string, string>();
|
||||
requestConfig.Headers["Authorization"] = Credential.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestCredentials : HMACCredential
|
||||
{
|
||||
public TestCredentials() { }
|
||||
|
||||
public TestCredentials(string key, string secret) : base(key, secret)
|
||||
{
|
||||
}
|
||||
|
||||
public TestCredentials(HMACCredential credential) : base(credential.Key, credential.Secret)
|
||||
{
|
||||
}
|
||||
|
||||
public TestCredentials WithHMAC(string key, string secret)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Key)) throw new InvalidOperationException("Credentials already set");
|
||||
|
||||
Key = key;
|
||||
Secret = secret;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new TestCredentials(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestEnvironment : TradeEnvironment
|
||||
{
|
||||
public string RestClientAddress { get; }
|
||||
public string SocketClientAddress { get; }
|
||||
|
||||
internal TestEnvironment(
|
||||
string name,
|
||||
string restAddress,
|
||||
string streamAddress) :
|
||||
base(name)
|
||||
{
|
||||
RestClientAddress = restAddress;
|
||||
SocketClientAddress = streamAddress;
|
||||
}
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
public TestEnvironment() : base(TradeEnvironmentNames.Live)
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Get the environment by name
|
||||
/// </summary>
|
||||
public static TestEnvironment? GetEnvironmentByName(string? name)
|
||||
=> name switch
|
||||
{
|
||||
TradeEnvironmentNames.Live => Live,
|
||||
"" => Live,
|
||||
null => Live,
|
||||
_ => default
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Available environment names
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static string[] All => [Live.Name];
|
||||
|
||||
/// <summary>
|
||||
/// Live environment
|
||||
/// </summary>
|
||||
public static TestEnvironment Live { get; }
|
||||
= new TestEnvironment(TradeEnvironmentNames.Live,
|
||||
"https://localhost",
|
||||
"wss://localhost");
|
||||
|
||||
/// <summary>
|
||||
/// Create a custom environment
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="spotRestAddress"></param>
|
||||
/// <param name="spotSocketStreamsAddress"></param>
|
||||
/// <returns></returns>
|
||||
public static TestEnvironment CreateCustom(
|
||||
string name,
|
||||
string spotRestAddress,
|
||||
string spotSocketStreamsAddress)
|
||||
=> new TestEnvironment(name, spotRestAddress, spotSocketStreamsAddress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
public class TestObject
|
||||
{
|
||||
[JsonPropertyName("other")]
|
||||
public string StringData { get; set; } = string.Empty;
|
||||
[JsonPropertyName("intData")]
|
||||
public int IntData { get; set; }
|
||||
[JsonPropertyName("decimalData")]
|
||||
public decimal DecimalData { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestQuery : Query<TestSocketMessage>
|
||||
{
|
||||
public TestQuery(TestSocketMessage request, bool authenticated) : base(request, authenticated, 1)
|
||||
{
|
||||
MessageRouter = MessageRouter.CreateForQuery<TestSocketMessage>(request.Id.ToString(), HandleMessage);
|
||||
}
|
||||
|
||||
private CallResult<TestSocketMessage>? HandleMessage(SocketConnection connection, DateTime time, string? arg3, TestSocketMessage message)
|
||||
{
|
||||
if (message.Data != "OK")
|
||||
return CallResult.Fail<TestSocketMessage>(new ServerError(ErrorInfo.Unknown with { Message = message.Data }));
|
||||
|
||||
return CallResult.Ok(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using CryptoExchange.Net.Testing.Implementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestApiClient : RestApiClient<TestEnvironment, TestAuthenticationProvider, TestCredentials>
|
||||
{
|
||||
protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler();
|
||||
|
||||
public TestRestApiClient(ILoggerFactory? loggerFactory, HttpClient? httpClient, TestRestOptions options)
|
||||
: base(loggerFactory, "Test", httpClient, options.Environment.RestClientAddress, options, options.ExchangeOptions)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) =>
|
||||
baseAsset + quoteAsset;
|
||||
|
||||
protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) =>
|
||||
new TestAuthenticationProvider(credentials);
|
||||
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
|
||||
internal void SetNextResponse(string data, HttpStatusCode code)
|
||||
{
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(data);
|
||||
var responseStream = new MemoryStream();
|
||||
responseStream.Write(expectedBytes, 0, expectedBytes.Length);
|
||||
responseStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var response = new TestResponse(code, responseStream);
|
||||
var request = new TestRequest(response);
|
||||
|
||||
var factory = new TestRequestFactory(request);
|
||||
RequestFactory = factory;
|
||||
}
|
||||
|
||||
internal async Task<HttpResult<T>> GetResponseAsync<T>(HttpMethod? httpMethod = null, Parameters? collection = null, RateLimitGate? rateLimitGate = null)
|
||||
{
|
||||
var definition = new RequestDefinition(BaseAddress, "/path", httpMethod ?? HttpMethod.Get)
|
||||
{
|
||||
Weight = rateLimitGate == null ? 0 : 1,
|
||||
RateLimitGate = rateLimitGate
|
||||
};
|
||||
return await SendAsync<T>(definition, collection ?? new Parameters(new ParameterSerializationSettings()), default);
|
||||
}
|
||||
|
||||
internal void SetParameterPosition(HttpMethod httpMethod, HttpMethodParameterPosition pos)
|
||||
{
|
||||
ParameterPositions[httpMethod] = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestClient : BaseRestClient<TestEnvironment, TestCredentials>
|
||||
{
|
||||
public TestRestApiClient ApiClient1 { get; set; }
|
||||
public TestRestApiClient ApiClient2 { get; set; }
|
||||
|
||||
public TestRestClient(Action<TestRestOptions>? optionsDelegate = null)
|
||||
: this(null, null, Options.Create(ApplyOptionsDelegate(optionsDelegate)))
|
||||
{
|
||||
}
|
||||
|
||||
public TestRestClient(HttpClient? httpClient, ILoggerFactory? loggerFactory, IOptions<TestRestOptions> options) : base(loggerFactory, "Test")
|
||||
{
|
||||
Initialize(options.Value);
|
||||
|
||||
ApiClient1 = AddApiClient(new TestRestApiClient(loggerFactory, httpClient, options.Value));
|
||||
ApiClient2 = AddApiClient(new TestRestApiClient(loggerFactory, httpClient, options.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using System.IO;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestMessageHandler : JsonRestMessageHandler
|
||||
{
|
||||
public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext());
|
||||
|
||||
public override async ValueTask<Error> ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream)
|
||||
{
|
||||
var (jsonError, jsonDocument) = await GetJsonDocument(responseStream).ConfigureAwait(false);
|
||||
if (jsonError != null)
|
||||
return jsonError;
|
||||
|
||||
int? code = jsonDocument!.RootElement.TryGetProperty("errorCode", out var codeProp) ? codeProp.GetInt32() : null;
|
||||
var msg = jsonDocument.RootElement.TryGetProperty("errorMessage", out var msgProp) ? msgProp.GetString() : null;
|
||||
if (msg == null)
|
||||
return new ServerError(ErrorInfo.Unknown);
|
||||
|
||||
if (code == null)
|
||||
return new ServerError(ErrorInfo.Unknown with { Message = msg });
|
||||
|
||||
return new ServerError(code.Value, new ErrorInfo(ErrorType.Unknown, false, "Error") with { Message = msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestOptions : RestExchangeOptions<TestEnvironment, TestCredentials>
|
||||
{
|
||||
internal static TestRestOptions Default { get; set; } = new TestRestOptions()
|
||||
{
|
||||
Environment = TestEnvironment.Live,
|
||||
AutoTimestamp = true
|
||||
};
|
||||
|
||||
public TestRestOptions()
|
||||
{
|
||||
Default?.Set(this);
|
||||
}
|
||||
|
||||
public RestApiOptions ExchangeOptions { get; private set; } = new RestApiOptions();
|
||||
|
||||
internal TestRestOptions Set(TestRestOptions targetOptions)
|
||||
{
|
||||
targetOptions = base.Set<TestRestOptions>(targetOptions);
|
||||
targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions);
|
||||
return targetOptions;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.ConverterTests;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object>))]
|
||||
[JsonSerializable(typeof(Parameters))]
|
||||
[JsonSerializable(typeof(TestObject))]
|
||||
|
||||
[JsonSerializable(typeof(TestSocketMessage))]
|
||||
[JsonSerializable(typeof(Test))]
|
||||
[JsonSerializable(typeof(Test2))]
|
||||
[JsonSerializable(typeof(Test3))]
|
||||
[JsonSerializable(typeof(NotNullableSTJBoolObject))]
|
||||
[JsonSerializable(typeof(STJBoolObject))]
|
||||
[JsonSerializable(typeof(NotNullableSTJEnumObject))]
|
||||
[JsonSerializable(typeof(STJEnumObject))]
|
||||
[JsonSerializable(typeof(STJDecimalObject))]
|
||||
[JsonSerializable(typeof(STJTimeObject))]
|
||||
internal partial class TestSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketApiClient : SocketApiClient<TestEnvironment, TestAuthenticationProvider, TestCredentials>
|
||||
{
|
||||
public TestSocketApiClient(ILoggerFactory? loggerFactory, TestSocketOptions options)
|
||||
: base(loggerFactory, "Test", options.Environment.SocketClientAddress, options, options.ExchangeOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public TestSocketApiClient(ILoggerFactory? loggerFactory, HttpClient httpClient, string baseAddress, TestSocketOptions options, SocketApiOptions apiOptions)
|
||||
: base(loggerFactory, "Test", baseAddress, options, apiOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public override ISocketMessageHandler CreateMessageConverter(WebSocketMessageType messageType) => new TestSocketMessageHandler();
|
||||
protected internal override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) =>
|
||||
baseAsset + quoteAsset;
|
||||
|
||||
protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) =>
|
||||
new TestAuthenticationProvider(credentials);
|
||||
|
||||
public async Task<WebSocketResult<UpdateSubscription>> SubscribeToUpdatesAsync<T>(Action<DataEvent<T>> handler, bool subQuery, CancellationToken ct, int individualSubscriptionCount = 1)
|
||||
{
|
||||
var subscription = new TestSubscription<T>(_logger, handler, subQuery, false)
|
||||
{
|
||||
IndividualSubscriptionCount = individualSubscriptionCount
|
||||
};
|
||||
return await base.SubscribeAsync(subscription, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketClient : BaseSocketClient<TestEnvironment, TestCredentials>
|
||||
{
|
||||
public TestSocketApiClient ApiClient1 { get; set; }
|
||||
public TestSocketApiClient ApiClient2 { get; set; }
|
||||
|
||||
public TestSocketClient(Action<TestSocketOptions>? optionsDelegate = null)
|
||||
: this(null, Options.Create(ApplyOptionsDelegate(optionsDelegate)))
|
||||
{
|
||||
}
|
||||
|
||||
public TestSocketClient(ILoggerFactory? loggerFactory, IOptions<TestSocketOptions> options) : base(loggerFactory, "Test")
|
||||
{
|
||||
Initialize(options.Value);
|
||||
|
||||
ApiClient1 = AddApiClient(new TestSocketApiClient(loggerFactory, options.Value));
|
||||
ApiClient2 = AddApiClient(new TestSocketApiClient(loggerFactory, options.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal record TestSocketMessage
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
[JsonPropertyName("data")]
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketMessageHandler : JsonSocketMessageHandler
|
||||
{
|
||||
public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext());
|
||||
|
||||
public TestSocketMessageHandler()
|
||||
{
|
||||
}
|
||||
|
||||
protected override MessageTypeDefinition[] TypeEvaluators { get; } = [
|
||||
|
||||
new MessageTypeDefinition {
|
||||
ForceIfFound = true,
|
||||
Fields = [
|
||||
new PropertyFieldReference("id")
|
||||
],
|
||||
TypeIdentifierCallback = (doc) => doc.FieldValue("id")!
|
||||
},
|
||||
|
||||
new MessageTypeDefinition {
|
||||
Fields = [
|
||||
],
|
||||
StaticIdentifier = "test"
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketOptions : SocketExchangeOptions<TestEnvironment, TestCredentials>
|
||||
{
|
||||
internal static TestSocketOptions Default { get; set; } = new TestSocketOptions()
|
||||
{
|
||||
Environment = TestEnvironment.Live,
|
||||
AutoTimestamp = true
|
||||
};
|
||||
|
||||
public TestSocketOptions()
|
||||
{
|
||||
Default?.Set(this);
|
||||
}
|
||||
|
||||
public SocketApiOptions ExchangeOptions { get; private set; } = new SocketApiOptions();
|
||||
|
||||
internal TestSocketOptions Set(TestSocketOptions targetOptions)
|
||||
{
|
||||
targetOptions = base.Set<TestSocketOptions>(targetOptions);
|
||||
targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions);
|
||||
return targetOptions;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSubscription<T> : Subscription
|
||||
{
|
||||
private readonly Action<DataEvent<T>> _handler;
|
||||
private bool _subQuery;
|
||||
|
||||
public TestSubscription(ILogger logger, Action<DataEvent<T>> handler, bool subQuery, bool authenticated) : base(logger, authenticated, true)
|
||||
{
|
||||
_handler = handler;
|
||||
_subQuery = subQuery;
|
||||
|
||||
MessageRouter = MessageRouter.CreateForEvent<T>("test", HandleUpdate);
|
||||
}
|
||||
|
||||
protected override Query? GetSubQuery(SocketConnection connection)
|
||||
{
|
||||
if (!_subQuery)
|
||||
return null;
|
||||
|
||||
return new TestQuery(new TestSocketMessage { Id = 1, Data = "Sub" }, false);
|
||||
}
|
||||
|
||||
protected override Query? GetUnsubQuery(SocketConnection connection)
|
||||
{
|
||||
if (!_subQuery)
|
||||
return null;
|
||||
|
||||
return new TestQuery(new TestSocketMessage { Id = 2, Data = "Unsub" }, false);
|
||||
}
|
||||
|
||||
|
||||
private CallResult? HandleUpdate(SocketConnection connection, DateTime time, string? originalData, T data)
|
||||
{
|
||||
_handler(new DataEvent<T>("Test", data, time, originalData));
|
||||
return CallResult.Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class ManualUpdateSubscriptionTests
|
||||
{
|
||||
[Test]
|
||||
public void Constructor_Should_CreateSubscribedVirtualSubscription()
|
||||
{
|
||||
var controller = new ManualUpdateSubscription(socketId: 12);
|
||||
|
||||
Assert.That(controller.Subscription.SocketId, Is.EqualTo(12));
|
||||
Assert.That(controller.Subscription.Id, Is.GreaterThan(0));
|
||||
Assert.That(controller.Subscription.SocketStatus, Is.EqualTo(SocketStatus.Connected));
|
||||
Assert.That(controller.Subscription.SubscriptionStatus, Is.EqualTo(SubscriptionStatus.Subscribed));
|
||||
Assert.That(controller.Subscription.LastReceiveTime, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StateChanges_Should_BeVisibleOnSubscription()
|
||||
{
|
||||
var controller = new ManualUpdateSubscription();
|
||||
var timestamp = new DateTime(2026, 8, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
var statuses = new List<SubscriptionStatus>();
|
||||
controller.Subscription.SubscriptionStatusChanged += statuses.Add;
|
||||
|
||||
controller.SetLastReceiveTime(timestamp);
|
||||
controller.SetSocketStatus(SocketStatus.Reconnecting);
|
||||
controller.SetSubscriptionStatus(SubscriptionStatus.Subscribing);
|
||||
controller.SetSubscriptionStatus(SubscriptionStatus.Subscribed);
|
||||
|
||||
Assert.That(controller.Subscription.LastReceiveTime, Is.EqualTo(timestamp));
|
||||
Assert.That(controller.Subscription.SocketStatus, Is.EqualTo(SocketStatus.Reconnecting));
|
||||
Assert.That(controller.Subscription.SubscriptionStatus, Is.EqualTo(SubscriptionStatus.Subscribed));
|
||||
Assert.That(statuses, Is.EqualTo(new[]
|
||||
{
|
||||
SubscriptionStatus.Subscribing,
|
||||
SubscriptionStatus.Subscribed
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LifecycleMethods_Should_InvokeSubscriptionEvents()
|
||||
{
|
||||
var controller = new ManualUpdateSubscription();
|
||||
var error = new ServerError("Test error", ErrorInfo.Unknown);
|
||||
var exception = new InvalidOperationException("Test exception");
|
||||
var disconnectedPeriod = TimeSpan.FromMinutes(2);
|
||||
var lost = 0;
|
||||
var restored = TimeSpan.Zero;
|
||||
Error? resubscribeError = null;
|
||||
var paused = 0;
|
||||
var unpaused = 0;
|
||||
Exception? receivedException = null;
|
||||
|
||||
controller.Subscription.ConnectionLost += () => lost++;
|
||||
controller.Subscription.ConnectionRestored += x => restored = x;
|
||||
controller.Subscription.ResubscribingFailed += x => resubscribeError = x;
|
||||
controller.Subscription.ActivityPaused += () => paused++;
|
||||
controller.Subscription.ActivityUnpaused += () => unpaused++;
|
||||
controller.Subscription.Exception += x => receivedException = x;
|
||||
|
||||
controller.InvokeConnectionLost();
|
||||
controller.InvokeConnectionRestored(disconnectedPeriod);
|
||||
controller.InvokeResubscribingFailed(error);
|
||||
controller.InvokeActivityPaused();
|
||||
controller.InvokeActivityUnpaused();
|
||||
controller.InvokeException(exception);
|
||||
|
||||
Assert.That(lost, Is.EqualTo(1));
|
||||
Assert.That(restored, Is.EqualTo(disconnectedPeriod));
|
||||
Assert.That(resubscribeError, Is.SameAs(error));
|
||||
Assert.That(paused, Is.EqualTo(1));
|
||||
Assert.That(unpaused, Is.EqualTo(1));
|
||||
Assert.That(receivedException, Is.SameAs(exception));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvokeConnectionClosed_Should_CloseAndOnlyInvokeOnce()
|
||||
{
|
||||
var controller = new ManualUpdateSubscription();
|
||||
var closed = 0;
|
||||
controller.Subscription.ConnectionClosed += () => closed++;
|
||||
|
||||
controller.InvokeConnectionClosed();
|
||||
controller.InvokeConnectionClosed();
|
||||
|
||||
Assert.That(closed, Is.EqualTo(1));
|
||||
Assert.That(controller.Subscription.SocketStatus, Is.EqualTo(SocketStatus.Closed));
|
||||
Assert.That(controller.Subscription.SubscriptionStatus, Is.EqualTo(SubscriptionStatus.Closed));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SubscriptionOperations_Should_InvokeCallbacks()
|
||||
{
|
||||
var closes = 0;
|
||||
var reconnects = 0;
|
||||
var resubscribes = 0;
|
||||
var controller = new ManualUpdateSubscription(
|
||||
closeAsync: () =>
|
||||
{
|
||||
closes++;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
reconnectAsync: () =>
|
||||
{
|
||||
reconnects++;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
resubscribeAsync: () =>
|
||||
{
|
||||
resubscribes++;
|
||||
return Task.FromResult(CallResult.Ok());
|
||||
});
|
||||
|
||||
await controller.Subscription.ReconnectAsync();
|
||||
var resubscribeResult = await controller.Subscription.ResubscribeAsync();
|
||||
await controller.Subscription.CloseAsync();
|
||||
await controller.Subscription.CloseAsync();
|
||||
|
||||
Assert.That(reconnects, Is.EqualTo(1));
|
||||
Assert.That(resubscribes, Is.EqualTo(1));
|
||||
Assert.That(resubscribeResult.Success, Is.True);
|
||||
Assert.That(closes, Is.EqualTo(1));
|
||||
Assert.That(controller.Subscription.SubscriptionStatus, Is.EqualTo(SubscriptionStatus.Closed));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
@@ -15,9 +11,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public class OptionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void Init()
|
||||
public void TearDown()
|
||||
{
|
||||
TestClientOptions.Default = new TestClientOptions
|
||||
TestRestOptions.Default = new TestRestOptions
|
||||
{
|
||||
};
|
||||
}
|
||||
@@ -34,262 +30,123 @@ namespace CryptoExchange.Net.UnitTests
|
||||
// act
|
||||
// assert
|
||||
Assert.Throws(typeof(ArgumentException),
|
||||
() => new RestApiClientOptions() { ApiCredentials = new ApiCredentials(key, secret) });
|
||||
() => {
|
||||
var opts = new TestRestOptions()
|
||||
{
|
||||
ApiCredentials = new TestCredentials(key, secret)
|
||||
};
|
||||
opts.ApiCredentials.Validate();
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestBasicOptionsAreSet()
|
||||
{
|
||||
// arrange, act
|
||||
var options = new TestClientOptions
|
||||
var options = new TestRestOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
ReceiveWindow = TimeSpan.FromSeconds(10)
|
||||
ApiCredentials = new TestCredentials("123", "456"),
|
||||
RequestTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(options.ReceiveWindow, TimeSpan.FromSeconds(10));
|
||||
Assert.AreEqual(options.ApiCredentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(options.ApiCredentials.Secret.GetString(), "456");
|
||||
Assert.That(options.RequestTimeout == TimeSpan.FromSeconds(10));
|
||||
Assert.That(options.ApiCredentials.Key == "123");
|
||||
Assert.That(options.ApiCredentials.Secret == "456");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestApiOptionsAreSet()
|
||||
public void TestSetOptionsRest()
|
||||
{
|
||||
// arrange, act
|
||||
var options = new TestClientOptions
|
||||
{
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
BaseAddress = "http://test1.com"
|
||||
},
|
||||
Api2Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("789", "101"),
|
||||
BaseAddress = "http://test2.com"
|
||||
}
|
||||
};
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(options.Api1Options.ApiCredentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(options.Api1Options.ApiCredentials.Secret.GetString(), "456");
|
||||
Assert.AreEqual(options.Api1Options.BaseAddress, "http://test1.com");
|
||||
Assert.AreEqual(options.Api2Options.ApiCredentials.Key.GetString(), "789");
|
||||
Assert.AreEqual(options.Api2Options.ApiCredentials.Secret.GetString(), "101");
|
||||
Assert.AreEqual(options.Api2Options.BaseAddress, "http://test2.com");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestNotOverridenApiOptionsAreStillDefault()
|
||||
{
|
||||
// arrange, act
|
||||
var options = new TestClientOptions
|
||||
{
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
}
|
||||
};
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(options.Api1Options.RateLimitingBehaviour, RateLimitingBehaviour.Wait);
|
||||
Assert.AreEqual(options.Api1Options.BaseAddress, "https://api1.test.com/");
|
||||
Assert.AreEqual(options.Api2Options.BaseAddress, "https://api2.test.com/");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestSettingDefaultBaseOptionsAreRespected()
|
||||
{
|
||||
// arrange
|
||||
TestClientOptions.Default = new TestClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
LogLevel = LogLevel.Trace
|
||||
};
|
||||
|
||||
// act
|
||||
var options = new TestClientOptions();
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(options.LogLevel, LogLevel.Trace);
|
||||
Assert.AreEqual(options.ApiCredentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(options.ApiCredentials.Secret.GetString(), "456");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestSettingDefaultApiOptionsAreRespected()
|
||||
{
|
||||
// arrange
|
||||
TestClientOptions.Default = new TestClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
LogLevel = LogLevel.Trace,
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("456", "789")
|
||||
}
|
||||
};
|
||||
|
||||
// act
|
||||
var options = new TestClientOptions();
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(options.ApiCredentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(options.ApiCredentials.Secret.GetString(), "456");
|
||||
Assert.AreEqual(options.Api1Options.BaseAddress, "https://api1.test.com/");
|
||||
Assert.AreEqual(options.Api1Options.ApiCredentials.Key.GetString(), "456");
|
||||
Assert.AreEqual(options.Api1Options.ApiCredentials.Secret.GetString(), "789");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestSettingDefaultApiOptionsWithSomeOverriddenAreRespected()
|
||||
{
|
||||
// arrange
|
||||
TestClientOptions.Default = new TestClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
LogLevel = LogLevel.Trace,
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("456", "789")
|
||||
},
|
||||
Api2Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("111", "222")
|
||||
}
|
||||
};
|
||||
|
||||
// act
|
||||
var options = new TestClientOptions
|
||||
{
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("333", "444")
|
||||
}
|
||||
};
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(options.ApiCredentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(options.ApiCredentials.Secret.GetString(), "456");
|
||||
Assert.AreEqual(options.Api1Options.ApiCredentials.Key.GetString(), "333");
|
||||
Assert.AreEqual(options.Api1Options.ApiCredentials.Secret.GetString(), "444");
|
||||
Assert.AreEqual(options.Api2Options.ApiCredentials.Key.GetString(), "111");
|
||||
Assert.AreEqual(options.Api2Options.ApiCredentials.Secret.GetString(), "222");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestClientUsesCorrectOptions()
|
||||
{
|
||||
var client = new TestRestClient(new TestClientOptions()
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("111", "222")
|
||||
}
|
||||
});
|
||||
|
||||
Assert.AreEqual(client.Api1.AuthenticationProvider.Credentials.Key.GetString(), "111");
|
||||
Assert.AreEqual(client.Api1.AuthenticationProvider.Credentials.Secret.GetString(), "222");
|
||||
Assert.AreEqual(client.Api2.AuthenticationProvider.Credentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(client.Api2.AuthenticationProvider.Credentials.Secret.GetString(), "456");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestClientUsesCorrectOptionsWithDefault()
|
||||
{
|
||||
TestClientOptions.Default = new TestClientOptions()
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("111", "222")
|
||||
}
|
||||
};
|
||||
|
||||
var client = new TestRestClient();
|
||||
client.SetOptions(new UpdateOptions
|
||||
{
|
||||
RequestTimeout = TimeSpan.FromSeconds(2),
|
||||
Proxy = new ApiProxy("http://testproxy", 1234)
|
||||
});
|
||||
|
||||
Assert.AreEqual(client.Api1.AuthenticationProvider.Credentials.Key.GetString(), "111");
|
||||
Assert.AreEqual(client.Api1.AuthenticationProvider.Credentials.Secret.GetString(), "222");
|
||||
Assert.AreEqual(client.Api2.AuthenticationProvider.Credentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(client.Api2.AuthenticationProvider.Credentials.Secret.GetString(), "456");
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null);
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy"));
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234));
|
||||
Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestClientUsesCorrectOptionsWithOverridingDefault()
|
||||
public void TestSetOptionsRestWithCredentials()
|
||||
{
|
||||
TestClientOptions.Default = new TestClientOptions()
|
||||
var client = new TestRestClient();
|
||||
client.SetOptions(new UpdateOptions<TestCredentials>
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("111", "222")
|
||||
}
|
||||
};
|
||||
|
||||
var client = new TestRestClient(new TestClientOptions
|
||||
{
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("333", "444")
|
||||
},
|
||||
Api2Options = new RestApiClientOptions()
|
||||
{
|
||||
BaseAddress = "http://test.com"
|
||||
}
|
||||
ApiCredentials = new TestCredentials("123", "456"),
|
||||
RequestTimeout = TimeSpan.FromSeconds(2),
|
||||
Proxy = new ApiProxy("http://testproxy", 1234)
|
||||
});
|
||||
|
||||
Assert.AreEqual(client.Api1.AuthenticationProvider.Credentials.Key.GetString(), "333");
|
||||
Assert.AreEqual(client.Api1.AuthenticationProvider.Credentials.Secret.GetString(), "444");
|
||||
Assert.AreEqual(client.Api2.AuthenticationProvider.Credentials.Key.GetString(), "123");
|
||||
Assert.AreEqual(client.Api2.AuthenticationProvider.Credentials.Secret.GetString(), "456");
|
||||
Assert.AreEqual(client.Api2.BaseAddress, "http://test.com");
|
||||
Assert.That(client.ApiClient1.ApiCredentials, Is.Not.Null);
|
||||
Assert.That(client.ApiClient1.ApiCredentials!.Key, Is.EqualTo("123"));
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null);
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy"));
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234));
|
||||
Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestWhenUpdatingSettingsExistingClientsAreNotAffected()
|
||||
{
|
||||
TestRestOptions.Default = new TestRestOptions
|
||||
{
|
||||
ApiCredentials = new TestCredentials("111", "222"),
|
||||
RequestTimeout = TimeSpan.FromSeconds(1),
|
||||
};
|
||||
|
||||
var client1 = new TestRestClient();
|
||||
|
||||
Assert.That(client1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(1)));
|
||||
Assert.That(client1.ClientOptions.ApiCredentials!.Key, Is.EqualTo("111"));
|
||||
|
||||
TestRestOptions.Default.ApiCredentials = new TestCredentials("333", "444");
|
||||
TestRestOptions.Default.RequestTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
var client2 = new TestRestClient();
|
||||
|
||||
Assert.That(client2.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2)));
|
||||
Assert.That(client2.ClientOptions.ApiCredentials!.Key, Is.EqualTo("333"));
|
||||
}
|
||||
}
|
||||
|
||||
public class TestClientOptions: BaseRestClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default options for the futures client
|
||||
/// </summary>
|
||||
public static TestClientOptions Default { get; set; } = new TestClientOptions();
|
||||
//public class TestClientOptions: RestExchangeOptions<TestEnvironment, HMACCredential>
|
||||
//{
|
||||
// /// <summary>
|
||||
// /// Default options for the futures client
|
||||
// /// </summary>
|
||||
// public static TestClientOptions Default { get; set; } = new TestClientOptions()
|
||||
// {
|
||||
// Environment = new TestEnvironment("test", "https://test.com")
|
||||
// };
|
||||
|
||||
/// <summary>
|
||||
/// The default receive window for requests
|
||||
/// </summary>
|
||||
public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5);
|
||||
// /// <summary>
|
||||
// /// ctor
|
||||
// /// </summary>
|
||||
// public TestClientOptions()
|
||||
// {
|
||||
// Default?.Set(this);
|
||||
// }
|
||||
|
||||
private RestApiClientOptions _api1Options = new RestApiClientOptions("https://api1.test.com/");
|
||||
public RestApiClientOptions Api1Options
|
||||
{
|
||||
get => _api1Options;
|
||||
set => _api1Options = new RestApiClientOptions(_api1Options, value);
|
||||
}
|
||||
// /// <summary>
|
||||
// /// The default receive window for requests
|
||||
// /// </summary>
|
||||
// public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
private RestApiClientOptions _api2Options = new RestApiClientOptions("https://api2.test.com/");
|
||||
public RestApiClientOptions Api2Options
|
||||
{
|
||||
get => _api2Options;
|
||||
set => _api2Options = new RestApiClientOptions(_api2Options, value);
|
||||
}
|
||||
// public RestApiOptions Api1Options { get; private set; } = new RestApiOptions();
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public TestClientOptions(): this(Default)
|
||||
{
|
||||
}
|
||||
// public RestApiOptions Api2Options { get; set; } = new RestApiOptions();
|
||||
|
||||
public TestClientOptions(TestClientOptions baseOn): base(baseOn)
|
||||
{
|
||||
if (baseOn == null)
|
||||
return;
|
||||
|
||||
ReceiveWindow = baseOn.ReceiveWindow;
|
||||
|
||||
Api1Options = new RestApiClientOptions(baseOn.Api1Options, null);
|
||||
Api2Options = new RestApiClientOptions(baseOn.Api2Options, null);
|
||||
}
|
||||
}
|
||||
// internal TestClientOptions Set(TestClientOptions targetOptions)
|
||||
// {
|
||||
// targetOptions = base.Set<TestClientOptions>(targetOptions);
|
||||
// targetOptions.Api1Options = Api1Options.Set(targetOptions.Api1Options);
|
||||
// targetOptions.Api2Options = Api2Options.Set(targetOptions.Api2Options);
|
||||
// return targetOptions;
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.ConverterTests;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
internal class ParameterCollectionTests
|
||||
{
|
||||
[Test]
|
||||
public void AddingBasicValue_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", "value");
|
||||
Assert.That(parameters["test"], Is.EqualTo("value"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalBasicNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingDecimalValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", 0.1m, DecimalSerialization.String);
|
||||
Assert.That(parameters["test"], Is.EqualTo("0.1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingDecimalValueAsString2_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
Decimal = DecimalSerialization.String
|
||||
});
|
||||
parameters.Add("test", 0.1m);
|
||||
Assert.That(parameters["test"], Is.EqualTo("0.1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalIntNullValueAsString_DoesntSetValue()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", (int?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingLongValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", 1L, IntegerSerialization.String);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalLongValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
Integer = IntegerSerialization.String
|
||||
});
|
||||
parameters.Add("test", 1L);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalLongNullValueAsString_DoesntSetValue()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", (long?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingMillisecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), DateTimeSerialization.MillisecondsNumber);
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600000));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalMillisecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
DateTimes = DateTimeSerialization.MillisecondsNumber
|
||||
});
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600000));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalMillisecondNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", (DateTime?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingMillisecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), DateTimeSerialization.MillisecondsString);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600000"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalMillisecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
DateTimes = DateTimeSerialization.MillisecondsString
|
||||
});
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600000"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingSecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), DateTimeSerialization.SecondsNumber);
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalSecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
DateTimes = DateTimeSerialization.SecondsNumber
|
||||
});
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingSecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), DateTimeSerialization.SecondsString);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalSecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
DateTimes = DateTimeSerialization.SecondsString
|
||||
});
|
||||
parameters.Add("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingEnum_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", TestEnum.Two);
|
||||
Assert.That(parameters["test"], Is.EqualTo("2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalEnum_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", (TestEnum?)TestEnum.Two);
|
||||
Assert.That(parameters["test"], Is.EqualTo("2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalEnumNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", (TestEnum?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingEnumAsInt_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", TestEnum.Two, EnumSerialization.Number);
|
||||
Assert.That(parameters["test"], Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalEnumAsInt_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
Enum = EnumSerialization.Number
|
||||
});
|
||||
parameters.Add("test", TestEnum.Two);
|
||||
Assert.That(parameters["test"], Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingCommaSeparated_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.AddCommaSeparated("test", ["1", "2"]);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1,2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalCommaSeparatedNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.AddCommaSeparated("test", (string[]?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingCommaSeparatedEnum_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.AddCommaSeparated("test", [TestEnum.Two, TestEnum.One]);
|
||||
Assert.That(parameters["test"], Is.EqualTo("2,1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingBoolString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", true, BoolSerialization.String);
|
||||
Assert.That(parameters["test"], Is.EqualTo("true"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalBoolString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings()
|
||||
{
|
||||
Bool = BoolSerialization.String
|
||||
});
|
||||
parameters.Add("test", true);
|
||||
Assert.That(parameters["test"], Is.EqualTo("true"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalBoolStringNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new Parameters(new ParameterSerializationSettings());
|
||||
parameters.Add("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Filters;
|
||||
using CryptoExchange.Net.RateLimiting.Guards;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class RateLimitTests
|
||||
{
|
||||
[TestCase(1, 0.1)]
|
||||
[TestCase(2, 0.1)]
|
||||
[TestCase(5, 1)]
|
||||
[TestCase(1, 2)]
|
||||
public async Task PartialEndpointRateLimiterBasics(int requests, double perSeconds)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new PathStartFilter("/sapi/"), requests, TimeSpan.FromSeconds(perSeconds), RateLimitWindowType.Fixed));
|
||||
|
||||
var triggered = false;
|
||||
rateLimiter.RateLimitTriggered += (x) => { triggered = true; };
|
||||
var requestDefinition = new RequestDefinition("https://test.com", "/sapi/v1/system/status", HttpMethod.Get);
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(i == requests ? triggered : !triggered);
|
||||
}
|
||||
triggered = false;
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(!triggered);
|
||||
}
|
||||
|
||||
[TestCase("/sapi/test1", true)]
|
||||
[TestCase("/sapi/test2", true)]
|
||||
[TestCase("/api/test1", false)]
|
||||
[TestCase("sapi/test1", true)]
|
||||
[TestCase("/sapi/", true)]
|
||||
public async Task PartialEndpointRateLimiterEndpoints(string endpoint, bool expectLimiting)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new PathStartFilter("/sapi/"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
|
||||
var requestDefinition = new RequestDefinition("https://test.com", endpoint, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("/sapi/", "/sapi/", true)]
|
||||
[TestCase("/sapi/test", "/sapi/test", true)]
|
||||
[TestCase("/sapi/test", "/sapi/test123", false)]
|
||||
[TestCase("/sapi/test", "/sapi/", false)]
|
||||
public async Task PartialEndpointRateLimiterEndpoints(string endpoint1, string endpoint2, bool expectLimiting)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new PathStartFilter("/sapi/"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
|
||||
var requestDefinition1 = new RequestDefinition("https://test.com", endpoint1, HttpMethod.Get);
|
||||
var requestDefinition2 = new RequestDefinition("https://test.com", endpoint2, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(expectLimiting ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
[TestCase(1, 0.1)]
|
||||
[TestCase(2, 0.1)]
|
||||
[TestCase(5, 1)]
|
||||
[TestCase(1, 2)]
|
||||
public async Task EndpointRateLimiterBasics(int requests, double perSeconds)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new PathStartFilter("/sapi/test"), requests, TimeSpan.FromSeconds(perSeconds), RateLimitWindowType.Fixed));
|
||||
|
||||
bool triggered = false;
|
||||
rateLimiter.RateLimitTriggered += (x) => { triggered = true; };
|
||||
var requestDefinition = new RequestDefinition("https://test.com", "/sapi/test", HttpMethod.Get);
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(i == requests ? triggered : !triggered);
|
||||
}
|
||||
triggered = false;
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(!triggered);
|
||||
}
|
||||
|
||||
[TestCase("/", false)]
|
||||
[TestCase("/sapi/test", true)]
|
||||
[TestCase("/sapi/test/123", false)]
|
||||
public async Task EndpointRateLimiterEndpoints(string endpoint, bool expectLimited)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathFilter("/sapi/test"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
|
||||
var requestDefinition = new RequestDefinition("https://test.com", endpoint, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("/", false)]
|
||||
[TestCase("/sapi/test", true)]
|
||||
[TestCase("/sapi/test2", true)]
|
||||
[TestCase("/sapi/test23", false)]
|
||||
public async Task EndpointRateLimiterMultipleEndpoints(string endpoint, bool expectLimited)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathsFilter(new[] { "/sapi/test", "/sapi/test2" }), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
var requestDefinition = new RequestDefinition("https://test.com", endpoint, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", true)]
|
||||
[TestCase("123", "456", "/sapi/test", "/sapi/test", false)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test2", true)]
|
||||
[TestCase("123", "123", "/sapi/test2", "/sapi/test", true)]
|
||||
[TestCase(null, "123", "/sapi/test", "/sapi/test", false)]
|
||||
[TestCase("123", null, "/sapi/test", "/sapi/test", false)]
|
||||
[TestCase(null, null, "/sapi/test", "/sapi/test", false)]
|
||||
public async Task ApiKeyRateLimiterBasics(string key1, string key2, string endpoint1, string endpoint2, bool expectLimited)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerApiKey, new AuthenticatedEndpointFilter(true), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Sliding));
|
||||
var requestDefinition1 = new RequestDefinition("https://test.com", endpoint1, HttpMethod.Get) { Authenticated = key1 != null };
|
||||
var requestDefinition2 = new RequestDefinition("https://test.com", endpoint2, HttpMethod.Get) { Authenticated = key2 != null };
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, key1, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, key2, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
[TestCase("/sapi/test", "/sapi/test", true)]
|
||||
[TestCase("/sapi/test1", "/api/test2", true)]
|
||||
[TestCase("/", "/sapi/test2", true)]
|
||||
public async Task TotalRateLimiterBasics(string endpoint1, string endpoint2, bool expectLimited)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, Array.Empty<IGuardFilter>(), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
var requestDefinition1 = new RequestDefinition("https://test.com", endpoint1, HttpMethod.Get);
|
||||
var requestDefinition2 = new RequestDefinition("https://test.com", endpoint2, HttpMethod.Get) { Authenticated = true };
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, null, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
[TestCase("https://test.com", "/sapi/test", "https://test.com", "/sapi/test", true)]
|
||||
[TestCase("https://test2.com", "/sapi/test", "https://test.com", "/sapi/test", false)]
|
||||
[TestCase("https://test.com", "/sapi/test", "https://test2.com", "/sapi/test", false)]
|
||||
[TestCase("https://test.com", "/sapi/test", "https://test.com", "/sapi/test2", true)]
|
||||
public async Task HostRateLimiterBasics(string host1, string endpoint1, string host2, string endpoint2, bool expectLimited)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new HostFilter("https://test.com"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
var requestDefinition1 = new RequestDefinition(host1, endpoint1, HttpMethod.Get);
|
||||
var requestDefinition2 = new RequestDefinition(host2, endpoint2, HttpMethod.Get) { Authenticated = true };
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
[TestCase("https://test.com", "https://test.com", true)]
|
||||
[TestCase("https://test2.com", "https://test.com", false)]
|
||||
[TestCase("https://test.com", "https://test2.com", false)]
|
||||
public async Task ConnectionRateLimiterBasics(string host1, string host2, bool expectLimited)
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host1, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host2, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ConnectionRateLimiterCancel()
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
|
||||
Assert.That(result2.Error, Is.TypeOf<CancellationRequestedError>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RateLimiterReset_Should_AllowNextRequestForSameDefinition()
|
||||
{
|
||||
// arrange
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerConnection, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
|
||||
|
||||
var definition = new RequestDefinition("https://test.com", "1", HttpMethod.Get) { ConnectionId = 1 };
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition, null, null, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RateLimiterReset_Should_NotAllowNextRequestForDifferentDefinition()
|
||||
{
|
||||
// arrange
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerConnection, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
|
||||
|
||||
var definition1 = new RequestDefinition("https://test.com", "1", HttpMethod.Get) { ConnectionId = 1 };
|
||||
var definition2 = new RequestDefinition("https://test.com", "2", HttpMethod.Get) { ConnectionId = 2 };
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition1, null, null, null, default);
|
||||
var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Not.Null);
|
||||
}
|
||||
|
||||
[TestCase(null, null, true)]
|
||||
[TestCase("Group1", null, false)]
|
||||
[TestCase(null, "Group2", false)]
|
||||
[TestCase("Group1", "Group2", false)]
|
||||
[TestCase("Group3", "Group3", true)]
|
||||
public async Task RateLimiterWithDifferentGroups_Should_LimitPerGroup(string? group1, string? group2, bool expectLimited)
|
||||
{
|
||||
// arrange
|
||||
var data = JsonSerializer.Serialize(new TestObject { });
|
||||
var client1 = new TestRestClient(x =>
|
||||
{
|
||||
x.RateLimitGroup = group1;
|
||||
});
|
||||
client1.ApiClient1.SetNextResponse(data, System.Net.HttpStatusCode.OK);
|
||||
var client2 = new TestRestClient(x =>
|
||||
{
|
||||
x.RateLimitGroup = group2;
|
||||
});
|
||||
client2.ApiClient1.SetNextResponse(data, System.Net.HttpStatusCode.OK);
|
||||
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(2), RateLimitWindowType.Fixed));
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
// act
|
||||
var result1 = await client1.ApiClient1.GetResponseAsync<TestObject>(rateLimitGate: rateLimiter);
|
||||
var result2 = await client2.ApiClient1.GetResponseAsync<TestObject>(rateLimitGate: rateLimiter);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt != null, Is.EqualTo(expectLimited));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Logging;
|
||||
using System.Threading;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class RestClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public void RequestingData_Should_ResultInData()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
client.SetResponse(JsonConvert.SerializeObject(expected), out _);
|
||||
|
||||
// act
|
||||
var result = client.Request<TestObject>().Result;
|
||||
|
||||
// assert
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.IsTrue(TestHelpers.AreEqual(expected, result.Data));
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void ReceivingInvalidData_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.SetResponse("{\"property\": 123", out _);
|
||||
|
||||
// act
|
||||
var result = client.Request<TestObject>().Result;
|
||||
|
||||
// assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.IsTrue(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorCode_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.SetErrorWithoutResponse(System.Net.HttpStatusCode.BadRequest, "Invalid request");
|
||||
|
||||
// act
|
||||
var result = await client.Request<TestObject>();
|
||||
|
||||
// assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.IsTrue(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndNotParsingError_Should_ResultInFlatError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.Request<TestObject>();
|
||||
|
||||
// assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.IsTrue(result.Error != null);
|
||||
Assert.IsTrue(result.Error is ServerError);
|
||||
Assert.IsTrue(result.Error.Message.Contains("Invalid request"));
|
||||
Assert.IsTrue(result.Error.Message.Contains("123"));
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndParsingError_Should_ResultInParsedError()
|
||||
{
|
||||
// arrange
|
||||
var client = new ParseErrorTestRestClient();
|
||||
client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.Request<TestObject>();
|
||||
|
||||
// assert
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.IsTrue(result.Error != null);
|
||||
Assert.IsTrue(result.Error is ServerError);
|
||||
Assert.IsTrue(result.Error.Code == 123);
|
||||
Assert.IsTrue(result.Error.Message == "Invalid request");
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void SettingOptions_Should_ResultInOptionsSet()
|
||||
{
|
||||
// arrange
|
||||
// act
|
||||
var client = new TestRestClient(new TestClientOptions()
|
||||
{
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
BaseAddress = "http://test.address.com",
|
||||
RateLimiters = new List<IRateLimiter> { new RateLimiter() },
|
||||
RateLimitingBehaviour = RateLimitingBehaviour.Fail
|
||||
},
|
||||
RequestTimeout = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
|
||||
|
||||
// assert
|
||||
Assert.IsTrue(((TestClientOptions)client.ClientOptions).Api1Options.BaseAddress == "http://test.address.com");
|
||||
Assert.IsTrue(((TestClientOptions)client.ClientOptions).Api1Options.RateLimiters.Count == 1);
|
||||
Assert.IsTrue(((TestClientOptions)client.ClientOptions).Api1Options.RateLimitingBehaviour == RateLimitingBehaviour.Fail);
|
||||
Assert.IsTrue(client.ClientOptions.RequestTimeout == TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[TestCase("GET", HttpMethodParameterPosition.InUri)] // No need to test InBody for GET since thats not valid
|
||||
[TestCase("POST", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("POST", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InBody)]
|
||||
public async Task Setting_Should_ResultInOptionsSet(string method, HttpMethodParameterPosition pos)
|
||||
{
|
||||
// arrange
|
||||
// act
|
||||
var client = new TestRestClient(new TestClientOptions()
|
||||
{
|
||||
Api1Options = new RestApiClientOptions
|
||||
{
|
||||
BaseAddress = "http://test.address.com"
|
||||
}
|
||||
});
|
||||
|
||||
client.Api1.SetParameterPosition(new HttpMethod(method), pos);
|
||||
|
||||
client.SetResponse("{}", out var request);
|
||||
|
||||
await client.RequestWithParams<TestObject>(new HttpMethod(method), new Dictionary<string, object>
|
||||
{
|
||||
{ "TestParam1", "Value1" },
|
||||
{ "TestParam2", 2 },
|
||||
},
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "TestHeader", "123" }
|
||||
});
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(request.Method, new HttpMethod(method));
|
||||
Assert.AreEqual(request.Content?.Contains("TestParam1") == true, pos == HttpMethodParameterPosition.InBody);
|
||||
Assert.AreEqual(request.Uri.ToString().Contains("TestParam1"), pos == HttpMethodParameterPosition.InUri);
|
||||
Assert.AreEqual(request.Content?.Contains("TestParam2") == true, pos == HttpMethodParameterPosition.InBody);
|
||||
Assert.AreEqual(request.Uri.ToString().Contains("TestParam2"), pos == HttpMethodParameterPosition.InUri);
|
||||
Assert.AreEqual(request.GetHeaders().First().Key, "TestHeader");
|
||||
Assert.IsTrue(request.GetHeaders().First().Value.Contains("123"));
|
||||
}
|
||||
|
||||
|
||||
[TestCase(1, 0.1)]
|
||||
[TestCase(2, 0.1)]
|
||||
[TestCase(5, 1)]
|
||||
[TestCase(1, 2)]
|
||||
public async Task PartialEndpointRateLimiterBasics(int requests, double perSeconds)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddPartialEndpointLimit("/sapi/", requests, TimeSpan.FromSeconds(perSeconds));
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, "/sapi/v1/system/status", HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(i == requests? result1.Data > 1 : result1.Data == 0);
|
||||
}
|
||||
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.LimitRequestAsync(log, "/sapi/v1/system/status", HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(result2.Data == 0);
|
||||
}
|
||||
|
||||
[TestCase("/sapi/test1", true)]
|
||||
[TestCase("/sapi/test2", true)]
|
||||
[TestCase("/api/test1", false)]
|
||||
[TestCase("sapi/test1", false)]
|
||||
[TestCase("/sapi/", true)]
|
||||
public async Task PartialEndpointRateLimiterEndpoints(string endpoint, bool expectLimiting)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddPartialEndpointLimit("/sapi/", 1, TimeSpan.FromSeconds(0.1));
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, endpoint, HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
bool expected = i == 1 ? (expectLimiting ? result1.Data > 1 : result1.Data == 0) : result1.Data == 0;
|
||||
Assert.IsTrue(expected);
|
||||
}
|
||||
}
|
||||
[TestCase("/sapi/", "/sapi/", true)]
|
||||
[TestCase("/sapi/test", "/sapi/test", true)]
|
||||
[TestCase("/sapi/test", "/sapi/test123", false)]
|
||||
[TestCase("/sapi/test", "/sapi/", false)]
|
||||
public async Task PartialEndpointRateLimiterEndpoints(string endpoint1, string endpoint2, bool expectLimiting)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddPartialEndpointLimit("/sapi/", 1, TimeSpan.FromSeconds(0.1), countPerEndpoint: true);
|
||||
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, endpoint1, HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
var result2 = await rateLimiter.LimitRequestAsync(log, endpoint2, HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(result1.Data == 0);
|
||||
Assert.IsTrue(expectLimiting ? result2.Data > 0 : result2.Data == 0);
|
||||
}
|
||||
|
||||
[TestCase(1, 0.1)]
|
||||
[TestCase(2, 0.1)]
|
||||
[TestCase(5, 1)]
|
||||
[TestCase(1, 2)]
|
||||
public async Task EndpointRateLimiterBasics(int requests, double perSeconds)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddEndpointLimit("/sapi/test", requests, TimeSpan.FromSeconds(perSeconds));
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, "/sapi/test", HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(i == requests ? result1.Data > 1 : result1.Data == 0);
|
||||
}
|
||||
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.LimitRequestAsync(log, "/sapi/test", HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(result2.Data == 0);
|
||||
}
|
||||
|
||||
[TestCase("/", false)]
|
||||
[TestCase("/sapi/test", true)]
|
||||
[TestCase("/sapi/test/123", false)]
|
||||
public async Task EndpointRateLimiterEndpoints(string endpoint, bool expectLimited)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddEndpointLimit("/sapi/test", 1, TimeSpan.FromSeconds(0.1));
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, endpoint, HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
bool expected = i == 1 ? (expectLimited ? result1.Data > 1 : result1.Data == 0) : result1.Data == 0;
|
||||
Assert.IsTrue(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("/", false)]
|
||||
[TestCase("/sapi/test", true)]
|
||||
[TestCase("/sapi/test2", true)]
|
||||
[TestCase("/sapi/test23", false)]
|
||||
public async Task EndpointRateLimiterMultipleEndpoints(string endpoint, bool expectLimited)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddEndpointLimit(new[] { "/sapi/test", "/sapi/test2" }, 1, TimeSpan.FromSeconds(0.1));
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, endpoint, HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
bool expected = i == 1 ? (expectLimited ? result1.Data > 1 : result1.Data == 0) : result1.Data == 0;
|
||||
Assert.IsTrue(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", true, true, true, true)]
|
||||
[TestCase("123", "456", "/sapi/test", "/sapi/test", true, true, true, false)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test2", true, true, true, true)]
|
||||
[TestCase("123", "123", "/sapi/test2", "/sapi/test", true, true, true, true)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", true, false, true, false)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", false, true, true, false)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", false, false, true, false)]
|
||||
[TestCase(null, "123", "/sapi/test", "/sapi/test", false, true, true, false)]
|
||||
[TestCase("123", null, "/sapi/test", "/sapi/test", true, false, true, false)]
|
||||
[TestCase(null, null, "/sapi/test", "/sapi/test", false, false, true, false)]
|
||||
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", true, true, false, true)]
|
||||
[TestCase("123", "456", "/sapi/test", "/sapi/test", true, true, false, false)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test2", true, true, false, true)]
|
||||
[TestCase("123", "123", "/sapi/test2", "/sapi/test", true, true, false, true)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", true, false, false, true)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", false, true, false, true)]
|
||||
[TestCase("123", "123", "/sapi/test", "/sapi/test", false, false, false, true)]
|
||||
[TestCase(null, "123", "/sapi/test", "/sapi/test", false, true, false, false)]
|
||||
[TestCase("123", null, "/sapi/test", "/sapi/test", true, false, false, false)]
|
||||
[TestCase(null, null, "/sapi/test", "/sapi/test", false, false, false, true)]
|
||||
public async Task ApiKeyRateLimiterBasics(string key1, string key2, string endpoint1, string endpoint2, bool signed1, bool signed2, bool onlyForSignedRequests, bool expectLimited)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddApiKeyLimit(1, TimeSpan.FromSeconds(0.1), onlyForSignedRequests, false);
|
||||
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, endpoint1, HttpMethod.Get, signed1, key1?.ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
var result2 = await rateLimiter.LimitRequestAsync(log, endpoint2, HttpMethod.Get, signed2, key2?.ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(result1.Data == 0);
|
||||
Assert.IsTrue(expectLimited ? result2.Data > 0 : result2.Data == 0);
|
||||
}
|
||||
|
||||
[TestCase("/sapi/test", "/sapi/test", true)]
|
||||
[TestCase("/sapi/test1", "/api/test2", true)]
|
||||
[TestCase("/", "/sapi/test2", true)]
|
||||
public async Task TotalRateLimiterBasics(string endpoint1, string endpoint2, bool expectLimited)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddTotalRateLimit(1, TimeSpan.FromSeconds(0.1));
|
||||
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, endpoint1, HttpMethod.Get, false, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
var result2 = await rateLimiter.LimitRequestAsync(log, endpoint2, HttpMethod.Get, true, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(result1.Data == 0);
|
||||
Assert.IsTrue(expectLimited ? result2.Data > 0 : result2.Data == 0);
|
||||
}
|
||||
|
||||
[TestCase("/sapi/test", true, true, true, false)]
|
||||
[TestCase("/sapi/test", false, true, true, false)]
|
||||
[TestCase("/sapi/test", false, true, false, true)]
|
||||
[TestCase("/sapi/test", true, true, false, true)]
|
||||
public async Task ApiKeyRateLimiterIgnores_TotalRateLimiter_IfSet(string endpoint, bool signed1, bool signed2, bool ignoreTotal, bool expectLimited)
|
||||
{
|
||||
var log = new Log("Test");
|
||||
log.Level = LogLevel.Trace;
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.AddApiKeyLimit(100, TimeSpan.FromSeconds(0.1), true, ignoreTotal);
|
||||
rateLimiter.AddTotalRateLimit(1, TimeSpan.FromSeconds(0.1));
|
||||
|
||||
var result1 = await rateLimiter.LimitRequestAsync(log, endpoint, HttpMethod.Get, signed1, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
var result2 = await rateLimiter.LimitRequestAsync(log, endpoint, HttpMethod.Get, signed2, "123".ToSecureString(), RateLimitingBehaviour.Wait, 1, default);
|
||||
Assert.IsTrue(result1.Data == 0);
|
||||
Assert.IsTrue(expectLimited ? result2.Data > 0 : result2.Data == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class SharedQuantityTests
|
||||
{
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_AllNull_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(null, null, null);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_AllZero_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(0, 0, 0);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_BaseAssetSet_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(1.5m, null, null);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_QuoteAssetSet_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(null, 100m, null);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_ContractsSet_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(null, null, 10m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_NegativeValue_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(-1m, 0, 0);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_DefaultConstructor_Should_SetAllPropertiesToNull()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = new SharedQuantity();
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Base_Should_SetBaseAssetQuantity()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 1.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Base(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedQuantity));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Base_WithZero_Should_SetZeroQuantity()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = SharedQuantity.Base(0m);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(0m));
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Base_WithLargeValue_Should_SetCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var largeValue = 999999.123456789m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Base(largeValue);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(largeValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Quote_Should_SetQuoteAssetQuantity()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 100m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Quote(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(expectedQuantity));
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Quote_WithDecimal_Should_PreserveDecimals()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 50.123456m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Quote(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(expectedQuantity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Contracts_Should_SetContractQuantity()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 10m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Contracts(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(expectedQuantity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Contracts_WithFractionalValue_Should_SetCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 2.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Contracts(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(expectedQuantity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 50m;
|
||||
var expectedBase = 2m; // 100 / 50 = 2
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedBase));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 3m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 100 / 3 = 33.333... should round to 33.33
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(33.33m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 7m;
|
||||
var lotSize = 0.1m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 100 / 7 = 14.285714... should adjust to nearest 0.1 = 14.3
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(14.3m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithHighPrecision_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var price = 0.00001m;
|
||||
var decimalPlaces = 8;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 2m;
|
||||
var price = 50m;
|
||||
var expectedQuote = 100m; // 2 * 50 = 100
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedQuote));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 1.234567m;
|
||||
var price = 10m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 1.234567 * 10 = 12.34567 should round to 12.35
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(12.35m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 3.456m;
|
||||
var price = 10m;
|
||||
var lotSize = 1m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 3.456 * 10 = 34.56 should adjust to nearest 1 = 35
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(35m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithSmallValues_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 0.001m;
|
||||
var price = 0.1m;
|
||||
var decimalPlaces = 8;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(0.0001m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 10m;
|
||||
var expectedContracts = 10m; // 100 / 10 = 10
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedContracts));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 3m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 100 / 3 = 33.333... should round to 33.33
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(33.33m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 7m;
|
||||
var lotSize = 0.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 100 / 7 = 14.285714... should adjust to nearest 0.5 = 14.5
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(14.5m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithFractionalContract_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 1m;
|
||||
var contractSize = 0.1m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(10m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var contractSize = 10m;
|
||||
var price = 50m;
|
||||
var expectedContracts = 2m; // 1000 / 50 / 10 = 2
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedContracts));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var contractSize = 3m;
|
||||
var price = 7m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 100 / 7 / 3 = 4.761904... should round to 4.76
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(4.76m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var contractSize = 7m;
|
||||
var price = 13m;
|
||||
var lotSize = 0.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 1000 / 13 / 7 = 10.989... should adjust to nearest 0.5 = 11.0
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(11.0m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithComplexValues_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 5000m;
|
||||
var contractSize = 0.01m;
|
||||
var price = 25000m;
|
||||
var decimalPlaces = 4;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 5000 / 25000 / 0.01 = 20
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(20m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_DefaultConstructor_Should_SetAllPropertiesToNull()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = new SharedOrderQuantity();
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetBaseAsset()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = 5m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(baseAssetQuantity: baseAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetQuoteAsset()
|
||||
{
|
||||
// arrange
|
||||
var quoteAsset = 100m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(quoteAssetQuantity: quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetContracts()
|
||||
{
|
||||
// arrange
|
||||
var contracts = 10m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(contractQuantity: contracts);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(contracts));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetAllValues()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = 1m;
|
||||
var quoteAsset = 50m;
|
||||
var contracts = 5m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(baseAsset, quoteAsset, contracts);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(contracts));
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_WithNullValues_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = new SharedOrderQuantity(null, null, null);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_RecordEquality_SameValues_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = SharedQuantity.Base(10m);
|
||||
var quantity2 = SharedQuantity.Base(10m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_RecordEquality_DifferentValues_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = SharedQuantity.Base(10m);
|
||||
var quantity2 = SharedQuantity.Base(20m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.Not.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_RecordEquality_DifferentTypes_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = SharedQuantity.Base(10m);
|
||||
var quantity2 = SharedQuantity.Quote(10m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.Not.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_RecordEquality_SameValues_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = new SharedOrderQuantity(5m, 100m, 2m);
|
||||
var quantity2 = new SharedOrderQuantity(5m, 100m, 2m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 3m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price);
|
||||
|
||||
// assert
|
||||
// Default decimalPlaces = 8, default lotSize = 0.00000001
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 1.234567m;
|
||||
var price = 10m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 3m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var contractSize = 10m;
|
||||
var price = 50m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class SharedSymbolTests
|
||||
{
|
||||
[Test]
|
||||
public void SharedSymbol_Constructor_Should_SetAllProperties()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(symbol.DeliverTime, Is.Null);
|
||||
Assert.That(symbol.SymbolName, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_Constructor_WithDeliveryTime_Should_SetDeliveryTime()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.DeliveryLinear;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
var deliveryTime = new DateTime(2026, 6, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, deliveryTime);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(symbol.DeliverTime, Is.EqualTo(deliveryTime));
|
||||
Assert.That(symbol.SymbolName, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_Constructor_WithNullDeliveryTime_Should_SetToNull()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "ETH";
|
||||
var quoteAsset = "BTC";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, deliverTime: null);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.DeliverTime, Is.Null);
|
||||
}
|
||||
|
||||
[TestCase(TradingMode.Spot)]
|
||||
[TestCase(TradingMode.PerpetualLinear)]
|
||||
[TestCase(TradingMode.PerpetualInverse)]
|
||||
[TestCase(TradingMode.DeliveryLinear)]
|
||||
[TestCase(TradingMode.DeliveryInverse)]
|
||||
public void SharedSymbol_Constructor_WithDifferentTradingModes_Should_SetCorrectly(TradingMode tradingMode)
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_ConstructorWithSymbolName_Should_SetSymbolName()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
var symbolName = "BTC-USDT";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo(symbolName));
|
||||
Assert.That(symbol.DeliverTime, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_ConstructorWithSymbolName_WithCustomFormat_Should_SetCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.PerpetualLinear;
|
||||
var baseAsset = "ETH";
|
||||
var quoteAsset = "USDT";
|
||||
var symbolName = "ETHUSDT-PERP";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo(symbolName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_ConstructorWithSymbolName_WithEmptyString_Should_SetEmptyString()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
var symbolName = "";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo(""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithSymbolNameSet_Should_ReturnSymbolName()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "CUSTOM-BTC-USDT");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("CUSTOM-BTC-USDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithSymbolNameNull_Should_UseFormatFunction()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}/{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTC/USDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithComplexFormatFunction_Should_ApplyCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.PerpetualLinear, "ETH", "USDT");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => t == TradingMode.PerpetualLinear ? $"{b}{q}-PERP" : $"{b}{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("ETHUSDT-PERP"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithDeliveryTime_Should_PassDeliveryTimeToFormatter()
|
||||
{
|
||||
// arrange
|
||||
var deliveryTime = new DateTime(2026, 6, 25);
|
||||
var symbol = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime);
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => d.HasValue ? $"{b}{q}_{d.Value:yyyyMMdd}" : $"{b}{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTCUSDT_20260625"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithTradingMode_Should_PassTradingModeToFormatter()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.PerpetualInverse, "BTC", "USD");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) =>
|
||||
{
|
||||
return t switch
|
||||
{
|
||||
TradingMode.Spot => $"{b}{q}",
|
||||
TradingMode.PerpetualLinear => $"{b}{q}-PERP",
|
||||
TradingMode.PerpetualInverse => $"{b}{q}I-PERP",
|
||||
_ => $"{b}{q}"
|
||||
};
|
||||
});
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTCUSDI-PERP"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithEmptySymbolName_Should_UseFormatFunction()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}-{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTC-USDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithWhitespaceSymbolName_Should_ReturnWhitespace()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", " ");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}-{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo(" "));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_SameValues_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentBaseAsset_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "ETH", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentQuoteAsset_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "EUR");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentTradingMode_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentDeliveryTime_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var deliveryTime1 = new DateTime(2026, 6, 25);
|
||||
var deliveryTime2 = new DateTime(2026, 9, 25);
|
||||
var symbol1 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime1);
|
||||
var symbol2 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime2);
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentSymbolName_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTCUSDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTC-USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_OneWithSymbolNameOneWithout_Should_NotBeEqual()
|
||||
{
|
||||
// NOTE; although this should probably be equal it's considered not because the SymbolName property isn't equal
|
||||
// Overridding equality to ignore SymbolName would be possible but would break the default record equality behavior and cause confusion
|
||||
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTCUSDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_WithAllPropertiesSet_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var deliveryTime = new DateTime(2026, 6, 25);
|
||||
var symbol1 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime)
|
||||
{
|
||||
SymbolName = "BTCUSDT-0625"
|
||||
};
|
||||
var symbol2 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime)
|
||||
{
|
||||
SymbolName = "BTCUSDT-0625"
|
||||
};
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_Properties_Should_BeSettable()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
symbol.BaseAsset = "ETH";
|
||||
symbol.QuoteAsset = "EUR";
|
||||
symbol.TradingMode = TradingMode.PerpetualLinear;
|
||||
symbol.SymbolName = "CUSTOM";
|
||||
symbol.DeliverTime = DateTime.UtcNow;
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo("ETH"));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo("EUR"));
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(TradingMode.PerpetualLinear));
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo("CUSTOM"));
|
||||
Assert.That(symbol.DeliverTime, Is.Not.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_WithSpecialCharactersInAssets_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = "BTC-123";
|
||||
var quoteAsset = "USDT_2.0";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_WithLongAssetNames_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = "VERYLONGASSETNAMEFORTESTING";
|
||||
var quoteAsset = "ANOTHERVERYLONGASSETNAME";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SocketClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public void SettingOptions_Should_ResultInOptionsSet()
|
||||
{
|
||||
//arrange
|
||||
//act
|
||||
var client = new TestSocketClient(new TestOptions()
|
||||
{
|
||||
SubOptions = new RestApiClientOptions
|
||||
{
|
||||
BaseAddress = "http://test.address.com"
|
||||
},
|
||||
ReconnectInterval = TimeSpan.FromSeconds(6)
|
||||
});
|
||||
|
||||
|
||||
//assert
|
||||
Assert.IsTrue(client.SubClient.Options.BaseAddress == "http://test.address.com");
|
||||
Assert.IsTrue(client.ClientOptions.ReconnectInterval.TotalSeconds == 6);
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public void ConnectSocket_Should_ReturnConnectionResult(bool canConnect)
|
||||
{
|
||||
//arrange
|
||||
var client = new TestSocketClient();
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = canConnect;
|
||||
|
||||
//act
|
||||
var connectResult = client.ConnectSocketSub(new SocketConnection(client, null, socket, null));
|
||||
|
||||
//assert
|
||||
Assert.IsTrue(connectResult.Success == canConnect);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void SocketMessages_Should_BeProcessedInDataHandlers()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
|
||||
var socket = client.CreateSocket();
|
||||
socket.ShouldReconnect = true;
|
||||
socket.CanConnect = true;
|
||||
socket.DisconnectTime = DateTime.UtcNow;
|
||||
var sub = new SocketConnection(client, null, socket, null);
|
||||
var rstEvent = new ManualResetEvent(false);
|
||||
JToken result = null;
|
||||
sub.AddSubscription(SocketSubscription.CreateForIdentifier(10, "TestHandler", true, false, (messageEvent) =>
|
||||
{
|
||||
result = messageEvent.JsonData;
|
||||
rstEvent.Set();
|
||||
}));
|
||||
client.ConnectSocketSub(sub);
|
||||
|
||||
// act
|
||||
socket.InvokeMessage("{\"property\": 123}");
|
||||
rstEvent.WaitOne(1000);
|
||||
|
||||
// assert
|
||||
Assert.IsTrue((int)result["property"] == 123);
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled)
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug, OutputOriginalData = enabled });
|
||||
var socket = client.CreateSocket();
|
||||
socket.ShouldReconnect = true;
|
||||
socket.CanConnect = true;
|
||||
socket.DisconnectTime = DateTime.UtcNow;
|
||||
var sub = new SocketConnection(client, null, socket, null);
|
||||
var rstEvent = new ManualResetEvent(false);
|
||||
string original = null;
|
||||
sub.AddSubscription(SocketSubscription.CreateForIdentifier(10, "TestHandler", true, false, (messageEvent) =>
|
||||
{
|
||||
original = messageEvent.OriginalData;
|
||||
rstEvent.Set();
|
||||
}));
|
||||
client.ConnectSocketSub(sub);
|
||||
|
||||
// act
|
||||
socket.InvokeMessage("{\"property\": 123}");
|
||||
rstEvent.WaitOne(1000);
|
||||
|
||||
// assert
|
||||
Assert.IsTrue(original == (enabled ? "{\"property\": 123}" : null));
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void UnsubscribingStream_Should_CloseTheSocket()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = true;
|
||||
var sub = new SocketConnection(client, null, socket, null);
|
||||
client.ConnectSocketSub(sub);
|
||||
var us = SocketSubscription.CreateForIdentifier(10, "Test", true, false, (e) => { });
|
||||
var ups = new UpdateSubscription(sub, us);
|
||||
sub.AddSubscription(us);
|
||||
|
||||
// act
|
||||
client.UnsubscribeAsync(ups).Wait();
|
||||
|
||||
// assert
|
||||
Assert.IsTrue(socket.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void UnsubscribingAll_Should_CloseAllSockets()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
|
||||
var socket1 = client.CreateSocket();
|
||||
var socket2 = client.CreateSocket();
|
||||
socket1.CanConnect = true;
|
||||
socket2.CanConnect = true;
|
||||
var sub1 = new SocketConnection(client, null, socket1, null);
|
||||
var sub2 = new SocketConnection(client, null, socket2, null);
|
||||
client.ConnectSocketSub(sub1);
|
||||
client.ConnectSocketSub(sub2);
|
||||
|
||||
// act
|
||||
client.UnsubscribeAllAsync().Wait();
|
||||
|
||||
// assert
|
||||
Assert.IsTrue(socket1.Connected == false);
|
||||
Assert.IsTrue(socket2.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void FailingToConnectSocket_Should_ReturnError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = false;
|
||||
var sub = new SocketConnection(client, null, socket, null);
|
||||
|
||||
// act
|
||||
var connectResult = client.ConnectSocketSub(sub);
|
||||
|
||||
// assert
|
||||
Assert.IsFalse(connectResult.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.SocketRoutingTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class QueryRouterTests
|
||||
{
|
||||
[Test]
|
||||
public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType()
|
||||
{
|
||||
// arrange
|
||||
var routes = new MessageRoute[]
|
||||
{
|
||||
MessageRoute.CreateForEvent<string>("type1", (_, _, _, _) => null),
|
||||
MessageRoute.CreateForEvent<string>("type1", "topic1", (_, _, _, _) => null),
|
||||
MessageRoute.CreateForEvent<int>("type2", "topic2", (_, _, _, _) => null)
|
||||
};
|
||||
|
||||
var router = new QueryRouter(routes);
|
||||
|
||||
// act
|
||||
var type1Routes = router.GetRoutes("type1");
|
||||
var type2Routes = router.GetRoutes("type2");
|
||||
var missingRoutes = router.GetRoutes("missing");
|
||||
|
||||
// assert
|
||||
Assert.That(type1Routes, Is.Not.Null);
|
||||
Assert.That(type2Routes, Is.Not.Null);
|
||||
Assert.That(missingRoutes, Is.Null);
|
||||
|
||||
Assert.That(type1Routes, Is.TypeOf<QueryRouteCollection>());
|
||||
Assert.That(type2Routes, Is.TypeOf<QueryRouteCollection>());
|
||||
Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddRoute_Should_SetMultipleReaders_WhenAnyRouteAllowsMultipleReaders()
|
||||
{
|
||||
// arrange
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
|
||||
// act
|
||||
collection.AddRoute(null, MessageRoute.CreateForEvent<string>("type", (_, _, _, _) => null));
|
||||
var beforeMultipleReaders = collection.MultipleReaders;
|
||||
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) => null, true));
|
||||
var afterMultipleReaders = collection.MultipleReaders;
|
||||
|
||||
// assert
|
||||
Assert.That(beforeMultipleReaders, Is.False);
|
||||
Assert.That(afterMultipleReaders, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute.CreateForEvent<string>("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.Null);
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ReturnFalse_WhenNoRoutesMatch()
|
||||
{
|
||||
// arrange
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("other-topic", MessageRoute.CreateForEvent<string>("type", "other-topic", (_, _, _, _) => null));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute.CreateForEvent<string>("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.Null);
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_StopAfterFirstNonNullMatchingResult_WhenMultipleReadersIsFalse()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var expectedResult = CallResult.Ok();
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return expectedResult;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return CallResult.Ok();
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(expectedResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ContinueAfterNonNullMatchingResult_WhenMultipleReadersIsTrue()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var expectedResult = CallResult.Ok();
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return expectedResult;
|
||||
}, true));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return CallResult.Ok();
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(expectedResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first", "second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ContinueUntilNonNullResult_WhenEarlierMatchingRoutesReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var expectedResult = CallResult.Ok();
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return expectedResult;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("third");
|
||||
return CallResult.Ok();
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(expectedResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first", "second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ReturnHandledTrue_WhenMatchingRoutesReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) => null));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using CryptoExchange.Net.Sockets.Interfaces;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.SocketRoutingTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class RoutingTableTests
|
||||
{
|
||||
[Test]
|
||||
public void Update_Should_CreateEntriesPerTypeIdentifier_WithCorrectDeserializationTypeAndHandlers()
|
||||
{
|
||||
// arrange
|
||||
var processor1 = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute.CreateForEvent<string>("type1", (_, _, _, _) => null),
|
||||
MessageRoute.CreateForEvent<string>("type1", "topic1", (_, _, _, _) => null)));
|
||||
|
||||
var processor2 = new TestMessageProcessor(
|
||||
2,
|
||||
MessageRouter.Create(
|
||||
MessageRoute.CreateForEvent<int>("type2", "topic2", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
|
||||
// act
|
||||
table.Update(new IMessageProcessor[] { processor1, processor2 });
|
||||
|
||||
var type1Entry = table.GetRouteTableEntry("type1");
|
||||
var type2Entry = table.GetRouteTableEntry("type2");
|
||||
var missingEntry = table.GetRouteTableEntry("missing");
|
||||
|
||||
// assert
|
||||
Assert.That(type1Entry, Is.Not.Null);
|
||||
Assert.That(type2Entry, Is.Not.Null);
|
||||
Assert.That(missingEntry, Is.Null);
|
||||
|
||||
Assert.That(type1Entry!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(type1Entry.IsStringOutput, Is.True);
|
||||
Assert.That(type1Entry.Handlers, Has.Count.EqualTo(1));
|
||||
Assert.That(type1Entry.Handlers.Single(), Is.SameAs(processor1));
|
||||
|
||||
Assert.That(type2Entry!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
Assert.That(type2Entry.IsStringOutput, Is.False);
|
||||
Assert.That(type2Entry.Handlers, Has.Count.EqualTo(1));
|
||||
Assert.That(type2Entry.Handlers.Single(), Is.SameAs(processor2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Update_Should_AddMultipleProcessors_ForSameTypeIdentifier()
|
||||
{
|
||||
// arrange
|
||||
var processor1 = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute.CreateForEvent<string>("type1", (_, _, _, _) => null)));
|
||||
|
||||
var processor2 = new TestMessageProcessor(
|
||||
2,
|
||||
MessageRouter.Create(
|
||||
MessageRoute.CreateForEvent<string>("type1", "topic1", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
|
||||
// act
|
||||
table.Update(new IMessageProcessor[] { processor1, processor2 });
|
||||
var entry = table.GetRouteTableEntry("type1");
|
||||
|
||||
// assert
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
Assert.That(entry!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(entry.Handlers, Has.Count.EqualTo(2));
|
||||
Assert.That(entry.Handlers, Does.Contain(processor1));
|
||||
Assert.That(entry.Handlers, Does.Contain(processor2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Update_Should_ReplacePreviousEntries()
|
||||
{
|
||||
// arrange
|
||||
var initialProcessor = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute.CreateForEvent<string>("type1", (_, _, _, _) => null)));
|
||||
|
||||
var replacementProcessor = new TestMessageProcessor(
|
||||
2,
|
||||
MessageRouter.Create(
|
||||
MessageRoute.CreateForEvent<int>("type2", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
table.Update(new IMessageProcessor[] { initialProcessor });
|
||||
|
||||
// act
|
||||
table.Update(new IMessageProcessor[] { replacementProcessor });
|
||||
|
||||
var oldEntry = table.GetRouteTableEntry("type1");
|
||||
var newEntry = table.GetRouteTableEntry("type2");
|
||||
|
||||
// assert
|
||||
Assert.That(oldEntry, Is.Null);
|
||||
Assert.That(newEntry, Is.Not.Null);
|
||||
Assert.That(newEntry!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
Assert.That(newEntry.Handlers, Has.Count.EqualTo(1));
|
||||
Assert.That(newEntry.Handlers.Single(), Is.SameAs(replacementProcessor));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Update_WithEmptyProcessors_Should_ClearEntries()
|
||||
{
|
||||
// arrange
|
||||
var processor = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute.CreateForEvent<string>("type1", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
table.Update(new IMessageProcessor[] { processor });
|
||||
|
||||
// act
|
||||
table.Update(Array.Empty<IMessageProcessor>());
|
||||
|
||||
// assert
|
||||
Assert.That(table.GetRouteTableEntry("type1"), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypeRoutingCollection_Should_SetIsStringOutput_BasedOnDeserializationType()
|
||||
{
|
||||
// arrange & act
|
||||
var stringCollection = new TypeRoutingCollection(typeof(string));
|
||||
var intCollection = new TypeRoutingCollection(typeof(int));
|
||||
|
||||
// assert
|
||||
Assert.That(stringCollection.IsStringOutput, Is.True);
|
||||
Assert.That(stringCollection.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(stringCollection.Handlers, Is.Empty);
|
||||
|
||||
Assert.That(intCollection.IsStringOutput, Is.False);
|
||||
Assert.That(intCollection.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
Assert.That(intCollection.Handlers, Is.Empty);
|
||||
}
|
||||
|
||||
private sealed class TestMessageProcessor : IMessageProcessor
|
||||
{
|
||||
public int Id { get; }
|
||||
public MessageRouter MessageRouter { get; }
|
||||
|
||||
public TestMessageProcessor(int id, MessageRouter messageRouter)
|
||||
{
|
||||
Id = id;
|
||||
MessageRouter = messageRouter;
|
||||
}
|
||||
|
||||
#pragma warning disable CS0067 // The event is never used, but it's required by the interface
|
||||
public event Action? OnMessageRouterUpdated;
|
||||
#pragma warning restore CS0067
|
||||
public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection socketConnection, DateTime receiveTime, string? originalData, object result)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.SocketRoutingTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SubscriptionRouterTests
|
||||
{
|
||||
[Test]
|
||||
public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType()
|
||||
{
|
||||
// arrange
|
||||
var routes = new MessageRoute[]
|
||||
{
|
||||
MessageRoute.CreateForEvent<string>("type1", (_, _, _, _) => null),
|
||||
MessageRoute.CreateForEvent<string>("type1", "topic1", (_, _, _, _) => null),
|
||||
MessageRoute.CreateForEvent<int>("type2", "topic2", (_, _, _, _) => null)
|
||||
};
|
||||
|
||||
var router = new SubscriptionRouter(routes);
|
||||
|
||||
// act
|
||||
var type1Routes = router.GetRoutes("type1");
|
||||
var type2Routes = router.GetRoutes("type2");
|
||||
var missingRoutes = router.GetRoutes("missing");
|
||||
|
||||
// assert
|
||||
Assert.That(type1Routes, Is.Not.Null);
|
||||
Assert.That(type2Routes, Is.Not.Null);
|
||||
Assert.That(missingRoutes, Is.Null);
|
||||
|
||||
Assert.That(type1Routes, Is.TypeOf<SubscriptionRouteCollection>());
|
||||
Assert.That(type2Routes, Is.TypeOf<SubscriptionRouteCollection>());
|
||||
Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute.CreateForEvent<string>("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(CallResult.Ok()));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ReturnFalse_WhenNoRoutesMatch()
|
||||
{
|
||||
// arrange
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute("other-topic", MessageRoute.CreateForEvent<string>("type", "other-topic", (_, _, _, _) => null));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(result, Is.SameAs(CallResult.Ok()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute.CreateForEvent<string>("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(CallResult.Ok()));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeAllMatchingTopicRoutes()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return CallResult.Ok();
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(CallResult.Ok()));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first", "second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_NotInvokeTopicRoutes_WhenTopicFilterIsNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute.CreateForEvent<string>("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(result, Is.SameAs(CallResult.Ok()));
|
||||
Assert.That(calls, Is.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,28 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.OrderBook;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SymbolOrderBookTests
|
||||
{
|
||||
private static OrderBookOptions defaultOrderBookOptions = new OrderBookOptions();
|
||||
private static readonly OrderBookOptions _defaultOrderBookOptions = new OrderBookOptions();
|
||||
|
||||
private class TestableSymbolOrderBook : SymbolOrderBook
|
||||
{
|
||||
public TestableSymbolOrderBook() : base("Test", "BTC/USD", defaultOrderBookOptions)
|
||||
public TestableSymbolOrderBook() : base(null, "Test", "Test", "BTC/USD")
|
||||
{
|
||||
Initialize(_defaultOrderBookOptions);
|
||||
}
|
||||
|
||||
|
||||
protected override Task<CallResult<bool>> DoResyncAsync(CancellationToken ct)
|
||||
protected override Task<CallResult> DoResyncAsync(CancellationToken ct)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@@ -35,12 +38,12 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public void SetData(IEnumerable<ISymbolOrderBookEntry> bids, IEnumerable<ISymbolOrderBookEntry> asks)
|
||||
{
|
||||
Status = OrderBookStatus.Synced;
|
||||
base.bids.Clear();
|
||||
base._bids.Clear();
|
||||
foreach (var bid in bids)
|
||||
base.bids.Add(bid.Price, bid);
|
||||
base.asks.Clear();
|
||||
base._bids.Add(bid.Price, bid);
|
||||
base._asks.Clear();
|
||||
foreach (var ask in asks)
|
||||
base.asks.Add(ask.Price, ask);
|
||||
base._asks.Add(ask.Price, ask);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,31 +57,31 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public void GivenEmptyBidList_WhenBestBid_ThenEmptySymbolOrderBookEntry()
|
||||
{
|
||||
var symbolOrderBook = new TestableSymbolOrderBook();
|
||||
Assert.IsNotNull(symbolOrderBook.BestBid);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestBid.Price);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestAsk.Quantity);
|
||||
ClassicAssert.IsNotNull(symbolOrderBook.BestBid);
|
||||
Assert.That(0m == symbolOrderBook.BestBid.Price);
|
||||
Assert.That(0m == symbolOrderBook.BestAsk.Quantity);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void GivenEmptyAskList_WhenBestAsk_ThenEmptySymbolOrderBookEntry()
|
||||
{
|
||||
var symbolOrderBook = new TestableSymbolOrderBook();
|
||||
Assert.IsNotNull(symbolOrderBook.BestBid);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestBid.Price);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestAsk.Quantity);
|
||||
ClassicAssert.IsNotNull(symbolOrderBook.BestBid);
|
||||
Assert.That(0m == symbolOrderBook.BestBid.Price);
|
||||
Assert.That(0m == symbolOrderBook.BestAsk.Quantity);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void GivenEmptyBidAndAskList_WhenBestOffers_ThenEmptySymbolOrderBookEntries()
|
||||
{
|
||||
var symbolOrderBook = new TestableSymbolOrderBook();
|
||||
Assert.IsNotNull(symbolOrderBook.BestOffers);
|
||||
Assert.IsNotNull(symbolOrderBook.BestOffers.Bid);
|
||||
Assert.IsNotNull(symbolOrderBook.BestOffers.Ask);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestOffers.Bid.Price);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestOffers.Bid.Quantity);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestOffers.Ask.Price);
|
||||
Assert.AreEqual(0m, symbolOrderBook.BestOffers.Ask.Quantity);
|
||||
ClassicAssert.IsNotNull(symbolOrderBook.BestOffers);
|
||||
ClassicAssert.IsNotNull(symbolOrderBook.BestOffers.Bid);
|
||||
ClassicAssert.IsNotNull(symbolOrderBook.BestOffers.Ask);
|
||||
Assert.That(0m == symbolOrderBook.BestOffers.Bid.Price);
|
||||
Assert.That(0m == symbolOrderBook.BestOffers.Bid.Quantity);
|
||||
Assert.That(0m == symbolOrderBook.BestOffers.Ask.Price);
|
||||
Assert.That(0m == symbolOrderBook.BestOffers.Ask.Quantity);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
@@ -101,12 +104,40 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var resultBids2 = orderbook.CalculateAverageFillPrice(1.5m, OrderBookEntryType.Bid);
|
||||
var resultAsks2 = orderbook.CalculateAverageFillPrice(1.5m, OrderBookEntryType.Ask);
|
||||
|
||||
Assert.True(resultBids.Success);
|
||||
Assert.True(resultAsks.Success);
|
||||
Assert.AreEqual(1.05m, resultBids.Data);
|
||||
Assert.AreEqual(1.25m, resultAsks.Data);
|
||||
Assert.AreEqual(1.06666667m, resultBids2.Data);
|
||||
Assert.AreEqual(1.23333333m, resultAsks2.Data);
|
||||
Assert.That(resultBids.Success);
|
||||
Assert.That(resultAsks.Success);
|
||||
Assert.That(1.05m == resultBids.Data);
|
||||
Assert.That(1.25m == resultAsks.Data);
|
||||
Assert.That(1.06666667m == resultBids2.Data);
|
||||
Assert.That(1.23333333m == resultAsks2.Data);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void CalculateTradableAmount()
|
||||
{
|
||||
var orderbook = new TestableSymbolOrderBook();
|
||||
orderbook.SetData(new List<ISymbolOrderBookEntry>
|
||||
{
|
||||
new BookEntry{ Price = 1, Quantity = 1 },
|
||||
new BookEntry{ Price = 1.1m, Quantity = 1 },
|
||||
},
|
||||
new List<ISymbolOrderBookEntry>()
|
||||
{
|
||||
new BookEntry{ Price = 1.2m, Quantity = 1 },
|
||||
new BookEntry{ Price = 1.3m, Quantity = 1 },
|
||||
});
|
||||
|
||||
var resultBids = orderbook.CalculateTradableAmount(2, OrderBookEntryType.Bid);
|
||||
var resultAsks = orderbook.CalculateTradableAmount(2, OrderBookEntryType.Ask);
|
||||
var resultBids2 = orderbook.CalculateTradableAmount(1.5m, OrderBookEntryType.Bid);
|
||||
var resultAsks2 = orderbook.CalculateTradableAmount(1.5m, OrderBookEntryType.Ask);
|
||||
|
||||
Assert.That(resultBids.Success);
|
||||
Assert.That(resultAsks.Success);
|
||||
Assert.That(1.9m == resultBids.Data);
|
||||
Assert.That(1.61538462m == resultAsks.Data);
|
||||
Assert.That(1.4m == resultBids2.Data);
|
||||
Assert.That(1.23076923m == resultAsks2.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
public class TestBaseClient: BaseClient
|
||||
{
|
||||
public TestBaseClient(): base("Test", new BaseClientOptions())
|
||||
{
|
||||
}
|
||||
|
||||
public TestBaseClient(BaseRestClientOptions exchangeOptions) : base("Test", exchangeOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public void Log(LogLevel verbosity, string data)
|
||||
{
|
||||
log.Write(verbosity, data);
|
||||
}
|
||||
|
||||
public CallResult<T> Deserialize<T>(string data)
|
||||
{
|
||||
return Deserialize<T>(data, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public class TestAuthProvider : AuthenticationProvider
|
||||
{
|
||||
public TestAuthProvider(ApiCredentials credentials) : base(credentials)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AuthenticateRequest(RestApiClient apiClient, Uri uri, HttpMethod method, Dictionary<string, object> providedParameters, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, out SortedDictionary<string, object> uriParameters, out SortedDictionary<string, object> bodyParameters, out Dictionary<string, string> headers)
|
||||
{
|
||||
bodyParameters = new SortedDictionary<string, object>();
|
||||
uriParameters = new SortedDictionary<string, object>();
|
||||
headers = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public override string Sign(string toSign)
|
||||
{
|
||||
return toSign;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestHelpers
|
||||
{
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static bool AreEqual<T>(T self, T to, params string[] ignore) where T : class
|
||||
{
|
||||
if (self != null && to != null)
|
||||
{
|
||||
var type = self.GetType();
|
||||
var ignoreList = new List<string>(ignore);
|
||||
foreach (var pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (ignoreList.Contains(pi.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var selfValue = type.GetProperty(pi.Name).GetValue(self, null);
|
||||
var toValue = type.GetProperty(pi.Name).GetValue(to, null);
|
||||
|
||||
if (pi.PropertyType.IsClass && !pi.PropertyType.Module.ScopeName.Equals("System.Private.CoreLib.dll"))
|
||||
{
|
||||
// Check of "CommonLanguageRuntimeLibrary" is needed because string is also a class
|
||||
if (AreEqual(selfValue, toValue, ignore))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selfValue != toValue && (selfValue == null || !selfValue.Equals(toValue)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return self == to;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestObject
|
||||
{
|
||||
[JsonProperty("other")]
|
||||
public string StringData { get; set; }
|
||||
public int IntData { get; set; }
|
||||
public decimal DecimalData { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestRestClient: BaseRestClient
|
||||
{
|
||||
public TestRestApi1Client Api1 { get; }
|
||||
public TestRestApi2Client Api2 { get; }
|
||||
|
||||
public TestRestClient() : this(new TestClientOptions())
|
||||
{
|
||||
}
|
||||
|
||||
public TestRestClient(TestClientOptions exchangeOptions) : base("Test", exchangeOptions)
|
||||
{
|
||||
Api1 = new TestRestApi1Client(exchangeOptions);
|
||||
Api2 = new TestRestApi2Client(exchangeOptions);
|
||||
RequestFactory = new Mock<IRequestFactory>().Object;
|
||||
}
|
||||
|
||||
public void SetResponse(string responseData, out IRequest requestObj)
|
||||
{
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(responseData);
|
||||
var responseStream = new MemoryStream();
|
||||
responseStream.Write(expectedBytes, 0, expectedBytes.Length);
|
||||
responseStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var response = new Mock<IResponse>();
|
||||
response.Setup(c => c.IsSuccessStatusCode).Returns(true);
|
||||
response.Setup(c => c.GetResponseStreamAsync()).Returns(Task.FromResult((Stream)responseStream));
|
||||
|
||||
var headers = new Dictionary<string, IEnumerable<string>>();
|
||||
var request = new Mock<IRequest>();
|
||||
request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com"));
|
||||
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Returns(Task.FromResult(response.Object));
|
||||
request.Setup(c => c.SetContent(It.IsAny<string>(), It.IsAny<string>())).Callback(new Action<string, string>((content, type) => { request.Setup(r => r.Content).Returns(content); }));
|
||||
request.Setup(c => c.AddHeader(It.IsAny<string>(), It.IsAny<string>())).Callback<string, string>((key, val) => headers.Add(key, new List<string> { val }));
|
||||
request.Setup(c => c.GetHeaders()).Returns(() => headers);
|
||||
|
||||
var factory = Mock.Get(RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Callback<HttpMethod, Uri, int>((method, uri, id) =>
|
||||
{
|
||||
request.Setup(a => a.Uri).Returns(uri);
|
||||
request.Setup(a => a.Method).Returns(method);
|
||||
})
|
||||
.Returns(request.Object);
|
||||
requestObj = request.Object;
|
||||
}
|
||||
|
||||
public void SetErrorWithoutResponse(HttpStatusCode code, string message)
|
||||
{
|
||||
var we = new HttpRequestException();
|
||||
typeof(HttpRequestException).GetField("_message", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).SetValue(we, message);
|
||||
|
||||
var request = new Mock<IRequest>();
|
||||
request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com"));
|
||||
request.Setup(c => c.GetHeaders()).Returns(new Dictionary<string, IEnumerable<string>>());
|
||||
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Throws(we);
|
||||
|
||||
var factory = Mock.Get(RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Returns(request.Object);
|
||||
}
|
||||
|
||||
public void SetErrorWithResponse(string responseData, HttpStatusCode code)
|
||||
{
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(responseData);
|
||||
var responseStream = new MemoryStream();
|
||||
responseStream.Write(expectedBytes, 0, expectedBytes.Length);
|
||||
responseStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var response = new Mock<IResponse>();
|
||||
response.Setup(c => c.IsSuccessStatusCode).Returns(false);
|
||||
response.Setup(c => c.GetResponseStreamAsync()).Returns(Task.FromResult((Stream)responseStream));
|
||||
|
||||
var headers = new Dictionary<string, IEnumerable<string>>();
|
||||
var request = new Mock<IRequest>();
|
||||
request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com"));
|
||||
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Returns(Task.FromResult(response.Object));
|
||||
request.Setup(c => c.AddHeader(It.IsAny<string>(), It.IsAny<string>())).Callback<string, string>((key, val) => headers.Add(key, new List<string> { val }));
|
||||
request.Setup(c => c.GetHeaders()).Returns(headers);
|
||||
|
||||
var factory = Mock.Get(RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Callback<HttpMethod, Uri, int>((method, uri, id) => request.Setup(a => a.Uri).Returns(uri))
|
||||
.Returns(request.Object);
|
||||
}
|
||||
|
||||
public async Task<CallResult<T>> Request<T>(CancellationToken ct = default) where T:class
|
||||
{
|
||||
return await SendRequestAsync<T>(Api1, new Uri("http://www.test.com"), HttpMethod.Get, ct);
|
||||
}
|
||||
|
||||
public async Task<CallResult<T>> RequestWithParams<T>(HttpMethod method, Dictionary<string, object> parameters, Dictionary<string, string> headers) where T : class
|
||||
{
|
||||
return await SendRequestAsync<T>(Api1, new Uri("http://www.test.com"), method, default, parameters, additionalHeaders: headers);
|
||||
}
|
||||
}
|
||||
|
||||
public class TestRestApi1Client : RestApiClient
|
||||
{
|
||||
public TestRestApi1Client(TestClientOptions options): base(options, options.Api1Options)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetParameterPosition(HttpMethod method, HttpMethodParameterPosition position)
|
||||
{
|
||||
ParameterPositions[method] = position;
|
||||
}
|
||||
|
||||
public override TimeSpan GetTimeOffset()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
|
||||
=> new TestAuthProvider(credentials);
|
||||
|
||||
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override TimeSyncInfo GetTimeSyncInfo()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class TestRestApi2Client : RestApiClient
|
||||
{
|
||||
public TestRestApi2Client(TestClientOptions options) : base(options, options.Api2Options)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public override TimeSpan GetTimeOffset()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
|
||||
=> new TestAuthProvider(credentials);
|
||||
|
||||
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override TimeSyncInfo GetTimeSyncInfo()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class TestAuthProvider : AuthenticationProvider
|
||||
{
|
||||
public TestAuthProvider(ApiCredentials credentials) : base(credentials)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AuthenticateRequest(RestApiClient apiClient, Uri uri, HttpMethod method, Dictionary<string, object> providedParameters, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, out SortedDictionary<string, object> uriParameters, out SortedDictionary<string, object> bodyParameters, out Dictionary<string, string> headers)
|
||||
{
|
||||
uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? new SortedDictionary<string, object>(providedParameters) : new SortedDictionary<string, object>();
|
||||
bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? new SortedDictionary<string, object>(providedParameters) : new SortedDictionary<string, object>();
|
||||
headers = new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
|
||||
public class ParseErrorTestRestClient: TestRestClient
|
||||
{
|
||||
public ParseErrorTestRestClient() { }
|
||||
public ParseErrorTestRestClient(TestClientOptions exchangeOptions) : base(exchangeOptions) { }
|
||||
|
||||
protected override Error ParseErrorResponse(JToken error)
|
||||
{
|
||||
return new ServerError((int)error["errorCode"], (string)error["errorMessage"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System;
|
||||
using System.Security.Authentication;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestSocket: IWebsocket
|
||||
{
|
||||
public bool CanConnect { get; set; }
|
||||
public bool Connected { get; set; }
|
||||
|
||||
public event Action OnClose;
|
||||
|
||||
#pragma warning disable 0067
|
||||
public event Action OnReconnected;
|
||||
public event Action OnReconnecting;
|
||||
#pragma warning restore 0067
|
||||
public event Action<string> OnMessage;
|
||||
public event Action<Exception> OnError;
|
||||
public event Action OnOpen;
|
||||
public Func<Task<Uri>> GetReconnectionUrl { get; set; }
|
||||
|
||||
public int Id { get; }
|
||||
public bool ShouldReconnect { get; set; }
|
||||
public TimeSpan Timeout { get; set; }
|
||||
public Func<string, string> DataInterpreterString { get; set; }
|
||||
public Func<byte[], string> DataInterpreterBytes { get; set; }
|
||||
public DateTime? DisconnectTime { get; set; }
|
||||
public string Url { get; }
|
||||
public bool IsClosed => !Connected;
|
||||
public bool IsOpen => Connected;
|
||||
public bool PingConnection { get; set; }
|
||||
public TimeSpan PingInterval { get; set; }
|
||||
public SslProtocols SSLProtocols { get; set; }
|
||||
public Encoding Encoding { get; set; }
|
||||
|
||||
public int ConnectCalls { get; private set; }
|
||||
public bool Reconnecting { get; set; }
|
||||
public string Origin { get; set; }
|
||||
public int? RatelimitPerSecond { get; set; }
|
||||
|
||||
public double IncomingKbps => throw new NotImplementedException();
|
||||
|
||||
public Uri Uri => new Uri("");
|
||||
|
||||
public TimeSpan KeepAliveInterval { get; set; }
|
||||
|
||||
public static int lastId = 0;
|
||||
public static object lastIdLock = new object();
|
||||
|
||||
public TestSocket()
|
||||
{
|
||||
lock (lastIdLock)
|
||||
{
|
||||
Id = lastId + 1;
|
||||
lastId++;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ConnectAsync()
|
||||
{
|
||||
Connected = CanConnect;
|
||||
ConnectCalls++;
|
||||
if (CanConnect)
|
||||
InvokeOpen();
|
||||
return Task.FromResult(CanConnect);
|
||||
}
|
||||
|
||||
public void Send(string data)
|
||||
{
|
||||
if(!Connected)
|
||||
throw new Exception("Socket not connected");
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
Connected = false;
|
||||
DisconnectTime = DateTime.UtcNow;
|
||||
OnClose?.Invoke();
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public void SetProxy(string host, int port)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public void InvokeClose()
|
||||
{
|
||||
Connected = false;
|
||||
DisconnectTime = DateTime.UtcNow;
|
||||
Reconnecting = true;
|
||||
OnClose?.Invoke();
|
||||
}
|
||||
|
||||
public void InvokeOpen()
|
||||
{
|
||||
OnOpen?.Invoke();
|
||||
}
|
||||
|
||||
public void InvokeMessage(string data)
|
||||
{
|
||||
OnMessage?.Invoke(data);
|
||||
}
|
||||
|
||||
public void SetProxy(ApiProxy proxy)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void InvokeError(Exception error)
|
||||
{
|
||||
OnError?.Invoke(error);
|
||||
}
|
||||
public Task ReconnectAsync() => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Logging;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestSocketClient: BaseSocketClient
|
||||
{
|
||||
public TestSubSocketClient SubClient { get; }
|
||||
|
||||
public TestSocketClient() : this(new TestOptions())
|
||||
{
|
||||
}
|
||||
|
||||
public TestSocketClient(TestOptions exchangeOptions) : base("test", exchangeOptions)
|
||||
{
|
||||
SubClient = new TestSubSocketClient(exchangeOptions, exchangeOptions.SubOptions);
|
||||
SocketFactory = new Mock<IWebsocketFactory>().Object;
|
||||
Mock.Get(SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<Log>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket());
|
||||
}
|
||||
|
||||
public TestSocket CreateSocket()
|
||||
{
|
||||
Mock.Get(SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<Log>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket());
|
||||
return (TestSocket)CreateSocket("https://localhost:123/");
|
||||
}
|
||||
|
||||
public CallResult<bool> ConnectSocketSub(SocketConnection sub)
|
||||
{
|
||||
return ConnectSocketAsync(sub).Result;
|
||||
}
|
||||
|
||||
protected internal override bool HandleQueryResponse<T>(SocketConnection s, object request, JToken data, out CallResult<T> callResult)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected internal override bool HandleSubscriptionResponse(SocketConnection s, SocketSubscription subscription, object request, JToken message,
|
||||
out CallResult<object> callResult)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected internal override bool MessageMatchesHandler(SocketConnection s, JToken message, object request)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected internal override bool MessageMatchesHandler(SocketConnection s, JToken message, string identifier)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected internal override Task<CallResult<bool>> AuthenticateSocketAsync(SocketConnection s)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected internal override Task<bool> UnsubscribeAsync(SocketConnection connection, SocketSubscription s)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class TestOptions: BaseSocketClientOptions
|
||||
{
|
||||
public ApiClientOptions SubOptions { get; set; } = new ApiClientOptions();
|
||||
}
|
||||
|
||||
public class TestSubSocketClient : SocketApiClient
|
||||
{
|
||||
|
||||
public TestSubSocketClient(BaseClientOptions options, ApiClientOptions apiOptions): base(options, apiOptions)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
|
||||
=> new TestAuthProvider(credentials);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestStringLogger : ILogger
|
||||
{
|
||||
StringBuilder _builder = new StringBuilder();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
|
||||
{
|
||||
_builder.AppendLine(formatter(state, exception));
|
||||
}
|
||||
|
||||
public string GetLogs()
|
||||
{
|
||||
return _builder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.TokenManagement;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class TokenManagementTests
|
||||
{
|
||||
private static readonly TimeSpan TestMaintenanceInterval = TimeSpan.FromMilliseconds(5);
|
||||
|
||||
[Test]
|
||||
public async Task AcquireWithoutApiKeyReturnsCredentialsError()
|
||||
{
|
||||
var starts = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) =>
|
||||
{
|
||||
starts++;
|
||||
return Task.FromResult(CallResult.Ok("token"));
|
||||
});
|
||||
|
||||
var result = await manager.AcquireAsync(new TokenScope("Test", "Test", "Test", ""));
|
||||
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.TypeOf<NoApiCredentialsError>());
|
||||
Assert.That(starts, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartTokenFailureIsReturned()
|
||||
{
|
||||
var error = new ServerError(ErrorType.Unknown, "start failed");
|
||||
var manager = CreateManager((_, _) => Task.FromResult(CallResult.Fail<string>(error)));
|
||||
|
||||
var result = await manager.AcquireAsync(CreateScope());
|
||||
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.SameAs(error));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ActiveTokenIsSharedWhileLeasedAndStoppedAfterLastRelease()
|
||||
{
|
||||
var starts = 0;
|
||||
var stops = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token-" + ++starts)),
|
||||
stopToken: (_, _) =>
|
||||
{
|
||||
stops++;
|
||||
return Task.FromResult(CallResult.Ok());
|
||||
});
|
||||
var scope = CreateScope();
|
||||
|
||||
var first = await manager.AcquireAsync(scope);
|
||||
var second = await manager.AcquireAsync(scope);
|
||||
AssertSuccess(first);
|
||||
AssertSuccess(second);
|
||||
|
||||
Assert.That(second.Data!.Token.Token, Is.EqualTo(first.Data!.Token.Token));
|
||||
Assert.That(starts, Is.EqualTo(1));
|
||||
|
||||
await first.Data!.ReleaseAsync();
|
||||
Assert.That(stops, Is.EqualTo(0));
|
||||
|
||||
await second.Data!.ReleaseAsync();
|
||||
Assert.That(stops, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ActiveTokenStartsNewTokenAfterLeaseRelease()
|
||||
{
|
||||
var starts = 0;
|
||||
var stops = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token-" + ++starts)),
|
||||
stopToken: (_, _) =>
|
||||
{
|
||||
stops++;
|
||||
return Task.FromResult(CallResult.Ok());
|
||||
});
|
||||
var scope = CreateScope();
|
||||
|
||||
var first = await manager.AcquireAsync(scope);
|
||||
AssertSuccess(first);
|
||||
await first.Data!.ReleaseAsync();
|
||||
var second = await manager.AcquireAsync(scope);
|
||||
AssertSuccess(second);
|
||||
|
||||
Assert.That(second.Data!.Token.Token, Is.Not.EqualTo(first.Data!.Token.Token));
|
||||
Assert.That(starts, Is.EqualTo(2));
|
||||
Assert.That(stops, Is.EqualTo(1));
|
||||
await second.Data!.ReleaseAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReleasingLeaseTwiceOnlyStopsActiveTokenOnce()
|
||||
{
|
||||
var stops = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token")),
|
||||
stopToken: (_, _) =>
|
||||
{
|
||||
stops++;
|
||||
return Task.FromResult(CallResult.Ok());
|
||||
});
|
||||
|
||||
var leaseResult = await manager.AcquireAsync(CreateScope());
|
||||
AssertSuccess(leaseResult);
|
||||
|
||||
await leaseResult.Data!.ReleaseAsync();
|
||||
await leaseResult.Data!.ReleaseAsync();
|
||||
|
||||
Assert.That(stops, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CachedTokenIsReusedAfterLeaseRelease()
|
||||
{
|
||||
var starts = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token-" + ++starts)),
|
||||
managementType: TokenManagementType.Cached);
|
||||
var scope = CreateScope();
|
||||
|
||||
var first = await manager.AcquireAsync(scope);
|
||||
AssertSuccess(first);
|
||||
await first.Data!.ReleaseAsync();
|
||||
var second = await manager.AcquireAsync(scope);
|
||||
AssertSuccess(second);
|
||||
|
||||
Assert.That(second.Data!.Token.Token, Is.EqualTo(first.Data!.Token.Token));
|
||||
Assert.That(starts, Is.EqualTo(1));
|
||||
await second.Data!.ReleaseAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CachedTokensAreScopedIndependently()
|
||||
{
|
||||
var starts = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token-" + ++starts)),
|
||||
managementType: TokenManagementType.Cached);
|
||||
|
||||
var firstScope = CreateScope(additionalIdentifier: "one");
|
||||
var secondScope = CreateScope(additionalIdentifier: "two");
|
||||
|
||||
var first = await manager.AcquireAsync(firstScope);
|
||||
var second = await manager.AcquireAsync(secondScope);
|
||||
AssertSuccess(first);
|
||||
AssertSuccess(second);
|
||||
await first.Data!.ReleaseAsync();
|
||||
await second.Data!.ReleaseAsync();
|
||||
|
||||
var firstAgain = await manager.AcquireAsync(firstScope);
|
||||
AssertSuccess(firstAgain);
|
||||
|
||||
Assert.That(firstAgain.Data!.Token.Token, Is.EqualTo(first.Data!.Token.Token));
|
||||
Assert.That(second.Data!.Token.Token, Is.Not.EqualTo(first.Data!.Token.Token));
|
||||
Assert.That(starts, Is.EqualTo(2));
|
||||
await firstAgain.Data!.ReleaseAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExpiredCachedTokenIsNotReused()
|
||||
{
|
||||
var starts = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token-" + ++starts)),
|
||||
timeValid: TimeSpan.FromMilliseconds(20),
|
||||
managementType: TokenManagementType.Cached);
|
||||
var scope = CreateScope();
|
||||
|
||||
var first = await manager.AcquireAsync(scope);
|
||||
AssertSuccess(first);
|
||||
await first.Data!.ReleaseAsync();
|
||||
await Task.Delay(50);
|
||||
var second = await manager.AcquireAsync(scope);
|
||||
AssertSuccess(second);
|
||||
|
||||
Assert.That(first.Data!.Token.Status, Is.EqualTo(TokenStatus.Expired));
|
||||
Assert.That(second.Data!.Token.Token, Is.Not.EqualTo(first.Data!.Token.Token));
|
||||
Assert.That(starts, Is.EqualTo(2));
|
||||
await second.Data!.ReleaseAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CachedTokenDoesNotRunKeepAliveLoop()
|
||||
{
|
||||
var keepAlives = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token")),
|
||||
refreshInterval: TimeSpan.FromMilliseconds(1),
|
||||
keepAliveToken: (_, _) =>
|
||||
{
|
||||
keepAlives++;
|
||||
return Task.FromResult(CallResult.Ok());
|
||||
},
|
||||
managementType: TokenManagementType.Cached);
|
||||
|
||||
var leaseResult = await manager.AcquireAsync(CreateScope());
|
||||
AssertSuccess(leaseResult);
|
||||
await Task.Delay(50);
|
||||
|
||||
Assert.That(keepAlives, Is.EqualTo(0));
|
||||
await leaseResult.Data!.ReleaseAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ActiveTokenKeepAliveRefreshesValidity()
|
||||
{
|
||||
var keepAlives = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token")),
|
||||
refreshInterval: TimeSpan.FromMilliseconds(1),
|
||||
timeValid: TimeSpan.FromSeconds(1),
|
||||
keepAliveToken: (_, _) =>
|
||||
{
|
||||
keepAlives++;
|
||||
return Task.FromResult(CallResult.Ok());
|
||||
});
|
||||
|
||||
var leaseResult = await manager.AcquireAsync(CreateScope());
|
||||
AssertSuccess(leaseResult);
|
||||
var originalValidUntil = leaseResult.Data!.Token.ValidUntil;
|
||||
|
||||
await WaitUntilAsync(() => keepAlives > 0);
|
||||
|
||||
Assert.That(leaseResult.Data!.Token.ValidUntil, Is.GreaterThan(originalValidUntil));
|
||||
await leaseResult.Data!.ReleaseAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ActiveTokenKeepAliveFailureExpiresTokenWhenValidityPassed()
|
||||
{
|
||||
var starts = 0;
|
||||
var expired = false;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token-" + ++starts)),
|
||||
refreshInterval: TimeSpan.FromMilliseconds(1),
|
||||
timeValid: TimeSpan.FromMilliseconds(25),
|
||||
keepAliveToken: (_, _) => Task.FromResult(CallResult.Fail(new ServerError(ErrorType.Unknown, "keep alive failed"))));
|
||||
|
||||
var leaseResult = await manager.AcquireAsync(CreateScope());
|
||||
AssertSuccess(leaseResult);
|
||||
leaseResult.Data!.Token.Expired += _ => expired = true;
|
||||
|
||||
await WaitUntilAsync(() => expired);
|
||||
|
||||
Assert.That(leaseResult.Data!.Token.Status, Is.EqualTo(TokenStatus.Expired));
|
||||
|
||||
var nextLease = await manager.AcquireAsync(CreateScope());
|
||||
AssertSuccess(nextLease);
|
||||
Assert.That(nextLease.Data!.Token.Token, Is.Not.EqualTo(leaseResult.Data!.Token.Token));
|
||||
Assert.That(starts, Is.EqualTo(2));
|
||||
|
||||
await leaseResult.Data!.ReleaseAsync();
|
||||
await nextLease.Data!.ReleaseAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AcquireAndReplaceReleasesPreviousSubscriptionLease()
|
||||
{
|
||||
var starts = 0;
|
||||
var stops = 0;
|
||||
var manager = CreateManager(
|
||||
(_, _) => Task.FromResult(CallResult.Ok("token-" + ++starts)),
|
||||
stopToken: (_, _) =>
|
||||
{
|
||||
stops++;
|
||||
return Task.FromResult(CallResult.Ok());
|
||||
});
|
||||
var subscription = new TestSubscription();
|
||||
|
||||
var first = await manager.AcquireAndReplaceAsync(subscription, CreateScope(additionalIdentifier: "one"));
|
||||
AssertSuccess(first);
|
||||
var second = await manager.AcquireAndReplaceAsync(subscription, CreateScope(additionalIdentifier: "two"));
|
||||
AssertSuccess(second);
|
||||
|
||||
Assert.That(subscription.TokenLease, Is.SameAs(second.Data));
|
||||
Assert.That(second.Data!.Token.Token, Is.Not.EqualTo(first.Data!.Token.Token));
|
||||
Assert.That(stops, Is.EqualTo(1));
|
||||
|
||||
await subscription.TokenLease!.ReleaseAsync();
|
||||
}
|
||||
|
||||
private static TokenManager CreateManager(
|
||||
Func<TokenScope, System.Threading.CancellationToken, Task<CallResult<string>>> startToken,
|
||||
TimeSpan? refreshInterval = null,
|
||||
TimeSpan? timeValid = null,
|
||||
Func<TokenInfo, System.Threading.CancellationToken, Task<CallResult>>? keepAliveToken = null,
|
||||
Func<TokenInfo, System.Threading.CancellationToken, Task<CallResult>>? stopToken = null,
|
||||
TokenManagementType managementType = TokenManagementType.Active)
|
||||
{
|
||||
return new TokenManager(
|
||||
Guid.NewGuid().ToString(),
|
||||
null,
|
||||
refreshInterval ?? TimeSpan.FromMinutes(1),
|
||||
timeValid ?? TimeSpan.FromMinutes(1),
|
||||
startToken,
|
||||
keepAliveToken,
|
||||
stopToken,
|
||||
managementType,
|
||||
TestMaintenanceInterval);
|
||||
}
|
||||
|
||||
private static TokenScope CreateScope(string apiKey = "apiKey", string? additionalIdentifier = null)
|
||||
=> new TokenScope("Test", "Test", "Test", apiKey, additionalIdentifier);
|
||||
|
||||
private static void AssertSuccess(CallResult<TokenLease> result)
|
||||
{
|
||||
Assert.That(result.Success, Is.True, result.Error?.ToString());
|
||||
Assert.That(result.Data, Is.Not.Null);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var timeout = DateTime.UtcNow.AddSeconds(2);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > timeout)
|
||||
Assert.Fail("Condition was not met within the timeout");
|
||||
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSubscription : Subscription
|
||||
{
|
||||
public TestSubscription() : base(NullLogger.Instance, true)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Query? GetSubQuery(SocketConnection connection) => null;
|
||||
|
||||
protected override Query? GetUnsubQuery(SocketConnection connection) => null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using CryptoExchange.Net;
|
||||
using CryptoExchange.Net.Objects;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
internal class UriSerializationTests
|
||||
{
|
||||
[Test]
|
||||
public void CreateParamString_SerializesBasicValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", "1" },
|
||||
{ "b", 2 },
|
||||
{ "c", true }
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamString_SerializesArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1", "2" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a[]=1&a[]=2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamStringEncoded_SerializesArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1+2", "2+3" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.Array);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a[]=1%2B2&a[]=2%2B3"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamString_SerializesJsonArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1", "2" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.JsonArray);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=[1,2]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamStringEncoded_SerializesJsonArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1+2", "2+3" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.JsonArray);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=[1%2B2,2%2B3]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamString_SerializesMultipleValuesArrayCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1", "2" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.MultipleValues);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1&a=2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamStringEncoded_SerializesMultipleValuesArrayCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1+2", "2+3" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.MultipleValues);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1%2B2&a=2%2B3"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorClient", "Examples\Bl
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{5734C2A9-F12C-4754-A8B9-640C24DC4E02}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleClient", "Examples\ConsoleClient\ConsoleClient.csproj", "{23480C58-23BF-4EBF-A173-B7F51A043A99}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleClient", "Examples\ConsoleClient\ConsoleClient.csproj", "{23480C58-23BF-4EBF-A173-B7F51A043A99}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedClients", "Examples\SharedClients\SharedClients.csproj", "{988A87EF-EAEA-4313-A6CF-FA869813D5AB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -35,6 +37,10 @@ Global
|
||||
{23480C58-23BF-4EBF-A173-B7F51A043A99}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{23480C58-23BF-4EBF-A173-B7F51A043A99}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{23480C58-23BF-4EBF-A173-B7F51A043A99}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{988A87EF-EAEA-4313-A6CF-FA869813D5AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{988A87EF-EAEA-4313-A6CF-FA869813D5AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{988A87EF-EAEA-4313-A6CF-FA869813D5AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{988A87EF-EAEA-4313-A6CF-FA869813D5AB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -42,6 +48,7 @@ Global
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{AF4F5C19-162E-48F4-8B0B-BA5A2D7CE06A} = {5734C2A9-F12C-4754-A8B9-640C24DC4E02}
|
||||
{23480C58-23BF-4EBF-A173-B7F51A043A99} = {5734C2A9-F12C-4754-A8B9-640C24DC4E02}
|
||||
{988A87EF-EAEA-4313-A6CF-FA869813D5AB} = {5734C2A9-F12C-4754-A8B9-640C24DC4E02}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {0D1B9CE9-E0B7-4B8B-88BF-6EA2CC8CA3D7}
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CryptoExchange.Net.UnitTests")]
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CryptoExchange.Net.UnitTests")]
|
||||
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
internal static class IsExternalInit { }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace CryptoExchange.Net.Attributes
|
||||
/// <summary>
|
||||
/// Map a enum entry to string values
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public class MapAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#if !NETSTANDARD2_1
|
||||
namespace System.Diagnostics.CodeAnalysis
|
||||
namespace System.Diagnostics.CodeAnalysis
|
||||
{
|
||||
using System;
|
||||
#if NETSTANDARD2_0
|
||||
|
||||
/// <summary>
|
||||
/// Specifies that <see langword="null"/> is allowed as an input even if the
|
||||
@@ -206,5 +206,26 @@ namespace System.Diagnostics.CodeAnalysis
|
||||
ReturnValue = returnValue;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if NETSTANDARD2_0 || NETSTANDARD2_1
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = false)]
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed class MemberNotNullWhenAttribute : Attribute
|
||||
{
|
||||
public MemberNotNullWhenAttribute(bool returnValue, string member)
|
||||
{
|
||||
ReturnValue = returnValue;
|
||||
Members = [member];
|
||||
}
|
||||
|
||||
public MemberNotNullWhenAttribute(bool returnValue, params string[] members)
|
||||
{
|
||||
ReturnValue = returnValue;
|
||||
Members = members;
|
||||
}
|
||||
|
||||
public bool ReturnValue { get; }
|
||||
public string[] Members { get; }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
@@ -1,129 +1,19 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Api credentials, used to sign requests accessing private endpoints
|
||||
/// </summary>
|
||||
public class ApiCredentials: IDisposable
|
||||
public abstract class ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// The api key to authenticate requests
|
||||
/// Validate the API credentials
|
||||
/// </summary>
|
||||
public SecureString? Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The api secret to authenticate requests
|
||||
/// </summary>
|
||||
public SecureString? Secret { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The private key to authenticate requests
|
||||
/// </summary>
|
||||
public PrivateKey? PrivateKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create Api credentials providing a private key for authentication
|
||||
/// </summary>
|
||||
/// <param name="privateKey">The private key used for signing</param>
|
||||
public ApiCredentials(PrivateKey privateKey)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create Api credentials providing an api key and secret for authentication
|
||||
/// </summary>
|
||||
/// <param name="key">The api key used for identification</param>
|
||||
/// <param name="secret">The api secret used for signing</param>
|
||||
public ApiCredentials(SecureString key, SecureString secret)
|
||||
{
|
||||
if (key == null || secret == null)
|
||||
throw new ArgumentException("Key and secret can't be null/empty");
|
||||
|
||||
Key = key;
|
||||
Secret = secret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create Api credentials providing an api key and secret for authentication
|
||||
/// </summary>
|
||||
/// <param name="key">The api key used for identification</param>
|
||||
/// <param name="secret">The api secret used for signing</param>
|
||||
public ApiCredentials(string key, string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(secret))
|
||||
throw new ArgumentException("Key and secret can't be null/empty");
|
||||
|
||||
Key = key.ToSecureString();
|
||||
Secret = secret.ToSecureString();
|
||||
}
|
||||
public abstract void Validate();
|
||||
|
||||
/// <summary>
|
||||
/// Copy the credentials
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public virtual ApiCredentials Copy()
|
||||
{
|
||||
if (PrivateKey == null)
|
||||
// Use .GetString() to create a copy of the SecureString
|
||||
return new ApiCredentials(Key!.GetString(), Secret!.GetString());
|
||||
else
|
||||
return new ApiCredentials(PrivateKey!.Copy());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create Api credentials providing a stream containing json data. The json data should include two values: apiKey and apiSecret
|
||||
/// </summary>
|
||||
/// <param name="inputStream">The stream containing the json data</param>
|
||||
/// <param name="identifierKey">A key to identify the credentials for the API. For example, when set to `binanceKey` the json data should contain a value for the property `binanceKey`. Defaults to 'apiKey'.</param>
|
||||
/// <param name="identifierSecret">A key to identify the credentials for the API. For example, when set to `binanceSecret` the json data should contain a value for the property `binanceSecret`. Defaults to 'apiSecret'.</param>
|
||||
public ApiCredentials(Stream inputStream, string? identifierKey = null, string? identifierSecret = null)
|
||||
{
|
||||
using var reader = new StreamReader(inputStream, Encoding.UTF8, false, 512, true);
|
||||
|
||||
var stringData = reader.ReadToEnd();
|
||||
var jsonData = stringData.ToJToken();
|
||||
if(jsonData == null)
|
||||
throw new ArgumentException("Input stream not valid json data");
|
||||
|
||||
var key = TryGetValue(jsonData, identifierKey ?? "apiKey");
|
||||
var secret = TryGetValue(jsonData, identifierSecret ?? "apiSecret");
|
||||
|
||||
if (key == null || secret == null)
|
||||
throw new ArgumentException("apiKey or apiSecret value not found in Json credential file");
|
||||
|
||||
Key = key.ToSecureString();
|
||||
Secret = secret.ToSecureString();
|
||||
|
||||
inputStream.Seek(0, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try get the value of a key from a JToken
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="key"></param>
|
||||
/// <returns></returns>
|
||||
protected string? TryGetValue(JToken data, string key)
|
||||
{
|
||||
if (data[key] == null)
|
||||
return null;
|
||||
return (string) data[key]!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Key?.Dispose();
|
||||
Secret?.Dispose();
|
||||
PrivateKey?.Dispose();
|
||||
}
|
||||
public abstract ApiCredentials Copy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
using CryptoExchange.Net.Converters;
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
#if NET8_0_OR_GREATER
|
||||
using NSec.Cryptography;
|
||||
#endif
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using System.Net;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
@@ -14,53 +22,27 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// </summary>
|
||||
public abstract class AuthenticationProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// The provided credentials
|
||||
/// </summary>
|
||||
public ApiCredentials Credentials { get; }
|
||||
internal IAuthTimeProvider TimeProvider { get; set; } = new AuthTimeProvider();
|
||||
|
||||
/// <summary>
|
||||
/// The public identifier for the provided credentials
|
||||
/// </summary>
|
||||
protected byte[] _sBytes;
|
||||
public abstract string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// Authenticate a REST request
|
||||
/// </summary>
|
||||
/// <param name="credentials"></param>
|
||||
protected AuthenticationProvider(ApiCredentials credentials)
|
||||
{
|
||||
if (credentials.Secret == null)
|
||||
throw new ArgumentException("ApiKey/Secret needed");
|
||||
|
||||
Credentials = credentials;
|
||||
_sBytes = Encoding.UTF8.GetBytes(credentials.Secret.GetString());
|
||||
}
|
||||
/// <param name="apiClient">The API client sending the request</param>
|
||||
/// <param name="requestConfig">The request configuration</param>
|
||||
public abstract void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig);
|
||||
|
||||
/// <summary>
|
||||
/// Authenticate a request. Output parameters should include the providedParameters input
|
||||
/// Get an authentication query for a websocket
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The Api client sending the request</param>
|
||||
/// <param name="uri">The uri for the request</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="providedParameters">The request parameters</param>
|
||||
/// <param name="auth">If the requests should be authenticated</param>
|
||||
/// <param name="arraySerialization">Array serialization type</param>
|
||||
/// <param name="parameterPosition">The position where the providedParameters should go</param>
|
||||
/// <param name="uriParameters">Parameters that need to be in the Uri of the request. Should include the provided parameters if they should go in the uri</param>
|
||||
/// <param name="bodyParameters">Parameters that need to be in the body of the request. Should include the provided parameters if they should go in the body</param>
|
||||
/// <param name="headers">The headers that should be send with the request</param>
|
||||
public abstract void AuthenticateRequest(
|
||||
RestApiClient apiClient,
|
||||
Uri uri,
|
||||
HttpMethod method,
|
||||
Dictionary<string, object> providedParameters,
|
||||
bool auth,
|
||||
ArrayParametersSerialization arraySerialization,
|
||||
HttpMethodParameterPosition parameterPosition,
|
||||
out SortedDictionary<string, object> uriParameters,
|
||||
out SortedDictionary<string, object> bodyParameters,
|
||||
out Dictionary<string, string> headers
|
||||
);
|
||||
/// <param name="apiClient">The API client sending the request</param>
|
||||
/// <param name="connection">The connection to authenticate</param>
|
||||
/// <param name="context">Optional context required for creating the authentication query</param>
|
||||
public virtual Query? GetAuthenticationQuery(SocketApiClient apiClient, SocketConnection connection, Dictionary<string, object?>? context = null) => null;
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 sign the data and return the bytes
|
||||
@@ -73,6 +55,17 @@ namespace CryptoExchange.Net.Authentication
|
||||
return encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 sign the data and return the bytes
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] SignSHA256Bytes(byte[] data)
|
||||
{
|
||||
using var encryptor = SHA256.Create();
|
||||
return encryptor.ComputeHash(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
@@ -83,7 +76,20 @@ namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
using var encryptor = SHA256.Create();
|
||||
var resultBytes = encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes): BytesToHexString(resultBytes);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected static string SignSHA256(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = SHA256.Create();
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -99,6 +105,41 @@ namespace CryptoExchange.Net.Authentication
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected static string SignSHA384(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = SHA384.Create();
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] SignSHA384Bytes(string data)
|
||||
{
|
||||
using var encryptor = SHA384.Create();
|
||||
return encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] SignSHA384Bytes(byte[] data)
|
||||
{
|
||||
using var encryptor = SHA384.Create();
|
||||
return encryptor.ComputeHash(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
@@ -112,6 +153,41 @@ namespace CryptoExchange.Net.Authentication
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected static string SignSHA512(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = SHA512.Create();
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] SignSHA512Bytes(string data)
|
||||
{
|
||||
using var encryptor = SHA512.Create();
|
||||
return encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] SignSHA512Bytes(byte[] data)
|
||||
{
|
||||
using var encryptor = SHA512.Create();
|
||||
return encryptor.ComputeHash(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MD5 sign the data and return the hash
|
||||
/// </summary>
|
||||
@@ -125,6 +201,302 @@ namespace CryptoExchange.Net.Authentication
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MD5 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected static string SignMD5(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = MD5.Create();
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MD5 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] SignMD5Bytes(string data)
|
||||
{
|
||||
using var encryptor = MD5.Create();
|
||||
return encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA256(HMACCredential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA256(credential,Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA256(HMACCredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA256(credential.GetSBytes());
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA384(HMACCredential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA384(credential, Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA384(HMACCredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA384(credential.GetSBytes());
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA512(HMACCredential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA512(credential, Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA512(HMACCredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA512(credential.GetSBytes());
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 sign the data
|
||||
/// </summary>
|
||||
protected string SignRSASHA256(RSACredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var rsa = credential.GetSigner();
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA384 sign the data
|
||||
/// </summary>
|
||||
protected string SignRSASHA384(RSACredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var rsa = credential.GetSigner();
|
||||
using var sha384 = SHA384.Create();
|
||||
var hash = sha384.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA512 sign the data
|
||||
/// </summary>
|
||||
protected string SignRSASHA512(RSACredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var rsa = credential.GetSigner();
|
||||
using var sha512 = SHA512.Create();
|
||||
var hash = sha512.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
public string SignEd25519(Ed25519Credential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignEd25519(credential, Encoding.ASCII.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
public string SignEd25519(Ed25519Credential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var signKey = credential.GetSigningKey();
|
||||
var resultBytes = SignatureAlgorithm.Ed25519.Sign(signKey, data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to hex string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToHexString(byte[] buff)
|
||||
=> BytesToHexString(new ArraySegment<byte>(buff));
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to hex string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToHexString(ArraySegment<byte> buff)
|
||||
{
|
||||
#if NET9_0_OR_GREATER
|
||||
return Convert.ToHexString(buff);
|
||||
#else
|
||||
var result = string.Empty;
|
||||
foreach (var t in buff)
|
||||
result += t.ToString("X2");
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a hex encoded string to byte array
|
||||
/// </summary>
|
||||
/// <param name="hexString"></param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] HexToBytesString(string hexString)
|
||||
{
|
||||
if (hexString.StartsWith("0x"))
|
||||
hexString = hexString.Substring(2);
|
||||
|
||||
byte[] bytes = new byte[hexString.Length / 2];
|
||||
for (int i = 0; i < hexString.Length; i += 2)
|
||||
{
|
||||
string hexSubstring = hexString.Substring(i, 2);
|
||||
bytes[i / 2] = Convert.ToByte(hexSubstring, 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to base64 string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToBase64String(byte[] buff)
|
||||
{
|
||||
return Convert.ToBase64String(buff);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current timestamp including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected DateTime GetTimestamp(RestApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
{
|
||||
var result = TimeProvider.GetTime().Add(TimeOffsetManager.GetRestOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
|
||||
if (includeOneSecondOffset)
|
||||
result = result.AddSeconds(-1);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current timestamp including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected DateTime GetTimestamp(SocketApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
{
|
||||
var timestamp = TimeProvider.GetTime();
|
||||
if(apiClient.ApiOptions.AutoTimestamp ?? apiClient.ClientOptions.AutoTimestamp)
|
||||
timestamp = timestamp.Add(-TimeOffsetManager.GetSocketOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
|
||||
|
||||
if (includeOneSecondOffset)
|
||||
timestamp = timestamp.AddSeconds(-1);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a string including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected string GetMillisecondTimestamp(RestApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a string including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected string GetMillisecondTimestamp(SocketApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a long including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected long GetMillisecondTimestampLong(RestApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value;
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a long including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected long GetMillisecondTimestampLong(SocketApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value;
|
||||
|
||||
/// <summary>
|
||||
/// Return the serialized request body
|
||||
/// </summary>
|
||||
/// <param name="serializer"></param>
|
||||
/// <param name="parameters"></param>
|
||||
/// <returns></returns>
|
||||
protected static string GetSerializedBody(IMessageSerializer serializer, Parameters? parameters)
|
||||
{
|
||||
if (serializer is not IStringMessageSerializer stringSerializer)
|
||||
throw new InvalidOperationException("Non-string message serializer can't get serialized request body");
|
||||
|
||||
if (parameters?.BodyValue != null)
|
||||
return stringSerializer.Serialize(parameters.BodyValue);
|
||||
else
|
||||
return stringSerializer.Serialize(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class AuthenticationProvider<TApiCredentials> : AuthenticationProvider
|
||||
where TApiCredentials: ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// API credentials used for signing requests
|
||||
/// </summary>
|
||||
public TApiCredentials ApiCredentials { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected AuthenticationProvider(TApiCredentials credentials)
|
||||
{
|
||||
credentials.Validate();
|
||||
|
||||
ApiCredentials = credentials;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class AuthenticationProvider<TApiCredentials, TCredentialType> : AuthenticationProvider<TApiCredentials>
|
||||
where TApiCredentials : ApiCredentials
|
||||
where TCredentialType : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// The specific credential type used for signing requests.
|
||||
/// </summary>
|
||||
public TCredentialType Credential { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Key => Credential.Key;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected AuthenticationProvider(
|
||||
TApiCredentials credentials,
|
||||
TCredentialType? credential) : base(credentials)
|
||||
{
|
||||
if (credential == null)
|
||||
throw new ArgumentException($"Missing \"{typeof(TCredentialType).Name}\" credentials on \"{credentials.GetType().Name}\"");
|
||||
|
||||
Credential = credential;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
@@ -132,10 +504,20 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA256(string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA256(Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA256(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA256(_sBytes);
|
||||
var resultBytes = encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not HMACCredential hmacCredential)
|
||||
throw new InvalidOperationException($"Invalid HMAC signing without HMAC credentials provided");
|
||||
|
||||
return SignHMACSHA256(hmacCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -145,10 +527,20 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA384(string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA384(Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign</param>
|
||||
/// <param name="outputType">String type</param>
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA384(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA384(_sBytes);
|
||||
var resultBytes = encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not HMACCredential hmacCredential)
|
||||
throw new InvalidOperationException($"Invalid HMAC signing without HMAC credentials provided");
|
||||
|
||||
return SignHMACSHA384(hmacCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -168,72 +560,71 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA512(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA512(_sBytes);
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not HMACCredential hmacCredential)
|
||||
throw new InvalidOperationException($"Invalid HMAC signing without HMAC credentials provided");
|
||||
|
||||
return SignHMACSHA512(hmacCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sign a string
|
||||
/// SHA256 sign the data
|
||||
/// </summary>
|
||||
/// <param name="toSign"></param>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="outputType"></param>
|
||||
/// <returns></returns>
|
||||
public virtual string Sign(string toSign)
|
||||
protected string SignRSASHA256(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
return toSign;
|
||||
if (Credential is not RSACredential rsaCredential)
|
||||
throw new InvalidOperationException($"Invalid RSA signing without RSA credentials provided");
|
||||
|
||||
return SignRSASHA256(rsaCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sign a byte array
|
||||
/// SHA384 sign the data
|
||||
/// </summary>
|
||||
/// <param name="toSign"></param>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="outputType"></param>
|
||||
/// <returns></returns>
|
||||
public virtual byte[] Sign(byte[] toSign)
|
||||
protected string SignRSASHA384(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
return toSign;
|
||||
if (Credential is not RSACredential rsaCredential)
|
||||
throw new InvalidOperationException($"Invalid RSA signing without RSA credentials provided");
|
||||
|
||||
return SignRSASHA384(rsaCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to hex string
|
||||
/// SHA512 sign the data
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="outputType"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToHexString(byte[] buff)
|
||||
protected string SignRSASHA512(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var result = string.Empty;
|
||||
foreach (var t in buff)
|
||||
result += t.ToString("X2");
|
||||
return result;
|
||||
if (Credential is not RSACredential rsaCredential)
|
||||
throw new InvalidOperationException($"Invalid RSA signing without RSA credentials provided");
|
||||
|
||||
return SignRSASHA512(rsaCredential, data, outputType);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Convert byte array to base64 string
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToBase64String(byte[] buff)
|
||||
{
|
||||
return Convert.ToBase64String(buff);
|
||||
}
|
||||
public string SignEd25519(string data, SignOutputType? outputType = null)
|
||||
=> SignEd25519(Encoding.ASCII.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// Get current timestamp including the time sync offset from the api client
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
/// <param name="apiClient"></param>
|
||||
/// <returns></returns>
|
||||
protected static DateTime GetTimestamp(RestApiClient apiClient)
|
||||
public string SignEd25519(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
return DateTime.UtcNow.Add(apiClient?.GetTimeOffset() ?? TimeSpan.Zero)!;
|
||||
}
|
||||
if (Credential is not Ed25519Credential ed25519Credential)
|
||||
throw new InvalidOperationException($"Invalid Ed25519 signing without Ed25519 credentials provided");
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a string including the time sync offset from the api client
|
||||
/// </summary>
|
||||
/// <param name="apiClient"></param>
|
||||
/// <returns></returns>
|
||||
protected static string GetMillisecondTimestamp(RestApiClient apiClient)
|
||||
{
|
||||
return DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient)).Value.ToString(CultureInfo.InvariantCulture);
|
||||
return SignEd25519(ed25519Credential, data, outputType);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for a set of credentials
|
||||
/// </summary>
|
||||
public abstract class CredentialSet : ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// The (public) key/identifier for this credential pair
|
||||
/// </summary>
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public CredentialSet() { }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public CredentialSet(string key)
|
||||
{
|
||||
Key = key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate the API credential
|
||||
/// </summary>
|
||||
public override void Validate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Key))
|
||||
throw new ArgumentException($"Key not set on {GetType().Name}", nameof(Key));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Api key credentials
|
||||
/// </summary>
|
||||
public class ApiKeyCredential : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key</param>
|
||||
public ApiKeyCredential(string key) : base(key)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new ApiKeyCredential(Key);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMAC credentials
|
||||
/// </summary>
|
||||
public class HMACCredential : CredentialSet
|
||||
{
|
||||
private byte[]? _sBytes;
|
||||
|
||||
/// <summary>
|
||||
/// API secret
|
||||
/// </summary>
|
||||
public string Secret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public HMACCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="secret">Api secret</param>
|
||||
public HMACCredential(string key, string secret) : base(key)
|
||||
{
|
||||
Secret = secret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the secret value bytes
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public byte[] GetSBytes()
|
||||
{
|
||||
return _sBytes ??= Encoding.UTF8.GetBytes(Secret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new HMACCredential(Key, Secret);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Secret))
|
||||
throw new ArgumentException($"Secret not set on {GetType().Name}", nameof(Secret));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMAC credentials
|
||||
/// </summary>
|
||||
public class HMACPassCredential : HMACCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public HMACPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="secret">Api secret</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public HMACPassCredential(string key, string secret, string pass) : base(key, secret)
|
||||
{
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new HMACPassCredential(Key, Secret, Pass);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"Pass not set on {GetType().Name}", nameof(Pass));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA credentials
|
||||
/// </summary>
|
||||
public abstract class RSACredential : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Private key
|
||||
/// </summary>
|
||||
public string PrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSACredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public RSACredential(string key, string privateKey) : base(key)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public abstract RSA GetSigner();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(PrivateKey))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA credentials
|
||||
/// </summary>
|
||||
public abstract class RSAPassCredential : RSACredential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public RSAPassCredential(string key, string privateKey, string pass) : base(key, privateKey)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
#if NETSTANDARD2_1_OR_GREATER || NET7_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// RSA credentials in PEM/base64 format
|
||||
/// </summary>
|
||||
public class RSAPemCredential : RSACredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAPemCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public RSAPemCredential(string key, string privateKey) : base(key, privateKey)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
var key = PrivateKey!
|
||||
.Replace("\n", "")
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Trim();
|
||||
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(
|
||||
key)
|
||||
, out _);
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAPemCredential(Key, PrivateKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA PEM/Base64 credentials
|
||||
/// </summary>
|
||||
public class RSAPemPassCredential : RSAPassCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAPemPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Api secret</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public RSAPemPassCredential(string key, string privateKey, string pass) : base(key, privateKey, pass)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
var key = PrivateKey!
|
||||
.Replace("\n", "")
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Trim();
|
||||
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(
|
||||
key)
|
||||
, out _);
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAPemPassCredential(Key, PrivateKey, Pass);
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// RSA credentials in XML format
|
||||
/// </summary>
|
||||
public class RSAXmlCredential : RSACredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAXmlCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public RSAXmlCredential(string key, string privateKey) : base(key, privateKey)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
rsa.FromXmlString(PrivateKey);
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAXmlCredential(Key, PrivateKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA XML credentials
|
||||
/// </summary>
|
||||
public class RSAXmlPassCredential : RSAPassCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAXmlPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Api secret</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public RSAXmlPassCredential(string key, string privateKey, string pass) : base(key, privateKey, pass)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
rsa.FromXmlString(PrivateKey);
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAXmlPassCredential(Key, PrivateKey, Pass);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Credentials in Ed25519 format
|
||||
/// </summary>
|
||||
public class Ed25519Credential : CredentialSet
|
||||
{
|
||||
private NSec.Cryptography.Key? _signKey;
|
||||
|
||||
/// <summary>
|
||||
/// Private key
|
||||
/// </summary>
|
||||
public string PrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public Ed25519Credential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public Ed25519Credential(string key, string privateKey) : base(key)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get signing key
|
||||
/// </summary>
|
||||
public NSec.Cryptography.Key GetSigningKey()
|
||||
{
|
||||
if (_signKey != null)
|
||||
return _signKey;
|
||||
|
||||
var key = PrivateKey!
|
||||
.Replace("\n", "")
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Trim();
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
_signKey = NSec.Cryptography.Key.Import(NSec.Cryptography.SignatureAlgorithm.Ed25519, keyBytes, NSec.Cryptography.KeyBlobFormat.PkixPrivateKey);
|
||||
return _signKey;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new Ed25519Credential(Key, PrivateKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(PrivateKey))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 credentials
|
||||
/// </summary>
|
||||
public class Ed25519PassCredential : Ed25519Credential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public Ed25519PassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public Ed25519PassCredential(string key, string privateKey, string pass) : base(key, privateKey)
|
||||
{
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new Ed25519PassCredential(Key, PrivateKey, Pass);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"Pass not set on {GetType().Name}", nameof(Pass));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Credentials in ECDsa format
|
||||
/// </summary>
|
||||
public class ECDsaCredential : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Private key
|
||||
/// </summary>
|
||||
public string PrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public ECDsaCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public ECDsaCredential(string key, string privateKey) : base(key)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new ECDsaCredential(Key, PrivateKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(PrivateKey))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ECDsa credentials
|
||||
/// </summary>
|
||||
public class ECDsaPassCredential : ECDsaCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public ECDsaPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public ECDsaPassCredential(string key, string privateKey, string pass) : base(key, privateKey)
|
||||
{
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new ECDsaPassCredential(Key, PrivateKey, Pass);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"Pass not set on {GetType().Name}", nameof(Pass));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using System;
|
||||
using System.Security;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Private key info
|
||||
/// </summary>
|
||||
public class PrivateKey : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The private key
|
||||
/// </summary>
|
||||
public SecureString Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The private key's pass phrase
|
||||
/// </summary>
|
||||
public SecureString? Passphrase { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the private key is encrypted or not
|
||||
/// </summary>
|
||||
public bool IsEncrypted { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a private key providing an encrypted key information
|
||||
/// </summary>
|
||||
/// <param name="key">The private key used for signing</param>
|
||||
/// <param name="passphrase">The private key's passphrase</param>
|
||||
public PrivateKey(SecureString key, SecureString passphrase)
|
||||
{
|
||||
Key = key;
|
||||
Passphrase = passphrase;
|
||||
|
||||
IsEncrypted = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a private key providing an encrypted key information
|
||||
/// </summary>
|
||||
/// <param name="key">The private key used for signing</param>
|
||||
/// <param name="passphrase">The private key's passphrase</param>
|
||||
public PrivateKey(string key, string passphrase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(passphrase))
|
||||
throw new ArgumentException("Key and passphrase can't be null/empty");
|
||||
|
||||
var secureKey = new SecureString();
|
||||
foreach (var c in key)
|
||||
secureKey.AppendChar(c);
|
||||
secureKey.MakeReadOnly();
|
||||
Key = secureKey;
|
||||
|
||||
var securePassphrase = new SecureString();
|
||||
foreach (var c in passphrase)
|
||||
securePassphrase.AppendChar(c);
|
||||
securePassphrase.MakeReadOnly();
|
||||
Passphrase = securePassphrase;
|
||||
|
||||
IsEncrypted = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a private key providing an unencrypted key information
|
||||
/// </summary>
|
||||
/// <param name="key">The private key used for signing</param>
|
||||
public PrivateKey(SecureString key)
|
||||
{
|
||||
Key = key;
|
||||
|
||||
IsEncrypted = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a private key providing an encrypted key information
|
||||
/// </summary>
|
||||
/// <param name="key">The private key used for signing</param>
|
||||
public PrivateKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key can't be null/empty");
|
||||
|
||||
Key = key.ToSecureString();
|
||||
|
||||
IsEncrypted = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy the private key
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public PrivateKey Copy()
|
||||
{
|
||||
if (Passphrase == null)
|
||||
return new PrivateKey(Key.GetString());
|
||||
else
|
||||
return new PrivateKey(Key.GetString(), Passphrase.GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Key?.Dispose();
|
||||
Passphrase?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication.Signing
|
||||
{
|
||||
/// <summary>
|
||||
/// ABI encoding
|
||||
/// </summary>
|
||||
public static class CeAbiEncoder
|
||||
{
|
||||
/// <summary>
|
||||
/// ABI encode string as Sha3Keccack hashed byte value
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeString(string value)
|
||||
{
|
||||
var abiValueEncoded = CeSha3Keccack.CalculateHash(Encoding.UTF8.GetBytes(value));
|
||||
return abiValueEncoded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode bool value as uint256 with 1 for true and 0 for false, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeBool(bool value)
|
||||
=> AbiValueEncodeInt((byte)(value ? 1 : 0));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode byte value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(byte value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode short value as int256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(short value)
|
||||
=> AbiValueEncodeBigInteger(true, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode int value as int256, as per ABI specification
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeInt(int value)
|
||||
=> AbiValueEncodeBigInteger(true, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode long value as int256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(long value)
|
||||
=> AbiValueEncodeBigInteger(true, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode ushort value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(ushort value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode uint value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeInt(uint value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode ulong value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeInt(ulong value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode big integer value as int256 or uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeBigInteger(bool signed, BigInteger value)
|
||||
{
|
||||
var result = new byte[32];
|
||||
if (signed && value < 0)
|
||||
{
|
||||
// Pad with FF
|
||||
for (int i = 0; i < result.Length; i++)
|
||||
{
|
||||
result[i] = 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
var t = value.ToByteArray();
|
||||
if (t.Length == 33)
|
||||
{
|
||||
// Strip last byte
|
||||
var strip1 = new byte[32];
|
||||
Array.Copy(t, 0, strip1, 0, 32);
|
||||
t = strip1;
|
||||
}
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
t = t.AsEnumerable().Reverse().ToArray();
|
||||
|
||||
t.CopyTo(result, result.Length - t.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode address value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeAddress(string value)
|
||||
{
|
||||
var result = new byte[32];
|
||||
var h = value.HexStringToBytes();
|
||||
h.CopyTo(result, result.Length - h.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode hex string value as bytes32, as per ABI specification. The hex string is expected to be a 0x prefixed string, and the resulting bytes will be right aligned in the 32 bytes result, with leading zeros if the hex string is shorter than 32 bytes. If the hex string is longer than 32 bytes, an exception will be thrown.
|
||||
/// </summary>
|
||||
/// <param name="length"></param>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeHexBytes(int length, string value)
|
||||
=> AbiValueEncodeBytes(value.Length, value.HexStringToBytes());
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode byte array value as bytes32, as per ABI specification. The resulting bytes will be right aligned in the 32 bytes result, with leading zeros if the byte array is shorter than 32 bytes. If the byte array is longer than 32 bytes, an exception will be thrown.
|
||||
/// </summary>
|
||||
/// <param name="length"></param>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public static byte[] AbiValueEncodeBytes(int length, byte[] value)
|
||||
{
|
||||
if (length != 32)
|
||||
throw new Exception("Only 32 bytes size supported");
|
||||
|
||||
if (value.Length == 32)
|
||||
return value;
|
||||
|
||||
var result = new byte[32];
|
||||
value.CopyTo(result, result.Length - value.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication.Signing
|
||||
{
|
||||
/// <summary>
|
||||
/// EIP712 Typed Data Encoder
|
||||
/// </summary>
|
||||
public static class CeEip712TypedDataEncoder
|
||||
{
|
||||
/// <summary>
|
||||
/// Encode EIP712 typed data according to the specification, with the provided primary type, domain fields and message fields.
|
||||
/// The resulting byte array is the 0x19 0x01 prefix followed by the hash of the domain and the hash of the message, which can be signed with ECDsa secp256k1 to produce a signature that can be verified on chain with EIP712.
|
||||
/// Note that this implementation does not support all possible EIP712 types, but it should cover most common use cases
|
||||
/// </summary>
|
||||
public static byte[] EncodeEip721(
|
||||
string primaryType,
|
||||
IEnumerable<(string Name, string Type, object Value)> domainFields,
|
||||
IEnumerable<(string Name, string Type, object Value)> messageFields)
|
||||
{
|
||||
var data = new CeTypedDataRaw()
|
||||
{
|
||||
PrimaryType = primaryType,
|
||||
DomainRawValues = domainFields.Select(x => new CeMemberValue
|
||||
{
|
||||
TypeName = x.Type,
|
||||
Value = x.Value,
|
||||
}).ToArray(),
|
||||
|
||||
Message = messageFields.Select(x => new CeMemberValue
|
||||
{
|
||||
TypeName = x.Type,
|
||||
Value = x.Value,
|
||||
}).ToArray(),
|
||||
Types = new Dictionary<string, CeMemberDescription[]>
|
||||
{
|
||||
{
|
||||
"EIP712Domain",
|
||||
domainFields.Select(x => new CeMemberDescription
|
||||
{
|
||||
Name = x.Name,
|
||||
Type = x.Type
|
||||
}).ToArray()
|
||||
},
|
||||
{
|
||||
primaryType,
|
||||
messageFields.Select(x => new CeMemberDescription
|
||||
{
|
||||
Name = x.Name,
|
||||
Type = x.Type
|
||||
}).ToArray()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return EncodeTypedDataRaw(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode EIP712 typed data according to the specification, with the provided primary type, domain fields and message fields.
|
||||
/// The resulting byte array is the 0x19 0x01 prefix followed by the hash of the domain and the hash of the message, which can be signed with ECDsa secp256k1 to produce a signature that can be verified on chain with EIP712.
|
||||
/// Note that this implementation does not support all possible EIP712 types, but it should cover most common use cases
|
||||
/// </summary>
|
||||
public static byte[] EncodeTypedDataRaw(CeTypedDataRaw typedData)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(memoryStream);
|
||||
|
||||
// Write 0x19 0x01 prefix
|
||||
writer.Write((byte)0x19);
|
||||
writer.Write((byte)0x01);
|
||||
|
||||
// Write domain
|
||||
writer.Write(HashStruct(typedData.Types, "EIP712Domain", typedData.DomainRawValues));
|
||||
|
||||
// Write message
|
||||
writer.Write(HashStruct(typedData.Types, typedData.PrimaryType, typedData.Message));
|
||||
|
||||
writer.Flush();
|
||||
var result = memoryStream.ToArray();
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
private static byte[] HashStruct(IDictionary<string, CeMemberDescription[]> types, string primaryType, IEnumerable<CeMemberValue> message)
|
||||
{
|
||||
var memoryStream = new MemoryStream();
|
||||
var writer = new BinaryWriter(memoryStream);
|
||||
|
||||
// Encode the type header
|
||||
EncodeType(writer, types, primaryType);
|
||||
|
||||
// Encode the data
|
||||
EncodeData(writer, types, message);
|
||||
|
||||
writer.Flush();
|
||||
return CeSha3Keccack.CalculateHash(memoryStream.ToArray());
|
||||
|
||||
}
|
||||
|
||||
private static void EncodeData(BinaryWriter writer, IDictionary<string, CeMemberDescription[]> types, IEnumerable<CeMemberValue> memberValues)
|
||||
{
|
||||
foreach (var memberValue in memberValues)
|
||||
{
|
||||
switch (memberValue.TypeName)
|
||||
{
|
||||
case var refType when IsReferenceType(refType):
|
||||
writer.Write(HashStruct(types, memberValue.TypeName, (IEnumerable<CeMemberValue>)memberValue.Value));
|
||||
break;
|
||||
|
||||
case "string":
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeString((string)memberValue.Value));
|
||||
break;
|
||||
|
||||
case "bool":
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeBool((bool)memberValue.Value));
|
||||
break;
|
||||
|
||||
case "address":
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeAddress((string)memberValue.Value));
|
||||
break;
|
||||
|
||||
default:
|
||||
if (memberValue.TypeName.Contains("["))
|
||||
{
|
||||
var items = (IList)memberValue.Value;
|
||||
var itemsMemberValues = new List<CeMemberValue>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
itemsMemberValues.Add(new CeMemberValue()
|
||||
{
|
||||
TypeName = memberValue.TypeName.Substring(0, memberValue.TypeName.LastIndexOf("[")),
|
||||
Value = item
|
||||
});
|
||||
}
|
||||
|
||||
var memoryStream = new MemoryStream();
|
||||
var writerItem = new BinaryWriter(memoryStream);
|
||||
|
||||
EncodeData(writerItem, types, itemsMemberValues);
|
||||
writerItem.Flush();
|
||||
writer.Write(CeSha3Keccack.CalculateHash(memoryStream.ToArray()));
|
||||
}
|
||||
else if (memberValue.TypeName.StartsWith("int") || memberValue.TypeName.StartsWith("uint"))
|
||||
{
|
||||
if (memberValue.Value is string v)
|
||||
{
|
||||
if (!BigInteger.TryParse(v, out BigInteger parsedOutput))
|
||||
throw new Exception($"Failed to encode BigInteger string {v}");
|
||||
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeBigInteger(memberValue.TypeName[0] != 'u', parsedOutput));
|
||||
}
|
||||
else if (memberValue.Value is byte b)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(b));
|
||||
}
|
||||
else if (memberValue.Value is short s)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(s));
|
||||
}
|
||||
else if (memberValue.Value is int i)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(i));
|
||||
}
|
||||
else if (memberValue.Value is long l)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(l));
|
||||
}
|
||||
else if (memberValue.Value is ushort us)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(us));
|
||||
}
|
||||
else if (memberValue.Value is uint ui)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(ui));
|
||||
}
|
||||
else if (memberValue.Value is ulong ul)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(ul));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Unknown number value");
|
||||
}
|
||||
}
|
||||
else if (memberValue.TypeName.StartsWith("bytes"))
|
||||
{
|
||||
var length = memberValue.TypeName.Length == 5 ? 32 : int.Parse(memberValue.TypeName.Substring(5));
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeBytes(length, (byte[])memberValue.Value));
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EncodeType(BinaryWriter writer, IDictionary<string, CeMemberDescription[]> types, string typeName)
|
||||
{
|
||||
var encodedTypes = EncodeTypes(types, typeName);
|
||||
var encodedPrimaryType = encodedTypes.Single(x => x.Key == typeName);
|
||||
var encodedReferenceTypes = encodedTypes.Where(x => x.Key != typeName).OrderBy(x => x.Key).Select(x => x.Value);
|
||||
var fullyEncodedType = encodedPrimaryType.Value + string.Join(string.Empty, encodedReferenceTypes.ToArray());
|
||||
|
||||
writer.Write(CeSha3Keccack.CalculateHash(Encoding.UTF8.GetBytes(fullyEncodedType)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a list of type => type(parameters), for example:<br />
|
||||
/// { IP712Domain, EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) }
|
||||
/// </summary>
|
||||
private static IList<KeyValuePair<string, string>> EncodeTypes(IDictionary<string, CeMemberDescription[]> types, string currentTypeName)
|
||||
{
|
||||
var currentTypeMembers = types[currentTypeName];
|
||||
var currentTypeMembersEncoded = currentTypeMembers.Select(x => x.Type + " " + x.Name);
|
||||
var result = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new KeyValuePair<string, string>(currentTypeName, currentTypeName + "(" + string.Join(",", currentTypeMembersEncoded.ToArray()) + ")")
|
||||
};
|
||||
|
||||
result.AddRange(currentTypeMembers.Select(x => x.Type.Contains("[") ? x.Type.Substring(0, x.Type.IndexOf("[")) : x.Type)
|
||||
.Distinct()
|
||||
.Where(IsReferenceType)
|
||||
.SelectMany(x => EncodeTypes(types, x)));
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static bool IsReferenceType(string typeName)
|
||||
{
|
||||
switch (typeName)
|
||||
{
|
||||
case var bytes when new Regex("bytes\\d+").IsMatch(bytes):
|
||||
case var @uint when new Regex("uint\\d+").IsMatch(@uint):
|
||||
case var @int when new Regex("int\\d+").IsMatch(@int):
|
||||
case "bytes":
|
||||
case "string":
|
||||
case "bool":
|
||||
case "address":
|
||||
case var array when array.Contains("["):
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Member description
|
||||
/// </summary>
|
||||
public class CeMemberDescription
|
||||
{
|
||||
/// <summary>
|
||||
/// Name
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Type
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Member value
|
||||
/// </summary>
|
||||
public class CeMemberValue
|
||||
{
|
||||
/// <summary>
|
||||
/// Type name
|
||||
/// </summary>
|
||||
public string TypeName { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Value
|
||||
/// </summary>
|
||||
public object Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed data raw, used for encoding EIP712 typed data with the provided primary type, domain fields and message fields.
|
||||
/// </summary>
|
||||
public class CeTypedDataRaw
|
||||
{
|
||||
/// <summary>
|
||||
/// Type dictionary
|
||||
/// </summary>
|
||||
public IDictionary<string, CeMemberDescription[]> Types { get; set; } = new Dictionary<string, CeMemberDescription[]>();
|
||||
/// <summary>
|
||||
/// Primary type
|
||||
/// </summary>
|
||||
public string PrimaryType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Message values
|
||||
/// </summary>
|
||||
public CeMemberValue[] Message { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Domain values
|
||||
/// </summary>
|
||||
public CeMemberValue[] DomainRawValues { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication.Signing
|
||||
{
|
||||
/// <summary>
|
||||
/// Sha3 Keccack hashing, as per Ethereum specification, with 256 bit output
|
||||
/// </summary>
|
||||
public class CeSha3Keccack
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculate the Keccack256 hash of the provided data, as per Ethereum specification
|
||||
/// </summary>
|
||||
public static byte[] CalculateHash(byte[] data)
|
||||
{
|
||||
var digest = new CeKeccakDigest256();
|
||||
var output = new byte[digest.GetDigestSize()];
|
||||
digest.BlockUpdate(data, data.Length);
|
||||
digest.DoFinal(output, 0);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
internal class CeKeccakDigest256
|
||||
{
|
||||
private static readonly ulong[] _keccakRoundConstants = KeccakInitializeRoundConstants();
|
||||
private static readonly int[] _keccakRhoOffsets = KeccakInitializeRhoOffsets();
|
||||
|
||||
private readonly int _rate;
|
||||
private const int _stateLength = 1600 / 8;
|
||||
private readonly ulong[] _state = new ulong[_stateLength / 8];
|
||||
private readonly byte[] _dataQueue = new byte[1536 / 8];
|
||||
private int _bitsInQueue;
|
||||
private int _fixedOutputLength;
|
||||
private bool _squeezing;
|
||||
private int _bitsAvailableForSqueezing;
|
||||
|
||||
public CeKeccakDigest256()
|
||||
{
|
||||
_rate = 1600 - (256 << 1);
|
||||
_bitsInQueue = 0;
|
||||
_squeezing = false;
|
||||
_bitsAvailableForSqueezing = 0;
|
||||
_fixedOutputLength = 1600 - _rate >> 1;
|
||||
}
|
||||
|
||||
internal void BlockUpdate(byte[] data, int length)
|
||||
{
|
||||
int bytesInQueue = _bitsInQueue >> 3;
|
||||
int rateBytes = _rate >> 3;
|
||||
|
||||
int count = 0;
|
||||
while (count < length)
|
||||
{
|
||||
if (bytesInQueue == 0 && count <= length - rateBytes)
|
||||
{
|
||||
do
|
||||
{
|
||||
KeccakAbsorb(data, count);
|
||||
count += rateBytes;
|
||||
} while (count <= length - rateBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
int partialBlock = Math.Min(rateBytes - bytesInQueue, length - count);
|
||||
Array.Copy(data, count, _dataQueue, bytesInQueue, partialBlock);
|
||||
|
||||
bytesInQueue += partialBlock;
|
||||
count += partialBlock;
|
||||
|
||||
if (bytesInQueue == rateBytes)
|
||||
{
|
||||
KeccakAbsorb(_dataQueue, 0);
|
||||
bytesInQueue = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_bitsInQueue = bytesInQueue << 3;
|
||||
}
|
||||
|
||||
internal void DoFinal(byte[] output, int outOff)
|
||||
{
|
||||
Squeeze(output, outOff, _fixedOutputLength >> 3);
|
||||
}
|
||||
|
||||
internal int GetDigestSize() => _fixedOutputLength >> 3;
|
||||
|
||||
protected void Squeeze(byte[] output, int off, int len)
|
||||
{
|
||||
if (!_squeezing)
|
||||
PadAndSwitchToSqueezingPhase();
|
||||
|
||||
long outputLength = (long)len << 3;
|
||||
long i = 0;
|
||||
while (i < outputLength)
|
||||
{
|
||||
if (_bitsAvailableForSqueezing == 0)
|
||||
{
|
||||
KeccakPermutation();
|
||||
KeccakExtract();
|
||||
_bitsAvailableForSqueezing = _rate;
|
||||
}
|
||||
|
||||
int partialBlock = (int)Math.Min(_bitsAvailableForSqueezing, outputLength - i);
|
||||
Array.Copy(_dataQueue, _rate - _bitsAvailableForSqueezing >> 3, output, off + (int)(i >> 3),
|
||||
partialBlock >> 3);
|
||||
_bitsAvailableForSqueezing -= partialBlock;
|
||||
i += partialBlock;
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong[] KeccakInitializeRoundConstants()
|
||||
{
|
||||
ulong[] keccakRoundConstants = new ulong[24];
|
||||
byte LFSRState = 0x01;
|
||||
|
||||
for (int i = 0; i < 24; i++)
|
||||
{
|
||||
keccakRoundConstants[i] = 0;
|
||||
for (int j = 0; j < 7; j++)
|
||||
{
|
||||
int bitPosition = (1 << j) - 1;
|
||||
|
||||
// LFSR86540
|
||||
|
||||
bool loBit = (LFSRState & 0x01) != 0;
|
||||
if (loBit)
|
||||
keccakRoundConstants[i] ^= 1UL << bitPosition;
|
||||
|
||||
bool hiBit = (LFSRState & 0x80) != 0;
|
||||
LFSRState <<= 1;
|
||||
if (hiBit)
|
||||
LFSRState ^= 0x71;
|
||||
}
|
||||
}
|
||||
|
||||
return keccakRoundConstants;
|
||||
}
|
||||
private static int[] KeccakInitializeRhoOffsets()
|
||||
{
|
||||
int[] keccakRhoOffsets = new int[25];
|
||||
int x, y, t, newX, newY;
|
||||
|
||||
int rhoOffset = 0;
|
||||
keccakRhoOffsets[0] = rhoOffset;
|
||||
x = 1;
|
||||
y = 0;
|
||||
for (t = 1; t < 25; t++)
|
||||
{
|
||||
rhoOffset = rhoOffset + t & 63;
|
||||
keccakRhoOffsets[x % 5 + 5 * (y % 5)] = rhoOffset;
|
||||
newX = (0 * x + 1 * y) % 5;
|
||||
newY = (2 * x + 3 * y) % 5;
|
||||
x = newX;
|
||||
y = newY;
|
||||
}
|
||||
|
||||
return keccakRhoOffsets;
|
||||
}
|
||||
|
||||
private void KeccakAbsorb(byte[] data, int off)
|
||||
{
|
||||
int count = _rate >> 6;
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
_state[i] ^= Pack.LeToUInt64(data, off);
|
||||
off += 8;
|
||||
}
|
||||
|
||||
KeccakPermutation();
|
||||
}
|
||||
|
||||
private void KeccakPermutation()
|
||||
{
|
||||
for (int i = 0; i < 24; i++)
|
||||
{
|
||||
Theta(_state);
|
||||
Rho(_state);
|
||||
Pi(_state);
|
||||
Chi(_state);
|
||||
Iota(_state, i);
|
||||
}
|
||||
}
|
||||
private static ulong LeftRotate(ulong v, int r)
|
||||
{
|
||||
return v << r | v >> -r;
|
||||
}
|
||||
|
||||
private static void Theta(ulong[] A)
|
||||
{
|
||||
ulong C0 = A[0 + 0] ^ A[0 + 5] ^ A[0 + 10] ^ A[0 + 15] ^ A[0 + 20];
|
||||
ulong C1 = A[1 + 0] ^ A[1 + 5] ^ A[1 + 10] ^ A[1 + 15] ^ A[1 + 20];
|
||||
ulong C2 = A[2 + 0] ^ A[2 + 5] ^ A[2 + 10] ^ A[2 + 15] ^ A[2 + 20];
|
||||
ulong C3 = A[3 + 0] ^ A[3 + 5] ^ A[3 + 10] ^ A[3 + 15] ^ A[3 + 20];
|
||||
ulong C4 = A[4 + 0] ^ A[4 + 5] ^ A[4 + 10] ^ A[4 + 15] ^ A[4 + 20];
|
||||
|
||||
ulong dX = LeftRotate(C1, 1) ^ C4;
|
||||
|
||||
A[0] ^= dX;
|
||||
A[5] ^= dX;
|
||||
A[10] ^= dX;
|
||||
A[15] ^= dX;
|
||||
A[20] ^= dX;
|
||||
|
||||
dX = LeftRotate(C2, 1) ^ C0;
|
||||
|
||||
A[1] ^= dX;
|
||||
A[6] ^= dX;
|
||||
A[11] ^= dX;
|
||||
A[16] ^= dX;
|
||||
A[21] ^= dX;
|
||||
|
||||
dX = LeftRotate(C3, 1) ^ C1;
|
||||
|
||||
A[2] ^= dX;
|
||||
A[7] ^= dX;
|
||||
A[12] ^= dX;
|
||||
A[17] ^= dX;
|
||||
A[22] ^= dX;
|
||||
|
||||
dX = LeftRotate(C4, 1) ^ C2;
|
||||
|
||||
A[3] ^= dX;
|
||||
A[8] ^= dX;
|
||||
A[13] ^= dX;
|
||||
A[18] ^= dX;
|
||||
A[23] ^= dX;
|
||||
|
||||
dX = LeftRotate(C0, 1) ^ C3;
|
||||
|
||||
A[4] ^= dX;
|
||||
A[9] ^= dX;
|
||||
A[14] ^= dX;
|
||||
A[19] ^= dX;
|
||||
A[24] ^= dX;
|
||||
}
|
||||
|
||||
private static void Rho(ulong[] A)
|
||||
{
|
||||
// KeccakRhoOffsets[0] == 0
|
||||
for (int x = 1; x < 25; x++)
|
||||
{
|
||||
A[x] = LeftRotate(A[x], _keccakRhoOffsets[x]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Pi(ulong[] A)
|
||||
{
|
||||
ulong a1 = A[1];
|
||||
A[1] = A[6];
|
||||
A[6] = A[9];
|
||||
A[9] = A[22];
|
||||
A[22] = A[14];
|
||||
A[14] = A[20];
|
||||
A[20] = A[2];
|
||||
A[2] = A[12];
|
||||
A[12] = A[13];
|
||||
A[13] = A[19];
|
||||
A[19] = A[23];
|
||||
A[23] = A[15];
|
||||
A[15] = A[4];
|
||||
A[4] = A[24];
|
||||
A[24] = A[21];
|
||||
A[21] = A[8];
|
||||
A[8] = A[16];
|
||||
A[16] = A[5];
|
||||
A[5] = A[3];
|
||||
A[3] = A[18];
|
||||
A[18] = A[17];
|
||||
A[17] = A[11];
|
||||
A[11] = A[7];
|
||||
A[7] = A[10];
|
||||
A[10] = a1;
|
||||
}
|
||||
|
||||
private static void Chi(ulong[] A)
|
||||
{
|
||||
ulong chiC0, chiC1, chiC2, chiC3, chiC4;
|
||||
|
||||
for (int yBy5 = 0; yBy5 < 25; yBy5 += 5)
|
||||
{
|
||||
chiC0 = A[0 + yBy5] ^ ~A[(0 + 1) % 5 + yBy5] & A[(0 + 2) % 5 + yBy5];
|
||||
chiC1 = A[1 + yBy5] ^ ~A[(1 + 1) % 5 + yBy5] & A[(1 + 2) % 5 + yBy5];
|
||||
chiC2 = A[2 + yBy5] ^ ~A[(2 + 1) % 5 + yBy5] & A[(2 + 2) % 5 + yBy5];
|
||||
chiC3 = A[3 + yBy5] ^ ~A[(3 + 1) % 5 + yBy5] & A[(3 + 2) % 5 + yBy5];
|
||||
chiC4 = A[4 + yBy5] ^ ~A[(4 + 1) % 5 + yBy5] & A[(4 + 2) % 5 + yBy5];
|
||||
|
||||
A[0 + yBy5] = chiC0;
|
||||
A[1 + yBy5] = chiC1;
|
||||
A[2 + yBy5] = chiC2;
|
||||
A[3 + yBy5] = chiC3;
|
||||
A[4 + yBy5] = chiC4;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Iota(ulong[] A, int indexRound)
|
||||
{
|
||||
A[0] ^= _keccakRoundConstants[indexRound];
|
||||
}
|
||||
|
||||
private void PadAndSwitchToSqueezingPhase()
|
||||
{
|
||||
Debug.Assert(_bitsInQueue < _rate);
|
||||
|
||||
_dataQueue[_bitsInQueue >> 3] |= (byte)(1U << (_bitsInQueue & 7));
|
||||
|
||||
if (++_bitsInQueue == _rate)
|
||||
{
|
||||
KeccakAbsorb(_dataQueue, 0);
|
||||
_bitsInQueue = 0;
|
||||
}
|
||||
|
||||
{
|
||||
int full = _bitsInQueue >> 6, partial = _bitsInQueue & 63;
|
||||
int off = 0;
|
||||
for (int i = 0; i < full; ++i)
|
||||
{
|
||||
_state[i] ^= Pack.LeToUInt64(_dataQueue, off);
|
||||
off += 8;
|
||||
}
|
||||
|
||||
if (partial > 0)
|
||||
{
|
||||
ulong mask = (1UL << partial) - 1UL;
|
||||
_state[full] ^= Pack.LeToUInt64(_dataQueue, off) & mask;
|
||||
}
|
||||
|
||||
_state[_rate - 1 >> 6] ^= 1UL << 63;
|
||||
}
|
||||
|
||||
KeccakPermutation();
|
||||
KeccakExtract();
|
||||
_bitsAvailableForSqueezing = _rate;
|
||||
|
||||
_bitsInQueue = 0;
|
||||
_squeezing = true;
|
||||
}
|
||||
private void KeccakExtract()
|
||||
{
|
||||
Pack.UInt64ToLe(_state, 0, _rate >> 6, _dataQueue, 0);
|
||||
}
|
||||
|
||||
static class Pack
|
||||
{
|
||||
internal static ulong LeToUInt64(byte[] bs, int off)
|
||||
{
|
||||
uint lo = LeToUInt32(bs, off);
|
||||
uint hi = LeToUInt32(bs, off + 4);
|
||||
return (ulong)hi << 32 | lo;
|
||||
}
|
||||
internal static uint LeToUInt32(byte[] bs, int off)
|
||||
{
|
||||
return bs[off]
|
||||
| (uint)bs[off + 1] << 8
|
||||
| (uint)bs[off + 2] << 16
|
||||
| (uint)bs[off + 3] << 24;
|
||||
}
|
||||
|
||||
internal static void UInt64ToLe(ulong[] ns, int nsOff, int nsLen, byte[] bs, int bsOff)
|
||||
{
|
||||
for (int i = 0; i < nsLen; ++i)
|
||||
{
|
||||
UInt64ToLe(ns[nsOff + i], bs, bsOff);
|
||||
bsOff += 8;
|
||||
}
|
||||
}
|
||||
internal static void UInt64ToLe(ulong n, byte[] bs, int off)
|
||||
{
|
||||
UInt32ToLe((uint)n, bs, off);
|
||||
UInt32ToLe((uint)(n >> 32), bs, off + 4);
|
||||
}
|
||||
|
||||
internal static void UInt32ToLe(uint n, byte[] bs, int off)
|
||||
{
|
||||
bs[off] = (byte)n;
|
||||
bs[off + 1] = (byte)(n >> 8);
|
||||
bs[off + 2] = (byte)(n >> 16);
|
||||
bs[off + 3] = (byte)(n >> 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace CryptoExchange.Net.Caching
|
||||
{
|
||||
internal class MemoryCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheItem> _cache = new ConcurrentDictionary<string, CacheItem>();
|
||||
#if NET9_0_OR_GREATER
|
||||
private readonly Lock _lock = new Lock();
|
||||
#else
|
||||
private readonly object _lock = new object();
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Add a new cache entry. Will override an existing entry if it already exists
|
||||
/// </summary>
|
||||
/// <param name="key">The key identifier</param>
|
||||
/// <param name="value">Cache value</param>
|
||||
public void Add(string key, object value)
|
||||
{
|
||||
var cacheItem = new CacheItem(DateTime.UtcNow, value);
|
||||
_cache.AddOrUpdate(key, cacheItem, (key, val1) => cacheItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a cached value
|
||||
/// </summary>
|
||||
/// <param name="key">The key identifier</param>
|
||||
/// <param name="maxAge">The max age of the cached entry</param>
|
||||
/// <returns>Cached value if it was in cache</returns>
|
||||
public object? Get(string key, TimeSpan maxAge)
|
||||
{
|
||||
foreach (var item in _cache.Where(x => DateTime.UtcNow - x.Value.CacheTime > maxAge).ToList())
|
||||
_cache.TryRemove(item.Key, out _);
|
||||
|
||||
_cache.TryGetValue(key, out CacheItem? value);
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
return value.Value;
|
||||
}
|
||||
|
||||
private class CacheItem
|
||||
{
|
||||
public DateTime CacheTime { get; }
|
||||
public object Value { get; }
|
||||
|
||||
public CacheItem(DateTime cacheTime, object value)
|
||||
{
|
||||
CacheTime = cacheTime;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace CryptoExchange.Net
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <summary>
|
||||
/// Base API for all API clients
|
||||
/// </summary>
|
||||
public abstract class BaseApiClient: IDisposable
|
||||
public abstract class BaseApiClient : IDisposable, IBaseApiClient
|
||||
{
|
||||
private ApiCredentials? _apiCredentials;
|
||||
private AuthenticationProvider? _authenticationProvider;
|
||||
private bool _created;
|
||||
private bool _disposing;
|
||||
/// <summary>
|
||||
/// Client name
|
||||
/// </summary>
|
||||
protected string? _clientName;
|
||||
|
||||
/// <summary>
|
||||
/// The authentication provider for this API client. (null if no credentials are set)
|
||||
/// Logger
|
||||
/// </summary>
|
||||
public AuthenticationProvider? AuthenticationProvider
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_created && !_disposing && _apiCredentials != null)
|
||||
{
|
||||
_authenticationProvider = CreateAuthenticationProvider(_apiCredentials);
|
||||
_created = true;
|
||||
}
|
||||
protected ILogger _logger;
|
||||
|
||||
return _authenticationProvider;
|
||||
/// <summary>
|
||||
/// If we are disposing
|
||||
/// </summary>
|
||||
protected bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a proxy is configured
|
||||
/// </summary>
|
||||
protected bool _proxyConfigured;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the client
|
||||
/// </summary>
|
||||
protected internal string ClientName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_clientName != null)
|
||||
return _clientName;
|
||||
|
||||
_clientName = GetType().Name;
|
||||
return _clientName;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where to put the parameters for requests with different Http methods
|
||||
/// The name of the exchange this client is for
|
||||
/// </summary>
|
||||
public Dictionary<HttpMethod, HttpMethodParameterPosition> ParameterPositions { get; set; } = new Dictionary<HttpMethod, HttpMethodParameterPosition>
|
||||
{
|
||||
{ HttpMethod.Get, HttpMethodParameterPosition.InUri },
|
||||
{ HttpMethod.Post, HttpMethodParameterPosition.InBody },
|
||||
{ HttpMethod.Delete, HttpMethodParameterPosition.InBody },
|
||||
{ HttpMethod.Put, HttpMethodParameterPosition.InBody }
|
||||
};
|
||||
public string Exchange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Request body content type
|
||||
/// The environment this client communicates to
|
||||
/// </summary>
|
||||
public RequestBodyFormat requestBodyFormat = RequestBodyFormat.Json;
|
||||
public string BaseAddress { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not we need to manually parse an error instead of relying on the http status code
|
||||
/// Output the original string data along with the deserialized object
|
||||
/// </summary>
|
||||
public bool manualParseError = false;
|
||||
public bool OutputOriginalData { get; }
|
||||
|
||||
/// <summary>
|
||||
/// How to serialize array parameters when making requests
|
||||
/// Api options
|
||||
/// </summary>
|
||||
public ArrayParametersSerialization arraySerialization = ArrayParametersSerialization.Array;
|
||||
public ApiOptions ApiOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// What request body should be set when no data is send (only used in combination with postParametersPosition.InBody)
|
||||
/// Client Options
|
||||
/// </summary>
|
||||
public string requestBodyEmptyContent = "{}";
|
||||
public ExchangeOptions ClientOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The base address for this API client
|
||||
/// Mapping of a response code to known error types
|
||||
/// </summary>
|
||||
internal protected string BaseAddress { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Api client options
|
||||
/// </summary>
|
||||
internal ApiClientOptions Options { get; }
|
||||
protected internal virtual ErrorMapping ErrorMapping { get; } = new ErrorMapping([]);
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="options">Client options</param>
|
||||
/// <param name="apiOptions">Api client options</param>
|
||||
protected BaseApiClient(BaseClientOptions options, ApiClientOptions apiOptions)
|
||||
/// <param name="loggerFactory">Logger factory</param>
|
||||
/// <param name="exchange">The exchange name</param>
|
||||
/// <param name="outputOriginalData">Should data from this client include the original data in the call result</param>
|
||||
/// <param name="baseAddress">Base address for this API client</param>
|
||||
/// <param name="clientOptions">Client options</param>
|
||||
/// <param name="apiOptions">Api options</param>
|
||||
protected BaseApiClient(
|
||||
ILoggerFactory? loggerFactory,
|
||||
string exchange,
|
||||
bool outputOriginalData,
|
||||
string baseAddress,
|
||||
ExchangeOptions clientOptions,
|
||||
ApiOptions apiOptions)
|
||||
{
|
||||
Options = apiOptions;
|
||||
_apiCredentials = apiOptions.ApiCredentials?.Copy() ?? options.ApiCredentials?.Copy();
|
||||
BaseAddress = apiOptions.BaseAddress;
|
||||
}
|
||||
var loggerName = ClientName.StartsWith(exchange, StringComparison.OrdinalIgnoreCase)
|
||||
? exchange + "." + ClientName.Substring(exchange.Length).TrimStart('.')
|
||||
: exchange + "." + ClientName;
|
||||
_logger = loggerFactory?.CreateLogger(loggerName) ?? NullLogger.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Create an AuthenticationProvider implementation instance based on the provided credentials
|
||||
/// </summary>
|
||||
/// <param name="credentials"></param>
|
||||
/// <returns></returns>
|
||||
protected abstract AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials);
|
||||
Exchange = exchange;
|
||||
ClientOptions = clientOptions;
|
||||
ApiOptions = apiOptions;
|
||||
OutputOriginalData = outputOriginalData;
|
||||
BaseAddress = baseAddress;
|
||||
|
||||
_proxyConfigured = ClientOptions.Proxy != null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetApiCredentials(ApiCredentials credentials)
|
||||
{
|
||||
_apiCredentials = credentials?.Copy();
|
||||
_created = false;
|
||||
_authenticationProvider = null;
|
||||
}
|
||||
public abstract string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null);
|
||||
|
||||
/// <summary>
|
||||
/// Get error info for a response code
|
||||
/// </summary>
|
||||
public ErrorInfo GetErrorInfo(int code, string? message = null) => GetErrorInfo(code.ToString(), message);
|
||||
|
||||
/// <summary>
|
||||
/// Get error info for a response code
|
||||
/// </summary>
|
||||
public ErrorInfo GetErrorInfo(string code, string? message = null) => ErrorMapping.GetErrorInfo(code.ToString(), message);
|
||||
|
||||
/// <summary>
|
||||
/// Dispose
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_disposing = true;
|
||||
_apiCredentials?.Dispose();
|
||||
AuthenticationProvider?.Credentials?.Dispose();
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose
|
||||
/// </summary>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,295 +1,124 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Logging;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace CryptoExchange.Net
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <summary>
|
||||
/// The base for all clients, websocket client and rest client
|
||||
/// </summary>
|
||||
public abstract class BaseClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Version of the CryptoExchange.Net base library
|
||||
/// </summary>
|
||||
public Version CryptoExchangeLibVersion { get; } = typeof(BaseClient).Assembly.GetName().Version!;
|
||||
|
||||
/// <summary>
|
||||
/// Version of the client implementation
|
||||
/// </summary>
|
||||
public Version ExchangeLibVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
lock(_versionLock)
|
||||
{
|
||||
if (_exchangeVersion == null)
|
||||
_exchangeVersion = GetType().Assembly.GetName().Version!;
|
||||
|
||||
return _exchangeVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name of the API the client is for
|
||||
/// </summary>
|
||||
internal string Name { get; }
|
||||
public string Exchange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether client is disposed
|
||||
/// </summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
internal List<BaseApiClient> ApiClients { get; } = new List<BaseApiClient>();
|
||||
|
||||
/// <summary>
|
||||
/// The log object
|
||||
/// </summary>
|
||||
protected internal Log log;
|
||||
/// <summary>
|
||||
/// The last used id, use NextId() to get the next id and up this
|
||||
/// </summary>
|
||||
protected static int lastId;
|
||||
/// <summary>
|
||||
/// Lock for id generating
|
||||
/// </summary>
|
||||
protected static object idLock = new object();
|
||||
protected internal ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// A default serializer
|
||||
/// </summary>
|
||||
private static readonly JsonSerializer defaultSerializer = JsonSerializer.Create(new JsonSerializerSettings
|
||||
{
|
||||
DateTimeZoneHandling = DateTimeZoneHandling.Utc,
|
||||
Culture = CultureInfo.InvariantCulture
|
||||
});
|
||||
#if NET9_0_OR_GREATER
|
||||
private readonly Lock _versionLock = new Lock();
|
||||
#else
|
||||
private readonly object _versionLock = new object();
|
||||
#endif
|
||||
private Version _exchangeVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Provided client options
|
||||
/// </summary>
|
||||
public BaseClientOptions ClientOptions { get; }
|
||||
public ExchangeOptions ClientOptions { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the API this client is for</param>
|
||||
/// <param name="options">The options for this client</param>
|
||||
protected BaseClient(string name, BaseClientOptions options)
|
||||
/// <param name="logger">Logger</param>
|
||||
/// <param name="exchange">The name of the exchange this client is for</param>
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
|
||||
protected BaseClient(ILoggerFactory? logger, string exchange)
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
|
||||
{
|
||||
log = new Log(name);
|
||||
log.UpdateWriters(options.LogWriters);
|
||||
log.Level = options.LogLevel;
|
||||
options.OnLoggingChanged += HandleLogConfigChange;
|
||||
Exchange = exchange;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the client with the specified options
|
||||
/// </summary>
|
||||
/// <param name="options"></param>
|
||||
/// <exception cref="ArgumentNullException"></exception>
|
||||
protected virtual void Initialize(ExchangeOptions options)
|
||||
{
|
||||
if (options == null)
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
|
||||
ClientOptions = options;
|
||||
|
||||
Name = name;
|
||||
|
||||
log.Write(LogLevel.Trace, $"Client configuration: {options}, CryptoExchange.Net: v{typeof(BaseClient).Assembly.GetName().Version}, {name}.Net: v{GetType().Assembly.GetName().Version}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register an API client
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The client</param>
|
||||
protected T AddApiClient<T>(T apiClient) where T: BaseApiClient
|
||||
protected T AddApiClient<T>(T apiClient) where T : BaseApiClient
|
||||
{
|
||||
log.Write(LogLevel.Trace, $" {apiClient.GetType().Name} configuration: {apiClient.Options}");
|
||||
if (ClientOptions == null)
|
||||
throw new InvalidOperationException("Client should have called Initialize before adding API clients");
|
||||
|
||||
ApiClients.Add(apiClient);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse the json data and return a JToken, validating the input not being empty and being valid json
|
||||
/// Apply the options delegate to a new options instance
|
||||
/// </summary>
|
||||
/// <param name="data">The data to parse</param>
|
||||
/// <returns></returns>
|
||||
protected CallResult<JToken> ValidateJson(string data)
|
||||
protected static T ApplyOptionsDelegate<T>(Action<T>? del) where T: new()
|
||||
{
|
||||
if (string.IsNullOrEmpty(data))
|
||||
{
|
||||
var info = "Empty data object received";
|
||||
log.Write(LogLevel.Error, info);
|
||||
return new CallResult<JToken>(new DeserializeError(info, data));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new CallResult<JToken>(JToken.Parse(data));
|
||||
}
|
||||
catch (JsonReaderException jre)
|
||||
{
|
||||
var info = $"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}";
|
||||
return new CallResult<JToken>(new DeserializeError(info, data));
|
||||
}
|
||||
catch (JsonSerializationException jse)
|
||||
{
|
||||
var info = $"Deserialize JsonSerializationException: {jse.Message}";
|
||||
return new CallResult<JToken>(new DeserializeError(info, data));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var exceptionInfo = ex.ToLogString();
|
||||
var info = $"Deserialize Unknown Exception: {exceptionInfo}";
|
||||
return new CallResult<JToken>(new DeserializeError(info, data));
|
||||
}
|
||||
var opts = new T();
|
||||
del?.Invoke(opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize a string into an object
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize into</typeparam>
|
||||
/// <param name="data">The data to deserialize</param>
|
||||
/// <param name="serializer">A specific serializer to use</param>
|
||||
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
|
||||
/// <returns></returns>
|
||||
protected CallResult<T> Deserialize<T>(string data, JsonSerializer? serializer = null, int? requestId = null)
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
var tokenResult = ValidateJson(data);
|
||||
if (!tokenResult)
|
||||
{
|
||||
log.Write(LogLevel.Error, tokenResult.Error!.Message);
|
||||
return new CallResult<T>( tokenResult.Error);
|
||||
}
|
||||
|
||||
return Deserialize<T>(tokenResult.Data, serializer, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize a JToken into an object
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize into</typeparam>
|
||||
/// <param name="obj">The data to deserialize</param>
|
||||
/// <param name="serializer">A specific serializer to use</param>
|
||||
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
|
||||
/// <returns></returns>
|
||||
protected CallResult<T> Deserialize<T>(JToken obj, JsonSerializer? serializer = null, int? requestId = null)
|
||||
{
|
||||
serializer ??= defaultSerializer;
|
||||
|
||||
try
|
||||
{
|
||||
return new CallResult<T>(obj.ToObject<T>(serializer)!);
|
||||
}
|
||||
catch (JsonReaderException jre)
|
||||
{
|
||||
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message} Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {obj}";
|
||||
log.Write(LogLevel.Error, info);
|
||||
return new CallResult<T>(new DeserializeError(info, obj));
|
||||
}
|
||||
catch (JsonSerializationException jse)
|
||||
{
|
||||
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message} data: {obj}";
|
||||
log.Write(LogLevel.Error, info);
|
||||
return new CallResult<T>(new DeserializeError(info, obj));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var exceptionInfo = ex.ToLogString();
|
||||
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {obj}";
|
||||
log.Write(LogLevel.Error, info);
|
||||
return new CallResult<T>(new DeserializeError(info, obj));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize a stream into an object
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize into</typeparam>
|
||||
/// <param name="stream">The stream to deserialize</param>
|
||||
/// <param name="serializer">A specific serializer to use</param>
|
||||
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
|
||||
/// <param name="elapsedMilliseconds">Milliseconds response time for the request this stream is a response for</param>
|
||||
/// <returns></returns>
|
||||
protected async Task<CallResult<T>> DeserializeAsync<T>(Stream stream, JsonSerializer? serializer = null, int? requestId = null, long? elapsedMilliseconds = null)
|
||||
{
|
||||
serializer ??= defaultSerializer;
|
||||
string? data = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Let the reader keep the stream open so we're able to seek if needed. The calling method will close the stream.
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true);
|
||||
|
||||
// If we have to output the original json data or output the data into the logging we'll have to read to full response
|
||||
// in order to log/return the json data
|
||||
if (ClientOptions.OutputOriginalData || log.Level == LogLevel.Trace)
|
||||
{
|
||||
data = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] ": "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms{(log.Level == LogLevel.Trace ? (": " + data) : "")}");
|
||||
var result = Deserialize<T>(data, serializer, requestId);
|
||||
if(ClientOptions.OutputOriginalData)
|
||||
result.OriginalData = data;
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we don't have to keep track of the original json data we can use the JsonTextReader to deserialize the stream directly
|
||||
// into the desired object, which has increased performance over first reading the string value into memory and deserializing from that
|
||||
using var jsonReader = new JsonTextReader(reader);
|
||||
log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] ": "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms");
|
||||
return new CallResult<T>(serializer.Deserialize<T>(jsonReader)!);
|
||||
}
|
||||
catch (JsonReaderException jre)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
// If we can seek the stream rewind it so we can retrieve the original data that was sent
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
data = await ReadStreamAsync(stream).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
data = "[Data only available in Trace LogLevel]";
|
||||
}
|
||||
log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {data}");
|
||||
return new CallResult<T>(new DeserializeError($"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}", data));
|
||||
}
|
||||
catch (JsonSerializationException jse)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
data = await ReadStreamAsync(stream).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
data = "[Data only available in Trace LogLevel]";
|
||||
}
|
||||
|
||||
log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message}, data: {data}");
|
||||
return new CallResult<T>(new DeserializeError($"Deserialize JsonSerializationException: {jse.Message}", data));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
data = await ReadStreamAsync(stream).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
data = "[Data only available in Trace LogLevel]";
|
||||
}
|
||||
|
||||
var exceptionInfo = ex.ToLogString();
|
||||
log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {data}");
|
||||
return new CallResult<T>(new DeserializeError($"Deserialize Unknown Exception: {exceptionInfo}", data));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadStreamAsync(Stream stream)
|
||||
{
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true);
|
||||
return await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a new unique id. The id is staticly stored so it is guarenteed to be unique across different client instances
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected static int NextId()
|
||||
{
|
||||
lock (idLock)
|
||||
{
|
||||
lastId += 1;
|
||||
return lastId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a change in the client options log config
|
||||
/// </summary>
|
||||
private void HandleLogConfigChange()
|
||||
{
|
||||
log.UpdateWriters(ClientOptions.LogWriters);
|
||||
log.Level = ClientOptions.LogLevel;
|
||||
return $"{GetType().Name}, CryptoExchange.Net: v{CryptoExchangeLibVersion}, {Exchange}.Net: v{ExchangeLibVersion}, configuration: {ClientOptions}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -297,8 +126,8 @@ namespace CryptoExchange.Net
|
||||
/// </summary>
|
||||
public virtual void Dispose()
|
||||
{
|
||||
log.Write(LogLevel.Debug, "Disposing client");
|
||||
ClientOptions.OnLoggingChanged -= HandleLogConfigChange;
|
||||
Disposed = true;
|
||||
|
||||
foreach (var client in ApiClients)
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Requests;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <summary>
|
||||
/// Base rest client
|
||||
@@ -23,474 +15,77 @@ namespace CryptoExchange.Net
|
||||
public abstract class BaseRestClient : BaseClient, IRestClient
|
||||
{
|
||||
/// <summary>
|
||||
/// The factory for creating requests. Used for unit testing
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
public IRequestFactory RequestFactory { get; set; } = new RequestFactory();
|
||||
|
||||
internal new List<RestApiClient> ApiClients => base.ApiClients.OfType<RestApiClient>().ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public int TotalRequestsMade => ApiClients.OfType<RestApiClient>().Sum(s => s.TotalRequestsMade);
|
||||
|
||||
/// <summary>
|
||||
/// Request headers to be sent with each request
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected Dictionary<string, string>? StandardRequestHeaders { get; set; }
|
||||
/// <param name="loggerFactory">Logger factory</param>
|
||||
/// <param name="name">The name of the API this client is for</param>
|
||||
protected BaseRestClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
_logger = loggerFactory?.CreateLogger(name + ".RestClient") ?? NullLoggerFactory.Instance.CreateLogger(name);
|
||||
|
||||
LibraryHelpers.StaticLogger = loggerFactory?.CreateLogger("CryptoExchange");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client options
|
||||
/// Update options
|
||||
/// </summary>
|
||||
public new BaseRestClientOptions ClientOptions { get; }
|
||||
public virtual void SetOptions(UpdateOptions options)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class BaseRestClient<TEnvironment, TApiCredentials> : BaseRestClient, IRestClient<TApiCredentials>
|
||||
where TEnvironment : TradeEnvironment
|
||||
where TApiCredentials : ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
internal new List<RestApiClient<TEnvironment, TApiCredentials>> ApiClients => base.ApiClients.OfType<RestApiClient<TEnvironment, TApiCredentials>>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Provided client options
|
||||
/// </summary>
|
||||
public new RestExchangeOptions<TEnvironment, TApiCredentials> ClientOptions => (RestExchangeOptions<TEnvironment, TApiCredentials>)base.ClientOptions;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory</param>
|
||||
/// <param name="name">The name of the API this client is for</param>
|
||||
/// <param name="options">The options for this client</param>
|
||||
protected BaseRestClient(string name, BaseRestClientOptions options) : base(name, options)
|
||||
protected BaseRestClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
if (options == null)
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
|
||||
ClientOptions = options;
|
||||
RequestFactory.Configure(options.RequestTimeout, options.Proxy, options.HttpClient);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetApiCredentials(ApiCredentials credentials)
|
||||
/// <summary>
|
||||
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
|
||||
/// </summary>
|
||||
/// <param name="credentials">The credentials to set</param>
|
||||
public virtual void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetApiCredentials(credentials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a request to the uri and returns if it was successful
|
||||
/// Update options
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The API client the request is for</param>
|
||||
/// <param name="uri">The uri to send the request to</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <param name="parameters">The parameters of the request</param>
|
||||
/// <param name="signed">Whether or not the request should be authenticated</param>
|
||||
/// <param name="parameterPosition">Where the parameters should be placed, overwrites the value set in the client</param>
|
||||
/// <param name="arraySerialization">How array parameters should be serialized, overwrites the value set in the client</param>
|
||||
/// <param name="requestWeight">Credits used for the request</param>
|
||||
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
|
||||
/// <param name="additionalHeaders">Additional headers to send with the request</param>
|
||||
/// <param name="ignoreRatelimit">Ignore rate limits for this request</param>
|
||||
/// <returns></returns>
|
||||
[return: NotNull]
|
||||
protected virtual async Task<WebCallResult> SendRequestAsync(RestApiClient apiClient,
|
||||
Uri uri,
|
||||
HttpMethod method,
|
||||
CancellationToken cancellationToken,
|
||||
Dictionary<string, object>? parameters = null,
|
||||
bool signed = false,
|
||||
HttpMethodParameterPosition? parameterPosition = null,
|
||||
ArrayParametersSerialization? arraySerialization = null,
|
||||
int requestWeight = 1,
|
||||
JsonSerializer? deserializer = null,
|
||||
Dictionary<string, string>? additionalHeaders = null,
|
||||
bool ignoreRatelimit = false)
|
||||
public virtual void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
var request = await PrepareRequestAsync(apiClient, uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false);
|
||||
if (!request)
|
||||
return new WebCallResult(request.Error!);
|
||||
|
||||
var result = await GetResponseAsync<object>(apiClient, request.Data, deserializer, cancellationToken, true).ConfigureAwait(false);
|
||||
return result.AsDataless();
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a request to the uri and deserialize the response into the provided type parameter
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize into</typeparam>
|
||||
/// <param name="apiClient">The API client the request is for</param>
|
||||
/// <param name="uri">The uri to send the request to</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <param name="parameters">The parameters of the request</param>
|
||||
/// <param name="signed">Whether or not the request should be authenticated</param>
|
||||
/// <param name="parameterPosition">Where the parameters should be placed, overwrites the value set in the client</param>
|
||||
/// <param name="arraySerialization">How array parameters should be serialized, overwrites the value set in the client</param>
|
||||
/// <param name="requestWeight">Credits used for the request</param>
|
||||
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
|
||||
/// <param name="additionalHeaders">Additional headers to send with the request</param>
|
||||
/// <param name="ignoreRatelimit">Ignore rate limits for this request</param>
|
||||
/// <returns></returns>
|
||||
[return: NotNull]
|
||||
protected virtual async Task<WebCallResult<T>> SendRequestAsync<T>(
|
||||
RestApiClient apiClient,
|
||||
Uri uri,
|
||||
HttpMethod method,
|
||||
CancellationToken cancellationToken,
|
||||
Dictionary<string, object>? parameters = null,
|
||||
bool signed = false,
|
||||
HttpMethodParameterPosition? parameterPosition = null,
|
||||
ArrayParametersSerialization? arraySerialization = null,
|
||||
int requestWeight = 1,
|
||||
JsonSerializer? deserializer = null,
|
||||
Dictionary<string, string>? additionalHeaders = null,
|
||||
bool ignoreRatelimit = false
|
||||
) where T : class
|
||||
{
|
||||
var request = await PrepareRequestAsync(apiClient, uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false);
|
||||
if (!request)
|
||||
return new WebCallResult<T>(request.Error!);
|
||||
|
||||
return await GetResponseAsync<T>(apiClient, request.Data, deserializer, cancellationToken, false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares a request to be sent to the server
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The API client the request is for</param>
|
||||
/// <param name="uri">The uri to send the request to</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <param name="parameters">The parameters of the request</param>
|
||||
/// <param name="signed">Whether or not the request should be authenticated</param>
|
||||
/// <param name="parameterPosition">Where the parameters should be placed, overwrites the value set in the client</param>
|
||||
/// <param name="arraySerialization">How array parameters should be serialized, overwrites the value set in the client</param>
|
||||
/// <param name="requestWeight">Credits used for the request</param>
|
||||
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
|
||||
/// <param name="additionalHeaders">Additional headers to send with the request</param>
|
||||
/// <param name="ignoreRatelimit">Ignore rate limits for this request</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<IRequest>> PrepareRequestAsync(RestApiClient apiClient,
|
||||
Uri uri,
|
||||
HttpMethod method,
|
||||
CancellationToken cancellationToken,
|
||||
Dictionary<string, object>? parameters = null,
|
||||
bool signed = false,
|
||||
HttpMethodParameterPosition? parameterPosition = null,
|
||||
ArrayParametersSerialization? arraySerialization = null,
|
||||
int requestWeight = 1,
|
||||
JsonSerializer? deserializer = null,
|
||||
Dictionary<string, string>? additionalHeaders = null,
|
||||
bool ignoreRatelimit = false)
|
||||
{
|
||||
var requestId = NextId();
|
||||
|
||||
if (signed)
|
||||
{
|
||||
var syncTask = apiClient.SyncTimeAsync();
|
||||
var timeSyncInfo = apiClient.GetTimeSyncInfo();
|
||||
if (timeSyncInfo.TimeSyncState.LastSyncTime == default)
|
||||
{
|
||||
// Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue
|
||||
var syncTimeResult = await syncTask.ConfigureAwait(false);
|
||||
if (!syncTimeResult)
|
||||
{
|
||||
log.Write(LogLevel.Debug, $"[{requestId}] Failed to sync time, aborting request: " + syncTimeResult.Error);
|
||||
return syncTimeResult.As<IRequest>(default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ignoreRatelimit)
|
||||
{
|
||||
foreach (var limiter in apiClient.RateLimiters)
|
||||
{
|
||||
var limitResult = await limiter.LimitRequestAsync(log, uri.AbsolutePath, method, signed, apiClient.Options.ApiCredentials?.Key, apiClient.Options.RateLimitingBehaviour, requestWeight, cancellationToken).ConfigureAwait(false);
|
||||
if (!limitResult.Success)
|
||||
return new CallResult<IRequest>(limitResult.Error!);
|
||||
}
|
||||
}
|
||||
|
||||
if (signed && apiClient.AuthenticationProvider == null)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"[{requestId}] Request {uri.AbsolutePath} failed because no ApiCredentials were provided");
|
||||
return new CallResult<IRequest>(new NoApiCredentialsError());
|
||||
}
|
||||
|
||||
log.Write(LogLevel.Information, $"[{requestId}] Creating request for " + uri);
|
||||
var paramsPosition = parameterPosition ?? apiClient.ParameterPositions[method];
|
||||
var request = ConstructRequest(apiClient, uri, method, parameters, signed, paramsPosition, arraySerialization ?? apiClient.arraySerialization, requestId, additionalHeaders);
|
||||
|
||||
string? paramString = "";
|
||||
if (paramsPosition == HttpMethodParameterPosition.InBody)
|
||||
paramString = $" with request body '{request.Content}'";
|
||||
|
||||
var headers = request.GetHeaders();
|
||||
if (headers.Any())
|
||||
paramString += " with headers " + string.Join(", ", headers.Select(h => h.Key + $"=[{string.Join(",", h.Value)}]"));
|
||||
|
||||
apiClient.TotalRequestsMade++;
|
||||
log.Write(LogLevel.Trace, $"[{requestId}] Sending {method}{(signed ? " signed" : "")} request to {request.Uri}{paramString ?? " "}{(ClientOptions.Proxy == null ? "" : $" via proxy {ClientOptions.Proxy.Host}")}");
|
||||
return new CallResult<IRequest>(request);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Executes the request and returns the result deserialized into the type parameter class
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The client making the request</param>
|
||||
/// <param name="request">The request object to execute</param>
|
||||
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <param name="expectedEmptyResponse">If an empty response is expected</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<WebCallResult<T>> GetResponseAsync<T>(
|
||||
BaseApiClient apiClient,
|
||||
IRequest request,
|
||||
JsonSerializer? deserializer,
|
||||
CancellationToken cancellationToken,
|
||||
bool expectedEmptyResponse)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
var statusCode = response.StatusCode;
|
||||
var headers = response.ResponseHeaders;
|
||||
var responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
// If we have to manually parse error responses (can't rely on HttpStatusCode) we'll need to read the full
|
||||
// response before being able to deserialize it into the resulting type since we don't know if it an error response or data
|
||||
if (apiClient.manualParseError)
|
||||
{
|
||||
using var reader = new StreamReader(responseStream);
|
||||
var data = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
responseStream.Close();
|
||||
response.Close();
|
||||
log.Write(LogLevel.Debug, $"[{request.RequestId}] Response received in {sw.ElapsedMilliseconds}ms{(log.Level == LogLevel.Trace ? (": "+data): "")}");
|
||||
|
||||
if (!expectedEmptyResponse)
|
||||
{
|
||||
// Validate if it is valid json. Sometimes other data will be returned, 502 error html pages for example
|
||||
var parseResult = ValidateJson(data);
|
||||
if (!parseResult.Success)
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!);
|
||||
|
||||
// Let the library implementation see if it is an error response, and if so parse the error
|
||||
var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false);
|
||||
if (error != null)
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!);
|
||||
|
||||
// Not an error, so continue deserializing
|
||||
var deserializeResult = Deserialize<T>(parseResult.Data, deserializer, request.RequestId);
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), deserializeResult.Data, deserializeResult.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
{
|
||||
var parseResult = ValidateJson(data);
|
||||
if (!parseResult.Success)
|
||||
// Not empty, and not json
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!);
|
||||
|
||||
var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false);
|
||||
if (error != null)
|
||||
// Error response
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!);
|
||||
}
|
||||
|
||||
// Empty success response; okay
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, default);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (expectedEmptyResponse)
|
||||
{
|
||||
// We expected an empty response and the request is successful and don't manually parse errors, so assume it's correct
|
||||
responseStream.Close();
|
||||
response.Close();
|
||||
|
||||
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, null);
|
||||
}
|
||||
|
||||
// Success status code, and we don't have to check for errors. Continue deserializing directly from the stream
|
||||
var desResult = await DeserializeAsync<T>(responseStream, deserializer, request.RequestId, sw.ElapsedMilliseconds).ConfigureAwait(false);
|
||||
responseStream.Close();
|
||||
response.Close();
|
||||
|
||||
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, ClientOptions.OutputOriginalData ? desResult.OriginalData : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), desResult.Data, desResult.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Http status code indicates error
|
||||
using var reader = new StreamReader(responseStream);
|
||||
var data = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
log.Write(LogLevel.Warning, $"[{request.RequestId}] Error received in {sw.ElapsedMilliseconds}ms: {data}");
|
||||
responseStream.Close();
|
||||
response.Close();
|
||||
var parseResult = ValidateJson(data);
|
||||
var error = parseResult.Success ? ParseErrorResponse(parseResult.Data) : new ServerError(data)!;
|
||||
if(error.Code == null || error.Code == 0)
|
||||
error.Code = (int)response.StatusCode;
|
||||
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, data, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error);
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException requestException)
|
||||
{
|
||||
// Request exception, can't reach server for instance
|
||||
var exceptionInfo = requestException.ToLogString();
|
||||
log.Write(LogLevel.Warning, $"[{request.RequestId}] Request exception: " + exceptionInfo);
|
||||
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError(exceptionInfo));
|
||||
}
|
||||
catch (OperationCanceledException canceledException)
|
||||
{
|
||||
if (cancellationToken != default && canceledException.CancellationToken == cancellationToken)
|
||||
{
|
||||
// Cancellation token canceled by caller
|
||||
log.Write(LogLevel.Warning, $"[{request.RequestId}] Request canceled by cancellation token");
|
||||
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new CancellationRequestedError());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Request timed out
|
||||
log.Write(LogLevel.Warning, $"[{request.RequestId}] Request timed out: " + canceledException.ToLogString());
|
||||
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError($"[{request.RequestId}] Request timed out"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error.
|
||||
/// When setting manualParseError to true this method will be called for each response to be able to check if the response is an error or not.
|
||||
/// If the response is an error this method should return the parsed error, else it should return null
|
||||
/// </summary>
|
||||
/// <param name="data">Received data</param>
|
||||
/// <returns>Null if not an error, Error otherwise</returns>
|
||||
protected virtual Task<ServerError?> TryParseErrorAsync(JToken data)
|
||||
{
|
||||
return Task.FromResult<ServerError?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a request object
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The API client the request is for</param>
|
||||
/// <param name="uri">The uri to send the request to</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="parameters">The parameters of the request</param>
|
||||
/// <param name="signed">Whether or not the request should be authenticated</param>
|
||||
/// <param name="parameterPosition">Where the parameters should be placed</param>
|
||||
/// <param name="arraySerialization">How array parameters should be serialized</param>
|
||||
/// <param name="requestId">Unique id of a request</param>
|
||||
/// <param name="additionalHeaders">Additional headers to send with the request</param>
|
||||
/// <returns></returns>
|
||||
protected virtual IRequest ConstructRequest(
|
||||
RestApiClient apiClient,
|
||||
Uri uri,
|
||||
HttpMethod method,
|
||||
Dictionary<string, object>? parameters,
|
||||
bool signed,
|
||||
HttpMethodParameterPosition parameterPosition,
|
||||
ArrayParametersSerialization arraySerialization,
|
||||
int requestId,
|
||||
Dictionary<string, string>? additionalHeaders)
|
||||
{
|
||||
parameters ??= new Dictionary<string, object>();
|
||||
|
||||
for (var i = 0; i< parameters.Count; i++)
|
||||
{
|
||||
var kvp = parameters.ElementAt(i);
|
||||
if (kvp.Value is Func<object> delegateValue)
|
||||
parameters[kvp.Key] = delegateValue();
|
||||
}
|
||||
|
||||
if (parameterPosition == HttpMethodParameterPosition.InUri)
|
||||
{
|
||||
foreach (var parameter in parameters)
|
||||
uri = uri.AddQueryParmeter(parameter.Key, parameter.Value.ToString());
|
||||
}
|
||||
|
||||
var headers = new Dictionary<string, string>();
|
||||
var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
|
||||
var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
|
||||
if (apiClient.AuthenticationProvider != null)
|
||||
apiClient.AuthenticationProvider.AuthenticateRequest(
|
||||
apiClient,
|
||||
uri,
|
||||
method,
|
||||
parameters,
|
||||
signed,
|
||||
arraySerialization,
|
||||
parameterPosition,
|
||||
out uriParameters,
|
||||
out bodyParameters,
|
||||
out headers);
|
||||
|
||||
// Sanity check
|
||||
foreach(var param in parameters)
|
||||
{
|
||||
if (!uriParameters.ContainsKey(param.Key) && !bodyParameters.ContainsKey(param.Key))
|
||||
throw new Exception($"Missing parameter {param.Key} after authentication processing. AuthenticationProvider implementation " +
|
||||
$"should return provided parameters in either the uri or body parameters output");
|
||||
}
|
||||
|
||||
// Add the auth parameters to the uri, start with a new URI to be able to sort the parameters including the auth parameters
|
||||
uri = uri.SetParameters(uriParameters, arraySerialization);
|
||||
|
||||
var request = RequestFactory.Create(method, uri, requestId);
|
||||
request.Accept = Constants.JsonContentHeader;
|
||||
|
||||
foreach (var header in headers)
|
||||
request.AddHeader(header.Key, header.Value);
|
||||
|
||||
if (additionalHeaders != null)
|
||||
{
|
||||
foreach (var header in additionalHeaders)
|
||||
request.AddHeader(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (StandardRequestHeaders != null)
|
||||
{
|
||||
foreach (var header in StandardRequestHeaders)
|
||||
// Only add it if it isn't overwritten
|
||||
if (additionalHeaders?.ContainsKey(header.Key) != true)
|
||||
request.AddHeader(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (parameterPosition == HttpMethodParameterPosition.InBody)
|
||||
{
|
||||
var contentType = apiClient.requestBodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader;
|
||||
if (bodyParameters.Any())
|
||||
WriteParamBody(apiClient, request, bodyParameters, contentType);
|
||||
else
|
||||
request.SetContent(apiClient.requestBodyEmptyContent, contentType);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the parameters of the request to the request object body
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The client making the request</param>
|
||||
/// <param name="request">The request to set the parameters on</param>
|
||||
/// <param name="parameters">The parameters to set</param>
|
||||
/// <param name="contentType">The content type of the data</param>
|
||||
protected virtual void WriteParamBody(BaseApiClient apiClient, IRequest request, SortedDictionary<string, object> parameters, string contentType)
|
||||
{
|
||||
if (apiClient.requestBodyFormat == RequestBodyFormat.Json)
|
||||
{
|
||||
// Write the parameters as json in the body
|
||||
var stringData = JsonConvert.SerializeObject(parameters);
|
||||
request.SetContent(stringData, contentType);
|
||||
}
|
||||
else if (apiClient.requestBodyFormat == RequestBodyFormat.FormData)
|
||||
{
|
||||
// Write the parameters as form data in the body
|
||||
var stringData = parameters.ToFormData();
|
||||
request.SetContent(stringData, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an error response from the server. Only used when server returns a status other than Success(200)
|
||||
/// </summary>
|
||||
/// <param name="error">The string the request returned</param>
|
||||
/// <returns></returns>
|
||||
protected virtual Error ParseErrorResponse(JToken error)
|
||||
{
|
||||
return new ServerError(error.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,704 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Logging.Extensions;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace CryptoExchange.Net
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <summary>
|
||||
/// Base for socket client implementations
|
||||
/// </summary>
|
||||
public abstract class BaseSocketClient: BaseClient, ISocketClient
|
||||
public abstract class BaseSocketClient : BaseClient, ISocketClient
|
||||
{
|
||||
#region fields
|
||||
/// <summary>
|
||||
/// The factory for creating sockets. Used for unit testing
|
||||
/// </summary>
|
||||
public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory();
|
||||
|
||||
/// <summary>
|
||||
/// List of socket connections currently connecting/connected
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
protected internal ConcurrentDictionary<int, SocketConnection> socketConnections = new();
|
||||
/// <summary>
|
||||
/// Semaphore used while creating sockets
|
||||
/// </summary>
|
||||
protected internal readonly SemaphoreSlim semaphoreSlim = new(1);
|
||||
/// <summary>
|
||||
/// Keep alive interval for websocket connection
|
||||
/// </summary>
|
||||
protected TimeSpan KeepAliveInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
/// <summary>
|
||||
/// Delegate used for processing byte data received from socket connections before it is processed by handlers
|
||||
/// </summary>
|
||||
protected Func<byte[], string>? dataInterpreterBytes;
|
||||
/// <summary>
|
||||
/// Delegate used for processing string data received from socket connections before it is processed by handlers
|
||||
/// </summary>
|
||||
protected Func<string, string>? dataInterpreterString;
|
||||
/// <summary>
|
||||
/// Handlers for data from the socket which doesn't need to be forwarded to the caller. Ping or welcome messages for example.
|
||||
/// </summary>
|
||||
protected Dictionary<string, Action<MessageEvent>> genericHandlers = new();
|
||||
/// <summary>
|
||||
/// The task that is sending periodic data on the websocket. Can be used for sending Ping messages every x seconds or similair. Not necesarry.
|
||||
/// </summary>
|
||||
protected Task? periodicTask;
|
||||
/// <summary>
|
||||
/// Wait event for the periodicTask
|
||||
/// </summary>
|
||||
protected AsyncResetEvent? periodicEvent;
|
||||
internal new List<SocketApiClient> ApiClients => base.ApiClients.OfType<SocketApiClient>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// If client is disposing
|
||||
/// </summary>
|
||||
protected bool disposing;
|
||||
|
||||
/// <summary>
|
||||
/// If true; data which is a response to a query will also be distributed to subscriptions
|
||||
/// If false; data which is a response to a query won't get forwarded to subscriptions as well
|
||||
/// </summary>
|
||||
protected internal bool ContinueOnQueryResponse { get; protected set; }
|
||||
|
||||
/// <summary>
|
||||
/// If a message is received on the socket which is not handled by a handler this boolean determines whether this logs an error message
|
||||
/// </summary>
|
||||
protected internal bool UnhandledMessageExpected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The max amount of outgoing messages per socket per second
|
||||
/// </summary>
|
||||
protected internal int? RateLimitPerSocketPerSecond { get; set; }
|
||||
protected bool _disposing;
|
||||
|
||||
/// <inheritdoc />
|
||||
public double IncomingKbps
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!socketConnections.Any())
|
||||
return 0;
|
||||
|
||||
return socketConnections.Sum(s => s.Value.IncomingKbps);
|
||||
}
|
||||
}
|
||||
public int CurrentConnections => ApiClients.OfType<SocketApiClient>().Sum(c => c.CurrentConnections);
|
||||
/// <inheritdoc />
|
||||
public int CurrentSubscriptions => ApiClients.OfType<SocketApiClient>().Sum(s => s.CurrentSubscriptions);
|
||||
/// <inheritdoc />
|
||||
public double IncomingKbps => ApiClients.OfType<SocketApiClient>().Sum(s => s.IncomingKbps);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CurrentConnections => socketConnections.Count;
|
||||
/// <inheritdoc />
|
||||
public int CurrentSubscriptions
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!socketConnections.Any())
|
||||
return 0;
|
||||
|
||||
return socketConnections.Sum(s => s.Value.SubscriptionCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client options
|
||||
/// </summary>
|
||||
public new BaseSocketClientOptions ClientOptions { get; }
|
||||
|
||||
public new SocketExchangeOptions ClientOptions => (SocketExchangeOptions)base.ClientOptions;
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the API this client is for</param>
|
||||
/// <param name="options">The options for this client</param>
|
||||
protected BaseSocketClient(string name, BaseSocketClientOptions options) : base(name, options)
|
||||
/// <param name="loggerFactory">Logger factory</param>
|
||||
/// <param name="name">The name of the exchange this client is for</param>
|
||||
protected BaseSocketClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
ClientOptions = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
_logger = loggerFactory?.CreateLogger(name + ".SocketClient") ?? NullLoggerFactory.Instance.CreateLogger(name);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetApiCredentials(ApiCredentials credentials)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetApiCredentials(credentials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a delegate to be used for processing data received from socket connections before it is processed by handlers
|
||||
/// </summary>
|
||||
/// <param name="byteHandler">Handler for byte data</param>
|
||||
/// <param name="stringHandler">Handler for string data</param>
|
||||
protected void SetDataInterpreter(Func<byte[], string>? byteHandler, Func<string, string>? stringHandler)
|
||||
{
|
||||
dataInterpreterBytes = byteHandler;
|
||||
dataInterpreterString = stringHandler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect to an url and listen for data on the BaseAddress
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the expected data</typeparam>
|
||||
/// <param name="apiClient">The API client the subscription is for</param>
|
||||
/// <param name="request">The optional request object to send, will be serialized to json</param>
|
||||
/// <param name="identifier">The identifier to use, necessary if no request object is sent</param>
|
||||
/// <param name="authenticated">If the subscription is to an authenticated endpoint</param>
|
||||
/// <param name="dataHandler">The handler of update data</param>
|
||||
/// <param name="ct">Cancellation token for closing this subscription</param>
|
||||
/// <returns></returns>
|
||||
protected virtual Task<CallResult<UpdateSubscription>> SubscribeAsync<T>(SocketApiClient apiClient, object? request, string? identifier, bool authenticated, Action<DataEvent<T>> dataHandler, CancellationToken ct)
|
||||
{
|
||||
return SubscribeAsync(apiClient, apiClient.Options.BaseAddress, request, identifier, authenticated, dataHandler, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect to an url and listen for data
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the expected data</typeparam>
|
||||
/// <param name="apiClient">The API client the subscription is for</param>
|
||||
/// <param name="url">The URL to connect to</param>
|
||||
/// <param name="request">The optional request object to send, will be serialized to json</param>
|
||||
/// <param name="identifier">The identifier to use, necessary if no request object is sent</param>
|
||||
/// <param name="authenticated">If the subscription is to an authenticated endpoint</param>
|
||||
/// <param name="dataHandler">The handler of update data</param>
|
||||
/// <param name="ct">Cancellation token for closing this subscription</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<UpdateSubscription>> SubscribeAsync<T>(SocketApiClient apiClient, string url, object? request, string? identifier, bool authenticated, Action<DataEvent<T>> dataHandler, CancellationToken ct)
|
||||
{
|
||||
if (disposing)
|
||||
return new CallResult<UpdateSubscription>(new InvalidOperationError("Client disposed, can't subscribe"));
|
||||
|
||||
SocketConnection socketConnection;
|
||||
SocketSubscription? subscription;
|
||||
var released = false;
|
||||
// Wait for a semaphore here, so we only connect 1 socket at a time.
|
||||
// This is necessary for being able to see if connections can be combined
|
||||
try
|
||||
{
|
||||
await semaphoreSlim.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new CallResult<UpdateSubscription>(new CancellationRequestedError());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
// Get a new or existing socket connection
|
||||
var socketResult = await GetSocketConnection(apiClient, url, authenticated).ConfigureAwait(false);
|
||||
if(!socketResult)
|
||||
return socketResult.As<UpdateSubscription>(null);
|
||||
|
||||
socketConnection = socketResult.Data;
|
||||
|
||||
// Add a subscription on the socket connection
|
||||
subscription = AddSubscription(request, identifier, true, socketConnection, dataHandler, authenticated);
|
||||
if (subscription == null)
|
||||
{
|
||||
log.Write(LogLevel.Trace, $"Socket {socketConnection.SocketId} failed to add subscription, retrying on different connection");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ClientOptions.SocketSubscriptionsCombineTarget == 1)
|
||||
{
|
||||
// Only 1 subscription per connection, so no need to wait for connection since a new subscription will create a new connection anyway
|
||||
semaphoreSlim.Release();
|
||||
released = true;
|
||||
}
|
||||
|
||||
var needsConnecting = !socketConnection.Connected;
|
||||
|
||||
var connectResult = await ConnectIfNeededAsync(socketConnection, authenticated).ConfigureAwait(false);
|
||||
if (!connectResult)
|
||||
return new CallResult<UpdateSubscription>(connectResult.Error!);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!released)
|
||||
semaphoreSlim.Release();
|
||||
}
|
||||
|
||||
if (socketConnection.PausedActivity)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} has been paused, can't subscribe at this moment");
|
||||
return new CallResult<UpdateSubscription>( new ServerError("Socket is paused"));
|
||||
}
|
||||
|
||||
if (request != null)
|
||||
{
|
||||
// Send the request and wait for answer
|
||||
var subResult = await SubscribeAndWaitAsync(socketConnection, request, subscription).ConfigureAwait(false);
|
||||
if (!subResult)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} failed to subscribe: {subResult.Error}");
|
||||
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
|
||||
return new CallResult<UpdateSubscription>(subResult.Error!);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No request to be sent, so just mark the subscription as comfirmed
|
||||
subscription.Confirmed = true;
|
||||
}
|
||||
|
||||
if (ct != default)
|
||||
{
|
||||
subscription.CancellationTokenRegistration = ct.Register(async () =>
|
||||
{
|
||||
log.Write(LogLevel.Information, $"Socket {socketConnection.SocketId} Cancellation token set, closing subscription");
|
||||
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
|
||||
}, false);
|
||||
}
|
||||
|
||||
log.Write(LogLevel.Information, $"Socket {socketConnection.SocketId} subscription {subscription.Id} completed successfully");
|
||||
return new CallResult<UpdateSubscription>(new UpdateSubscription(socketConnection, subscription));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the subscribe request and waits for a response to that request
|
||||
/// </summary>
|
||||
/// <param name="socketConnection">The connection to send the request on</param>
|
||||
/// <param name="request">The request to send, will be serialized to json</param>
|
||||
/// <param name="subscription">The subscription the request is for</param>
|
||||
/// <returns></returns>
|
||||
protected internal virtual async Task<CallResult<bool>> SubscribeAndWaitAsync(SocketConnection socketConnection, object request, SocketSubscription subscription)
|
||||
{
|
||||
CallResult<object>? callResult = null;
|
||||
await socketConnection.SendAndWaitAsync(request, ClientOptions.SocketResponseTimeout, data => HandleSubscriptionResponse(socketConnection, subscription, request, data, out callResult)).ConfigureAwait(false);
|
||||
|
||||
if (callResult?.Success == true)
|
||||
{
|
||||
subscription.Confirmed = true;
|
||||
return new CallResult<bool>(true);
|
||||
}
|
||||
|
||||
if(callResult== null)
|
||||
return new CallResult<bool>(new ServerError("No response on subscription request received"));
|
||||
|
||||
return new CallResult<bool>(callResult.Error!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a query on a socket connection to the BaseAddress and wait for the response
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Expected result type</typeparam>
|
||||
/// <param name="apiClient">The API client the query is for</param>
|
||||
/// <param name="request">The request to send, will be serialized to json</param>
|
||||
/// <param name="authenticated">If the query is to an authenticated endpoint</param>
|
||||
/// <returns></returns>
|
||||
protected virtual Task<CallResult<T>> QueryAsync<T>(SocketApiClient apiClient, object request, bool authenticated)
|
||||
{
|
||||
return QueryAsync<T>(apiClient, apiClient.Options.BaseAddress, request, authenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a query on a socket connection and wait for the response
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The expected result type</typeparam>
|
||||
/// <param name="apiClient">The API client the query is for</param>
|
||||
/// <param name="url">The url for the request</param>
|
||||
/// <param name="request">The request to send</param>
|
||||
/// <param name="authenticated">Whether the socket should be authenticated</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<T>> QueryAsync<T>(SocketApiClient apiClient, string url, object request, bool authenticated)
|
||||
{
|
||||
if (disposing)
|
||||
return new CallResult<T>(new InvalidOperationError("Client disposed, can't query"));
|
||||
|
||||
SocketConnection socketConnection;
|
||||
var released = false;
|
||||
await semaphoreSlim.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var socketResult = await GetSocketConnection(apiClient, url, authenticated).ConfigureAwait(false);
|
||||
if (!socketResult)
|
||||
return socketResult.As<T>(default);
|
||||
|
||||
socketConnection = socketResult.Data;
|
||||
|
||||
if (ClientOptions.SocketSubscriptionsCombineTarget == 1)
|
||||
{
|
||||
// Can release early when only a single sub per connection
|
||||
semaphoreSlim.Release();
|
||||
released = true;
|
||||
}
|
||||
|
||||
var connectResult = await ConnectIfNeededAsync(socketConnection, authenticated).ConfigureAwait(false);
|
||||
if (!connectResult)
|
||||
return new CallResult<T>(connectResult.Error!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!released)
|
||||
semaphoreSlim.Release();
|
||||
}
|
||||
|
||||
if (socketConnection.PausedActivity)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} has been paused, can't send query at this moment");
|
||||
return new CallResult<T>(new ServerError("Socket is paused"));
|
||||
}
|
||||
|
||||
return await QueryAndWaitAsync<T>(socketConnection, request).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the query request and waits for the result
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The expected result type</typeparam>
|
||||
/// <param name="socket">The connection to send and wait on</param>
|
||||
/// <param name="request">The request to send</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<T>> QueryAndWaitAsync<T>(SocketConnection socket, object request)
|
||||
{
|
||||
var dataResult = new CallResult<T>(new ServerError("No response on query received"));
|
||||
await socket.SendAndWaitAsync(request, ClientOptions.SocketResponseTimeout, data =>
|
||||
{
|
||||
if (!HandleQueryResponse<T>(socket, request, data, out var callResult))
|
||||
return false;
|
||||
|
||||
dataResult = callResult;
|
||||
return true;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return dataResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a socket needs to be connected and does so if needed. Also authenticates on the socket if needed
|
||||
/// </summary>
|
||||
/// <param name="socket">The connection to check</param>
|
||||
/// <param name="authenticated">Whether the socket should authenticated</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<bool>> ConnectIfNeededAsync(SocketConnection socket, bool authenticated)
|
||||
{
|
||||
if (socket.Connected)
|
||||
return new CallResult<bool>(true);
|
||||
|
||||
var connectResult = await ConnectSocketAsync(socket).ConfigureAwait(false);
|
||||
if (!connectResult)
|
||||
return new CallResult<bool>(connectResult.Error!);
|
||||
|
||||
if (!authenticated || socket.Authenticated)
|
||||
return new CallResult<bool>(true);
|
||||
|
||||
log.Write(LogLevel.Debug, $"Attempting to authenticate {socket.SocketId}");
|
||||
var result = await AuthenticateSocketAsync(socket).ConfigureAwait(false);
|
||||
if (!result)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"Socket {socket.SocketId} authentication failed");
|
||||
if(socket.Connected)
|
||||
await socket.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
result.Error!.Message = "Authentication failed: " + result.Error.Message;
|
||||
return new CallResult<bool>(result.Error);
|
||||
}
|
||||
|
||||
socket.Authenticated = true;
|
||||
return new CallResult<bool>(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The socketConnection received data (the data JToken parameter). The implementation of this method should check if the received data is a response to the query that was send (the request parameter).
|
||||
/// For example; A query is sent in a request message with an Id parameter with value 10. The socket receives data and calls this method to see if the data it received is an
|
||||
/// anwser to any query that was done. The implementation of this method should check if the response.Id == request.Id to see if they match (assuming the api has some sort of Id tracking on messages,
|
||||
/// if not some other method has be implemented to match the messages).
|
||||
/// If the messages match, the callResult out parameter should be set with the deserialized data in the from of (T) and return true.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of response that is expected on the query</typeparam>
|
||||
/// <param name="socketConnection">The socket connection</param>
|
||||
/// <param name="request">The request that a response is awaited for</param>
|
||||
/// <param name="data">The message received from the server</param>
|
||||
/// <param name="callResult">The interpretation (null if message wasn't a response to the request)</param>
|
||||
/// <returns>True if the message was a response to the query</returns>
|
||||
protected internal abstract bool HandleQueryResponse<T>(SocketConnection socketConnection, object request, JToken data, [NotNullWhen(true)]out CallResult<T>? callResult);
|
||||
/// <summary>
|
||||
/// The socketConnection received data (the data JToken parameter). The implementation of this method should check if the received data is a response to the subscription request that was send (the request parameter).
|
||||
/// For example; A subscribe request message is send with an Id parameter with value 10. The socket receives data and calls this method to see if the data it received is an
|
||||
/// anwser to any subscription request that was done. The implementation of this method should check if the response.Id == request.Id to see if they match (assuming the api has some sort of Id tracking on messages,
|
||||
/// if not some other method has be implemented to match the messages).
|
||||
/// If the messages match, the callResult out parameter should be set with the deserialized data in the from of (T) and return true.
|
||||
/// </summary>
|
||||
/// <param name="socketConnection">The socket connection</param>
|
||||
/// <param name="subscription">A subscription that waiting for a subscription response</param>
|
||||
/// <param name="request">The request that the subscription sent</param>
|
||||
/// <param name="data">The message received from the server</param>
|
||||
/// <param name="callResult">The interpretation (null if message wasn't a response to the request)</param>
|
||||
/// <returns>True if the message was a response to the subscription request</returns>
|
||||
protected internal abstract bool HandleSubscriptionResponse(SocketConnection socketConnection, SocketSubscription subscription, object request, JToken data, out CallResult<object>? callResult);
|
||||
/// <summary>
|
||||
/// Needs to check if a received message matches a handler by request. After subscribing data message will come in. These data messages need to be matched to a specific connection
|
||||
/// to pass the correct data to the correct handler. The implementation of this method should check if the message received matches the subscribe request that was sent.
|
||||
/// </summary>
|
||||
/// <param name="socketConnection">The socket connection the message was recieved on</param>
|
||||
/// <param name="message">The received data</param>
|
||||
/// <param name="request">The subscription request</param>
|
||||
/// <returns>True if the message is for the subscription which sent the request</returns>
|
||||
protected internal abstract bool MessageMatchesHandler(SocketConnection socketConnection, JToken message, object request);
|
||||
/// <summary>
|
||||
/// Needs to check if a received message matches a handler by identifier. Generally used by GenericHandlers. For example; a generic handler is registered which handles ping messages
|
||||
/// from the server. This method should check if the message received is a ping message and the identifer is the identifier of the GenericHandler
|
||||
/// </summary>
|
||||
/// <param name="socketConnection">The socket connection the message was recieved on</param>
|
||||
/// <param name="message">The received data</param>
|
||||
/// <param name="identifier">The string identifier of the handler</param>
|
||||
/// <returns>True if the message is for the handler which has the identifier</returns>
|
||||
protected internal abstract bool MessageMatchesHandler(SocketConnection socketConnection, JToken message, string identifier);
|
||||
/// <summary>
|
||||
/// Needs to authenticate the socket so authenticated queries/subscriptions can be made on this socket connection
|
||||
/// </summary>
|
||||
/// <param name="socketConnection">The socket connection that should be authenticated</param>
|
||||
/// <returns></returns>
|
||||
protected internal abstract Task<CallResult<bool>> AuthenticateSocketAsync(SocketConnection socketConnection);
|
||||
/// <summary>
|
||||
/// Needs to unsubscribe a subscription, typically by sending an unsubscribe request. If multiple subscriptions per socket is not allowed this can just return since the socket will be closed anyway
|
||||
/// </summary>
|
||||
/// <param name="connection">The connection on which to unsubscribe</param>
|
||||
/// <param name="subscriptionToUnsub">The subscription to unsubscribe</param>
|
||||
/// <returns></returns>
|
||||
protected internal abstract Task<bool> UnsubscribeAsync(SocketConnection connection, SocketSubscription subscriptionToUnsub);
|
||||
|
||||
/// <summary>
|
||||
/// Optional handler to interpolate data before sending it to the handlers
|
||||
/// </summary>
|
||||
/// <param name="message"></param>
|
||||
/// <returns></returns>
|
||||
protected internal virtual JToken ProcessTokenData(JToken message)
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a subscription to a connection
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of data the subscription expects</typeparam>
|
||||
/// <param name="request">The request of the subscription</param>
|
||||
/// <param name="identifier">The identifier of the subscription (can be null if request param is used)</param>
|
||||
/// <param name="userSubscription">Whether or not this is a user subscription (counts towards the max amount of handlers on a socket)</param>
|
||||
/// <param name="connection">The socket connection the handler is on</param>
|
||||
/// <param name="dataHandler">The handler of the data received</param>
|
||||
/// <param name="authenticated">Whether the subscription needs authentication</param>
|
||||
/// <returns></returns>
|
||||
protected virtual SocketSubscription? AddSubscription<T>(object? request, string? identifier, bool userSubscription, SocketConnection connection, Action<DataEvent<T>> dataHandler, bool authenticated)
|
||||
{
|
||||
void InternalHandler(MessageEvent messageEvent)
|
||||
{
|
||||
if (typeof(T) == typeof(string))
|
||||
{
|
||||
var stringData = (T)Convert.ChangeType(messageEvent.JsonData.ToString(), typeof(T));
|
||||
dataHandler(new DataEvent<T>(stringData, null, ClientOptions.OutputOriginalData ? messageEvent.OriginalData : null, messageEvent.ReceivedTimestamp));
|
||||
return;
|
||||
}
|
||||
|
||||
var desResult = Deserialize<T>(messageEvent.JsonData);
|
||||
if (!desResult)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"Socket {connection.SocketId} Failed to deserialize data into type {typeof(T)}: {desResult.Error}");
|
||||
return;
|
||||
}
|
||||
|
||||
dataHandler(new DataEvent<T>(desResult.Data, null, ClientOptions.OutputOriginalData ? messageEvent.OriginalData : null, messageEvent.ReceivedTimestamp));
|
||||
}
|
||||
|
||||
var subscription = request == null
|
||||
? SocketSubscription.CreateForIdentifier(NextId(), identifier!, userSubscription, authenticated, InternalHandler)
|
||||
: SocketSubscription.CreateForRequest(NextId(), request, userSubscription, authenticated, InternalHandler);
|
||||
if (!connection.AddSubscription(subscription))
|
||||
return null;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a generic message handler. Used for example to reply to ping requests
|
||||
/// </summary>
|
||||
/// <param name="identifier">The name of the request handler. Needs to be unique</param>
|
||||
/// <param name="action">The action to execute when receiving a message for this handler (checked by <see cref="MessageMatchesHandler(SocketConnection, Newtonsoft.Json.Linq.JToken,string)"/>)</param>
|
||||
protected void AddGenericHandler(string identifier, Action<MessageEvent> action)
|
||||
{
|
||||
genericHandlers.Add(identifier, action);
|
||||
var subscription = SocketSubscription.CreateForIdentifier(NextId(), identifier, false, false, action);
|
||||
foreach (var connection in socketConnections.Values)
|
||||
connection.AddSubscription(subscription);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the url to connect to (defaults to BaseAddress form the client options)
|
||||
/// </summary>
|
||||
/// <param name="apiClient"></param>
|
||||
/// <param name="address"></param>
|
||||
/// <param name="authentication"></param>
|
||||
/// <returns></returns>
|
||||
protected virtual Task<CallResult<string?>> GetConnectionUrlAsync(SocketApiClient apiClient, string address, bool authentication)
|
||||
{
|
||||
return Task.FromResult(new CallResult<string?>(address));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the url to reconnect to after losing a connection
|
||||
/// </summary>
|
||||
/// <param name="apiClient"></param>
|
||||
/// <param name="connection"></param>
|
||||
/// <returns></returns>
|
||||
public virtual Task<Uri?> GetReconnectUriAsync(SocketApiClient apiClient, SocketConnection connection)
|
||||
{
|
||||
return Task.FromResult<Uri?>(connection.ConnectionUri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a connection for a new subscription or query. Can be an existing if there are open position or a new one.
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The API client the connection is for</param>
|
||||
/// <param name="address">The address the socket is for</param>
|
||||
/// <param name="authenticated">Whether the socket should be authenticated</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<SocketConnection>> GetSocketConnection(SocketApiClient apiClient, string address, bool authenticated)
|
||||
{
|
||||
var socketResult = socketConnections.Where(s => (s.Value.Status == SocketConnection.SocketStatus.None || s.Value.Status == SocketConnection.SocketStatus.Connected)
|
||||
&& s.Value.Tag.TrimEnd('/') == address.TrimEnd('/')
|
||||
&& (s.Value.ApiClient.GetType() == apiClient.GetType())
|
||||
&& (s.Value.Authenticated == authenticated || !authenticated) && s.Value.Connected).OrderBy(s => s.Value.SubscriptionCount).FirstOrDefault();
|
||||
var result = socketResult.Equals(default(KeyValuePair<int, SocketConnection>)) ? null : socketResult.Value;
|
||||
if (result != null)
|
||||
{
|
||||
if (result.SubscriptionCount < ClientOptions.SocketSubscriptionsCombineTarget || (socketConnections.Count >= ClientOptions.MaxSocketConnections && socketConnections.All(s => s.Value.SubscriptionCount >= ClientOptions.SocketSubscriptionsCombineTarget)))
|
||||
{
|
||||
// Use existing socket if it has less than target connections OR it has the least connections and we can't make new
|
||||
return new CallResult<SocketConnection>(result);
|
||||
}
|
||||
}
|
||||
|
||||
var connectionAddress = await GetConnectionUrlAsync(apiClient, address, authenticated).ConfigureAwait(false);
|
||||
if (!connectionAddress)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"Failed to determine connection url: " + connectionAddress.Error);
|
||||
return connectionAddress.As<SocketConnection>(null);
|
||||
}
|
||||
|
||||
if (connectionAddress.Data != address)
|
||||
log.Write(LogLevel.Debug, $"Connection address set to " + connectionAddress.Data);
|
||||
|
||||
// Create new socket
|
||||
var socket = CreateSocket(connectionAddress.Data!);
|
||||
var socketConnection = new SocketConnection(this, apiClient, socket, address);
|
||||
socketConnection.UnhandledMessage += HandleUnhandledMessage;
|
||||
foreach (var kvp in genericHandlers)
|
||||
{
|
||||
var handler = SocketSubscription.CreateForIdentifier(NextId(), kvp.Key, false, false, kvp.Value);
|
||||
socketConnection.AddSubscription(handler);
|
||||
}
|
||||
|
||||
return new CallResult<SocketConnection>(socketConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process an unhandled message
|
||||
/// </summary>
|
||||
/// <param name="token">The token that wasn't processed</param>
|
||||
protected virtual void HandleUnhandledMessage(JToken token)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect a socket
|
||||
/// </summary>
|
||||
/// <param name="socketConnection">The socket to connect</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<bool>> ConnectSocketAsync(SocketConnection socketConnection)
|
||||
{
|
||||
if (await socketConnection.ConnectAsync().ConfigureAwait(false))
|
||||
{
|
||||
socketConnections.TryAdd(socketConnection.SocketId, socketConnection);
|
||||
return new CallResult<bool>(true);
|
||||
}
|
||||
|
||||
socketConnection.Dispose();
|
||||
return new CallResult<bool>(new CantConnectError());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get parameters for the websocket connection
|
||||
/// </summary>
|
||||
/// <param name="address">The address to connect to</param>
|
||||
/// <returns></returns>
|
||||
protected virtual WebSocketParameters GetWebSocketParameters(string address)
|
||||
=> new (new Uri(address), ClientOptions.AutoReconnect)
|
||||
{
|
||||
DataInterpreterBytes = dataInterpreterBytes,
|
||||
DataInterpreterString = dataInterpreterString,
|
||||
KeepAliveInterval = KeepAliveInterval,
|
||||
ReconnectInterval = ClientOptions.ReconnectInterval,
|
||||
RatelimitPerSecond = RateLimitPerSocketPerSecond,
|
||||
Proxy = ClientOptions.Proxy,
|
||||
Timeout = ClientOptions.SocketNoDataTimeout
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a socket for an address
|
||||
/// </summary>
|
||||
/// <param name="address">The address the socket should connect to</param>
|
||||
/// <returns></returns>
|
||||
protected virtual IWebsocket CreateSocket(string address)
|
||||
{
|
||||
var socket = SocketFactory.CreateWebsocket(log, GetWebSocketParameters(address));
|
||||
log.Write(LogLevel.Debug, $"Socket {socket.Id} new socket created for " + address);
|
||||
return socket;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodically sends data over a socket connection
|
||||
/// </summary>
|
||||
/// <param name="identifier">Identifier for the periodic send</param>
|
||||
/// <param name="interval">How often</param>
|
||||
/// <param name="objGetter">Method returning the object to send</param>
|
||||
public virtual void SendPeriodic(string identifier, TimeSpan interval, Func<SocketConnection, object> objGetter)
|
||||
{
|
||||
if (objGetter == null)
|
||||
throw new ArgumentNullException(nameof(objGetter));
|
||||
|
||||
periodicEvent = new AsyncResetEvent();
|
||||
periodicTask = Task.Run(async () =>
|
||||
{
|
||||
while (!disposing)
|
||||
{
|
||||
await periodicEvent.WaitAsync(interval).ConfigureAwait(false);
|
||||
if (disposing)
|
||||
break;
|
||||
|
||||
foreach (var socketConnection in socketConnections.Values)
|
||||
{
|
||||
if (disposing)
|
||||
break;
|
||||
|
||||
if (!socketConnection.Connected)
|
||||
continue;
|
||||
|
||||
var obj = objGetter(socketConnection);
|
||||
if (obj == null)
|
||||
continue;
|
||||
|
||||
log.Write(LogLevel.Trace, $"Socket {socketConnection.SocketId} sending periodic {identifier}");
|
||||
|
||||
try
|
||||
{
|
||||
socketConnection.Send(obj);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} Periodic send {identifier} failed: " + ex.ToLogString());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
LibraryHelpers.StaticLogger = loggerFactory?.CreateLogger("CryptoExchange");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -708,23 +61,12 @@ namespace CryptoExchange.Net
|
||||
/// <returns></returns>
|
||||
public virtual async Task UnsubscribeAsync(int subscriptionId)
|
||||
{
|
||||
SocketSubscription? subscription = null;
|
||||
SocketConnection? connection = null;
|
||||
foreach(var socket in socketConnections.Values.ToList())
|
||||
foreach (var socket in ApiClients.OfType<SocketApiClient>())
|
||||
{
|
||||
subscription = socket.GetSubscription(subscriptionId);
|
||||
if (subscription != null)
|
||||
{
|
||||
connection = socket;
|
||||
var result = await socket.UnsubscribeAsync(subscriptionId).ConfigureAwait(false);
|
||||
if (result)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (subscription == null || connection == null)
|
||||
return;
|
||||
|
||||
log.Write(LogLevel.Information, $"Socket {connection.SocketId} Unsubscribing subscription " + subscriptionId);
|
||||
await connection.CloseAsync(subscription).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -737,7 +79,7 @@ namespace CryptoExchange.Net
|
||||
if (subscription == null)
|
||||
throw new ArgumentNullException(nameof(subscription));
|
||||
|
||||
log.Write(LogLevel.Information, $"Socket {subscription.SocketId} Unsubscribing subscription " + subscription.Id);
|
||||
_logger.UnsubscribingSubscription(subscription.SocketId, subscription.Id);
|
||||
await subscription.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -747,13 +89,9 @@ namespace CryptoExchange.Net
|
||||
/// <returns></returns>
|
||||
public virtual async Task UnsubscribeAllAsync()
|
||||
{
|
||||
log.Write(LogLevel.Information, $"Unsubscribing all {socketConnections.Sum(s => s.Value.SubscriptionCount)} subscriptions");
|
||||
var tasks = new List<Task>();
|
||||
{
|
||||
var socketList = socketConnections.Values;
|
||||
foreach (var sub in socketList)
|
||||
tasks.Add(sub.CloseAsync());
|
||||
}
|
||||
foreach (var client in ApiClients.OfType<SocketApiClient>())
|
||||
tasks.Add(client.UnsubscribeAllAsync());
|
||||
|
||||
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
|
||||
}
|
||||
@@ -764,12 +102,11 @@ namespace CryptoExchange.Net
|
||||
/// <returns></returns>
|
||||
public virtual async Task ReconnectAsync()
|
||||
{
|
||||
log.Write(LogLevel.Information, $"Reconnecting all {socketConnections.Count} connections");
|
||||
_logger.ReconnectingAllConnections(CurrentConnections);
|
||||
var tasks = new List<Task>();
|
||||
foreach (var client in ApiClients.OfType<SocketApiClient>())
|
||||
{
|
||||
var socketList = socketConnections.Values;
|
||||
foreach (var sub in socketList)
|
||||
tasks.Add(sub.TriggerReconnectAsync());
|
||||
tasks.Add(client.ReconnectAsync());
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
|
||||
@@ -780,32 +117,81 @@ namespace CryptoExchange.Net
|
||||
/// </summary>
|
||||
public string GetSubscriptionsState()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"{socketConnections.Count} connections, {CurrentSubscriptions} subscriptions, kbps: {IncomingKbps}");
|
||||
foreach(var connection in socketConnections)
|
||||
var result = new StringBuilder();
|
||||
foreach (var client in ApiClients.OfType<SocketApiClient>().Where(c => c.CurrentSubscriptions > 0))
|
||||
{
|
||||
sb.AppendLine($" Connection {connection.Key}: {connection.Value.SubscriptionCount} subscriptions, status: {connection.Value.Status}, authenticated: {connection.Value.Authenticated}, kbps: {connection.Value.IncomingKbps}");
|
||||
foreach (var subscription in connection.Value.Subscriptions)
|
||||
sb.AppendLine($" Subscription {subscription.Id}, authenticated: {subscription.Authenticated}, confirmed: {subscription.Confirmed}");
|
||||
result.AppendLine(client.GetSubscriptionsState());
|
||||
}
|
||||
return sb.ToString();
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose the client
|
||||
/// Returns the state of all socket api clients
|
||||
/// </summary>
|
||||
public override void Dispose()
|
||||
/// <returns></returns>
|
||||
public List<SocketApiClient.SocketApiClientState> GetSocketApiClientStates()
|
||||
{
|
||||
disposing = true;
|
||||
periodicEvent?.Set();
|
||||
periodicEvent?.Dispose();
|
||||
if (socketConnections.Sum(s => s.Value.SubscriptionCount) > 0)
|
||||
var result = new List<SocketApiClient.SocketApiClientState>();
|
||||
foreach (var client in ApiClients.OfType<SocketApiClient>())
|
||||
{
|
||||
log.Write(LogLevel.Debug, "Disposing socket client, closing all subscriptions");
|
||||
_ = UnsubscribeAllAsync();
|
||||
result.Add(client.GetState());
|
||||
}
|
||||
semaphoreSlim?.Dispose();
|
||||
base.Dispose();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update options
|
||||
/// </summary>
|
||||
public virtual void SetOptions(UpdateOptions options)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class BaseSocketClient<TEnvironment, TApiCredentials> : BaseSocketClient, ISocketClient<TApiCredentials>
|
||||
where TEnvironment : TradeEnvironment
|
||||
where TApiCredentials : ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
internal new List<SocketApiClient<TEnvironment, TApiCredentials>> ApiClients => base.ApiClients.OfType<SocketApiClient<TEnvironment, TApiCredentials>>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Provided client options
|
||||
/// </summary>
|
||||
public new SocketExchangeOptions<TEnvironment, TApiCredentials> ClientOptions => (SocketExchangeOptions<TEnvironment, TApiCredentials>)base.ClientOptions;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory</param>
|
||||
/// <param name="name">The name of the API this client is for</param>
|
||||
protected BaseSocketClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
|
||||
/// </summary>
|
||||
/// <param name="credentials">The credentials to set</param>
|
||||
public virtual void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetApiCredentials(credentials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update options
|
||||
/// </summary>
|
||||
public virtual void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public abstract class UserClientProvider<TRestClient, TSocketClient, TRestOptions, TSocketOptions, TCredentials, TEnvironment>
|
||||
where TRestClient : IRestClient<TCredentials>
|
||||
where TSocketClient : ISocketClient<TCredentials>
|
||||
where TRestOptions : RestExchangeOptions<TEnvironment, TCredentials>, new()
|
||||
where TSocketOptions : SocketExchangeOptions<TEnvironment, TCredentials>, new()
|
||||
where TCredentials : ApiCredentials
|
||||
where TEnvironment : TradeEnvironment
|
||||
{
|
||||
private ConcurrentDictionary<string, TRestClient> _restClients = new ConcurrentDictionary<string, TRestClient>();
|
||||
private ConcurrentDictionary<string, TSocketClient> _socketClients = new ConcurrentDictionary<string, TSocketClient>();
|
||||
|
||||
private readonly IOptions<TRestOptions> _restOptions;
|
||||
private readonly IOptions<TSocketOptions> _socketOptions;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string ExchangeName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public UserClientProvider(
|
||||
HttpClient? httpClient,
|
||||
ILoggerFactory? loggerFactory,
|
||||
IOptions<TRestOptions> restOptions,
|
||||
IOptions<TSocketOptions> socketOptions)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_httpClient.Timeout = restOptions.Value.RequestTimeout;
|
||||
_loggerFactory = loggerFactory;
|
||||
_restOptions = restOptions;
|
||||
_socketOptions = socketOptions;
|
||||
}
|
||||
|
||||
|
||||
private IOptions<TRestOptions> SetRestEnvironment(IOptions<TRestOptions> options, TEnvironment? environment)
|
||||
{
|
||||
if (environment == null)
|
||||
return options;
|
||||
|
||||
var newRestClientOptions = new TRestOptions();
|
||||
options.Value.Set(newRestClientOptions);
|
||||
newRestClientOptions.Environment = environment;
|
||||
return Options.Create(newRestClientOptions);
|
||||
}
|
||||
|
||||
private IOptions<TSocketOptions> SetSocketEnvironment(IOptions<TSocketOptions> options, TEnvironment? environment)
|
||||
{
|
||||
if (environment == null)
|
||||
return options;
|
||||
|
||||
var newSocketClientOptions = new TSocketOptions();
|
||||
options.Value.Set(newSocketClientOptions);
|
||||
newSocketClientOptions.Environment = environment;
|
||||
return Options.Create(newSocketClientOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InitializeUserClient(string userIdentifier, TCredentials credentials, TEnvironment? environment = null)
|
||||
{
|
||||
CreateRestClient(userIdentifier, credentials, environment);
|
||||
CreateSocketClient(userIdentifier, credentials, environment);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TRestClient GetRestClient(string userIdentifier, TCredentials? credentials = null, TEnvironment? environment = null)
|
||||
{
|
||||
if (!_restClients.TryGetValue(userIdentifier, out var client) || client.Disposed)
|
||||
client = CreateRestClient(userIdentifier, credentials, environment);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TSocketClient GetSocketClient(string userIdentifier, TCredentials? credentials = null, TEnvironment? environment = null)
|
||||
{
|
||||
if (!_socketClients.TryGetValue(userIdentifier, out var client) || client.Disposed)
|
||||
client = CreateSocketClient(userIdentifier, credentials, environment);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private TRestClient CreateRestClient(string userIdentifier, TCredentials? credentials, TEnvironment? environment)
|
||||
{
|
||||
var clientRestOptions = SetRestEnvironment(_restOptions, environment);
|
||||
var client = ConstructRestClient(_httpClient, _loggerFactory, clientRestOptions);
|
||||
if (credentials != null)
|
||||
{
|
||||
_restClients[userIdentifier] = client;
|
||||
client.SetApiCredentials(credentials);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private TSocketClient CreateSocketClient(string userIdentifier, TCredentials? credentials, TEnvironment? environment)
|
||||
{
|
||||
var clientSocketOptions = SetSocketEnvironment(_socketOptions, environment);
|
||||
var client = ConstructSocketClient(_loggerFactory, clientSocketOptions);
|
||||
if (credentials != null)
|
||||
{
|
||||
_socketClients[userIdentifier] = client;
|
||||
client.SetApiCredentials(credentials);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new instance of the rest client
|
||||
/// </summary>
|
||||
protected abstract TRestClient ConstructRestClient(
|
||||
HttpClient client,
|
||||
ILoggerFactory? loggerFactory,
|
||||
IOptions<TRestOptions> options);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new instance of the socket client
|
||||
/// </summary>
|
||||
protected abstract TSocketClient ConstructSocketClient(
|
||||
ILoggerFactory? loggerFactory,
|
||||
IOptions<TSocketOptions> options);
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearUserClients(string userIdentifier)
|
||||
{
|
||||
_restClients.TryRemove(userIdentifier, out var restClient);
|
||||
_socketClients.TryRemove(userIdentifier, out var socketClient);
|
||||
restClient?.Dispose();
|
||||
socketClient?.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var client in _restClients.Values)
|
||||
client.Dispose();
|
||||
_restClients.Clear();
|
||||
|
||||
foreach (var client in _socketClients.Values)
|
||||
client.Dispose();
|
||||
_socketClients.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the provided options delegate to a new instance of the specified type.
|
||||
/// </summary>
|
||||
protected static T ApplyOptionsDelegate<T>(Action<T>? del) where T : new()
|
||||
{
|
||||
var opts = new T();
|
||||
del?.Invoke(opts);
|
||||
return opts;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Balance data
|
||||
/// </summary>
|
||||
public class Balance: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The asset name
|
||||
/// </summary>
|
||||
public string Asset { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Quantity available
|
||||
/// </summary>
|
||||
public decimal? Available { get; set; }
|
||||
/// <summary>
|
||||
/// Total quantity
|
||||
/// </summary>
|
||||
public decimal? Total { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for common objects
|
||||
/// </summary>
|
||||
public class BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The source object the data is derived from
|
||||
/// </summary>
|
||||
public object SourceObject { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Order type
|
||||
/// </summary>
|
||||
public enum CommonOrderType
|
||||
{
|
||||
/// <summary>
|
||||
/// Limit type
|
||||
/// </summary>
|
||||
Limit,
|
||||
/// <summary>
|
||||
/// Market type
|
||||
/// </summary>
|
||||
Market,
|
||||
/// <summary>
|
||||
/// Other order type
|
||||
/// </summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order side
|
||||
/// </summary>
|
||||
public enum CommonOrderSide
|
||||
{
|
||||
/// <summary>
|
||||
/// Buy order
|
||||
/// </summary>
|
||||
Buy,
|
||||
/// <summary>
|
||||
/// Sell order
|
||||
/// </summary>
|
||||
Sell
|
||||
}
|
||||
/// <summary>
|
||||
/// Order status
|
||||
/// </summary>
|
||||
public enum CommonOrderStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// placed and not fully filled order
|
||||
/// </summary>
|
||||
Active,
|
||||
/// <summary>
|
||||
/// canceled order
|
||||
/// </summary>
|
||||
Canceled,
|
||||
/// <summary>
|
||||
/// filled order
|
||||
/// </summary>
|
||||
Filled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position side
|
||||
/// </summary>
|
||||
public enum CommonPositionSide
|
||||
{
|
||||
/// <summary>
|
||||
/// Long position
|
||||
/// </summary>
|
||||
Long,
|
||||
/// <summary>
|
||||
/// Short position
|
||||
/// </summary>
|
||||
Short,
|
||||
/// <summary>
|
||||
/// Both
|
||||
/// </summary>
|
||||
Both
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Kline data
|
||||
/// </summary>
|
||||
public class Kline: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Opening time of the kline
|
||||
/// </summary>
|
||||
public DateTime OpenTime { get; set; }
|
||||
/// <summary>
|
||||
/// Price at the open time
|
||||
/// </summary>
|
||||
public decimal? OpenPrice { get; set; }
|
||||
/// <summary>
|
||||
/// Highest price of the kline
|
||||
/// </summary>
|
||||
public decimal? HighPrice { get; set; }
|
||||
/// <summary>
|
||||
/// Lowest price of the kline
|
||||
/// </summary>
|
||||
public decimal? LowPrice { get; set; }
|
||||
/// <summary>
|
||||
/// Close price of the kline
|
||||
/// </summary>
|
||||
public decimal? ClosePrice { get; set; }
|
||||
/// <summary>
|
||||
/// Volume of the kline
|
||||
/// </summary>
|
||||
public decimal? Volume { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Order data
|
||||
/// </summary>
|
||||
public class Order: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Id of the order
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Symbol of the order
|
||||
/// </summary>
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Price of the order
|
||||
/// </summary>
|
||||
public decimal? Price { get; set; }
|
||||
/// <summary>
|
||||
/// Quantity of the order
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
/// <summary>
|
||||
/// The quantity of the order which has been filled
|
||||
/// </summary>
|
||||
public decimal? QuantityFilled { get; set; }
|
||||
/// <summary>
|
||||
/// Status of the order
|
||||
/// </summary>
|
||||
public CommonOrderStatus Status { get; set; }
|
||||
/// <summary>
|
||||
/// Side of the order
|
||||
/// </summary>
|
||||
public CommonOrderSide Side { get; set; }
|
||||
/// <summary>
|
||||
/// Type of the order
|
||||
/// </summary>
|
||||
public CommonOrderType Type { get; set; }
|
||||
/// <summary>
|
||||
/// Order time
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Order book data
|
||||
/// </summary>
|
||||
public class OrderBook: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// List of bids
|
||||
/// </summary>
|
||||
public IEnumerable<OrderBookEntry> Bids { get; set; } = Array.Empty<OrderBookEntry>();
|
||||
/// <summary>
|
||||
/// List of asks
|
||||
/// </summary>
|
||||
public IEnumerable<OrderBookEntry> Asks { get; set; } = Array.Empty<OrderBookEntry>();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Order book entry
|
||||
/// </summary>
|
||||
public class OrderBookEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Quantity of the entry
|
||||
/// </summary>
|
||||
public decimal Quantity { get; set; }
|
||||
/// <summary>
|
||||
/// Price of the entry
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Id of an order
|
||||
/// </summary>
|
||||
public class OrderId: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Id of an order
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Position data
|
||||
/// </summary>
|
||||
public class Position: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Id of the position
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
/// <summary>
|
||||
/// Symbol of the position
|
||||
/// </summary>
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Leverage
|
||||
/// </summary>
|
||||
public decimal Leverage { get; set; }
|
||||
/// <summary>
|
||||
/// Position quantity
|
||||
/// </summary>
|
||||
public decimal Quantity { get; set; }
|
||||
/// <summary>
|
||||
/// Entry price
|
||||
/// </summary>
|
||||
public decimal? EntryPrice { get; set; }
|
||||
/// <summary>
|
||||
/// Liquidation price
|
||||
/// </summary>
|
||||
public decimal? LiquidationPrice { get; set; }
|
||||
/// <summary>
|
||||
/// Unrealized profit and loss
|
||||
/// </summary>
|
||||
public decimal? UnrealizedPnl { get; set; }
|
||||
/// <summary>
|
||||
/// Realized profit and loss
|
||||
/// </summary>
|
||||
public decimal? RealizedPnl { get; set; }
|
||||
/// <summary>
|
||||
/// Mark price
|
||||
/// </summary>
|
||||
public decimal? MarkPrice { get; set; }
|
||||
/// <summary>
|
||||
/// Auto adding margin
|
||||
/// </summary>
|
||||
public bool? AutoMargin { get; set; }
|
||||
/// <summary>
|
||||
/// Position margin
|
||||
/// </summary>
|
||||
public decimal? PositionMargin { get; set; }
|
||||
/// <summary>
|
||||
/// Position side
|
||||
/// </summary>
|
||||
public CommonPositionSide? Side { get; set; }
|
||||
/// <summary>
|
||||
/// Is isolated
|
||||
/// </summary>
|
||||
public bool? Isolated { get; set; }
|
||||
/// <summary>
|
||||
/// Maintenance margin
|
||||
/// </summary>
|
||||
public decimal? MaintananceMargin { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol data
|
||||
/// </summary>
|
||||
public class Symbol: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the symbol
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Minimal quantity of an order
|
||||
/// </summary>
|
||||
public decimal? MinTradeQuantity { get; set; }
|
||||
/// <summary>
|
||||
/// Step with which the quantity should increase
|
||||
/// </summary>
|
||||
public decimal? QuantityStep { get; set; }
|
||||
/// <summary>
|
||||
/// step with which the price should increase
|
||||
/// </summary>
|
||||
public decimal? PriceStep { get; set; }
|
||||
/// <summary>
|
||||
/// The max amount of decimals for quantity
|
||||
/// </summary>
|
||||
public int? QuantityDecimals { get; set; }
|
||||
/// <summary>
|
||||
/// The max amount of decimal for price
|
||||
/// </summary>
|
||||
public int? PriceDecimals { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Ticker data
|
||||
/// </summary>
|
||||
public class Ticker: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol
|
||||
/// </summary>
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Price 24 hours ago
|
||||
/// </summary>
|
||||
public decimal? Price24H { get; set; }
|
||||
/// <summary>
|
||||
/// Last trade price
|
||||
/// </summary>
|
||||
public decimal? LastPrice { get; set; }
|
||||
/// <summary>
|
||||
/// 24 hour low price
|
||||
/// </summary>
|
||||
public decimal? LowPrice { get; set; }
|
||||
/// <summary>
|
||||
/// 24 hour high price
|
||||
/// </summary>
|
||||
public decimal? HighPrice { get; set; }
|
||||
/// <summary>
|
||||
/// 24 hour volume
|
||||
/// </summary>
|
||||
public decimal? Volume { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.CommonObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Trade data
|
||||
/// </summary>
|
||||
public class Trade: BaseCommonObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol of the trade
|
||||
/// </summary>
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Price of the trade
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
/// <summary>
|
||||
/// Quantity of the trade
|
||||
/// </summary>
|
||||
public decimal Quantity { get; set; }
|
||||
/// <summary>
|
||||
/// Timestamp of the trade
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User trade info
|
||||
/// </summary>
|
||||
public class UserTrade: Trade
|
||||
{
|
||||
/// <summary>
|
||||
/// Id of the trade
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Order id of the trade
|
||||
/// </summary>
|
||||
public string? OrderId { get; set; }
|
||||
/// <summary>
|
||||
/// Fee of the trade
|
||||
/// </summary>
|
||||
public decimal? Fee { get; set; }
|
||||
/// <summary>
|
||||
/// The asset the fee is paid in
|
||||
/// </summary>
|
||||
public string? FeeAsset { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Converter for arrays to objects. Can deserialize data like [0.1, 0.2, "test"] to an object. Mapping is done by marking the class with [JsonConverter(typeof(ArrayConverter))] and the properties
|
||||
/// with [ArrayProperty(x)] where x is the index of the property in the array
|
||||
/// </summary>
|
||||
public class ArrayConverter : JsonConverter
|
||||
{
|
||||
private static readonly ConcurrentDictionary<(MemberInfo, Type), Attribute> attributeByMemberInfoAndTypeCache = new ConcurrentDictionary<(MemberInfo, Type), Attribute>();
|
||||
private static readonly ConcurrentDictionary<(Type, Type), Attribute> attributeByTypeAndTypeCache = new ConcurrentDictionary<(Type, Type), Attribute>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (objectType == typeof(JToken))
|
||||
return JToken.Load(reader);
|
||||
|
||||
var result = Activator.CreateInstance(objectType);
|
||||
var arr = JArray.Load(reader);
|
||||
return ParseObject(arr, result, objectType);
|
||||
}
|
||||
|
||||
private static object ParseObject(JArray arr, object result, Type objectType)
|
||||
{
|
||||
foreach (var property in objectType.GetProperties())
|
||||
{
|
||||
var attribute = GetCustomAttribute<ArrayPropertyAttribute>(property);
|
||||
|
||||
if (attribute == null)
|
||||
continue;
|
||||
|
||||
if (attribute.Index >= arr.Count)
|
||||
continue;
|
||||
|
||||
if (property.PropertyType.BaseType == typeof(Array))
|
||||
{
|
||||
var objType = property.PropertyType.GetElementType();
|
||||
var innerArray = (JArray)arr[attribute.Index];
|
||||
var count = 0;
|
||||
if (innerArray.Count == 0)
|
||||
{
|
||||
var arrayResult = (IList)Activator.CreateInstance(property.PropertyType, new [] { 0 });
|
||||
property.SetValue(result, arrayResult);
|
||||
}
|
||||
else if (innerArray[0].Type == JTokenType.Array)
|
||||
{
|
||||
var arrayResult = (IList)Activator.CreateInstance(property.PropertyType, new [] { innerArray.Count });
|
||||
foreach (var obj in innerArray)
|
||||
{
|
||||
var innerObj = Activator.CreateInstance(objType!);
|
||||
arrayResult[count] = ParseObject((JArray)obj, innerObj, objType!);
|
||||
count++;
|
||||
}
|
||||
property.SetValue(result, arrayResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
var arrayResult = (IList)Activator.CreateInstance(property.PropertyType, new [] { 1 });
|
||||
var innerObj = Activator.CreateInstance(objType!);
|
||||
arrayResult[0] = ParseObject(innerArray, innerObj, objType!);
|
||||
property.SetValue(result, arrayResult);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var converterAttribute = GetCustomAttribute<JsonConverterAttribute>(property) ?? GetCustomAttribute<JsonConverterAttribute>(property.PropertyType);
|
||||
var conversionAttribute = GetCustomAttribute<JsonConversionAttribute>(property) ?? GetCustomAttribute<JsonConversionAttribute>(property.PropertyType);
|
||||
|
||||
object? value;
|
||||
if (converterAttribute != null)
|
||||
{
|
||||
value = arr[attribute.Index].ToObject(property.PropertyType, new JsonSerializer {Converters = {(JsonConverter) Activator.CreateInstance(converterAttribute.ConverterType)}});
|
||||
}
|
||||
else if (conversionAttribute != null)
|
||||
{
|
||||
value = arr[attribute.Index].ToObject(property.PropertyType);
|
||||
}
|
||||
else
|
||||
{
|
||||
value = arr[attribute.Index];
|
||||
}
|
||||
|
||||
if (value != null && property.PropertyType.IsInstanceOfType(value))
|
||||
property.SetValue(result, value);
|
||||
else
|
||||
{
|
||||
if (value is JToken token)
|
||||
if (token.Type == JTokenType.Null)
|
||||
value = null;
|
||||
|
||||
if ((property.PropertyType == typeof(decimal)
|
||||
|| property.PropertyType == typeof(decimal?))
|
||||
&& (value != null && value.ToString().Contains("e")))
|
||||
{
|
||||
if (decimal.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var dec))
|
||||
property.SetValue(result, dec);
|
||||
}
|
||||
else
|
||||
{
|
||||
property.SetValue(result, value == null ? null : Convert.ChangeType(value, property.PropertyType));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
if (value == null)
|
||||
return;
|
||||
|
||||
writer.WriteStartArray();
|
||||
var props = value.GetType().GetProperties();
|
||||
var ordered = props.OrderBy(p => GetCustomAttribute<ArrayPropertyAttribute>(p)?.Index);
|
||||
|
||||
var last = -1;
|
||||
foreach (var prop in ordered)
|
||||
{
|
||||
var arrayProp = GetCustomAttribute<ArrayPropertyAttribute>(prop);
|
||||
if (arrayProp == null)
|
||||
continue;
|
||||
|
||||
if (arrayProp.Index == last)
|
||||
continue;
|
||||
|
||||
while (arrayProp.Index != last + 1)
|
||||
{
|
||||
writer.WriteValue((string?)null);
|
||||
last += 1;
|
||||
}
|
||||
|
||||
last = arrayProp.Index;
|
||||
var converterAttribute = GetCustomAttribute<JsonConverterAttribute>(prop);
|
||||
if (converterAttribute != null)
|
||||
writer.WriteRawValue(JsonConvert.SerializeObject(prop.GetValue(value), (JsonConverter)Activator.CreateInstance(converterAttribute.ConverterType)));
|
||||
else if (!IsSimple(prop.PropertyType))
|
||||
serializer.Serialize(writer, prop.GetValue(value));
|
||||
else
|
||||
writer.WriteValue(prop.GetValue(value));
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static bool IsSimple(Type type)
|
||||
{
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
{
|
||||
// nullable type, check if the nested type is simple.
|
||||
return IsSimple(type.GetGenericArguments()[0]);
|
||||
}
|
||||
return type.IsPrimitive
|
||||
|| type.IsEnum
|
||||
|| type == typeof(string)
|
||||
|| type == typeof(decimal);
|
||||
}
|
||||
|
||||
private static T? GetCustomAttribute<T>(MemberInfo memberInfo) where T : Attribute =>
|
||||
(T?)attributeByMemberInfoAndTypeCache.GetOrAdd((memberInfo, typeof(T)), tuple => memberInfo.GetCustomAttribute(typeof(T)));
|
||||
|
||||
private static T? GetCustomAttribute<T>(Type type) where T : Attribute =>
|
||||
(T?)attributeByTypeAndTypeCache.GetOrAdd((type, typeof(T)), tuple => type.GetCustomAttribute(typeof(T)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark property as an index in the array
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class ArrayPropertyAttribute: Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The index in the array
|
||||
/// </summary>
|
||||
public int Index { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="index"></param>
|
||||
public ArrayPropertyAttribute(int index)
|
||||
{
|
||||
Index = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Mark property as an index in the array
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class ArrayPropertyAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The index in the array
|
||||
/// </summary>
|
||||
public int Index { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="index"></param>
|
||||
public ArrayPropertyAttribute(int index)
|
||||
{
|
||||
Index = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CryptoExchange.Net.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for enum converters
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of enum to convert</typeparam>
|
||||
public abstract class BaseConverter<T>: JsonConverter where T: struct
|
||||
{
|
||||
/// <summary>
|
||||
/// The enum->string mapping
|
||||
/// </summary>
|
||||
protected abstract List<KeyValuePair<T, string>> Mapping { get; }
|
||||
private readonly bool quotes;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="useQuotes"></param>
|
||||
protected BaseConverter(bool useQuotes)
|
||||
{
|
||||
quotes = useQuotes;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
var stringValue = value == null? null: GetValue((T) value);
|
||||
if (quotes)
|
||||
writer.WriteValue(stringValue);
|
||||
else
|
||||
writer.WriteRawValue(stringValue);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.Value == null)
|
||||
return null;
|
||||
|
||||
var stringValue = reader.Value.ToString();
|
||||
if (string.IsNullOrWhiteSpace(stringValue))
|
||||
return null;
|
||||
|
||||
if (!GetValue(stringValue, out var result))
|
||||
{
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Cannot map enum value. EnumType: {typeof(T)}, Value: {reader.Value}, Known values: {string.Join(", ", Mapping.Select(m => m.Value))}. If you think {reader.Value} should added please open an issue on the Github repo");
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a string value
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <returns></returns>
|
||||
public T ReadString(string data)
|
||||
{
|
||||
return Mapping.FirstOrDefault(v => v.Value == data).Key;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
// Check if it is type, or nullable of type
|
||||
return objectType == typeof(T) || Nullable.GetUnderlyingType(objectType) == typeof(T);
|
||||
}
|
||||
|
||||
private bool GetValue(string value, out T result)
|
||||
{
|
||||
// Check for exact match first, then if not found fallback to a case insensitive match
|
||||
var mapping = Mapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCulture));
|
||||
if(mapping.Equals(default(KeyValuePair<T, string>)))
|
||||
mapping = Mapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (!mapping.Equals(default(KeyValuePair<T, string>)))
|
||||
{
|
||||
result = mapping.Key;
|
||||
return true;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private string GetValue(T value)
|
||||
{
|
||||
return Mapping.FirstOrDefault(v => v.Key.Equals(value)).Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
namespace CryptoExchange.Net.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Datetime converter. Supports converting from string/long/double to DateTime and back. Numbers are assumed to be the time since 1970-01-01.
|
||||
/// </summary>
|
||||
public class DateTimeConverter: JsonConverter
|
||||
{
|
||||
private static readonly DateTime _epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private const long ticksPerSecond = TimeSpan.TicksPerMillisecond * 1000;
|
||||
private const decimal ticksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000;
|
||||
private const decimal ticksPerNanosecond = TimeSpan.TicksPerMillisecond / 1000m / 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return objectType == typeof(DateTime) || objectType == typeof(DateTime?);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.Value == null)
|
||||
return null;
|
||||
|
||||
if(reader.TokenType is JsonToken.Integer)
|
||||
{
|
||||
var longValue = (long)reader.Value;
|
||||
if (longValue == 0 || longValue == -1)
|
||||
return objectType == typeof(DateTime) ? default(DateTime): null;
|
||||
if (longValue < 19999999999)
|
||||
return ConvertFromSeconds(longValue);
|
||||
if (longValue < 19999999999999)
|
||||
return ConvertFromMilliseconds(longValue);
|
||||
if (longValue < 19999999999999999)
|
||||
return ConvertFromMicroseconds(longValue);
|
||||
|
||||
return ConvertFromNanoseconds(longValue);
|
||||
}
|
||||
else if (reader.TokenType is JsonToken.Float)
|
||||
{
|
||||
var doubleValue = (double)reader.Value;
|
||||
if (doubleValue == 0 || doubleValue == -1)
|
||||
return objectType == typeof(DateTime) ? default(DateTime) : null;
|
||||
|
||||
if (doubleValue < 19999999999)
|
||||
return ConvertFromSeconds(doubleValue);
|
||||
|
||||
return ConvertFromMilliseconds(doubleValue);
|
||||
}
|
||||
else if(reader.TokenType is JsonToken.String)
|
||||
{
|
||||
var stringValue = (string)reader.Value;
|
||||
if (string.IsNullOrWhiteSpace(stringValue))
|
||||
return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(stringValue) || stringValue == "0" || stringValue == "-1")
|
||||
return objectType == typeof(DateTime) ? default(DateTime) : null;
|
||||
|
||||
if (stringValue.Length == 8)
|
||||
{
|
||||
// Parse 20211103 format
|
||||
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|
||||
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|
||||
|| !int.TryParse(stringValue.Substring(6, 2), out var day))
|
||||
{
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
|
||||
return default;
|
||||
}
|
||||
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (stringValue.Length == 6)
|
||||
{
|
||||
// Parse 211103 format
|
||||
if (!int.TryParse(stringValue.Substring(0, 2), out var year)
|
||||
|| !int.TryParse(stringValue.Substring(2, 2), out var month)
|
||||
|| !int.TryParse(stringValue.Substring(4, 2), out var day))
|
||||
{
|
||||
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
|
||||
return default;
|
||||
}
|
||||
return new DateTime(year + 2000, month, day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
|
||||
{
|
||||
// Parse 1637745563.000 format
|
||||
if (doubleValue < 19999999999)
|
||||
return ConvertFromSeconds(doubleValue);
|
||||
if (doubleValue < 19999999999999)
|
||||
return ConvertFromMilliseconds((long)doubleValue);
|
||||
if (doubleValue < 19999999999999999)
|
||||
return ConvertFromMicroseconds((long)doubleValue);
|
||||
|
||||
return ConvertFromNanoseconds((long)doubleValue);
|
||||
}
|
||||
|
||||
if(stringValue.Length == 10)
|
||||
{
|
||||
// Parse 2021-11-03 format
|
||||
var values = stringValue.Split('-');
|
||||
if(!int.TryParse(values[0], out var year)
|
||||
|| !int.TryParse(values[1], out var month)
|
||||
|| !int.TryParse(values[2], out var day))
|
||||
{
|
||||
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
|
||||
return default;
|
||||
}
|
||||
|
||||
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
return DateTime.Parse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
}
|
||||
else if(reader.TokenType == JsonToken.Date)
|
||||
{
|
||||
return (DateTime)reader.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a seconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="seconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromSeconds(double seconds) => _epoch.AddTicks((long)Math.Round(seconds * ticksPerSecond));
|
||||
/// <summary>
|
||||
/// Convert a milliseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="milliseconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromMilliseconds(double milliseconds) => _epoch.AddTicks((long)Math.Round(milliseconds * TimeSpan.TicksPerMillisecond));
|
||||
/// <summary>
|
||||
/// Convert a microseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="microseconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromMicroseconds(long microseconds) => _epoch.AddTicks((long)Math.Round(microseconds * ticksPerMicrosecond));
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="nanoseconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromNanoseconds(long nanoseconds) => _epoch.AddTicks((long)Math.Round(nanoseconds * ticksPerNanosecond));
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to seconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToSeconds(DateTime? time) => time == null ? null: (long)Math.Round((time.Value - _epoch).TotalSeconds);
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to milliseconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToMilliseconds(DateTime? time) => time == null ? null : (long)Math.Round((time.Value - _epoch).TotalMilliseconds);
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to microseconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToMicroseconds(DateTime? time) => time == null ? null : (long)Math.Round((time.Value - _epoch).Ticks / ticksPerMicrosecond);
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to nanoseconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToNanoseconds(DateTime? time) => time == null ? null : (long)Math.Round((time.Value - _epoch).Ticks / ticksPerNanosecond);
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
var datetimeValue = (DateTime?)value;
|
||||
if (datetimeValue == null)
|
||||
writer.WriteValue((DateTime?)null);
|
||||
if(datetimeValue == default(DateTime))
|
||||
writer.WriteValue((DateTime?)null);
|
||||
else
|
||||
writer.WriteValue((long)Math.Round(((DateTime)value! - new DateTime(1970, 1, 1)).TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Converter for enum values. Enums entries should be noted with a MapAttribute to map the enum value to a string value
|
||||
/// </summary>
|
||||
public class EnumConverter : JsonConverter
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Type, List<KeyValuePair<object, string>>> _mapping = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return objectType.IsEnum;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||
{
|
||||
var enumType = Nullable.GetUnderlyingType(objectType) ?? objectType;
|
||||
if (!_mapping.TryGetValue(enumType, out var mapping))
|
||||
mapping = AddMapping(enumType);
|
||||
|
||||
var stringValue = reader.Value?.ToString();
|
||||
if (stringValue == null)
|
||||
{
|
||||
// Received null value
|
||||
var emptyResult = GetDefaultValue(objectType, enumType);
|
||||
if(emptyResult != null)
|
||||
// If the property we're parsing to isn't nullable there isn't a correct way to return this as null will either throw an exception (.net framework) or the default enum value (dotnet core).
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Received null enum value, but property type is not a nullable enum. EnumType: {enumType.Name}. If you think {enumType.Name} should be nullable please open an issue on the Github repo");
|
||||
|
||||
return emptyResult;
|
||||
}
|
||||
|
||||
if (!GetValue(enumType, mapping, stringValue!, out var result))
|
||||
{
|
||||
var defaultValue = GetDefaultValue(objectType, enumType);
|
||||
if (string.IsNullOrWhiteSpace(stringValue))
|
||||
{
|
||||
if (defaultValue != null)
|
||||
// We received an empty string and have no mapping for it, and the property isn't nullable
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Received empty string as enum value, but property type is not a nullable enum. EnumType: {enumType.Name}. If you think {enumType.Name} should be nullable please open an issue on the Github repo");
|
||||
}
|
||||
else
|
||||
// We received an enum value but weren't able to parse it.
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Cannot map enum value. EnumType: {enumType.Name}, Value: {reader.Value}, Known values: {string.Join(", ", mapping.Select(m => m.Value))}. If you think {reader.Value} should added please open an issue on the Github repo");
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object? GetDefaultValue(Type objectType, Type enumType)
|
||||
{
|
||||
if (Nullable.GetUnderlyingType(objectType) != null)
|
||||
return null;
|
||||
|
||||
return Activator.CreateInstance(enumType); // return default value
|
||||
}
|
||||
|
||||
private static List<KeyValuePair<object, string>> AddMapping(Type objectType)
|
||||
{
|
||||
var mapping = new List<KeyValuePair<object, string>>();
|
||||
var enumMembers = objectType.GetMembers();
|
||||
foreach (var member in enumMembers)
|
||||
{
|
||||
var maps = member.GetCustomAttributes(typeof(MapAttribute), false);
|
||||
foreach (MapAttribute attribute in maps)
|
||||
{
|
||||
foreach (var value in attribute.Values)
|
||||
mapping.Add(new KeyValuePair<object, string>(Enum.Parse(objectType, member.Name), value));
|
||||
}
|
||||
}
|
||||
_mapping.TryAdd(objectType, mapping);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
private static bool GetValue(Type objectType, List<KeyValuePair<object, string>> enumMapping, string value, out object? result)
|
||||
{
|
||||
// Check for exact match first, then if not found fallback to a case insensitive match
|
||||
var mapping = enumMapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCulture));
|
||||
if (mapping.Equals(default(KeyValuePair<object, string>)))
|
||||
mapping = enumMapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (!mapping.Equals(default(KeyValuePair<object, string>)))
|
||||
{
|
||||
result = mapping.Key;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// If no explicit mapping is found try to parse string
|
||||
result = Enum.Parse(objectType, value, true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the string value for an enum value using the MapAttribute mapping. When multiple values are mapped for a enum entry the first value will be returned
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <param name="enumValue"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("enumValue")]
|
||||
public static string? GetString<T>(T enumValue)
|
||||
{
|
||||
var objectType = typeof(T);
|
||||
objectType = Nullable.GetUnderlyingType(objectType) ?? objectType;
|
||||
|
||||
if (!_mapping.TryGetValue(objectType, out var mapping))
|
||||
mapping = AddMapping(objectType);
|
||||
|
||||
return enumValue == null ? null : (mapping.FirstOrDefault(v => v.Key.Equals(enumValue)).Value ?? enumValue.ToString());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
var stringValue = GetString(value);
|
||||
writer.WriteRawValue(stringValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Caching for JsonSerializerContext instances
|
||||
/// </summary>
|
||||
public static class JsonSerializerContextCache
|
||||
{
|
||||
private static ConcurrentDictionary<Type, JsonSerializerContext> _cache = new ConcurrentDictionary<Type, JsonSerializerContext>();
|
||||
|
||||
/// <summary>
|
||||
/// Get the instance of the provided type T. It will be created if it doesn't exist yet.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Implementation type of the JsonSerializerContext</typeparam>
|
||||
public static JsonSerializerContext GetOrCreate<T>() where T: JsonSerializerContext, new()
|
||||
{
|
||||
var contextType = typeof(T);
|
||||
if (_cache.TryGetValue(contextType, out var context))
|
||||
return context;
|
||||
|
||||
var instance = new T();
|
||||
_cache[contextType] = instance;
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using System.IO;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// REST message handler
|
||||
/// </summary>
|
||||
public interface IRestMessageHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The `accept` HTTP response header for the request
|
||||
/// </summary>
|
||||
MediaTypeWithQualityHeaderValue AcceptHeader { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a seekable stream is required
|
||||
/// </summary>
|
||||
bool RequiresSeekableStream { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parse the response when the HTTP response status indicated an error
|
||||
/// </summary>
|
||||
ValueTask<Error> ParseErrorResponse(
|
||||
int httpStatusCode,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream);
|
||||
|
||||
/// <summary>
|
||||
/// Parse the response when the HTTP response status indicated a rate limit error
|
||||
/// </summary>
|
||||
ValueTask<ServerRateLimitError> ParseErrorRateLimitResponse(
|
||||
int httpStatusCode,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the response is an error response; if so return the error.<br />
|
||||
/// Note that if the API returns a standard result wrapper, something like this:
|
||||
/// <code>{ "code": 400, "msg": "error", "data": {} }</code>
|
||||
/// then the `CheckDeserializedResponse` method should be used for checking the result
|
||||
/// </summary>
|
||||
ValueTask<Error?> CheckForErrorResponse(
|
||||
RequestDefinition request,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize the response stream
|
||||
/// </summary>
|
||||
ValueTask<(T? Result, Error? Error)> TryDeserializeAsync<T>(
|
||||
Stream responseStream,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the resulting T object indicates an error or not
|
||||
/// </summary>
|
||||
Error? CheckDeserializedResponse<T>(HttpResponseHeaders responseHeaders, T result);
|
||||
}
|
||||
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// WebSocket message handler
|
||||
/// </summary>
|
||||
public interface ISocketMessageHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Get an identifier for the message which can be used to determine the type of the message
|
||||
/// </summary>
|
||||
string? GetTypeIdentifier(ReadOnlySpan<byte> data, WebSocketMessageType? webSocketMessageType);
|
||||
|
||||
/// <summary>
|
||||
/// Get optional topic filter, for example a symbol name
|
||||
/// </summary>
|
||||
string? GetTopicFilter(object deserializedObject);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize to the provided type
|
||||
/// </summary>
|
||||
object Deserialize(ReadOnlySpan<byte> data, Type type);
|
||||
}
|
||||
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// Message type definition
|
||||
/// </summary>
|
||||
public class MessageTypeDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to immediately select the definition when it is matched. Can only be used when the evaluator has a single unique field to look for
|
||||
/// </summary>
|
||||
public bool ForceIfFound { get; set; }
|
||||
/// <summary>
|
||||
/// The fields a message needs to contain for this definition
|
||||
/// </summary>
|
||||
public MessageFieldReference[] Fields { get; set; } = [];
|
||||
/// <summary>
|
||||
/// The callback for getting the identifier string
|
||||
/// </summary>
|
||||
public Func<SearchResult, string>? TypeIdentifierCallback { get; set; }
|
||||
/// <summary>
|
||||
/// The static identifier string to return when this evaluator is matched
|
||||
/// </summary>
|
||||
public string? StaticIdentifier { get; set; }
|
||||
|
||||
internal string? GetMessageType(SearchResult result)
|
||||
{
|
||||
if (StaticIdentifier != null)
|
||||
return StaticIdentifier;
|
||||
|
||||
return TypeIdentifierCallback!(result);
|
||||
}
|
||||
|
||||
internal bool Satisfied(SearchResult result)
|
||||
{
|
||||
foreach(var field in Fields)
|
||||
{
|
||||
if (!result.Contains(field))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
internal class MessageEvalutorFieldReference
|
||||
{
|
||||
public bool SkipReading { get; set; }
|
||||
public bool OverlappingField { get; set; }
|
||||
public MessageFieldReference Field { get; set; }
|
||||
public MessageTypeDefinition? ForceEvaluator { get; set; }
|
||||
|
||||
public MessageEvalutorFieldReference(MessageFieldReference field)
|
||||
{
|
||||
Field = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user