mirror of
https://github.com/meilisearch/meilisearch.git
synced 2025-11-25 05:57:11 +00:00
Compare commits
3547 Commits
v0.2.1
...
optimize-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160cba1b46 | ||
|
|
dfbdc565f9 | ||
|
|
1bb05f2716 | ||
|
|
dc21af46e5 | ||
|
|
22aa349e31 | ||
|
|
9cf6acb671 | ||
|
|
78c5826c57 | ||
|
|
7e6f3274fa | ||
|
|
94ef326be3 | ||
|
|
d01a3ab889 | ||
|
|
ad494b6f77 | ||
|
|
2f11686c81 | ||
|
|
01a47e2db5 | ||
|
|
cfacc79ad7 | ||
|
|
8e370ed9ab | ||
|
|
32d6af6527 | ||
|
|
be3240d2dd | ||
|
|
2de6868858 | ||
|
|
d419a91207 | ||
|
|
a9fb5a4d50 | ||
|
|
0353537fef | ||
|
|
da7729e4a8 | ||
|
|
074a6a0cce | ||
|
|
755b1a59a2 | ||
|
|
bb5b18b82c | ||
|
|
01d9560318 | ||
|
|
719879d4d2 | ||
|
|
fb9b298645 | ||
|
|
23f02f241e | ||
|
|
5d80ff41a2 | ||
|
|
f4989590db | ||
|
|
bba5fab5e5 | ||
|
|
05ffe24d64 | ||
|
|
6f95ae9879 | ||
|
|
480b881e15 | ||
|
|
43fecbf382 | ||
|
|
5588a6415a | ||
|
|
b0757e75c4 | ||
|
|
106be03ba8 | ||
|
|
70c55208f1 | ||
|
|
d56bf66022 | ||
|
|
2c300c72c9 | ||
|
|
a146fd45b9 | ||
|
|
63e1fb4f96 | ||
|
|
be1c6f9dc4 | ||
|
|
c251b527b0 | ||
|
|
1dc3724c1f | ||
|
|
c1ad56281d | ||
|
|
83e5f45a91 | ||
|
|
aff8cd1774 | ||
|
|
d1296d03ea | ||
|
|
6f7a4d95d9 | ||
|
|
9ea96fa5c1 | ||
|
|
19f732e623 | ||
|
|
d833e62282 | ||
|
|
a733271ced | ||
|
|
28609c4176 | ||
|
|
a626cf4c99 | ||
|
|
9b660e1058 | ||
|
|
38b85ec547 | ||
|
|
ed185fb636 | ||
|
|
f7b47b43f4 | ||
|
|
8e703fbabe | ||
|
|
7ced5c2cc7 | ||
|
|
ef95d1d545 | ||
|
|
f98c8d7f8b | ||
|
|
4862993482 | ||
|
|
8a64ee0c14 | ||
|
|
05ee2eff01 | ||
|
|
7ee8855499 | ||
|
|
7f4fab876d | ||
|
|
688d6f704b | ||
|
|
f83188fd60 | ||
|
|
9e261b996f | ||
|
|
90b0a4e99f | ||
|
|
dad86fc3d6 | ||
|
|
7feb15df28 | ||
|
|
bf865f51bb | ||
|
|
13f258513f | ||
|
|
f8aa21bc16 | ||
|
|
1ffe90bf15 | ||
|
|
c47369b502 | ||
|
|
32c8846514 | ||
|
|
7490383d4f | ||
|
|
c6ed756dbc | ||
|
|
de16de20f4 | ||
|
|
c484d28646 | ||
|
|
5318e53248 | ||
|
|
06e05cc4f8 | ||
|
|
ba839a909f | ||
|
|
8b98303191 | ||
|
|
2dde6fadb4 | ||
|
|
eb8d53a915 | ||
|
|
10f3150150 | ||
|
|
54cd9976d7 | ||
|
|
5ae5b06018 | ||
|
|
6f910f89eb | ||
|
|
b455c3897f | ||
|
|
9a8fb6c55a | ||
|
|
4016161035 | ||
|
|
9d692ba1c6 | ||
|
|
22e1ac969a | ||
|
|
3340af1ba9 | ||
|
|
49d509936b | ||
|
|
e46b853fbf | ||
|
|
44e004d895 | ||
|
|
71bf9b5b9b | ||
|
|
053071d866 | ||
|
|
0f6e650ba2 | ||
|
|
97daea5a66 | ||
|
|
8990b12609 | ||
|
|
07a35c6445 | ||
|
|
4fc73195e6 | ||
|
|
1425d62a31 | ||
|
|
87d4bf672c | ||
|
|
2063fbd985 | ||
|
|
de356061db | ||
|
|
9a6841c7ce | ||
|
|
354f7fb2bf | ||
|
|
0333bad057 | ||
|
|
0e416b4bcd | ||
|
|
20dd259f23 | ||
|
|
18095fa4e1 | ||
|
|
b8745420da | ||
|
|
36cb09eb25 | ||
|
|
8fc3b7d3b0 | ||
|
|
64e3096790 | ||
|
|
b594d49def | ||
|
|
fbba67fbe9 | ||
|
|
232b2baaa3 | ||
|
|
02c5c193a2 | ||
|
|
b9b32d65a8 | ||
|
|
680606fd82 | ||
|
|
4a494ad2fa | ||
|
|
2b2e571c76 | ||
|
|
6f0d3472b1 | ||
|
|
5cd13cc303 | ||
|
|
1e3dcbea3f | ||
|
|
b96399d24b | ||
|
|
5450b5ced3 | ||
|
|
c924614527 | ||
|
|
96d4fd54bb | ||
|
|
3e5d6be86b | ||
|
|
2b944ecd89 | ||
|
|
db42268888 | ||
|
|
108b3520de | ||
|
|
f64b824c45 | ||
|
|
fc4990b968 | ||
|
|
39a1dcb32c | ||
|
|
afcc493480 | ||
|
|
6171f17f1d | ||
|
|
55169ff914 | ||
|
|
0a16f71563 | ||
|
|
bd280f75e7 | ||
|
|
617802bae7 | ||
|
|
1a7631c807 | ||
|
|
17f30c2b2d | ||
|
|
09938c9b6f | ||
|
|
f5306eb5b0 | ||
|
|
173eea06e1 | ||
|
|
8d09772334 | ||
|
|
987a7f8926 | ||
|
|
0928f3d41c | ||
|
|
09ec8e9fca | ||
|
|
1968950b0f | ||
|
|
6ffa222218 | ||
|
|
79e67df73d | ||
|
|
7fd66b80ca | ||
|
|
0cfad7eeac | ||
|
|
72be296852 | ||
|
|
a7bff35e49 | ||
|
|
3b01ed4fe8 | ||
|
|
cbd27d313c | ||
|
|
6ac8675c6d | ||
|
|
df61ca9cae | ||
|
|
bbd685af5e | ||
|
|
9b9cbc815b | ||
|
|
fd11903920 | ||
|
|
c3003065e8 | ||
|
|
c6ce3452cf | ||
|
|
e5b760c59a | ||
|
|
277a0a7967 | ||
|
|
64b5b2e1f8 | ||
|
|
10d3b367dc | ||
|
|
ba55905377 | ||
|
|
0e7e16ae72 | ||
|
|
80c156df3f | ||
|
|
4b6c3e72ff | ||
|
|
3e46543060 | ||
|
|
b83455f345 | ||
|
|
953a209f02 | ||
|
|
0c5352fc22 | ||
|
|
8ac8fcb0c9 | ||
|
|
4667c9fe1a | ||
|
|
12b5eabd5d | ||
|
|
cf2d8de48a | ||
|
|
419922e475 | ||
|
|
c9cd1738a5 | ||
|
|
0258659278 | ||
|
|
ce37f53a16 | ||
|
|
bcb51905d7 | ||
|
|
10a71fdb10 | ||
|
|
f8d3f739ad | ||
|
|
bb405aa729 | ||
|
|
7e3d5ebc8e | ||
|
|
dfce9ba468 | ||
|
|
9eea142e2b | ||
|
|
8b8c3e32f0 | ||
|
|
08d72e32a4 | ||
|
|
ac9e7bdbe3 | ||
|
|
4512eed8f5 | ||
|
|
e769043576 | ||
|
|
df721b2e9e | ||
|
|
0656df3a6d | ||
|
|
7652295d2c | ||
|
|
94b32cce01 | ||
|
|
b2e2dc8558 | ||
|
|
1816db8c1f | ||
|
|
c295924ea2 | ||
|
|
1f62e83267 | ||
|
|
b3c8915702 | ||
|
|
151f494110 | ||
|
|
96152a3d32 | ||
|
|
84f52ac175 | ||
|
|
70916d6596 | ||
|
|
b9a79eb858 | ||
|
|
a57b2d9538 | ||
|
|
34c8888f56 | ||
|
|
d54643455c | ||
|
|
96a5791e39 | ||
|
|
e2c204cf86 | ||
|
|
d80e8b64af | ||
|
|
c11d21879a | ||
|
|
d6dd234914 | ||
|
|
4970525541 | ||
|
|
461b91fd13 | ||
|
|
004c8b6be3 | ||
|
|
9d5cc88cd5 | ||
|
|
d22f07f5b2 | ||
|
|
e81c7aa2e6 | ||
|
|
39db6ea42b | ||
|
|
47007fa71b | ||
|
|
627f13df85 | ||
|
|
97c14f6fcc | ||
|
|
446f1f31e0 | ||
|
|
ddad6cc069 | ||
|
|
ab39df9693 | ||
|
|
1465b5e0ff | ||
|
|
8800b348f0 | ||
|
|
082d6b89ff | ||
|
|
b82c86c8f5 | ||
|
|
36d94257d8 | ||
|
|
3f80468f18 | ||
|
|
8509243e68 | ||
|
|
3684c822f1 | ||
|
|
80f7d87356 | ||
|
|
d2f457a076 | ||
|
|
e5ef5a6f9c | ||
|
|
5450fecaef | ||
|
|
deba0cc096 | ||
|
|
26e7bdf702 | ||
|
|
3441cc6c36 | ||
|
|
c7711c7816 | ||
|
|
d47b997120 | ||
|
|
1e310ecc7d | ||
|
|
4cb2c6ef1e | ||
|
|
a9ef399a6b | ||
|
|
5a2972fc19 | ||
|
|
ba51ca83ec | ||
|
|
1647ca3c1f | ||
|
|
74a1f88d88 | ||
|
|
f58507379a | ||
|
|
6b2016b350 | ||
|
|
3015265bde | ||
|
|
49d8fadb52 | ||
|
|
127171c812 | ||
|
|
67b6f4340a | ||
|
|
986a99296d | ||
|
|
92d86ce6aa | ||
|
|
3c85b29865 | ||
|
|
8349f38197 | ||
|
|
64654ef7c3 | ||
|
|
0f9c134114 | ||
|
|
7b47e4e87a | ||
|
|
8743d73973 | ||
|
|
f0aceb4fba | ||
|
|
61035a3ea4 | ||
|
|
4778884105 | ||
|
|
57fde30b91 | ||
|
|
56eb2907c9 | ||
|
|
414d0907ce | ||
|
|
60a8249de6 | ||
|
|
46cdc17701 | ||
|
|
6a0231cb28 | ||
|
|
7fa3eb1003 | ||
|
|
2f0625a984 | ||
|
|
737b891a41 | ||
|
|
5a5066023b | ||
|
|
aa50acb031 | ||
|
|
9935db86c7 | ||
|
|
f65116b208 | ||
|
|
341756a0eb | ||
|
|
5f0e9b63d2 | ||
|
|
ca9ba2d90c | ||
|
|
2c248a68a4 | ||
|
|
641ca5a857 | ||
|
|
6bf4db0bca | ||
|
|
4e9accdeb7 | ||
|
|
ae4e419db4 | ||
|
|
50763aac82 | ||
|
|
3517eae47f | ||
|
|
0250ea9157 | ||
|
|
6d221058f1 | ||
|
|
a23998f2e3 | ||
|
|
49e857776c | ||
|
|
7fa7f3d1a4 | ||
|
|
85d19bfb3e | ||
|
|
a65a2bea1e | ||
|
|
5670b4d012 | ||
|
|
b9866d8df2 | ||
|
|
b9b9cba154 | ||
|
|
cd2239eb2d | ||
|
|
348af6cfbf | ||
|
|
5337bdb9c5 | ||
|
|
a350cc1186 | ||
|
|
5c4c38c79c | ||
|
|
b94eabe48c | ||
|
|
c46f3587de | ||
|
|
34f75d9792 | ||
|
|
3c5f7dbf7e | ||
|
|
3d0a4a3d18 | ||
|
|
6025372565 | ||
|
|
3d10af0333 | ||
|
|
c07f3b44b7 | ||
|
|
38d681c230 | ||
|
|
e85377e725 | ||
|
|
6ff8bf823d | ||
|
|
4d25229df9 | ||
|
|
f1cd6b6ee8 | ||
|
|
63f75bd187 | ||
|
|
acf3357cf3 | ||
|
|
202d6105b2 | ||
|
|
0714551101 | ||
|
|
04381011b0 | ||
|
|
1ef87cc6d0 | ||
|
|
4a9000bb96 | ||
|
|
754c49f991 | ||
|
|
97adef6bfc | ||
|
|
a7fd199ded | ||
|
|
2692b8c960 | ||
|
|
58a1124e9a | ||
|
|
b57ad15a24 | ||
|
|
9b064e53e7 | ||
|
|
289bfd46ff | ||
|
|
64b0a50a58 | ||
|
|
b1333ab5b0 | ||
|
|
276dc6043a | ||
|
|
b9e676b8ca | ||
|
|
6c06fb226d | ||
|
|
41249be274 | ||
|
|
049cf0fcee | ||
|
|
2ee210483f | ||
|
|
13205066f3 | ||
|
|
b3661bf8ec | ||
|
|
0990e95830 | ||
|
|
f67167fa9f | ||
|
|
31584f34e8 | ||
|
|
a70e0a6422 | ||
|
|
348345f555 | ||
|
|
683206e140 | ||
|
|
69d312209e | ||
|
|
013fe4cbc9 | ||
|
|
dc2cc1ee89 | ||
|
|
bb5f0e1485 | ||
|
|
d5e33637b7 | ||
|
|
c321ac61b5 | ||
|
|
67dea08a0a | ||
|
|
e9f66b8766 | ||
|
|
dd43ba6234 | ||
|
|
27a88bcd47 | ||
|
|
065fe19452 | ||
|
|
981fba5b44 | ||
|
|
09734f0732 | ||
|
|
a523828f61 | ||
|
|
7f7958f815 | ||
|
|
9e344f6576 | ||
|
|
09a72cee03 | ||
|
|
6fc6b83632 | ||
|
|
eee2cd5abf | ||
|
|
87e4125875 | ||
|
|
7ece7a9d9e | ||
|
|
403f03cb2c | ||
|
|
b28aa8e666 | ||
|
|
98107565c0 | ||
|
|
a2d7c16f91 | ||
|
|
ffafd5b976 | ||
|
|
9f1c88680d | ||
|
|
9edd407a88 | ||
|
|
8bc6e8dcf9 | ||
|
|
2624c76517 | ||
|
|
891d042164 | ||
|
|
b3a11e04af | ||
|
|
acdb10a307 | ||
|
|
8fecc6238d | ||
|
|
405af09fc8 | ||
|
|
0d6be2efab | ||
|
|
94f04e79eb | ||
|
|
381e98053f | ||
|
|
6c2fdc7743 | ||
|
|
513b37e245 | ||
|
|
13a0e78d3f | ||
|
|
80d8ac40af | ||
|
|
48d107cc13 | ||
|
|
3ef250c30a | ||
|
|
c7b489f8cb | ||
|
|
ce12000af3 | ||
|
|
3c72f4dc51 | ||
|
|
ce85981a4e | ||
|
|
193c666bf9 | ||
|
|
705d10a96d | ||
|
|
c0056ab73f | ||
|
|
3df542f072 | ||
|
|
09212abdf7 | ||
|
|
ee6be4f6b9 | ||
|
|
4e3b20ed73 | ||
|
|
6a82a055d3 | ||
|
|
7e65816d63 | ||
|
|
5bffa4b7f9 | ||
|
|
d1c0ecceb9 | ||
|
|
1d683865cf | ||
|
|
4aef7c5ac5 | ||
|
|
968053649b | ||
|
|
ac48860bbb | ||
|
|
46e6d23dd2 | ||
|
|
55c9514c6b | ||
|
|
86c1e83ea1 | ||
|
|
3273fe0470 | ||
|
|
bb9372114c | ||
|
|
7468a5e96c | ||
|
|
a87faa0db7 | ||
|
|
b2bbd13c27 | ||
|
|
22c61a1ecb | ||
|
|
e271395971 | ||
|
|
32843f30d9 | ||
|
|
469aa8feab | ||
|
|
748d5b69a5 | ||
|
|
3b2fe3aec8 | ||
|
|
d9eb1f7f00 | ||
|
|
f5a72bb19a | ||
|
|
35bf7ee538 | ||
|
|
c8895cab77 | ||
|
|
b669a73432 | ||
|
|
833f7fbdbe | ||
|
|
62ce8e0bda | ||
|
|
ddd25bfe01 | ||
|
|
19da45c53b | ||
|
|
0026410c61 | ||
|
|
b57c59baa4 | ||
|
|
a356c8359c | ||
|
|
b138b92d39 | ||
|
|
58e2903177 | ||
|
|
af8a5f2c21 | ||
|
|
d6400aef27 | ||
|
|
81fe65afed | ||
|
|
c2b58720d1 | ||
|
|
5515aa5045 | ||
|
|
15150db957 | ||
|
|
3b2e467ca6 | ||
|
|
0c9e8cdf8d | ||
|
|
8d624b3800 | ||
|
|
4fbb83a34d | ||
|
|
961e22493c | ||
|
|
09ee8e34a5 | ||
|
|
7e832105d7 | ||
|
|
ff6a7b6007 | ||
|
|
6312e7f1f3 | ||
|
|
bfb375ac87 | ||
|
|
21d277a0ef | ||
|
|
c3e3c900f2 | ||
|
|
05c8d81e65 | ||
|
|
cd6276eef9 | ||
|
|
7bcaa2fd13 | ||
|
|
67ecd7c147 | ||
|
|
ce6ff294cf | ||
|
|
b318ab46cb | ||
|
|
5890600101 | ||
|
|
e2a9414c7a | ||
|
|
216965e9d9 | ||
|
|
d0ddbcc2b2 | ||
|
|
41db2601a6 | ||
|
|
42cb94e1f4 | ||
|
|
6e7a0cc65d | ||
|
|
23eba82038 | ||
|
|
001b9acb63 | ||
|
|
af65ccfd6a | ||
|
|
0c7251475d | ||
|
|
1a87b2f37d | ||
|
|
752a0e13ad | ||
|
|
ccaca33446 | ||
|
|
2a90e805a2 | ||
|
|
c4a2d70d19 | ||
|
|
f7e4a0177d | ||
|
|
cca65499de | ||
|
|
80fa7dbbfa | ||
|
|
c24b1e5250 | ||
|
|
78cf8f1f9f | ||
|
|
1da7277817 | ||
|
|
c71c95feb0 | ||
|
|
3bee31e6c7 | ||
|
|
9448ca58aa | ||
|
|
c9a236b0af | ||
|
|
622c15e825 | ||
|
|
054598734a | ||
|
|
7ca647f0d0 | ||
|
|
aa50fcb1f0 | ||
|
|
b408de0761 | ||
|
|
72d9c5ee5c | ||
|
|
2b7440d4b5 | ||
|
|
3bc6a18bcd | ||
|
|
db56d6cb11 | ||
|
|
a5759139bf | ||
|
|
8a959da120 | ||
|
|
0a78750465 | ||
|
|
372f4fc924 | ||
|
|
ae5b401e74 | ||
|
|
c562655be7 | ||
|
|
c8bb54cd94 | ||
|
|
8c80326dd5 | ||
|
|
bad4bed439 | ||
|
|
7828da15c3 | ||
|
|
7e2f6063ae | ||
|
|
2b766a2f26 | ||
|
|
8ae504bfb0 | ||
|
|
5981e6c57c | ||
|
|
1be3a1e945 | ||
|
|
629b897845 | ||
|
|
9f5fee404b | ||
|
|
40bf98711c | ||
|
|
f9f075bca2 | ||
|
|
0c1a3d59eb | ||
|
|
523fb5cd56 | ||
|
|
436f61a7f4 | ||
|
|
3fab5869fa | ||
|
|
0515c6e844 | ||
|
|
38176181ac | ||
|
|
a7e634bd4f | ||
|
|
78a381a30b | ||
|
|
343bce6a29 | ||
|
|
d263f762bf | ||
|
|
dfaeb19566 | ||
|
|
010dcc3e80 | ||
|
|
d0aa5f747c | ||
|
|
f6d53e03f1 | ||
|
|
3ecebd15ee | ||
|
|
db83e39a7f | ||
|
|
5d48f72ade | ||
|
|
1818026a84 | ||
|
|
0ad7d38eec | ||
|
|
b17ad5c2be | ||
|
|
1824b3c07b | ||
|
|
c9c7da3626 | ||
|
|
030a90523d | ||
|
|
56d223a51d | ||
|
|
f558ff826a | ||
|
|
5fb4ed60e7 | ||
|
|
0d2a358cc2 | ||
|
|
595250c93e | ||
|
|
c636988935 | ||
|
|
eea483c470 | ||
|
|
d53c61a6d0 | ||
|
|
c0d4f71a34 | ||
|
|
f56989e46e | ||
|
|
c0251eb680 | ||
|
|
450b81ca13 | ||
|
|
2f3faadcbf | ||
|
|
5986a2d126 | ||
|
|
d75e84f625 | ||
|
|
c221277fd2 | ||
|
|
3b30fadb55 | ||
|
|
d7df4d6b84 | ||
|
|
fd854035c1 | ||
|
|
4d1c138842 | ||
|
|
7649239b08 | ||
|
|
0e2f6ba1b6 | ||
|
|
f529c46598 | ||
|
|
1ba49d2ddb | ||
|
|
1b5ca88231 | ||
|
|
37329e0784 | ||
|
|
eaff393c76 | ||
|
|
a845cd8880 | ||
|
|
b28a465304 | ||
|
|
845d3114ea | ||
|
|
287fa7ca74 | ||
|
|
80ed9654e1 | ||
|
|
7ddab7ef31 | ||
|
|
d534a7f7c8 | ||
|
|
5af51c852c | ||
|
|
ee7970f603 | ||
|
|
5453877ca7 | ||
|
|
ea0a5271f7 | ||
|
|
879cc4ec26 | ||
|
|
6ac2475aba | ||
|
|
47d5f659e0 | ||
|
|
8c9e51e94f | ||
|
|
0da5aca9f6 | ||
|
|
9906db9e64 | ||
|
|
8096b568f0 | ||
|
|
2934a77832 | ||
|
|
cf6cb938a6 | ||
|
|
8ff6b1b540 | ||
|
|
a938a9ab0f | ||
|
|
ae73386723 | ||
|
|
34c8a859eb | ||
|
|
80d039042b | ||
|
|
5606e22d97 | ||
|
|
23e35fa526 | ||
|
|
82033f935e | ||
|
|
ae2b0e7aa7 | ||
|
|
948615537b | ||
|
|
a0e129304c | ||
|
|
8d72d538de | ||
|
|
ffefd0caf2 | ||
|
|
fa196986c2 | ||
|
|
a30e02c18c | ||
|
|
c9f3726447 | ||
|
|
8363200fd7 | ||
|
|
37548eb720 | ||
|
|
dadce6032d | ||
|
|
0a1d2ce231 | ||
|
|
9d01c5d882 | ||
|
|
53fc2edab3 | ||
|
|
b7c5b78a61 | ||
|
|
f081dc2001 | ||
|
|
2cf7daa227 | ||
|
|
9d75fbc619 | ||
|
|
3b1b9a277b | ||
|
|
40e87b9544 | ||
|
|
ded7922be5 | ||
|
|
11ef64ee43 | ||
|
|
5e6d7b7649 | ||
|
|
5fd9616b5f | ||
|
|
a1227648ba | ||
|
|
919f4173cf | ||
|
|
7c5aad4073 | ||
|
|
d47ccd9199 | ||
|
|
cc5e884b34 | ||
|
|
ac5535055f | ||
|
|
15cb4dafa9 | ||
|
|
8ca76d9fdf | ||
|
|
f62e52ec68 | ||
|
|
bf01c674ea | ||
|
|
e9b6a05b75 | ||
|
|
6bbc1b4316 | ||
|
|
3c696da274 | ||
|
|
d9d6dee550 | ||
|
|
cc6306c0e1 | ||
|
|
b59145385e | ||
|
|
3f4e0ec971 | ||
|
|
ec0716ddd1 | ||
|
|
6d6725b3b8 | ||
|
|
6660be2cb7 | ||
|
|
847fcb570b | ||
|
|
4095ec462e | ||
|
|
f7f2421e71 | ||
|
|
b664a46e91 | ||
|
|
06e6eaa7b4 | ||
|
|
30a094cbb2 | ||
|
|
904bae98f8 | ||
|
|
c32f13a909 | ||
|
|
519093ea65 | ||
|
|
bd49d1c4b5 | ||
|
|
2665c0099d | ||
|
|
d65f055030 | ||
|
|
66d87761b7 | ||
|
|
ba69ad672a | ||
|
|
7934e3956b | ||
|
|
68fe93b7db | ||
|
|
efd0ea9e1e | ||
|
|
6ef73eb226 | ||
|
|
fc2f23d36c | ||
|
|
7c39fab453 | ||
|
|
c5164c01c0 | ||
|
|
351ad32d77 | ||
|
|
3ad8311bdd | ||
|
|
ea5ae2bae5 | ||
|
|
72e3adc55e | ||
|
|
b250392e8d | ||
|
|
d8b0d68840 | ||
|
|
c4737749ab | ||
|
|
a1ab02f9fb | ||
|
|
bba64b32ca | ||
|
|
9abd2aa9d7 | ||
|
|
de35a9a605 | ||
|
|
ed750e8792 | ||
|
|
37ca50832c | ||
|
|
31c7a0105b | ||
|
|
ddab9eafa1 | ||
|
|
76a4f86e0c | ||
|
|
6b34318274 | ||
|
|
5508c6c154 | ||
|
|
9a62ac0c94 | ||
|
|
01737ef847 | ||
|
|
3144b572c4 | ||
|
|
10de92987a | ||
|
|
c752c14c46 | ||
|
|
87a8bf5e96 | ||
|
|
ba14ea1243 | ||
|
|
9be90011c6 | ||
|
|
f9b14ca149 | ||
|
|
6591acfdfa | ||
|
|
e64ba122e1 | ||
|
|
a9523146a3 | ||
|
|
392ee86714 | ||
|
|
1d73f484f0 | ||
|
|
cfcd3ae048 | ||
|
|
5395041dcb | ||
|
|
40eabd50d1 | ||
|
|
35ffd0ec3a | ||
|
|
d3d76bf97a | ||
|
|
595ae42e94 | ||
|
|
0667d940f9 | ||
|
|
75d1272325 | ||
|
|
8e2d6cf87d | ||
|
|
9e1bba40f7 | ||
|
|
f7bb499c28 | ||
|
|
b33b1ef3dd | ||
|
|
30aeda7a1a | ||
|
|
22d9d660cc | ||
|
|
7524bfc07f | ||
|
|
bda7472880 | ||
|
|
1ed05c6c07 | ||
|
|
0b3e0a59cb | ||
|
|
0616f68eb0 | ||
|
|
6b8e5a4c92 | ||
|
|
d72c887422 | ||
|
|
664d09e86a | ||
|
|
e226b1a87f | ||
|
|
b227666271 | ||
|
|
6fea050813 | ||
|
|
cf67964133 | ||
|
|
f8d04b11d5 | ||
|
|
3a29cbf0ae | ||
|
|
66f5de9703 | ||
|
|
cbaca2b579 | ||
|
|
a76d9b15c9 | ||
|
|
59636fa688 | ||
|
|
ff0908d3fa | ||
|
|
21f35762ca | ||
|
|
7464720426 | ||
|
|
6e57c40c37 | ||
|
|
c8518f4ab2 | ||
|
|
b9c061ab3d | ||
|
|
d905bbf961 | ||
|
|
6641e7aa50 | ||
|
|
61c15b69fb | ||
|
|
8ec0c4c913 | ||
|
|
0a9d6e8210 | ||
|
|
0ed800b612 | ||
|
|
4ac005b094 | ||
|
|
5e3a53b576 | ||
|
|
e87146b0d9 | ||
|
|
5caa79df67 | ||
|
|
d519e1036f | ||
|
|
19eebc0b0a | ||
|
|
5585020753 | ||
|
|
ef7e7a8f11 | ||
|
|
eb91f27b65 | ||
|
|
24eef577c5 | ||
|
|
e7e4ccf74f | ||
|
|
017ecf76e3 | ||
|
|
1c9ceadd8d | ||
|
|
36ab7b3ebd | ||
|
|
b4038597ba | ||
|
|
79817bd465 | ||
|
|
93ad8f04b5 | ||
|
|
e4cb7ed30f | ||
|
|
b9e060423f | ||
|
|
ead1ec3396 | ||
|
|
306a8cd059 | ||
|
|
4c50deb4b7 | ||
|
|
be75426e64 | ||
|
|
23458de588 | ||
|
|
9fd849d48b | ||
|
|
2b28bc9510 | ||
|
|
d107b3f46c | ||
|
|
44149bec60 | ||
|
|
f80b4fdedd | ||
|
|
fd4a90549b | ||
|
|
b602a0836a | ||
|
|
7349fca607 | ||
|
|
4bacc8e47d | ||
|
|
7141f89c5f | ||
|
|
893654fb15 | ||
|
|
c9e1d054c7 | ||
|
|
2e2eeb0a42 | ||
|
|
0f342ac46e | ||
|
|
29ac324e90 | ||
|
|
23f11e355d | ||
|
|
f09016b2bc | ||
|
|
1fa3aeceeb | ||
|
|
443afdc412 | ||
|
|
776befc1f0 | ||
|
|
3edbc74430 | ||
|
|
3172c96042 | ||
|
|
60473637fe | ||
|
|
b969f34317 | ||
|
|
6c46fbbc57 | ||
|
|
87115b02d9 | ||
|
|
c614520405 | ||
|
|
3756f5a0ca | ||
|
|
5b4e4bb858 | ||
|
|
dffd90b966 | ||
|
|
a92a0c3ed3 | ||
|
|
0774b1efa5 | ||
|
|
7fc7eb7457 | ||
|
|
602a327aa8 | ||
|
|
14c6ae4735 | ||
|
|
493a0e377d | ||
|
|
5e3e108143 | ||
|
|
66dbd3cd34 | ||
|
|
9a1e44dc78 | ||
|
|
37b267ffb3 | ||
|
|
dfa199f98f | ||
|
|
c6d107a05f | ||
|
|
ddbcf449da | ||
|
|
9fa61439b1 | ||
|
|
02dd1ea29d | ||
|
|
a38215de98 | ||
|
|
85b5260d9d | ||
|
|
4b4ebad9a9 | ||
|
|
ece4c739f4 | ||
|
|
85ae34cf9f | ||
|
|
0448f0ce56 | ||
|
|
4835d82a0b | ||
|
|
eaddee9fe2 | ||
|
|
2190764162 | ||
|
|
3b91764587 | ||
|
|
0c3ec549f8 | ||
|
|
fca686e7f8 | ||
|
|
607e28749a | ||
|
|
bffab21b10 | ||
|
|
d9165c7f77 | ||
|
|
2ef58ccce9 | ||
|
|
4009804221 | ||
|
|
151f691609 | ||
|
|
81993b6a15 | ||
|
|
4eb3817b03 | ||
|
|
18cb514073 | ||
|
|
ddd40d87a7 | ||
|
|
137272b8de | ||
|
|
e400ae900d | ||
|
|
c388dca5ec | ||
|
|
6a691db7f8 | ||
|
|
ed783b67ca | ||
|
|
ee372a7b30 | ||
|
|
66f39aaa92 | ||
|
|
03af99650d | ||
|
|
44a2ff07b1 | ||
|
|
fb95540394 | ||
|
|
be00fafb29 | ||
|
|
0bc376a17b | ||
|
|
05d5de47cb | ||
|
|
d3eb604d66 | ||
|
|
d6db210ef3 | ||
|
|
80ca42922f | ||
|
|
fe5df6d06f | ||
|
|
535442179e | ||
|
|
b17dae9ac0 | ||
|
|
5fad37aebd | ||
|
|
311933614e | ||
|
|
8fa6502b16 | ||
|
|
1f537e1b60 | ||
|
|
5bac65f8b8 | ||
|
|
911630000f | ||
|
|
6e8a3fe8de | ||
|
|
2a14948123 | ||
|
|
61e5eed493 | ||
|
|
d30830a55c | ||
|
|
102c46f88b | ||
|
|
5fa9bc67d7 | ||
|
|
3503fbf7fe | ||
|
|
1cc733f801 | ||
|
|
7a27cbcc78 | ||
|
|
6f8e670dee | ||
|
|
df4e9f4e1e | ||
|
|
3747f5bdd8 | ||
|
|
56766cffc3 | ||
|
|
692c676625 | ||
|
|
ddfd7def35 | ||
|
|
bcaee4d179 | ||
|
|
539a57026d | ||
|
|
654f49ccec | ||
|
|
c1376a9f2a | ||
|
|
9ac999ca59 | ||
|
|
6a1964f146 | ||
|
|
90018755c5 | ||
|
|
95211e2665 | ||
|
|
7cd94e5486 | ||
|
|
35ef6a9204 | ||
|
|
41272e7148 | ||
|
|
8ff39d8432 | ||
|
|
e22f57cae5 | ||
|
|
67afb0e3fe | ||
|
|
f56f31c277 | ||
|
|
b7c4754be2 | ||
|
|
3118f32221 | ||
|
|
c9cc504e7b | ||
|
|
b9d189bf12 | ||
|
|
c32012c44a | ||
|
|
dfce44fa3b | ||
|
|
42a6260b65 | ||
|
|
5353be74c3 | ||
|
|
12542bf922 | ||
|
|
def737edee | ||
|
|
60518449fc | ||
|
|
09d4e37044 | ||
|
|
e14640e530 | ||
|
|
7f734f0a18 | ||
|
|
cd2f886234 | ||
|
|
dda4fe10b3 | ||
|
|
148a896cca | ||
|
|
e8eb589d6e | ||
|
|
770b6d25ae | ||
|
|
be10d90f07 | ||
|
|
fd3fa1ef45 | ||
|
|
a57943b77e | ||
|
|
6fafdb7711 | ||
|
|
0f7625e29a | ||
|
|
7afccfc92d | ||
|
|
6e59da26d4 | ||
|
|
928930ddd5 | ||
|
|
6d2f7af642 | ||
|
|
5b995ba080 | ||
|
|
168a1315de | ||
|
|
c101b2a5cb | ||
|
|
971c361e0f | ||
|
|
be50b2bec6 | ||
|
|
49c918defa | ||
|
|
d595623162 | ||
|
|
169e739634 | ||
|
|
08138c7c23 | ||
|
|
bbb012ad0f | ||
|
|
331d28102f | ||
|
|
efa69875d9 | ||
|
|
59797bfc7b | ||
|
|
c0f9c891f5 | ||
|
|
33514b28be | ||
|
|
e3a913e03f | ||
|
|
7e80337e5b | ||
|
|
8d4723d91b | ||
|
|
4cdf680a81 | ||
|
|
63e67f72e3 | ||
|
|
0cd66c3a89 | ||
|
|
b092a624ed | ||
|
|
24e84d7ca1 | ||
|
|
14f9056349 | ||
|
|
b444e2e074 | ||
|
|
723cb4d520 | ||
|
|
90116155b4 | ||
|
|
0d01c0e935 | ||
|
|
e002509bf2 | ||
|
|
19c5c74291 | ||
|
|
b6fec60243 | ||
|
|
9d0fa8112b | ||
|
|
d30f5b1bef | ||
|
|
7691b0d721 | ||
|
|
b8c954eb3f | ||
|
|
a8c146fd13 | ||
|
|
70df41bc62 | ||
|
|
1782753387 | ||
|
|
23ccf4429e | ||
|
|
bf4e799dba | ||
|
|
cb695bdec3 | ||
|
|
be70eb881a | ||
|
|
867c277088 | ||
|
|
96f72f009a | ||
|
|
cf4a466b6b | ||
|
|
087e4626ce | ||
|
|
64462c842b | ||
|
|
e0f73fe742 | ||
|
|
ea4c831de0 | ||
|
|
51387b2c80 | ||
|
|
2d8dd87cad | ||
|
|
d9dd2a038b | ||
|
|
1227ce8091 | ||
|
|
cd63c80be8 | ||
|
|
e0a5eebe79 | ||
|
|
850069af75 | ||
|
|
672fcee8aa | ||
|
|
d9b023c11f | ||
|
|
6b228f56cb | ||
|
|
dd645e6da4 | ||
|
|
149f46c184 | ||
|
|
96839c48c9 | ||
|
|
3e27d5e885 | ||
|
|
38fc876704 | ||
|
|
39d5a99095 | ||
|
|
2beb306834 | ||
|
|
f3e595e2f0 | ||
|
|
5d80d11b23 | ||
|
|
621529e9dc | ||
|
|
535aff8f7e | ||
|
|
7531280764 | ||
|
|
63daa8b15a | ||
|
|
92913e1eb8 | ||
|
|
418be3daa8 | ||
|
|
7e3b2ddff2 | ||
|
|
312d93961a | ||
|
|
8f05d8d546 | ||
|
|
f5ddea481a | ||
|
|
29ca8271b3 | ||
|
|
3084537d1e | ||
|
|
86ac994543 | ||
|
|
992b082c6f | ||
|
|
31fe263356 | ||
|
|
7a0b20c740 | ||
|
|
9810f6b695 | ||
|
|
09c74c04a0 | ||
|
|
b6cc932c09 | ||
|
|
1b5d918cb9 | ||
|
|
bf76d4a43c | ||
|
|
53b4b2fcbc | ||
|
|
9a8629a6a9 | ||
|
|
78308365ec | ||
|
|
976075578f | ||
|
|
243233f652 | ||
|
|
d66eea42bb | ||
|
|
c55f73bbc3 | ||
|
|
3e30d4270b | ||
|
|
80916baa21 | ||
|
|
1df8f041bd | ||
|
|
6a6e2a8cd1 | ||
|
|
f9d337b320 | ||
|
|
feb069f604 | ||
|
|
7e0eed5772 | ||
|
|
9bdd040dd0 | ||
|
|
e5dabf265a | ||
|
|
1a1046a0ef | ||
|
|
dd18319b44 | ||
|
|
d3cd7e92d1 | ||
|
|
553e7d8aaa | ||
|
|
f79b8287f5 | ||
|
|
b4c98f6cc3 | ||
|
|
5d4a0ac844 | ||
|
|
0136b02e5b | ||
|
|
f49a01703a | ||
|
|
e4f82aa441 | ||
|
|
751d1af2a6 | ||
|
|
076d8fbb84 | ||
|
|
4b2d01a453 | ||
|
|
a71fa25ebe | ||
|
|
b4db54cb1f | ||
|
|
b2ca600e79 | ||
|
|
83725a1330 | ||
|
|
587b837a6c | ||
|
|
2844fe959f | ||
|
|
41e271974a | ||
|
|
520d37983c | ||
|
|
487d82773a | ||
|
|
066085f6f5 | ||
|
|
0d1f5b7193 | ||
|
|
2f3a439566 | ||
|
|
9681ffca52 | ||
|
|
fddc60f893 | ||
|
|
0f024cc225 | ||
|
|
575ec2a06f | ||
|
|
83aef0a27d | ||
|
|
bc85d30076 | ||
|
|
bc417726fc | ||
|
|
9949a2a930 | ||
|
|
71e1cb472f | ||
|
|
38161ede33 | ||
|
|
70dd1e6263 | ||
|
|
e626c9c8b9 | ||
|
|
fa5f8f9531 | ||
|
|
acfe31151e | ||
|
|
cb71b714d7 | ||
|
|
4c6655f68c | ||
|
|
490836a7b3 | ||
|
|
c11c909bad | ||
|
|
5c9401ad94 | ||
|
|
768987583a | ||
|
|
cb58a8c776 | ||
|
|
4f0d3b065f | ||
|
|
a95c44193d | ||
|
|
2830853665 | ||
|
|
a4ca79c9b3 | ||
|
|
85b0878334 | ||
|
|
d61852a73f | ||
|
|
14b6224de7 | ||
|
|
f0958c7d9b | ||
|
|
01de7f9e36 | ||
|
|
9f9148a1c6 | ||
|
|
73db1b3822 | ||
|
|
abca68bf24 | ||
|
|
eeca841a21 | ||
|
|
3a9b86ad55 | ||
|
|
f1cc141f6c | ||
|
|
3011209e28 | ||
|
|
29bf6a8d42 | ||
|
|
c282466750 | ||
|
|
de9ea94f57 | ||
|
|
fe7640555d | ||
|
|
ec809ca487 | ||
|
|
1dc99ea451 | ||
|
|
f12ace3fbf | ||
|
|
c09e610bb5 | ||
|
|
712abf4c5f | ||
|
|
261df4b386 | ||
|
|
b0f399a51d | ||
|
|
348d112388 | ||
|
|
5c35a5d9fc | ||
|
|
a26bb50d62 | ||
|
|
a59f437ee3 | ||
|
|
d74c698adc | ||
|
|
8d8fe8fd29 | ||
|
|
c1c50f6714 | ||
|
|
d7ca68d8e9 | ||
|
|
01b09c065b | ||
|
|
08104fd49c | ||
|
|
3b601f615a | ||
|
|
b1f7fe24f6 | ||
|
|
fbd58f2eec | ||
|
|
79fc3bb84e | ||
|
|
8e4928c7ea | ||
|
|
d078cbf39b | ||
|
|
561596d8bc | ||
|
|
549b489c8a | ||
|
|
1e9f374ff8 | ||
|
|
817fcfdd88 | ||
|
|
fab50256bc | ||
|
|
b044608b25 | ||
|
|
ce4fb8ce20 | ||
|
|
adf91d286b | ||
|
|
0c1c7a3dd9 | ||
|
|
5b71751391 | ||
|
|
12f6709e1c | ||
|
|
5229f1e220 | ||
|
|
b6ca7929eb | ||
|
|
43204ca67b | ||
|
|
ad8d9a97d6 | ||
|
|
36f32f58d4 | ||
|
|
b4fd4212ad | ||
|
|
a1d34faaad | ||
|
|
a2368db154 | ||
|
|
381e07b7b6 | ||
|
|
74bb748a4e | ||
|
|
09113fc73c | ||
|
|
8638c9ab77 | ||
|
|
b676b10cfe | ||
|
|
f68c257452 | ||
|
|
880fc069bd | ||
|
|
a838238a63 | ||
|
|
834995b130 | ||
|
|
b000ae7614 | ||
|
|
f62779671b | ||
|
|
4b292c6e9b | ||
|
|
1c13100948 | ||
|
|
71226feb74 | ||
|
|
b9b4feada8 | ||
|
|
3175f09989 | ||
|
|
322d6b8cfe | ||
|
|
da36a6b5cd | ||
|
|
f2b2ca6d55 | ||
|
|
0ebe3900e0 | ||
|
|
ec3140a29e | ||
|
|
00b0a00fc5 | ||
|
|
adb970edcc | ||
|
|
6d24a4744f | ||
|
|
b1a5ef0aab | ||
|
|
7ec752ed1c | ||
|
|
0de696feaf | ||
|
|
d6b53c5e7a | ||
|
|
3456a78552 | ||
|
|
eb3d63691a | ||
|
|
c4ee937635 | ||
|
|
f6d1fb7ac2 | ||
|
|
97ef4a6c22 | ||
|
|
db7215eaa9 | ||
|
|
4b37a4a415 | ||
|
|
d1ad23e2d8 | ||
|
|
caa231aebe | ||
|
|
9cc31c2258 | ||
|
|
e2844f3a92 | ||
|
|
2e3d85c31a | ||
|
|
25af262e79 | ||
|
|
d0ef1ef174 | ||
|
|
905ace3e13 | ||
|
|
9092d35a3c | ||
|
|
2bdaa70f31 | ||
|
|
f91a3bc6ab | ||
|
|
1e4592dd7e | ||
|
|
50dc2fc7a5 | ||
|
|
76727455ca | ||
|
|
cf94b8e6e0 | ||
|
|
1cf9f43dfe | ||
|
|
2097554c09 | ||
|
|
56686dee40 | ||
|
|
763ee521be | ||
|
|
0bfdf9a785 | ||
|
|
fa573dabf0 | ||
|
|
abdf642d68 | ||
|
|
0dfd1b74c8 | ||
|
|
0d3fb5ee0d | ||
|
|
02277ec2cf | ||
|
|
70661ce50d | ||
|
|
8fc12b1526 | ||
|
|
439db1aae0 | ||
|
|
8afbb9c462 | ||
|
|
5c52a1393f | ||
|
|
112cd1787c | ||
|
|
d1550670a8 | ||
|
|
58f9974be4 | ||
|
|
3a2e7d3c3b | ||
|
|
c1b6f0e833 | ||
|
|
5f08e41a85 | ||
|
|
5d8a21b0de | ||
|
|
9e8888b603 | ||
|
|
623b71e81e | ||
|
|
c5c7e76805 | ||
|
|
e4b3d35ed8 | ||
|
|
33e55bd82e | ||
|
|
9543ab4db6 | ||
|
|
97909ce56e | ||
|
|
2f2484e186 | ||
|
|
2062b10b79 | ||
|
|
a0b022afee | ||
|
|
5a47cef9a8 | ||
|
|
9538790b33 | ||
|
|
4e2568fd6e | ||
|
|
dc5a3d4a62 | ||
|
|
7b02fdaddc | ||
|
|
c0d169e79e | ||
|
|
9840b5c7fb | ||
|
|
1ef061d92b | ||
|
|
79a1212ebe | ||
|
|
8d0269fcc4 | ||
|
|
5e656bb58a | ||
|
|
d9c0190497 | ||
|
|
5dffe566fd | ||
|
|
b769877183 | ||
|
|
446b66b0fe | ||
|
|
d0ec081e49 | ||
|
|
65130d9ee7 | ||
|
|
638009fb2b | ||
|
|
7f84f59472 | ||
|
|
4f8c771bb5 | ||
|
|
9e69f33f3c | ||
|
|
0da8fa115e | ||
|
|
811bc2f421 | ||
|
|
caaf8d3f40 | ||
|
|
7473cc6e27 | ||
|
|
56c9633c53 | ||
|
|
93002e734c | ||
|
|
60f6d1c373 | ||
|
|
a03d9d496e | ||
|
|
7904637893 | ||
|
|
def1596eaf | ||
|
|
5795254b2a | ||
|
|
fe5a494035 | ||
|
|
13e864d29f | ||
|
|
a780cff8fd | ||
|
|
7cb2dcbdf8 | ||
|
|
f068d7f978 | ||
|
|
18d4d6097a | ||
|
|
b119bb4ab0 | ||
|
|
d65b5db97f | ||
|
|
d4be4d80db | ||
|
|
9996c59183 | ||
|
|
88bf867a3e | ||
|
|
7009906d55 | ||
|
|
ca1bb7dc1c | ||
|
|
aa04124bfc | ||
|
|
2be834fced | ||
|
|
11c81ab4cb | ||
|
|
0f767e3743 | ||
|
|
92d954ddfe | ||
|
|
1e659bb17b | ||
|
|
e8bd5ea4e0 | ||
|
|
d765397c82 | ||
|
|
d46a2713d2 | ||
|
|
8932f302ce | ||
|
|
51105d3b1c | ||
|
|
efc1225cd8 | ||
|
|
41220a7f96 | ||
|
|
7312c13665 | ||
|
|
e6220a1346 | ||
|
|
3ef0830c5d | ||
|
|
eb7616ca0f | ||
|
|
592fcbc71f | ||
|
|
20e1caef47 | ||
|
|
2d19b78dd8 | ||
|
|
99551fc21b | ||
|
|
d30641e9ca | ||
|
|
2716c1aebb | ||
|
|
1a65eed724 | ||
|
|
a26a0a4eec | ||
|
|
a56ac66e6c | ||
|
|
7e2d7601f2 | ||
|
|
1550b7d6ba | ||
|
|
9f40896f4a | ||
|
|
75c0718691 | ||
|
|
509a56a43d | ||
|
|
2d7785ae0c | ||
|
|
d0552e765e | ||
|
|
3a7c1f2469 | ||
|
|
df6ba0e824 | ||
|
|
6609f9e3be | ||
|
|
1c4f0b2ccf | ||
|
|
10fc870684 | ||
|
|
dffbaca63b | ||
|
|
b3c8f0e1f6 | ||
|
|
bc5a5e37ea | ||
|
|
33c6c4f0ee | ||
|
|
39c16c0fe4 | ||
|
|
1cb64caae4 | ||
|
|
b258f4f394 | ||
|
|
c47369839b | ||
|
|
b924e897f1 | ||
|
|
e818c33fec | ||
|
|
9278a6fe59 | ||
|
|
3593ebb8aa | ||
|
|
464639aa0f | ||
|
|
4acbe8e473 | ||
|
|
7ad553670f | ||
|
|
2185fb8367 | ||
|
|
cbcf50960f | ||
|
|
89846d1656 | ||
|
|
e5175f5dc1 | ||
|
|
1a6dcec83a | ||
|
|
fe260f1330 | ||
|
|
991d8e1ec6 | ||
|
|
49a0e8aa19 | ||
|
|
912f0286b3 | ||
|
|
dcf29e1081 | ||
|
|
529f7962f4 | ||
|
|
8a11c6c429 | ||
|
|
4cbf866821 | ||
|
|
e0e23636c6 | ||
|
|
295f496e8a | ||
|
|
47a1bc34de | ||
|
|
6d837e3e07 | ||
|
|
1b671d4302 | ||
|
|
c30b32e173 | ||
|
|
9e798fea75 | ||
|
|
384afb3455 | ||
|
|
92a7c8cd17 | ||
|
|
8b7735c20a | ||
|
|
7d748fa384 | ||
|
|
d767990424 | ||
|
|
ef438852cd | ||
|
|
40ced3ff8d | ||
|
|
5f5402a3ab | ||
|
|
26dcb9e66d | ||
|
|
956012da95 | ||
|
|
24192fc550 | ||
|
|
efca63f9ce | ||
|
|
c3552cecdf | ||
|
|
0f94ef8abc | ||
|
|
0275b36fb0 | ||
|
|
1b5fc61eb6 | ||
|
|
0fee81678e | ||
|
|
c4d898a265 | ||
|
|
e389c088eb | ||
|
|
ceb8d6e1c9 | ||
|
|
0cc79d414f | ||
|
|
8d11b368d1 | ||
|
|
706643dfed | ||
|
|
b192cb9c1f | ||
|
|
998d5ead34 | ||
|
|
ec7eb7798f | ||
|
|
a717925caa | ||
|
|
88ae02f8d9 | ||
|
|
eb03a3ccb1 | ||
|
|
77740829bd | ||
|
|
928fb34eff | ||
|
|
1e6b40a24b | ||
|
|
78217bcf18 | ||
|
|
53c88d9fa3 | ||
|
|
b14fdb1163 | ||
|
|
3d5fba94c2 | ||
|
|
3ee2b07918 | ||
|
|
8bc7dd8b03 | ||
|
|
e6fd1afc3d | ||
|
|
a961f0ce75 | ||
|
|
cea0c1f41d | ||
|
|
703d2026e4 | ||
|
|
3d85b2d854 | ||
|
|
bb79a15c04 | ||
|
|
4fe2a13c71 | ||
|
|
51829ad85e | ||
|
|
c78f351300 | ||
|
|
ee675eadf1 | ||
|
|
33830d5ecf | ||
|
|
2b154524bb | ||
|
|
b626d02ffe | ||
|
|
9ce68d11a7 | ||
|
|
5a38f13cae | ||
|
|
7055384aeb | ||
|
|
0c41adf868 | ||
|
|
1ba46f8f77 | ||
|
|
f80ea24d2b | ||
|
|
d34d7cbc37 | ||
|
|
5014f74649 | ||
|
|
1f32f35d9e | ||
|
|
f3b6bf55a6 | ||
|
|
9e6a7e3aa9 | ||
|
|
77481d7c76 | ||
|
|
c2461e5066 | ||
|
|
e4bd1bc5ce | ||
|
|
90f57c1329 | ||
|
|
6af769af20 | ||
|
|
6bcf20c70e | ||
|
|
bb79695e44 | ||
|
|
ea5517bc8c | ||
|
|
da08a1f25c | ||
|
|
a72d2f66cd | ||
|
|
e5df58bc04 | ||
|
|
662ffc8fa5 | ||
|
|
ce5e4743e6 | ||
|
|
dd2914873b | ||
|
|
d9a29cae60 | ||
|
|
7a737d2bd3 | ||
|
|
881b099c8e | ||
|
|
c6bb36efa5 | ||
|
|
526a05565e | ||
|
|
09f13823f4 | ||
|
|
b8e535579f | ||
|
|
63d443deb8 | ||
|
|
f8c338e3a7 | ||
|
|
6c470cf687 | ||
|
|
ec63e13896 | ||
|
|
1746132c7d | ||
|
|
ec230c2835 | ||
|
|
bf3c04f2dc | ||
|
|
45665245dc | ||
|
|
94c5c5843b | ||
|
|
c05d260d9a | ||
|
|
8eceba98d3 | ||
|
|
2c380731b9 | ||
|
|
7ce74f95a2 | ||
|
|
a3813dd453 | ||
|
|
ec3a08ea0c | ||
|
|
b0717b75d9 | ||
|
|
6359a08cfe | ||
|
|
f87afbc558 | ||
|
|
8df5f73706 | ||
|
|
9eaf048a06 | ||
|
|
adfdb99abc | ||
|
|
ae1655586c | ||
|
|
698a1ea582 | ||
|
|
87412f63ef | ||
|
|
09d9a29176 | ||
|
|
dd9eae8c26 | ||
|
|
a1d04fbff5 | ||
|
|
dd1a08087b | ||
|
|
51ba1bd7d3 | ||
|
|
f881e8691e | ||
|
|
94c0858c27 | ||
|
|
6aaa4a8e19 | ||
|
|
cb23775d18 | ||
|
|
0344cf5874 | ||
|
|
4a1b033765 | ||
|
|
dcd60a5b45 | ||
|
|
b1962c8e02 | ||
|
|
40ef9a3c6a | ||
|
|
2206a44baf | ||
|
|
4ee6ce7871 | ||
|
|
6cb8052d3d | ||
|
|
73973e2b9e | ||
|
|
89e05fc6c5 | ||
|
|
248e9b3808 | ||
|
|
79c63049d7 | ||
|
|
96cffeab1e | ||
|
|
39a18d4edc | ||
|
|
6e1ddfea5a | ||
|
|
d8af4a7202 | ||
|
|
3d51db5929 | ||
|
|
b0956c09c1 | ||
|
|
a294462a06 | ||
|
|
5bc464dc53 | ||
|
|
7807a8dcff | ||
|
|
0bad5529d8 | ||
|
|
4fe885408b | ||
|
|
9a1ab4e69f | ||
|
|
e0b3c4f82f | ||
|
|
ac858d9800 | ||
|
|
7050236a93 | ||
|
|
0f2143e7fd | ||
|
|
b9f79c8df0 | ||
|
|
9587ea7f06 | ||
|
|
7f68b83cb7 | ||
|
|
d7c077cffb | ||
|
|
7d6ec7f3d3 | ||
|
|
f3dc853be3 | ||
|
|
28095c6454 | ||
|
|
48507460b2 | ||
|
|
bb7d3be1b8 | ||
|
|
d029464de8 | ||
|
|
79d09705d8 | ||
|
|
868658f3d8 | ||
|
|
fe87477238 | ||
|
|
d892a2643e | ||
|
|
83ffdc888a | ||
|
|
4041d9dc48 | ||
|
|
1f16c8d224 | ||
|
|
06f9dae0f3 | ||
|
|
48d5f88c1a | ||
|
|
eb53ed4cc1 | ||
|
|
46293546f3 | ||
|
|
3cc3637e2d | ||
|
|
1f51fc8baf | ||
|
|
e9da191b7d | ||
|
|
d73fbdef2e | ||
|
|
44dcfe29aa | ||
|
|
a85e7abb0c | ||
|
|
4847884165 | ||
|
|
7f6a54cb12 | ||
|
|
520f7c09ba | ||
|
|
35a7b800eb | ||
|
|
c966b1dd94 | ||
|
|
ee838be41b | ||
|
|
127e944866 | ||
|
|
cc81aca6a4 | ||
|
|
46d7cedb18 | ||
|
|
5f33672f0e | ||
|
|
b690f1103a | ||
|
|
91089db444 | ||
|
|
70fd4f109d | ||
|
|
186b0869df | ||
|
|
7652fc1a04 | ||
|
|
2f418ee767 | ||
|
|
2ecde74fa4 | ||
|
|
7ecefe37da | ||
|
|
89d13706f1 | ||
|
|
d4b1331a0a | ||
|
|
147756750b | ||
|
|
8b99860e85 | ||
|
|
a2c8dae914 | ||
|
|
1640d9ea91 | ||
|
|
6b4ea7f594 | ||
|
|
c8b05712fa | ||
|
|
56b4782ee1 | ||
|
|
b6831320f9 | ||
|
|
8a52979ffa | ||
|
|
ca3b343b1f | ||
|
|
f8ea081df5 | ||
|
|
588bc8f9ef | ||
|
|
233c1e304d | ||
|
|
a268d0e283 | ||
|
|
9992c36ced | ||
|
|
81255814b1 | ||
|
|
764ced8b5c | ||
|
|
3c25ab0d50 | ||
|
|
63a3a1fd90 | ||
|
|
761c2b0639 | ||
|
|
c6dbd81823 | ||
|
|
13c5289ff1 | ||
|
|
23fae3328b | ||
|
|
85f3b192d5 | ||
|
|
204c743bcc | ||
|
|
4aaa561147 | ||
|
|
018cadc598 | ||
|
|
2138f54954 | ||
|
|
0a0eee4993 | ||
|
|
e0c5740050 | ||
|
|
0c27bea135 | ||
|
|
1145599c04 | ||
|
|
9dd1ecdc2a | ||
|
|
f4cf96915a | ||
|
|
f6d0689967 | ||
|
|
a2ac2de011 | ||
|
|
6a742ee62c | ||
|
|
58fab035bb | ||
|
|
07bb1e2c4e | ||
|
|
94bd14ede3 | ||
|
|
0c17b166df | ||
|
|
dd324807f9 | ||
|
|
c29b86849b | ||
|
|
abbea59732 | ||
|
|
01479dcf99 | ||
|
|
0c80d891c0 | ||
|
|
f727dcc8c6 | ||
|
|
55fadd7f87 | ||
|
|
fcf1d4e922 | ||
|
|
c079f60346 | ||
|
|
77c0a0fba5 | ||
|
|
adc71a70ce | ||
|
|
99c89cf2ba | ||
|
|
49b74b587a | ||
|
|
c61fab1435 | ||
|
|
2ee2e6a9b2 | ||
|
|
c4846dafca | ||
|
|
77d5dd452f | ||
|
|
e4d45b0500 | ||
|
|
7d9637861f | ||
|
|
271c8ba991 | ||
|
|
8617bcf8bd | ||
|
|
66b64c1f80 | ||
|
|
30dd790884 | ||
|
|
40b3451a4e | ||
|
|
3f68460d6c | ||
|
|
79a4bc8129 | ||
|
|
1fad72e019 | ||
|
|
2ae90f9c5d | ||
|
|
53cf500e36 | ||
|
|
a56e8c1a0c | ||
|
|
0cd8869349 | ||
|
|
5ca3382f5c | ||
|
|
dcc6f20f31 | ||
|
|
5ecf514d28 | ||
|
|
8061a04661 | ||
|
|
562da9dd3f | ||
|
|
f475385788 | ||
|
|
9661ee5d64 | ||
|
|
4a0f5f1b03 | ||
|
|
ce652fc8df | ||
|
|
07e7acc35d | ||
|
|
51e0d6d5ee | ||
|
|
4e1597bd1d | ||
|
|
06403a5708 | ||
|
|
9d421d5ed4 | ||
|
|
e9b90d5380 | ||
|
|
944a5bb36e | ||
|
|
2f93cce7aa | ||
|
|
ac4d795eff | ||
|
|
ced32afd9f | ||
|
|
281a445998 | ||
|
|
d9254c4355 | ||
|
|
86211b1ddd | ||
|
|
7d28f8cff0 | ||
|
|
f4f42ec441 | ||
|
|
3992d917ec | ||
|
|
964e52ef08 | ||
|
|
65ca80bdde | ||
|
|
b8ebf07555 | ||
|
|
f04dd2af39 | ||
|
|
d52e6fc21e | ||
|
|
561f29042c | ||
|
|
3987d17e40 | ||
|
|
c0515bcfe2 | ||
|
|
7d2ae9089e | ||
|
|
4552c42f88 | ||
|
|
a9c7b73744 | ||
|
|
c2282ab5cb | ||
|
|
f090f42e7a | ||
|
|
6a0a9fec6b | ||
|
|
a955e04ab6 | ||
|
|
ae5581d37c | ||
|
|
181eaf95f5 | ||
|
|
581dcd5735 | ||
|
|
f3d65ec5e9 | ||
|
|
17b84691f2 | ||
|
|
47138c7632 | ||
|
|
8432c8584a | ||
|
|
a56db854a2 | ||
|
|
9e2a95b1a3 | ||
|
|
ae3c8af56c | ||
|
|
70dce6cc0b | ||
|
|
77083d9e80 | ||
|
|
4a66803d76 | ||
|
|
eff8570f59 | ||
|
|
3cd799a744 | ||
|
|
e285404c3e | ||
|
|
70d935a2da | ||
|
|
7c7143d435 | ||
|
|
9aca6fab88 | ||
|
|
d1f34f926e | ||
|
|
62532b8f79 | ||
|
|
402203aa2a | ||
|
|
cf97b9ff2b | ||
|
|
e7b541a2af | ||
|
|
4cf66831d4 | ||
|
|
f41284a133 | ||
|
|
a77d517ac1 | ||
|
|
fc351b54d9 | ||
|
|
c2fdb0ad4d | ||
|
|
1968bfac4d | ||
|
|
c4dfd5f0c3 | ||
|
|
ac2af4354d | ||
|
|
9227b7cb2f | ||
|
|
e1e5935e3c | ||
|
|
4316d991a2 | ||
|
|
d1be3d60df | ||
|
|
a9a9ed6318 | ||
|
|
79708aeb67 | ||
|
|
0c2777dfd5 | ||
|
|
5ba58c1e9c | ||
|
|
c994fe4609 | ||
|
|
658166c05e | ||
|
|
6bcc302950 | ||
|
|
d8a337fcac | ||
|
|
672a4b5400 | ||
|
|
61ce749122 | ||
|
|
ee02d55e67 | ||
|
|
417d0ae92a | ||
|
|
22108f9f90 | ||
|
|
101e050746 | ||
|
|
45d8f36f5e | ||
|
|
caaaf15fd6 | ||
|
|
60a42bc511 | ||
|
|
3f939f3ccf | ||
|
|
7d9c5f64aa | ||
|
|
c7ab4dccc3 | ||
|
|
ac89c35edc | ||
|
|
af2cbd0258 | ||
|
|
0a3e946726 | ||
|
|
d3758b6f76 | ||
|
|
c95bf0cdf0 | ||
|
|
4bca26298e | ||
|
|
ded6483173 | ||
|
|
097cae90a7 | ||
|
|
739c860cfd | ||
|
|
f01bb9cee3 | ||
|
|
b8b8cc1312 | ||
|
|
27a7238d3f | ||
|
|
ec9dcd3285 | ||
|
|
ba2cfcc72d | ||
|
|
5270cc0eae | ||
|
|
2bb695d60f | ||
|
|
556ba956b8 | ||
|
|
b1226be2c8 | ||
|
|
b293948d36 | ||
|
|
ed3f8f5cc0 | ||
|
|
4c5effe714 | ||
|
|
68692a256e | ||
|
|
72eed0e369 | ||
|
|
588add8bec | ||
|
|
a7bd0681a0 | ||
|
|
999758f7a1 | ||
|
|
2d7b2e651d | ||
|
|
b723f23f14 | ||
|
|
ae9a41a19f | ||
|
|
86f32e4ee4 | ||
|
|
1873c0399a | ||
|
|
47eeed0a4c | ||
|
|
91d6e90d5d | ||
|
|
4d08f04db2 | ||
|
|
93ce32d94d | ||
|
|
4fe90a1a1c | ||
|
|
22c204fea6 | ||
|
|
e1253b6969 | ||
|
|
f175d20599 | ||
|
|
4d9819f6ef | ||
|
|
bead4075d8 | ||
|
|
1823fa18c9 | ||
|
|
4738fa94d0 | ||
|
|
aad5b789a7 | ||
|
|
5c0b541248 | ||
|
|
a9e9e72840 | ||
|
|
a580a6a44d | ||
|
|
1eaf28f823 | ||
|
|
3a634cb583 | ||
|
|
8bb1b6146f | ||
|
|
6c7175dfc2 | ||
|
|
28b9c158b1 | ||
|
|
4ea0e0fc05 | ||
|
|
b28be43cc6 | ||
|
|
4a71861066 | ||
|
|
5f25703d44 | ||
|
|
c317af58bc | ||
|
|
a8ba809656 | ||
|
|
6766de437f | ||
|
|
fa7379e129 | ||
|
|
9fb0d94fc3 | ||
|
|
8fd9dc231c | ||
|
|
4ca46b9e5f | ||
|
|
90b930ed7f | ||
|
|
f44f8a823a | ||
|
|
e89b11b1fa | ||
|
|
e0976d10ba | ||
|
|
ea681026f7 | ||
|
|
759f6b48ee | ||
|
|
ec047eefd2 | ||
|
|
811426b161 | ||
|
|
b1d9ad7134 | ||
|
|
e000e10e01 | ||
|
|
8dea9662dc | ||
|
|
ed44e684cc | ||
|
|
f18e795124 | ||
|
|
f1c09a54be | ||
|
|
8d462afb79 | ||
|
|
f988306691 | ||
|
|
d43dc4824c | ||
|
|
482f734e53 | ||
|
|
f8f02af23e | ||
|
|
cb50781d2d | ||
|
|
1df0fdf3e2 | ||
|
|
a95a18afe4 | ||
|
|
9af0a08122 | ||
|
|
69c91d2b56 | ||
|
|
97ba5e97c6 | ||
|
|
8760beed1c | ||
|
|
15464e57af | ||
|
|
c984fa1071 | ||
|
|
97f35de41f | ||
|
|
81e9fd8933 | ||
|
|
17c463ca61 | ||
|
|
f0ca193122 | ||
|
|
940f83698c | ||
|
|
ccb7104dee | ||
|
|
da056a6877 | ||
|
|
e9c95f6623 | ||
|
|
f37a420a04 | ||
|
|
6c63ee6798 | ||
|
|
60371b9dcf | ||
|
|
4119ae8655 | ||
|
|
8183202868 | ||
|
|
74410d8c6b | ||
|
|
c1808513fe | ||
|
|
eeccdce33a | ||
|
|
a6667b14df | ||
|
|
62e908264e | ||
|
|
2fe52d0a4f | ||
|
|
d01c93aeee | ||
|
|
c75ffbf3d5 | ||
|
|
e3e475c5b1 | ||
|
|
6a3f625e11 | ||
|
|
1d910dbb42 | ||
|
|
bf3f36b46e | ||
|
|
686f987180 | ||
|
|
334933b874 | ||
|
|
d22fab5bae | ||
|
|
ddd7789713 | ||
|
|
ff38220b68 | ||
|
|
7a7cb9bcbf | ||
|
|
fe9c99a11b | ||
|
|
9b47bbc1ac | ||
|
|
430a5f902b | ||
|
|
bc0d53e819 | ||
|
|
0bb8b3a68d | ||
|
|
e5c220b82c | ||
|
|
60c636738b | ||
|
|
06b2a587af | ||
|
|
26b1e5a51b | ||
|
|
81f343a46a | ||
|
|
956adfc90a | ||
|
|
c7c8ca63b6 | ||
|
|
fa40c6e3d4 | ||
|
|
7ccbbb7a75 | ||
|
|
948c89c26f | ||
|
|
768791440a | ||
|
|
08a8dc0d0d | ||
|
|
0675ecdd73 | ||
|
|
08c160c178 | ||
|
|
677627586c | ||
|
|
0731971300 | ||
|
|
c290719984 | ||
|
|
2a145e288c | ||
|
|
aeb676e757 | ||
|
|
2852349e68 | ||
|
|
0447594e02 | ||
|
|
748a8240dd | ||
|
|
808be4678a | ||
|
|
398577f116 | ||
|
|
8e64a24d19 | ||
|
|
8b149c9aa3 | ||
|
|
a7c88c7951 | ||
|
|
db64e19b8d | ||
|
|
b574960755 | ||
|
|
c6434f609c | ||
|
|
206308c1aa | ||
|
|
6527d3e492 | ||
|
|
e616b1e356 | ||
|
|
8843062604 | ||
|
|
5e00842087 | ||
|
|
8a4d05b7bb | ||
|
|
061832af7f | ||
|
|
9dd818ed7b | ||
|
|
0e04c90abe | ||
|
|
b07e21ab3c | ||
|
|
83ea088bf7 | ||
|
|
48eb78b14d | ||
|
|
e3d1314bd8 | ||
|
|
b4d447b5cb | ||
|
|
a05aef5c14 | ||
|
|
3de5161dd8 | ||
|
|
d1e9ded76f | ||
|
|
12ee7b9b13 | ||
|
|
d9dc2036a7 | ||
|
|
54861335a0 | ||
|
|
8e0d8f4533 | ||
|
|
0cd9e62fc6 | ||
|
|
02ef1d41d7 | ||
|
|
1a38bfd31f | ||
|
|
0d7c4beecd | ||
|
|
55e1552957 | ||
|
|
7c9eaaeadb | ||
|
|
d12ef576fc | ||
|
|
a05eea3a11 | ||
|
|
446b2e7058 | ||
|
|
e06f3808c0 | ||
|
|
6d79107b14 | ||
|
|
5fe0e06342 | ||
|
|
6eb7843858 | ||
|
|
2904ca7f57 | ||
|
|
54686b0505 | ||
|
|
861c6fec06 | ||
|
|
eec954ede1 | ||
|
|
aa99c1ba55 | ||
|
|
29b1f55bb0 | ||
|
|
8c0ab106c7 | ||
|
|
dec0e2545d | ||
|
|
90cf4b9462 | ||
|
|
2bd5d2474e | ||
|
|
a6e08a83a7 | ||
|
|
ed11dd62da | ||
|
|
c977b70921 | ||
|
|
31c9ccd8be | ||
|
|
044dbb0333 | ||
|
|
d45c794a9e | ||
|
|
c9dd7e10b9 | ||
|
|
56ad400c49 | ||
|
|
e2b0402cf5 | ||
|
|
0c7fffeaf6 | ||
|
|
5f8dc21dd2 | ||
|
|
7a27f9b610 | ||
|
|
1944dd70c7 | ||
|
|
3ec76ac33d | ||
|
|
72bc22dfd1 | ||
|
|
b8e677efd2 | ||
|
|
65079f5e2e | ||
|
|
cfb21b94e8 | ||
|
|
cf74cfed15 | ||
|
|
f564a9ce51 | ||
|
|
cd1a3ad7c9 | ||
|
|
85d0a914ac | ||
|
|
d3e7e18b7d | ||
|
|
d6c76b02e3 | ||
|
|
fe3e20751c | ||
|
|
aab041e692 | ||
|
|
75e22fc7f5 | ||
|
|
6fff49b33b | ||
|
|
2eaab48532 | ||
|
|
43df4a56c4 | ||
|
|
680756500c | ||
|
|
0645a6568e | ||
|
|
3a0861694d | ||
|
|
0f4182bddf | ||
|
|
cc4284b89e | ||
|
|
a326466f32 | ||
|
|
5a67862e00 | ||
|
|
201bb3f80a | ||
|
|
49afe7d89f | ||
|
|
f968d039f7 | ||
|
|
705669ddf8 | ||
|
|
73dd345cda | ||
|
|
65c6e46775 | ||
|
|
7a1d003341 | ||
|
|
6a2a56d48f | ||
|
|
9ff5bdd297 | ||
|
|
4ba5e22f64 | ||
|
|
a8ab15d65d | ||
|
|
93953103ad | ||
|
|
f25890c140 | ||
|
|
39cf1931ae | ||
|
|
bbb6771625 | ||
|
|
e9f9f270e1 | ||
|
|
190b78b7be | ||
|
|
257f9fb2b2 | ||
|
|
d35a104ad3 | ||
|
|
9bae7a35bf | ||
|
|
33c7c5a7e3 | ||
|
|
91363daeaa | ||
|
|
f9ab85adbe | ||
|
|
9dbf43d3e7 | ||
|
|
772f4d6671 | ||
|
|
1b57218739 | ||
|
|
8767269b47 | ||
|
|
baceaed582 | ||
|
|
62a28bc2a1 | ||
|
|
f83caa6c40 | ||
|
|
53b1483e71 | ||
|
|
a0eafea200 | ||
|
|
10dace305d | ||
|
|
1eace79f77 | ||
|
|
e6033e174d | ||
|
|
f1925b8f71 | ||
|
|
834f3cc192 | ||
|
|
e049aead16 | ||
|
|
0a9c9670e7 | ||
|
|
1744dcebfe | ||
|
|
29712916e6 | ||
|
|
4d2783bb04 | ||
|
|
50f0fbb05c | ||
|
|
5a842ec94a | ||
|
|
372680e2ab | ||
|
|
6465a3f549 | ||
|
|
690eab4a25 | ||
|
|
dc2e5ceed2 | ||
|
|
1639a7338d | ||
|
|
ac7226bb27 | ||
|
|
086020e543 | ||
|
|
452d456fad | ||
|
|
f741942226 | ||
|
|
a27399cf65 | ||
|
|
29b8810db8 | ||
|
|
a5a47911d1 | ||
|
|
7bf6a3d7b2 | ||
|
|
0cabcb7c79 | ||
|
|
f359b64d59 | ||
|
|
2f3ecab8d9 | ||
|
|
17f71a1a55 | ||
|
|
bfe3bb0eeb | ||
|
|
0a67248bfe | ||
|
|
2644f087d0 | ||
|
|
91c8c7a2e3 | ||
|
|
029abd3413 | ||
|
|
726756bad4 | ||
|
|
10c56d9919 | ||
|
|
5f59f93804 | ||
|
|
704defea78 | ||
|
|
eb240c8b60 | ||
|
|
c3bcd7a410 | ||
|
|
26124e6436 | ||
|
|
3cd6f5c7ea | ||
|
|
7c646e031c | ||
|
|
0a2ca075d3 | ||
|
|
b406b6ee44 | ||
|
|
726e867058 | ||
|
|
f4d918d22a | ||
|
|
5ef3a01b6c | ||
|
|
5a98f1f076 | ||
|
|
4398f2c023 | ||
|
|
afc3b0915b | ||
|
|
f313de98c8 | ||
|
|
03d4651077 | ||
|
|
32f6a9a457 | ||
|
|
099a0802fc | ||
|
|
e258e0b2c2 | ||
|
|
c254320860 | ||
|
|
51fd849852 | ||
|
|
ab170ce4fd | ||
|
|
90226dc8a9 | ||
|
|
63868b2600 | ||
|
|
22d439f682 | ||
|
|
394f2abd49 | ||
|
|
030bcd8b05 | ||
|
|
d8d29d3615 | ||
|
|
efe5984d54 | ||
|
|
63260e6443 | ||
|
|
a794970b72 | ||
|
|
ba0f44e361 | ||
|
|
4acaecd921 | ||
|
|
84a3e95fa4 | ||
|
|
f045e111ea | ||
|
|
87a76c2a60 | ||
|
|
4edaebab90 | ||
|
|
b43137b508 | ||
|
|
0ca44b6a82 | ||
|
|
ae2de4d0c4 | ||
|
|
e47b4acd08 | ||
|
|
a07c3743f0 | ||
|
|
954f572e79 | ||
|
|
733c02dd7c | ||
|
|
c94daf8c3d | ||
|
|
6db51ed8b2 | ||
|
|
118c673eaf | ||
|
|
a9a2d3bca3 | ||
|
|
4a9e56aa4f | ||
|
|
14bb9505eb | ||
|
|
d937aeac0a | ||
|
|
dd540d2540 | ||
|
|
4ecaf99047 | ||
|
|
445a6c9ea2 | ||
|
|
67b7d60cb0 | ||
|
|
94b3e8e56e | ||
|
|
89b5ae63fc | ||
|
|
2a79dc9ded | ||
|
|
5ed62dbf76 | ||
|
|
cb267b68ed | ||
|
|
6539be6c46 | ||
|
|
a23bdb31a3 | ||
|
|
9014290875 | ||
|
|
1903302a74 | ||
|
|
75c3cb4bb6 | ||
|
|
bfd0f806f8 | ||
|
|
afab8a7846 | ||
|
|
afacdbc7a0 | ||
|
|
18a50b4dac | ||
|
|
fb69769991 | ||
|
|
750e7382c6 | ||
|
|
2464cc7a6d | ||
|
|
f078cbac4d | ||
|
|
aa545e5386 | ||
|
|
9711100ff1 | ||
|
|
8c49ee1b3b | ||
|
|
44cb7f68f9 | ||
|
|
25dc2ad66f | ||
|
|
624bd56459 | ||
|
|
7a6615cfa7 | ||
|
|
bcad3ffd7c | ||
|
|
98d87fa1ff | ||
|
|
7e00bf4bfa | ||
|
|
476aecf86d | ||
|
|
c39b358518 | ||
|
|
bd5d25429b | ||
|
|
982fb7b786 | ||
|
|
7dc628965c | ||
|
|
d114250ebb | ||
|
|
8eec3bcdc2 | ||
|
|
0583cd8e5d | ||
|
|
83b6fc48e1 | ||
|
|
4b5437a882 | ||
|
|
de4caef468 | ||
|
|
36b763b84e | ||
|
|
c06dd35af1 | ||
|
|
51b7cb2722 | ||
|
|
7f5fb50307 | ||
|
|
4262561596 | ||
|
|
8471796987 | ||
|
|
2775aeb6ac | ||
|
|
a747e79e5d | ||
|
|
5773c5c865 | ||
|
|
51d7c84e73 | ||
|
|
6f0b6933e6 | ||
|
|
f5a936614a | ||
|
|
308630c094 | ||
|
|
f54397e0cf | ||
|
|
754efe1f42 | ||
|
|
05c30c879f | ||
|
|
99e8d4adae | ||
|
|
ac63f1cd7a | ||
|
|
169749396b | ||
|
|
a0637c2c6d | ||
|
|
edbba64711 | ||
|
|
9ba711dfe5 | ||
|
|
6bce83dde8 | ||
|
|
629a658c75 | ||
|
|
2f6c55ef78 | ||
|
|
a6457718f2 | ||
|
|
3bf23a7c59 | ||
|
|
bbe3a10107 | ||
|
|
37ee0f36c1 | ||
|
|
e92f544fd1 | ||
|
|
d7b49fa671 | ||
|
|
41707e3245 | ||
|
|
3c51e9f5ed | ||
|
|
7d3e937134 | ||
|
|
6445eea946 | ||
|
|
ced6cc0e23 | ||
|
|
944a3943e5 | ||
|
|
d419f151a0 | ||
|
|
b2124822a3 | ||
|
|
f60b912f12 | ||
|
|
e1f956ce18 | ||
|
|
ab16e2eff1 | ||
|
|
3da607749f | ||
|
|
a626e5e935 | ||
|
|
3d73a4895e | ||
|
|
979b01a1c0 | ||
|
|
38cf489acf | ||
|
|
60264763f4 | ||
|
|
d55124e524 | ||
|
|
643933c3b0 | ||
|
|
44fd9384bd | ||
|
|
75d0d2df6c | ||
|
|
92d9283d1a | ||
|
|
9b46887f75 | ||
|
|
ad267cbe59 | ||
|
|
029772e11f | ||
|
|
2ef888d100 | ||
|
|
4e1e41994c | ||
|
|
0545424781 | ||
|
|
69af8e9e3d | ||
|
|
9c7abebde4 | ||
|
|
e240591128 | ||
|
|
0bceaa5669 | ||
|
|
3423c0b246 | ||
|
|
0953d99198 | ||
|
|
7ad835baf5 | ||
|
|
8309e00ed3 | ||
|
|
4f6a6b1359 | ||
|
|
21253a2bcb | ||
|
|
8e9296c66f | ||
|
|
641d12fb2d | ||
|
|
2019db972d | ||
|
|
0d2f5d3fe0 | ||
|
|
21567eeb8f | ||
|
|
b1272d05b4 | ||
|
|
feb12a581e | ||
|
|
4ad4d7cf34 | ||
|
|
a38498fe1e | ||
|
|
8ea6ef1e90 | ||
|
|
4f2b68eef1 | ||
|
|
f1d55314d5 | ||
|
|
c7701ebd19 | ||
|
|
05c3f598ac | ||
|
|
3d771f2289 | ||
|
|
8035ca7138 | ||
|
|
60a90e96f3 | ||
|
|
6167a10e5e | ||
|
|
ce28567dda | ||
|
|
179942b07a | ||
|
|
fabb1985ca | ||
|
|
33bfcbeba7 | ||
|
|
3143ffe208 | ||
|
|
c52d6d0741 | ||
|
|
ce7a9073e1 | ||
|
|
95d1762f19 | ||
|
|
e5079004e1 | ||
|
|
f6795775e2 | ||
|
|
2d31371975 | ||
|
|
26d29783ce | ||
|
|
0ebf7b6214 | ||
|
|
6add10b18f | ||
|
|
940105efb3 | ||
|
|
3e13e728aa | ||
|
|
8cd224899c | ||
|
|
35605c9f57 | ||
|
|
c6e68c87cd | ||
|
|
7685165089 | ||
|
|
c6bad90c79 | ||
|
|
8aeeea8382 | ||
|
|
0ee46f773e | ||
|
|
ff2490ca8b | ||
|
|
2ada9c5d72 | ||
|
|
18b56c6af8 | ||
|
|
6fee7e638c | ||
|
|
f0822a86e1 | ||
|
|
d007bf13f1 | ||
|
|
cff9e1fd94 | ||
|
|
56b01ba440 | ||
|
|
11e00c906f | ||
|
|
32843e9ade | ||
|
|
cf6c6eb117 | ||
|
|
6df56c4ec5 | ||
|
|
aabfe73b38 | ||
|
|
263583c118 | ||
|
|
3ab8baa1b4 | ||
|
|
73c60d7768 | ||
|
|
987a60a6c0 | ||
|
|
ae6a92f89a | ||
|
|
0fc624aa81 | ||
|
|
af50a5528f | ||
|
|
b2877b3549 | ||
|
|
5f1ca15a7c | ||
|
|
e1002862a9 | ||
|
|
3fe3c8cf02 | ||
|
|
ed051b65ad | ||
|
|
8f0d9ccd87 | ||
|
|
adaf74bc87 | ||
|
|
a2321d1562 | ||
|
|
e51ea55ae3 | ||
|
|
3af2f8b344 | ||
|
|
f6c531a5a8 | ||
|
|
2ae05d9fd1 | ||
|
|
e95cec7ea6 | ||
|
|
3bd5a90976 | ||
|
|
68ad570cfc | ||
|
|
db45826232 | ||
|
|
df7284a4df | ||
|
|
b327442eb6 | ||
|
|
1370b19402 | ||
|
|
5ee4a1e954 | ||
|
|
8a2e60dc09 | ||
|
|
2a32ad39a0 | ||
|
|
2bf82b3198 | ||
|
|
c9f10432b8 | ||
|
|
fb6a9ea280 | ||
|
|
05344043b2 | ||
|
|
d9e2e1a177 | ||
|
|
51b3139c0b | ||
|
|
4254cfbce5 | ||
|
|
e2546f2646 | ||
|
|
9c58ca7ce5 | ||
|
|
0e20ac28e5 | ||
|
|
30fd24aa47 | ||
|
|
3bd15a4195 | ||
|
|
c771694623 | ||
|
|
d69180ec67 | ||
|
|
e2db197b3f | ||
|
|
4c2af8e515 | ||
|
|
81b1aed7a1 | ||
|
|
7c7f753463 | ||
|
|
f1ac76a283 | ||
|
|
2b7d614e84 | ||
|
|
b859477ffd | ||
|
|
b6570f7016 | ||
|
|
c1a2c7b610 | ||
|
|
b16088eec1 | ||
|
|
8438ac9756 | ||
|
|
a3a389cae6 | ||
|
|
8cebf78485 | ||
|
|
166a301c7f | ||
|
|
fac35e34e9 | ||
|
|
0883e345d0 | ||
|
|
7096fdb56b | ||
|
|
a5ab4b3f64 | ||
|
|
7e6f068b18 | ||
|
|
dc246b97e6 | ||
|
|
1ce7e09a44 | ||
|
|
690023baff | ||
|
|
ea4c3b613a | ||
|
|
8f990b2079 | ||
|
|
82fa060bc8 | ||
|
|
a7cda7f950 | ||
|
|
59ed3e88b3 | ||
|
|
6d33376595 | ||
|
|
92897e7ad0 | ||
|
|
92ce0f5c2b | ||
|
|
c946d144ce | ||
|
|
bc7b0a38fd | ||
|
|
6c87723b19 | ||
|
|
cd1679dea7 | ||
|
|
c5daa4a256 | ||
|
|
df2eed1be3 | ||
|
|
5193382b07 | ||
|
|
e40d9e7462 | ||
|
|
ddeb5745be | ||
|
|
a60e3fb1cb | ||
|
|
7bbb101555 | ||
|
|
788e2202c9 | ||
|
|
3bca31856d | ||
|
|
5bf15a4190 | ||
|
|
016bfa391b | ||
|
|
e6a7521610 | ||
|
|
3e84f916b6 | ||
|
|
2d2c933611 | ||
|
|
d30874c912 | ||
|
|
e2b115f3a9 | ||
|
|
ae30ee2ade | ||
|
|
3026840530 | ||
|
|
d300d788c7 | ||
|
|
2828b5fa19 | ||
|
|
25b3c9a057 | ||
|
|
2558ce9a00 | ||
|
|
65ed2dcc1b | ||
|
|
5e063da14f | ||
|
|
615825b9fd | ||
|
|
3502d8b48c | ||
|
|
a1d20ea8c8 | ||
|
|
ef7b1cc829 | ||
|
|
2c9776c3e8 | ||
|
|
3743d8ca5b | ||
|
|
e222e20517 | ||
|
|
10d7dc75f3 | ||
|
|
f6300497f7 | ||
|
|
1cae6c18b2 | ||
|
|
1fef613024 | ||
|
|
047407342b | ||
|
|
e2b71b0e57 | ||
|
|
9c1de3adfc | ||
|
|
54707e4e24 | ||
|
|
a94ee167fc | ||
|
|
ce789682cc | ||
|
|
c95d4e48a5 | ||
|
|
1f35db2ddc | ||
|
|
be1320d21d | ||
|
|
308c652b30 | ||
|
|
80ab82897e | ||
|
|
71578a5462 | ||
|
|
eca39ad7bf | ||
|
|
28a3e4005a | ||
|
|
f38d0d731f | ||
|
|
5051a796a0 | ||
|
|
869b6019c6 | ||
|
|
347045adf2 | ||
|
|
e5126af458 | ||
|
|
effbb7f7f1 | ||
|
|
a88f6c3241 | ||
|
|
b96da94f92 | ||
|
|
305665cd42 | ||
|
|
f2b7aea16c | ||
|
|
71e3b5bc11 | ||
|
|
cd12e2717c | ||
|
|
7a8e64be30 | ||
|
|
36abcb3976 | ||
|
|
5dc7d498bd | ||
|
|
e9c5928fd3 | ||
|
|
48e94b4372 | ||
|
|
e3e32e7f2b | ||
|
|
b215e9e848 | ||
|
|
44ae21671c | ||
|
|
0ce2666d2f | ||
|
|
d7f099d3ba | ||
|
|
e07fe017c1 | ||
|
|
270c7b0288 | ||
|
|
59c67f6bc8 | ||
|
|
dd08cfc6a3 | ||
|
|
b89e76ccb4 | ||
|
|
57e515d5e2 | ||
|
|
b62945961f | ||
|
|
61ce9486fc | ||
|
|
2e55457ecc | ||
|
|
fe21a43364 | ||
|
|
dee12c9c4d | ||
|
|
bd1929695c | ||
|
|
7ba92da5e5 | ||
|
|
4ae2097cdc | ||
|
|
1f2ab71bb6 | ||
|
|
f3b1261e2f | ||
|
|
b47f7dd4c7 | ||
|
|
674476155a | ||
|
|
2e3a765dac | ||
|
|
382e300326 | ||
|
|
dff36eaef4 | ||
|
|
bdd088830a | ||
|
|
17401cfbe9 | ||
|
|
c4287cdfac | ||
|
|
9c0956049a | ||
|
|
899559a060 | ||
|
|
99866ba484 | ||
|
|
36c7fd0cf1 | ||
|
|
ea308eb798 | ||
|
|
bc8ff49de3 | ||
|
|
e74d2c1872 | ||
|
|
4bd7e46ba6 | ||
|
|
ff3149f6fa | ||
|
|
27b3b53bc5 | ||
|
|
5e2861ff55 | ||
|
|
38d41252e6 | ||
|
|
5fed155f15 | ||
|
|
6a1f73a304 | ||
|
|
22fbff98d4 | ||
|
|
85833e3a0a | ||
|
|
b08f6737ac | ||
|
|
5ec130e6dc | ||
|
|
6c581fb3bd | ||
|
|
73b5c87cbb | ||
|
|
0aa16dd3b1 | ||
|
|
540308dc63 | ||
|
|
6d6c8e8fb2 | ||
|
|
6cc80d2565 | ||
|
|
5265fafd7a | ||
|
|
287226b609 | ||
|
|
7119b21b46 | ||
|
|
d1f1bfe071 | ||
|
|
812465e014 | ||
|
|
86bab04997 | ||
|
|
867bd1ffd7 | ||
|
|
16e075983d | ||
|
|
1b7a6687c8 | ||
|
|
8c41fb2b49 | ||
|
|
c1797c4e75 | ||
|
|
1c094346e2 | ||
|
|
cd3c0d750c | ||
|
|
3d2f04a7af | ||
|
|
10d047a636 | ||
|
|
10211737c5 | ||
|
|
45e55bc054 | ||
|
|
1892ba8973 | ||
|
|
b7c287ffb7 | ||
|
|
457b645f3c | ||
|
|
0185ffad89 | ||
|
|
08edc9d5d0 | ||
|
|
979bea0327 | ||
|
|
c7ea9f4cf3 | ||
|
|
233651bef8 | ||
|
|
c6fb591348 | ||
|
|
644e78df89 | ||
|
|
500eeca3fb | ||
|
|
c418abe92d | ||
|
|
2fdf33a006 | ||
|
|
c3cf0cade9 | ||
|
|
210bc68ced | ||
|
|
193bded4b7 | ||
|
|
8f4d090f34 | ||
|
|
a0a481697b | ||
|
|
c3d5778aae | ||
|
|
3e031d8297 | ||
|
|
83f50914ec | ||
|
|
d3916f28aa | ||
|
|
dcf1096ac3 | ||
|
|
66568a913c | ||
|
|
6db6b40659 | ||
|
|
780ac5cfd3 | ||
|
|
d24209f5a7 | ||
|
|
29d021ad4d | ||
|
|
eb28276923 | ||
|
|
0679ec4f41 | ||
|
|
1b5b71869f | ||
|
|
6681681a76 | ||
|
|
83d8dc0d2b | ||
|
|
49499ca54d | ||
|
|
16a63c74ea | ||
|
|
b4df54197b | ||
|
|
a28b428074 | ||
|
|
e5a336a042 | ||
|
|
5e5702833c | ||
|
|
03063cf349 | ||
|
|
241b842ef7 | ||
|
|
184c290773 | ||
|
|
5c638184e9 | ||
|
|
3a88910a24 | ||
|
|
eddd453564 | ||
|
|
38c43759bb | ||
|
|
26225a2fdf | ||
|
|
9950fffb6f | ||
|
|
f5d57c9dce | ||
|
|
bc9c80a5ee | ||
|
|
702f7445ec | ||
|
|
dcb93e3166 | ||
|
|
02b79e0040 | ||
|
|
88b71fb6c4 | ||
|
|
95bb443430 | ||
|
|
1b47a10e89 | ||
|
|
006e54109b | ||
|
|
7eb6333933 | ||
|
|
065da3d613 | ||
|
|
e698fa0b63 | ||
|
|
8b662be42b | ||
|
|
52a4f7cd23 | ||
|
|
690b8e0dd0 | ||
|
|
bc6d86c8ce | ||
|
|
fbf7117d6a | ||
|
|
51472142c6 | ||
|
|
91d1bd5903 | ||
|
|
69aee870da | ||
|
|
3b25bd71ab | ||
|
|
c18e907f96 | ||
|
|
e3808b8694 | ||
|
|
116b301359 | ||
|
|
3ed510b78e | ||
|
|
565c46fdd4 | ||
|
|
b0255076de | ||
|
|
67348f2251 | ||
|
|
227bc716d8 | ||
|
|
c3467313e5 | ||
|
|
c82eed010a | ||
|
|
158c2b5382 | ||
|
|
2d1d59acb7 | ||
|
|
0088de9802 | ||
|
|
f49d2bca64 | ||
|
|
b7273c450f | ||
|
|
4130fddcc8 | ||
|
|
4f05045acb | ||
|
|
bc16c9beb7 | ||
|
|
0af9f6cf6e | ||
|
|
022aeac808 | ||
|
|
20461ccf36 | ||
|
|
7297396162 | ||
|
|
c15deb41b0 | ||
|
|
cb2a08db7e | ||
|
|
67703b5ea2 | ||
|
|
c445abb982 | ||
|
|
38d97fa339 | ||
|
|
d45f0819be | ||
|
|
9375d0efbe | ||
|
|
2291c33074 | ||
|
|
0a216066f4 | ||
|
|
eea2a9cfc3 | ||
|
|
33c2b9c5ff | ||
|
|
1129812e6e | ||
|
|
b1b0c6b4b3 | ||
|
|
6ae3f2f8b9 | ||
|
|
f8d594e7ea | ||
|
|
38c3aa542f | ||
|
|
f3382125e1 | ||
|
|
592a438ae8 | ||
|
|
d84a86897c | ||
|
|
88c063e887 | ||
|
|
ba8a410d4c | ||
|
|
451061f4b8 | ||
|
|
ae17aa4955 | ||
|
|
f589d07706 | ||
|
|
3f343ebfdb | ||
|
|
95ea3e39d2 | ||
|
|
a6dcd7a421 | ||
|
|
fa9b7dd29f | ||
|
|
fd65cf9dcb | ||
|
|
6e9d7f94d4 | ||
|
|
6151bc262f | ||
|
|
b62f9fabf2 | ||
|
|
86e1ba871f | ||
|
|
a6ac902bf4 | ||
|
|
4cdb67c249 | ||
|
|
29622e11f5 | ||
|
|
3ca8db2cc1 | ||
|
|
cc5eb885ea | ||
|
|
f6972ec682 | ||
|
|
cfe21f7b02 | ||
|
|
2d82f1b655 | ||
|
|
cf6e481c14 | ||
|
|
7be376721c | ||
|
|
ce0e8415d5 | ||
|
|
4ccf1d10bd | ||
|
|
c25641ff2d | ||
|
|
14c1aba6c7 | ||
|
|
8204d961de | ||
|
|
ef3bcd65ab | ||
|
|
b06e33f3d3 | ||
|
|
179969a9e2 | ||
|
|
c984d8d5a5 | ||
|
|
8ffa80883a | ||
|
|
86c3482cbd | ||
|
|
16a99aa95e | ||
|
|
6d86968c4c | ||
|
|
8df6d6e954 | ||
|
|
8aeddec982 | ||
|
|
f4ae0844ab | ||
|
|
d56968cb23 | ||
|
|
c5b6e641a4 | ||
|
|
041eed2a06 | ||
|
|
54c675e195 | ||
|
|
81ce90e57f | ||
|
|
6016f2e941 | ||
|
|
4d27318b72 | ||
|
|
decce4d8e4 | ||
|
|
1cb9f75026 | ||
|
|
5e31d28759 | ||
|
|
2b780ab2c5 | ||
|
|
a2f0f95337 | ||
|
|
72450c765d | ||
|
|
250aeaa86c | ||
|
|
06ace88901 | ||
|
|
47009615ee | ||
|
|
dda08d60d2 | ||
|
|
f182afc50b | ||
|
|
bb5d931f16 | ||
|
|
3c74e71d4f | ||
|
|
79e07fa852 | ||
|
|
aa95c26e07 | ||
|
|
2eb6f81c58 | ||
|
|
a067a1b16b | ||
|
|
1df51c52e0 | ||
|
|
96248d9bfa | ||
|
|
9d167c08f4 | ||
|
|
8e6560d102 | ||
|
|
ad83c3ab5a | ||
|
|
257b7b4df4 | ||
|
|
5ac757a5fd | ||
|
|
2d7a1bfce0 | ||
|
|
3845b89a16 | ||
|
|
ce8e12c7c5 | ||
|
|
4986adc186 | ||
|
|
dc9ca2ebc9 | ||
|
|
40d7396d90 | ||
|
|
559c2f8907 | ||
|
|
dc6907e748 | ||
|
|
2143226f04 | ||
|
|
ea2a64a504 | ||
|
|
a5b0e468ee | ||
|
|
14b5fc4d6c | ||
|
|
f498bfed51 | ||
|
|
50a9825a0f | ||
|
|
5c49f08bb2 | ||
|
|
bbf9f41a04 | ||
|
|
6a32432b01 | ||
|
|
037724576e | ||
|
|
10b8a0ab00 | ||
|
|
faf0dd2f44 | ||
|
|
585bba43a0 | ||
|
|
b1528f9466 | ||
|
|
7a491a64c0 | ||
|
|
57503ad9bf | ||
|
|
c276dda305 | ||
|
|
9c0497c419 | ||
|
|
b33dac9faa | ||
|
|
f77f38dfa0 | ||
|
|
58fe87067b | ||
|
|
dbba310770 | ||
|
|
6deb481589 | ||
|
|
036977bfe4 | ||
|
|
d280848ff6 | ||
|
|
7a6f583b1f | ||
|
|
e078eafb1f | ||
|
|
6f534540a6 | ||
|
|
38d57d213f | ||
|
|
7c14769226 | ||
|
|
b71bbcffaa | ||
|
|
f83e874e35 | ||
|
|
ae0a11e422 | ||
|
|
116a637cfd | ||
|
|
83cf683db4 | ||
|
|
1b3312871e | ||
|
|
0e12920910 | ||
|
|
a35eb16a2a | ||
|
|
4f0ead625b | ||
|
|
21d122a870 | ||
|
|
130fb74928 | ||
|
|
bbe1845f66 | ||
|
|
2ee90a891c | ||
|
|
203c83bdb4 | ||
|
|
73918d803c | ||
|
|
110adcae85 | ||
|
|
c536ea64c3 | ||
|
|
aa7a6d5f8c | ||
|
|
91c6539baf | ||
|
|
f0590d3301 | ||
|
|
a5c5df0290 | ||
|
|
f0c2913dcf | ||
|
|
9c6d590950 | ||
|
|
ab3339f5a1 | ||
|
|
43ce45f62b | ||
|
|
2b5d153361 | ||
|
|
cde8845143 | ||
|
|
7c0d8f073b | ||
|
|
69adb1d771 | ||
|
|
a2bc689b92 | ||
|
|
a9adbda2cd | ||
|
|
0b9fe2c072 | ||
|
|
789e05304c | ||
|
|
7604387701 | ||
|
|
daffcaf4c6 | ||
|
|
ff1ec599e0 | ||
|
|
e44d498c94 | ||
|
|
c334d6b7fe | ||
|
|
5465e401bb | ||
|
|
9cc3c56c9c | ||
|
|
d7a7560220 | ||
|
|
70a529d197 | ||
|
|
be31a14326 | ||
|
|
96139da0d2 | ||
|
|
74fa9ee4df | ||
|
|
00336c5154 | ||
|
|
3912d1ec4b | ||
|
|
70d4f47f37 | ||
|
|
9809ded23d | ||
|
|
5f9a3546e0 | ||
|
|
db625a08f7 | ||
|
|
44fec1b6c9 | ||
|
|
54dacb362d | ||
|
|
6edb460bea | ||
|
|
40dab80dfa | ||
|
|
681711fced | ||
|
|
21c1473e0c | ||
|
|
8acbdcbbad | ||
|
|
da8abebfa2 | ||
|
|
4f7a7ea0bb | ||
|
|
d6c9ba8f08 | ||
|
|
ec8916bf54 | ||
|
|
81c573ec92 | ||
|
|
9420edadf4 | ||
|
|
d724a7659e | ||
|
|
887c212b49 | ||
|
|
07937ed6d7 | ||
|
|
a262c67ec3 | ||
|
|
13ca30c4d8 | ||
|
|
fbcec2975d | ||
|
|
6e1f4af833 | ||
|
|
856c5c4214 | ||
|
|
670e80c151 | ||
|
|
eed07c724f | ||
|
|
99d35fb940 | ||
|
|
106b886873 | ||
|
|
928876b553 | ||
|
|
58836d89aa | ||
|
|
1a5a104f13 | ||
|
|
9790c393a0 | ||
|
|
064cfa4755 | ||
|
|
ed6172aa94 | ||
|
|
8c140f6bcd | ||
|
|
1e1f0fcaf5 | ||
|
|
d21352a109 | ||
|
|
4be11f961b | ||
|
|
1163f390b3 | ||
|
|
534143e91d | ||
|
|
691e2a3c1d | ||
|
|
20b92fcb4c | ||
|
|
04bb49989f | ||
|
|
2aa7cb9d20 | ||
|
|
d12ff15ee3 | ||
|
|
11b684114d | ||
|
|
1bf177f81a | ||
|
|
df7dc54409 | ||
|
|
7e86056a27 | ||
|
|
59f74dabe7 | ||
|
|
4610198ba2 | ||
|
|
3d19f566b6 | ||
|
|
8d90cd8e35 | ||
|
|
610d44e703 | ||
|
|
0272b44d7e | ||
|
|
3eccf2fd76 | ||
|
|
736f285092 | ||
|
|
020cd7f9e8 | ||
|
|
40c0b14d1c | ||
|
|
a4dd033ccf | ||
|
|
48e8778881 | ||
|
|
4be23efe66 | ||
|
|
7d67750865 | ||
|
|
746e6e170c | ||
|
|
d93e35cace | ||
|
|
d75339a271 | ||
|
|
86ee0cbd6e | ||
|
|
248ccfc0d8 | ||
|
|
ea148575cf | ||
|
|
efc2be0b7b | ||
|
|
8d71112dcb | ||
|
|
dd03a6256a | ||
|
|
9c03bb3428 | ||
|
|
22b19c0d93 | ||
|
|
0f698d6bd9 | ||
|
|
4e91b31b1f | ||
|
|
f87c67fcad | ||
|
|
902625601a | ||
|
|
d17d4dc5ec | ||
|
|
ef6a4db182 | ||
|
|
11f3d7782d | ||
|
|
5b9fff6636 | ||
|
|
a8272f0eef | ||
|
|
951f0bcb10 | ||
|
|
d8ba405baf | ||
|
|
70f18a8086 | ||
|
|
0b5db77511 | ||
|
|
3a4130f344 | ||
|
|
1ea29bb92e | ||
|
|
04d34cb8aa | ||
|
|
bf80729e17 | ||
|
|
88b3c05155 | ||
|
|
6edef07e29 | ||
|
|
5ad73fe08b | ||
|
|
a4f26e8e48 | ||
|
|
cc10804607 | ||
|
|
f959cd76ae | ||
|
|
dcd332e2e4 | ||
|
|
f3a276d1e1 | ||
|
|
640d21a7d2 | ||
|
|
216cccbfba | ||
|
|
04d1da11f7 | ||
|
|
ee4e9dcc74 | ||
|
|
6fef04be20 | ||
|
|
86347bff3a | ||
|
|
e291d9954a | ||
|
|
7a548467b9 | ||
|
|
06d8e00ff3 | ||
|
|
225f5a172d | ||
|
|
e531ff2e98 | ||
|
|
8c8040884e | ||
|
|
e3611ad0e4 | ||
|
|
289bc6570b | ||
|
|
dc1849d291 | ||
|
|
17a66227f4 | ||
|
|
0e8b95f4bf | ||
|
|
5b8344cfc3 | ||
|
|
075f4034d9 | ||
|
|
c616ce99a8 | ||
|
|
6b9b5fda7e | ||
|
|
b756fc382a | ||
|
|
29fd54dcfa | ||
|
|
d664e97104 | ||
|
|
4466097d44 | ||
|
|
60b94d2dc1 | ||
|
|
51636402c2 | ||
|
|
fc8182d7d3 | ||
|
|
4f87465f18 | ||
|
|
5f1586ae85 | ||
|
|
8d3161a2cf | ||
|
|
8bc8214279 | ||
|
|
3ea5aa18a2 | ||
|
|
c4845b78a9 | ||
|
|
530e913e2f | ||
|
|
5917f212ba | ||
|
|
d2b1690191 | ||
|
|
710b7ea091 | ||
|
|
089579d835 | ||
|
|
7780293ddb | ||
|
|
773a51e7d0 | ||
|
|
7923752513 | ||
|
|
9a48091b21 | ||
|
|
30cb60f679 | ||
|
|
08687d8dab | ||
|
|
3a90233a3d | ||
|
|
32483cae2d | ||
|
|
d7f28e0260 | ||
|
|
9640c2aaa6 | ||
|
|
9a2b4d08e1 | ||
|
|
e91615fe59 | ||
|
|
aed02b2e19 | ||
|
|
83ad80d9db | ||
|
|
abdb7793fb | ||
|
|
387eb3fde3 | ||
|
|
e640bc90b4 | ||
|
|
3978378152 | ||
|
|
61e3e4f0b9 | ||
|
|
1def56ea11 | ||
|
|
6d686ac14f | ||
|
|
641e0d15f5 | ||
|
|
71b39426c0 | ||
|
|
57584eaccc | ||
|
|
f6fb31c531 | ||
|
|
0cea8ce5b5 | ||
|
|
d08b76a323 | ||
|
|
86a87d6032 | ||
|
|
e534929f80 | ||
|
|
fcc154da1c | ||
|
|
00d1200704 | ||
|
|
7cc096e0a2 | ||
|
|
58eaf78dc4 | ||
|
|
3be2281483 | ||
|
|
cc06d96993 | ||
|
|
93c7e700bc | ||
|
|
97c6757fc7 | ||
|
|
276d3f8e22 | ||
|
|
4869a88ae2 | ||
|
|
ae88bc31bc | ||
|
|
8aed1d96c5 | ||
|
|
c93949474c | ||
|
|
8cf19f1c6b | ||
|
|
a82ecb3cef | ||
|
|
04c2b37d82 | ||
|
|
ab3e8d6537 | ||
|
|
fd185a5e6b | ||
|
|
d9678f0040 | ||
|
|
840217b111 | ||
|
|
9605a2cd88 | ||
|
|
0f86ccc035 | ||
|
|
b3b73e2276 | ||
|
|
f241c999ad | ||
|
|
d4d2a2303a | ||
|
|
c8832409ad | ||
|
|
98f76aa952 | ||
|
|
4236632af6 | ||
|
|
e2c98244ec | ||
|
|
c1cf67c008 | ||
|
|
4abea919b2 | ||
|
|
d60aa722c0 | ||
|
|
055368acd8 | ||
|
|
7f2e5d091a | ||
|
|
c69ae8154f | ||
|
|
cd95b243bb | ||
|
|
1f1cb1f501 | ||
|
|
530738cfe9 | ||
|
|
878dd6912e | ||
|
|
5f0f699f37 | ||
|
|
ca13900699 | ||
|
|
cc97889b37 | ||
|
|
45ded0498b | ||
|
|
d01a3944c1 | ||
|
|
a0caf0d6d7 | ||
|
|
e22debb994 | ||
|
|
1b8df0ed8b | ||
|
|
3286a5213c | ||
|
|
394976d330 | ||
|
|
b95acbece0 | ||
|
|
c94f4dff71 | ||
|
|
e6465f4ea1 | ||
|
|
2b3c91aabd | ||
|
|
e97e13ce9f | ||
|
|
39e2b73718 | ||
|
|
a90facaa41 | ||
|
|
5527457655 | ||
|
|
076e781810 | ||
|
|
750d336018 | ||
|
|
e8251ad45b | ||
|
|
963ca1e2c7 | ||
|
|
12a6c7d54d | ||
|
|
2d0fc3f9d3 | ||
|
|
e554784527 | ||
|
|
2cb43fa638 | ||
|
|
66d5309a51 | ||
|
|
7eeedec7eb | ||
|
|
4b798c71ae | ||
|
|
685016bfec | ||
|
|
d30e5f6231 | ||
|
|
e854d67a55 | ||
|
|
23a89732a5 | ||
|
|
3a1f41ebdb | ||
|
|
f873761a27 | ||
|
|
ebf620c7f9 | ||
|
|
8b92bc3421 | ||
|
|
70a5aa61e9 | ||
|
|
a76169042f | ||
|
|
c9c3cfcee9 | ||
|
|
2e60ac5359 | ||
|
|
2dd7751e09 | ||
|
|
26bdabcdec | ||
|
|
fc8c7ed77e | ||
|
|
521c96354f | ||
|
|
9788779894 | ||
|
|
9b965764ab | ||
|
|
9a5a543311 | ||
|
|
b18fb868e8 | ||
|
|
c734af55c0 | ||
|
|
810b328ad2 | ||
|
|
0a8039d8d8 | ||
|
|
e51704c09a | ||
|
|
623a9012d5 | ||
|
|
b9a185634f | ||
|
|
b46889b5f0 | ||
|
|
ef9a0c07db | ||
|
|
3a6f3947c9 | ||
|
|
5c5f41d755 | ||
|
|
6803a8fad0 | ||
|
|
8e4b362e4d | ||
|
|
acb5e624c6 | ||
|
|
a98949ff1d | ||
|
|
f355280250 | ||
|
|
cee8d6a8d9 | ||
|
|
27326ea069 | ||
|
|
7bbe5aca5b | ||
|
|
1c4afe6d0f | ||
|
|
2d8f9a9849 | ||
|
|
3f41681b18 | ||
|
|
64791815fa | ||
|
|
8a36571a74 | ||
|
|
d18e775bec | ||
|
|
78381f1818 | ||
|
|
7f33a01ae1 | ||
|
|
d07d14d33a | ||
|
|
540d7886ab | ||
|
|
5a5d10af52 | ||
|
|
f95d077ef8 | ||
|
|
05dd99936f | ||
|
|
c086625773 | ||
|
|
dc17bebf4a | ||
|
|
026464b2e4 | ||
|
|
bd42158a70 | ||
|
|
df066f4321 | ||
|
|
69832e8c70 | ||
|
|
95eb6ad09a | ||
|
|
f3fc0bed45 | ||
|
|
5dd6b697b9 | ||
|
|
b7d170c7d1 | ||
|
|
7541172d12 | ||
|
|
85bf5d113c | ||
|
|
89fd397903 | ||
|
|
d8392f2f18 | ||
|
|
36b74f0efe | ||
|
|
68c0a36b00 | ||
|
|
a127b72a74 | ||
|
|
5782fb9e52 | ||
|
|
20319f7974 | ||
|
|
c4087e2ec2 | ||
|
|
b1d1f2f627 | ||
|
|
62fe6a8263 | ||
|
|
d88c10f3b4 | ||
|
|
00f49990c7 | ||
|
|
89f30ad47e | ||
|
|
3b1cbed238 | ||
|
|
4571b80a49 | ||
|
|
de2b8672d4 | ||
|
|
ccded7b429 | ||
|
|
1d4e98410a | ||
|
|
e493b27ef1 | ||
|
|
70589c136f | ||
|
|
1c3620a7d4 | ||
|
|
c2cc0704d7 | ||
|
|
2a50e08bb8 | ||
|
|
6b326a45d7 | ||
|
|
b73874bf24 | ||
|
|
95c8ad0f80 | ||
|
|
996763cc52 | ||
|
|
6a8171d335 | ||
|
|
2f32586dab | ||
|
|
db898001eb | ||
|
|
c2a12b661a | ||
|
|
f51c49db93 | ||
|
|
1be5b0f327 | ||
|
|
a136c62208 | ||
|
|
cc461b1331 | ||
|
|
dbe5363672 | ||
|
|
45d4361e7d | ||
|
|
b28c44cc6b | ||
|
|
b709a7a30a | ||
|
|
64c25bdb40 | ||
|
|
c230f244be | ||
|
|
02af4ff113 | ||
|
|
4dff8a215e | ||
|
|
41065305aa | ||
|
|
e9dce3ce81 | ||
|
|
ff7dde7522 | ||
|
|
a226fd23c3 | ||
|
|
776673ebae | ||
|
|
32d2cc3aea | ||
|
|
8a17fcdda5 | ||
|
|
9602d7a960 | ||
|
|
ac12a4b9c9 | ||
|
|
af96050944 | ||
|
|
a43b37dfc1 | ||
|
|
c08dcac1d4 | ||
|
|
a17dccd84e | ||
|
|
9a57cab3ee | ||
|
|
751b060320 | ||
|
|
4111b99a6d | ||
|
|
d6fb2b56d1 | ||
|
|
cb5c77e536 | ||
|
|
44c89b1ea2 | ||
|
|
26a285053b | ||
|
|
1446a6a2d2 | ||
|
|
047eba3ff3 | ||
|
|
8d9d183ce6 | ||
|
|
eb67195840 | ||
|
|
93306c2326 | ||
|
|
7d9cf8d713 | ||
|
|
03eb7898e7 | ||
|
|
0fbd4cd632 | ||
|
|
858bf359b8 | ||
|
|
5dc8465ebd | ||
|
|
0f30a221fa | ||
|
|
e86a547e93 | ||
|
|
32d8b4b83f | ||
|
|
78535b3e33 | ||
|
|
6c9a238973 | ||
|
|
cf5e228288 | ||
|
|
9dce41ed6b | ||
|
|
ca26a0f2e4 | ||
|
|
47d777c8f7 | ||
|
|
2ef51f7df9 | ||
|
|
2d7db2a80f | ||
|
|
526202ec8b | ||
|
|
86ab729356 | ||
|
|
dd74af4c70 | ||
|
|
b79a8457f9 | ||
|
|
d941c512db | ||
|
|
0ff73039e5 | ||
|
|
2ea3e9b081 | ||
|
|
da71821204 | ||
|
|
16f0914f09 | ||
|
|
1cf6afad9a | ||
|
|
261c21b057 | ||
|
|
925a22b644 | ||
|
|
dc5c42821e | ||
|
|
1667e1b32f | ||
|
|
c332c7bc70 | ||
|
|
5e8d432614 | ||
|
|
f6282ca031 | ||
|
|
3278d22279 | ||
|
|
c9618793e3 | ||
|
|
1ef785a9ef | ||
|
|
fdc98f9ef3 | ||
|
|
0de37819b4 | ||
|
|
9ff92c5d15 | ||
|
|
e629f51af4 | ||
|
|
b377003192 | ||
|
|
a7e40a78c1 | ||
|
|
9cdda8c46a | ||
|
|
b7ea812dcc | ||
|
|
710ab2386c | ||
|
|
81bf6d583d | ||
|
|
02575a2ef6 | ||
|
|
da6ab2753e | ||
|
|
97de72de83 | ||
|
|
12b80e08be | ||
|
|
4b130fa2e5 | ||
|
|
9dca18f966 | ||
|
|
543b65b09b | ||
|
|
9eb27811b1 | ||
|
|
7c3d93e5da | ||
|
|
485480560a | ||
|
|
0ac927794a | ||
|
|
e09d3b654d | ||
|
|
c5af5de4f0 | ||
|
|
19c22a8c5e | ||
|
|
0103c7bfd9 | ||
|
|
7b26bd88c0 | ||
|
|
da0168bd82 | ||
|
|
d1e59be46b | ||
|
|
9774db6011 | ||
|
|
46c19dfc5a | ||
|
|
9ed6752573 | ||
|
|
d8fdad1455 | ||
|
|
f56636e1e9 | ||
|
|
03599f1fc9 | ||
|
|
be78ecbf9a | ||
|
|
ba2b04ca89 | ||
|
|
121399f336 | ||
|
|
3fded51534 | ||
|
|
8f63ec39da | ||
|
|
5a1c1aeb02 | ||
|
|
6ec575f8de | ||
|
|
683b6afbfb | ||
|
|
663714bb6d | ||
|
|
bb35ca0d40 | ||
|
|
5f3072e67e | ||
|
|
2a4707d51e | ||
|
|
6534a9ec1d | ||
|
|
0a5ad4db06 | ||
|
|
6ee0d72c7b | ||
|
|
ba32ce21d0 | ||
|
|
0e224efa46 | ||
|
|
175461c13a | ||
|
|
c514692233 | ||
|
|
d8d0442d63 | ||
|
|
2236ebbd42 | ||
|
|
0bfba3e4ba | ||
|
|
a57a64823e | ||
|
|
aa05459e4f | ||
|
|
0615c5c52d | ||
|
|
487411340a | ||
|
|
5139dc7f3e | ||
|
|
88d0d3931c | ||
|
|
df2ef8d2e1 | ||
|
|
29229b2137 | ||
|
|
851cc38216 | ||
|
|
effbbc7370 | ||
|
|
08e3f23408 | ||
|
|
62a0aefe44 | ||
|
|
3476939b7e | ||
|
|
38e474deaf | ||
|
|
00c70d3cb5 | ||
|
|
af9fd9f552 | ||
|
|
0a731973b9 | ||
|
|
c4bd13bcdf | ||
|
|
a5bfbf244c | ||
|
|
39e0d9fc4a | ||
|
|
905bc5c1a6 | ||
|
|
0f395d43a0 | ||
|
|
0b5b7b0bf1 | ||
|
|
57dd679026 | ||
|
|
cdd69290c3 | ||
|
|
175b3dcb75 | ||
|
|
ca818e12a9 | ||
|
|
6b9426a051 | ||
|
|
cee5e50857 | ||
|
|
3fe346101b | ||
|
|
87e5998489 | ||
|
|
d7d1b6ff02 | ||
|
|
7073b42afa | ||
|
|
120d209e66 | ||
|
|
62e981c6b8 | ||
|
|
941302a4be | ||
|
|
20f423268e | ||
|
|
522013425b | ||
|
|
e3c413759f | ||
|
|
6ed97d1c19 | ||
|
|
53ad1fc068 | ||
|
|
1e2ef06c5c | ||
|
|
9db86f13f3 | ||
|
|
369461e635 | ||
|
|
d2d22ac76d | ||
|
|
a5a19fc9dd | ||
|
|
a36c991897 | ||
|
|
4f71219e17 | ||
|
|
69e0bae75e | ||
|
|
1b18679950 | ||
|
|
e1c119b5a8 | ||
|
|
03709910fd | ||
|
|
8fdb330195 | ||
|
|
59ae6458dc | ||
|
|
c10b701b9a | ||
|
|
80caa8b60d | ||
|
|
97cf5cca2a | ||
|
|
3e76dc718b | ||
|
|
5a17b5a63b | ||
|
|
5bc5185ac5 | ||
|
|
3712fa7c24 | ||
|
|
918cc235a4 | ||
|
|
8d24e54fa1 | ||
|
|
35b7b58ff7 | ||
|
|
ffc29a319f | ||
|
|
ba3ac5ea7b | ||
|
|
ee6a54fe4c | ||
|
|
f6ff79085e | ||
|
|
bcd38c7d5a | ||
|
|
aaeb25828f | ||
|
|
af26c39482 | ||
|
|
2006259a23 | ||
|
|
707e2f4d77 | ||
|
|
8d8aed36a8 | ||
|
|
2658ef0176 | ||
|
|
400d542fef | ||
|
|
f46868407c | ||
|
|
e3fa07077c | ||
|
|
e5763e73eb | ||
|
|
fd880e0a0e | ||
|
|
e33cc89846 | ||
|
|
f40b373f9f | ||
|
|
cd8535d410 | ||
|
|
f07b99fe97 | ||
|
|
f45a00df3b | ||
|
|
cd864c40bc | ||
|
|
91b44a2759 | ||
|
|
d8cd8c5def | ||
|
|
b0be06540a | ||
|
|
4deee93a55 | ||
|
|
451c0a6d03 | ||
|
|
0db3e6c58c | ||
|
|
f83d6df4ef | ||
|
|
5a9e25c315 | ||
|
|
50e3c2c3de | ||
|
|
093ee9732f | ||
|
|
333189ee51 | ||
|
|
50b8a66794 | ||
|
|
8be3fc1a66 | ||
|
|
b5503989f9 | ||
|
|
5b8bc09826 | ||
|
|
c8ee21f227 | ||
|
|
a420fbf1e8 | ||
|
|
ca34c28335 | ||
|
|
3e1b81c4ce | ||
|
|
9b353dfda6 | ||
|
|
d8dcc6f34b | ||
|
|
fba1272a3e | ||
|
|
e20a038970 | ||
|
|
6f34dccc89 | ||
|
|
f5b0eb044a | ||
|
|
bae86e978e | ||
|
|
8030a822ab | ||
|
|
9c5ec110e5 | ||
|
|
67302d09f3 | ||
|
|
7dc9ea78fa | ||
|
|
0ee56314fb | ||
|
|
b7b60b5fe5 | ||
|
|
d9c9fafd78 | ||
|
|
bb0a79c577 | ||
|
|
81d44a0854 | ||
|
|
ebc95cb8f2 | ||
|
|
a488c00a2e | ||
|
|
bf3c2c3725 | ||
|
|
89df496f0c | ||
|
|
9959f2e952 | ||
|
|
795557c046 | ||
|
|
225a3bf184 | ||
|
|
e65d7418b7 | ||
|
|
f478bbf826 | ||
|
|
5e691c2140 | ||
|
|
e0cadaa68d | ||
|
|
9175e4686b | ||
|
|
e8afca614c | ||
|
|
4f4b630ae9 | ||
|
|
6b6db2f8e6 | ||
|
|
b7ed22bc59 | ||
|
|
97cc3c7cce | ||
|
|
f5d52396f5 | ||
|
|
9cc154da05 | ||
|
|
5aa49d232c | ||
|
|
1cb42cbb30 | ||
|
|
9f320590d3 | ||
|
|
1b0fd2e0ba | ||
|
|
b249b2a81b | ||
|
|
0a5d4eb7ed | ||
|
|
3dcbc737f3 | ||
|
|
43f11e929d | ||
|
|
8f2a551cca | ||
|
|
8f044c6853 | ||
|
|
a76c00a787 | ||
|
|
0633f16b4d | ||
|
|
59fafb8b30 | ||
|
|
d2bd99cc2a | ||
|
|
62930ecc4e | ||
|
|
6cb57aa8a4 | ||
|
|
9861c3878e | ||
|
|
707d7b062b | ||
|
|
18736bdcd0 | ||
|
|
e8b2e86007 | ||
|
|
ae8b4f56f2 | ||
|
|
28a0074497 | ||
|
|
71c039db09 | ||
|
|
15646c258b | ||
|
|
25a5605b35 | ||
|
|
b630e32c6a | ||
|
|
c39254bf98 | ||
|
|
994a0e78f1 | ||
|
|
ab2ca15c5c | ||
|
|
07f447c457 | ||
|
|
62c8f1ba04 | ||
|
|
e08edc2d6b | ||
|
|
a147c09b06 | ||
|
|
9fca74443e | ||
|
|
6f258f71d5 | ||
|
|
ce61c16dbe | ||
|
|
4c973238a1 | ||
|
|
3a8da82792 | ||
|
|
f10da122ff | ||
|
|
ec20a8cacb | ||
|
|
102fb506db | ||
|
|
34ba520f44 | ||
|
|
fa099555c0 | ||
|
|
8387c5b14e | ||
|
|
5040095228 | ||
|
|
788fae59a1 | ||
|
|
e042f44e0d | ||
|
|
b1fc3e5cec | ||
|
|
d7b1b7a2a9 | ||
|
|
97744ad24f | ||
|
|
2e79b2a871 | ||
|
|
349f0f7068 | ||
|
|
94f9587db1 | ||
|
|
6df8f62022 | ||
|
|
8c71473498 | ||
|
|
08d89053da | ||
|
|
4b36fa0739 | ||
|
|
921b063a71 | ||
|
|
3de633c869 | ||
|
|
021f0545eb | ||
|
|
b701eb85b8 | ||
|
|
4e80378a77 | ||
|
|
830d2f28b9 | ||
|
|
c5ba34d0b0 | ||
|
|
2e31bb519a | ||
|
|
169bd4cb39 | ||
|
|
aa90f22865 | ||
|
|
9bba90c47e | ||
|
|
2844cb5bca | ||
|
|
dff81bb161 | ||
|
|
1f2abce7c3 | ||
|
|
e67ada8823 | ||
|
|
42e39f6eb5 | ||
|
|
f317a7a322 | ||
|
|
8434ecbb43 | ||
|
|
0c18026240 | ||
|
|
6eb25687f8 | ||
|
|
737db5668b | ||
|
|
f16e0333e4 | ||
|
|
27ffcaabe9 | ||
|
|
db031a5b95 | ||
|
|
2e9fbd07cd | ||
|
|
74acf83464 | ||
|
|
3dc057ca9c | ||
|
|
e142339106 | ||
|
|
39038750a8 | ||
|
|
f68733bf11 | ||
|
|
85edb3e90c | ||
|
|
d7ce6d016b | ||
|
|
9023a12ad4 | ||
|
|
0547671246 | ||
|
|
068f1bc202 | ||
|
|
7035f76077 | ||
|
|
f0268d49fe | ||
|
|
7dbf5d6319 | ||
|
|
ed6b6038ee | ||
|
|
ad24ef8a25 | ||
|
|
645bab7748 | ||
|
|
abd7d1de48 | ||
|
|
ea0ee070ef | ||
|
|
2a69170f14 | ||
|
|
725e7b4229 | ||
|
|
187e6740bd | ||
|
|
4b40d5b0d4 | ||
|
|
ee2bad20c7 | ||
|
|
b7805fee93 | ||
|
|
0104e93ba9 | ||
|
|
25a4961453 | ||
|
|
7338e522bd | ||
|
|
58c020a2e1 | ||
|
|
f7eced03fd | ||
|
|
9be7c02461 | ||
|
|
9483f2df60 | ||
|
|
f17a05c342 | ||
|
|
e41c551757 | ||
|
|
95dfbd1fe0 | ||
|
|
287d5dee4d | ||
|
|
77405cc103 | ||
|
|
abf7191eec | ||
|
|
c6bb2b6f9c | ||
|
|
acede0f3e8 | ||
|
|
e56106cbdc | ||
|
|
87f9528791 | ||
|
|
397522f277 | ||
|
|
a745819ddf | ||
|
|
5d5bcf7011 | ||
|
|
19e67dcf0b | ||
|
|
1897da5348 | ||
|
|
d8cbb03c42 | ||
|
|
bc227bef21 | ||
|
|
3bcb1dc802 | ||
|
|
d0786b4156 | ||
|
|
14790eeae3 | ||
|
|
3056b351fa | ||
|
|
52fca57114 | ||
|
|
ee7a570b2f | ||
|
|
61dcf72e04 | ||
|
|
bace8ad510 | ||
|
|
e0b759839d | ||
|
|
05b0a3e7d2 | ||
|
|
2518037b91 | ||
|
|
3e452f362c | ||
|
|
4900544574 | ||
|
|
858589dc6b | ||
|
|
915f2e70a3 | ||
|
|
aae301878c | ||
|
|
383a49b44f | ||
|
|
a45cc4b618 | ||
|
|
aef7d7825f | ||
|
|
f28ce661af | ||
|
|
74eb9c8d0f | ||
|
|
d664221c64 | ||
|
|
58bff3d4ac | ||
|
|
2c206eb98c | ||
|
|
19724e5af9 | ||
|
|
c9e0ad132c | ||
|
|
24f265a963 | ||
|
|
f8a743ee00 | ||
|
|
64971de7ed | ||
|
|
a960c325f3 | ||
|
|
a799470997 | ||
|
|
10414791a2 | ||
|
|
743974e60d | ||
|
|
0e267cae4b | ||
|
|
12a352ae2f | ||
|
|
5070b27728 | ||
|
|
7a6b734078 | ||
|
|
24823da6f7 | ||
|
|
8701cb3a8f | ||
|
|
315fc1fbe3 | ||
|
|
23833bac10 | ||
|
|
8235b6efc9 | ||
|
|
7f937eea5a | ||
|
|
a1cf634ac1 | ||
|
|
c86472e997 | ||
|
|
26cb398a6f | ||
|
|
f6e664d298 | ||
|
|
9437cecf87 | ||
|
|
13309511b3 | ||
|
|
1941cb16c0 | ||
|
|
55823c5d5d | ||
|
|
4721da1679 | ||
|
|
482f750231 | ||
|
|
d5119db165 | ||
|
|
37578ed74f | ||
|
|
f5992ce822 | ||
|
|
badb0035c5 | ||
|
|
4bc14aa261 | ||
|
|
a0c4ec0be0 | ||
|
|
264fffa826 | ||
|
|
bddb37e44f | ||
|
|
6393b0cbc0 | ||
|
|
a8df438814 | ||
|
|
8014857ebf | ||
|
|
9e7261a48f | ||
|
|
c4e70d0475 | ||
|
|
cbb0aaa217 | ||
|
|
ce50e74491 | ||
|
|
e103e1c277 | ||
|
|
64929fe5dc | ||
|
|
b108f1e6c9 | ||
|
|
58b417e045 | ||
|
|
2e5a616d8e | ||
|
|
092d446a7e | ||
|
|
85a1f126bf | ||
|
|
cf58cf86da | ||
|
|
db6210c7ee | ||
|
|
83cd071827 | ||
|
|
084c3a95b6 | ||
|
|
78908aa34e | ||
|
|
cf27706f91 | ||
|
|
d3f53a7fd6 | ||
|
|
508af5613f | ||
|
|
c615c31016 | ||
|
|
908b28790b | ||
|
|
4c0279729b | ||
|
|
96dfac5b33 | ||
|
|
8576218b51 | ||
|
|
1c1f9201b8 | ||
|
|
4398b88a3a | ||
|
|
73e79f5ca4 | ||
|
|
1bfd51d6e9 | ||
|
|
0d2daf27f2 | ||
|
|
87f0d8cf3c | ||
|
|
06d5a10902 | ||
|
|
94b89c5439 | ||
|
|
c5e951be09 | ||
|
|
66ae5c8161 | ||
|
|
8438e2202f | ||
|
|
7a6166d229 | ||
|
|
d46fa4b215 | ||
|
|
2bd5b4ab86 | ||
|
|
5efbc5ceb3 | ||
|
|
2e905bac08 | ||
|
|
4c0ad5f964 | ||
|
|
455cbf3bf4 | ||
|
|
a3a28c56fa | ||
|
|
b0b3175641 | ||
|
|
c2f0df3f73 | ||
|
|
820f1f9ac6 | ||
|
|
337aee5b65 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
target
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Meilisearch version:** [e.g. v0.20.0]
|
||||
|
||||
**Additional context**
|
||||
Additional information that may be relevant to the issue.
|
||||
[e.g. architecture, device, OS, browser]
|
||||
13
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
13
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
contact_links:
|
||||
- name: Language support request & feedback
|
||||
url: https://github.com/meilisearch/product/discussions/categories/feedback-feature-proposal?discussions_q=label%3Aproduct%3Acore%3Atokenizer+category%3A%22Feedback+%26+Feature+Proposal%22
|
||||
about: The requests and feedback regarding Language support are not managed in this repository. Please upvote the related discussion in our dedicated product repository or open a new one if it doesn't exist.
|
||||
- name: Feature request & feedback
|
||||
url: https://github.com/meilisearch/product/discussions/categories/feedback-feature-proposal
|
||||
about: The feature requests and feedback regarding the already existing features are not managed in this repository. Please open a discussion in our dedicated product repository
|
||||
- name: Documentation issue
|
||||
url: https://github.com/meilisearch/documentation/issues/new
|
||||
about: For documentation issues, open an issue or a PR in the documentation repository
|
||||
- name: Support questions & other
|
||||
url: https://github.com/meilisearch/meilisearch/discussions/new
|
||||
about: For any other question, open a discussion in this repository
|
||||
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Set update schedule for GitHub Actions only
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
labels:
|
||||
- 'skip changelog'
|
||||
- 'dependencies'
|
||||
rebase-strategy: disabled
|
||||
28
.github/scripts/check-release.sh
vendored
Normal file
28
.github/scripts/check-release.sh
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# check_tag $current_tag $file_tag $file_name
|
||||
function check_tag {
|
||||
if [[ "$1" != "$2" ]]; then
|
||||
echo "Error: the current tag does not match the version in $3: found $2 - expected $1"
|
||||
ret=1
|
||||
fi
|
||||
}
|
||||
|
||||
ret=0
|
||||
current_tag=${GITHUB_REF#'refs/tags/v'}
|
||||
|
||||
toml_files='*/Cargo.toml'
|
||||
for toml_file in $toml_files;
|
||||
do
|
||||
file_tag="$(grep '^version = ' $toml_file | cut -d '=' -f 2 | tr -d '"' | tr -d ' ')"
|
||||
check_tag $current_tag $file_tag $toml_file
|
||||
done
|
||||
|
||||
lock_file='Cargo.lock'
|
||||
lock_tag=$(grep -A 1 'name = "meilisearch-auth"' $lock_file | grep version | cut -d '=' -f 2 | tr -d '"' | tr -d ' ')
|
||||
check_tag $current_tag $lock_tag $lock_file
|
||||
|
||||
if [[ "$ret" -eq 0 ]] ; then
|
||||
echo 'OK'
|
||||
fi
|
||||
exit $ret
|
||||
132
.github/scripts/is-latest-release.sh
vendored
Normal file
132
.github/scripts/is-latest-release.sh
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Was used in our CIs to publish the latest docker image. Not used anymore, will be used again when v1 and v2 will be out and we will want to maintain multiple stable versions.
|
||||
# Returns "true" or "false" (as a string) to be used in the `if` in GHA
|
||||
|
||||
# Checks if the current tag should be the latest (in terms of semver and not of release date).
|
||||
# Ex: previous tag -> v2.1.1
|
||||
# new tag -> v1.20.3
|
||||
# The new tag (v1.20.3) should NOT be the latest
|
||||
# So it returns "false", the `latest` tag should not be updated for the release v1.20.3 and still need to correspond to v2.1.1
|
||||
|
||||
# GLOBAL
|
||||
GREP_SEMVER_REGEXP='v\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)$' # i.e. v[number].[number].[number]
|
||||
|
||||
# FUNCTIONS
|
||||
|
||||
# semverParseInto and semverLT from https://github.com/cloudflare/semver_bash/blob/master/semver.sh
|
||||
|
||||
# usage: semverParseInto version major minor patch special
|
||||
# version: the string version
|
||||
# major, minor, patch, special: will be assigned by the function
|
||||
semverParseInto() {
|
||||
local RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z-]*\)'
|
||||
#MAJOR
|
||||
eval $2=`echo $1 | sed -e "s#$RE#\1#"`
|
||||
#MINOR
|
||||
eval $3=`echo $1 | sed -e "s#$RE#\2#"`
|
||||
#MINOR
|
||||
eval $4=`echo $1 | sed -e "s#$RE#\3#"`
|
||||
#SPECIAL
|
||||
eval $5=`echo $1 | sed -e "s#$RE#\4#"`
|
||||
}
|
||||
|
||||
# usage: semverLT version1 version2
|
||||
semverLT() {
|
||||
local MAJOR_A=0
|
||||
local MINOR_A=0
|
||||
local PATCH_A=0
|
||||
local SPECIAL_A=0
|
||||
|
||||
local MAJOR_B=0
|
||||
local MINOR_B=0
|
||||
local PATCH_B=0
|
||||
local SPECIAL_B=0
|
||||
|
||||
semverParseInto $1 MAJOR_A MINOR_A PATCH_A SPECIAL_A
|
||||
semverParseInto $2 MAJOR_B MINOR_B PATCH_B SPECIAL_B
|
||||
|
||||
if [ $MAJOR_A -lt $MAJOR_B ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ $MAJOR_A -le $MAJOR_B ] && [ $MINOR_A -lt $MINOR_B ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ $MAJOR_A -le $MAJOR_B ] && [ $MINOR_A -le $MINOR_B ] && [ $PATCH_A -lt $PATCH_B ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "_$SPECIAL_A" == "_" ] && [ "_$SPECIAL_B" == "_" ] ; then
|
||||
return 1
|
||||
fi
|
||||
if [ "_$SPECIAL_A" == "_" ] && [ "_$SPECIAL_B" != "_" ] ; then
|
||||
return 1
|
||||
fi
|
||||
if [ "_$SPECIAL_A" != "_" ] && [ "_$SPECIAL_B" == "_" ] ; then
|
||||
return 0
|
||||
fi
|
||||
if [ "_$SPECIAL_A" < "_$SPECIAL_B" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Returns the tag of the latest stable release (in terms of semver and not of release date)
|
||||
get_latest() {
|
||||
temp_file='temp_file' # temp_file needed because the grep would start before the download is over
|
||||
curl -s 'https://api.github.com/repos/meilisearch/meilisearch/releases' > "$temp_file"
|
||||
releases=$(cat "$temp_file" | \
|
||||
grep -E "tag_name|draft|prerelease" \
|
||||
| tr -d ',"' | cut -d ':' -f2 | tr -d ' ')
|
||||
# Returns a list of [tag_name draft_boolean prerelease_boolean ...]
|
||||
# Ex: v0.10.1 false false v0.9.1-rc.1 false true v0.9.0 false false...
|
||||
|
||||
i=0
|
||||
latest=""
|
||||
current_tag=""
|
||||
for release_info in $releases; do
|
||||
if [ $i -eq 0 ]; then # Cheking tag_name
|
||||
if echo "$release_info" | grep -q "$GREP_SEMVER_REGEXP"; then # If it's not an alpha or beta release
|
||||
current_tag=$release_info
|
||||
else
|
||||
current_tag=""
|
||||
fi
|
||||
i=1
|
||||
elif [ $i -eq 1 ]; then # Checking draft boolean
|
||||
if [ "$release_info" = "true" ]; then
|
||||
current_tag=""
|
||||
fi
|
||||
i=2
|
||||
elif [ $i -eq 2 ]; then # Checking prerelease boolean
|
||||
if [ "$release_info" = "true" ]; then
|
||||
current_tag=""
|
||||
fi
|
||||
i=0
|
||||
if [ "$current_tag" != "" ]; then # If the current_tag is valid
|
||||
if [ "$latest" = "" ]; then # If there is no latest yet
|
||||
latest="$current_tag"
|
||||
else
|
||||
semverLT $current_tag $latest # Comparing latest and the current tag
|
||||
if [ $? -eq 1 ]; then
|
||||
latest="$current_tag"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$temp_file"
|
||||
echo $latest
|
||||
}
|
||||
|
||||
# MAIN
|
||||
current_tag="$(echo $GITHUB_REF | tr -d 'refs/tags/')"
|
||||
latest="$(get_latest)"
|
||||
|
||||
if [ "$current_tag" != "$latest" ]; then
|
||||
# The current release tag is not the latest
|
||||
echo "false"
|
||||
else
|
||||
# The current release tag is the latest
|
||||
echo "true"
|
||||
fi
|
||||
33
.github/workflows/coverage.yml
vendored
Normal file
33
.github/workflows/coverage.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
name: Execute code coverage
|
||||
|
||||
jobs:
|
||||
nightly-coverage:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
override: true
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clean
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --all-features --no-fail-fast
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=unwind -Zpanic_abort_tests"
|
||||
- uses: actions-rs/grcov@v0.1
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ${{ steps.coverage.outputs.report }}
|
||||
yml: ./codecov.yml
|
||||
fail_ci_if_error: true
|
||||
23
.github/workflows/create-issue-dependencies.yml
vendored
Normal file
23
.github/workflows/create-issue-dependencies.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Create issue to upgrade dependencies
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 */3 *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
create-issue:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Create an issue
|
||||
uses: actions-ecosystem/action-create-issue@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
title: Upgrade dependencies
|
||||
body: |
|
||||
We need to update the dependencies of the Meilisearch repository, and, if possible, the dependencies of all the core-team repositories that Meilisearch depends on (milli, charabia, heed...).
|
||||
|
||||
⚠️ This issue should only be done at the beginning of the sprint!
|
||||
labels: |
|
||||
dependencies
|
||||
maintenance
|
||||
15
.github/workflows/flaky.yml
vendored
Normal file
15
.github/workflows/flaky.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Look for flaky tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * FRI" # every friday at 12:00PM
|
||||
|
||||
jobs:
|
||||
flaky:
|
||||
runs-on: ubuntu-18.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install cargo-flaky
|
||||
run: cargo install cargo-flaky
|
||||
- name: Run cargo flaky 100 times
|
||||
run: cargo flaky -i 100 --release
|
||||
131
.github/workflows/publish-binaries.yml
vendored
Normal file
131
.github/workflows/publish-binaries.yml
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
name: Publish binaries to release
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check the version validity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
# Check if the tag has the v<nmumber>.<number>.<number> format.
|
||||
# If yes, it means we are publishing an official release.
|
||||
# If no, we are releasing a RC, so no need to check the version.
|
||||
- name: Check tag format
|
||||
if: github.event_name != 'schedule'
|
||||
id: check-tag-format
|
||||
run: |
|
||||
escaped_tag=$(printf "%q" ${{ github.ref_name }})
|
||||
|
||||
if [[ $escaped_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo ::set-output name=stable::true
|
||||
else
|
||||
echo ::set-output name=stable::false
|
||||
fi
|
||||
- name: Check release validity
|
||||
if: steps.check-tag-format.outputs.stable == 'true'
|
||||
run: bash .github/scripts/check-release.sh
|
||||
|
||||
publish:
|
||||
name: Publish binary for ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: check-version
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-18.04, macos-latest, windows-latest]
|
||||
include:
|
||||
- os: ubuntu-18.04
|
||||
artifact_name: meilisearch
|
||||
asset_name: meilisearch-linux-amd64
|
||||
- os: macos-latest
|
||||
artifact_name: meilisearch
|
||||
asset_name: meilisearch-macos-amd64
|
||||
- os: windows-latest
|
||||
artifact_name: meilisearch.exe
|
||||
asset_name: meilisearch-windows-amd64.exe
|
||||
|
||||
steps:
|
||||
- uses: hecrj/setup-rust-action@master
|
||||
with:
|
||||
rust-version: stable
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build
|
||||
run: cargo build --release --locked
|
||||
- name: Upload binaries to release
|
||||
uses: svenstaro/upload-release-action@v1-release
|
||||
with:
|
||||
repo_token: ${{ secrets.PUBLISH_TOKEN }}
|
||||
file: target/release/${{ matrix.artifact_name }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
publish-aarch64:
|
||||
name: Publish binary for aarch64
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: check-version
|
||||
continue-on-error: false
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- build: aarch64
|
||||
os: ubuntu-18.04
|
||||
target: aarch64-unknown-linux-gnu
|
||||
linker: gcc-aarch64-linux-gnu
|
||||
use-cross: true
|
||||
asset_name: meilisearch-linux-aarch64
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Installing Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
profile: minimal
|
||||
target: ${{ matrix.target }}
|
||||
override: true
|
||||
|
||||
- name: APT update
|
||||
run: |
|
||||
sudo apt update
|
||||
|
||||
- name: Install target specific tools
|
||||
if: matrix.use-cross
|
||||
run: |
|
||||
sudo apt-get install -y ${{ matrix.linker }}
|
||||
|
||||
- name: Configure target aarch64 GNU
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
## Environment variable is not passed using env:
|
||||
## LD gold won't work with MUSL
|
||||
# env:
|
||||
# JEMALLOC_SYS_WITH_LG_PAGE: 16
|
||||
# RUSTFLAGS: '-Clink-arg=-fuse-ld=gold'
|
||||
run: |
|
||||
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config
|
||||
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config
|
||||
echo 'JEMALLOC_SYS_WITH_LG_PAGE=16' >> $GITHUB_ENV
|
||||
echo RUSTFLAGS="-Clink-arg=-fuse-ld=gold" >> $GITHUB_ENV
|
||||
|
||||
- name: Cargo build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: build
|
||||
use-cross: ${{ matrix.use-cross }}
|
||||
args: --release --target ${{ matrix.target }}
|
||||
|
||||
- name: List target output files
|
||||
run: ls -lR ./target
|
||||
|
||||
- name: Upload the binary to release
|
||||
uses: svenstaro/upload-release-action@v1-release
|
||||
with:
|
||||
repo_token: ${{ secrets.PUBLISH_TOKEN }}
|
||||
file: target/${{ matrix.target }}/release/meilisearch
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
49
.github/workflows/publish-deb-brew-pkg.yml
vendored
Normal file
49
.github/workflows/publish-deb-brew-pkg.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Publish deb pkg to GitHub release & APT repository & Homebrew
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check the version validity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check release validity
|
||||
run: bash .github/scripts/check-release.sh
|
||||
|
||||
debian:
|
||||
name: Publish debian packagge
|
||||
runs-on: ubuntu-18.04
|
||||
needs: check-version
|
||||
steps:
|
||||
- uses: hecrj/setup-rust-action@master
|
||||
with:
|
||||
rust-version: stable
|
||||
- name: Install cargo-deb
|
||||
run: cargo install cargo-deb
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build deb package
|
||||
run: cargo deb -p meilisearch-http -o target/debian/meilisearch.deb
|
||||
- name: Upload debian pkg to release
|
||||
uses: svenstaro/upload-release-action@v1-release
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: target/debian/meilisearch.deb
|
||||
asset_name: meilisearch.deb
|
||||
tag: ${{ github.ref }}
|
||||
- name: Upload debian pkg to apt repository
|
||||
run: curl -F package=@target/debian/meilisearch.deb https://${{ secrets.GEMFURY_PUSH_TOKEN }}@push.fury.io/meilisearch/
|
||||
|
||||
homebrew:
|
||||
name: Bump Homebrew formula
|
||||
runs-on: ubuntu-18.04
|
||||
needs: check-version
|
||||
steps:
|
||||
- name: Create PR to Homebrew
|
||||
uses: mislav/bump-homebrew-formula-action@v1
|
||||
with:
|
||||
formula-name: meilisearch
|
||||
env:
|
||||
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_COMMITTER_TOKEN }}
|
||||
71
.github/workflows/publish-docker-images.yml
vendored
Normal file
71
.github/workflows/publish-docker-images.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # Every day at 4:00am
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
name: Publish tagged images to Docker Hub
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# Check if the tag has the v<nmumber>.<number>.<number> format. If yes, it means we are publishing an official release.
|
||||
# In this situation, we need to set `output.stable` to create/update the following tags (additionally to the `vX.Y.Z` Docker tag):
|
||||
# - a `vX.Y` (without patch version) Docker tag
|
||||
# - a `latest` Docker tag
|
||||
- name: Check tag format
|
||||
if: github.event_name != 'schedule'
|
||||
id: check-tag-format
|
||||
run: |
|
||||
escaped_tag=$(printf "%q" ${{ github.ref_name }})
|
||||
|
||||
if [[ $escaped_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo ::set-output name=stable::true
|
||||
else
|
||||
echo ::set-output name=stable::false
|
||||
fi
|
||||
|
||||
# Check only the validity of the tag for official releases (not for pre-releases or other tags)
|
||||
- name: Check release validity
|
||||
if: github.event_name != 'schedule' && steps.check-tag-format.outputs.stable == 'true'
|
||||
run: bash .github/scripts/check-release.sh
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'schedule'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: getmeili/meilisearch
|
||||
# The lastest and `vX.Y` tags are only pushed for the official Meilisearch releases
|
||||
# See https://github.com/docker/metadata-action#latest-tag
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern=v{{major}}.{{minor}},enable=${{ steps.check-tag-format.outputs.stable == 'true' }}
|
||||
type=raw,value=latest,enable=${{ steps.check-tag-format.outputs.stable == 'true' }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
# We do not push tags for the cron jobs, this is only for test purposes
|
||||
push: ${{ github.event_name != 'schedule' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
96
.github/workflows/rust.yml
vendored
Normal file
96
.github/workflows/rust.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
# trying and staging branches are for Bors config
|
||||
branches:
|
||||
- trying
|
||||
- staging
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-18.04, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: rui314/setup-mold@v1 # Optimize link time
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2.0.0
|
||||
- name: Run cargo check without any default features
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: build
|
||||
args: --locked --release --no-default-features
|
||||
- name: Run cargo test
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --locked --release
|
||||
|
||||
# We run tests in debug also, to make sure that the debug_assertions are hit
|
||||
test-debug:
|
||||
name: Run tests in debug
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: rui314/setup-mold@v1 # Optimize link time
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2.0.0
|
||||
- name: Run tests in debug
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --locked
|
||||
|
||||
clippy:
|
||||
name: Run Clippy
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: rui314/setup-mold@v1 # Optimize link time
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: clippy
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2.0.0
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --all-targets -- --deny warnings
|
||||
|
||||
fmt:
|
||||
name: Run Rustfmt
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: rui314/setup-mold@v1 # Optimize link time
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2.0.0
|
||||
- name: Run cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
/rocksdb
|
||||
/target
|
||||
/Cargo.lock
|
||||
**/*.rs.bk
|
||||
**/*.csv
|
||||
**/*.json_lines
|
||||
**/*.rdb
|
||||
**/*.rs.bk
|
||||
/*.mdb
|
||||
/query-history.txt
|
||||
/data.ms
|
||||
/snapshots
|
||||
/dumps
|
||||
|
||||
22
.travis.yml
22
.travis.yml
@@ -1,22 +0,0 @@
|
||||
language: rust
|
||||
|
||||
cache: cargo
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
|
||||
# Test crates on their minimum Rust versions.
|
||||
- rust: 1.31.0
|
||||
name: "meilidb on 1.31.0"
|
||||
script: ./ci/meilidb.sh
|
||||
|
||||
# Test crates on nightly Rust.
|
||||
- rust: nightly
|
||||
name: "meilidb on nightly"
|
||||
script: ./ci/meilidb.sh
|
||||
|
||||
76
CODE_OF_CONDUCT.md
Normal file
76
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at bonjour@meilisearch.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
110
CONTRIBUTING.md
Normal file
110
CONTRIBUTING.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Contributing
|
||||
|
||||
First, thank you for contributing to Meilisearch! The goal of this document is to provide everything you need to start contributing to Meilisearch.
|
||||
|
||||
Remember that there are many ways to contribute other than writing code: writing [tutorials or blog posts](https://github.com/meilisearch/awesome-meilisearch), improving [the documentation](https://github.com/meilisearch/documentation), submitting [bug reports](https://github.com/meilisearch/meilisearch/issues/new?assignees=&labels=&template=bug_report.md&title=) and [feature requests](https://github.com/meilisearch/product/discussions/categories/feedback-feature-proposal)...
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Assumptions](#assumptions)
|
||||
- [How to Contribute](#how-to-contribute)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Git Guidelines](#git-guidelines)
|
||||
- [Release Process (for internal team only)](#release-process-for-internal-team-only)
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **You're familiar with [GitHub](https://github.com) and the [Pull Requests](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests)(PR) workflow.**
|
||||
2. **You've read the Meilisearch [documentation](https://docs.meilisearch.com).**
|
||||
3. **You know about the [Meilisearch community](https://docs.meilisearch.com/learn/what_is_meilisearch/contact.html).
|
||||
Please use this for help.**
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. Ensure your change has an issue! Find an
|
||||
[existing issue](https://github.com/meilisearch/meilisearch/issues/) or [open a new issue](https://github.com/meilisearch/meilisearch/issues/new).
|
||||
* This is where you can get a feel if the change will be accepted or not.
|
||||
2. Once approved, [fork the Meilisearch repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) in your own GitHub account.
|
||||
3. [Create a new Git branch](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-and-deleting-branches-within-your-repository)
|
||||
4. Review the [Development Workflow](#development-workflow) section that describes the steps to maintain the repository.
|
||||
5. Make your changes on your branch.
|
||||
6. [Submit the branch as a Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) pointing to the `main` branch of the Meilisearch repository. A maintainer should comment and/or review your Pull Request within a few days. Although depending on the circumstances, it may take longer.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Setup and run Meilisearch
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
We recommend using the `--release` flag to test the full performance of Meilisearch.
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
This command will be triggered to each PR as a requirement for merging it.
|
||||
|
||||
If you get a "Too many open files" error you might want to increase the open file limit using this command:
|
||||
|
||||
```bash
|
||||
ulimit -Sn 3000
|
||||
```
|
||||
|
||||
## Git Guidelines
|
||||
|
||||
### Git Branches
|
||||
|
||||
All changes must be made in a branch and submitted as PR.
|
||||
|
||||
We do not enforce any branch naming style, but please use something descriptive of your changes.
|
||||
|
||||
### Git Commits
|
||||
|
||||
As minimal requirements, your commit message should:
|
||||
- be capitalized
|
||||
- not finish by a dot or any other punctuation character (!,?)
|
||||
- start with a verb so that we can read your commit message this way: "This commit will ...", where "..." is the commit message.
|
||||
e.g.: "Fix the home page button" or "Add more tests for create_index method"
|
||||
|
||||
We don't follow any other convention, but if you want to use one, we recommend [the Chris Beams one](https://chris.beams.io/posts/git-commit/).
|
||||
|
||||
### GitHub Pull Requests
|
||||
|
||||
Some notes on GitHub PRs:
|
||||
|
||||
- All PRs must be reviewed and approved by at least one maintainer.
|
||||
- The PR title should be accurate and descriptive of the changes.
|
||||
- [Convert your PR as a draft](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/changing-the-stage-of-a-pull-request) if your changes are a work in progress: no one will review it until you pass your PR as ready for review.<br>
|
||||
The draft PRs are recommended when you want to show that you are working on something and make your work visible.
|
||||
- The branch related to the PR must be **up-to-date with `main`** before merging. Fortunately, this project uses [Bors](https://github.com/bors-ng/bors-ng) to automatically enforce this requirement without the PR author having to rebase manually.
|
||||
|
||||
## Release Process (for internal team only)
|
||||
|
||||
Meilisearch tools follow the [Semantic Versioning Convention](https://semver.org/).
|
||||
|
||||
### Automation to rebase and Merge the PRs
|
||||
|
||||
This project integrates a bot that helps us manage pull requests merging.<br>
|
||||
_[Read more about this](https://github.com/meilisearch/integration-guides/blob/main/resources/bors.md)._
|
||||
|
||||
### How to Publish a new Release
|
||||
|
||||
The full Meilisearch release process is described in [this guide](https://github.com/meilisearch/core-team/blob/main/resources/meilisearch-release.md). Please follow it carefully before doing any release.
|
||||
|
||||
### Release assets
|
||||
|
||||
For each release, the following assets are created:
|
||||
- Binaries for differents platforms (Linux, MacOS, Windows and ARM architectures) are attached to the GitHub release
|
||||
- Binaries are pushed to HomeBrew and APT (not published for RC)
|
||||
- Docker tags are created/updated:
|
||||
- `vX.Y.Z`
|
||||
- `vX.Y` (not published for RC)
|
||||
- `latest` (not published for RC)
|
||||
|
||||
<hr>
|
||||
|
||||
Thank you again for reading this through, we can not wait to begin to work with you if you made your way through this contributing guide ❤️
|
||||
4038
Cargo.lock
generated
Normal file
4038
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
72
Cargo.toml
72
Cargo.toml
@@ -1,61 +1,15 @@
|
||||
[package]
|
||||
edition = "2018"
|
||||
name = "meilidb"
|
||||
version = "0.2.1"
|
||||
authors = ["Kerollmops <renault.cle@gmail.com>"]
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"meilisearch-http",
|
||||
"meilisearch-types",
|
||||
"meilisearch-lib",
|
||||
"meilisearch-auth",
|
||||
"permissive-json-pointer",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
bincode = "1.0"
|
||||
byteorder = "1.2"
|
||||
crossbeam = "0.6"
|
||||
elapsed = "0.1"
|
||||
fst = "0.3"
|
||||
hashbrown = { version = "0.1", features = ["serde"] }
|
||||
lazy_static = "1.1"
|
||||
levenshtein_automata = { version = "0.1", features = ["fst_automaton"] }
|
||||
linked-hash-map = { version = "0.5", features = ["serde_impl"] }
|
||||
log = "0.4"
|
||||
sdset = "0.3"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
unidecode = "0.3"
|
||||
[profile.dev.package.flate2]
|
||||
opt-level = 3
|
||||
|
||||
[dependencies.toml]
|
||||
git = "https://github.com/Kerollmops/toml-rs.git"
|
||||
features = ["preserve_order"]
|
||||
rev = "0372ba6"
|
||||
|
||||
[dependencies.rocksdb]
|
||||
git = "https://github.com/pingcap/rust-rocksdb.git"
|
||||
rev = "306e201"
|
||||
|
||||
[dependencies.group-by]
|
||||
git = "https://github.com/Kerollmops/group-by.git"
|
||||
rev = "5a113fe"
|
||||
|
||||
[features]
|
||||
default = ["simd"]
|
||||
i128 = ["bincode/i128", "byteorder/i128"]
|
||||
portable = ["rocksdb/portable"]
|
||||
simd = ["rocksdb/sse"]
|
||||
nightly = ["hashbrown/nightly", "group-by/nightly"]
|
||||
|
||||
[dev-dependencies]
|
||||
csv = "1.0"
|
||||
env_logger = "0.6"
|
||||
jemallocator = "0.1"
|
||||
quickcheck = "0.8"
|
||||
rand = "0.6"
|
||||
rand_xorshift = "0.1"
|
||||
structopt = "0.2"
|
||||
tempfile = "3.0"
|
||||
termcolor = "1.0"
|
||||
warp = "0.1"
|
||||
|
||||
[dev-dependencies.chashmap]
|
||||
git = "https://gitlab.redox-os.org/redox-os/tfs.git"
|
||||
rev = "b3e7cae1"
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
[profile.dev.package.milli]
|
||||
opt-level = 3
|
||||
|
||||
7
Cross.toml
Normal file
7
Cross.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[build.env]
|
||||
passthrough = [
|
||||
"RUST_BACKTRACE",
|
||||
"CARGO_TERM_COLOR",
|
||||
"RUSTFLAGS",
|
||||
"JEMALLOC_SYS_WITH_LG_PAGE"
|
||||
]
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# Compile
|
||||
FROM rust:alpine3.14 AS compiler
|
||||
|
||||
RUN apk add -q --update-cache --no-cache build-base openssl-dev
|
||||
|
||||
WORKDIR /meilisearch
|
||||
|
||||
ARG COMMIT_SHA
|
||||
ARG COMMIT_DATE
|
||||
ENV COMMIT_SHA=${COMMIT_SHA} COMMIT_DATE=${COMMIT_DATE}
|
||||
ENV RUSTFLAGS="-C target-feature=-crt-static"
|
||||
|
||||
COPY . .
|
||||
RUN set -eux; \
|
||||
apkArch="$(apk --print-arch)"; \
|
||||
if [ "$apkArch" = "aarch64" ]; then \
|
||||
export JEMALLOC_SYS_WITH_LG_PAGE=16; \
|
||||
fi && \
|
||||
cargo build --release
|
||||
|
||||
# Run
|
||||
FROM alpine:3.14
|
||||
|
||||
ENV MEILI_HTTP_ADDR 0.0.0.0:7700
|
||||
ENV MEILI_SERVER_PROVIDER docker
|
||||
|
||||
RUN apk update --quiet \
|
||||
&& apk add -q --no-cache libgcc tini curl
|
||||
|
||||
# add meilisearch to the `/bin` so you can run it from anywhere and it's easy
|
||||
# to find.
|
||||
COPY --from=compiler /meilisearch/target/release/meilisearch /bin/meilisearch
|
||||
# To stay compatible with the older version of the container (pre v0.27.0) we're
|
||||
# going to symlink the meilisearch binary in the path to `/meilisearch`
|
||||
RUN ln -s /bin/meilisearch /meilisearch
|
||||
|
||||
# This directory should hold all the data related to meilisearch so we're going
|
||||
# to move our PWD in there.
|
||||
# We don't want to put the meilisearch binary
|
||||
WORKDIR /meili_data
|
||||
|
||||
|
||||
EXPOSE 7700/tcp
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD /bin/meilisearch
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Clément Renault
|
||||
Copyright (c) 2019-2022 Meili SAS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
229
README.md
229
README.md
@@ -1,60 +1,205 @@
|
||||
# MeiliDB
|
||||
<p align="center">
|
||||
<img src="assets/logo.svg" alt="Meilisearch" width="200" height="200" />
|
||||
</p>
|
||||
|
||||
[](https://travis-ci.org/Kerollmops/MeiliDB)
|
||||
[](https://deps.rs/repo/github/Kerollmops/MeiliDB)
|
||||
[](https://github.com/Kerollmops/MeiliDB)
|
||||
[](
|
||||
https://www.rust-lang.org)
|
||||
<h1 align="center">Meilisearch</h1>
|
||||
|
||||
A _full-text search database_ using a key-value store internally.
|
||||
<h4 align="center">
|
||||
<a href="https://www.meilisearch.com">Website</a> |
|
||||
<a href="https://roadmap.meilisearch.com/tabs/1-under-consideration">Roadmap</a> |
|
||||
<a href="https://blog.meilisearch.com">Blog</a> |
|
||||
<a href="https://fr.linkedin.com/company/meilisearch">LinkedIn</a> |
|
||||
<a href="https://twitter.com/meilisearch">Twitter</a> |
|
||||
<a href="https://docs.meilisearch.com">Documentation</a> |
|
||||
<a href="https://docs.meilisearch.com/faq/">FAQ</a>
|
||||
</h4>
|
||||
|
||||
It uses [RocksDB](https://github.com/facebook/rocksdb) as the internal key-value store. The key-value store allows us to handle updates and queries with small memory and CPU overheads.
|
||||
<p align="center">
|
||||
<a href="https://github.com/meilisearch/meilisearch/actions"><img src="https://github.com/meilisearch/meilisearch/workflows/Cargo%20test/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://deps.rs/repo/github/meilisearch/meilisearch"><img src="https://deps.rs/repo/github/meilisearch/meilisearch/status.svg" alt="Dependency status"></a>
|
||||
<a href="https://github.com/meilisearch/meilisearch/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-informational" alt="License"></a>
|
||||
<a href="https://slack.meilisearch.com"><img src="https://img.shields.io/badge/slack-meilisearch-blue.svg?logo=slack" alt="Slack"></a>
|
||||
<a href="https://github.com/meilisearch/meilisearch/discussions" alt="Discussions"><img src="https://img.shields.io/badge/github-discussions-red" /></a>
|
||||
<a href="https://app.bors.tech/repositories/26457"><img src="https://bors.tech/images/badge_small.svg" alt="Bors enabled"></a>
|
||||
</p>
|
||||
|
||||
You can [read the deep dive](deep-dive.md) if you want more information on the engine, it describes the whole process of generating updates and handling queries.
|
||||
<p align="center">⚡ Lightning Fast, Ultra Relevant, and Typo-Tolerant Search Engine 🔍</p>
|
||||
|
||||
We will be proud if you submit issues and pull requests. You can help to grow this project and start contributing by checking [issues tagged "good-first-issue"](https://github.com/Kerollmops/MeiliDB/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). It is a good start!
|
||||
**Meilisearch** is a powerful, fast, open-source, easy to use and deploy search engine. Both searching and indexing are highly customizable. Features such as typo-tolerance, filters, and synonyms are provided out-of-the-box.
|
||||
For more information about features go to [our documentation](https://docs.meilisearch.com/).
|
||||
|
||||
The project is only a library yet. It means that there is no binary provided yet. To get started, you can check the examples wich are made to work with the data located in the `misc/` folder.
|
||||
<p align="center">
|
||||
<img src="assets/trumen-fast.gif" alt="Web interface gif" />
|
||||
</p>
|
||||
|
||||
MeiliDB will be a binary in a near future so you will be able to use it as a database out-of-the-box. We should be able to query it using a [to-be-defined](https://github.com/Kerollmops/MeiliDB/issues/38) protocol. This is our current goal, [see the milestones](https://github.com/Kerollmops/MeiliDB/milestones). In the end, the binary will be a bunch of network protocols and wrappers around the library - which will also be published on [crates.io](https://crates.io). Both the binary and the library will follow the same update cycle.
|
||||
## ✨ Features
|
||||
* Search-as-you-type experience (answers < 50 milliseconds)
|
||||
* Full-text search
|
||||
* Typo tolerant (understands typos and misspelling)
|
||||
* Faceted search and filters
|
||||
* Supports hanzi (Chinese characters)
|
||||
* Supports synonyms
|
||||
* Easy to install, deploy, and maintain
|
||||
* Whole documents are returned
|
||||
* Highly customizable
|
||||
* RESTful API
|
||||
|
||||
## Getting started
|
||||
|
||||
### Deploy the Server
|
||||
|
||||
## Performances
|
||||
|
||||
With a database composed of _100 353_ documents with _352_ attributes each and _90_ of them indexed.
|
||||
So nearly _9 million_ fields indexed for _35 million_ stored we can handle more than _1.2k req/sec_ on an Intel i7-7700 (8) @ 4.2GHz.
|
||||
|
||||
Requests are made using [wrk](https://github.com/wg/wrk) and scripted to generate real users queries.
|
||||
|
||||
```
|
||||
Running 10s test @ http://localhost:2230
|
||||
2 threads and 12 connections
|
||||
Thread Stats Avg Stdev Max +/- Stdev
|
||||
Latency 18.86ms 49.39ms 614.89ms 95.23%
|
||||
Req/Sec 620.41 59.53 790.00 65.00%
|
||||
12359 requests in 10.00s, 3.26MB read
|
||||
Requests/sec: 1235.54
|
||||
Transfer/sec: 334.22KB
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
The default Rust allocator has recently been [changed to use the system allocator](https://github.com/rust-lang/rust/pull/51241/).
|
||||
We have seen much better performances when [using jemalloc as the global allocator](https://github.com/alexcrichton/jemallocator#documentation).
|
||||
|
||||
## Usage and examples
|
||||
|
||||
MeiliDB runs with an index like most search engines.
|
||||
So to test the library you can create one by indexing a simple csv file.
|
||||
#### Homebrew (Mac OS)
|
||||
|
||||
```bash
|
||||
cargo run --release --example create-database -- test.mdb misc/kaggle.csv --schema schema-example.toml
|
||||
brew update && brew install meilisearch
|
||||
meilisearch
|
||||
```
|
||||
|
||||
Once the command is executed, the index should be in the `test.mdb` folder. You are now able to run the `query-database` example and play with MeiliDB.
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
cargo run --release --example query-database -- test.mdb -n 10 id title
|
||||
docker run -p 7700:7700 -v "$(pwd)/meili_data:/meili_data" getmeili/meilisearch
|
||||
```
|
||||
|
||||
#### Announcing a cloud-hosted Meilisearch
|
||||
|
||||
Join the closed beta by filling out this [form](https://meilisearch.typeform.com/to/VI2cI2rv).
|
||||
|
||||
#### Try Meilisearch in our Sandbox
|
||||
|
||||
Create a Meilisearch instance in [Meilisearch Sandbox](https://sandbox.meilisearch.com/). This instance is free, and will be active for 48 hours.
|
||||
|
||||
#### Run on Digital Ocean
|
||||
|
||||
[](https://marketplace.digitalocean.com/apps/meilisearch?action=deploy&refcode=7c67bd97e101)
|
||||
|
||||
#### Deploy on Platform.sh
|
||||
|
||||
<a href="https://console.platform.sh/projects/create-project?template=https://raw.githubusercontent.com/platformsh/template-builder/master/templates/meilisearch/.platform.template.yaml&utm_content=meilisearch&utm_source=github&utm_medium=button&utm_campaign=deploy_on_platform">
|
||||
<img src="https://platform.sh/images/deploy/lg-blue.svg" alt="Deploy on Platform.sh" width="180px" />
|
||||
</a>
|
||||
|
||||
#### APT (Debian & Ubuntu)
|
||||
|
||||
```bash
|
||||
echo "deb [trusted=yes] https://apt.fury.io/meilisearch/ /" > /etc/apt/sources.list.d/fury.list
|
||||
apt update && apt install meilisearch-http
|
||||
meilisearch
|
||||
```
|
||||
|
||||
#### Download the binary (Linux & Mac OS)
|
||||
|
||||
```bash
|
||||
curl -L https://install.meilisearch.com | sh
|
||||
./meilisearch
|
||||
```
|
||||
|
||||
#### Compile and run it from sources
|
||||
|
||||
If you have the latest stable Rust toolchain installed on your local system, clone the repository and change it to your working directory.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/meilisearch/meilisearch.git
|
||||
cd meilisearch
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
### Create an Index and Upload Some Documents
|
||||
|
||||
Let's create an index! If you need a sample dataset, use [this movie database](https://www.notion.so/meilisearch/A-movies-dataset-to-test-Meili-1cbf7c9cfa4247249c40edfa22d7ca87#b5ae399b81834705ba5420ac70358a65). You can also find it in the `datasets/` directory.
|
||||
|
||||
```bash
|
||||
curl -L https://docs.meilisearch.com/movies.json -o movies.json
|
||||
```
|
||||
|
||||
Now, you're ready to index some data.
|
||||
|
||||
```bash
|
||||
curl -i -X POST 'http://127.0.0.1:7700/indexes/movies/documents' \
|
||||
--header 'content-type: application/json' \
|
||||
--data-binary @movies.json
|
||||
```
|
||||
|
||||
### Search for Documents
|
||||
|
||||
#### In command line
|
||||
|
||||
The search engine is now aware of your documents and can serve those via an HTTP server.
|
||||
|
||||
The [`jq` command-line tool](https://stedolan.github.io/jq/) can greatly help you read the server responses.
|
||||
|
||||
```bash
|
||||
curl 'http://127.0.0.1:7700/indexes/movies/search?q=botman+robin&limit=2' | jq
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"id": "415",
|
||||
"title": "Batman & Robin",
|
||||
"poster": "https://image.tmdb.org/t/p/w1280/79AYCcxw3kSKbhGpx1LiqaCAbwo.jpg",
|
||||
"overview": "Along with crime-fighting partner Robin and new recruit Batgirl, Batman battles the dual threat of frosty genius Mr. Freeze and homicidal horticulturalist Poison Ivy. Freeze plans to put Gotham City on ice, while Ivy tries to drive a wedge between the dynamic duo.",
|
||||
"release_date": 866768400
|
||||
},
|
||||
{
|
||||
"id": "411736",
|
||||
"title": "Batman: Return of the Caped Crusaders",
|
||||
"poster": "https://image.tmdb.org/t/p/w1280/GW3IyMW5Xgl0cgCN8wu96IlNpD.jpg",
|
||||
"overview": "Adam West and Burt Ward returns to their iconic roles of Batman and Robin. Featuring the voices of Adam West, Burt Ward, and Julie Newmar, the film sees the superheroes going up against classic villains like The Joker, The Riddler, The Penguin and Catwoman, both in Gotham City… and in space.",
|
||||
"release_date": 1475888400
|
||||
}
|
||||
],
|
||||
"nbHits": 8,
|
||||
"exhaustiveNbHits": false,
|
||||
"query": "botman robin",
|
||||
"limit": 2,
|
||||
"offset": 0,
|
||||
"processingTimeMs": 2
|
||||
}
|
||||
```
|
||||
|
||||
#### Use the Web Interface
|
||||
|
||||
We also deliver an **out-of-the-box [web interface](https://github.com/meilisearch/mini-dashboard)** in which you can test Meilisearch interactively.
|
||||
|
||||
You can access the web interface in your web browser at the root of the server. The default URL is [http://127.0.0.1:7700](http://127.0.0.1:7700). All you need to do is open your web browser and enter Meilisearch’s address to visit it. This will lead you to a web page with a search bar that will allow you to search in the selected index.
|
||||
|
||||
| [See the gif above](#demo)
|
||||
|
||||
## Documentation
|
||||
|
||||
Now that your Meilisearch server is up and running, you can learn more about how to tune your search engine in [the documentation](https://docs.meilisearch.com).
|
||||
|
||||
## Contributing
|
||||
|
||||
Hey! We're glad you're thinking about contributing to Meilisearch! Feel free to pick an [issue labeled as `good first issue`](https://github.com/meilisearch/meilisearch/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22), and to ask any question you need. Some points might not be clear and we are available to help you!
|
||||
|
||||
Also, we recommend following the [CONTRIBUTING](./CONTRIBUTING.md) to create your PR.
|
||||
|
||||
## Core engine and tokenizer
|
||||
|
||||
The code in this repository is only concerned with managing multiple indexes, handling the update store, and exposing an HTTP API.
|
||||
|
||||
Search and indexation are the domain of our core engine, [`milli`](https://github.com/meilisearch/milli), while tokenization is handled by [our `tokenizer` library](https://github.com/meilisearch/tokenizer/).
|
||||
## Telemetry
|
||||
|
||||
Meilisearch collects anonymous data regarding general usage.
|
||||
This helps us better understand developers' usage of Meilisearch features.
|
||||
|
||||
To find out more on what information we're retrieving, please see our documentation on [Telemetry](https://docs.meilisearch.com/learn/what_is_meilisearch/telemetry.html).
|
||||
|
||||
This program is optional, you can disable these analytics by using the `MEILI_NO_ANALYTICS` env variable.
|
||||
|
||||
## Feature request
|
||||
|
||||
The feature requests are not managed in this repository. Please visit our [dedicated repository](https://github.com/meilisearch/product) to see our work about the Meilisearch product.
|
||||
|
||||
If you have a feature request or any feedback about an existing feature, please open [a discussion](https://github.com/meilisearch/product/discussions).
|
||||
Also, feel free to participate in the current discussions, we are looking forward to reading your comments.
|
||||
|
||||
## 💌 Contact
|
||||
|
||||
Please visit [this page](https://docs.meilisearch.com/learn/what_is_meilisearch/contact.html#contact-us).
|
||||
|
||||
Meilisearch is developed by [Meili](https://www.meilisearch.com), a young company. To know more about us, you can [read our blog](https://blog.meilisearch.com). Any suggestion or feedback is highly appreciated. Thank you for your support!
|
||||
|
||||
33
SECURITY.md
Normal file
33
SECURITY.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Security
|
||||
|
||||
Meilisearch takes the security of our software products and services seriously.
|
||||
|
||||
If you believe you have found a security vulnerability in any Meilisearch-owned repository, please report it to us as described below.
|
||||
|
||||
## Supported versions
|
||||
|
||||
As long as we are pre-v1.0, only the latest version of Meilisearch will be supported with security updates.
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
⚠️ Please do not report security vulnerabilities through public GitHub issues. ⚠️
|
||||
|
||||
Instead, please kindly email us at security@meilisearch.com
|
||||
|
||||
Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
|
||||
|
||||
- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
|
||||
- Full paths of source file(s) related to the manifestation of the issue
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue, including how an attacker might exploit the issue
|
||||
|
||||
This information will help us triage your report more quickly.
|
||||
|
||||
You will receive a response from us within 72 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity.
|
||||
|
||||
## Preferred languages
|
||||
|
||||
We prefer all communications to be in English.
|
||||
BIN
assets/crates-io-demo.gif
Normal file
BIN
assets/crates-io-demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 MiB |
23
assets/do-btn-blue.svg
Normal file
23
assets/do-btn-blue.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="200px" height="42px" viewBox="0 0 200 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>do-btn-blue</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Partner-welcome-kit-Copy-3" transform="translate(-651.000000, -762.000000)">
|
||||
<g id="do-btn-blue" transform="translate(651.000000, 763.000000)">
|
||||
<rect id="Rectangle-Copy" fill="#0069FF" x="0" y="0" width="200" height="40" rx="6"></rect>
|
||||
<path d="M45,0 L45,40" id="Line-2" stroke="#FFFFFF" stroke-linecap="square"></path>
|
||||
<g id="DO_Logo_horizontal_blue-Copy" transform="translate(13.000000, 11.000000)" fill="#FFFFFF">
|
||||
<path d="M10.0098493,20 L10.0098493,16.1262429 C14.12457,16.1262429 17.2897398,12.0548452 15.7269372,7.74627862 C15.1334679,6.14538921 13.8674,4.86072487 12.2650328,4.28756693 C7.952489,2.72620566 3.87733294,5.88845634 3.87733294,9.99938223 C3.87733294,9.99938223 3.87733294,9.99938223 3.87733294,9.99938223 L0,9.99938223 C0,3.45747613 6.3303395,-1.64165309 13.1948014,0.492866119 C16.2017127,1.42177726 18.57559,3.81322933 19.5053586,6.79760341 C21.6418482,13.6754986 16.5577943,20 10.0098493,20 Z" id="XMLID_49_"></path>
|
||||
<polygon id="XMLID_47_" points="9.56521739 15.6521739 6.08695652 15.6521739 6.08695652 12.173913 6.08695652 12.173913 9.56521739 12.173913 9.56521739 12.173913"></polygon>
|
||||
<polygon id="XMLID_46_" points="6.08695652 19.1304348 3.47826087 19.1304348 3.47826087 19.1304348 3.47826087 16.5217391 6.08695652 16.5217391"></polygon>
|
||||
<polygon id="XMLID_45_" points="3.47826087 16.5217391 0.869565217 16.5217391 0.869565217 16.5217391 0.869565217 13.9130435 0.869565217 13.9130435 3.47826087 13.9130435 3.47826087 13.9130435"></polygon>
|
||||
</g>
|
||||
<text id="Create-a-Droplet-Copy" font-family="Sailec-Medium, Sailec" font-size="16" font-weight="400" fill="#FFFFFF">
|
||||
<tspan x="58" y="26">Create a Droplet</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
19
assets/logo.svg
Normal file
19
assets/logo.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 237L55.426 96.7678C63.2367 77.0063 82.499 64 103.955 64H137.371L81.9447 204.232C74.1341 223.993 54.8717 237 33.4156 237H0Z" fill="url(#paint0_linear_1_898)"/>
|
||||
<path d="M81.3123 237L136.738 96.7682C144.549 77.0067 163.811 64.0004 185.267 64.0004H218.683L163.257 204.232C155.446 223.994 136.184 237 114.728 237H81.3123Z" fill="url(#paint1_linear_1_898)"/>
|
||||
<path d="M162.629 237L218.055 96.7682C225.866 77.0067 245.128 64.0004 266.584 64.0004H300L244.574 204.232C236.763 223.994 217.501 237 196.045 237H162.629Z" fill="url(#paint2_linear_1_898)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1_898" x1="300.001" y1="50.7858" x2="1.63474" y2="221.244" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5CAA"/>
|
||||
<stop offset="1" stop-color="#FF4E62"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1_898" x1="300.001" y1="50.7858" x2="1.63474" y2="221.244" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5CAA"/>
|
||||
<stop offset="1" stop-color="#FF4E62"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_1_898" x1="300.001" y1="50.7858" x2="1.63474" y2="221.244" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5CAA"/>
|
||||
<stop offset="1" stop-color="#FF4E62"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/trumen-fast.gif
Normal file
BIN
assets/trumen-fast.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
11
bors.toml
Normal file
11
bors.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
status = [
|
||||
'Tests on ubuntu-18.04',
|
||||
'Tests on macos-latest',
|
||||
'Tests on windows-latest',
|
||||
'Run Clippy',
|
||||
'Run Rustfmt',
|
||||
'Run tests in debug',
|
||||
]
|
||||
pr_status = ['Milestone Check']
|
||||
# 3 hours timeout
|
||||
timeout-sec = 10800
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd "$(dirname "$0")"/..
|
||||
set -ex
|
||||
|
||||
export RUSTFLAGS="-D warnings"
|
||||
|
||||
cargo check --no-default-features
|
||||
cargo check --bins --examples --tests
|
||||
cargo test
|
||||
|
||||
if [[ "$TRAVIS_RUST_VERSION" == "nightly" ]]; then
|
||||
cargo check --no-default-features --features nightly
|
||||
cargo test --features nightly
|
||||
fi
|
||||
140
deep-dive.md
140
deep-dive.md
@@ -1,140 +0,0 @@
|
||||
# A deep dive in MeiliDB
|
||||
|
||||
On the 9 of december 2018.
|
||||
|
||||
MeiliDB is a full text search engine based on a final state transducer named [fst](https://github.com/BurntSushi/fst) and a key-value store named [RocksDB](https://github.com/facebook/rocksdb). The goal of a search engine is to store data and to respond to queries as accurate and fast as possible. To achieve this it must save the data as an [inverted index](https://en.wikipedia.org/wiki/Inverted_index).
|
||||
|
||||
|
||||
|
||||
<!-- MarkdownTOC autolink="true" -->
|
||||
|
||||
- [Where is the data stored?](#where-is-the-data-stored)
|
||||
- [What does the key-value store contains?](#what-does-the-key-value-store-contains)
|
||||
- [The blob type](#the-blob-type)
|
||||
- [A final state transducer](#a-final-state-transducer)
|
||||
- [Document indexes](#document-indexes)
|
||||
- [Document ids](#document-ids)
|
||||
- [The schema](#the-schema)
|
||||
- [Document attributes](#document-attributes)
|
||||
- [How is an update handled?](#how-is-an-update-handled)
|
||||
- [The merge operation is CPU consuming](#the-merge-operation-is-cpu-consuming)
|
||||
- [How is a request processed?](#how-is-a-request-processed)
|
||||
- [Query lexemes](#query-lexemes)
|
||||
- [Automatons and query index](#automatons-and-query-index)
|
||||
- [Sort by criteria](#sort-by-criteria)
|
||||
- [Retrieve original documents](#retrieve-original-documents)
|
||||
|
||||
<!-- /MarkdownTOC -->
|
||||
|
||||
## Where is the data stored?
|
||||
|
||||
MeiliDB is entirely backed by a key-value store like any good database (i.e. Postgres, MySQL). This brings a great flexibility in the way documents can be stored and updates handled along time.
|
||||
|
||||
[RocksDB brings some](https://rocksdb.org/blog/2015/02/27/write-batch-with-index.html) of the [A.C.I.D. properties](https://en.wikipedia.org/wiki/ACID_(computer_science)) to help us be sure the saved data is consistent, for example we use SST files and the key-value store ability to load them in one time to manage updates.
|
||||
|
||||
Note that the SST file have the same restriction as the fst, it needs its keys to be added in order at creation.
|
||||
|
||||
|
||||
|
||||
## What does the key-value store contains?
|
||||
|
||||
It contain the blob, the schema and the documents stored attributes.
|
||||
|
||||
### The blob type
|
||||
|
||||
[The Blob type](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/blob/mod.rs#L16-L19) is a data structure that indicate if an update is a positive or a negative one. In the case where the update is considered positive, the blob will contain [an fst map and the document indexes](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/blob/positive/blob.rs#L15-L18) associated. In the other case it will only contain [all the document ids](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/blob/negative/blob.rs#L12-L14) that must be considered removed.
|
||||
|
||||
The Blob type [is stored under the "*data-index*" entry](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/update/positive/update.rs#L497-L499) and marked as [a merge operation](https://github.com/facebook/rocksdb/wiki/Merge-Operator-Implementation) in the key-value store.
|
||||
|
||||
#### A final state transducer
|
||||
|
||||
_...also abbreviated fst_
|
||||
|
||||
This is the first entry point of the engine, you can read more about how it work with the beautiful blog post of @BurntSushi, [Index 1,600,000,000 Keys with Automata and Rust](https://blog.burntsushi.net/transducers/).
|
||||
|
||||
To make it short it is a powerful way to store all the words that are present in the indexed documents. You construct it by giving it all the words you want to index associated with a value that, for the moment, can only be an `u64`. When you want to search in it you can provide any automaton you want, in MeiliDB [a custom levenshtein automaton](https://github.com/tantivy-search/levenshtein-automata/) is used.
|
||||
|
||||
Note that the number under each word is auto-incremental, each new word have a new number that is greater than the prevous one.
|
||||
|
||||
Another powerful feature of `fst` is that it can nearly avoid using RAM and be streamed to disk for example, the problem is that the keys must be always added in lexicographic order, so you must sort them before, for the moment MeiliDB uses a [BTreeMap](https://github.com/Kerollmops/raptor-rs/blob/8abdb0a228e2808fe1814a6a0641a4b72d158579/src/metadata/doc_indexes.rs#L107-L112).
|
||||
|
||||
#### Document indexes
|
||||
|
||||
As it has been specified, the `fst` can only store a number corresponding to a word, an `u64`, but the goal of the search engine is to retrieve a match in a document when a query is made. You want it to return some sort of position in an attribute in a document, an information about where the given word match.
|
||||
|
||||
To make it possible, a custom data structure has been developed, the document indexes is composed of two arrays, the ranges array and all the docindexes corresponding to a given range, each range identify the word number. The [DocIndexes](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/data/doc_indexes.rs#L23) type is designed to be streamed when constructed, consumming a minimum amount of ram like the fst. Another advantage is that the slices are accessible in `O(1)` when you know the word associated number.
|
||||
|
||||
#### Document ids
|
||||
|
||||
This is a simple ordered list of all documents ids which must be considered deleted. It is used with [the sdset library](https://docs.rs/sdset/0.3.0/sdset/duo/struct.DifferenceByKey.html), the docindexes and the `DifferenceByKey` operation builder when merging blobs.
|
||||
|
||||
When a blob represent a negative update it only contains this simple slice of deleted documents ids.
|
||||
|
||||
### The schema
|
||||
|
||||
The schema is a data struture that represents which documents attributes should be stored and which should be indexed. It is stored under the "_data-schema_" entry and given to MeiliDB only at the creation.
|
||||
|
||||
Each document attribute is associated to a unique 32 bit number named `SchemaAttr`.
|
||||
|
||||
In the future this schema type could be given along with updates and probably be different from the original, the database could be able to handled this document structure and reindex it.
|
||||
|
||||
### Document attributes
|
||||
|
||||
When the engine handle a query the result that the requester want is a document, not only the [match](https://github.com/Kerollmops/MeiliDB/blob/fc2cdf92596fc002ce278e3aa8718640ac44724d/src/lib.rs#L51-L79) associated to it, fields of the original document must be returned too.
|
||||
|
||||
So MeiliDB again uses the power of the underlying key-value store and save the documents attributes marked as _STORE_. The key is prefixed by "_doc_" followed by the 64 bit document id in bytes and the schema attribute number in bytes corresponding to the document attribute stored.
|
||||
|
||||
When a document field is saved in the key-value store its value is binary encoded using the [bincode](https://docs.rs/bincode/) library, so a document must be serializable using serde.
|
||||
|
||||
|
||||
|
||||
## How is an update handled?
|
||||
|
||||
First of all an update in MeiliDB is nothing more than [a RocksDB SST file](https://github.com/facebook/rocksdb/wiki/Creating-and-Ingesting-SST-files). It contains the blob and all the documents attributes binary encoded like described above. Note that the blob is stored under the "_data-index_" key marked as [a merge operation](https://github.com/facebook/rocksdb/wiki/Merge-Operator-Implementation).
|
||||
|
||||
### The merge operation is CPU consuming
|
||||
|
||||
When [the database ingest an update](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/mod.rs#L108-L145) it gives the SST file to the underlying RocksDB, once it has ingested it there is a "_data-index_" entry available, we can request it but the key-value store will call a function before, a merge operation is performed.
|
||||
|
||||
This merge operation is done on multiple blobs as you have understood and will compute a [PositiveBlob](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/blob/positive/blob.rs#L15), this type contains the fst and document indexes structures allowing us to search for documents. This two data structures can be considered as the inverted index.
|
||||
|
||||
The computation time of this merge is important, RocksDB doesn't keep the previous merged result, it will call our merge operation each time until it decided to do a compaction. So [we must force this compaction earlier](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/mod.rs#L129-L131) when we receive an update to reduce this cost.
|
||||
|
||||
This way when we request the "_data-index_" value it will gives us the previously merged positive blob without any other merge overhead.
|
||||
|
||||
|
||||
|
||||
## How is a request processed?
|
||||
|
||||
Now that we have our "_data-index_" we are able to return results based on a query. In the MeiliDB universe a query is a string.
|
||||
|
||||
### Query lexemes
|
||||
|
||||
The first step to be able to call the underlying structures is to split the query in words, for that we use a [custom tokenizer](https://github.com/Kerollmops/MeiliDB/blob/fc2cdf92596fc002ce278e3aa8718640ac44724d/src/tokenizer/mod.rs) that is not finished for the moment, [there is an open issue](https://github.com/Kerollmops/MeiliDB/issues/3). Note that a tokenizer is specialized for a human language, this is the hard part.
|
||||
|
||||
### Automatons and query index
|
||||
|
||||
So to query the fst we need an automaton, in MeiliDB we use a [levenshtein automaton](https://en.wikipedia.org/wiki/Levenshtein_automaton), this automaton is constructed using a string and a maximum distance. According to the [Algolia's blog post](https://blog.algolia.com/inside-the-algolia-engine-part-3-query-processing/#algolia%e2%80%99s-way-of-searching-for-alternatives) we [created the DFAs](https://github.com/Kerollmops/MeiliDB/blob/fc2cdf92596fc002ce278e3aa8718640ac44724d/src/automaton.rs#L62-L75) with different settings.
|
||||
|
||||
Thanks to the power of the fst library [it is possible to union multiple automatons](https://docs.rs/fst/0.3.2/fst/map/struct.OpBuilder.html#method.union) on the same fst map, it will allow us to know which [automaton returns a word according to its index](https://github.com/Kerollmops/MeiliDB/blob/fc2cdf92596fc002ce278e3aa8718640ac44724d/src/metadata/ops.rs#L111). The `Stream` is able to return all the numbers associated to the words. We use these numbers to find the whole list of `DocIndexes` associated and do the union set operation.
|
||||
|
||||
With all these informations it is possible [to reconstruct a list of all the DocIndexes associated](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/rank/query_builder.rs#L62-L99) with the words queried.
|
||||
|
||||
### Sort by criteria
|
||||
|
||||
Now that we are able to get a big list of [DocIndexes](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/lib.rs#L21-L36) it is not enough to sort them by criteria, we need more informations like the levenshtein distance or the fact that a query word match exactly the word stored in the fst. So [we stuff it a little bit](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/rank/query_builder.rs#L86-L93), and aggregate all these [Matches](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/lib.rs#L47-L74) for each document. This way it will be easy to sort a simple vector of document using a bunch of functions.
|
||||
|
||||
With this big list of documents and associated matches [we are able to sort only the part of the slice that we want](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/rank/query_builder.rs#L108-L119) using bucket sorting. [Each criterion](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/rank/criterion/mod.rs#L75-L87) is evaluated on each subslice without copy, thanks to [GroupByMut](https://github.com/Kerollmops/group-by/blob/cab857bae01463dbd0edb99b0e0d7f3624e6c6f5/src/lib.rs#L180-L185) which, I hope [will soon be merged](https://github.com/rust-lang/rfcs/pull/2477).
|
||||
|
||||
Note that it is possible to customize the criteria used by using the `QueryBuilder::with_criteria` constructor, this way you can implement some custom ranking based on the document attributes using the appropriate structure and the `retrieve_document` method.
|
||||
|
||||
### Retrieve original documents
|
||||
|
||||
The [DatabaseView](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/database_view.rs#L18-L24) structure that you must have created to be able to query the database have [two functions](https://github.com/Kerollmops/MeiliDB/blob/550dc1e99224e386516877450320f694947332d4/src/database/database_view.rs#L60-L76) that allows you to retrieve a full (or not) document according to the schema you specified at creation time (i.e. the _STORED_ attributes).
|
||||
|
||||
As you can see, these functions force the created type `T` to implement [the serde Deserialize trait](https://docs.rs/serde/1.0.81/serde/trait.Deserialize.html), MeiliDB will use the `bincode::deserialise` function for each attribute to construct your type and return it to you.
|
||||
|
||||
|
||||
|
||||
At this point, MeiliDB work is over 🎉
|
||||
|
||||
240
download-latest.sh
Normal file
240
download-latest.sh
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/bin/sh
|
||||
|
||||
# COLORS
|
||||
RED='\033[31m'
|
||||
GREEN='\033[32m'
|
||||
DEFAULT='\033[0m'
|
||||
|
||||
# GLOBALS
|
||||
GREP_SEMVER_REGEXP='v\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)$' # i.e. v[number].[number].[number]
|
||||
|
||||
# FUNCTIONS
|
||||
|
||||
# semverParseInto and semverLT from https://github.com/cloudflare/semver_bash/blob/master/semver.sh
|
||||
|
||||
# usage: semverParseInto version major minor patch special
|
||||
# version: the string version
|
||||
# major, minor, patch, special: will be assigned by the function
|
||||
semverParseInto() {
|
||||
local RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z-]*\)'
|
||||
#MAJOR
|
||||
eval $2=`echo $1 | sed -e "s#$RE#\1#"`
|
||||
#MINOR
|
||||
eval $3=`echo $1 | sed -e "s#$RE#\2#"`
|
||||
#PATCH
|
||||
eval $4=`echo $1 | sed -e "s#$RE#\3#"`
|
||||
#SPECIAL
|
||||
eval $5=`echo $1 | sed -e "s#$RE#\4#"`
|
||||
}
|
||||
|
||||
# usage: semverLT version1 version2
|
||||
semverLT() {
|
||||
local MAJOR_A=0
|
||||
local MINOR_A=0
|
||||
local PATCH_A=0
|
||||
local SPECIAL_A=0
|
||||
|
||||
local MAJOR_B=0
|
||||
local MINOR_B=0
|
||||
local PATCH_B=0
|
||||
local SPECIAL_B=0
|
||||
|
||||
semverParseInto $1 MAJOR_A MINOR_A PATCH_A SPECIAL_A
|
||||
semverParseInto $2 MAJOR_B MINOR_B PATCH_B SPECIAL_B
|
||||
|
||||
if [ $MAJOR_A -lt $MAJOR_B ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ $MAJOR_A -le $MAJOR_B ] && [ $MINOR_A -lt $MINOR_B ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ $MAJOR_A -le $MAJOR_B ] && [ $MINOR_A -le $MINOR_B ] && [ $PATCH_A -lt $PATCH_B ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "_$SPECIAL_A" == '_' ] && [ "_$SPECIAL_B" == '_' ] ; then
|
||||
return 1
|
||||
fi
|
||||
if [ "_$SPECIAL_A" == '_' ] && [ "_$SPECIAL_B" != '_' ] ; then
|
||||
return 1
|
||||
fi
|
||||
if [ "_$SPECIAL_A" != '_' ] && [ "_$SPECIAL_B" == '_' ] ; then
|
||||
return 0
|
||||
fi
|
||||
if [ "_$SPECIAL_A" < "_$SPECIAL_B" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get a token from https://github.com/settings/tokens to increase rate limit (from 60 to 5000), make sure the token scope is set to 'public_repo'
|
||||
# Create GITHUB_PAT environment variable once you acquired the token to start using it
|
||||
# Returns the tag of the latest stable release (in terms of semver and not of release date)
|
||||
get_latest() {
|
||||
temp_file='temp_file' # temp_file needed because the grep would start before the download is over
|
||||
|
||||
if [ -z "$GITHUB_PAT" ]; then
|
||||
curl -s 'https://api.github.com/repos/meilisearch/meilisearch/releases' > "$temp_file" || return 1
|
||||
else
|
||||
curl -H "Authorization: token $GITHUB_PAT" -s 'https://api.github.com/repos/meilisearch/meilisearch/releases' > "$temp_file" || return 1
|
||||
fi
|
||||
|
||||
releases=$(cat "$temp_file" | \
|
||||
grep -E '"tag_name":|"draft":|"prerelease":' \
|
||||
| tr -d ',"' | cut -d ':' -f2 | tr -d ' ')
|
||||
# Returns a list of [tag_name draft_boolean prerelease_boolean ...]
|
||||
# Ex: v0.10.1 false false v0.9.1-rc.1 false true v0.9.0 false false...
|
||||
|
||||
i=0
|
||||
latest=''
|
||||
current_tag=''
|
||||
for release_info in $releases; do
|
||||
if [ $i -eq 0 ]; then # Checking tag_name
|
||||
if echo "$release_info" | grep -q "$GREP_SEMVER_REGEXP"; then # If it's not an alpha or beta release
|
||||
current_tag=$release_info
|
||||
else
|
||||
current_tag=''
|
||||
fi
|
||||
i=1
|
||||
elif [ $i -eq 1 ]; then # Checking draft boolean
|
||||
if [ "$release_info" = 'true' ]; then
|
||||
current_tag=''
|
||||
fi
|
||||
i=2
|
||||
elif [ $i -eq 2 ]; then # Checking prerelease boolean
|
||||
if [ "$release_info" = 'true' ]; then
|
||||
current_tag=''
|
||||
fi
|
||||
i=0
|
||||
if [ "$current_tag" != '' ]; then # If the current_tag is valid
|
||||
if [ "$latest" = '' ]; then # If there is no latest yet
|
||||
latest="$current_tag"
|
||||
else
|
||||
semverLT $current_tag $latest # Comparing latest and the current tag
|
||||
if [ $? -eq 1 ]; then
|
||||
latest="$current_tag"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$temp_file"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Gets the OS by setting the $os variable
|
||||
# Returns 0 in case of success, 1 otherwise.
|
||||
get_os() {
|
||||
os_name=$(uname -s)
|
||||
case "$os_name" in
|
||||
'Darwin')
|
||||
os='macos'
|
||||
;;
|
||||
'Linux')
|
||||
os='linux'
|
||||
;;
|
||||
'MINGW'*)
|
||||
os='windows'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# Gets the architecture by setting the $archi variable
|
||||
# Returns 0 in case of success, 1 otherwise.
|
||||
get_archi() {
|
||||
architecture=$(uname -m)
|
||||
case "$architecture" in
|
||||
'x86_64' | 'amd64' )
|
||||
archi='amd64'
|
||||
;;
|
||||
'arm64')
|
||||
if [ $os = 'macos' ]; then # MacOS M1
|
||||
archi='amd64'
|
||||
else
|
||||
archi='aarch64'
|
||||
fi
|
||||
;;
|
||||
'aarch64')
|
||||
archi='aarch64'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
success_usage() {
|
||||
printf "$GREEN%s\n$DEFAULT" "Meilisearch $latest binary successfully downloaded as '$binary_name' file."
|
||||
echo ''
|
||||
echo 'Run it:'
|
||||
echo ' $ ./meilisearch'
|
||||
echo 'Usage:'
|
||||
echo ' $ ./meilisearch --help'
|
||||
}
|
||||
|
||||
not_available_failure_usage() {
|
||||
printf "$RED%s\n$DEFAULT" 'ERROR: Meilisearch binary is not available for your OS distribution or your architecture yet.'
|
||||
echo ''
|
||||
echo 'However, you can easily compile the binary from the source files.'
|
||||
echo 'Follow the steps at the page ("Source" tab): https://docs.meilisearch.com/learn/getting_started/installation.html'
|
||||
}
|
||||
|
||||
fetch_release_failure_usage() {
|
||||
echo ''
|
||||
printf "$RED%s\n$DEFAULT" 'ERROR: Impossible to get the latest stable version of Meilisearch.'
|
||||
echo 'Please let us know about this issue: https://github.com/meilisearch/meilisearch/issues/new/choose'
|
||||
}
|
||||
|
||||
# MAIN
|
||||
|
||||
# Fill $latest variable
|
||||
if ! get_latest; then
|
||||
fetch_release_failure_usage # TO CHANGE
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$latest" = '' ]; then
|
||||
fetch_release_failure_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fill $os variable
|
||||
if ! get_os; then
|
||||
not_available_failure_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fill $archi variable
|
||||
if ! get_archi; then
|
||||
not_available_failure_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading Meilisearch binary $latest for $os, architecture $archi..."
|
||||
case "$os" in
|
||||
'windows')
|
||||
release_file="meilisearch-$os-$archi.exe"
|
||||
binary_name='meilisearch.exe'
|
||||
|
||||
;;
|
||||
*)
|
||||
release_file="meilisearch-$os-$archi"
|
||||
binary_name='meilisearch'
|
||||
|
||||
esac
|
||||
|
||||
# Fetch the Meilisearch binary
|
||||
link="https://github.com/meilisearch/meilisearch/releases/download/$latest/$release_file"
|
||||
curl --fail -OL "$link"
|
||||
if [ $? -ne 0 ]; then
|
||||
fetch_release_failure_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "$release_file" "$binary_name"
|
||||
chmod 744 "$binary_name"
|
||||
success_usage
|
||||
@@ -1,138 +0,0 @@
|
||||
#[global_allocator]
|
||||
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
||||
|
||||
use std::io::{self, BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::error::Error;
|
||||
use std::borrow::Cow;
|
||||
use std::fs::File;
|
||||
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use structopt::StructOpt;
|
||||
|
||||
use meilidb::database::{Database, Schema, UpdateBuilder};
|
||||
use meilidb::tokenizer::DefaultBuilder;
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
pub struct Opt {
|
||||
/// The destination where the database must be created.
|
||||
#[structopt(parse(from_os_str))]
|
||||
pub database_path: PathBuf,
|
||||
|
||||
/// The csv file to index.
|
||||
#[structopt(parse(from_os_str))]
|
||||
pub csv_data_path: PathBuf,
|
||||
|
||||
/// The path to the schema.
|
||||
#[structopt(long = "schema", parse(from_os_str))]
|
||||
pub schema_path: PathBuf,
|
||||
|
||||
/// The path to the list of stop words (one by line).
|
||||
#[structopt(long = "stop-words", parse(from_os_str))]
|
||||
pub stop_words_path: Option<PathBuf>,
|
||||
|
||||
#[structopt(long = "update-group-size")]
|
||||
pub update_group_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Document<'a> (
|
||||
#[serde(borrow)]
|
||||
HashMap<Cow<'a, str>, Cow<'a, str>>
|
||||
);
|
||||
|
||||
fn index(
|
||||
schema: Schema,
|
||||
database_path: &Path,
|
||||
csv_data_path: &Path,
|
||||
update_group_size: Option<usize>,
|
||||
stop_words: &HashSet<String>,
|
||||
) -> Result<Database, Box<Error>>
|
||||
{
|
||||
let database = Database::create(database_path, &schema)?;
|
||||
|
||||
let mut rdr = csv::Reader::from_path(csv_data_path)?;
|
||||
let mut raw_record = csv::StringRecord::new();
|
||||
let headers = rdr.headers()?.clone();
|
||||
|
||||
let mut i = 0;
|
||||
let mut end_of_file = false;
|
||||
|
||||
while !end_of_file {
|
||||
let tokenizer_builder = DefaultBuilder::new();
|
||||
let update_path = tempfile::NamedTempFile::new()?;
|
||||
let mut update = UpdateBuilder::new(update_path.path().to_path_buf(), schema.clone());
|
||||
|
||||
loop {
|
||||
end_of_file = !rdr.read_record(&mut raw_record)?;
|
||||
if end_of_file { break }
|
||||
|
||||
let document: Document = match raw_record.deserialize(Some(&headers)) {
|
||||
Ok(document) => document,
|
||||
Err(e) => {
|
||||
eprintln!("{:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
update.update_document(&document, &tokenizer_builder, &stop_words)?;
|
||||
|
||||
print!("\rindexing document {}", i);
|
||||
i += 1;
|
||||
|
||||
if let Some(group_size) = update_group_size {
|
||||
if i % group_size == 0 { break }
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
println!("building update...");
|
||||
let update = update.build()?;
|
||||
println!("ingesting update...");
|
||||
database.ingest_update_file(update)?;
|
||||
}
|
||||
|
||||
Ok(database)
|
||||
}
|
||||
|
||||
fn retrieve_stop_words(path: &Path) -> io::Result<HashSet<String>> {
|
||||
let f = File::open(path)?;
|
||||
let reader = BufReader::new(f);
|
||||
let mut words = HashSet::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let word = line.trim().to_string();
|
||||
words.insert(word);
|
||||
}
|
||||
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<Error>> {
|
||||
let _ = env_logger::init();
|
||||
let opt = Opt::from_args();
|
||||
|
||||
let schema = {
|
||||
let file = File::open(&opt.schema_path)?;
|
||||
Schema::from_toml(file)?
|
||||
};
|
||||
|
||||
let stop_words = match opt.stop_words_path {
|
||||
Some(ref path) => retrieve_stop_words(path)?,
|
||||
None => HashSet::new(),
|
||||
};
|
||||
|
||||
let (elapsed, result) = elapsed::measure_time(|| {
|
||||
index(schema, &opt.database_path, &opt.csv_data_path, opt.update_group_size, &stop_words)
|
||||
});
|
||||
|
||||
if let Err(e) = result {
|
||||
return Err(e.into())
|
||||
}
|
||||
|
||||
println!("database created in {} at: {:?}", elapsed, opt.database_path);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
#[global_allocator]
|
||||
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
||||
|
||||
use log::{error, info};
|
||||
use std::error::Error;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, BufRead, BufReader};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{PathBuf, Path};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use chashmap::CHashMap;
|
||||
use chashmap::ReadGuard;
|
||||
use elapsed::measure_time;
|
||||
use meilidb::database::Database;
|
||||
use meilidb::database::UpdateBuilder;
|
||||
use meilidb::database::schema::Schema;
|
||||
use meilidb::database::schema::SchemaBuilder;
|
||||
use meilidb::tokenizer::DefaultBuilder;
|
||||
use serde_derive::Deserialize;
|
||||
use serde_derive::Serialize;
|
||||
use structopt::StructOpt;
|
||||
use warp::{Rejection, Filter};
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
pub struct Opt {
|
||||
/// The destination where the database must be created.
|
||||
#[structopt(parse(from_os_str))]
|
||||
pub database_path: PathBuf,
|
||||
|
||||
/// The address and port to bind the server to.
|
||||
#[structopt(short = "l", default_value = "127.0.0.1:8080")]
|
||||
pub listen_addr: SocketAddr,
|
||||
|
||||
/// The path to the list of stop words (one by line).
|
||||
#[structopt(long = "stop-words", parse(from_os_str))]
|
||||
pub stop_words: PathBuf,
|
||||
}
|
||||
|
||||
//
|
||||
// ERRORS FOR THE MULTIDATABASE
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DatabaseError {
|
||||
AlreadyExist,
|
||||
NotExist,
|
||||
NotFound(String),
|
||||
Unknown(Box<Error>),
|
||||
}
|
||||
|
||||
impl fmt::Display for DatabaseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
DatabaseError::AlreadyExist => write!(f, "File already exist"),
|
||||
DatabaseError::NotExist => write!(f, "File not exist"),
|
||||
DatabaseError::NotFound(ref name) => write!(f, "Database {} not found", name),
|
||||
DatabaseError::Unknown(e) => write!(f, "{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DatabaseError {}
|
||||
|
||||
impl From<Box<Error>> for DatabaseError {
|
||||
fn from(e: Box<Error>) -> DatabaseError {
|
||||
DatabaseError::Unknown(e)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MULTIDATABASE DEFINITION
|
||||
//
|
||||
|
||||
pub struct MultiDatabase {
|
||||
databases: CHashMap<String, Database>,
|
||||
db_path: PathBuf,
|
||||
stop_words: HashSet<String>,
|
||||
}
|
||||
|
||||
impl MultiDatabase {
|
||||
|
||||
pub fn new(path: PathBuf, stop_words: HashSet<String>) -> MultiDatabase {
|
||||
MultiDatabase {
|
||||
databases: CHashMap::new(),
|
||||
db_path: path,
|
||||
stop_words: stop_words
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(&self, name: String, schema: Schema) -> Result<(), DatabaseError> {
|
||||
let rdb_name = format!("{}.mdb", name);
|
||||
let database_path = self.db_path.join(rdb_name);
|
||||
|
||||
if database_path.exists() {
|
||||
return Err(DatabaseError::AlreadyExist.into());
|
||||
}
|
||||
|
||||
let index = Database::create(database_path, &schema)?;
|
||||
|
||||
self.databases.insert_new(name, index);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(&self, name: String) -> Result<(), DatabaseError> {
|
||||
let rdb_name = format!("{}.mdb", name);
|
||||
let index_path = self.db_path.join(rdb_name);
|
||||
|
||||
if !index_path.exists() {
|
||||
return Err(DatabaseError::NotExist.into());
|
||||
}
|
||||
|
||||
let index = Database::open(index_path)?;
|
||||
|
||||
self.databases.insert_new(name, index);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_existing(&self) {
|
||||
let paths = match fs::read_dir(self.db_path.clone()){
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
return
|
||||
}
|
||||
};
|
||||
|
||||
for path in paths {
|
||||
let path = match path {
|
||||
Ok(p) => p.path(),
|
||||
Err(_) => continue
|
||||
};
|
||||
|
||||
let path_str = match path.to_str() {
|
||||
Some(p) => p,
|
||||
None => continue
|
||||
};
|
||||
|
||||
let extension = match get_extension_from_path(path_str) {
|
||||
Some(e) => e,
|
||||
None => continue
|
||||
};
|
||||
|
||||
if extension != "mdb" {
|
||||
continue
|
||||
}
|
||||
|
||||
let name = match get_file_name_from_path(path_str) {
|
||||
Some(f) => f,
|
||||
None => continue
|
||||
};
|
||||
|
||||
let db = match Database::open(path.clone()) {
|
||||
Ok(db) => db,
|
||||
Err(_) => continue
|
||||
};
|
||||
|
||||
self.databases.insert_new(name.to_string(), db);
|
||||
info!("Load database {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_or_load(&self, name: String, schema: Schema) -> Result<(), DatabaseError> {
|
||||
match self.create(name.clone(), schema) {
|
||||
Err(DatabaseError::AlreadyExist) => self.load(name),
|
||||
x => x,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, name: String) -> Result<ReadGuard<String, Database>, Box<Error>> {
|
||||
Ok(self.databases.get(&name).ok_or(DatabaseError::NotFound(name))?)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_extension_from_path(path: &str) -> Option<&str> {
|
||||
Path::new(path).extension().and_then(OsStr::to_str)
|
||||
}
|
||||
|
||||
fn get_file_name_from_path(path: &str) -> Option<&str> {
|
||||
Path::new(path).file_stem().and_then(OsStr::to_str)
|
||||
}
|
||||
|
||||
fn retrieve_stop_words(path: &Path) -> io::Result<HashSet<String>> {
|
||||
let f = File::open(path)?;
|
||||
let reader = BufReader::new(f);
|
||||
let mut words = HashSet::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let word = line.trim().to_string();
|
||||
words.insert(word);
|
||||
}
|
||||
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
//
|
||||
// PARAMS & BODY FOR HTTPS HANDLERS
|
||||
//
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateBody {
|
||||
name: String,
|
||||
schema: SchemaBuilder,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IngestBody {
|
||||
insert: Option<Vec<HashMap<String, String>>>,
|
||||
delete: Option<Vec<HashMap<String, String>>>
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IngestResponse {
|
||||
inserted: usize,
|
||||
deleted: usize
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchQuery {
|
||||
q: String,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
//
|
||||
// HTTP ROUTES
|
||||
//
|
||||
|
||||
// Create a new index.
|
||||
// The index name should be unused and the schema valid.
|
||||
//
|
||||
// POST /create
|
||||
// Body:
|
||||
// - name: String
|
||||
// - schema: JSON
|
||||
// - stopwords: Vec<String>
|
||||
fn create(body: CreateBody, db: Arc<MultiDatabase>) -> Result<String, Rejection> {
|
||||
let schema = body.schema.build();
|
||||
|
||||
match db.create(body.name.clone(), schema) {
|
||||
Ok(_) => Ok(format!("{} created ", body.name)),
|
||||
Err(e) => {
|
||||
error!("{:?}", e);
|
||||
return Err(warp::reject::not_found())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ingest new document.
|
||||
// It's possible to have positive or/and negative updates.
|
||||
//
|
||||
// PUT /:name/ingest
|
||||
// Body:
|
||||
// - insert: Option<Vec<JSON>>
|
||||
// - delete: Option<Vec<String>>
|
||||
fn ingest(index_name: String, body: IngestBody, db: Arc<MultiDatabase>) -> Result<String, Rejection> {
|
||||
|
||||
let schema = {
|
||||
let index = match db.get(index_name.clone()){
|
||||
Ok(i) => i,
|
||||
Err(_) => return Err(warp::reject::not_found()),
|
||||
};
|
||||
let view = index.view();
|
||||
|
||||
view.schema().clone()
|
||||
};
|
||||
|
||||
let tokenizer_builder = DefaultBuilder::new();
|
||||
let now = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
|
||||
Ok(n) => n.as_secs(),
|
||||
Err(_) => panic!("SystemTime before UNIX EPOCH!"),
|
||||
};
|
||||
|
||||
let sst_name = format!("update-{}-{}.sst", index_name, now);
|
||||
let sst_path = db.db_path.join(sst_name);
|
||||
|
||||
let mut response = IngestResponse{inserted: 0, deleted: 0};
|
||||
let mut update = UpdateBuilder::new(sst_path, schema);
|
||||
|
||||
if let Some(documents) = body.delete {
|
||||
for doc in documents {
|
||||
if let Err(e) = update.remove_document(doc) {
|
||||
error!("Impossible to remove document; {:?}", e);
|
||||
} else {
|
||||
response.deleted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stop_words = &db.stop_words;
|
||||
if let Some(documents) = body.insert {
|
||||
for doc in documents {
|
||||
if let Err(e) = update.update_document(doc, &tokenizer_builder, &stop_words) {
|
||||
error!("Impossible to update document; {:?}", e);
|
||||
} else {
|
||||
response.inserted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let update = match update.build() {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
error!("Impossible to create an update file; {:?}", e);
|
||||
return Err(warp::reject::not_found())
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let index = match db.get(index_name.clone()){
|
||||
Ok(i) => i,
|
||||
Err(_) => return Err(warp::reject::not_found()),
|
||||
};
|
||||
|
||||
if let Err(e) = index.ingest_update_file(update) {
|
||||
error!("Impossible to ingest sst file; {:?}", e);
|
||||
return Err(warp::reject::not_found())
|
||||
};
|
||||
}
|
||||
|
||||
if let Ok(response) = serde_json::to_string(&response) {
|
||||
return Ok(response);
|
||||
};
|
||||
|
||||
return Err(warp::reject::not_found())
|
||||
}
|
||||
|
||||
// Search in a specific index
|
||||
// The default limit is 20
|
||||
//
|
||||
// GET /:name/search
|
||||
// Params:
|
||||
// - query: String
|
||||
// - limit: Option<usize>
|
||||
fn search(index_name: String, query: SearchQuery, db: Arc<MultiDatabase>) -> Result<String, Rejection> {
|
||||
|
||||
let view = {
|
||||
let index = match db.get(index_name.clone()){
|
||||
Ok(i) => i,
|
||||
Err(_) => return Err(warp::reject::not_found()),
|
||||
};
|
||||
index.view()
|
||||
};
|
||||
|
||||
let limit = query.limit.unwrap_or(20);
|
||||
|
||||
let query_builder = match view.query_builder() {
|
||||
Ok(q) => q,
|
||||
Err(_err) => return Err(warp::reject::not_found()),
|
||||
};
|
||||
|
||||
let (time, responses) = measure_time(|| {
|
||||
let docs = query_builder.query(&query.q, 0..limit);
|
||||
let mut results: Vec<HashMap<String, String>> = Vec::with_capacity(limit);
|
||||
for doc in docs {
|
||||
match view.document_by_id(doc.id) {
|
||||
Ok(val) => results.push(val),
|
||||
Err(e) => println!("{:?}", e),
|
||||
}
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
let response = match serde_json::to_string(&responses) {
|
||||
Ok(val) => val,
|
||||
Err(err) => format!("{:?}", err),
|
||||
};
|
||||
|
||||
info!("index: {} - search: {:?} - limit: {} - time: {}", index_name, query.q, limit, time);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn start_server(listen_addr: SocketAddr, db: Arc<MultiDatabase>) {
|
||||
let index_path = warp::path("index").and(warp::path::param::<String>());
|
||||
let db = warp::any().map(move || db.clone());
|
||||
|
||||
let create_path = warp::path("create").and(warp::path::end());
|
||||
let ingest_path = index_path.and(warp::path("ingest")).and(warp::path::end());
|
||||
let search_path = index_path.and(warp::path("search")).and(warp::path::end());
|
||||
|
||||
let create = warp::post2()
|
||||
.and(create_path)
|
||||
.and(warp::body::json())
|
||||
.and(db.clone())
|
||||
.and_then(create);
|
||||
|
||||
let ingest = warp::put2()
|
||||
.and(ingest_path)
|
||||
.and(warp::body::json())
|
||||
.and(db.clone())
|
||||
.and_then(ingest);
|
||||
|
||||
let search = warp::get2()
|
||||
.and(search_path)
|
||||
.and(warp::query())
|
||||
.and(db.clone())
|
||||
.and_then(search);
|
||||
|
||||
let api = create
|
||||
.or(ingest)
|
||||
.or(search);
|
||||
|
||||
let logs = warp::log("server");
|
||||
let headers = warp::reply::with::header("Content-Type", "application/json");
|
||||
|
||||
let routes = api.with(logs).with(headers);
|
||||
|
||||
info!("Server is started on {}", listen_addr);
|
||||
warp::serve(routes).run(listen_addr);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let opt = Opt::from_args();
|
||||
|
||||
let stop_words = match retrieve_stop_words(&opt.stop_words) {
|
||||
Ok(s) => s,
|
||||
Err(_) => HashSet::new(),
|
||||
};
|
||||
|
||||
let db = Arc::new(MultiDatabase::new(opt.database_path.clone(), stop_words));
|
||||
|
||||
db.load_existing();
|
||||
|
||||
start_server(opt.listen_addr, db);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
#[global_allocator]
|
||||
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
||||
|
||||
use std::collections::btree_map::{BTreeMap, Entry};
|
||||
use std::iter::FromIterator;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::error::Error;
|
||||
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
|
||||
use structopt::StructOpt;
|
||||
|
||||
use meilidb::database::schema::SchemaAttr;
|
||||
use meilidb::database::Database;
|
||||
use meilidb::Match;
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
pub struct Opt {
|
||||
/// The destination where the database must be created
|
||||
#[structopt(parse(from_os_str))]
|
||||
pub database_path: PathBuf,
|
||||
|
||||
/// Fields that must be displayed.
|
||||
pub displayed_fields: Vec<String>,
|
||||
|
||||
/// The number of returned results
|
||||
#[structopt(short = "n", long = "number-results", default_value = "10")]
|
||||
pub number_results: usize,
|
||||
}
|
||||
|
||||
type Document = HashMap<String, String>;
|
||||
|
||||
fn display_highlights(text: &str, ranges: &[usize]) -> io::Result<()> {
|
||||
let mut stdout = StandardStream::stdout(ColorChoice::Always);
|
||||
let mut highlighted = false;
|
||||
|
||||
for range in ranges.windows(2) {
|
||||
let [start, end] = match range { [start, end] => [*start, *end], _ => unreachable!() };
|
||||
if highlighted {
|
||||
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
|
||||
}
|
||||
write!(&mut stdout, "{}", &text[start..end])?;
|
||||
stdout.reset()?;
|
||||
highlighted = !highlighted;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn char_to_byte_range(index: usize, length: usize, text: &str) -> (usize, usize) {
|
||||
let mut byte_index = 0;
|
||||
let mut byte_length = 0;
|
||||
|
||||
for (n, (i, c)) in text.char_indices().enumerate() {
|
||||
if n == index {
|
||||
byte_index = i;
|
||||
}
|
||||
|
||||
if n + 1 == index + length {
|
||||
byte_length = i - byte_index + c.len_utf8();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(byte_index, byte_length)
|
||||
}
|
||||
|
||||
fn create_highlight_areas(text: &str, matches: &[Match], attribute: SchemaAttr) -> Vec<usize> {
|
||||
let mut byte_indexes = BTreeMap::new();
|
||||
|
||||
for match_ in matches {
|
||||
let match_attribute = match_.attribute.attribute();
|
||||
if SchemaAttr::new(match_attribute) == attribute {
|
||||
let word_area = match_.word_area;
|
||||
|
||||
let char_index = word_area.char_index() as usize;
|
||||
let char_length = word_area.length() as usize;
|
||||
let (byte_index, byte_length) = char_to_byte_range(char_index, char_length, text);
|
||||
|
||||
match byte_indexes.entry(byte_index) {
|
||||
Entry::Vacant(entry) => { entry.insert(byte_length); },
|
||||
Entry::Occupied(mut entry) => {
|
||||
if *entry.get() < byte_length {
|
||||
entry.insert(byte_length);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut title_areas = Vec::new();
|
||||
title_areas.push(0);
|
||||
for (byte_index, length) in byte_indexes {
|
||||
title_areas.push(byte_index);
|
||||
title_areas.push(byte_index + length);
|
||||
}
|
||||
title_areas.push(text.len());
|
||||
title_areas.sort_unstable();
|
||||
title_areas
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<Error>> {
|
||||
let _ = env_logger::init();
|
||||
let opt = Opt::from_args();
|
||||
|
||||
let (elapsed, result) = elapsed::measure_time(|| Database::open(&opt.database_path));
|
||||
let database = result?;
|
||||
println!("database prepared for you in {}", elapsed);
|
||||
|
||||
let mut buffer = String::new();
|
||||
let input = io::stdin();
|
||||
|
||||
loop {
|
||||
print!("Searching for: ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
if input.read_line(&mut buffer)? == 0 { break }
|
||||
let query = buffer.trim_end_matches('\n');
|
||||
|
||||
let view = database.view();
|
||||
let schema = view.schema();
|
||||
|
||||
let (elapsed, documents) = elapsed::measure_time(|| {
|
||||
let builder = view.query_builder().unwrap();
|
||||
builder.query(query, 0..opt.number_results)
|
||||
});
|
||||
|
||||
let number_of_documents = documents.len();
|
||||
for doc in documents {
|
||||
match view.document_by_id::<Document>(doc.id) {
|
||||
Ok(document) => {
|
||||
for name in &opt.displayed_fields {
|
||||
let attr = match schema.attribute(name) {
|
||||
Some(attr) => attr,
|
||||
None => continue,
|
||||
};
|
||||
let text = match document.get(name) {
|
||||
Some(text) => text,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
print!("{}: ", name);
|
||||
let areas = create_highlight_areas(&text, &doc.matches, attr);
|
||||
display_highlights(&text, &areas)?;
|
||||
println!();
|
||||
}
|
||||
},
|
||||
Err(e) => eprintln!("{}", e),
|
||||
}
|
||||
|
||||
let mut matching_attributes = HashSet::new();
|
||||
for _match in doc.matches {
|
||||
let attr = SchemaAttr::new(_match.attribute.attribute());
|
||||
let name = schema.attribute_name(attr);
|
||||
matching_attributes.insert(name);
|
||||
}
|
||||
|
||||
let matching_attributes = Vec::from_iter(matching_attributes);
|
||||
println!("matching in: {:?}", matching_attributes);
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
eprintln!("===== Found {} results in {} =====", number_of_documents, elapsed);
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# This schema has been generated ...
|
||||
# The order in which the attributes are declared is important,
|
||||
# it specify the attribute xxx...
|
||||
|
||||
identifier = "id"
|
||||
|
||||
[attributes.id]
|
||||
stored = true
|
||||
|
||||
[attributes.title]
|
||||
stored = true
|
||||
indexed = true
|
||||
|
||||
[attributes.description]
|
||||
stored = true
|
||||
indexed = true
|
||||
|
||||
[attributes.image]
|
||||
stored = true
|
||||
17
meilisearch-auth/Cargo.toml
Normal file
17
meilisearch-auth/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "meilisearch-auth"
|
||||
version = "0.28.1"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
enum-iterator = "0.7.0"
|
||||
hmac = "0.12.1"
|
||||
meilisearch-types = { path = "../meilisearch-types" }
|
||||
milli = { git = "https://github.com/meilisearch/milli.git", tag = "v0.31.2" }
|
||||
rand = "0.8.4"
|
||||
serde = { version = "1.0.136", features = ["derive"] }
|
||||
serde_json = { version = "1.0.79", features = ["preserve_order"] }
|
||||
sha2 = "0.10.2"
|
||||
thiserror = "1.0.30"
|
||||
time = { version = "0.3.7", features = ["serde-well-known", "formatting", "parsing", "macros"] }
|
||||
uuid = { version = "1.1.2", features = ["serde", "v4"] }
|
||||
151
meilisearch-auth/src/action.rs
Normal file
151
meilisearch-auth/src/action.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use enum_iterator::IntoEnumIterator;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::hash::Hash;
|
||||
|
||||
#[derive(IntoEnumIterator, Copy, Clone, Serialize, Deserialize, Debug, Eq, PartialEq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum Action {
|
||||
#[serde(rename = "*")]
|
||||
All = actions::ALL,
|
||||
#[serde(rename = "search")]
|
||||
Search = actions::SEARCH,
|
||||
#[serde(rename = "documents.*")]
|
||||
DocumentsAll = actions::DOCUMENTS_ALL,
|
||||
#[serde(rename = "documents.add")]
|
||||
DocumentsAdd = actions::DOCUMENTS_ADD,
|
||||
#[serde(rename = "documents.get")]
|
||||
DocumentsGet = actions::DOCUMENTS_GET,
|
||||
#[serde(rename = "documents.delete")]
|
||||
DocumentsDelete = actions::DOCUMENTS_DELETE,
|
||||
#[serde(rename = "indexes.*")]
|
||||
IndexesAll = actions::INDEXES_ALL,
|
||||
#[serde(rename = "indexes.create")]
|
||||
IndexesAdd = actions::INDEXES_CREATE,
|
||||
#[serde(rename = "indexes.get")]
|
||||
IndexesGet = actions::INDEXES_GET,
|
||||
#[serde(rename = "indexes.update")]
|
||||
IndexesUpdate = actions::INDEXES_UPDATE,
|
||||
#[serde(rename = "indexes.delete")]
|
||||
IndexesDelete = actions::INDEXES_DELETE,
|
||||
#[serde(rename = "tasks.*")]
|
||||
TasksAll = actions::TASKS_ALL,
|
||||
#[serde(rename = "tasks.get")]
|
||||
TasksGet = actions::TASKS_GET,
|
||||
#[serde(rename = "settings.*")]
|
||||
SettingsAll = actions::SETTINGS_ALL,
|
||||
#[serde(rename = "settings.get")]
|
||||
SettingsGet = actions::SETTINGS_GET,
|
||||
#[serde(rename = "settings.update")]
|
||||
SettingsUpdate = actions::SETTINGS_UPDATE,
|
||||
#[serde(rename = "stats.*")]
|
||||
StatsAll = actions::STATS_ALL,
|
||||
#[serde(rename = "stats.get")]
|
||||
StatsGet = actions::STATS_GET,
|
||||
#[serde(rename = "dumps.*")]
|
||||
DumpsAll = actions::DUMPS_ALL,
|
||||
#[serde(rename = "dumps.create")]
|
||||
DumpsCreate = actions::DUMPS_CREATE,
|
||||
#[serde(rename = "version")]
|
||||
Version = actions::VERSION,
|
||||
#[serde(rename = "keys.create")]
|
||||
KeysAdd = actions::KEYS_CREATE,
|
||||
#[serde(rename = "keys.get")]
|
||||
KeysGet = actions::KEYS_GET,
|
||||
#[serde(rename = "keys.update")]
|
||||
KeysUpdate = actions::KEYS_UPDATE,
|
||||
#[serde(rename = "keys.delete")]
|
||||
KeysDelete = actions::KEYS_DELETE,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn from_repr(repr: u8) -> Option<Self> {
|
||||
use actions::*;
|
||||
match repr {
|
||||
ALL => Some(Self::All),
|
||||
SEARCH => Some(Self::Search),
|
||||
DOCUMENTS_ALL => Some(Self::DocumentsAll),
|
||||
DOCUMENTS_ADD => Some(Self::DocumentsAdd),
|
||||
DOCUMENTS_GET => Some(Self::DocumentsGet),
|
||||
DOCUMENTS_DELETE => Some(Self::DocumentsDelete),
|
||||
INDEXES_ALL => Some(Self::IndexesAll),
|
||||
INDEXES_CREATE => Some(Self::IndexesAdd),
|
||||
INDEXES_GET => Some(Self::IndexesGet),
|
||||
INDEXES_UPDATE => Some(Self::IndexesUpdate),
|
||||
INDEXES_DELETE => Some(Self::IndexesDelete),
|
||||
TASKS_ALL => Some(Self::TasksAll),
|
||||
TASKS_GET => Some(Self::TasksGet),
|
||||
SETTINGS_ALL => Some(Self::SettingsAll),
|
||||
SETTINGS_GET => Some(Self::SettingsGet),
|
||||
SETTINGS_UPDATE => Some(Self::SettingsUpdate),
|
||||
STATS_ALL => Some(Self::StatsAll),
|
||||
STATS_GET => Some(Self::StatsGet),
|
||||
DUMPS_ALL => Some(Self::DumpsAll),
|
||||
DUMPS_CREATE => Some(Self::DumpsCreate),
|
||||
VERSION => Some(Self::Version),
|
||||
KEYS_CREATE => Some(Self::KeysAdd),
|
||||
KEYS_GET => Some(Self::KeysGet),
|
||||
KEYS_UPDATE => Some(Self::KeysUpdate),
|
||||
KEYS_DELETE => Some(Self::KeysDelete),
|
||||
_otherwise => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repr(&self) -> u8 {
|
||||
use actions::*;
|
||||
match self {
|
||||
Self::All => ALL,
|
||||
Self::Search => SEARCH,
|
||||
Self::DocumentsAll => DOCUMENTS_ALL,
|
||||
Self::DocumentsAdd => DOCUMENTS_ADD,
|
||||
Self::DocumentsGet => DOCUMENTS_GET,
|
||||
Self::DocumentsDelete => DOCUMENTS_DELETE,
|
||||
Self::IndexesAll => INDEXES_ALL,
|
||||
Self::IndexesAdd => INDEXES_CREATE,
|
||||
Self::IndexesGet => INDEXES_GET,
|
||||
Self::IndexesUpdate => INDEXES_UPDATE,
|
||||
Self::IndexesDelete => INDEXES_DELETE,
|
||||
Self::TasksAll => TASKS_ALL,
|
||||
Self::TasksGet => TASKS_GET,
|
||||
Self::SettingsAll => SETTINGS_ALL,
|
||||
Self::SettingsGet => SETTINGS_GET,
|
||||
Self::SettingsUpdate => SETTINGS_UPDATE,
|
||||
Self::StatsAll => STATS_ALL,
|
||||
Self::StatsGet => STATS_GET,
|
||||
Self::DumpsAll => DUMPS_ALL,
|
||||
Self::DumpsCreate => DUMPS_CREATE,
|
||||
Self::Version => VERSION,
|
||||
Self::KeysAdd => KEYS_CREATE,
|
||||
Self::KeysGet => KEYS_GET,
|
||||
Self::KeysUpdate => KEYS_UPDATE,
|
||||
Self::KeysDelete => KEYS_DELETE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod actions {
|
||||
pub(crate) const ALL: u8 = 0;
|
||||
pub const SEARCH: u8 = 1;
|
||||
pub const DOCUMENTS_ALL: u8 = 2;
|
||||
pub const DOCUMENTS_ADD: u8 = 3;
|
||||
pub const DOCUMENTS_GET: u8 = 4;
|
||||
pub const DOCUMENTS_DELETE: u8 = 5;
|
||||
pub const INDEXES_ALL: u8 = 6;
|
||||
pub const INDEXES_CREATE: u8 = 7;
|
||||
pub const INDEXES_GET: u8 = 8;
|
||||
pub const INDEXES_UPDATE: u8 = 9;
|
||||
pub const INDEXES_DELETE: u8 = 10;
|
||||
pub const TASKS_ALL: u8 = 11;
|
||||
pub const TASKS_GET: u8 = 12;
|
||||
pub const SETTINGS_ALL: u8 = 13;
|
||||
pub const SETTINGS_GET: u8 = 14;
|
||||
pub const SETTINGS_UPDATE: u8 = 15;
|
||||
pub const STATS_ALL: u8 = 16;
|
||||
pub const STATS_GET: u8 = 17;
|
||||
pub const DUMPS_ALL: u8 = 18;
|
||||
pub const DUMPS_CREATE: u8 = 19;
|
||||
pub const VERSION: u8 = 20;
|
||||
pub const KEYS_CREATE: u8 = 21;
|
||||
pub const KEYS_GET: u8 = 22;
|
||||
pub const KEYS_UPDATE: u8 = 23;
|
||||
pub const KEYS_DELETE: u8 = 24;
|
||||
}
|
||||
47
meilisearch-auth/src/dump.rs
Normal file
47
meilisearch-auth/src/dump.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use serde_json::Deserializer;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{AuthController, HeedAuthStore, Result};
|
||||
|
||||
const KEYS_PATH: &str = "keys";
|
||||
|
||||
impl AuthController {
|
||||
pub fn dump(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<()> {
|
||||
let mut store = HeedAuthStore::new(&src)?;
|
||||
|
||||
// do not attempt to close the database on drop!
|
||||
store.set_drop_on_close(false);
|
||||
|
||||
let keys_file_path = dst.as_ref().join(KEYS_PATH);
|
||||
|
||||
let keys = store.list_api_keys()?;
|
||||
let mut keys_file = File::create(&keys_file_path)?;
|
||||
for key in keys {
|
||||
serde_json::to_writer(&mut keys_file, &key)?;
|
||||
keys_file.write_all(b"\n")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_dump(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<()> {
|
||||
let store = HeedAuthStore::new(&dst)?;
|
||||
|
||||
let keys_file_path = src.as_ref().join(KEYS_PATH);
|
||||
|
||||
if !keys_file_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let reader = BufReader::new(File::open(&keys_file_path)?);
|
||||
for key in Deserializer::from_reader(reader).into_iter() {
|
||||
store.put_api_key(key?)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
60
meilisearch-auth/src/error.rs
Normal file
60
meilisearch-auth/src/error.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::error::Error;
|
||||
|
||||
use meilisearch_types::error::{Code, ErrorCode};
|
||||
use meilisearch_types::internal_error;
|
||||
use serde_json::Value;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AuthControllerError>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthControllerError {
|
||||
#[error("`{0}` field is mandatory.")]
|
||||
MissingParameter(&'static str),
|
||||
#[error("`actions` field value `{0}` is invalid. It should be an array of string representing action names.")]
|
||||
InvalidApiKeyActions(Value),
|
||||
#[error("`indexes` field value `{0}` is invalid. It should be an array of string representing index names.")]
|
||||
InvalidApiKeyIndexes(Value),
|
||||
#[error("`expiresAt` field value `{0}` is invalid. It should follow the RFC 3339 format to represents a date or datetime in the future or specified as a null value. e.g. 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'.")]
|
||||
InvalidApiKeyExpiresAt(Value),
|
||||
#[error("`description` field value `{0}` is invalid. It should be a string or specified as a null value.")]
|
||||
InvalidApiKeyDescription(Value),
|
||||
#[error(
|
||||
"`name` field value `{0}` is invalid. It should be a string or specified as a null value."
|
||||
)]
|
||||
InvalidApiKeyName(Value),
|
||||
#[error("`uid` field value `{0}` is invalid. It should be a valid UUID v4 string or omitted.")]
|
||||
InvalidApiKeyUid(Value),
|
||||
#[error("API key `{0}` not found.")]
|
||||
ApiKeyNotFound(String),
|
||||
#[error("`uid` field value `{0}` is already an existing API key.")]
|
||||
ApiKeyAlreadyExists(String),
|
||||
#[error("The `{0}` field cannot be modified for the given resource.")]
|
||||
ImmutableField(String),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(Box<dyn Error + Send + Sync + 'static>),
|
||||
}
|
||||
|
||||
internal_error!(
|
||||
AuthControllerError: milli::heed::Error,
|
||||
std::io::Error,
|
||||
serde_json::Error,
|
||||
std::str::Utf8Error
|
||||
);
|
||||
|
||||
impl ErrorCode for AuthControllerError {
|
||||
fn error_code(&self) -> Code {
|
||||
match self {
|
||||
Self::MissingParameter(_) => Code::MissingParameter,
|
||||
Self::InvalidApiKeyActions(_) => Code::InvalidApiKeyActions,
|
||||
Self::InvalidApiKeyIndexes(_) => Code::InvalidApiKeyIndexes,
|
||||
Self::InvalidApiKeyExpiresAt(_) => Code::InvalidApiKeyExpiresAt,
|
||||
Self::InvalidApiKeyDescription(_) => Code::InvalidApiKeyDescription,
|
||||
Self::InvalidApiKeyName(_) => Code::InvalidApiKeyName,
|
||||
Self::ApiKeyNotFound(_) => Code::ApiKeyNotFound,
|
||||
Self::InvalidApiKeyUid(_) => Code::InvalidApiKeyUid,
|
||||
Self::ApiKeyAlreadyExists(_) => Code::ApiKeyAlreadyExists,
|
||||
Self::ImmutableField(_) => Code::ImmutableField,
|
||||
Self::Internal(_) => Code::Internal,
|
||||
}
|
||||
}
|
||||
}
|
||||
201
meilisearch-auth/src/key.rs
Normal file
201
meilisearch-auth/src/key.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use crate::action::Action;
|
||||
use crate::error::{AuthControllerError, Result};
|
||||
use crate::store::KeyId;
|
||||
|
||||
use meilisearch_types::index_uid::IndexUid;
|
||||
use meilisearch_types::star_or::StarOr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{from_value, Value};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::macros::{format_description, time};
|
||||
use time::{Date, OffsetDateTime, PrimitiveDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Key {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub uid: KeyId,
|
||||
pub actions: Vec<Action>,
|
||||
pub indexes: Vec<StarOr<IndexUid>>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub expires_at: Option<OffsetDateTime>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn create_from_value(value: Value) -> Result<Self> {
|
||||
let name = match value.get("name") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(des) => from_value(des.clone())
|
||||
.map(Some)
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyName(des.clone()))?,
|
||||
};
|
||||
|
||||
let description = match value.get("description") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(des) => from_value(des.clone())
|
||||
.map(Some)
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyDescription(des.clone()))?,
|
||||
};
|
||||
|
||||
let uid = value.get("uid").map_or_else(
|
||||
|| Ok(Uuid::new_v4()),
|
||||
|uid| {
|
||||
from_value(uid.clone())
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyUid(uid.clone()))
|
||||
},
|
||||
)?;
|
||||
|
||||
let actions = value
|
||||
.get("actions")
|
||||
.map(|act| {
|
||||
from_value(act.clone())
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyActions(act.clone()))
|
||||
})
|
||||
.ok_or(AuthControllerError::MissingParameter("actions"))??;
|
||||
|
||||
let indexes = value
|
||||
.get("indexes")
|
||||
.map(|ind| {
|
||||
from_value(ind.clone())
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyIndexes(ind.clone()))
|
||||
})
|
||||
.ok_or(AuthControllerError::MissingParameter("indexes"))??;
|
||||
|
||||
let expires_at = value
|
||||
.get("expiresAt")
|
||||
.map(parse_expiration_date)
|
||||
.ok_or(AuthControllerError::MissingParameter("expiresAt"))??;
|
||||
|
||||
let created_at = OffsetDateTime::now_utc();
|
||||
let updated_at = created_at;
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
description,
|
||||
uid,
|
||||
actions,
|
||||
indexes,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_from_value(&mut self, value: Value) -> Result<()> {
|
||||
if let Some(des) = value.get("description") {
|
||||
let des = from_value(des.clone())
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyDescription(des.clone()));
|
||||
self.description = des?;
|
||||
}
|
||||
|
||||
if let Some(des) = value.get("name") {
|
||||
let des = from_value(des.clone())
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyName(des.clone()));
|
||||
self.name = des?;
|
||||
}
|
||||
|
||||
if value.get("uid").is_some() {
|
||||
return Err(AuthControllerError::ImmutableField("uid".to_string()));
|
||||
}
|
||||
|
||||
if value.get("actions").is_some() {
|
||||
return Err(AuthControllerError::ImmutableField("actions".to_string()));
|
||||
}
|
||||
|
||||
if value.get("indexes").is_some() {
|
||||
return Err(AuthControllerError::ImmutableField("indexes".to_string()));
|
||||
}
|
||||
|
||||
if value.get("expiresAt").is_some() {
|
||||
return Err(AuthControllerError::ImmutableField("expiresAt".to_string()));
|
||||
}
|
||||
|
||||
if value.get("createdAt").is_some() {
|
||||
return Err(AuthControllerError::ImmutableField("createdAt".to_string()));
|
||||
}
|
||||
|
||||
if value.get("updatedAt").is_some() {
|
||||
return Err(AuthControllerError::ImmutableField("updatedAt".to_string()));
|
||||
}
|
||||
|
||||
self.updated_at = OffsetDateTime::now_utc();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn default_admin() -> Self {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let uid = Uuid::new_v4();
|
||||
Self {
|
||||
name: Some("Default Admin API Key".to_string()),
|
||||
description: Some("Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend".to_string()),
|
||||
uid,
|
||||
actions: vec![Action::All],
|
||||
indexes: vec![StarOr::Star],
|
||||
expires_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_search() -> Self {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let uid = Uuid::new_v4();
|
||||
Self {
|
||||
name: Some("Default Search API Key".to_string()),
|
||||
description: Some("Use it to search from the frontend".to_string()),
|
||||
uid,
|
||||
actions: vec![Action::Search],
|
||||
indexes: vec![StarOr::Star],
|
||||
expires_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_expiration_date(value: &Value) -> Result<Option<OffsetDateTime>> {
|
||||
match value {
|
||||
Value::String(string) => OffsetDateTime::parse(string, &Rfc3339)
|
||||
.or_else(|_| {
|
||||
PrimitiveDateTime::parse(
|
||||
string,
|
||||
format_description!(
|
||||
"[year repr:full base:calendar]-[month repr:numerical]-[day]T[hour]:[minute]:[second]"
|
||||
),
|
||||
).map(|datetime| datetime.assume_utc())
|
||||
})
|
||||
.or_else(|_| {
|
||||
PrimitiveDateTime::parse(
|
||||
string,
|
||||
format_description!(
|
||||
"[year repr:full base:calendar]-[month repr:numerical]-[day] [hour]:[minute]:[second]"
|
||||
),
|
||||
).map(|datetime| datetime.assume_utc())
|
||||
})
|
||||
.or_else(|_| {
|
||||
Date::parse(string, format_description!(
|
||||
"[year repr:full base:calendar]-[month repr:numerical]-[day]"
|
||||
)).map(|date| PrimitiveDateTime::new(date, time!(00:00)).assume_utc())
|
||||
})
|
||||
.map_err(|_| AuthControllerError::InvalidApiKeyExpiresAt(value.clone()))
|
||||
// check if the key is already expired.
|
||||
.and_then(|d| {
|
||||
if d > OffsetDateTime::now_utc() {
|
||||
Ok(d)
|
||||
} else {
|
||||
Err(AuthControllerError::InvalidApiKeyExpiresAt(value.clone()))
|
||||
}
|
||||
})
|
||||
.map(Option::Some),
|
||||
Value::Null => Ok(None),
|
||||
_otherwise => Err(AuthControllerError::InvalidApiKeyExpiresAt(value.clone())),
|
||||
}
|
||||
}
|
||||
258
meilisearch-auth/src/lib.rs
Normal file
258
meilisearch-auth/src/lib.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
mod action;
|
||||
mod dump;
|
||||
pub mod error;
|
||||
mod key;
|
||||
mod store;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use action::{actions, Action};
|
||||
use error::{AuthControllerError, Result};
|
||||
pub use key::Key;
|
||||
use meilisearch_types::star_or::StarOr;
|
||||
use store::generate_key_as_hexa;
|
||||
pub use store::open_auth_store_env;
|
||||
use store::HeedAuthStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthController {
|
||||
store: Arc<HeedAuthStore>,
|
||||
master_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthController {
|
||||
pub fn new(db_path: impl AsRef<Path>, master_key: &Option<String>) -> Result<Self> {
|
||||
let store = HeedAuthStore::new(db_path)?;
|
||||
|
||||
if store.is_empty()? {
|
||||
generate_default_keys(&store)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
store: Arc::new(store),
|
||||
master_key: master_key.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_key(&self, value: Value) -> Result<Key> {
|
||||
let key = Key::create_from_value(value)?;
|
||||
match self.store.get_api_key(key.uid)? {
|
||||
Some(_) => Err(AuthControllerError::ApiKeyAlreadyExists(
|
||||
key.uid.to_string(),
|
||||
)),
|
||||
None => self.store.put_api_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_key(&self, uid: Uuid, value: Value) -> Result<Key> {
|
||||
let mut key = self.get_key(uid)?;
|
||||
key.update_from_value(value)?;
|
||||
self.store.put_api_key(key)
|
||||
}
|
||||
|
||||
pub fn get_key(&self, uid: Uuid) -> Result<Key> {
|
||||
self.store
|
||||
.get_api_key(uid)?
|
||||
.ok_or_else(|| AuthControllerError::ApiKeyNotFound(uid.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_optional_uid_from_encoded_key(&self, encoded_key: &[u8]) -> Result<Option<Uuid>> {
|
||||
match &self.master_key {
|
||||
Some(master_key) => self
|
||||
.store
|
||||
.get_uid_from_encoded_key(encoded_key, master_key.as_bytes()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_uid_from_encoded_key(&self, encoded_key: &str) -> Result<Uuid> {
|
||||
self.get_optional_uid_from_encoded_key(encoded_key.as_bytes())?
|
||||
.ok_or_else(|| AuthControllerError::ApiKeyNotFound(encoded_key.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_key_filters(
|
||||
&self,
|
||||
uid: Uuid,
|
||||
search_rules: Option<SearchRules>,
|
||||
) -> Result<AuthFilter> {
|
||||
let mut filters = AuthFilter::default();
|
||||
let key = self
|
||||
.store
|
||||
.get_api_key(uid)?
|
||||
.ok_or_else(|| AuthControllerError::ApiKeyNotFound(uid.to_string()))?;
|
||||
|
||||
if !key.indexes.iter().any(|i| i == &StarOr::Star) {
|
||||
filters.search_rules = match search_rules {
|
||||
// Intersect search_rules with parent key authorized indexes.
|
||||
Some(search_rules) => SearchRules::Map(
|
||||
key.indexes
|
||||
.into_iter()
|
||||
.filter_map(|index| {
|
||||
search_rules.get_index_search_rules(index.deref()).map(
|
||||
|index_search_rules| {
|
||||
(String::from(index), Some(index_search_rules))
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
None => SearchRules::Set(key.indexes.into_iter().map(String::from).collect()),
|
||||
};
|
||||
} else if let Some(search_rules) = search_rules {
|
||||
filters.search_rules = search_rules;
|
||||
}
|
||||
|
||||
filters.allow_index_creation = key
|
||||
.actions
|
||||
.iter()
|
||||
.any(|&action| action == Action::IndexesAdd || action == Action::All);
|
||||
|
||||
Ok(filters)
|
||||
}
|
||||
|
||||
pub fn list_keys(&self) -> Result<Vec<Key>> {
|
||||
self.store.list_api_keys()
|
||||
}
|
||||
|
||||
pub fn delete_key(&self, uid: Uuid) -> Result<()> {
|
||||
if self.store.delete_api_key(uid)? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthControllerError::ApiKeyNotFound(uid.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_master_key(&self) -> Option<&String> {
|
||||
self.master_key.as_ref()
|
||||
}
|
||||
|
||||
/// Generate a valid key from a key id using the current master key.
|
||||
/// Returns None if no master key has been set.
|
||||
pub fn generate_key(&self, uid: Uuid) -> Option<String> {
|
||||
self.master_key
|
||||
.as_ref()
|
||||
.map(|master_key| generate_key_as_hexa(uid, master_key.as_bytes()))
|
||||
}
|
||||
|
||||
/// Check if the provided key is authorized to make a specific action
|
||||
/// without checking if the key is valid.
|
||||
pub fn is_key_authorized(
|
||||
&self,
|
||||
uid: Uuid,
|
||||
action: Action,
|
||||
index: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
match self
|
||||
.store
|
||||
// check if the key has access to all indexes.
|
||||
.get_expiration_date(uid, action, None)?
|
||||
.or(match index {
|
||||
// else check if the key has access to the requested index.
|
||||
Some(index) => {
|
||||
self.store
|
||||
.get_expiration_date(uid, action, Some(index.as_bytes()))?
|
||||
}
|
||||
// or to any index if no index has been requested.
|
||||
None => self.store.prefix_first_expiration_date(uid, action)?,
|
||||
}) {
|
||||
// check expiration date.
|
||||
Some(Some(exp)) => Ok(OffsetDateTime::now_utc() < exp),
|
||||
// no expiration date.
|
||||
Some(None) => Ok(true),
|
||||
// action or index forbidden.
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthFilter {
|
||||
pub search_rules: SearchRules,
|
||||
pub allow_index_creation: bool,
|
||||
}
|
||||
|
||||
impl Default for AuthFilter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
search_rules: SearchRules::default(),
|
||||
allow_index_creation: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent wrapper around a list of allowed indexes with the search rules to apply for each.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum SearchRules {
|
||||
Set(HashSet<String>),
|
||||
Map(HashMap<String, Option<IndexSearchRules>>),
|
||||
}
|
||||
|
||||
impl Default for SearchRules {
|
||||
fn default() -> Self {
|
||||
Self::Set(Some("*".to_string()).into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchRules {
|
||||
pub fn is_index_authorized(&self, index: &str) -> bool {
|
||||
match self {
|
||||
Self::Set(set) => set.contains("*") || set.contains(index),
|
||||
Self::Map(map) => map.contains_key("*") || map.contains_key(index),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_index_search_rules(&self, index: &str) -> Option<IndexSearchRules> {
|
||||
match self {
|
||||
Self::Set(set) => {
|
||||
if set.contains("*") || set.contains(index) {
|
||||
Some(IndexSearchRules::default())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Self::Map(map) => map
|
||||
.get(index)
|
||||
.or_else(|| map.get("*"))
|
||||
.map(|isr| isr.clone().unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for SearchRules {
|
||||
type Item = (String, IndexSearchRules);
|
||||
type IntoIter = Box<dyn Iterator<Item = Self::Item>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
match self {
|
||||
Self::Set(array) => {
|
||||
Box::new(array.into_iter().map(|i| (i, IndexSearchRules::default())))
|
||||
}
|
||||
Self::Map(map) => {
|
||||
Box::new(map.into_iter().map(|(i, isr)| (i, isr.unwrap_or_default())))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains the rules to apply on the top of the search query for a specific index.
|
||||
///
|
||||
/// filter: search filter to apply in addition to query filters.
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct IndexSearchRules {
|
||||
pub filter: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn generate_default_keys(store: &HeedAuthStore) -> Result<()> {
|
||||
store.put_api_key(Key::default_admin())?;
|
||||
store.put_api_key(Key::default_search())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
319
meilisearch-auth/src/store.rs
Normal file
319
meilisearch-auth/src/store.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashSet;
|
||||
use std::convert::TryFrom;
|
||||
use std::convert::TryInto;
|
||||
use std::fs::create_dir_all;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::str;
|
||||
use std::sync::Arc;
|
||||
|
||||
use enum_iterator::IntoEnumIterator;
|
||||
use hmac::{Hmac, Mac};
|
||||
use meilisearch_types::star_or::StarOr;
|
||||
use milli::heed::types::{ByteSlice, DecodeIgnore, SerdeJson};
|
||||
use milli::heed::{Database, Env, EnvOpenOptions, RwTxn};
|
||||
use sha2::Sha256;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::fmt::Hyphenated;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::error::Result;
|
||||
use super::{Action, Key};
|
||||
|
||||
const AUTH_STORE_SIZE: usize = 1_073_741_824; //1GiB
|
||||
const AUTH_DB_PATH: &str = "auth";
|
||||
const KEY_DB_NAME: &str = "api-keys";
|
||||
const KEY_ID_ACTION_INDEX_EXPIRATION_DB_NAME: &str = "keyid-action-index-expiration";
|
||||
|
||||
pub type KeyId = Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HeedAuthStore {
|
||||
env: Arc<Env>,
|
||||
keys: Database<ByteSlice, SerdeJson<Key>>,
|
||||
action_keyid_index_expiration: Database<KeyIdActionCodec, SerdeJson<Option<OffsetDateTime>>>,
|
||||
should_close_on_drop: bool,
|
||||
}
|
||||
|
||||
impl Drop for HeedAuthStore {
|
||||
fn drop(&mut self) {
|
||||
if self.should_close_on_drop && Arc::strong_count(&self.env) == 1 {
|
||||
self.env.as_ref().clone().prepare_for_closing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_auth_store_env(path: &Path) -> milli::heed::Result<milli::heed::Env> {
|
||||
let mut options = EnvOpenOptions::new();
|
||||
options.map_size(AUTH_STORE_SIZE); // 1GB
|
||||
options.max_dbs(2);
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
impl HeedAuthStore {
|
||||
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref().join(AUTH_DB_PATH);
|
||||
create_dir_all(&path)?;
|
||||
let env = Arc::new(open_auth_store_env(path.as_ref())?);
|
||||
let keys = env.create_database(Some(KEY_DB_NAME))?;
|
||||
let action_keyid_index_expiration =
|
||||
env.create_database(Some(KEY_ID_ACTION_INDEX_EXPIRATION_DB_NAME))?;
|
||||
Ok(Self {
|
||||
env,
|
||||
keys,
|
||||
action_keyid_index_expiration,
|
||||
should_close_on_drop: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_drop_on_close(&mut self, v: bool) {
|
||||
self.should_close_on_drop = v;
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> Result<bool> {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
|
||||
Ok(self.keys.len(&rtxn)? == 0)
|
||||
}
|
||||
|
||||
pub fn put_api_key(&self, key: Key) -> Result<Key> {
|
||||
let uid = key.uid;
|
||||
let mut wtxn = self.env.write_txn()?;
|
||||
|
||||
self.keys.put(&mut wtxn, uid.as_bytes(), &key)?;
|
||||
|
||||
// delete key from inverted database before refilling it.
|
||||
self.delete_key_from_inverted_db(&mut wtxn, &uid)?;
|
||||
// create inverted database.
|
||||
let db = self.action_keyid_index_expiration;
|
||||
|
||||
let mut actions = HashSet::new();
|
||||
for action in &key.actions {
|
||||
match action {
|
||||
Action::All => actions.extend(Action::into_enum_iter()),
|
||||
Action::DocumentsAll => {
|
||||
actions.extend(
|
||||
[
|
||||
Action::DocumentsGet,
|
||||
Action::DocumentsDelete,
|
||||
Action::DocumentsAdd,
|
||||
]
|
||||
.iter(),
|
||||
);
|
||||
}
|
||||
Action::IndexesAll => {
|
||||
actions.extend(
|
||||
[
|
||||
Action::IndexesAdd,
|
||||
Action::IndexesDelete,
|
||||
Action::IndexesGet,
|
||||
Action::IndexesUpdate,
|
||||
]
|
||||
.iter(),
|
||||
);
|
||||
}
|
||||
Action::SettingsAll => {
|
||||
actions.extend([Action::SettingsGet, Action::SettingsUpdate].iter());
|
||||
}
|
||||
Action::DumpsAll => {
|
||||
actions.insert(Action::DumpsCreate);
|
||||
}
|
||||
Action::TasksAll => {
|
||||
actions.insert(Action::TasksGet);
|
||||
}
|
||||
Action::StatsAll => {
|
||||
actions.insert(Action::StatsGet);
|
||||
}
|
||||
other => {
|
||||
actions.insert(*other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let no_index_restriction = key.indexes.contains(&StarOr::Star);
|
||||
for action in actions {
|
||||
if no_index_restriction {
|
||||
// If there is no index restriction we put None.
|
||||
db.put(&mut wtxn, &(&uid, &action, None), &key.expires_at)?;
|
||||
} else {
|
||||
// else we create a key for each index.
|
||||
for index in key.indexes.iter() {
|
||||
db.put(
|
||||
&mut wtxn,
|
||||
&(&uid, &action, Some(index.deref().as_bytes())),
|
||||
&key.expires_at,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn get_api_key(&self, uid: Uuid) -> Result<Option<Key>> {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
self.keys.get(&rtxn, uid.as_bytes()).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub fn get_uid_from_encoded_key(
|
||||
&self,
|
||||
encoded_key: &[u8],
|
||||
master_key: &[u8],
|
||||
) -> Result<Option<Uuid>> {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
let uid = self
|
||||
.keys
|
||||
.remap_data_type::<DecodeIgnore>()
|
||||
.iter(&rtxn)?
|
||||
.filter_map(|res| match res {
|
||||
Ok((uid, _)) => {
|
||||
let (uid, _) = try_split_array_at(uid)?;
|
||||
let uid = Uuid::from_bytes(*uid);
|
||||
if generate_key_as_hexa(uid, master_key).as_bytes() == encoded_key {
|
||||
Some(uid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
})
|
||||
.next();
|
||||
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
pub fn delete_api_key(&self, uid: Uuid) -> Result<bool> {
|
||||
let mut wtxn = self.env.write_txn()?;
|
||||
let existing = self.keys.delete(&mut wtxn, uid.as_bytes())?;
|
||||
self.delete_key_from_inverted_db(&mut wtxn, &uid)?;
|
||||
wtxn.commit()?;
|
||||
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
pub fn list_api_keys(&self) -> Result<Vec<Key>> {
|
||||
let mut list = Vec::new();
|
||||
let rtxn = self.env.read_txn()?;
|
||||
for result in self.keys.remap_key_type::<DecodeIgnore>().iter(&rtxn)? {
|
||||
let (_, content) = result?;
|
||||
list.push(content);
|
||||
}
|
||||
list.sort_unstable_by_key(|k| Reverse(k.created_at));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub fn get_expiration_date(
|
||||
&self,
|
||||
uid: Uuid,
|
||||
action: Action,
|
||||
index: Option<&[u8]>,
|
||||
) -> Result<Option<Option<OffsetDateTime>>> {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
let tuple = (&uid, &action, index);
|
||||
Ok(self.action_keyid_index_expiration.get(&rtxn, &tuple)?)
|
||||
}
|
||||
|
||||
pub fn prefix_first_expiration_date(
|
||||
&self,
|
||||
uid: Uuid,
|
||||
action: Action,
|
||||
) -> Result<Option<Option<OffsetDateTime>>> {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
let tuple = (&uid, &action, None);
|
||||
let exp = self
|
||||
.action_keyid_index_expiration
|
||||
.prefix_iter(&rtxn, &tuple)?
|
||||
.next()
|
||||
.transpose()?
|
||||
.map(|(_, expiration)| expiration);
|
||||
|
||||
Ok(exp)
|
||||
}
|
||||
|
||||
fn delete_key_from_inverted_db(&self, wtxn: &mut RwTxn, key: &KeyId) -> Result<()> {
|
||||
let mut iter = self
|
||||
.action_keyid_index_expiration
|
||||
.remap_types::<ByteSlice, DecodeIgnore>()
|
||||
.prefix_iter_mut(wtxn, key.as_bytes())?;
|
||||
while iter.next().transpose()?.is_some() {
|
||||
// safety: we don't keep references from inside the LMDB database.
|
||||
unsafe { iter.del_current()? };
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec allowing to retrieve the expiration date of an action,
|
||||
/// optionally on a specific index, for a given key.
|
||||
pub struct KeyIdActionCodec;
|
||||
|
||||
impl<'a> milli::heed::BytesDecode<'a> for KeyIdActionCodec {
|
||||
type DItem = (KeyId, Action, Option<&'a [u8]>);
|
||||
|
||||
fn bytes_decode(bytes: &'a [u8]) -> Option<Self::DItem> {
|
||||
let (key_id_bytes, action_bytes) = try_split_array_at(bytes)?;
|
||||
let (action_bytes, index) = match try_split_array_at(action_bytes)? {
|
||||
(action, []) => (action, None),
|
||||
(action, index) => (action, Some(index)),
|
||||
};
|
||||
let key_id = Uuid::from_bytes(*key_id_bytes);
|
||||
let action = Action::from_repr(u8::from_be_bytes(*action_bytes))?;
|
||||
|
||||
Some((key_id, action, index))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> milli::heed::BytesEncode<'a> for KeyIdActionCodec {
|
||||
type EItem = (&'a KeyId, &'a Action, Option<&'a [u8]>);
|
||||
|
||||
fn bytes_encode((key_id, action, index): &Self::EItem) -> Option<Cow<[u8]>> {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
bytes.extend_from_slice(key_id.as_bytes());
|
||||
let action_bytes = u8::to_be_bytes(action.repr());
|
||||
bytes.extend_from_slice(&action_bytes);
|
||||
if let Some(index) = index {
|
||||
bytes.extend_from_slice(index);
|
||||
}
|
||||
|
||||
Some(Cow::Owned(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_key_as_hexa(uid: Uuid, master_key: &[u8]) -> String {
|
||||
// format uid as hyphenated allowing user to generate their own keys.
|
||||
let mut uid_buffer = [0; Hyphenated::LENGTH];
|
||||
let uid = uid.hyphenated().encode_lower(&mut uid_buffer);
|
||||
|
||||
// new_from_slice function never fail.
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(master_key).unwrap();
|
||||
mac.update(uid.as_bytes());
|
||||
|
||||
let result = mac.finalize();
|
||||
format!("{:x}", result.into_bytes())
|
||||
}
|
||||
|
||||
/// Divides one slice into two at an index, returns `None` if mid is out of bounds.
|
||||
pub fn try_split_at<T>(slice: &[T], mid: usize) -> Option<(&[T], &[T])> {
|
||||
if mid <= slice.len() {
|
||||
Some(slice.split_at(mid))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Divides one slice into an array and the tail at an index,
|
||||
/// returns `None` if `N` is out of bounds.
|
||||
pub fn try_split_array_at<T, const N: usize>(slice: &[T]) -> Option<(&[T; N], &[T])>
|
||||
where
|
||||
[T; N]: for<'a> TryFrom<&'a [T]>,
|
||||
{
|
||||
let (head, tail) = try_split_at(slice, N)?;
|
||||
let head = head.try_into().ok()?;
|
||||
Some((head, tail))
|
||||
}
|
||||
109
meilisearch-http/Cargo.toml
Normal file
109
meilisearch-http/Cargo.toml
Normal file
@@ -0,0 +1,109 @@
|
||||
[package]
|
||||
authors = ["Quentin de Quelen <quentin@dequelen.me>", "Clément Renault <clement@meilisearch.com>"]
|
||||
description = "Meilisearch HTTP server"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
name = "meilisearch-http"
|
||||
version = "0.28.1"
|
||||
|
||||
[[bin]]
|
||||
name = "meilisearch"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = { version = "1.0.56", optional = true }
|
||||
cargo_toml = { version = "0.11.4", optional = true }
|
||||
hex = { version = "0.4.3", optional = true }
|
||||
reqwest = { version = "0.11.9", features = ["blocking", "rustls-tls"], default-features = false, optional = true }
|
||||
sha-1 = { version = "0.10.0", optional = true }
|
||||
static-files = { version = "0.2.3", optional = true }
|
||||
tempfile = { version = "3.3.0", optional = true }
|
||||
vergen = { version = "7.0.0", default-features = false, features = ["git"] }
|
||||
zip = { version = "0.5.13", optional = true }
|
||||
|
||||
[dependencies]
|
||||
actix-cors = "0.6.1"
|
||||
actix-web = { version = "4.0.1", default-features = false, features = ["macros", "compress-brotli", "compress-gzip", "cookies", "rustls"] }
|
||||
actix-web-static-files = { git = "https://github.com/kilork/actix-web-static-files.git", rev = "2d3b6160", optional = true }
|
||||
anyhow = { version = "1.0.56", features = ["backtrace"] }
|
||||
async-stream = "0.3.3"
|
||||
async-trait = "0.1.52"
|
||||
bstr = "0.2.17"
|
||||
byte-unit = { version = "4.0.14", default-features = false, features = ["std", "serde"] }
|
||||
bytes = "1.1.0"
|
||||
clap = { version = "3.1.6", features = ["derive", "env"] }
|
||||
crossbeam-channel = "0.5.2"
|
||||
either = "1.6.1"
|
||||
env_logger = "0.9.0"
|
||||
flate2 = "1.0.22"
|
||||
fst = "0.4.7"
|
||||
futures = "0.3.21"
|
||||
futures-util = "0.3.21"
|
||||
http = "0.2.6"
|
||||
indexmap = { version = "1.8.0", features = ["serde-1"] }
|
||||
itertools = "0.10.3"
|
||||
jsonwebtoken = "8.0.1"
|
||||
log = "0.4.14"
|
||||
meilisearch-auth = { path = "../meilisearch-auth" }
|
||||
meilisearch-types = { path = "../meilisearch-types" }
|
||||
meilisearch-lib = { path = "../meilisearch-lib" }
|
||||
mime = "0.3.16"
|
||||
num_cpus = "1.13.1"
|
||||
obkv = "0.2.0"
|
||||
once_cell = "1.10.0"
|
||||
parking_lot = "0.12.0"
|
||||
pin-project-lite = "0.2.8"
|
||||
platform-dirs = "0.3.0"
|
||||
rand = "0.8.5"
|
||||
rayon = "1.5.1"
|
||||
regex = "1.5.5"
|
||||
reqwest = { version = "0.11.4", features = ["rustls-tls", "json"], default-features = false }
|
||||
rustls = "0.20.4"
|
||||
rustls-pemfile = "0.3.0"
|
||||
segment = { version = "0.2.0", optional = true }
|
||||
serde = { version = "1.0.136", features = ["derive"] }
|
||||
serde-cs = "0.2.3"
|
||||
serde_json = { version = "1.0.79", features = ["preserve_order"] }
|
||||
sha2 = "0.10.2"
|
||||
siphasher = "0.3.10"
|
||||
slice-group-by = "0.3.0"
|
||||
static-files = { version = "0.2.3", optional = true }
|
||||
sysinfo = "0.23.5"
|
||||
tar = "0.4.38"
|
||||
tempfile = "3.3.0"
|
||||
thiserror = "1.0.30"
|
||||
time = { version = "0.3.7", features = ["serde-well-known", "formatting", "parsing", "macros"] }
|
||||
tokio = { version = "1.17.0", features = ["full"] }
|
||||
tokio-stream = "0.1.8"
|
||||
uuid = { version = "1.1.2", features = ["serde", "v4"] }
|
||||
walkdir = "2.3.2"
|
||||
|
||||
[dev-dependencies]
|
||||
actix-rt = "2.7.0"
|
||||
assert-json-diff = "2.0.1"
|
||||
manifest-dir-macros = "0.1.14"
|
||||
maplit = "1.0.2"
|
||||
urlencoding = "2.1.0"
|
||||
yaup = "0.2.0"
|
||||
|
||||
[features]
|
||||
default = ["analytics", "mini-dashboard"]
|
||||
analytics = ["segment"]
|
||||
mini-dashboard = [
|
||||
"actix-web-static-files",
|
||||
"static-files",
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"hex",
|
||||
"reqwest",
|
||||
"sha-1",
|
||||
"tempfile",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tikv-jemallocator = "0.4.3"
|
||||
|
||||
[package.metadata.mini-dashboard]
|
||||
assets-url = "https://github.com/meilisearch/mini-dashboard/releases/download/v0.2.1/build.zip"
|
||||
sha1 = "05a02ff13c3982091884a3f81d28bf53e72607b2"
|
||||
86
meilisearch-http/build.rs
Normal file
86
meilisearch-http/build.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use vergen::{vergen, Config};
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = vergen(Config::default()) {
|
||||
println!("cargo:warning=vergen: {}", e);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mini-dashboard")]
|
||||
mini_dashboard::setup_mini_dashboard().expect("Could not load the mini-dashboard assets");
|
||||
}
|
||||
|
||||
#[cfg(feature = "mini-dashboard")]
|
||||
mod mini_dashboard {
|
||||
use std::env;
|
||||
use std::fs::{create_dir_all, File, OpenOptions};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use cargo_toml::Manifest;
|
||||
use reqwest::blocking::get;
|
||||
use sha1::{Digest, Sha1};
|
||||
use static_files::resource_dir;
|
||||
|
||||
pub fn setup_mini_dashboard() -> anyhow::Result<()> {
|
||||
let cargo_manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let cargo_toml = cargo_manifest_dir.join("Cargo.toml");
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
|
||||
let sha1_path = out_dir.join(".mini-dashboard.sha1");
|
||||
let dashboard_dir = out_dir.join("mini-dashboard");
|
||||
|
||||
let manifest = Manifest::from_path(cargo_toml).unwrap();
|
||||
|
||||
let meta = &manifest
|
||||
.package
|
||||
.as_ref()
|
||||
.context("package not specified in Cargo.toml")?
|
||||
.metadata
|
||||
.as_ref()
|
||||
.context("no metadata specified in Cargo.toml")?["mini-dashboard"];
|
||||
|
||||
// Check if there already is a dashboard built, and if it is up to date.
|
||||
if sha1_path.exists() && dashboard_dir.exists() {
|
||||
let mut sha1_file = File::open(&sha1_path)?;
|
||||
let mut sha1 = String::new();
|
||||
sha1_file.read_to_string(&mut sha1)?;
|
||||
if sha1 == meta["sha1"].as_str().unwrap() {
|
||||
// Nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let url = meta["assets-url"].as_str().unwrap();
|
||||
|
||||
let dashboard_assets_bytes = get(url)?.bytes()?;
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&dashboard_assets_bytes);
|
||||
let sha1 = hex::encode(hasher.finalize());
|
||||
|
||||
assert_eq!(
|
||||
meta["sha1"].as_str().unwrap(),
|
||||
sha1,
|
||||
"Downloaded mini-dashboard shasum differs from the one specified in the Cargo.toml"
|
||||
);
|
||||
|
||||
create_dir_all(&dashboard_dir)?;
|
||||
let cursor = Cursor::new(&dashboard_assets_bytes);
|
||||
let mut zip = zip::read::ZipArchive::new(cursor)?;
|
||||
zip.extract(&dashboard_dir)?;
|
||||
resource_dir(&dashboard_dir).build()?;
|
||||
|
||||
// Write the sha1 for the dashboard back to file.
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(sha1_path)?;
|
||||
|
||||
file.write_all(sha1.as_bytes())?;
|
||||
file.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
51
meilisearch-http/src/analytics/mock_analytics.rs
Normal file
51
meilisearch-http/src/analytics/mock_analytics.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::{any::Any, sync::Arc};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{routes::indexes::documents::UpdateDocumentsQuery, Opt};
|
||||
|
||||
use super::{find_user_id, Analytics};
|
||||
|
||||
pub struct MockAnalytics;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SearchAggregator {}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl SearchAggregator {
|
||||
pub fn from_query(_: &dyn Any, _: &dyn Any) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn succeed(&mut self, _: &dyn Any) {}
|
||||
}
|
||||
|
||||
impl MockAnalytics {
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new(opt: &Opt) -> (Arc<dyn Analytics>, String) {
|
||||
let user = find_user_id(&opt.db_path).unwrap_or_default();
|
||||
(Arc::new(Self), user)
|
||||
}
|
||||
}
|
||||
|
||||
impl Analytics for MockAnalytics {
|
||||
// These methods are noop and should be optimized out
|
||||
fn publish(&self, _event_name: String, _send: Value, _request: Option<&HttpRequest>) {}
|
||||
fn get_search(&self, _aggregate: super::SearchAggregator) {}
|
||||
fn post_search(&self, _aggregate: super::SearchAggregator) {}
|
||||
fn add_documents(
|
||||
&self,
|
||||
_documents_query: &UpdateDocumentsQuery,
|
||||
_index_creation: bool,
|
||||
_request: &HttpRequest,
|
||||
) {
|
||||
}
|
||||
fn update_documents(
|
||||
&self,
|
||||
_documents_query: &UpdateDocumentsQuery,
|
||||
_index_creation: bool,
|
||||
_request: &HttpRequest,
|
||||
) {
|
||||
}
|
||||
}
|
||||
84
meilisearch-http/src/analytics/mod.rs
Normal file
84
meilisearch-http/src/analytics/mod.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
mod mock_analytics;
|
||||
// if we are in release mode and the feature analytics was enabled
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
mod segment_analytics;
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use once_cell::sync::Lazy;
|
||||
use platform_dirs::AppDirs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::routes::indexes::documents::UpdateDocumentsQuery;
|
||||
|
||||
pub use mock_analytics::MockAnalytics;
|
||||
|
||||
// if we are in debug mode OR the analytics feature is disabled
|
||||
// the `SegmentAnalytics` point to the mock instead of the real analytics
|
||||
#[cfg(any(debug_assertions, not(feature = "analytics")))]
|
||||
pub type SegmentAnalytics = mock_analytics::MockAnalytics;
|
||||
#[cfg(any(debug_assertions, not(feature = "analytics")))]
|
||||
pub type SearchAggregator = mock_analytics::SearchAggregator;
|
||||
|
||||
// if we are in release mode and the feature analytics was enabled
|
||||
// we use the real analytics
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
pub type SegmentAnalytics = segment_analytics::SegmentAnalytics;
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
pub type SearchAggregator = segment_analytics::SearchAggregator;
|
||||
|
||||
/// The Meilisearch config dir:
|
||||
/// `~/.config/Meilisearch` on *NIX or *BSD.
|
||||
/// `~/Library/ApplicationSupport` on macOS.
|
||||
/// `%APPDATA` (= `C:\Users%USERNAME%\AppData\Roaming`) on windows.
|
||||
static MEILISEARCH_CONFIG_PATH: Lazy<Option<PathBuf>> =
|
||||
Lazy::new(|| AppDirs::new(Some("Meilisearch"), false).map(|appdir| appdir.config_dir));
|
||||
|
||||
fn config_user_id_path(db_path: &Path) -> Option<PathBuf> {
|
||||
db_path
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.map(|path| {
|
||||
path.join("instance-uid")
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('/', "-")
|
||||
})
|
||||
.zip(MEILISEARCH_CONFIG_PATH.as_ref())
|
||||
.map(|(filename, config_path)| config_path.join(filename.trim_start_matches('-')))
|
||||
}
|
||||
|
||||
/// Look for the instance-uid in the `data.ms` or in `~/.config/Meilisearch/path-to-db-instance-uid`
|
||||
fn find_user_id(db_path: &Path) -> Option<String> {
|
||||
fs::read_to_string(db_path.join("instance-uid"))
|
||||
.ok()
|
||||
.or_else(|| fs::read_to_string(&config_user_id_path(db_path)?).ok())
|
||||
}
|
||||
|
||||
pub trait Analytics: Sync + Send {
|
||||
/// The method used to publish most analytics that do not need to be batched every hours
|
||||
fn publish(&self, event_name: String, send: Value, request: Option<&HttpRequest>);
|
||||
|
||||
/// This method should be called to aggregate a get search
|
||||
fn get_search(&self, aggregate: SearchAggregator);
|
||||
|
||||
/// This method should be called to aggregate a post search
|
||||
fn post_search(&self, aggregate: SearchAggregator);
|
||||
|
||||
// this method should be called to aggregate a add documents request
|
||||
fn add_documents(
|
||||
&self,
|
||||
documents_query: &UpdateDocumentsQuery,
|
||||
index_creation: bool,
|
||||
request: &HttpRequest,
|
||||
);
|
||||
// this method should be called to batch a update documents request
|
||||
fn update_documents(
|
||||
&self,
|
||||
documents_query: &UpdateDocumentsQuery,
|
||||
index_creation: bool,
|
||||
request: &HttpRequest,
|
||||
);
|
||||
}
|
||||
626
meilisearch-http/src/analytics/segment_analytics.rs
Normal file
626
meilisearch-http/src/analytics/segment_analytics.rs
Normal file
@@ -0,0 +1,626 @@
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use actix_web::http::header::USER_AGENT;
|
||||
use actix_web::HttpRequest;
|
||||
use http::header::CONTENT_TYPE;
|
||||
use meilisearch_auth::SearchRules;
|
||||
use meilisearch_lib::index::{
|
||||
SearchQuery, SearchResult, DEFAULT_CROP_LENGTH, DEFAULT_CROP_MARKER,
|
||||
DEFAULT_HIGHLIGHT_POST_TAG, DEFAULT_HIGHLIGHT_PRE_TAG,
|
||||
};
|
||||
use meilisearch_lib::index_controller::Stats;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use segment::message::{Identify, Track, User};
|
||||
use segment::{AutoBatcher, Batcher, HttpClient};
|
||||
use serde_json::{json, Value};
|
||||
use sysinfo::{DiskExt, System, SystemExt};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::{self, Receiver, Sender};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::Analytics;
|
||||
use crate::routes::indexes::documents::UpdateDocumentsQuery;
|
||||
use crate::Opt;
|
||||
|
||||
use super::{config_user_id_path, MEILISEARCH_CONFIG_PATH};
|
||||
|
||||
const ANALYTICS_HEADER: &str = "X-Meilisearch-Client";
|
||||
|
||||
/// Write the instance-uid in the `data.ms` and in `~/.config/MeiliSearch/path-to-db-instance-uid`. Ignore the errors.
|
||||
fn write_user_id(db_path: &Path, user_id: &str) {
|
||||
let _ = fs::write(db_path.join("instance-uid"), user_id.as_bytes());
|
||||
if let Some((meilisearch_config_path, user_id_path)) = MEILISEARCH_CONFIG_PATH
|
||||
.as_ref()
|
||||
.zip(config_user_id_path(db_path))
|
||||
{
|
||||
let _ = fs::create_dir_all(&meilisearch_config_path);
|
||||
let _ = fs::write(user_id_path, user_id.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
const SEGMENT_API_KEY: &str = "P3FWhhEsJiEDCuEHpmcN9DHcK4hVfBvb";
|
||||
|
||||
pub fn extract_user_agents(request: &HttpRequest) -> Vec<String> {
|
||||
request
|
||||
.headers()
|
||||
.get(ANALYTICS_HEADER)
|
||||
.or_else(|| request.headers().get(USER_AGENT))
|
||||
.map(|header| header.to_str().ok())
|
||||
.flatten()
|
||||
.unwrap_or("unknown")
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub enum AnalyticsMsg {
|
||||
BatchMessage(Track),
|
||||
AggregateGetSearch(SearchAggregator),
|
||||
AggregatePostSearch(SearchAggregator),
|
||||
AggregateAddDocuments(DocumentsAggregator),
|
||||
AggregateUpdateDocuments(DocumentsAggregator),
|
||||
}
|
||||
|
||||
pub struct SegmentAnalytics {
|
||||
sender: Sender<AnalyticsMsg>,
|
||||
user: User,
|
||||
}
|
||||
|
||||
impl SegmentAnalytics {
|
||||
pub async fn new(opt: &Opt, meilisearch: &MeiliSearch) -> (Arc<dyn Analytics>, String) {
|
||||
let user_id = super::find_user_id(&opt.db_path);
|
||||
let first_time_run = user_id.is_none();
|
||||
let user_id = user_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
write_user_id(&opt.db_path, &user_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build();
|
||||
|
||||
// if reqwest throws an error we won't be able to send analytics
|
||||
if client.is_err() {
|
||||
return super::MockAnalytics::new(opt);
|
||||
}
|
||||
|
||||
let client = HttpClient::new(
|
||||
client.unwrap(),
|
||||
"https://telemetry.meilisearch.com".to_string(),
|
||||
);
|
||||
let user = User::UserId { user_id };
|
||||
let mut batcher = AutoBatcher::new(client, Batcher::new(None), SEGMENT_API_KEY.to_string());
|
||||
|
||||
// If Meilisearch is Launched for the first time:
|
||||
// 1. Send an event Launched associated to the user `total_launch`.
|
||||
// 2. Batch an event Launched with the real instance-id and send it in one hour.
|
||||
if first_time_run {
|
||||
let _ = batcher
|
||||
.push(Track {
|
||||
user: User::UserId {
|
||||
user_id: "total_launch".to_string(),
|
||||
},
|
||||
event: "Launched".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let _ = batcher.flush().await;
|
||||
let _ = batcher
|
||||
.push(Track {
|
||||
user: user.clone(),
|
||||
event: "Launched".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let (sender, inbox) = mpsc::channel(100); // How many analytics can we bufferize
|
||||
|
||||
let segment = Box::new(Segment {
|
||||
inbox,
|
||||
user: user.clone(),
|
||||
opt: opt.clone(),
|
||||
batcher,
|
||||
post_search_aggregator: SearchAggregator::default(),
|
||||
get_search_aggregator: SearchAggregator::default(),
|
||||
add_documents_aggregator: DocumentsAggregator::default(),
|
||||
update_documents_aggregator: DocumentsAggregator::default(),
|
||||
});
|
||||
tokio::spawn(segment.run(meilisearch.clone()));
|
||||
|
||||
let this = Self {
|
||||
sender,
|
||||
user: user.clone(),
|
||||
};
|
||||
|
||||
(Arc::new(this), user.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Analytics for SegmentAnalytics {
|
||||
fn publish(&self, event_name: String, mut send: Value, request: Option<&HttpRequest>) {
|
||||
let user_agent = request.map(|req| extract_user_agents(req));
|
||||
|
||||
send["user-agent"] = json!(user_agent);
|
||||
let event = Track {
|
||||
user: self.user.clone(),
|
||||
event: event_name.clone(),
|
||||
properties: send,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = self
|
||||
.sender
|
||||
.try_send(AnalyticsMsg::BatchMessage(event.into()));
|
||||
}
|
||||
|
||||
fn get_search(&self, aggregate: SearchAggregator) {
|
||||
let _ = self
|
||||
.sender
|
||||
.try_send(AnalyticsMsg::AggregateGetSearch(aggregate));
|
||||
}
|
||||
|
||||
fn post_search(&self, aggregate: SearchAggregator) {
|
||||
let _ = self
|
||||
.sender
|
||||
.try_send(AnalyticsMsg::AggregatePostSearch(aggregate));
|
||||
}
|
||||
|
||||
fn add_documents(
|
||||
&self,
|
||||
documents_query: &UpdateDocumentsQuery,
|
||||
index_creation: bool,
|
||||
request: &HttpRequest,
|
||||
) {
|
||||
let aggregate = DocumentsAggregator::from_query(documents_query, index_creation, request);
|
||||
let _ = self
|
||||
.sender
|
||||
.try_send(AnalyticsMsg::AggregateAddDocuments(aggregate));
|
||||
}
|
||||
|
||||
fn update_documents(
|
||||
&self,
|
||||
documents_query: &UpdateDocumentsQuery,
|
||||
index_creation: bool,
|
||||
request: &HttpRequest,
|
||||
) {
|
||||
let aggregate = DocumentsAggregator::from_query(documents_query, index_creation, request);
|
||||
let _ = self
|
||||
.sender
|
||||
.try_send(AnalyticsMsg::AggregateUpdateDocuments(aggregate));
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Segment {
|
||||
inbox: Receiver<AnalyticsMsg>,
|
||||
user: User,
|
||||
opt: Opt,
|
||||
batcher: AutoBatcher,
|
||||
get_search_aggregator: SearchAggregator,
|
||||
post_search_aggregator: SearchAggregator,
|
||||
add_documents_aggregator: DocumentsAggregator,
|
||||
update_documents_aggregator: DocumentsAggregator,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
fn compute_traits(opt: &Opt, stats: Stats) -> Value {
|
||||
static FIRST_START_TIMESTAMP: Lazy<Instant> = Lazy::new(Instant::now);
|
||||
static SYSTEM: Lazy<Value> = Lazy::new(|| {
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
let kernel_version = sys
|
||||
.kernel_version()
|
||||
.map(|k| k.split_once("-").map(|(k, _)| k.to_string()))
|
||||
.flatten();
|
||||
json!({
|
||||
"distribution": sys.name(),
|
||||
"kernel_version": kernel_version,
|
||||
"cores": sys.processors().len(),
|
||||
"ram_size": sys.total_memory(),
|
||||
"disk_size": sys.disks().iter().map(|disk| disk.total_space()).max(),
|
||||
"server_provider": std::env::var("MEILI_SERVER_PROVIDER").ok(),
|
||||
})
|
||||
});
|
||||
// The infos are all cli option except every option containing sensitive information.
|
||||
// We consider an information as sensible if it contains a path, an address or a key.
|
||||
let infos = {
|
||||
// First we see if any sensitive fields were used.
|
||||
let db_path = opt.db_path != PathBuf::from("./data.ms");
|
||||
let import_dump = opt.import_dump.is_some();
|
||||
let dumps_dir = opt.dumps_dir != PathBuf::from("dumps/");
|
||||
let import_snapshot = opt.import_snapshot.is_some();
|
||||
let snapshots_dir = opt.snapshot_dir != PathBuf::from("snapshots/");
|
||||
let http_addr = opt.http_addr != "127.0.0.1:7700";
|
||||
|
||||
let mut infos = serde_json::to_value(opt).unwrap();
|
||||
|
||||
// Then we overwrite all sensitive field with a boolean representing if
|
||||
// the feature was used or not.
|
||||
infos["db_path"] = json!(db_path);
|
||||
infos["import_dump"] = json!(import_dump);
|
||||
infos["dumps_dir"] = json!(dumps_dir);
|
||||
infos["import_snapshot"] = json!(import_snapshot);
|
||||
infos["snapshot_dir"] = json!(snapshots_dir);
|
||||
infos["http_addr"] = json!(http_addr);
|
||||
|
||||
infos
|
||||
};
|
||||
|
||||
let number_of_documents = stats
|
||||
.indexes
|
||||
.values()
|
||||
.map(|index| index.number_of_documents)
|
||||
.collect::<Vec<u64>>();
|
||||
|
||||
json!({
|
||||
"start_since_days": FIRST_START_TIMESTAMP.elapsed().as_secs() / (60 * 60 * 24), // one day
|
||||
"system": *SYSTEM,
|
||||
"stats": {
|
||||
"database_size": stats.database_size,
|
||||
"indexes_number": stats.indexes.len(),
|
||||
"documents_number": number_of_documents,
|
||||
},
|
||||
"infos": infos,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run(mut self, meilisearch: MeiliSearch) {
|
||||
const INTERVAL: Duration = Duration::from_secs(60 * 60); // one hour
|
||||
// The first batch must be sent after one hour.
|
||||
let mut interval =
|
||||
tokio::time::interval_at(tokio::time::Instant::now() + INTERVAL, INTERVAL);
|
||||
|
||||
loop {
|
||||
select! {
|
||||
_ = interval.tick() => {
|
||||
self.tick(meilisearch.clone()).await;
|
||||
},
|
||||
msg = self.inbox.recv() => {
|
||||
match msg {
|
||||
Some(AnalyticsMsg::BatchMessage(msg)) => drop(self.batcher.push(msg).await),
|
||||
Some(AnalyticsMsg::AggregateGetSearch(agreg)) => self.get_search_aggregator.aggregate(agreg),
|
||||
Some(AnalyticsMsg::AggregatePostSearch(agreg)) => self.post_search_aggregator.aggregate(agreg),
|
||||
Some(AnalyticsMsg::AggregateAddDocuments(agreg)) => self.add_documents_aggregator.aggregate(agreg),
|
||||
Some(AnalyticsMsg::AggregateUpdateDocuments(agreg)) => self.update_documents_aggregator.aggregate(agreg),
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&mut self, meilisearch: MeiliSearch) {
|
||||
if let Ok(stats) = meilisearch.get_all_stats(&SearchRules::default()).await {
|
||||
let _ = self
|
||||
.batcher
|
||||
.push(Identify {
|
||||
context: Some(json!({
|
||||
"app": {
|
||||
"version": env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
})),
|
||||
user: self.user.clone(),
|
||||
traits: Self::compute_traits(&self.opt, stats),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let get_search = std::mem::take(&mut self.get_search_aggregator)
|
||||
.into_event(&self.user, "Documents Searched GET");
|
||||
let post_search = std::mem::take(&mut self.post_search_aggregator)
|
||||
.into_event(&self.user, "Documents Searched POST");
|
||||
let add_documents = std::mem::take(&mut self.add_documents_aggregator)
|
||||
.into_event(&self.user, "Documents Added");
|
||||
let update_documents = std::mem::take(&mut self.update_documents_aggregator)
|
||||
.into_event(&self.user, "Documents Updated");
|
||||
|
||||
if let Some(get_search) = get_search {
|
||||
let _ = self.batcher.push(get_search).await;
|
||||
}
|
||||
if let Some(post_search) = post_search {
|
||||
let _ = self.batcher.push(post_search).await;
|
||||
}
|
||||
if let Some(add_documents) = add_documents {
|
||||
let _ = self.batcher.push(add_documents).await;
|
||||
}
|
||||
if let Some(update_documents) = update_documents {
|
||||
let _ = self.batcher.push(update_documents).await;
|
||||
}
|
||||
let _ = self.batcher.flush().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SearchAggregator {
|
||||
timestamp: Option<OffsetDateTime>,
|
||||
|
||||
// context
|
||||
user_agents: HashSet<String>,
|
||||
|
||||
// requests
|
||||
total_received: usize,
|
||||
total_succeeded: usize,
|
||||
time_spent: BinaryHeap<usize>,
|
||||
|
||||
// sort
|
||||
sort_with_geo_point: bool,
|
||||
// everytime a request has a filter, this field must be incremented by the number of terms it contains
|
||||
sort_sum_of_criteria_terms: usize,
|
||||
// everytime a request has a filter, this field must be incremented by one
|
||||
sort_total_number_of_criteria: usize,
|
||||
|
||||
// filter
|
||||
filter_with_geo_radius: bool,
|
||||
// everytime a request has a filter, this field must be incremented by the number of terms it contains
|
||||
filter_sum_of_criteria_terms: usize,
|
||||
// everytime a request has a filter, this field must be incremented by one
|
||||
filter_total_number_of_criteria: usize,
|
||||
used_syntax: HashMap<String, usize>,
|
||||
|
||||
// q
|
||||
// The maximum number of terms in a q request
|
||||
max_terms_number: usize,
|
||||
|
||||
// pagination
|
||||
max_limit: usize,
|
||||
max_offset: usize,
|
||||
|
||||
// formatting
|
||||
highlight_pre_tag: bool,
|
||||
highlight_post_tag: bool,
|
||||
crop_marker: bool,
|
||||
show_matches_position: bool,
|
||||
crop_length: bool,
|
||||
}
|
||||
|
||||
impl SearchAggregator {
|
||||
pub fn from_query(query: &SearchQuery, request: &HttpRequest) -> Self {
|
||||
let mut ret = Self::default();
|
||||
ret.timestamp = Some(OffsetDateTime::now_utc());
|
||||
|
||||
ret.total_received = 1;
|
||||
ret.user_agents = extract_user_agents(request).into_iter().collect();
|
||||
|
||||
if let Some(ref sort) = query.sort {
|
||||
ret.sort_total_number_of_criteria = 1;
|
||||
ret.sort_with_geo_point = sort.iter().any(|s| s.contains("_geoPoint("));
|
||||
ret.sort_sum_of_criteria_terms = sort.len();
|
||||
}
|
||||
|
||||
if let Some(ref filter) = query.filter {
|
||||
static RE: Lazy<Regex> = Lazy::new(|| Regex::new("AND | OR").unwrap());
|
||||
ret.filter_total_number_of_criteria = 1;
|
||||
|
||||
let syntax = match filter {
|
||||
Value::String(_) => "string".to_string(),
|
||||
Value::Array(values) => {
|
||||
if values
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.any(|s| RE.is_match(&s))
|
||||
{
|
||||
"mixed".to_string()
|
||||
} else {
|
||||
"array".to_string()
|
||||
}
|
||||
}
|
||||
_ => "none".to_string(),
|
||||
};
|
||||
// convert the string to a HashMap
|
||||
ret.used_syntax.insert(syntax, 1);
|
||||
|
||||
let stringified_filters = filter.to_string();
|
||||
ret.filter_with_geo_radius = stringified_filters.contains("_geoRadius(");
|
||||
ret.filter_sum_of_criteria_terms = RE.split(&stringified_filters).count();
|
||||
}
|
||||
|
||||
if let Some(ref q) = query.q {
|
||||
ret.max_terms_number = q.split_whitespace().count();
|
||||
}
|
||||
|
||||
ret.max_limit = query.limit;
|
||||
ret.max_offset = query.offset.unwrap_or_default();
|
||||
|
||||
ret.highlight_pre_tag = query.highlight_pre_tag != DEFAULT_HIGHLIGHT_PRE_TAG();
|
||||
ret.highlight_post_tag = query.highlight_post_tag != DEFAULT_HIGHLIGHT_POST_TAG();
|
||||
ret.crop_marker = query.crop_marker != DEFAULT_CROP_MARKER();
|
||||
ret.crop_length = query.crop_length != DEFAULT_CROP_LENGTH();
|
||||
ret.show_matches_position = query.show_matches_position;
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
pub fn succeed(&mut self, result: &SearchResult) {
|
||||
self.total_succeeded = self.total_succeeded.saturating_add(1);
|
||||
self.time_spent.push(result.processing_time_ms as usize);
|
||||
}
|
||||
|
||||
/// Aggregate one [SearchAggregator] into another.
|
||||
pub fn aggregate(&mut self, mut other: Self) {
|
||||
if self.timestamp.is_none() {
|
||||
self.timestamp = other.timestamp;
|
||||
}
|
||||
|
||||
// context
|
||||
for user_agent in other.user_agents.into_iter() {
|
||||
self.user_agents.insert(user_agent);
|
||||
}
|
||||
// request
|
||||
self.total_received = self.total_received.saturating_add(other.total_received);
|
||||
self.total_succeeded = self.total_succeeded.saturating_add(other.total_succeeded);
|
||||
self.time_spent.append(&mut other.time_spent);
|
||||
// sort
|
||||
self.sort_with_geo_point |= other.sort_with_geo_point;
|
||||
self.sort_sum_of_criteria_terms = self
|
||||
.sort_sum_of_criteria_terms
|
||||
.saturating_add(other.sort_sum_of_criteria_terms);
|
||||
self.sort_total_number_of_criteria = self
|
||||
.sort_total_number_of_criteria
|
||||
.saturating_add(other.sort_total_number_of_criteria);
|
||||
// filter
|
||||
self.filter_with_geo_radius |= other.filter_with_geo_radius;
|
||||
self.filter_sum_of_criteria_terms = self
|
||||
.filter_sum_of_criteria_terms
|
||||
.saturating_add(other.filter_sum_of_criteria_terms);
|
||||
self.filter_total_number_of_criteria = self
|
||||
.filter_total_number_of_criteria
|
||||
.saturating_add(other.filter_total_number_of_criteria);
|
||||
for (key, value) in other.used_syntax.into_iter() {
|
||||
let used_syntax = self.used_syntax.entry(key).or_insert(0);
|
||||
*used_syntax = used_syntax.saturating_add(value);
|
||||
}
|
||||
// q
|
||||
self.max_terms_number = self.max_terms_number.max(other.max_terms_number);
|
||||
// pagination
|
||||
self.max_limit = self.max_limit.max(other.max_limit);
|
||||
self.max_offset = self.max_offset.max(other.max_offset);
|
||||
|
||||
self.highlight_pre_tag |= other.highlight_pre_tag;
|
||||
self.highlight_post_tag |= other.highlight_post_tag;
|
||||
self.crop_marker |= other.crop_marker;
|
||||
self.show_matches_position |= other.show_matches_position;
|
||||
self.crop_length |= other.crop_length;
|
||||
}
|
||||
|
||||
pub fn into_event(self, user: &User, event_name: &str) -> Option<Track> {
|
||||
if self.total_received == 0 {
|
||||
None
|
||||
} else {
|
||||
// the index of the 99th percentage of value
|
||||
let percentile_99th = 0.99 * (self.total_succeeded as f64 - 1.) + 1.;
|
||||
// we get all the values in a sorted manner
|
||||
let time_spent = self.time_spent.into_sorted_vec();
|
||||
// We are only interested by the slowest value of the 99th fastest results
|
||||
let time_spent = time_spent.get(percentile_99th as usize);
|
||||
|
||||
let properties = json!({
|
||||
"user-agent": self.user_agents,
|
||||
"requests": {
|
||||
"99th_response_time": time_spent.map(|t| format!("{:.2}", t)),
|
||||
"total_succeeded": self.total_succeeded,
|
||||
"total_failed": self.total_received.saturating_sub(self.total_succeeded), // just to be sure we never panics
|
||||
"total_received": self.total_received,
|
||||
},
|
||||
"sort": {
|
||||
"with_geoPoint": self.sort_with_geo_point,
|
||||
"avg_criteria_number": format!("{:.2}", self.sort_sum_of_criteria_terms as f64 / self.sort_total_number_of_criteria as f64),
|
||||
},
|
||||
"filter": {
|
||||
"with_geoRadius": self.filter_with_geo_radius,
|
||||
"avg_criteria_number": format!("{:.2}", self.filter_sum_of_criteria_terms as f64 / self.filter_total_number_of_criteria as f64),
|
||||
"most_used_syntax": self.used_syntax.iter().max_by_key(|(_, v)| *v).map(|(k, _)| json!(k)).unwrap_or_else(|| json!(null)),
|
||||
},
|
||||
"q": {
|
||||
"max_terms_number": self.max_terms_number,
|
||||
},
|
||||
"pagination": {
|
||||
"max_limit": self.max_limit,
|
||||
"max_offset": self.max_offset,
|
||||
},
|
||||
"formatting": {
|
||||
"highlight_pre_tag": self.highlight_pre_tag,
|
||||
"highlight_post_tag": self.highlight_post_tag,
|
||||
"crop_marker": self.crop_marker,
|
||||
"show_matches_position": self.show_matches_position,
|
||||
"crop_length": self.crop_length,
|
||||
},
|
||||
});
|
||||
|
||||
Some(Track {
|
||||
timestamp: self.timestamp,
|
||||
user: user.clone(),
|
||||
event: event_name.to_string(),
|
||||
properties,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DocumentsAggregator {
|
||||
timestamp: Option<OffsetDateTime>,
|
||||
|
||||
// set to true when at least one request was received
|
||||
updated: bool,
|
||||
|
||||
// context
|
||||
user_agents: HashSet<String>,
|
||||
|
||||
content_types: HashSet<String>,
|
||||
primary_keys: HashSet<String>,
|
||||
index_creation: bool,
|
||||
}
|
||||
|
||||
impl DocumentsAggregator {
|
||||
pub fn from_query(
|
||||
documents_query: &UpdateDocumentsQuery,
|
||||
index_creation: bool,
|
||||
request: &HttpRequest,
|
||||
) -> Self {
|
||||
let mut ret = Self::default();
|
||||
ret.timestamp = Some(OffsetDateTime::now_utc());
|
||||
|
||||
ret.updated = true;
|
||||
ret.user_agents = extract_user_agents(request).into_iter().collect();
|
||||
if let Some(primary_key) = documents_query.primary_key.clone() {
|
||||
ret.primary_keys.insert(primary_key);
|
||||
}
|
||||
let content_type = request
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|s| s.to_str().ok())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
ret.content_types.insert(content_type);
|
||||
ret.index_creation = index_creation;
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
/// Aggregate one [DocumentsAggregator] into another.
|
||||
pub fn aggregate(&mut self, other: Self) {
|
||||
if self.timestamp.is_none() {
|
||||
self.timestamp = other.timestamp;
|
||||
}
|
||||
|
||||
self.updated |= other.updated;
|
||||
// we can't create a union because there is no `into_union` method
|
||||
for user_agent in other.user_agents {
|
||||
self.user_agents.insert(user_agent);
|
||||
}
|
||||
for primary_key in other.primary_keys {
|
||||
self.primary_keys.insert(primary_key);
|
||||
}
|
||||
for content_type in other.content_types {
|
||||
self.content_types.insert(content_type);
|
||||
}
|
||||
self.index_creation |= other.index_creation;
|
||||
}
|
||||
|
||||
pub fn into_event(self, user: &User, event_name: &str) -> Option<Track> {
|
||||
if !self.updated {
|
||||
None
|
||||
} else {
|
||||
let properties = json!({
|
||||
"user-agent": self.user_agents,
|
||||
"payload_type": self.content_types,
|
||||
"primary_key": self.primary_keys,
|
||||
"index_creation": self.index_creation,
|
||||
});
|
||||
|
||||
Some(Track {
|
||||
timestamp: self.timestamp,
|
||||
user: user.clone(),
|
||||
event: event_name.to_string(),
|
||||
properties,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
98
meilisearch-http/src/error.rs
Normal file
98
meilisearch-http/src/error.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use actix_web as aweb;
|
||||
use aweb::error::{JsonPayloadError, QueryPayloadError};
|
||||
use meilisearch_types::error::{Code, ErrorCode, ResponseError};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MeilisearchHttpError {
|
||||
#[error("A Content-Type header is missing. Accepted values for the Content-Type header are: {}",
|
||||
.0.iter().map(|s| format!("`{}`", s)).collect::<Vec<_>>().join(", "))]
|
||||
MissingContentType(Vec<String>),
|
||||
#[error(
|
||||
"The Content-Type `{0}` is invalid. Accepted values for the Content-Type header are: {}",
|
||||
.1.iter().map(|s| format!("`{}`", s)).collect::<Vec<_>>().join(", ")
|
||||
)]
|
||||
InvalidContentType(String, Vec<String>),
|
||||
}
|
||||
|
||||
impl ErrorCode for MeilisearchHttpError {
|
||||
fn error_code(&self) -> Code {
|
||||
match self {
|
||||
MeilisearchHttpError::MissingContentType(_) => Code::MissingContentType,
|
||||
MeilisearchHttpError::InvalidContentType(_, _) => Code::InvalidContentType,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MeilisearchHttpError> for aweb::Error {
|
||||
fn from(other: MeilisearchHttpError) -> Self {
|
||||
aweb::Error::from(ResponseError::from(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PayloadError {
|
||||
#[error("{0}")]
|
||||
Json(JsonPayloadError),
|
||||
#[error("{0}")]
|
||||
Query(QueryPayloadError),
|
||||
#[error("The json payload provided is malformed. `{0}`.")]
|
||||
MalformedPayload(serde_json::error::Error),
|
||||
#[error("A json payload is missing.")]
|
||||
MissingPayload,
|
||||
}
|
||||
|
||||
impl ErrorCode for PayloadError {
|
||||
fn error_code(&self) -> Code {
|
||||
match self {
|
||||
PayloadError::Json(err) => match err {
|
||||
JsonPayloadError::Overflow { .. } => Code::PayloadTooLarge,
|
||||
JsonPayloadError::ContentType => Code::UnsupportedMediaType,
|
||||
JsonPayloadError::Payload(aweb::error::PayloadError::Overflow) => {
|
||||
Code::PayloadTooLarge
|
||||
}
|
||||
JsonPayloadError::Payload(_) => Code::BadRequest,
|
||||
JsonPayloadError::Deserialize(_) => Code::BadRequest,
|
||||
JsonPayloadError::Serialize(_) => Code::Internal,
|
||||
_ => Code::Internal,
|
||||
},
|
||||
PayloadError::Query(err) => match err {
|
||||
QueryPayloadError::Deserialize(_) => Code::BadRequest,
|
||||
_ => Code::Internal,
|
||||
},
|
||||
PayloadError::MissingPayload => Code::MissingPayload,
|
||||
PayloadError::MalformedPayload(_) => Code::MalformedPayload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsonPayloadError> for PayloadError {
|
||||
fn from(other: JsonPayloadError) -> Self {
|
||||
match other {
|
||||
JsonPayloadError::Deserialize(e)
|
||||
if e.classify() == serde_json::error::Category::Eof
|
||||
&& e.line() == 1
|
||||
&& e.column() == 0 =>
|
||||
{
|
||||
Self::MissingPayload
|
||||
}
|
||||
JsonPayloadError::Deserialize(e)
|
||||
if e.classify() != serde_json::error::Category::Data =>
|
||||
{
|
||||
Self::MalformedPayload(e)
|
||||
}
|
||||
_ => Self::Json(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QueryPayloadError> for PayloadError {
|
||||
fn from(other: QueryPayloadError) -> Self {
|
||||
Self::Query(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PayloadError> for aweb::Error {
|
||||
fn from(other: PayloadError) -> Self {
|
||||
aweb::Error::from(ResponseError::from(other))
|
||||
}
|
||||
}
|
||||
22
meilisearch-http/src/extractors/authentication/error.rs
Normal file
22
meilisearch-http/src/extractors/authentication/error.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use meilisearch_types::error::{Code, ErrorCode};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthenticationError {
|
||||
#[error("The Authorization header is missing. It must use the bearer authorization method.")]
|
||||
MissingAuthorizationHeader,
|
||||
#[error("The provided API key is invalid.")]
|
||||
InvalidToken,
|
||||
// Triggered on configuration error.
|
||||
#[error("An internal error has occurred. `Irretrievable state`.")]
|
||||
IrretrievableState,
|
||||
}
|
||||
|
||||
impl ErrorCode for AuthenticationError {
|
||||
fn error_code(&self) -> Code {
|
||||
match self {
|
||||
AuthenticationError::MissingAuthorizationHeader => Code::MissingAuthorizationHeader,
|
||||
AuthenticationError::InvalidToken => Code::InvalidToken,
|
||||
AuthenticationError::IrretrievableState => Code::Internal,
|
||||
}
|
||||
}
|
||||
}
|
||||
254
meilisearch-http/src/extractors/authentication/mod.rs
Normal file
254
meilisearch-http/src/extractors/authentication/mod.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
mod error;
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::ops::Deref;
|
||||
use std::pin::Pin;
|
||||
|
||||
use actix_web::FromRequest;
|
||||
use error::AuthenticationError;
|
||||
use futures::future::err;
|
||||
use futures::Future;
|
||||
use meilisearch_auth::{AuthController, AuthFilter};
|
||||
use meilisearch_types::error::{Code, ResponseError};
|
||||
|
||||
pub struct GuardedData<P, D> {
|
||||
data: D,
|
||||
filters: AuthFilter,
|
||||
_marker: PhantomData<P>,
|
||||
}
|
||||
|
||||
impl<P, D> GuardedData<P, D> {
|
||||
pub fn filters(&self) -> &AuthFilter {
|
||||
&self.filters
|
||||
}
|
||||
|
||||
async fn auth_bearer(
|
||||
auth: AuthController,
|
||||
token: String,
|
||||
index: Option<String>,
|
||||
data: Option<D>,
|
||||
) -> Result<Self, ResponseError>
|
||||
where
|
||||
P: Policy + 'static,
|
||||
{
|
||||
match Self::authenticate(auth, token, index).await? {
|
||||
Some(filters) => match data {
|
||||
Some(data) => Ok(Self {
|
||||
data,
|
||||
filters,
|
||||
_marker: PhantomData,
|
||||
}),
|
||||
None => Err(AuthenticationError::IrretrievableState.into()),
|
||||
},
|
||||
None => Err(AuthenticationError::InvalidToken.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_token(auth: AuthController, data: Option<D>) -> Result<Self, ResponseError>
|
||||
where
|
||||
P: Policy + 'static,
|
||||
{
|
||||
match Self::authenticate(auth, String::new(), None).await? {
|
||||
Some(filters) => match data {
|
||||
Some(data) => Ok(Self {
|
||||
data,
|
||||
filters,
|
||||
_marker: PhantomData,
|
||||
}),
|
||||
None => Err(AuthenticationError::IrretrievableState.into()),
|
||||
},
|
||||
None => Err(AuthenticationError::MissingAuthorizationHeader.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
auth: AuthController,
|
||||
token: String,
|
||||
index: Option<String>,
|
||||
) -> Result<Option<AuthFilter>, ResponseError>
|
||||
where
|
||||
P: Policy + 'static,
|
||||
{
|
||||
tokio::task::spawn_blocking(move || P::authenticate(auth, token.as_ref(), index.as_deref()))
|
||||
.await
|
||||
.map_err(|e| ResponseError::from_msg(e.to_string(), Code::Internal))
|
||||
}
|
||||
}
|
||||
|
||||
impl<P, D> Deref for GuardedData<P, D> {
|
||||
type Target = D;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.data
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Policy + 'static, D: 'static + Clone> FromRequest for GuardedData<P, D> {
|
||||
type Error = ResponseError;
|
||||
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||
|
||||
fn from_request(
|
||||
req: &actix_web::HttpRequest,
|
||||
_payload: &mut actix_web::dev::Payload,
|
||||
) -> Self::Future {
|
||||
match req.app_data::<AuthController>().cloned() {
|
||||
Some(auth) => match req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.map(|type_token| type_token.to_str().unwrap_or_default().splitn(2, ' '))
|
||||
{
|
||||
Some(mut type_token) => match type_token.next() {
|
||||
Some("Bearer") => {
|
||||
// TODO: find a less hardcoded way?
|
||||
let index = req.match_info().get("index_uid");
|
||||
match type_token.next() {
|
||||
Some(token) => Box::pin(Self::auth_bearer(
|
||||
auth,
|
||||
token.to_string(),
|
||||
index.map(String::from),
|
||||
req.app_data::<D>().cloned(),
|
||||
)),
|
||||
None => Box::pin(err(AuthenticationError::InvalidToken.into())),
|
||||
}
|
||||
}
|
||||
_otherwise => {
|
||||
Box::pin(err(AuthenticationError::MissingAuthorizationHeader.into()))
|
||||
}
|
||||
},
|
||||
None => Box::pin(Self::auth_token(auth, req.app_data::<D>().cloned())),
|
||||
},
|
||||
None => Box::pin(err(AuthenticationError::IrretrievableState.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Policy {
|
||||
fn authenticate(auth: AuthController, token: &str, index: Option<&str>) -> Option<AuthFilter>;
|
||||
}
|
||||
|
||||
pub mod policies {
|
||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::extractors::authentication::Policy;
|
||||
use meilisearch_auth::{Action, AuthController, AuthFilter, SearchRules};
|
||||
// reexport actions in policies in order to be used in routes configuration.
|
||||
pub use meilisearch_auth::actions;
|
||||
|
||||
fn tenant_token_validation() -> Validation {
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = false;
|
||||
validation.required_spec_claims.remove("exp");
|
||||
validation.algorithms = vec![Algorithm::HS256, Algorithm::HS384, Algorithm::HS512];
|
||||
validation
|
||||
}
|
||||
|
||||
/// Extracts the key id used to sign the payload, without performing any validation.
|
||||
fn extract_key_id(token: &str) -> Option<Uuid> {
|
||||
let mut validation = tenant_token_validation();
|
||||
validation.insecure_disable_signature_validation();
|
||||
let dummy_key = DecodingKey::from_secret(b"secret");
|
||||
let token_data = decode::<Claims>(token, &dummy_key, &validation).ok()?;
|
||||
|
||||
// get token fields without validating it.
|
||||
let Claims { api_key_uid, .. } = token_data.claims;
|
||||
Some(api_key_uid)
|
||||
}
|
||||
|
||||
fn is_keys_action(action: u8) -> bool {
|
||||
use actions::*;
|
||||
matches!(action, KEYS_GET | KEYS_CREATE | KEYS_UPDATE | KEYS_DELETE)
|
||||
}
|
||||
|
||||
pub struct ActionPolicy<const A: u8>;
|
||||
|
||||
impl<const A: u8> Policy for ActionPolicy<A> {
|
||||
fn authenticate(
|
||||
auth: AuthController,
|
||||
token: &str,
|
||||
index: Option<&str>,
|
||||
) -> Option<AuthFilter> {
|
||||
// authenticate if token is the master key.
|
||||
// master key can only have access to keys routes.
|
||||
// if master key is None only keys routes are inaccessible.
|
||||
if auth
|
||||
.get_master_key()
|
||||
.map_or_else(|| !is_keys_action(A), |mk| mk == token)
|
||||
{
|
||||
return Some(AuthFilter::default());
|
||||
}
|
||||
|
||||
// Tenant token
|
||||
if let Some(filters) = ActionPolicy::<A>::authenticate_tenant_token(&auth, token, index)
|
||||
{
|
||||
return Some(filters);
|
||||
} else if let Some(action) = Action::from_repr(A) {
|
||||
// API key
|
||||
if let Ok(Some(uid)) = auth.get_optional_uid_from_encoded_key(token.as_bytes()) {
|
||||
if let Ok(true) = auth.is_key_authorized(uid, action, index) {
|
||||
return auth.get_key_filters(uid, None).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<const A: u8> ActionPolicy<A> {
|
||||
fn authenticate_tenant_token(
|
||||
auth: &AuthController,
|
||||
token: &str,
|
||||
index: Option<&str>,
|
||||
) -> Option<AuthFilter> {
|
||||
// Only search action can be accessed by a tenant token.
|
||||
if A != actions::SEARCH {
|
||||
return None;
|
||||
}
|
||||
|
||||
let uid = extract_key_id(token)?;
|
||||
// check if parent key is authorized to do the action.
|
||||
if auth.is_key_authorized(uid, Action::Search, index).ok()? {
|
||||
// Check if tenant token is valid.
|
||||
let key = auth.generate_key(uid)?;
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(key.as_bytes()),
|
||||
&tenant_token_validation(),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
// Check index access if an index restriction is provided.
|
||||
if let Some(index) = index {
|
||||
if !data.claims.search_rules.is_index_authorized(index) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if token is expired.
|
||||
if let Some(exp) = data.claims.exp {
|
||||
if OffsetDateTime::now_utc().unix_timestamp() > exp {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
return auth
|
||||
.get_key_filters(uid, Some(data.claims.search_rules))
|
||||
.ok();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Claims {
|
||||
search_rules: SearchRules,
|
||||
exp: Option<i64>,
|
||||
api_key_uid: Uuid,
|
||||
}
|
||||
}
|
||||
4
meilisearch-http/src/extractors/mod.rs
Normal file
4
meilisearch-http/src/extractors/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod payload;
|
||||
#[macro_use]
|
||||
pub mod authentication;
|
||||
pub mod sequential_extractor;
|
||||
67
meilisearch-http/src/extractors/payload.rs
Normal file
67
meilisearch-http/src/extractors/payload.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_web::error::PayloadError;
|
||||
use actix_web::{dev, web, FromRequest, HttpRequest};
|
||||
use futures::future::{ready, Ready};
|
||||
use futures::Stream;
|
||||
|
||||
pub struct Payload {
|
||||
payload: dev::Payload,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
pub struct PayloadConfig {
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl PayloadConfig {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self { limit }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PayloadConfig {
|
||||
fn default() -> Self {
|
||||
Self { limit: 256 * 1024 }
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequest for Payload {
|
||||
type Error = PayloadError;
|
||||
|
||||
type Future = Ready<Result<Payload, Self::Error>>;
|
||||
|
||||
#[inline]
|
||||
fn from_request(req: &HttpRequest, payload: &mut dev::Payload) -> Self::Future {
|
||||
let limit = req
|
||||
.app_data::<PayloadConfig>()
|
||||
.map(|c| c.limit)
|
||||
.unwrap_or(PayloadConfig::default().limit);
|
||||
ready(Ok(Payload {
|
||||
payload: payload.take(),
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for Payload {
|
||||
type Item = Result<web::Bytes, PayloadError>;
|
||||
|
||||
#[inline]
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
match Pin::new(&mut self.payload).poll_next(cx) {
|
||||
Poll::Ready(Some(result)) => match result {
|
||||
Ok(bytes) => match self.limit.checked_sub(bytes.len()) {
|
||||
Some(new_limit) => {
|
||||
self.limit = new_limit;
|
||||
Poll::Ready(Some(Ok(bytes)))
|
||||
}
|
||||
None => Poll::Ready(Some(Err(PayloadError::Overflow))),
|
||||
},
|
||||
x => Poll::Ready(Some(x)),
|
||||
},
|
||||
otherwise => otherwise,
|
||||
}
|
||||
}
|
||||
}
|
||||
148
meilisearch-http/src/extractors/sequential_extractor.rs
Normal file
148
meilisearch-http/src/extractors/sequential_extractor.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
#![allow(non_snake_case)]
|
||||
use std::{future::Future, pin::Pin, task::Poll};
|
||||
|
||||
use actix_web::{dev::Payload, FromRequest, Handler, HttpRequest};
|
||||
use pin_project_lite::pin_project;
|
||||
|
||||
/// `SeqHandler` is an actix `Handler` that enforces that extractors errors are returned in the
|
||||
/// same order as they are defined in the wrapped handler. This is needed because, by default, actix
|
||||
/// resolves the extractors concurrently, whereas we always need the authentication extractor to
|
||||
/// throw first.
|
||||
#[derive(Clone)]
|
||||
pub struct SeqHandler<H>(pub H);
|
||||
|
||||
pub struct SeqFromRequest<T>(T);
|
||||
|
||||
/// This macro implements `FromRequest` for arbitrary arity handler, except for one, which is
|
||||
/// useless anyway.
|
||||
macro_rules! gen_seq {
|
||||
($ty:ident; $($T:ident)+) => {
|
||||
pin_project! {
|
||||
pub struct $ty<$($T: FromRequest), +> {
|
||||
$(
|
||||
#[pin]
|
||||
$T: ExtractFuture<$T::Future, $T, $T::Error>,
|
||||
)+
|
||||
}
|
||||
}
|
||||
|
||||
impl<$($T: FromRequest), +> Future for $ty<$($T),+> {
|
||||
type Output = Result<SeqFromRequest<($($T),+)>, actix_web::Error>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
|
||||
let mut this = self.project();
|
||||
|
||||
let mut count_fut = 0;
|
||||
let mut count_finished = 0;
|
||||
|
||||
$(
|
||||
count_fut += 1;
|
||||
match this.$T.as_mut().project() {
|
||||
ExtractProj::Future { fut } => match fut.poll(cx) {
|
||||
Poll::Ready(Ok(output)) => {
|
||||
count_finished += 1;
|
||||
let _ = this
|
||||
.$T
|
||||
.as_mut()
|
||||
.project_replace(ExtractFuture::Done { output });
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
count_finished += 1;
|
||||
let _ = this
|
||||
.$T
|
||||
.as_mut()
|
||||
.project_replace(ExtractFuture::Error { error });
|
||||
}
|
||||
Poll::Pending => (),
|
||||
},
|
||||
ExtractProj::Done { .. } => count_finished += 1,
|
||||
ExtractProj::Error { .. } => {
|
||||
// short circuit if all previous are finished and we had an error.
|
||||
if count_finished == count_fut {
|
||||
match this.$T.project_replace(ExtractFuture::Empty) {
|
||||
ExtractReplaceProj::Error { error } => {
|
||||
return Poll::Ready(Err(error.into()))
|
||||
}
|
||||
_ => unreachable!("Invalid future state"),
|
||||
}
|
||||
} else {
|
||||
count_finished += 1;
|
||||
}
|
||||
}
|
||||
ExtractProj::Empty => unreachable!("From request polled after being finished. {}", stringify!($T)),
|
||||
}
|
||||
)+
|
||||
|
||||
if count_fut == count_finished {
|
||||
let result = (
|
||||
$(
|
||||
match this.$T.project_replace(ExtractFuture::Empty) {
|
||||
ExtractReplaceProj::Done { output } => output,
|
||||
ExtractReplaceProj::Error { error } => return Poll::Ready(Err(error.into())),
|
||||
_ => unreachable!("Invalid future state"),
|
||||
},
|
||||
)+
|
||||
);
|
||||
|
||||
Poll::Ready(Ok(SeqFromRequest(result)))
|
||||
} else {
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<$($T: FromRequest,)+> FromRequest for SeqFromRequest<($($T,)+)> {
|
||||
type Error = actix_web::Error;
|
||||
|
||||
type Future = $ty<$($T),+>;
|
||||
|
||||
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||
$ty {
|
||||
$(
|
||||
$T: ExtractFuture::Future {
|
||||
fut: $T::from_request(req, payload),
|
||||
},
|
||||
)+
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Han, $($T: FromRequest),+> Handler<SeqFromRequest<($($T),+)>> for SeqHandler<Han>
|
||||
where
|
||||
Han: Handler<($($T),+)>,
|
||||
{
|
||||
type Output = Han::Output;
|
||||
type Future = Han::Future;
|
||||
|
||||
fn call(&self, args: SeqFromRequest<($($T),+)>) -> Self::Future {
|
||||
self.0.call(args.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Not working for a single argument, but then, it is not really necessary.
|
||||
// gen_seq! { SeqFromRequestFut1; A }
|
||||
gen_seq! { SeqFromRequestFut2; A B }
|
||||
gen_seq! { SeqFromRequestFut3; A B C }
|
||||
gen_seq! { SeqFromRequestFut4; A B C D }
|
||||
gen_seq! { SeqFromRequestFut5; A B C D E }
|
||||
gen_seq! { SeqFromRequestFut6; A B C D E F }
|
||||
|
||||
pin_project! {
|
||||
#[project = ExtractProj]
|
||||
#[project_replace = ExtractReplaceProj]
|
||||
enum ExtractFuture<Fut, Res, Err> {
|
||||
Future {
|
||||
#[pin]
|
||||
fut: Fut,
|
||||
},
|
||||
Done {
|
||||
output: Res,
|
||||
},
|
||||
Error {
|
||||
error: Err,
|
||||
},
|
||||
Empty,
|
||||
}
|
||||
}
|
||||
17
meilisearch-http/src/helpers/env.rs
Normal file
17
meilisearch-http/src/helpers/env.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use meilisearch_lib::heed::Env;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub trait EnvSizer {
|
||||
fn size(&self) -> u64;
|
||||
}
|
||||
|
||||
impl EnvSizer for Env {
|
||||
fn size(&self) -> u64 {
|
||||
WalkDir::new(self.path())
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.metadata().ok())
|
||||
.filter(|metadata| metadata.is_file())
|
||||
.fold(0, |acc, m| acc + m.len())
|
||||
}
|
||||
}
|
||||
3
meilisearch-http/src/helpers/mod.rs
Normal file
3
meilisearch-http/src/helpers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod env;
|
||||
|
||||
pub use env::EnvSizer;
|
||||
174
meilisearch-http/src/lib.rs
Normal file
174
meilisearch-http/src/lib.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
#![allow(rustdoc::private_intra_doc_links)]
|
||||
#[macro_use]
|
||||
pub mod error;
|
||||
pub mod analytics;
|
||||
pub mod task;
|
||||
#[macro_use]
|
||||
pub mod extractors;
|
||||
pub mod helpers;
|
||||
pub mod option;
|
||||
pub mod routes;
|
||||
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::MeilisearchHttpError;
|
||||
use actix_web::error::JsonPayloadError;
|
||||
use analytics::Analytics;
|
||||
use error::PayloadError;
|
||||
use http::header::CONTENT_TYPE;
|
||||
pub use option::Opt;
|
||||
|
||||
use actix_web::{web, HttpRequest};
|
||||
|
||||
use extractors::payload::PayloadConfig;
|
||||
use meilisearch_auth::AuthController;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
|
||||
pub static AUTOBATCHING_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn setup_meilisearch(opt: &Opt) -> anyhow::Result<MeiliSearch> {
|
||||
let mut meilisearch = MeiliSearch::builder();
|
||||
|
||||
// enable autobatching?
|
||||
AUTOBATCHING_ENABLED.store(
|
||||
opt.scheduler_options.enable_auto_batching,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
|
||||
meilisearch
|
||||
.set_max_index_size(opt.max_index_size.get_bytes() as usize)
|
||||
.set_max_task_store_size(opt.max_task_db_size.get_bytes() as usize)
|
||||
// snapshot
|
||||
.set_ignore_missing_snapshot(opt.ignore_missing_snapshot)
|
||||
.set_ignore_snapshot_if_db_exists(opt.ignore_snapshot_if_db_exists)
|
||||
.set_snapshot_interval(Duration::from_secs(opt.snapshot_interval_sec))
|
||||
.set_snapshot_dir(opt.snapshot_dir.clone())
|
||||
// dump
|
||||
.set_ignore_missing_dump(opt.ignore_missing_dump)
|
||||
.set_ignore_dump_if_db_exists(opt.ignore_dump_if_db_exists)
|
||||
.set_dump_dst(opt.dumps_dir.clone());
|
||||
|
||||
if let Some(ref path) = opt.import_snapshot {
|
||||
meilisearch.set_import_snapshot(path.clone());
|
||||
}
|
||||
|
||||
if let Some(ref path) = opt.import_dump {
|
||||
meilisearch.set_dump_src(path.clone());
|
||||
}
|
||||
|
||||
if opt.schedule_snapshot {
|
||||
meilisearch.set_schedule_snapshot();
|
||||
}
|
||||
|
||||
meilisearch.build(
|
||||
opt.db_path.clone(),
|
||||
opt.indexer_options.clone(),
|
||||
opt.scheduler_options.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn configure_data(
|
||||
config: &mut web::ServiceConfig,
|
||||
data: MeiliSearch,
|
||||
auth: AuthController,
|
||||
opt: &Opt,
|
||||
analytics: Arc<dyn Analytics>,
|
||||
) {
|
||||
let http_payload_size_limit = opt.http_payload_size_limit.get_bytes() as usize;
|
||||
config
|
||||
.app_data(data)
|
||||
.app_data(auth)
|
||||
.app_data(web::Data::from(analytics))
|
||||
.app_data(
|
||||
web::JsonConfig::default()
|
||||
.content_type(|mime| mime == mime::APPLICATION_JSON)
|
||||
.error_handler(|err, req: &HttpRequest| match err {
|
||||
JsonPayloadError::ContentType => match req.headers().get(CONTENT_TYPE) {
|
||||
Some(content_type) => MeilisearchHttpError::InvalidContentType(
|
||||
content_type.to_str().unwrap_or("unknown").to_string(),
|
||||
vec![mime::APPLICATION_JSON.to_string()],
|
||||
)
|
||||
.into(),
|
||||
None => MeilisearchHttpError::MissingContentType(vec![
|
||||
mime::APPLICATION_JSON.to_string(),
|
||||
])
|
||||
.into(),
|
||||
},
|
||||
err => PayloadError::from(err).into(),
|
||||
}),
|
||||
)
|
||||
.app_data(PayloadConfig::new(http_payload_size_limit))
|
||||
.app_data(
|
||||
web::QueryConfig::default().error_handler(|err, _req| PayloadError::from(err).into()),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mini-dashboard")]
|
||||
pub fn dashboard(config: &mut web::ServiceConfig, enable_frontend: bool) {
|
||||
use actix_web::HttpResponse;
|
||||
use static_files::Resource;
|
||||
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
|
||||
}
|
||||
|
||||
if enable_frontend {
|
||||
let generated = generated::generate();
|
||||
// Generate routes for mini-dashboard assets
|
||||
for (path, resource) in generated.into_iter() {
|
||||
let Resource {
|
||||
mime_type, data, ..
|
||||
} = resource;
|
||||
// Redirect index.html to /
|
||||
if path == "index.html" {
|
||||
config.service(web::resource("/").route(web::get().to(move || async move {
|
||||
HttpResponse::Ok().content_type(mime_type).body(data)
|
||||
})));
|
||||
} else {
|
||||
config.service(web::resource(path).route(web::get().to(move || async move {
|
||||
HttpResponse::Ok().content_type(mime_type).body(data)
|
||||
})));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
config.service(web::resource("/").route(web::get().to(routes::running)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mini-dashboard"))]
|
||||
pub fn dashboard(config: &mut web::ServiceConfig, _enable_frontend: bool) {
|
||||
config.service(web::resource("/").route(web::get().to(routes::running)));
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! create_app {
|
||||
($data:expr, $auth:expr, $enable_frontend:expr, $opt:expr, $analytics:expr) => {{
|
||||
use actix_cors::Cors;
|
||||
use actix_web::middleware::TrailingSlash;
|
||||
use actix_web::App;
|
||||
use actix_web::{middleware, web};
|
||||
use meilisearch_http::error::MeilisearchHttpError;
|
||||
use meilisearch_http::routes;
|
||||
use meilisearch_http::{configure_data, dashboard};
|
||||
use meilisearch_types::error::ResponseError;
|
||||
|
||||
App::new()
|
||||
.configure(|s| configure_data(s, $data.clone(), $auth.clone(), &$opt, $analytics))
|
||||
.configure(routes::configure)
|
||||
.configure(|s| dashboard(s, $enable_frontend))
|
||||
.wrap(
|
||||
Cors::default()
|
||||
.send_wildcard()
|
||||
.allow_any_header()
|
||||
.allow_any_origin()
|
||||
.allow_any_method()
|
||||
.max_age(86_400), // 24h
|
||||
)
|
||||
.wrap(middleware::Logger::default())
|
||||
.wrap(middleware::Compress::default())
|
||||
.wrap(middleware::NormalizePath::new(
|
||||
middleware::TrailingSlash::Trim,
|
||||
))
|
||||
}};
|
||||
}
|
||||
162
meilisearch-http/src/main.rs
Normal file
162
meilisearch-http/src/main.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::http::KeepAlive;
|
||||
use actix_web::HttpServer;
|
||||
use clap::Parser;
|
||||
use meilisearch_auth::AuthController;
|
||||
use meilisearch_http::analytics;
|
||||
use meilisearch_http::analytics::Analytics;
|
||||
use meilisearch_http::{create_app, setup_meilisearch, Opt};
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[global_allocator]
|
||||
static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
||||
|
||||
/// does all the setup before meilisearch is launched
|
||||
fn setup(opt: &Opt) -> anyhow::Result<()> {
|
||||
let mut log_builder = env_logger::Builder::new();
|
||||
log_builder.parse_filters(&opt.log_level);
|
||||
if opt.log_level == "info" {
|
||||
// if we are in info we only allow the warn log_level for milli
|
||||
log_builder.filter_module("milli", log::LevelFilter::Warn);
|
||||
}
|
||||
|
||||
log_builder.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let opt = Opt::parse();
|
||||
|
||||
setup(&opt)?;
|
||||
|
||||
match opt.env.as_ref() {
|
||||
"production" => {
|
||||
if opt.master_key.is_none() {
|
||||
anyhow::bail!(
|
||||
"In production mode, the environment variable MEILI_MASTER_KEY is mandatory"
|
||||
)
|
||||
}
|
||||
}
|
||||
"development" => (),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
let meilisearch = setup_meilisearch(&opt)?;
|
||||
|
||||
let auth_controller = AuthController::new(&opt.db_path, &opt.master_key)?;
|
||||
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
let (analytics, user) = if !opt.no_analytics {
|
||||
analytics::SegmentAnalytics::new(&opt, &meilisearch).await
|
||||
} else {
|
||||
analytics::MockAnalytics::new(&opt)
|
||||
};
|
||||
#[cfg(any(debug_assertions, not(feature = "analytics")))]
|
||||
let (analytics, user) = analytics::MockAnalytics::new(&opt);
|
||||
|
||||
print_launch_resume(&opt, &user);
|
||||
|
||||
run_http(meilisearch, auth_controller, opt, analytics).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_http(
|
||||
data: MeiliSearch,
|
||||
auth_controller: AuthController,
|
||||
opt: Opt,
|
||||
analytics: Arc<dyn Analytics>,
|
||||
) -> anyhow::Result<()> {
|
||||
let _enable_dashboard = &opt.env == "development";
|
||||
let opt_clone = opt.clone();
|
||||
let http_server = HttpServer::new(move || {
|
||||
create_app!(
|
||||
data,
|
||||
auth_controller,
|
||||
_enable_dashboard,
|
||||
opt_clone,
|
||||
analytics.clone()
|
||||
)
|
||||
})
|
||||
// Disable signals allows the server to terminate immediately when a user enter CTRL-C
|
||||
.disable_signals()
|
||||
.keep_alive(KeepAlive::Os);
|
||||
|
||||
if let Some(config) = opt.get_ssl_config()? {
|
||||
http_server
|
||||
.bind_rustls(opt.http_addr, config)?
|
||||
.run()
|
||||
.await?;
|
||||
} else {
|
||||
http_server.bind(&opt.http_addr)?.run().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_launch_resume(opt: &Opt, user: &str) {
|
||||
let commit_sha = option_env!("VERGEN_GIT_SHA").unwrap_or("unknown");
|
||||
let commit_date = option_env!("VERGEN_GIT_COMMIT_TIMESTAMP").unwrap_or("unknown");
|
||||
|
||||
let ascii_name = r#"
|
||||
888b d888 d8b 888 d8b 888
|
||||
8888b d8888 Y8P 888 Y8P 888
|
||||
88888b.d88888 888 888
|
||||
888Y88888P888 .d88b. 888 888 888 .d8888b .d88b. 8888b. 888d888 .d8888b 88888b.
|
||||
888 Y888P 888 d8P Y8b 888 888 888 88K d8P Y8b "88b 888P" d88P" 888 "88b
|
||||
888 Y8P 888 88888888 888 888 888 "Y8888b. 88888888 .d888888 888 888 888 888
|
||||
888 " 888 Y8b. 888 888 888 X88 Y8b. 888 888 888 Y88b. 888 888
|
||||
888 888 "Y8888 888 888 888 88888P' "Y8888 "Y888888 888 "Y8888P 888 888
|
||||
"#;
|
||||
|
||||
eprintln!("{}", ascii_name);
|
||||
|
||||
eprintln!("Database path:\t\t{:?}", opt.db_path);
|
||||
eprintln!("Server listening on:\t\"http://{}\"", opt.http_addr);
|
||||
eprintln!("Environment:\t\t{:?}", opt.env);
|
||||
eprintln!("Commit SHA:\t\t{:?}", commit_sha.to_string());
|
||||
eprintln!("Commit date:\t\t{:?}", commit_date.to_string());
|
||||
eprintln!(
|
||||
"Package version:\t{:?}",
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
);
|
||||
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
{
|
||||
if !opt.no_analytics {
|
||||
eprintln!(
|
||||
"
|
||||
Thank you for using Meilisearch!
|
||||
|
||||
We collect anonymized analytics to improve our product and your experience. To learn more, including how to turn off analytics, visit our dedicated documentation page: https://docs.meilisearch.com/learn/what_is_meilisearch/telemetry.html
|
||||
|
||||
Anonymous telemetry:\t\"Enabled\""
|
||||
);
|
||||
} else {
|
||||
eprintln!("Anonymous telemetry:\t\"Disabled\"");
|
||||
}
|
||||
}
|
||||
|
||||
if !user.is_empty() {
|
||||
eprintln!("Instance UID:\t\t\"{}\"", user);
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
|
||||
if opt.master_key.is_some() {
|
||||
eprintln!("A Master Key has been set. Requests to Meilisearch won't be authorized unless you provide an authentication key.");
|
||||
} else {
|
||||
eprintln!("No master key found; The server will accept unidentified requests. \
|
||||
If you need some protection in development mode, please export a key: export MEILI_MASTER_KEY=xxx");
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!("Documentation:\t\thttps://docs.meilisearch.com");
|
||||
eprintln!("Source code:\t\thttps://github.com/meilisearch/meilisearch");
|
||||
eprintln!("Contact:\t\thttps://docs.meilisearch.com/resources/contact.html");
|
||||
eprintln!();
|
||||
}
|
||||
271
meilisearch-http/src/option.rs
Normal file
271
meilisearch-http/src/option.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use std::fs;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use byte_unit::Byte;
|
||||
use clap::Parser;
|
||||
use meilisearch_lib::options::{IndexerOpts, SchedulerConfig};
|
||||
use rustls::{
|
||||
server::{
|
||||
AllowAnyAnonymousOrAuthenticatedClient, AllowAnyAuthenticatedClient,
|
||||
ServerSessionMemoryCache,
|
||||
},
|
||||
RootCertStore,
|
||||
};
|
||||
use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
|
||||
use serde::Serialize;
|
||||
|
||||
const POSSIBLE_ENV: [&str; 2] = ["development", "production"];
|
||||
|
||||
#[derive(Debug, Clone, Parser, Serialize)]
|
||||
#[clap(version)]
|
||||
pub struct Opt {
|
||||
/// The destination where the database must be created.
|
||||
#[clap(long, env = "MEILI_DB_PATH", default_value = "./data.ms")]
|
||||
pub db_path: PathBuf,
|
||||
|
||||
/// The address on which the http server will listen.
|
||||
#[clap(long, env = "MEILI_HTTP_ADDR", default_value = "127.0.0.1:7700")]
|
||||
pub http_addr: String,
|
||||
|
||||
/// The master key allowing you to do everything on the server.
|
||||
#[serde(skip)]
|
||||
#[clap(long, env = "MEILI_MASTER_KEY")]
|
||||
pub master_key: Option<String>,
|
||||
|
||||
/// This environment variable must be set to `production` if you are running in production.
|
||||
/// If the server is running in development mode more logs will be displayed,
|
||||
/// and the master key can be avoided which implies that there is no security on the updates routes.
|
||||
/// This is useful to debug when integrating the engine with another service.
|
||||
#[clap(long, env = "MEILI_ENV", default_value = "development", possible_values = &POSSIBLE_ENV)]
|
||||
pub env: String,
|
||||
|
||||
/// Do not send analytics to Meili.
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
#[serde(skip)] // we can't send true
|
||||
#[clap(long, env = "MEILI_NO_ANALYTICS")]
|
||||
pub no_analytics: bool,
|
||||
|
||||
/// The maximum size, in bytes, of the main lmdb database directory
|
||||
#[clap(long, env = "MEILI_MAX_INDEX_SIZE", default_value = "100 GiB")]
|
||||
pub max_index_size: Byte,
|
||||
|
||||
/// The maximum size, in bytes, of the update lmdb database directory
|
||||
#[clap(long, env = "MEILI_MAX_TASK_DB_SIZE", default_value = "100 GiB")]
|
||||
pub max_task_db_size: Byte,
|
||||
|
||||
/// The maximum size, in bytes, of accepted JSON payloads
|
||||
#[clap(long, env = "MEILI_HTTP_PAYLOAD_SIZE_LIMIT", default_value = "100 MB")]
|
||||
pub http_payload_size_limit: Byte,
|
||||
|
||||
/// Read server certificates from CERTFILE.
|
||||
/// This should contain PEM-format certificates
|
||||
/// in the right order (the first certificate should
|
||||
/// certify KEYFILE, the last should be a root CA).
|
||||
#[serde(skip)]
|
||||
#[clap(long, env = "MEILI_SSL_CERT_PATH", parse(from_os_str))]
|
||||
pub ssl_cert_path: Option<PathBuf>,
|
||||
|
||||
/// Read private key from KEYFILE. This should be a RSA
|
||||
/// private key or PKCS8-encoded private key, in PEM format.
|
||||
#[serde(skip)]
|
||||
#[clap(long, env = "MEILI_SSL_KEY_PATH", parse(from_os_str))]
|
||||
pub ssl_key_path: Option<PathBuf>,
|
||||
|
||||
/// Enable client authentication, and accept certificates
|
||||
/// signed by those roots provided in CERTFILE.
|
||||
#[clap(long, env = "MEILI_SSL_AUTH_PATH", parse(from_os_str))]
|
||||
#[serde(skip)]
|
||||
pub ssl_auth_path: Option<PathBuf>,
|
||||
|
||||
/// Read DER-encoded OCSP response from OCSPFILE and staple to certificate.
|
||||
/// Optional
|
||||
#[serde(skip)]
|
||||
#[clap(long, env = "MEILI_SSL_OCSP_PATH", parse(from_os_str))]
|
||||
pub ssl_ocsp_path: Option<PathBuf>,
|
||||
|
||||
/// Send a fatal alert if the client does not complete client authentication.
|
||||
#[serde(skip)]
|
||||
#[clap(long, env = "MEILI_SSL_REQUIRE_AUTH")]
|
||||
pub ssl_require_auth: bool,
|
||||
|
||||
/// SSL support session resumption
|
||||
#[serde(skip)]
|
||||
#[clap(long, env = "MEILI_SSL_RESUMPTION")]
|
||||
pub ssl_resumption: bool,
|
||||
|
||||
/// SSL support tickets.
|
||||
#[serde(skip)]
|
||||
#[clap(long, env = "MEILI_SSL_TICKETS")]
|
||||
pub ssl_tickets: bool,
|
||||
|
||||
/// Defines the path of the snapshot file to import.
|
||||
/// This option will, by default, stop the process if a database already exist or if no snapshot exists at
|
||||
/// the given path. If this option is not specified no snapshot is imported.
|
||||
#[clap(long)]
|
||||
pub import_snapshot: Option<PathBuf>,
|
||||
|
||||
/// The engine will ignore a missing snapshot and not return an error in such case.
|
||||
#[clap(long, requires = "import-snapshot")]
|
||||
pub ignore_missing_snapshot: bool,
|
||||
|
||||
/// The engine will skip snapshot importation and not return an error in such case.
|
||||
#[clap(long, requires = "import-snapshot")]
|
||||
pub ignore_snapshot_if_db_exists: bool,
|
||||
|
||||
/// Defines the directory path where meilisearch will create snapshot each snapshot_time_gap.
|
||||
#[clap(long, env = "MEILI_SNAPSHOT_DIR", default_value = "snapshots/")]
|
||||
pub snapshot_dir: PathBuf,
|
||||
|
||||
/// Activate snapshot scheduling.
|
||||
#[clap(long, env = "MEILI_SCHEDULE_SNAPSHOT")]
|
||||
pub schedule_snapshot: bool,
|
||||
|
||||
/// Defines time interval, in seconds, between each snapshot creation.
|
||||
#[clap(long, env = "MEILI_SNAPSHOT_INTERVAL_SEC", default_value = "86400")] // 24h
|
||||
pub snapshot_interval_sec: u64,
|
||||
|
||||
/// Import a dump from the specified path, must be a `.dump` file.
|
||||
#[clap(long, conflicts_with = "import-snapshot")]
|
||||
pub import_dump: Option<PathBuf>,
|
||||
|
||||
/// If the dump doesn't exists, load or create the database specified by `db-path` instead.
|
||||
#[clap(long, requires = "import-dump")]
|
||||
pub ignore_missing_dump: bool,
|
||||
|
||||
/// Ignore the dump if a database already exists, and load that database instead.
|
||||
#[clap(long, requires = "import-dump")]
|
||||
pub ignore_dump_if_db_exists: bool,
|
||||
|
||||
/// Folder where dumps are created when the dump route is called.
|
||||
#[clap(long, env = "MEILI_DUMPS_DIR", default_value = "dumps/")]
|
||||
pub dumps_dir: PathBuf,
|
||||
|
||||
/// Set the log level
|
||||
#[clap(long, env = "MEILI_LOG_LEVEL", default_value = "info")]
|
||||
pub log_level: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
#[clap(flatten)]
|
||||
pub indexer_options: IndexerOpts,
|
||||
|
||||
#[serde(flatten)]
|
||||
#[clap(flatten)]
|
||||
pub scheduler_options: SchedulerConfig,
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
/// Wether analytics should be enabled or not.
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
pub fn analytics(&self) -> bool {
|
||||
!self.no_analytics
|
||||
}
|
||||
|
||||
pub fn get_ssl_config(&self) -> anyhow::Result<Option<rustls::ServerConfig>> {
|
||||
if let (Some(cert_path), Some(key_path)) = (&self.ssl_cert_path, &self.ssl_key_path) {
|
||||
let config = rustls::ServerConfig::builder().with_safe_defaults();
|
||||
|
||||
let config = match &self.ssl_auth_path {
|
||||
Some(auth_path) => {
|
||||
let roots = load_certs(auth_path.to_path_buf())?;
|
||||
let mut client_auth_roots = RootCertStore::empty();
|
||||
for root in roots {
|
||||
client_auth_roots.add(&root).unwrap();
|
||||
}
|
||||
if self.ssl_require_auth {
|
||||
let verifier = AllowAnyAuthenticatedClient::new(client_auth_roots);
|
||||
config.with_client_cert_verifier(verifier)
|
||||
} else {
|
||||
let verifier =
|
||||
AllowAnyAnonymousOrAuthenticatedClient::new(client_auth_roots);
|
||||
config.with_client_cert_verifier(verifier)
|
||||
}
|
||||
}
|
||||
None => config.with_no_client_auth(),
|
||||
};
|
||||
|
||||
let certs = load_certs(cert_path.to_path_buf())?;
|
||||
let privkey = load_private_key(key_path.to_path_buf())?;
|
||||
let ocsp = load_ocsp(&self.ssl_ocsp_path)?;
|
||||
let mut config = config
|
||||
.with_single_cert_with_ocsp_and_sct(certs, privkey, ocsp, vec![])
|
||||
.map_err(|_| anyhow::anyhow!("bad certificates/private key"))?;
|
||||
|
||||
config.key_log = Arc::new(rustls::KeyLogFile::new());
|
||||
|
||||
if self.ssl_resumption {
|
||||
config.session_storage = ServerSessionMemoryCache::new(256);
|
||||
}
|
||||
|
||||
if self.ssl_tickets {
|
||||
config.ticketer = rustls::Ticketer::new().unwrap();
|
||||
}
|
||||
|
||||
Ok(Some(config))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_certs(filename: PathBuf) -> anyhow::Result<Vec<rustls::Certificate>> {
|
||||
let certfile =
|
||||
fs::File::open(filename).map_err(|_| anyhow::anyhow!("cannot open certificate file"))?;
|
||||
let mut reader = BufReader::new(certfile);
|
||||
certs(&mut reader)
|
||||
.map(|certs| certs.into_iter().map(rustls::Certificate).collect())
|
||||
.map_err(|_| anyhow::anyhow!("cannot read certificate file"))
|
||||
}
|
||||
|
||||
fn load_private_key(filename: PathBuf) -> anyhow::Result<rustls::PrivateKey> {
|
||||
let rsa_keys = {
|
||||
let keyfile = fs::File::open(filename.clone())
|
||||
.map_err(|_| anyhow::anyhow!("cannot open private key file"))?;
|
||||
let mut reader = BufReader::new(keyfile);
|
||||
rsa_private_keys(&mut reader)
|
||||
.map_err(|_| anyhow::anyhow!("file contains invalid rsa private key"))?
|
||||
};
|
||||
|
||||
let pkcs8_keys = {
|
||||
let keyfile = fs::File::open(filename)
|
||||
.map_err(|_| anyhow::anyhow!("cannot open private key file"))?;
|
||||
let mut reader = BufReader::new(keyfile);
|
||||
pkcs8_private_keys(&mut reader).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"file contains invalid pkcs8 private key (encrypted keys not supported)"
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
// prefer to load pkcs8 keys
|
||||
if !pkcs8_keys.is_empty() {
|
||||
Ok(rustls::PrivateKey(pkcs8_keys[0].clone()))
|
||||
} else {
|
||||
assert!(!rsa_keys.is_empty());
|
||||
Ok(rustls::PrivateKey(rsa_keys[0].clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_ocsp(filename: &Option<PathBuf>) -> anyhow::Result<Vec<u8>> {
|
||||
let mut ret = Vec::new();
|
||||
|
||||
if let Some(ref name) = filename {
|
||||
fs::File::open(name)
|
||||
.map_err(|_| anyhow::anyhow!("cannot open ocsp file"))?
|
||||
.read_to_end(&mut ret)
|
||||
.map_err(|_| anyhow::anyhow!("cannot read oscp file"))?;
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_opt() {
|
||||
assert!(Opt::try_parse_from(Some("")).is_ok());
|
||||
}
|
||||
}
|
||||
160
meilisearch-http/src/routes/api_key.rs
Normal file
160
meilisearch-http/src/routes/api_key.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::str;
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use meilisearch_auth::{error::AuthControllerError, Action, AuthController, Key};
|
||||
use meilisearch_types::error::{Code, ResponseError};
|
||||
|
||||
use crate::extractors::{
|
||||
authentication::{policies::*, GuardedData},
|
||||
sequential_extractor::SeqHandler,
|
||||
};
|
||||
use crate::routes::Pagination;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("")
|
||||
.route(web::post().to(SeqHandler(create_api_key)))
|
||||
.route(web::get().to(SeqHandler(list_api_keys))),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{key}")
|
||||
.route(web::get().to(SeqHandler(get_api_key)))
|
||||
.route(web::patch().to(SeqHandler(patch_api_key)))
|
||||
.route(web::delete().to(SeqHandler(delete_api_key))),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn create_api_key(
|
||||
auth_controller: GuardedData<ActionPolicy<{ actions::KEYS_CREATE }>, AuthController>,
|
||||
body: web::Json<Value>,
|
||||
_req: HttpRequest,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let v = body.into_inner();
|
||||
let res = tokio::task::spawn_blocking(move || -> Result<_, AuthControllerError> {
|
||||
let key = auth_controller.create_key(v)?;
|
||||
Ok(KeyView::from_key(key, &auth_controller))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ResponseError::from_msg(e.to_string(), Code::Internal))??;
|
||||
|
||||
Ok(HttpResponse::Created().json(res))
|
||||
}
|
||||
|
||||
pub async fn list_api_keys(
|
||||
auth_controller: GuardedData<ActionPolicy<{ actions::KEYS_GET }>, AuthController>,
|
||||
paginate: web::Query<Pagination>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let page_view = tokio::task::spawn_blocking(move || -> Result<_, AuthControllerError> {
|
||||
let keys = auth_controller.list_keys()?;
|
||||
let page_view = paginate.auto_paginate_sized(
|
||||
keys.into_iter()
|
||||
.map(|k| KeyView::from_key(k, &auth_controller)),
|
||||
);
|
||||
|
||||
Ok(page_view)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ResponseError::from_msg(e.to_string(), Code::Internal))??;
|
||||
|
||||
Ok(HttpResponse::Ok().json(page_view))
|
||||
}
|
||||
|
||||
pub async fn get_api_key(
|
||||
auth_controller: GuardedData<ActionPolicy<{ actions::KEYS_GET }>, AuthController>,
|
||||
path: web::Path<AuthParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let key = path.into_inner().key;
|
||||
|
||||
let res = tokio::task::spawn_blocking(move || -> Result<_, AuthControllerError> {
|
||||
let uid =
|
||||
Uuid::parse_str(&key).or_else(|_| auth_controller.get_uid_from_encoded_key(&key))?;
|
||||
let key = auth_controller.get_key(uid)?;
|
||||
|
||||
Ok(KeyView::from_key(key, &auth_controller))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ResponseError::from_msg(e.to_string(), Code::Internal))??;
|
||||
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
}
|
||||
|
||||
pub async fn patch_api_key(
|
||||
auth_controller: GuardedData<ActionPolicy<{ actions::KEYS_UPDATE }>, AuthController>,
|
||||
body: web::Json<Value>,
|
||||
path: web::Path<AuthParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let key = path.into_inner().key;
|
||||
let body = body.into_inner();
|
||||
let res = tokio::task::spawn_blocking(move || -> Result<_, AuthControllerError> {
|
||||
let uid =
|
||||
Uuid::parse_str(&key).or_else(|_| auth_controller.get_uid_from_encoded_key(&key))?;
|
||||
let key = auth_controller.update_key(uid, body)?;
|
||||
|
||||
Ok(KeyView::from_key(key, &auth_controller))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ResponseError::from_msg(e.to_string(), Code::Internal))??;
|
||||
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(
|
||||
auth_controller: GuardedData<ActionPolicy<{ actions::KEYS_DELETE }>, AuthController>,
|
||||
path: web::Path<AuthParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let key = path.into_inner().key;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let uid =
|
||||
Uuid::parse_str(&key).or_else(|_| auth_controller.get_uid_from_encoded_key(&key))?;
|
||||
auth_controller.delete_key(uid)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ResponseError::from_msg(e.to_string(), Code::Internal))??;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthParam {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeyView {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
key: String,
|
||||
uid: Uuid,
|
||||
actions: Vec<Action>,
|
||||
indexes: Vec<String>,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::option::serialize")]
|
||||
expires_at: Option<OffsetDateTime>,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::serialize")]
|
||||
created_at: OffsetDateTime,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::serialize")]
|
||||
updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl KeyView {
|
||||
fn from_key(key: Key, auth: &AuthController) -> Self {
|
||||
let generated_key = auth.generate_key(key.uid).unwrap_or_default();
|
||||
|
||||
KeyView {
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
key: generated_key,
|
||||
uid: key.uid,
|
||||
actions: key.actions,
|
||||
indexes: key.indexes.into_iter().map(String::from).collect(),
|
||||
expires_at: key.expires_at,
|
||||
created_at: key.created_at,
|
||||
updated_at: key.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
27
meilisearch-http/src/routes/dump.rs
Normal file
27
meilisearch-http/src/routes/dump.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use log::debug;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::analytics::Analytics;
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use crate::extractors::sequential_extractor::SeqHandler;
|
||||
use crate::task::SummarizedTaskView;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::resource("").route(web::post().to(SeqHandler(create_dump))));
|
||||
}
|
||||
|
||||
pub async fn create_dump(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DUMPS_CREATE }>, MeiliSearch>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
analytics.publish("Dump Created".to_string(), json!({}), Some(&req));
|
||||
|
||||
let res: SummarizedTaskView = meilisearch.register_dump_task().await?.into();
|
||||
|
||||
debug!("returns: {:?}", res);
|
||||
Ok(HttpResponse::Accepted().json(res))
|
||||
}
|
||||
311
meilisearch-http/src/routes/indexes/documents.rs
Normal file
311
meilisearch-http/src/routes/indexes/documents.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use actix_web::error::PayloadError;
|
||||
use actix_web::http::header::CONTENT_TYPE;
|
||||
use actix_web::web::Bytes;
|
||||
use actix_web::HttpMessage;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use bstr::ByteSlice;
|
||||
use futures::{Stream, StreamExt};
|
||||
use log::debug;
|
||||
use meilisearch_lib::index_controller::{DocumentAdditionFormat, Update};
|
||||
use meilisearch_lib::milli::update::IndexDocumentsMethod;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use meilisearch_types::star_or::StarOr;
|
||||
use mime::Mime;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
use serde_cs::vec::CS;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::analytics::Analytics;
|
||||
use crate::error::MeilisearchHttpError;
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use crate::extractors::payload::Payload;
|
||||
use crate::extractors::sequential_extractor::SeqHandler;
|
||||
use crate::routes::{fold_star_or, PaginationView};
|
||||
use crate::task::SummarizedTaskView;
|
||||
|
||||
static ACCEPTED_CONTENT_TYPE: Lazy<Vec<String>> = Lazy::new(|| {
|
||||
vec![
|
||||
"application/json".to_string(),
|
||||
"application/x-ndjson".to_string(),
|
||||
"text/csv".to_string(),
|
||||
]
|
||||
});
|
||||
|
||||
/// This is required because Payload is not Sync nor Send
|
||||
fn payload_to_stream(mut payload: Payload) -> impl Stream<Item = Result<Bytes, PayloadError>> {
|
||||
let (snd, recv) = mpsc::channel(1);
|
||||
tokio::task::spawn_local(async move {
|
||||
while let Some(data) = payload.next().await {
|
||||
let _ = snd.send(data).await;
|
||||
}
|
||||
});
|
||||
tokio_stream::wrappers::ReceiverStream::new(recv)
|
||||
}
|
||||
|
||||
/// Extracts the mime type from the content type and return
|
||||
/// a meilisearch error if anything bad happen.
|
||||
fn extract_mime_type(req: &HttpRequest) -> Result<Option<Mime>, MeilisearchHttpError> {
|
||||
match req.mime_type() {
|
||||
Ok(Some(mime)) => Ok(Some(mime)),
|
||||
Ok(None) => Ok(None),
|
||||
Err(_) => match req.headers().get(CONTENT_TYPE) {
|
||||
Some(content_type) => Err(MeilisearchHttpError::InvalidContentType(
|
||||
content_type.as_bytes().as_bstr().to_string(),
|
||||
ACCEPTED_CONTENT_TYPE.clone(),
|
||||
)),
|
||||
None => Err(MeilisearchHttpError::MissingContentType(
|
||||
ACCEPTED_CONTENT_TYPE.clone(),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DocumentParam {
|
||||
index_uid: String,
|
||||
document_id: String,
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("")
|
||||
.route(web::get().to(SeqHandler(get_all_documents)))
|
||||
.route(web::post().to(SeqHandler(add_documents)))
|
||||
.route(web::put().to(SeqHandler(update_documents)))
|
||||
.route(web::delete().to(SeqHandler(clear_all_documents))),
|
||||
)
|
||||
// this route needs to be before the /documents/{document_id} to match properly
|
||||
.service(web::resource("/delete-batch").route(web::post().to(SeqHandler(delete_documents))))
|
||||
.service(
|
||||
web::resource("/{document_id}")
|
||||
.route(web::get().to(SeqHandler(get_document)))
|
||||
.route(web::delete().to(SeqHandler(delete_document))),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct GetDocument {
|
||||
fields: Option<CS<StarOr<String>>>,
|
||||
}
|
||||
|
||||
pub async fn get_document(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_GET }>, MeiliSearch>,
|
||||
path: web::Path<DocumentParam>,
|
||||
params: web::Query<GetDocument>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let index = path.index_uid.clone();
|
||||
let id = path.document_id.clone();
|
||||
let GetDocument { fields } = params.into_inner();
|
||||
let attributes_to_retrieve = fields.and_then(fold_star_or);
|
||||
|
||||
let document = meilisearch
|
||||
.document(index, id, attributes_to_retrieve)
|
||||
.await?;
|
||||
debug!("returns: {:?}", document);
|
||||
Ok(HttpResponse::Ok().json(document))
|
||||
}
|
||||
|
||||
pub async fn delete_document(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<DocumentParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let DocumentParam {
|
||||
document_id,
|
||||
index_uid,
|
||||
} = path.into_inner();
|
||||
let update = Update::DeleteDocuments(vec![document_id]);
|
||||
let task: SummarizedTaskView = meilisearch.register_update(index_uid, update).await?.into();
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BrowseQuery {
|
||||
#[serde(default)]
|
||||
offset: usize,
|
||||
#[serde(default = "crate::routes::PAGINATION_DEFAULT_LIMIT")]
|
||||
limit: usize,
|
||||
fields: Option<CS<StarOr<String>>>,
|
||||
}
|
||||
|
||||
pub async fn get_all_documents(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_GET }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<BrowseQuery>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
debug!("called with params: {:?}", params);
|
||||
let BrowseQuery {
|
||||
limit,
|
||||
offset,
|
||||
fields,
|
||||
} = params.into_inner();
|
||||
let attributes_to_retrieve = fields.and_then(fold_star_or);
|
||||
|
||||
let (total, documents) = meilisearch
|
||||
.documents(path.into_inner(), offset, limit, attributes_to_retrieve)
|
||||
.await?;
|
||||
|
||||
let ret = PaginationView::new(offset, limit, total as usize, documents);
|
||||
|
||||
debug!("returns: {:?}", ret);
|
||||
Ok(HttpResponse::Ok().json(ret))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateDocumentsQuery {
|
||||
pub primary_key: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn add_documents(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_ADD }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<UpdateDocumentsQuery>,
|
||||
body: Payload,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
debug!("called with params: {:?}", params);
|
||||
let params = params.into_inner();
|
||||
let index_uid = path.into_inner();
|
||||
|
||||
analytics.add_documents(
|
||||
¶ms,
|
||||
meilisearch.get_index(index_uid.clone()).await.is_err(),
|
||||
&req,
|
||||
);
|
||||
|
||||
let allow_index_creation = meilisearch.filters().allow_index_creation;
|
||||
let task = document_addition(
|
||||
extract_mime_type(&req)?,
|
||||
meilisearch,
|
||||
index_uid,
|
||||
params.primary_key,
|
||||
body,
|
||||
IndexDocumentsMethod::ReplaceDocuments,
|
||||
allow_index_creation,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
pub async fn update_documents(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_ADD }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<UpdateDocumentsQuery>,
|
||||
body: Payload,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
debug!("called with params: {:?}", params);
|
||||
let index_uid = path.into_inner();
|
||||
|
||||
analytics.update_documents(
|
||||
¶ms,
|
||||
meilisearch.get_index(index_uid.clone()).await.is_err(),
|
||||
&req,
|
||||
);
|
||||
|
||||
let allow_index_creation = meilisearch.filters().allow_index_creation;
|
||||
let task = document_addition(
|
||||
extract_mime_type(&req)?,
|
||||
meilisearch,
|
||||
index_uid,
|
||||
params.into_inner().primary_key,
|
||||
body,
|
||||
IndexDocumentsMethod::UpdateDocuments,
|
||||
allow_index_creation,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
async fn document_addition(
|
||||
mime_type: Option<Mime>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_ADD }>, MeiliSearch>,
|
||||
index_uid: String,
|
||||
primary_key: Option<String>,
|
||||
body: Payload,
|
||||
method: IndexDocumentsMethod,
|
||||
allow_index_creation: bool,
|
||||
) -> Result<SummarizedTaskView, ResponseError> {
|
||||
let format = match mime_type
|
||||
.as_ref()
|
||||
.map(|m| (m.type_().as_str(), m.subtype().as_str()))
|
||||
{
|
||||
Some(("application", "json")) => DocumentAdditionFormat::Json,
|
||||
Some(("application", "x-ndjson")) => DocumentAdditionFormat::Ndjson,
|
||||
Some(("text", "csv")) => DocumentAdditionFormat::Csv,
|
||||
Some((type_, subtype)) => {
|
||||
return Err(MeilisearchHttpError::InvalidContentType(
|
||||
format!("{}/{}", type_, subtype),
|
||||
ACCEPTED_CONTENT_TYPE.clone(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
MeilisearchHttpError::MissingContentType(ACCEPTED_CONTENT_TYPE.clone()).into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let update = Update::DocumentAddition {
|
||||
payload: Box::new(payload_to_stream(body)),
|
||||
primary_key,
|
||||
method,
|
||||
format,
|
||||
allow_index_creation,
|
||||
};
|
||||
|
||||
let task = meilisearch.register_update(index_uid, update).await?.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
pub async fn delete_documents(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
body: web::Json<Vec<Value>>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
debug!("called with params: {:?}", body);
|
||||
let ids = body
|
||||
.iter()
|
||||
.map(|v| {
|
||||
v.as_str()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| v.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let update = Update::DeleteDocuments(ids);
|
||||
let task: SummarizedTaskView = meilisearch
|
||||
.register_update(path.into_inner(), update)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
pub async fn clear_all_documents(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let update = Update::ClearDocuments;
|
||||
let task: SummarizedTaskView = meilisearch
|
||||
.register_update(path.into_inner(), update)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
166
meilisearch-http/src/routes/indexes/mod.rs
Normal file
166
meilisearch-http/src/routes/indexes/mod.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use log::debug;
|
||||
use meilisearch_lib::index_controller::Update;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::analytics::Analytics;
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use crate::extractors::sequential_extractor::SeqHandler;
|
||||
use crate::task::SummarizedTaskView;
|
||||
|
||||
use super::Pagination;
|
||||
|
||||
pub mod documents;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("")
|
||||
.route(web::get().to(list_indexes))
|
||||
.route(web::post().to(SeqHandler(create_index))),
|
||||
)
|
||||
.service(
|
||||
web::scope("/{index_uid}")
|
||||
.service(
|
||||
web::resource("")
|
||||
.route(web::get().to(SeqHandler(get_index)))
|
||||
.route(web::patch().to(SeqHandler(update_index)))
|
||||
.route(web::delete().to(SeqHandler(delete_index))),
|
||||
)
|
||||
.service(web::resource("/stats").route(web::get().to(SeqHandler(get_index_stats))))
|
||||
.service(web::scope("/documents").configure(documents::configure))
|
||||
.service(web::scope("/search").configure(search::configure))
|
||||
.service(web::scope("/settings").configure(settings::configure)),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn list_indexes(
|
||||
data: GuardedData<ActionPolicy<{ actions::INDEXES_GET }>, MeiliSearch>,
|
||||
paginate: web::Query<Pagination>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let search_rules = &data.filters().search_rules;
|
||||
let indexes: Vec<_> = data.list_indexes().await?;
|
||||
let nb_indexes = indexes.len();
|
||||
let iter = indexes
|
||||
.into_iter()
|
||||
.filter(|i| search_rules.is_index_authorized(&i.uid));
|
||||
let ret = paginate
|
||||
.into_inner()
|
||||
.auto_paginate_unsized(nb_indexes, iter);
|
||||
|
||||
debug!("returns: {:?}", ret);
|
||||
Ok(HttpResponse::Ok().json(ret))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct IndexCreateRequest {
|
||||
uid: String,
|
||||
primary_key: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_index(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_CREATE }>, MeiliSearch>,
|
||||
body: web::Json<IndexCreateRequest>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let IndexCreateRequest {
|
||||
primary_key, uid, ..
|
||||
} = body.into_inner();
|
||||
|
||||
analytics.publish(
|
||||
"Index Created".to_string(),
|
||||
json!({ "primary_key": primary_key }),
|
||||
Some(&req),
|
||||
);
|
||||
|
||||
let update = Update::CreateIndex { primary_key };
|
||||
let task: SummarizedTaskView = meilisearch.register_update(uid, update).await?.into();
|
||||
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[allow(dead_code)]
|
||||
pub struct UpdateIndexRequest {
|
||||
uid: Option<String>,
|
||||
primary_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateIndexResponse {
|
||||
name: String,
|
||||
uid: String,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::serialize")]
|
||||
created_at: OffsetDateTime,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::serialize")]
|
||||
updated_at: OffsetDateTime,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::serialize")]
|
||||
primary_key: OffsetDateTime,
|
||||
}
|
||||
|
||||
pub async fn get_index(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_GET }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let meta = meilisearch.get_index(path.into_inner()).await?;
|
||||
debug!("returns: {:?}", meta);
|
||||
Ok(HttpResponse::Ok().json(meta))
|
||||
}
|
||||
|
||||
pub async fn update_index(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_UPDATE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
body: web::Json<UpdateIndexRequest>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
debug!("called with params: {:?}", body);
|
||||
let body = body.into_inner();
|
||||
analytics.publish(
|
||||
"Index Updated".to_string(),
|
||||
json!({ "primary_key": body.primary_key}),
|
||||
Some(&req),
|
||||
);
|
||||
|
||||
let update = Update::UpdateIndex {
|
||||
primary_key: body.primary_key,
|
||||
};
|
||||
|
||||
let task: SummarizedTaskView = meilisearch
|
||||
.register_update(path.into_inner(), update)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
pub async fn delete_index(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let uid = path.into_inner();
|
||||
let update = Update::DeleteIndex;
|
||||
let task: SummarizedTaskView = meilisearch.register_update(uid, update).await?.into();
|
||||
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
pub async fn get_index_stats(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::STATS_GET }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let response = meilisearch.get_index_stats(path.into_inner()).await?;
|
||||
|
||||
debug!("returns: {:?}", response);
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
233
meilisearch-http/src/routes/indexes/search.rs
Normal file
233
meilisearch-http/src/routes/indexes/search.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use log::debug;
|
||||
use meilisearch_auth::IndexSearchRules;
|
||||
use meilisearch_lib::index::{
|
||||
SearchQuery, DEFAULT_CROP_LENGTH, DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG,
|
||||
DEFAULT_HIGHLIGHT_PRE_TAG, DEFAULT_SEARCH_LIMIT,
|
||||
};
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use serde::Deserialize;
|
||||
use serde_cs::vec::CS;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::analytics::{Analytics, SearchAggregator};
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use crate::extractors::sequential_extractor::SeqHandler;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("")
|
||||
.route(web::get().to(SeqHandler(search_with_url_query)))
|
||||
.route(web::post().to(SeqHandler(search_with_post))),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SearchQueryGet {
|
||||
q: Option<String>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
attributes_to_retrieve: Option<CS<String>>,
|
||||
attributes_to_crop: Option<CS<String>>,
|
||||
#[serde(default = "DEFAULT_CROP_LENGTH")]
|
||||
crop_length: usize,
|
||||
attributes_to_highlight: Option<CS<String>>,
|
||||
filter: Option<String>,
|
||||
sort: Option<String>,
|
||||
#[serde(default = "Default::default")]
|
||||
show_matches_position: bool,
|
||||
facets: Option<CS<String>>,
|
||||
#[serde(default = "DEFAULT_HIGHLIGHT_PRE_TAG")]
|
||||
highlight_pre_tag: String,
|
||||
#[serde(default = "DEFAULT_HIGHLIGHT_POST_TAG")]
|
||||
highlight_post_tag: String,
|
||||
#[serde(default = "DEFAULT_CROP_MARKER")]
|
||||
crop_marker: String,
|
||||
}
|
||||
|
||||
impl From<SearchQueryGet> for SearchQuery {
|
||||
fn from(other: SearchQueryGet) -> Self {
|
||||
let filter = match other.filter {
|
||||
Some(f) => match serde_json::from_str(&f) {
|
||||
Ok(v) => Some(v),
|
||||
_ => Some(Value::String(f)),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
Self {
|
||||
q: other.q,
|
||||
offset: other.offset,
|
||||
limit: other.limit.unwrap_or_else(DEFAULT_SEARCH_LIMIT),
|
||||
attributes_to_retrieve: other
|
||||
.attributes_to_retrieve
|
||||
.map(|o| o.into_iter().collect()),
|
||||
attributes_to_crop: other.attributes_to_crop.map(|o| o.into_iter().collect()),
|
||||
crop_length: other.crop_length,
|
||||
attributes_to_highlight: other
|
||||
.attributes_to_highlight
|
||||
.map(|o| o.into_iter().collect()),
|
||||
filter,
|
||||
sort: other.sort.map(|attr| fix_sort_query_parameters(&attr)),
|
||||
show_matches_position: other.show_matches_position,
|
||||
facets: other.facets.map(|o| o.into_iter().collect()),
|
||||
highlight_pre_tag: other.highlight_pre_tag,
|
||||
highlight_post_tag: other.highlight_post_tag,
|
||||
crop_marker: other.crop_marker,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Incorporate search rules in search query
|
||||
fn add_search_rules(query: &mut SearchQuery, rules: IndexSearchRules) {
|
||||
query.filter = match (query.filter.take(), rules.filter) {
|
||||
(None, rules_filter) => rules_filter,
|
||||
(filter, None) => filter,
|
||||
(Some(filter), Some(rules_filter)) => {
|
||||
let filter = match filter {
|
||||
Value::Array(filter) => filter,
|
||||
filter => vec![filter],
|
||||
};
|
||||
let rules_filter = match rules_filter {
|
||||
Value::Array(rules_filter) => rules_filter,
|
||||
rules_filter => vec![rules_filter],
|
||||
};
|
||||
|
||||
Some(Value::Array([filter, rules_filter].concat()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: TAMO: split on :asc, and :desc, instead of doing some weird things
|
||||
|
||||
/// Transform the sort query parameter into something that matches the post expected format.
|
||||
fn fix_sort_query_parameters(sort_query: &str) -> Vec<String> {
|
||||
let mut sort_parameters = Vec::new();
|
||||
let mut merge = false;
|
||||
for current_sort in sort_query.trim_matches('"').split(',').map(|s| s.trim()) {
|
||||
if current_sort.starts_with("_geoPoint(") {
|
||||
sort_parameters.push(current_sort.to_string());
|
||||
merge = true;
|
||||
} else if merge && !sort_parameters.is_empty() {
|
||||
let s = sort_parameters.last_mut().unwrap();
|
||||
s.push(',');
|
||||
s.push_str(current_sort);
|
||||
if current_sort.ends_with("):desc") || current_sort.ends_with("):asc") {
|
||||
merge = false;
|
||||
}
|
||||
} else {
|
||||
sort_parameters.push(current_sort.to_string());
|
||||
merge = false;
|
||||
}
|
||||
}
|
||||
sort_parameters
|
||||
}
|
||||
|
||||
pub async fn search_with_url_query(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SEARCH }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<SearchQueryGet>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
debug!("called with params: {:?}", params);
|
||||
let mut query: SearchQuery = params.into_inner().into();
|
||||
|
||||
let index_uid = path.into_inner();
|
||||
// Tenant token search_rules.
|
||||
if let Some(search_rules) = meilisearch
|
||||
.filters()
|
||||
.search_rules
|
||||
.get_index_search_rules(&index_uid)
|
||||
{
|
||||
add_search_rules(&mut query, search_rules);
|
||||
}
|
||||
|
||||
let mut aggregate = SearchAggregator::from_query(&query, &req);
|
||||
|
||||
let search_result = meilisearch.search(index_uid, query).await;
|
||||
if let Ok(ref search_result) = search_result {
|
||||
aggregate.succeed(search_result);
|
||||
}
|
||||
analytics.get_search(aggregate);
|
||||
|
||||
let search_result = search_result?;
|
||||
|
||||
debug!("returns: {:?}", search_result);
|
||||
Ok(HttpResponse::Ok().json(search_result))
|
||||
}
|
||||
|
||||
pub async fn search_with_post(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SEARCH }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Json<SearchQuery>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let mut query = params.into_inner();
|
||||
debug!("search called with params: {:?}", query);
|
||||
|
||||
let index_uid = path.into_inner();
|
||||
// Tenant token search_rules.
|
||||
if let Some(search_rules) = meilisearch
|
||||
.filters()
|
||||
.search_rules
|
||||
.get_index_search_rules(&index_uid)
|
||||
{
|
||||
add_search_rules(&mut query, search_rules);
|
||||
}
|
||||
|
||||
let mut aggregate = SearchAggregator::from_query(&query, &req);
|
||||
|
||||
let search_result = meilisearch.search(index_uid, query).await;
|
||||
if let Ok(ref search_result) = search_result {
|
||||
aggregate.succeed(search_result);
|
||||
}
|
||||
analytics.post_search(aggregate);
|
||||
|
||||
let search_result = search_result?;
|
||||
|
||||
debug!("returns: {:?}", search_result);
|
||||
Ok(HttpResponse::Ok().json(search_result))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fix_sort_query_parameters() {
|
||||
let sort = fix_sort_query_parameters("_geoPoint(12, 13):asc");
|
||||
assert_eq!(sort, vec!["_geoPoint(12,13):asc".to_string()]);
|
||||
let sort = fix_sort_query_parameters("doggo:asc,_geoPoint(12.45,13.56):desc");
|
||||
assert_eq!(
|
||||
sort,
|
||||
vec![
|
||||
"doggo:asc".to_string(),
|
||||
"_geoPoint(12.45,13.56):desc".to_string(),
|
||||
]
|
||||
);
|
||||
let sort = fix_sort_query_parameters(
|
||||
"doggo:asc , _geoPoint(12.45, 13.56, 2590352):desc , catto:desc",
|
||||
);
|
||||
assert_eq!(
|
||||
sort,
|
||||
vec![
|
||||
"doggo:asc".to_string(),
|
||||
"_geoPoint(12.45,13.56,2590352):desc".to_string(),
|
||||
"catto:desc".to_string(),
|
||||
]
|
||||
);
|
||||
let sort = fix_sort_query_parameters("doggo:asc , _geoPoint(1, 2), catto:desc");
|
||||
// This is ugly but eh, I don't want to write a full parser just for this unused route
|
||||
assert_eq!(
|
||||
sort,
|
||||
vec![
|
||||
"doggo:asc".to_string(),
|
||||
"_geoPoint(1,2),catto:desc".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
473
meilisearch-http/src/routes/indexes/settings.rs
Normal file
473
meilisearch-http/src/routes/indexes/settings.rs
Normal file
@@ -0,0 +1,473 @@
|
||||
use log::debug;
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use meilisearch_lib::index::{Settings, Unchecked};
|
||||
use meilisearch_lib::index_controller::Update;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::analytics::Analytics;
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use crate::task::SummarizedTaskView;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! make_setting_route {
|
||||
($route:literal, $update_verb:ident, $type:ty, $attr:ident, $camelcase_attr:literal, $analytics_var:ident, $analytics:expr) => {
|
||||
pub mod $attr {
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Resource};
|
||||
use log::debug;
|
||||
|
||||
use meilisearch_lib::milli::update::Setting;
|
||||
use meilisearch_lib::{index::Settings, index_controller::Update, MeiliSearch};
|
||||
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use $crate::analytics::Analytics;
|
||||
use $crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use $crate::extractors::sequential_extractor::SeqHandler;
|
||||
use $crate::task::SummarizedTaskView;
|
||||
|
||||
pub async fn delete(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let settings = Settings {
|
||||
$attr: Setting::Reset,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let allow_index_creation = meilisearch.filters().allow_index_creation;
|
||||
let update = Update::Settings {
|
||||
settings,
|
||||
is_deletion: true,
|
||||
allow_index_creation,
|
||||
};
|
||||
let task: SummarizedTaskView = meilisearch
|
||||
.register_update(index_uid.into_inner(), update)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: actix_web::web::Path<String>,
|
||||
body: actix_web::web::Json<Option<$type>>,
|
||||
req: HttpRequest,
|
||||
$analytics_var: web::Data<dyn Analytics>,
|
||||
) -> std::result::Result<HttpResponse, ResponseError> {
|
||||
let body = body.into_inner();
|
||||
|
||||
$analytics(&body, &req);
|
||||
|
||||
let settings = Settings {
|
||||
$attr: match body {
|
||||
Some(inner_body) => Setting::Set(inner_body),
|
||||
None => Setting::Reset,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let allow_index_creation = meilisearch.filters().allow_index_creation;
|
||||
let update = Update::Settings {
|
||||
settings,
|
||||
is_deletion: false,
|
||||
allow_index_creation,
|
||||
};
|
||||
let task: SummarizedTaskView = meilisearch
|
||||
.register_update(index_uid.into_inner(), update)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_GET }>, MeiliSearch>,
|
||||
index_uid: actix_web::web::Path<String>,
|
||||
) -> std::result::Result<HttpResponse, ResponseError> {
|
||||
let settings = meilisearch.settings(index_uid.into_inner()).await?;
|
||||
debug!("returns: {:?}", settings);
|
||||
let mut json = serde_json::json!(&settings);
|
||||
let val = json[$camelcase_attr].take();
|
||||
|
||||
Ok(HttpResponse::Ok().json(val))
|
||||
}
|
||||
|
||||
pub fn resources() -> Resource {
|
||||
Resource::new($route)
|
||||
.route(web::get().to(SeqHandler(get)))
|
||||
.route(web::$update_verb().to(SeqHandler(update)))
|
||||
.route(web::delete().to(SeqHandler(delete)))
|
||||
}
|
||||
}
|
||||
};
|
||||
($route:literal, $update_verb:ident, $type:ty, $attr:ident, $camelcase_attr:literal) => {
|
||||
make_setting_route!(
|
||||
$route,
|
||||
$update_verb,
|
||||
$type,
|
||||
$attr,
|
||||
$camelcase_attr,
|
||||
_analytics,
|
||||
|_, _| {}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
make_setting_route!(
|
||||
"/filterable-attributes",
|
||||
put,
|
||||
std::collections::BTreeSet<String>,
|
||||
filterable_attributes,
|
||||
"filterableAttributes",
|
||||
analytics,
|
||||
|setting: &Option<std::collections::BTreeSet<String>>, req: &HttpRequest| {
|
||||
use serde_json::json;
|
||||
|
||||
analytics.publish(
|
||||
"FilterableAttributes Updated".to_string(),
|
||||
json!({
|
||||
"filterable_attributes": {
|
||||
"total": setting.as_ref().map(|filter| filter.len()).unwrap_or(0),
|
||||
"has_geo": setting.as_ref().map(|filter| filter.contains("_geo")).unwrap_or(false),
|
||||
}
|
||||
}),
|
||||
Some(req),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/sortable-attributes",
|
||||
put,
|
||||
std::collections::BTreeSet<String>,
|
||||
sortable_attributes,
|
||||
"sortableAttributes",
|
||||
analytics,
|
||||
|setting: &Option<std::collections::BTreeSet<String>>, req: &HttpRequest| {
|
||||
use serde_json::json;
|
||||
|
||||
analytics.publish(
|
||||
"SortableAttributes Updated".to_string(),
|
||||
json!({
|
||||
"sortable_attributes": {
|
||||
"total": setting.as_ref().map(|sort| sort.len()),
|
||||
"has_geo": setting.as_ref().map(|sort| sort.contains("_geo")),
|
||||
},
|
||||
}),
|
||||
Some(req),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/displayed-attributes",
|
||||
put,
|
||||
Vec<String>,
|
||||
displayed_attributes,
|
||||
"displayedAttributes"
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/typo-tolerance",
|
||||
patch,
|
||||
meilisearch_lib::index::updates::TypoSettings,
|
||||
typo_tolerance,
|
||||
"typoTolerance",
|
||||
analytics,
|
||||
|setting: &Option<meilisearch_lib::index::updates::TypoSettings>, req: &HttpRequest| {
|
||||
use serde_json::json;
|
||||
|
||||
analytics.publish(
|
||||
"TypoTolerance Updated".to_string(),
|
||||
json!({
|
||||
"typo_tolerance": {
|
||||
"enabled": setting.as_ref().map(|s| !matches!(s.enabled, Setting::Set(false))),
|
||||
"disable_on_attributes": setting
|
||||
.as_ref()
|
||||
.and_then(|s| s.disable_on_attributes.as_ref().set().map(|m| !m.is_empty())),
|
||||
"disable_on_words": setting
|
||||
.as_ref()
|
||||
.and_then(|s| s.disable_on_words.as_ref().set().map(|m| !m.is_empty())),
|
||||
"min_word_size_for_one_typo": setting
|
||||
.as_ref()
|
||||
.and_then(|s| s.min_word_size_for_typos
|
||||
.as_ref()
|
||||
.set()
|
||||
.map(|s| s.one_typo.set()))
|
||||
.flatten(),
|
||||
"min_word_size_for_two_typos": setting
|
||||
.as_ref()
|
||||
.and_then(|s| s.min_word_size_for_typos
|
||||
.as_ref()
|
||||
.set()
|
||||
.map(|s| s.two_typos.set()))
|
||||
.flatten(),
|
||||
},
|
||||
}),
|
||||
Some(req),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/searchable-attributes",
|
||||
put,
|
||||
Vec<String>,
|
||||
searchable_attributes,
|
||||
"searchableAttributes",
|
||||
analytics,
|
||||
|setting: &Option<Vec<String>>, req: &HttpRequest| {
|
||||
use serde_json::json;
|
||||
|
||||
analytics.publish(
|
||||
"SearchableAttributes Updated".to_string(),
|
||||
json!({
|
||||
"searchable_attributes": {
|
||||
"total": setting.as_ref().map(|searchable| searchable.len()),
|
||||
},
|
||||
}),
|
||||
Some(req),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/stop-words",
|
||||
put,
|
||||
std::collections::BTreeSet<String>,
|
||||
stop_words,
|
||||
"stopWords"
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/synonyms",
|
||||
put,
|
||||
std::collections::BTreeMap<String, Vec<String>>,
|
||||
synonyms,
|
||||
"synonyms"
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/distinct-attribute",
|
||||
put,
|
||||
String,
|
||||
distinct_attribute,
|
||||
"distinctAttribute"
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/ranking-rules",
|
||||
put,
|
||||
Vec<String>,
|
||||
ranking_rules,
|
||||
"rankingRules",
|
||||
analytics,
|
||||
|setting: &Option<Vec<String>>, req: &HttpRequest| {
|
||||
use serde_json::json;
|
||||
|
||||
analytics.publish(
|
||||
"RankingRules Updated".to_string(),
|
||||
json!({
|
||||
"ranking_rules": {
|
||||
"sort_position": setting.as_ref().map(|sort| sort.iter().position(|s| s == "sort")),
|
||||
}
|
||||
}),
|
||||
Some(req),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/faceting",
|
||||
patch,
|
||||
meilisearch_lib::index::updates::FacetingSettings,
|
||||
faceting,
|
||||
"faceting",
|
||||
analytics,
|
||||
|setting: &Option<meilisearch_lib::index::updates::FacetingSettings>, req: &HttpRequest| {
|
||||
use serde_json::json;
|
||||
|
||||
analytics.publish(
|
||||
"Faceting Updated".to_string(),
|
||||
json!({
|
||||
"faceting": {
|
||||
"max_values_per_facet": setting.as_ref().and_then(|s| s.max_values_per_facet.set()),
|
||||
},
|
||||
}),
|
||||
Some(req),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
make_setting_route!(
|
||||
"/pagination",
|
||||
patch,
|
||||
meilisearch_lib::index::updates::PaginationSettings,
|
||||
pagination,
|
||||
"pagination",
|
||||
analytics,
|
||||
|setting: &Option<meilisearch_lib::index::updates::PaginationSettings>, req: &HttpRequest| {
|
||||
use serde_json::json;
|
||||
|
||||
analytics.publish(
|
||||
"Pagination Updated".to_string(),
|
||||
json!({
|
||||
"pagination": {
|
||||
"max_total_hits": setting.as_ref().and_then(|s| s.max_total_hits.set()),
|
||||
},
|
||||
}),
|
||||
Some(req),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
macro_rules! generate_configure {
|
||||
($($mod:ident),*) => {
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
use crate::extractors::sequential_extractor::SeqHandler;
|
||||
cfg.service(
|
||||
web::resource("")
|
||||
.route(web::patch().to(SeqHandler(update_all)))
|
||||
.route(web::get().to(SeqHandler(get_all)))
|
||||
.route(web::delete().to(SeqHandler(delete_all))))
|
||||
$(.service($mod::resources()))*;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
generate_configure!(
|
||||
filterable_attributes,
|
||||
sortable_attributes,
|
||||
displayed_attributes,
|
||||
searchable_attributes,
|
||||
distinct_attribute,
|
||||
stop_words,
|
||||
synonyms,
|
||||
ranking_rules,
|
||||
typo_tolerance,
|
||||
pagination,
|
||||
faceting
|
||||
);
|
||||
|
||||
pub async fn update_all(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
body: web::Json<Settings<Unchecked>>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let settings = body.into_inner();
|
||||
|
||||
analytics.publish(
|
||||
"Settings Updated".to_string(),
|
||||
json!({
|
||||
"ranking_rules": {
|
||||
"sort_position": settings.ranking_rules.as_ref().set().map(|sort| sort.iter().position(|s| s == "sort")),
|
||||
},
|
||||
"searchable_attributes": {
|
||||
"total": settings.searchable_attributes.as_ref().set().map(|searchable| searchable.len()),
|
||||
},
|
||||
"sortable_attributes": {
|
||||
"total": settings.sortable_attributes.as_ref().set().map(|sort| sort.len()),
|
||||
"has_geo": settings.sortable_attributes.as_ref().set().map(|sort| sort.iter().any(|s| s == "_geo")),
|
||||
},
|
||||
"filterable_attributes": {
|
||||
"total": settings.filterable_attributes.as_ref().set().map(|filter| filter.len()),
|
||||
"has_geo": settings.filterable_attributes.as_ref().set().map(|filter| filter.iter().any(|s| s == "_geo")),
|
||||
},
|
||||
"typo_tolerance": {
|
||||
"enabled": settings.typo_tolerance
|
||||
.as_ref()
|
||||
.set()
|
||||
.and_then(|s| s.enabled.as_ref().set())
|
||||
.copied(),
|
||||
"disable_on_attributes": settings.typo_tolerance
|
||||
.as_ref()
|
||||
.set()
|
||||
.and_then(|s| s.disable_on_attributes.as_ref().set().map(|m| !m.is_empty())),
|
||||
"disable_on_words": settings.typo_tolerance
|
||||
.as_ref()
|
||||
.set()
|
||||
.and_then(|s| s.disable_on_words.as_ref().set().map(|m| !m.is_empty())),
|
||||
"min_word_size_for_one_typo": settings.typo_tolerance
|
||||
.as_ref()
|
||||
.set()
|
||||
.and_then(|s| s.min_word_size_for_typos
|
||||
.as_ref()
|
||||
.set()
|
||||
.map(|s| s.one_typo.set()))
|
||||
.flatten(),
|
||||
"min_word_size_for_two_typos": settings.typo_tolerance
|
||||
.as_ref()
|
||||
.set()
|
||||
.and_then(|s| s.min_word_size_for_typos
|
||||
.as_ref()
|
||||
.set()
|
||||
.map(|s| s.two_typos.set()))
|
||||
.flatten(),
|
||||
},
|
||||
"faceting": {
|
||||
"max_values_per_facet": settings.faceting
|
||||
.as_ref()
|
||||
.set()
|
||||
.and_then(|s| s.max_values_per_facet.as_ref().set()),
|
||||
},
|
||||
"pagination": {
|
||||
"max_total_hits": settings.pagination
|
||||
.as_ref()
|
||||
.set()
|
||||
.and_then(|s| s.max_total_hits.as_ref().set()),
|
||||
},
|
||||
}),
|
||||
Some(&req),
|
||||
);
|
||||
|
||||
let allow_index_creation = meilisearch.filters().allow_index_creation;
|
||||
let update = Update::Settings {
|
||||
settings,
|
||||
is_deletion: false,
|
||||
allow_index_creation,
|
||||
};
|
||||
let task: SummarizedTaskView = meilisearch
|
||||
.register_update(index_uid.into_inner(), update)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
|
||||
pub async fn get_all(
|
||||
data: GuardedData<ActionPolicy<{ actions::SETTINGS_GET }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let settings = data.settings(index_uid.into_inner()).await?;
|
||||
debug!("returns: {:?}", settings);
|
||||
Ok(HttpResponse::Ok().json(settings))
|
||||
}
|
||||
|
||||
pub async fn delete_all(
|
||||
data: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let settings = Settings::cleared().into_unchecked();
|
||||
|
||||
let allow_index_creation = data.filters().allow_index_creation;
|
||||
let update = Update::Settings {
|
||||
settings,
|
||||
is_deletion: true,
|
||||
allow_index_creation,
|
||||
};
|
||||
let task: SummarizedTaskView = data
|
||||
.register_update(index_uid.into_inner(), update)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
debug!("returns: {:?}", task);
|
||||
Ok(HttpResponse::Accepted().json(task))
|
||||
}
|
||||
271
meilisearch-http/src/routes/mod.rs
Normal file
271
meilisearch-http/src/routes/mod.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use meilisearch_lib::index::{Settings, Unchecked};
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use meilisearch_types::star_or::StarOr;
|
||||
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
|
||||
mod api_key;
|
||||
mod dump;
|
||||
pub mod indexes;
|
||||
mod tasks;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/tasks").configure(tasks::configure))
|
||||
.service(web::resource("/health").route(web::get().to(get_health)))
|
||||
.service(web::scope("/keys").configure(api_key::configure))
|
||||
.service(web::scope("/dumps").configure(dump::configure))
|
||||
.service(web::resource("/stats").route(web::get().to(get_stats)))
|
||||
.service(web::resource("/version").route(web::get().to(get_version)))
|
||||
.service(web::scope("/indexes").configure(indexes::configure));
|
||||
}
|
||||
|
||||
/// Extracts the raw values from the `StarOr` types and
|
||||
/// return None if a `StarOr::Star` is encountered.
|
||||
pub fn fold_star_or<T, O>(content: impl IntoIterator<Item = StarOr<T>>) -> Option<O>
|
||||
where
|
||||
O: FromIterator<T>,
|
||||
{
|
||||
content
|
||||
.into_iter()
|
||||
.map(|value| match value {
|
||||
StarOr::Star => None,
|
||||
StarOr::Other(val) => Some(val),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const PAGINATION_DEFAULT_LIMIT: fn() -> usize = || 20;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Pagination {
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
#[serde(default = "PAGINATION_DEFAULT_LIMIT")]
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PaginationView<T> {
|
||||
pub results: Vec<T>,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
impl Pagination {
|
||||
/// Given the full data to paginate, returns the selected section.
|
||||
pub fn auto_paginate_sized<T>(
|
||||
self,
|
||||
content: impl IntoIterator<Item = T> + ExactSizeIterator,
|
||||
) -> PaginationView<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let total = content.len();
|
||||
let content: Vec<_> = content
|
||||
.into_iter()
|
||||
.skip(self.offset)
|
||||
.take(self.limit)
|
||||
.collect();
|
||||
self.format_with(total, content)
|
||||
}
|
||||
|
||||
/// Given an iterator and the total number of elements, returns the selected section.
|
||||
pub fn auto_paginate_unsized<T>(
|
||||
self,
|
||||
total: usize,
|
||||
content: impl IntoIterator<Item = T>,
|
||||
) -> PaginationView<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let content: Vec<_> = content
|
||||
.into_iter()
|
||||
.skip(self.offset)
|
||||
.take(self.limit)
|
||||
.collect();
|
||||
self.format_with(total, content)
|
||||
}
|
||||
|
||||
/// Given the data already paginated + the total number of elements, it stores
|
||||
/// everything in a [PaginationResult].
|
||||
pub fn format_with<T>(self, total: usize, results: Vec<T>) -> PaginationView<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
PaginationView {
|
||||
results,
|
||||
offset: self.offset,
|
||||
limit: self.limit,
|
||||
total,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PaginationView<T> {
|
||||
pub fn new(offset: usize, limit: usize, total: usize, results: Vec<T>) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
limit,
|
||||
results,
|
||||
total,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[serde(tag = "name")]
|
||||
pub enum UpdateType {
|
||||
ClearAll,
|
||||
Customs,
|
||||
DocumentsAddition {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
number: Option<usize>,
|
||||
},
|
||||
DocumentsPartial {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
number: Option<usize>,
|
||||
},
|
||||
DocumentsDeletion {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
number: Option<usize>,
|
||||
},
|
||||
Settings {
|
||||
settings: Settings<Unchecked>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessedUpdateResult {
|
||||
pub update_id: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub update_type: UpdateType,
|
||||
pub duration: f64, // in seconds
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub enqueued_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub processed_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FailedUpdateResult {
|
||||
pub update_id: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub update_type: UpdateType,
|
||||
pub error: ResponseError,
|
||||
pub duration: f64, // in seconds
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub enqueued_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub processed_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnqueuedUpdateResult {
|
||||
pub update_id: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub update_type: UpdateType,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub enqueued_at: OffsetDateTime,
|
||||
#[serde(
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "time::serde::rfc3339::option"
|
||||
)]
|
||||
pub started_processing_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "status")]
|
||||
pub enum UpdateStatusResponse {
|
||||
Enqueued {
|
||||
#[serde(flatten)]
|
||||
content: EnqueuedUpdateResult,
|
||||
},
|
||||
Processing {
|
||||
#[serde(flatten)]
|
||||
content: EnqueuedUpdateResult,
|
||||
},
|
||||
Failed {
|
||||
#[serde(flatten)]
|
||||
content: FailedUpdateResult,
|
||||
},
|
||||
Processed {
|
||||
#[serde(flatten)]
|
||||
content: ProcessedUpdateResult,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexUpdateResponse {
|
||||
pub update_id: u64,
|
||||
}
|
||||
|
||||
impl IndexUpdateResponse {
|
||||
pub fn with_id(update_id: u64) -> Self {
|
||||
Self { update_id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Always return a 200 with:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "status": "Meilisearch is running"
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn running() -> HttpResponse {
|
||||
HttpResponse::Ok().json(serde_json::json!({ "status": "Meilisearch is running" }))
|
||||
}
|
||||
|
||||
async fn get_stats(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::STATS_GET }>, MeiliSearch>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let search_rules = &meilisearch.filters().search_rules;
|
||||
let response = meilisearch.get_all_stats(search_rules).await?;
|
||||
|
||||
debug!("returns: {:?}", response);
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VersionResponse {
|
||||
commit_sha: String,
|
||||
commit_date: String,
|
||||
pkg_version: String,
|
||||
}
|
||||
|
||||
async fn get_version(
|
||||
_meilisearch: GuardedData<ActionPolicy<{ actions::VERSION }>, MeiliSearch>,
|
||||
) -> HttpResponse {
|
||||
let commit_sha = option_env!("VERGEN_GIT_SHA").unwrap_or("unknown");
|
||||
let commit_date = option_env!("VERGEN_GIT_COMMIT_TIMESTAMP").unwrap_or("unknown");
|
||||
|
||||
HttpResponse::Ok().json(VersionResponse {
|
||||
commit_sha: commit_sha.to_string(),
|
||||
commit_date: commit_date.to_string(),
|
||||
pkg_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct KeysResponse {
|
||||
private: Option<String>,
|
||||
public: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_health() -> Result<HttpResponse, ResponseError> {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "available" })))
|
||||
}
|
||||
203
meilisearch-http/src/routes/tasks.rs
Normal file
203
meilisearch-http/src/routes/tasks.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use meilisearch_lib::tasks::task::{TaskContent, TaskEvent, TaskId};
|
||||
use meilisearch_lib::tasks::TaskFilter;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use meilisearch_types::index_uid::IndexUid;
|
||||
use meilisearch_types::star_or::StarOr;
|
||||
use serde::Deserialize;
|
||||
use serde_cs::vec::CS;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::analytics::Analytics;
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use crate::extractors::sequential_extractor::SeqHandler;
|
||||
use crate::task::{TaskListView, TaskStatus, TaskType, TaskView};
|
||||
|
||||
use super::fold_star_or;
|
||||
|
||||
const DEFAULT_LIMIT: fn() -> usize = || 20;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::resource("").route(web::get().to(SeqHandler(get_tasks))))
|
||||
.service(web::resource("/{task_id}").route(web::get().to(SeqHandler(get_task))));
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct TasksFilterQuery {
|
||||
#[serde(rename = "type")]
|
||||
type_: Option<CS<StarOr<TaskType>>>,
|
||||
status: Option<CS<StarOr<TaskStatus>>>,
|
||||
index_uid: Option<CS<StarOr<IndexUid>>>,
|
||||
#[serde(default = "DEFAULT_LIMIT")]
|
||||
limit: usize,
|
||||
from: Option<TaskId>,
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
fn task_type_matches_content(type_: &TaskType, content: &TaskContent) -> bool {
|
||||
matches!((type_, content),
|
||||
(TaskType::IndexCreation, TaskContent::IndexCreation { .. })
|
||||
| (TaskType::IndexUpdate, TaskContent::IndexUpdate { .. })
|
||||
| (TaskType::IndexDeletion, TaskContent::IndexDeletion { .. })
|
||||
| (TaskType::DocumentAdditionOrUpdate, TaskContent::DocumentAddition { .. })
|
||||
| (TaskType::DocumentDeletion, TaskContent::DocumentDeletion{ .. })
|
||||
| (TaskType::SettingsUpdate, TaskContent::SettingsUpdate { .. })
|
||||
)
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
fn task_status_matches_events(status: &TaskStatus, events: &[TaskEvent]) -> bool {
|
||||
events.last().map_or(false, |event| {
|
||||
matches!((status, event),
|
||||
(TaskStatus::Enqueued, TaskEvent::Created(_))
|
||||
| (TaskStatus::Processing, TaskEvent::Processing(_) | TaskEvent::Batched { .. })
|
||||
| (TaskStatus::Succeeded, TaskEvent::Succeeded { .. })
|
||||
| (TaskStatus::Failed, TaskEvent::Failed { .. }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_tasks(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::TASKS_GET }>, MeiliSearch>,
|
||||
params: web::Query<TasksFilterQuery>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let TasksFilterQuery {
|
||||
type_,
|
||||
status,
|
||||
index_uid,
|
||||
limit,
|
||||
from,
|
||||
} = params.into_inner();
|
||||
|
||||
let search_rules = &meilisearch.filters().search_rules;
|
||||
|
||||
// We first transform a potential indexUid=* into a "not specified indexUid filter"
|
||||
// for every one of the filters: type, status, and indexUid.
|
||||
let type_: Option<Vec<_>> = type_.and_then(fold_star_or);
|
||||
let status: Option<Vec<_>> = status.and_then(fold_star_or);
|
||||
let index_uid: Option<Vec<_>> = index_uid.and_then(fold_star_or);
|
||||
|
||||
analytics.publish(
|
||||
"Tasks Seen".to_string(),
|
||||
json!({
|
||||
"filtered_by_index_uid": index_uid.as_ref().map_or(false, |v| !v.is_empty()),
|
||||
"filtered_by_type": type_.as_ref().map_or(false, |v| !v.is_empty()),
|
||||
"filtered_by_status": status.as_ref().map_or(false, |v| !v.is_empty()),
|
||||
}),
|
||||
Some(&req),
|
||||
);
|
||||
|
||||
// Then we filter on potential indexes and make sure that the search filter
|
||||
// restrictions are also applied.
|
||||
let indexes_filters = match index_uid {
|
||||
Some(indexes) => {
|
||||
let mut filters = TaskFilter::default();
|
||||
for name in indexes {
|
||||
if search_rules.is_index_authorized(&name) {
|
||||
filters.filter_index(name.to_string());
|
||||
}
|
||||
}
|
||||
Some(filters)
|
||||
}
|
||||
None => {
|
||||
if search_rules.is_index_authorized("*") {
|
||||
None
|
||||
} else {
|
||||
let mut filters = TaskFilter::default();
|
||||
for (index, _policy) in search_rules.clone() {
|
||||
filters.filter_index(index);
|
||||
}
|
||||
Some(filters)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Then we complete the task filter with other potential status and types filters.
|
||||
let filters = if type_.is_some() || status.is_some() {
|
||||
let mut filters = indexes_filters.unwrap_or_default();
|
||||
filters.filter_fn(move |task| {
|
||||
let matches_type = match &type_ {
|
||||
Some(types) => types
|
||||
.iter()
|
||||
.any(|t| task_type_matches_content(t, &task.content)),
|
||||
None => true,
|
||||
};
|
||||
|
||||
let matches_status = match &status {
|
||||
Some(statuses) => statuses
|
||||
.iter()
|
||||
.any(|t| task_status_matches_events(t, &task.events)),
|
||||
None => true,
|
||||
};
|
||||
|
||||
matches_type && matches_status
|
||||
});
|
||||
Some(filters)
|
||||
} else {
|
||||
indexes_filters
|
||||
};
|
||||
|
||||
// We +1 just to know if there is more after this "page" or not.
|
||||
let limit = limit.saturating_add(1);
|
||||
|
||||
let mut tasks_results: Vec<_> = meilisearch
|
||||
.list_tasks(filters, Some(limit), from)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(TaskView::from)
|
||||
.collect();
|
||||
|
||||
// If we were able to fetch the number +1 tasks we asked
|
||||
// it means that there is more to come.
|
||||
let next = if tasks_results.len() == limit {
|
||||
tasks_results.pop().map(|t| t.uid)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let from = tasks_results.first().map(|t| t.uid);
|
||||
|
||||
let tasks = TaskListView {
|
||||
results: tasks_results,
|
||||
limit: limit.saturating_sub(1),
|
||||
from,
|
||||
next,
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(tasks))
|
||||
}
|
||||
|
||||
async fn get_task(
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::TASKS_GET }>, MeiliSearch>,
|
||||
task_id: web::Path<TaskId>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
analytics.publish(
|
||||
"Tasks Seen".to_string(),
|
||||
json!({ "per_task_uid": true }),
|
||||
Some(&req),
|
||||
);
|
||||
|
||||
let search_rules = &meilisearch.filters().search_rules;
|
||||
let filters = if search_rules.is_index_authorized("*") {
|
||||
None
|
||||
} else {
|
||||
let mut filters = TaskFilter::default();
|
||||
for (index, _policy) in search_rules.clone() {
|
||||
filters.filter_index(index);
|
||||
}
|
||||
Some(filters)
|
||||
};
|
||||
|
||||
let task: TaskView = meilisearch
|
||||
.get_task(task_id.into_inner(), filters)
|
||||
.await?
|
||||
.into();
|
||||
|
||||
Ok(HttpResponse::Ok().json(task))
|
||||
}
|
||||
450
meilisearch-http/src/task.rs
Normal file
450
meilisearch-http/src/task.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Write};
|
||||
use std::str::FromStr;
|
||||
use std::write;
|
||||
|
||||
use meilisearch_lib::index::{Settings, Unchecked};
|
||||
use meilisearch_lib::tasks::batch::BatchId;
|
||||
use meilisearch_lib::tasks::task::{
|
||||
DocumentDeletion, Task, TaskContent, TaskEvent, TaskId, TaskResult,
|
||||
};
|
||||
use meilisearch_types::error::ResponseError;
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use crate::AUTOBATCHING_ENABLED;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TaskType {
|
||||
IndexCreation,
|
||||
IndexUpdate,
|
||||
IndexDeletion,
|
||||
DocumentAdditionOrUpdate,
|
||||
DocumentDeletion,
|
||||
SettingsUpdate,
|
||||
DumpCreation,
|
||||
}
|
||||
|
||||
impl From<TaskContent> for TaskType {
|
||||
fn from(other: TaskContent) -> Self {
|
||||
match other {
|
||||
TaskContent::IndexCreation { .. } => TaskType::IndexCreation,
|
||||
TaskContent::IndexUpdate { .. } => TaskType::IndexUpdate,
|
||||
TaskContent::IndexDeletion { .. } => TaskType::IndexDeletion,
|
||||
TaskContent::DocumentAddition { .. } => TaskType::DocumentAdditionOrUpdate,
|
||||
TaskContent::DocumentDeletion { .. } => TaskType::DocumentDeletion,
|
||||
TaskContent::SettingsUpdate { .. } => TaskType::SettingsUpdate,
|
||||
TaskContent::Dump { .. } => TaskType::DumpCreation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TaskTypeError {
|
||||
invalid_type: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for TaskTypeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"invalid task type `{}`, expecting one of: \
|
||||
indexCreation, indexUpdate, indexDeletion, documentAdditionOrUpdate, \
|
||||
documentDeletion, settingsUpdate, dumpCreation",
|
||||
self.invalid_type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for TaskTypeError {}
|
||||
|
||||
impl FromStr for TaskType {
|
||||
type Err = TaskTypeError;
|
||||
|
||||
fn from_str(type_: &str) -> Result<Self, TaskTypeError> {
|
||||
if type_.eq_ignore_ascii_case("indexCreation") {
|
||||
Ok(TaskType::IndexCreation)
|
||||
} else if type_.eq_ignore_ascii_case("indexUpdate") {
|
||||
Ok(TaskType::IndexUpdate)
|
||||
} else if type_.eq_ignore_ascii_case("indexDeletion") {
|
||||
Ok(TaskType::IndexDeletion)
|
||||
} else if type_.eq_ignore_ascii_case("documentAdditionOrUpdate") {
|
||||
Ok(TaskType::DocumentAdditionOrUpdate)
|
||||
} else if type_.eq_ignore_ascii_case("documentDeletion") {
|
||||
Ok(TaskType::DocumentDeletion)
|
||||
} else if type_.eq_ignore_ascii_case("settingsUpdate") {
|
||||
Ok(TaskType::SettingsUpdate)
|
||||
} else if type_.eq_ignore_ascii_case("dumpCreation") {
|
||||
Ok(TaskType::DumpCreation)
|
||||
} else {
|
||||
Err(TaskTypeError {
|
||||
invalid_type: type_.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TaskStatus {
|
||||
Enqueued,
|
||||
Processing,
|
||||
Succeeded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TaskStatusError {
|
||||
invalid_status: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for TaskStatusError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"invalid task status `{}`, expecting one of: \
|
||||
enqueued, processing, succeeded, or failed",
|
||||
self.invalid_status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for TaskStatusError {}
|
||||
|
||||
impl FromStr for TaskStatus {
|
||||
type Err = TaskStatusError;
|
||||
|
||||
fn from_str(status: &str) -> Result<Self, TaskStatusError> {
|
||||
if status.eq_ignore_ascii_case("enqueued") {
|
||||
Ok(TaskStatus::Enqueued)
|
||||
} else if status.eq_ignore_ascii_case("processing") {
|
||||
Ok(TaskStatus::Processing)
|
||||
} else if status.eq_ignore_ascii_case("succeeded") {
|
||||
Ok(TaskStatus::Succeeded)
|
||||
} else if status.eq_ignore_ascii_case("failed") {
|
||||
Ok(TaskStatus::Failed)
|
||||
} else {
|
||||
Err(TaskStatusError {
|
||||
invalid_status: status.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum TaskDetails {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DocumentAddition {
|
||||
received_documents: usize,
|
||||
indexed_documents: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Settings {
|
||||
#[serde(flatten)]
|
||||
settings: Settings<Unchecked>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
IndexInfo { primary_key: Option<String> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DocumentDeletion {
|
||||
received_document_ids: usize,
|
||||
deleted_documents: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ClearAll { deleted_documents: Option<u64> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Dump { dump_uid: String },
|
||||
}
|
||||
|
||||
/// Serialize a `time::Duration` as a best effort ISO 8601 while waiting for
|
||||
/// https://github.com/time-rs/time/issues/378.
|
||||
/// This code is a port of the old code of time that was removed in 0.2.
|
||||
fn serialize_duration<S: Serializer>(
|
||||
duration: &Option<Duration>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
match duration {
|
||||
Some(duration) => {
|
||||
// technically speaking, negative duration is not valid ISO 8601
|
||||
if duration.is_negative() {
|
||||
return serializer.serialize_none();
|
||||
}
|
||||
|
||||
const SECS_PER_DAY: i64 = Duration::DAY.whole_seconds();
|
||||
let secs = duration.whole_seconds();
|
||||
let days = secs / SECS_PER_DAY;
|
||||
let secs = secs - days * SECS_PER_DAY;
|
||||
let hasdate = days != 0;
|
||||
let nanos = duration.subsec_nanoseconds();
|
||||
let hastime = (secs != 0 || nanos != 0) || !hasdate;
|
||||
|
||||
// all the following unwrap can't fail
|
||||
let mut res = String::new();
|
||||
write!(&mut res, "P").unwrap();
|
||||
|
||||
if hasdate {
|
||||
write!(&mut res, "{}D", days).unwrap();
|
||||
}
|
||||
|
||||
const NANOS_PER_MILLI: i32 = Duration::MILLISECOND.subsec_nanoseconds();
|
||||
const NANOS_PER_MICRO: i32 = Duration::MICROSECOND.subsec_nanoseconds();
|
||||
|
||||
if hastime {
|
||||
if nanos == 0 {
|
||||
write!(&mut res, "T{}S", secs).unwrap();
|
||||
} else if nanos % NANOS_PER_MILLI == 0 {
|
||||
write!(&mut res, "T{}.{:03}S", secs, nanos / NANOS_PER_MILLI).unwrap();
|
||||
} else if nanos % NANOS_PER_MICRO == 0 {
|
||||
write!(&mut res, "T{}.{:06}S", secs, nanos / NANOS_PER_MICRO).unwrap();
|
||||
} else {
|
||||
write!(&mut res, "T{}.{:09}S", secs, nanos).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
serializer.serialize_str(&res)
|
||||
}
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskView {
|
||||
pub uid: TaskId,
|
||||
index_uid: Option<String>,
|
||||
status: TaskStatus,
|
||||
#[serde(rename = "type")]
|
||||
task_type: TaskType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<TaskDetails>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<ResponseError>,
|
||||
#[serde(serialize_with = "serialize_duration")]
|
||||
duration: Option<Duration>,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::serialize")]
|
||||
enqueued_at: OffsetDateTime,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::option::serialize")]
|
||||
started_at: Option<OffsetDateTime>,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::option::serialize")]
|
||||
finished_at: Option<OffsetDateTime>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
batch_uid: Option<Option<BatchId>>,
|
||||
}
|
||||
|
||||
impl From<Task> for TaskView {
|
||||
fn from(task: Task) -> Self {
|
||||
let index_uid = task.index_uid().map(String::from);
|
||||
let Task {
|
||||
id,
|
||||
content,
|
||||
events,
|
||||
} = task;
|
||||
|
||||
let (task_type, mut details) = match content {
|
||||
TaskContent::DocumentAddition {
|
||||
documents_count, ..
|
||||
} => {
|
||||
let details = TaskDetails::DocumentAddition {
|
||||
received_documents: documents_count,
|
||||
indexed_documents: None,
|
||||
};
|
||||
|
||||
(TaskType::DocumentAdditionOrUpdate, Some(details))
|
||||
}
|
||||
TaskContent::DocumentDeletion {
|
||||
deletion: DocumentDeletion::Ids(ids),
|
||||
..
|
||||
} => (
|
||||
TaskType::DocumentDeletion,
|
||||
Some(TaskDetails::DocumentDeletion {
|
||||
received_document_ids: ids.len(),
|
||||
deleted_documents: None,
|
||||
}),
|
||||
),
|
||||
TaskContent::DocumentDeletion {
|
||||
deletion: DocumentDeletion::Clear,
|
||||
..
|
||||
} => (
|
||||
TaskType::DocumentDeletion,
|
||||
Some(TaskDetails::ClearAll {
|
||||
deleted_documents: None,
|
||||
}),
|
||||
),
|
||||
TaskContent::IndexDeletion { .. } => (
|
||||
TaskType::IndexDeletion,
|
||||
Some(TaskDetails::ClearAll {
|
||||
deleted_documents: None,
|
||||
}),
|
||||
),
|
||||
TaskContent::SettingsUpdate { settings, .. } => (
|
||||
TaskType::SettingsUpdate,
|
||||
Some(TaskDetails::Settings { settings }),
|
||||
),
|
||||
TaskContent::IndexCreation { primary_key, .. } => (
|
||||
TaskType::IndexCreation,
|
||||
Some(TaskDetails::IndexInfo { primary_key }),
|
||||
),
|
||||
TaskContent::IndexUpdate { primary_key, .. } => (
|
||||
TaskType::IndexUpdate,
|
||||
Some(TaskDetails::IndexInfo { primary_key }),
|
||||
),
|
||||
TaskContent::Dump { uid } => (
|
||||
TaskType::DumpCreation,
|
||||
Some(TaskDetails::Dump { dump_uid: uid }),
|
||||
),
|
||||
};
|
||||
|
||||
// An event always has at least one event: "Created"
|
||||
let (status, error, finished_at) = match events.last().unwrap() {
|
||||
TaskEvent::Created(_) => (TaskStatus::Enqueued, None, None),
|
||||
TaskEvent::Batched { .. } => (TaskStatus::Enqueued, None, None),
|
||||
TaskEvent::Processing(_) => (TaskStatus::Processing, None, None),
|
||||
TaskEvent::Succeeded { timestamp, result } => {
|
||||
match (result, &mut details) {
|
||||
(
|
||||
TaskResult::DocumentAddition {
|
||||
indexed_documents: num,
|
||||
..
|
||||
},
|
||||
Some(TaskDetails::DocumentAddition {
|
||||
ref mut indexed_documents,
|
||||
..
|
||||
}),
|
||||
) => {
|
||||
indexed_documents.replace(*num);
|
||||
}
|
||||
(
|
||||
TaskResult::DocumentDeletion {
|
||||
deleted_documents: docs,
|
||||
..
|
||||
},
|
||||
Some(TaskDetails::DocumentDeletion {
|
||||
ref mut deleted_documents,
|
||||
..
|
||||
}),
|
||||
) => {
|
||||
deleted_documents.replace(*docs);
|
||||
}
|
||||
(
|
||||
TaskResult::ClearAll {
|
||||
deleted_documents: docs,
|
||||
},
|
||||
Some(TaskDetails::ClearAll {
|
||||
ref mut deleted_documents,
|
||||
}),
|
||||
) => {
|
||||
deleted_documents.replace(*docs);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
(TaskStatus::Succeeded, None, Some(*timestamp))
|
||||
}
|
||||
TaskEvent::Failed { timestamp, error } => {
|
||||
match details {
|
||||
Some(TaskDetails::DocumentDeletion {
|
||||
ref mut deleted_documents,
|
||||
..
|
||||
}) => {
|
||||
deleted_documents.replace(0);
|
||||
}
|
||||
Some(TaskDetails::ClearAll {
|
||||
ref mut deleted_documents,
|
||||
..
|
||||
}) => {
|
||||
deleted_documents.replace(0);
|
||||
}
|
||||
Some(TaskDetails::DocumentAddition {
|
||||
ref mut indexed_documents,
|
||||
..
|
||||
}) => {
|
||||
indexed_documents.replace(0);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
(TaskStatus::Failed, Some(error.clone()), Some(*timestamp))
|
||||
}
|
||||
};
|
||||
|
||||
let enqueued_at = match events.first() {
|
||||
Some(TaskEvent::Created(ts)) => *ts,
|
||||
_ => unreachable!("A task must always have a creation event."),
|
||||
};
|
||||
|
||||
let started_at = events.iter().find_map(|e| match e {
|
||||
TaskEvent::Processing(ts) => Some(*ts),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let duration = finished_at.zip(started_at).map(|(tf, ts)| (tf - ts));
|
||||
|
||||
let batch_uid = if AUTOBATCHING_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
let id = events.iter().find_map(|e| match e {
|
||||
TaskEvent::Batched { batch_id, .. } => Some(*batch_id),
|
||||
_ => None,
|
||||
});
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
uid: id,
|
||||
index_uid,
|
||||
status,
|
||||
task_type,
|
||||
details,
|
||||
error,
|
||||
duration,
|
||||
enqueued_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
batch_uid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TaskListView {
|
||||
pub results: Vec<TaskView>,
|
||||
pub limit: usize,
|
||||
pub from: Option<TaskId>,
|
||||
pub next: Option<TaskId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SummarizedTaskView {
|
||||
task_uid: TaskId,
|
||||
index_uid: Option<String>,
|
||||
status: TaskStatus,
|
||||
#[serde(rename = "type")]
|
||||
task_type: TaskType,
|
||||
#[serde(serialize_with = "time::serde::rfc3339::serialize")]
|
||||
enqueued_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl From<Task> for SummarizedTaskView {
|
||||
fn from(mut other: Task) -> Self {
|
||||
let created_event = other
|
||||
.events
|
||||
.drain(..1)
|
||||
.next()
|
||||
.expect("Task must have an enqueued event.");
|
||||
|
||||
let enqueued_at = match created_event {
|
||||
TaskEvent::Created(ts) => ts,
|
||||
_ => unreachable!("The first event of a task must always be 'Created'"),
|
||||
};
|
||||
|
||||
Self {
|
||||
task_uid: other.id,
|
||||
index_uid: other.index_uid().map(String::from),
|
||||
status: TaskStatus::Enqueued,
|
||||
task_type: other.content.into(),
|
||||
enqueued_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
meilisearch-http/tests/assets/dumps/v1/metadata.json
Normal file
12
meilisearch-http/tests/assets/dumps/v1/metadata.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"indices": [{
|
||||
"uid": "test",
|
||||
"primaryKey": "id"
|
||||
}, {
|
||||
"uid": "test2",
|
||||
"primaryKey": "test2_id"
|
||||
}
|
||||
],
|
||||
"dbVersion": "0.13.0",
|
||||
"dumpVersion": "1"
|
||||
}
|
||||
77
meilisearch-http/tests/assets/dumps/v1/test/documents.jsonl
Normal file
77
meilisearch-http/tests/assets/dumps/v1/test/documents.jsonl
Normal file
@@ -0,0 +1,77 @@
|
||||
{"id":0,"isActive":false,"balance":"$2,668.55","picture":"http://placehold.it/32x32","age":36,"color":"Green","name":"Lucas Hess","gender":"male","email":"lucashess@chorizon.com","phone":"+1 (998) 478-2597","address":"412 Losee Terrace, Blairstown, Georgia, 2825","about":"Mollit ad in exercitation quis. Anim est ut consequat fugiat duis magna aliquip velit nisi. Commodo eiusmod est consequat proident consectetur aliqua enim fugiat. Aliqua adipisicing laboris elit proident enim veniam laboris mollit. Incididunt fugiat minim ad nostrud deserunt tempor in. Id irure officia labore qui est labore nulla nisi. Magna sit quis tempor esse consectetur amet labore duis aliqua consequat.\r\n","registered":"2016-06-21T09:30:25 -02:00","latitude":-44.174957,"longitude":-145.725388,"tags":["bug","bug"]}
|
||||
{"id":1,"isActive":true,"balance":"$1,706.13","picture":"http://placehold.it/32x32","age":27,"color":"Green","name":"Cherry Orr","gender":"female","email":"cherryorr@chorizon.com","phone":"+1 (995) 479-3174","address":"442 Beverly Road, Ventress, New Mexico, 3361","about":"Exercitation officia mollit proident nostrud ea. Pariatur voluptate labore nostrud magna duis non elit et incididunt Lorem velit duis amet commodo. Irure in velit laboris pariatur. Do tempor ex deserunt duis minim amet.\r\n","registered":"2020-03-18T11:12:21 -01:00","latitude":-24.356932,"longitude":27.184808,"tags":["new issue","bug"]}
|
||||
{"id":2,"isActive":true,"balance":"$2,467.47","picture":"http://placehold.it/32x32","age":34,"color":"blue","name":"Patricia Goff","gender":"female","email":"patriciagoff@chorizon.com","phone":"+1 (864) 463-2277","address":"866 Hornell Loop, Cresaptown, Ohio, 1700","about":"Non culpa duis dolore Lorem aliqua. Labore veniam laborum cupidatat nostrud ea exercitation. Esse nostrud sit veniam laborum minim ullamco nulla aliqua est cillum magna. Duis non esse excepteur veniam voluptate sunt cupidatat nostrud consequat sint adipisicing ut excepteur. Incididunt sit aliquip non id magna amet deserunt esse quis dolor.\r\n","registered":"2014-10-28T12:59:30 -01:00","latitude":-64.008555,"longitude":11.867098,"tags":["good first issue"]}
|
||||
{"id":3,"isActive":true,"balance":"$3,344.40","picture":"http://placehold.it/32x32","age":35,"color":"blue","name":"Adeline Flynn","gender":"female","email":"adelineflynn@chorizon.com","phone":"+1 (994) 600-2840","address":"428 Paerdegat Avenue, Hollymead, Pennsylvania, 948","about":"Ex velit magna minim labore dolor id laborum incididunt. Proident dolor fugiat exercitation ad adipisicing amet dolore. Veniam nisi pariatur aute eu amet sint elit duis exercitation. Eu fugiat Lorem nostrud consequat aute sunt. Minim excepteur cillum laboris enim tempor adipisicing nulla reprehenderit ea velit Lorem qui in incididunt. Esse ipsum mollit deserunt ea exercitation ex aliqua anim magna cupidatat culpa.\r\n","registered":"2014-03-27T06:24:45 -01:00","latitude":-74.485173,"longitude":-11.059859,"tags":["bug","good first issue","wontfix","new issue"]}
|
||||
{"id":4,"isActive":false,"balance":"$2,575.78","picture":"http://placehold.it/32x32","age":39,"color":"Green","name":"Mariana Pacheco","gender":"female","email":"marianapacheco@chorizon.com","phone":"+1 (820) 414-2223","address":"664 Rapelye Street, Faywood, California, 7320","about":"Sint cillum enim eu Lorem dolore. Est excepteur cillum consequat incididunt. Ut consectetur et do culpa eiusmod ex ut id proident aliqua. Sunt dolor anim minim labore incididunt deserunt enim velit sunt ut in velit. Nulla ipsum cillum qui est minim officia in occaecat exercitation Lorem sunt. Aliqua minim excepteur tempor incididunt dolore. Quis amet ullamco et proident aliqua magna consequat.\r\n","registered":"2015-09-02T03:23:35 -02:00","latitude":75.763501,"longitude":-78.777124,"tags":["new issue"]}
|
||||
{"id":5,"isActive":true,"balance":"$3,793.09","picture":"http://placehold.it/32x32","age":20,"color":"Green","name":"Warren Watson","gender":"male","email":"warrenwatson@chorizon.com","phone":"+1 (807) 583-2427","address":"671 Prince Street, Faxon, Connecticut, 4275","about":"Cillum incididunt mollit labore ipsum elit ea. Lorem labore consectetur nulla ea fugiat sint esse cillum ea commodo id qui. Sint cillum mollit dolore enim quis esse. Nisi labore duis dolor tempor laborum laboris ad minim pariatur in excepteur sit. Aliqua anim amet sunt ullamco labore amet culpa irure esse eiusmod deserunt consequat Lorem nostrud.\r\n","registered":"2017-06-04T06:02:17 -02:00","latitude":29.979223,"longitude":25.358943,"tags":["wontfix","wontfix","wontfix"]}
|
||||
{"id":6,"isActive":true,"balance":"$2,919.70","picture":"http://placehold.it/32x32","age":20,"color":"blue","name":"Shelia Berry","gender":"female","email":"sheliaberry@chorizon.com","phone":"+1 (853) 511-2651","address":"437 Forrest Street, Coventry, Illinois, 2056","about":"Id occaecat qui voluptate proident culpa cillum nisi reprehenderit. Pariatur nostrud proident adipisicing reprehenderit eiusmod qui minim proident aliqua id cupidatat laboris deserunt. Proident sint laboris sit mollit dolor qui incididunt quis veniam cillum cupidatat ad nostrud ut. Aliquip consequat eiusmod eiusmod irure tempor do incididunt id culpa laboris eiusmod.\r\n","registered":"2018-07-11T02:45:01 -02:00","latitude":54.815991,"longitude":-118.690609,"tags":["good first issue","bug","wontfix","new issue"]}
|
||||
{"id":7,"isActive":true,"balance":"$1,349.50","picture":"http://placehold.it/32x32","age":28,"color":"Green","name":"Chrystal Boyd","gender":"female","email":"chrystalboyd@chorizon.com","phone":"+1 (936) 563-2802","address":"670 Croton Loop, Sussex, Florida, 4692","about":"Consequat ex voluptate consectetur laborum nulla. Qui voluptate Lorem amet labore est esse sunt. Nulla cupidatat consequat quis incididunt exercitation aliquip reprehenderit ea ea adipisicing reprehenderit id consectetur quis. Exercitation est incididunt ullamco non proident consequat. Nisi veniam aliquip fugiat voluptate ex id aute duis ullamco magna ipsum ad laborum ipsum. Cupidatat velit dolore esse nisi.\r\n","registered":"2016-11-01T07:36:04 -01:00","latitude":-24.711933,"longitude":147.246705,"tags":[]}
|
||||
{"id":8,"isActive":false,"balance":"$3,999.56","picture":"http://placehold.it/32x32","age":30,"color":"brown","name":"Martin Porter","gender":"male","email":"martinporter@chorizon.com","phone":"+1 (895) 580-2304","address":"577 Regent Place, Aguila, Guam, 6554","about":"Nostrud nulla labore ex excepteur labore enim cillum pariatur in do Lorem eiusmod ullamco est. Labore aliquip id ut nisi commodo pariatur ea esse laboris. Incididunt eu dolor esse excepteur nulla minim proident non cillum nisi dolore incididunt ipsum tempor.\r\n","registered":"2014-09-20T02:08:30 -02:00","latitude":-88.344273,"longitude":37.964466,"tags":[]}
|
||||
{"id":9,"isActive":true,"balance":"$3,729.71","picture":"http://placehold.it/32x32","age":26,"color":"blue","name":"Kelli Mendez","gender":"female","email":"kellimendez@chorizon.com","phone":"+1 (936) 401-2236","address":"242 Caton Place, Grazierville, Alabama, 3968","about":"Consectetur occaecat dolore esse eiusmod enim ea aliqua eiusmod amet velit laborum. Velit quis consequat consectetur velit fugiat labore commodo amet do. Magna minim est ad commodo consequat fugiat. Laboris duis Lorem ipsum irure sit ipsum consequat tempor sit. Est ad nulla duis quis velit anim id nulla. Cupidatat ea esse laboris eu veniam cupidatat proident veniam quis.\r\n","registered":"2018-05-04T10:35:30 -02:00","latitude":49.37551,"longitude":41.872323,"tags":["new issue","new issue"]}
|
||||
{"id":10,"isActive":false,"balance":"$1,127.47","picture":"http://placehold.it/32x32","age":27,"color":"blue","name":"Maddox Johns","gender":"male","email":"maddoxjohns@chorizon.com","phone":"+1 (892) 470-2357","address":"756 Beard Street, Avalon, Louisiana, 114","about":"Voluptate et dolor magna do do. Id do enim ut nulla esse culpa fugiat excepteur quis. Nostrud ad aliquip aliqua qui esse ut consequat proident deserunt esse cupidatat do elit fugiat. Sint cillum aliquip cillum laboris laborum laboris ad aliquip enim reprehenderit cillum eu sint. Sint ut ad duis do culpa non eiusmod amet non ipsum commodo. Pariatur aliquip sit deserunt non. Ut consequat pariatur deserunt veniam est sit eiusmod officia aliquip commodo sunt in eu duis.\r\n","registered":"2016-04-22T06:41:25 -02:00","latitude":66.640229,"longitude":-17.222666,"tags":["new issue","good first issue","good first issue","new issue"]}
|
||||
{"id":11,"isActive":true,"balance":"$1,351.43","picture":"http://placehold.it/32x32","age":28,"color":"Green","name":"Evans Wagner","gender":"male","email":"evanswagner@chorizon.com","phone":"+1 (889) 496-2332","address":"118 Monaco Place, Lutsen, Delaware, 6209","about":"Sunt consectetur enim ipsum consectetur occaecat reprehenderit nulla pariatur. Cupidatat do exercitation tempor voluptate duis nostrud dolor consectetur. Excepteur aliquip Lorem voluptate cillum est. Nisi velit nulla nostrud ea id officia laboris et.\r\n","registered":"2016-10-27T01:26:31 -02:00","latitude":-77.673222,"longitude":-142.657214,"tags":["good first issue","good first issue"]}
|
||||
{"id":12,"isActive":false,"balance":"$3,394.96","picture":"http://placehold.it/32x32","age":25,"color":"blue","name":"Aida Kirby","gender":"female","email":"aidakirby@chorizon.com","phone":"+1 (942) 532-2325","address":"797 Engert Avenue, Wilsonia, Idaho, 6532","about":"Mollit aute esse Lorem do laboris anim reprehenderit excepteur. Ipsum culpa esse voluptate officia cupidatat minim. Velit officia proident nostrud sunt irure labore. Culpa ex commodo amet dolor amet voluptate Lorem ex esse commodo fugiat quis non. Ex est adipisicing veniam sunt dolore ut aliqua nisi ex sit. Esse voluptate esse anim id adipisicing enim aute ea exercitation tempor cillum.\r\n","registered":"2018-06-18T04:39:57 -02:00","latitude":-58.062041,"longitude":34.999254,"tags":["new issue","wontfix","bug","new issue"]}
|
||||
{"id":13,"isActive":true,"balance":"$2,812.62","picture":"http://placehold.it/32x32","age":40,"color":"blue","name":"Nelda Burris","gender":"female","email":"neldaburris@chorizon.com","phone":"+1 (813) 600-2576","address":"160 Opal Court, Fowlerville, Tennessee, 2170","about":"Ipsum aliquip adipisicing elit magna. Veniam irure quis laborum laborum sint velit amet. Irure non eiusmod laborum fugiat qui quis Lorem culpa veniam commodo. Fugiat cupidatat dolore et consequat pariatur enim ex velit consequat deserunt quis. Deserunt et quis laborum cupidatat cillum minim cupidatat nisi do commodo commodo labore cupidatat ea. In excepteur sit nostrud nulla nostrud dolor sint. Et anim culpa aliquip laborum Lorem elit.\r\n","registered":"2015-08-15T12:39:53 -02:00","latitude":66.6871,"longitude":179.549488,"tags":["wontfix"]}
|
||||
{"id":14,"isActive":true,"balance":"$1,718.33","picture":"http://placehold.it/32x32","age":35,"color":"blue","name":"Jennifer Hart","gender":"female","email":"jenniferhart@chorizon.com","phone":"+1 (850) 537-2513","address":"124 Veranda Place, Nash, Utah, 985","about":"Amet amet voluptate in occaecat pariatur. Nulla ipsum esse quis qui in quis qui. Non est non nisi qui tempor commodo consequat fugiat. Sint eu ipsum aute anim anim. Ea nostrud excepteur exercitation consectetur Lorem.\r\n","registered":"2016-09-04T11:46:59 -02:00","latitude":-66.827751,"longitude":99.220079,"tags":["wontfix","bug","new issue","new issue"]}
|
||||
{"id":15,"isActive":false,"balance":"$2,698.16","picture":"http://placehold.it/32x32","age":28,"color":"blue","name":"Aurelia Contreras","gender":"female","email":"aureliacontreras@chorizon.com","phone":"+1 (932) 442-3103","address":"655 Dwight Street, Grapeview, Palau, 8356","about":"Qui adipisicing consectetur aute veniam culpa ipsum. Occaecat occaecat ut mollit enim enim elit Lorem nostrud Lorem. Consequat laborum mollit nulla aute cillum sunt mollit commodo velit culpa. Pariatur pariatur velit nostrud tempor. In minim enim cillum exercitation in laboris labore ea sunt in incididunt fugiat.\r\n","registered":"2014-09-11T10:43:15 -02:00","latitude":-71.328973,"longitude":133.404895,"tags":["wontfix","bug","good first issue"]}
|
||||
{"id":16,"isActive":true,"balance":"$3,303.25","picture":"http://placehold.it/32x32","age":28,"color":"brown","name":"Estella Bass","gender":"female","email":"estellabass@chorizon.com","phone":"+1 (825) 436-2909","address":"435 Rockwell Place, Garberville, Wisconsin, 2230","about":"Sit eiusmod mollit velit non. Qui ea in exercitation elit reprehenderit occaecat tempor minim officia. Culpa amet voluptate sit eiusmod pariatur.\r\n","registered":"2017-11-23T09:32:09 -01:00","latitude":81.17014,"longitude":-145.262693,"tags":["new issue"]}
|
||||
{"id":17,"isActive":false,"balance":"$3,579.20","picture":"http://placehold.it/32x32","age":25,"color":"brown","name":"Ortega Brennan","gender":"male","email":"ortegabrennan@chorizon.com","phone":"+1 (906) 526-2287","address":"440 Berry Street, Rivera, Maine, 1849","about":"Veniam velit non laboris consectetur sit aliquip enim proident velit in ipsum reprehenderit reprehenderit. Dolor qui nulla adipisicing ad magna dolore do ut duis et aute est. Qui est elit cupidatat nostrud. Laboris voluptate reprehenderit minim sint exercitation cupidatat ipsum sint consectetur velit sunt et officia incididunt. Ut amet Lorem minim deserunt officia officia irure qui et Lorem deserunt culpa sit.\r\n","registered":"2016-03-31T02:17:13 -02:00","latitude":-68.407524,"longitude":-113.642067,"tags":["new issue","wontfix"]}
|
||||
{"id":18,"isActive":false,"balance":"$1,484.92","picture":"http://placehold.it/32x32","age":39,"color":"blue","name":"Leonard Tillman","gender":"male","email":"leonardtillman@chorizon.com","phone":"+1 (864) 541-3456","address":"985 Provost Street, Charco, New Hampshire, 8632","about":"Consectetur ut magna sit id officia nostrud ipsum. Lorem cupidatat laborum nostrud aliquip magna qui est cupidatat exercitation et. Officia qui magna commodo id cillum magna ut ad veniam sunt sint ex. Id minim do in do exercitation aliquip incididunt ex esse. Nisi aliqua quis excepteur qui aute excepteur dolore eu pariatur irure id eu cupidatat eiusmod. Aliqua amet et dolore enim et eiusmod qui irure pariatur qui officia adipisicing nulla duis.\r\n","registered":"2018-05-06T08:21:27 -02:00","latitude":-8.581801,"longitude":-61.910062,"tags":["wontfix","new issue","bug","bug"]}
|
||||
{"id":19,"isActive":true,"balance":"$3,572.55","picture":"http://placehold.it/32x32","age":33,"color":"brown","name":"Dale Payne","gender":"male","email":"dalepayne@chorizon.com","phone":"+1 (814) 469-3499","address":"536 Dare Court, Ironton, Arkansas, 8605","about":"Et velit cupidatat velit incididunt mollit. Occaecat do labore aliqua dolore excepteur occaecat ut veniam ad ullamco tempor. Ut anim laboris deserunt culpa esse. Pariatur Lorem nulla cillum cupidatat nostrud Lorem commodo reprehenderit ut est. In dolor cillum reprehenderit laboris incididunt ad reprehenderit aute ipsum officia id in consequat. Culpa exercitation voluptate fugiat est Lorem ipsum in dolore dolor consequat Lorem et.\r\n","registered":"2019-10-11T01:01:33 -02:00","latitude":-18.280968,"longitude":-126.091797,"tags":["bug","wontfix","wontfix","wontfix"]}
|
||||
{"id":20,"isActive":true,"balance":"$1,986.48","picture":"http://placehold.it/32x32","age":38,"color":"Green","name":"Florence Long","gender":"female","email":"florencelong@chorizon.com","phone":"+1 (972) 557-3858","address":"519 Hendrickson Street, Templeton, Hawaii, 2389","about":"Quis officia occaecat veniam veniam. Ex minim enim labore cupidatat qui. Proident esse deserunt laborum laboris sunt nostrud.\r\n","registered":"2016-05-02T09:18:59 -02:00","latitude":-27.110866,"longitude":-45.09445,"tags":[]}
|
||||
{"id":21,"isActive":true,"balance":"$1,440.09","picture":"http://placehold.it/32x32","age":40,"color":"blue","name":"Levy Whitley","gender":"male","email":"levywhitley@chorizon.com","phone":"+1 (911) 458-2411","address":"187 Thomas Street, Hachita, North Carolina, 2989","about":"Velit laboris non minim elit sint deserunt fugiat. Aute minim ex commodo aute cillum aliquip fugiat pariatur nulla eiusmod pariatur consectetur. Qui ex ea qui laborum veniam adipisicing magna minim ut. In irure anim voluptate mollit et. Adipisicing labore ea mollit magna aliqua culpa velit est. Excepteur nisi veniam enim velit in ad officia irure laboris.\r\n","registered":"2014-04-30T07:31:38 -02:00","latitude":-6.537315,"longitude":171.813536,"tags":["bug"]}
|
||||
{"id":22,"isActive":false,"balance":"$2,938.57","picture":"http://placehold.it/32x32","age":35,"color":"blue","name":"Bernard Mcfarland","gender":"male","email":"bernardmcfarland@chorizon.com","phone":"+1 (979) 442-3386","address":"409 Hall Street, Keyport, Federated States Of Micronesia, 7011","about":"Reprehenderit irure aute et anim ullamco enim est tempor id ipsum mollit veniam aute ullamco. Consectetur dolor velit tempor est reprehenderit ut id non est ullamco voluptate. Commodo aute ullamco culpa non voluptate incididunt non culpa culpa nisi id proident cupidatat.\r\n","registered":"2017-08-10T10:07:59 -02:00","latitude":63.766795,"longitude":68.177069,"tags":[]}
|
||||
{"id":23,"isActive":true,"balance":"$1,678.49","picture":"http://placehold.it/32x32","age":31,"color":"brown","name":"Blanca Mcclain","gender":"female","email":"blancamcclain@chorizon.com","phone":"+1 (976) 439-2772","address":"176 Crooke Avenue, Valle, Virginia, 5373","about":"Aliquip sunt irure ut consectetur elit. Cillum amet incididunt et anim elit in incididunt adipisicing fugiat veniam esse veniam. Nisi qui sit occaecat tempor nostrud est aute cillum anim excepteur laboris magna in. Fugiat fugiat veniam cillum laborum ut pariatur amet nulla nulla. Nostrud mollit in laborum minim exercitation aute. Lorem aute ipsum laboris est adipisicing qui ullamco tempor adipisicing cupidatat mollit.\r\n","registered":"2015-10-12T11:57:28 -02:00","latitude":-8.944564,"longitude":-150.711709,"tags":["bug","wontfix","good first issue"]}
|
||||
{"id":24,"isActive":true,"balance":"$2,276.87","picture":"http://placehold.it/32x32","age":28,"color":"brown","name":"Espinoza Ford","gender":"male","email":"espinozaford@chorizon.com","phone":"+1 (945) 429-3975","address":"137 Bowery Street, Itmann, District Of Columbia, 1864","about":"Deserunt nisi aliquip esse occaecat laborum qui aliqua excepteur ea cupidatat dolore magna consequat. Culpa aliquip cillum incididunt proident est officia consequat duis. Elit tempor ut cupidatat nisi ea sint non labore aliquip amet. Deserunt labore cupidatat laboris dolor duis occaecat velit aliquip reprehenderit esse. Sit ad qui consectetur id anim nisi amet eiusmod.\r\n","registered":"2014-03-26T02:16:08 -01:00","latitude":-37.137666,"longitude":-51.811757,"tags":["wontfix","bug"]}
|
||||
{"id":25,"isActive":true,"balance":"$3,973.43","picture":"http://placehold.it/32x32","age":29,"color":"Green","name":"Sykes Conley","gender":"male","email":"sykesconley@chorizon.com","phone":"+1 (851) 401-3916","address":"345 Grand Street, Woodlands, Missouri, 4461","about":"Pariatur ullamco duis reprehenderit ad sit dolore. Dolore ex fugiat labore incididunt nostrud. Minim deserunt officia sunt enim magna elit veniam reprehenderit nisi cupidatat dolor eiusmod. Veniam laboris sint cillum et laboris nostrud culpa laboris anim. Incididunt velit pariatur cupidatat sit dolore in. Voluptate consectetur officia id nostrud velit mollit dolor. Id laboris consectetur culpa sunt pariatur minim sunt laboris sit.\r\n","registered":"2015-09-12T06:03:56 -02:00","latitude":67.282955,"longitude":-64.341323,"tags":["wontfix"]}
|
||||
{"id":26,"isActive":false,"balance":"$1,431.50","picture":"http://placehold.it/32x32","age":35,"color":"blue","name":"Barlow Duran","gender":"male","email":"barlowduran@chorizon.com","phone":"+1 (995) 436-2562","address":"481 Everett Avenue, Allison, Nebraska, 3065","about":"Proident quis eu officia adipisicing aliquip. Lorem laborum magna dolor et incididunt cillum excepteur et amet. Veniam consectetur officia fugiat magna consequat dolore elit aute exercitation fugiat excepteur ullamco. Sit qui proident reprehenderit ea ad qui culpa exercitation reprehenderit anim cupidatat. Nulla et duis Lorem cillum duis pariatur amet voluptate labore ut aliqua mollit anim ea. Nostrud incididunt et proident adipisicing non consequat tempor ullamco adipisicing incididunt. Incididunt cupidatat tempor fugiat officia qui eiusmod reprehenderit.\r\n","registered":"2017-06-29T04:28:43 -02:00","latitude":-38.70606,"longitude":55.02816,"tags":["new issue"]}
|
||||
{"id":27,"isActive":true,"balance":"$3,478.27","picture":"http://placehold.it/32x32","age":31,"color":"blue","name":"Schwartz Morgan","gender":"male","email":"schwartzmorgan@chorizon.com","phone":"+1 (861) 507-2067","address":"451 Lincoln Road, Fairlee, Washington, 2717","about":"Labore eiusmod sint dolore sunt eiusmod esse et in id aliquip. Aliqua consequat occaecat laborum labore ipsum enim non nostrud adipisicing adipisicing cillum occaecat. Duis minim est culpa sunt nulla ullamco adipisicing magna irure. Occaecat quis irure eiusmod fugiat quis commodo reprehenderit labore cillum commodo id et.\r\n","registered":"2016-05-10T08:34:54 -02:00","latitude":-75.886403,"longitude":93.044471,"tags":["bug","bug","wontfix","wontfix"]}
|
||||
{"id":28,"isActive":true,"balance":"$2,825.59","picture":"http://placehold.it/32x32","age":32,"color":"blue","name":"Kristy Leon","gender":"female","email":"kristyleon@chorizon.com","phone":"+1 (948) 465-2563","address":"594 Macon Street, Floris, South Dakota, 3565","about":"Proident veniam voluptate magna id do. Laboris enim dolor culpa quis. Esse voluptate elit commodo duis incididunt velit aliqua. Qui aute commodo incididunt elit eu Lorem dolore. Non esse duis do reprehenderit culpa minim. Ullamco consequat id do exercitation exercitation mollit ipsum velit eiusmod quis.\r\n","registered":"2014-12-14T04:10:29 -01:00","latitude":-50.01615,"longitude":-68.908804,"tags":["wontfix","good first issue"]}
|
||||
{"id":29,"isActive":false,"balance":"$3,028.03","picture":"http://placehold.it/32x32","age":39,"color":"blue","name":"Ashley Pittman","gender":"male","email":"ashleypittman@chorizon.com","phone":"+1 (928) 507-3523","address":"646 Adelphi Street, Clara, Colorado, 6056","about":"Incididunt cillum consectetur nulla sit sit labore nulla sit. Ullamco nisi mollit reprehenderit tempor irure in Lorem duis. Sunt eu aute laboris dolore commodo ipsum sint cupidatat veniam amet culpa incididunt aute ad. Quis dolore aliquip id aute mollit eiusmod nisi ipsum ut labore adipisicing do culpa.\r\n","registered":"2016-01-07T10:40:48 -01:00","latitude":-58.766037,"longitude":-124.828485,"tags":["wontfix"]}
|
||||
{"id":30,"isActive":true,"balance":"$2,021.11","picture":"http://placehold.it/32x32","age":32,"color":"blue","name":"Stacy Espinoza","gender":"female","email":"stacyespinoza@chorizon.com","phone":"+1 (999) 487-3253","address":"931 Alabama Avenue, Bangor, Alaska, 8215","about":"Id reprehenderit cupidatat exercitation anim ad nisi irure. Minim est proident mollit laborum. Duis ad duis eiusmod quis.\r\n","registered":"2014-07-16T06:15:53 -02:00","latitude":41.560197,"longitude":177.697,"tags":["new issue","new issue","bug"]}
|
||||
{"id":31,"isActive":false,"balance":"$3,609.82","picture":"http://placehold.it/32x32","age":32,"color":"blue","name":"Vilma Garza","gender":"female","email":"vilmagarza@chorizon.com","phone":"+1 (944) 585-2021","address":"565 Tech Place, Sedley, Puerto Rico, 858","about":"Excepteur et fugiat mollit incididunt cupidatat. Mollit nisi veniam sint eu exercitation amet labore. Voluptate est magna est amet qui minim excepteur cupidatat dolor quis id excepteur aliqua reprehenderit. Proident nostrud ex veniam officia nisi enim occaecat ex magna officia id consectetur ad eu. In et est reprehenderit cupidatat ad minim veniam proident nulla elit nisi veniam proident ex. Eu in irure sit veniam amet incididunt fugiat proident quis ullamco laboris.\r\n","registered":"2017-06-30T07:43:52 -02:00","latitude":-12.574889,"longitude":-54.771186,"tags":["new issue","wontfix","wontfix"]}
|
||||
{"id":32,"isActive":false,"balance":"$2,882.34","picture":"http://placehold.it/32x32","age":38,"color":"brown","name":"June Dunlap","gender":"female","email":"junedunlap@chorizon.com","phone":"+1 (997) 504-2937","address":"353 Cozine Avenue, Goodville, Indiana, 1438","about":"Non dolore ut Lorem dolore amet veniam fugiat reprehenderit ut amet ea ut. Non aliquip cillum ad occaecat non et sint quis proident velit laborum ullamco et. Quis qui tempor eu voluptate et proident duis est commodo laboris ex enim. Nisi aliquip laboris nostrud veniam aliqua ullamco. Et officia proident dolor aliqua incididunt veniam proident.\r\n","registered":"2016-08-23T08:54:11 -02:00","latitude":-27.883363,"longitude":-163.919683,"tags":["new issue","new issue","bug","wontfix"]}
|
||||
{"id":33,"isActive":true,"balance":"$3,556.54","picture":"http://placehold.it/32x32","age":33,"color":"brown","name":"Cecilia Greer","gender":"female","email":"ceciliagreer@chorizon.com","phone":"+1 (977) 573-3498","address":"696 Withers Street, Lydia, Oklahoma, 3220","about":"Dolor pariatur veniam ad enim eiusmod fugiat ullamco nulla veniam. Dolore dolor sit excepteur veniam adipisicing adipisicing excepteur commodo qui reprehenderit magna exercitation enim reprehenderit. Cupidatat eu ullamco excepteur sint do. Et cupidatat ex adipisicing veniam eu tempor reprehenderit ut eiusmod amet proident veniam nostrud. Tempor ex enim mollit laboris magna tempor. Et aliqua nostrud esse pariatur quis. Ut pariatur ea ipsum pariatur.\r\n","registered":"2017-01-13T11:30:12 -01:00","latitude":60.467215,"longitude":84.684575,"tags":["wontfix","good first issue","good first issue","wontfix"]}
|
||||
{"id":34,"isActive":true,"balance":"$1,413.35","picture":"http://placehold.it/32x32","age":33,"color":"brown","name":"Mckay Schroeder","gender":"male","email":"mckayschroeder@chorizon.com","phone":"+1 (816) 480-3657","address":"958 Miami Court, Rehrersburg, Northern Mariana Islands, 567","about":"Amet do velit excepteur tempor sit eu voluptate. Excepteur amet culpa ipsum in pariatur mollit amet nisi veniam. Laboris elit consectetur id anim qui laboris. Reprehenderit mollit laboris occaecat esse sunt Lorem Lorem sunt occaecat.\r\n","registered":"2016-02-08T04:50:15 -01:00","latitude":-72.413287,"longitude":-159.254371,"tags":["good first issue"]}
|
||||
{"id":35,"isActive":true,"balance":"$2,306.53","picture":"http://placehold.it/32x32","age":34,"color":"blue","name":"Sawyer Mccormick","gender":"male","email":"sawyermccormick@chorizon.com","phone":"+1 (829) 569-3012","address":"749 Apollo Street, Eastvale, Texas, 7373","about":"Est irure ex occaecat aute. Lorem ad ullamco esse cillum deserunt qui proident anim officia dolore. Incididunt tempor cupidatat nulla cupidatat ullamco reprehenderit Lorem. Laboris tempor do pariatur sint non officia id qui deserunt amet Lorem pariatur consectetur exercitation. Adipisicing reprehenderit pariatur duis ex cupidatat cillum ad laboris ex. Sunt voluptate pariatur esse amet dolore minim aliquip reprehenderit nisi velit mollit.\r\n","registered":"2019-11-30T11:53:23 -01:00","latitude":-48.978194,"longitude":110.950191,"tags":["good first issue","new issue","new issue","bug"]}
|
||||
{"id":36,"isActive":false,"balance":"$1,844.54","picture":"http://placehold.it/32x32","age":37,"color":"brown","name":"Barbra Valenzuela","gender":"female","email":"barbravalenzuela@chorizon.com","phone":"+1 (992) 512-2649","address":"617 Schenck Court, Reinerton, Michigan, 2908","about":"Deserunt adipisicing nisi et amet aliqua amet. Veniam occaecat et elit excepteur veniam. Aute irure culpa nostrud occaecat. Excepteur sit aute mollit commodo. Do ex pariatur consequat sint Lorem veniam laborum excepteur. Non voluptate ex laborum enim irure. Adipisicing excepteur anim elit esse.\r\n","registered":"2019-03-29T01:59:31 -01:00","latitude":45.193723,"longitude":-12.486778,"tags":["new issue","new issue","wontfix","wontfix"]}
|
||||
{"id":37,"isActive":false,"balance":"$3,469.82","picture":"http://placehold.it/32x32","age":39,"color":"brown","name":"Opal Weiss","gender":"female","email":"opalweiss@chorizon.com","phone":"+1 (809) 400-3079","address":"535 Bogart Street, Frizzleburg, Arizona, 5222","about":"Reprehenderit nostrud minim adipisicing voluptate nisi consequat id sint. Proident tempor est esse cupidatat minim irure esse do do sint dolor. In officia duis et voluptate Lorem minim cupidatat ipsum enim qui dolor quis in Lorem. Aliquip commodo ex quis exercitation reprehenderit. Lorem id reprehenderit cillum adipisicing sunt ipsum incididunt incididunt.\r\n","registered":"2019-09-04T07:22:28 -02:00","latitude":72.50376,"longitude":61.656435,"tags":["bug","bug","good first issue","good first issue"]}
|
||||
{"id":38,"isActive":true,"balance":"$1,992.38","picture":"http://placehold.it/32x32","age":40,"color":"Green","name":"Christina Short","gender":"female","email":"christinashort@chorizon.com","phone":"+1 (884) 589-2705","address":"594 Willmohr Street, Dexter, Montana, 660","about":"Quis commodo eu dolor incididunt. Nisi magna mollit nostrud do consequat irure exercitation mollit aute deserunt. Magna aute quis occaecat incididunt deserunt tempor nostrud sint ullamco ipsum. Anim in occaecat exercitation laborum nostrud eiusmod reprehenderit ea culpa et sit. Culpa voluptate consectetur nostrud do eu fugiat excepteur officia pariatur enim duis amet.\r\n","registered":"2014-01-21T09:31:56 -01:00","latitude":-42.762739,"longitude":77.052349,"tags":["bug","new issue"]}
|
||||
{"id":39,"isActive":false,"balance":"$1,722.85","picture":"http://placehold.it/32x32","age":29,"color":"brown","name":"Golden Horton","gender":"male","email":"goldenhorton@chorizon.com","phone":"+1 (903) 426-2489","address":"191 Schenck Avenue, Mayfair, North Dakota, 5000","about":"Cillum velit aliqua velit in quis do mollit in et veniam. Nostrud proident non irure commodo. Ea culpa duis enim adipisicing do sint et est culpa reprehenderit officia laborum. Non et nostrud tempor nostrud nostrud ea duis esse laboris occaecat laborum. In eu ipsum sit tempor esse eiusmod enim aliquip aute. Officia ea anim ea ea. Consequat aute deserunt tempor nulla nisi tempor velit.\r\n","registered":"2015-08-19T02:56:41 -02:00","latitude":69.922534,"longitude":9.881433,"tags":["bug"]}
|
||||
{"id":40,"isActive":false,"balance":"$1,656.54","picture":"http://placehold.it/32x32","age":21,"color":"blue","name":"Stafford Emerson","gender":"male","email":"staffordemerson@chorizon.com","phone":"+1 (992) 455-2573","address":"523 Thornton Street, Conway, Vermont, 6331","about":"Adipisicing cupidatat elit minim elit nostrud elit non eiusmod sunt ut. Enim minim irure officia irure occaecat mollit eu nostrud eiusmod adipisicing sunt. Elit deserunt commodo minim dolor qui. Nostrud officia ex proident mollit et dolor tempor pariatur. Ex consequat tempor eiusmod irure mollit cillum laboris est veniam ea mollit deserunt. Tempor sit voluptate excepteur elit ullamco.\r\n","registered":"2019-02-16T04:07:08 -01:00","latitude":-29.143111,"longitude":-57.207703,"tags":["wontfix","good first issue","good first issue"]}
|
||||
{"id":41,"isActive":false,"balance":"$1,861.56","picture":"http://placehold.it/32x32","age":21,"color":"brown","name":"Salinas Gamble","gender":"male","email":"salinasgamble@chorizon.com","phone":"+1 (901) 525-2373","address":"991 Nostrand Avenue, Kansas, Mississippi, 6756","about":"Consequat tempor adipisicing cupidatat aliquip. Mollit proident incididunt ad ipsum laborum. Dolor in elit minim aliquip aliquip voluptate reprehenderit mollit eiusmod excepteur aliquip minim nulla cupidatat.\r\n","registered":"2017-08-21T05:47:53 -02:00","latitude":-22.593819,"longitude":-63.613004,"tags":["good first issue","bug","bug","wontfix"]}
|
||||
{"id":42,"isActive":true,"balance":"$3,179.74","picture":"http://placehold.it/32x32","age":34,"color":"brown","name":"Graciela Russell","gender":"female","email":"gracielarussell@chorizon.com","phone":"+1 (893) 464-3951","address":"361 Greenpoint Avenue, Shrewsbury, New Jersey, 4713","about":"Ex amet duis incididunt consequat minim dolore deserunt reprehenderit adipisicing in mollit aliqua adipisicing sunt. In ullamco eu qui est eiusmod qui. Fugiat esse est Lorem dolore nisi mollit exercitation. Aliquip occaecat esse exercitation ex non aute velit excepteur duis aliquip id. Velit id non aliquip fugiat minim qui exercitation culpa tempor consectetur. Minim dolor labore ea aute aute eu.\r\n","registered":"2015-05-18T09:52:56 -02:00","latitude":-14.634444,"longitude":12.931783,"tags":["wontfix","bug","wontfix"]}
|
||||
{"id":43,"isActive":true,"balance":"$1,777.38","picture":"http://placehold.it/32x32","age":25,"color":"blue","name":"Arnold Bender","gender":"male","email":"arnoldbender@chorizon.com","phone":"+1 (945) 581-3808","address":"781 Lorraine Street, Gallina, American Samoa, 1832","about":"Et mollit laboris duis ut duis eiusmod aute laborum duis irure labore deserunt. Ut occaecat ullamco quis excepteur. Et commodo non sint laboris tempor laboris aliqua consequat magna ea aute minim tempor pariatur. Dolore occaecat qui irure Lorem nulla consequat non.\r\n","registered":"2018-12-23T02:26:30 -01:00","latitude":41.208579,"longitude":51.948925,"tags":["bug","good first issue","good first issue","wontfix"]}
|
||||
{"id":44,"isActive":true,"balance":"$2,893.45","picture":"http://placehold.it/32x32","age":22,"color":"Green","name":"Joni Spears","gender":"female","email":"jonispears@chorizon.com","phone":"+1 (916) 565-2124","address":"307 Harwood Place, Canterwood, Maryland, 2047","about":"Dolore consequat deserunt aliquip duis consequat minim occaecat enim est. Nulla aute reprehenderit est enim duis cillum ullamco aliquip eiusmod sunt. Labore eiusmod aliqua Lorem velit aliqua quis ex mollit mollit duis culpa et qui in. Cupidatat est id ullamco irure dolor nulla.\r\n","registered":"2015-03-01T12:38:28 -01:00","latitude":8.19071,"longitude":146.323808,"tags":["wontfix","new issue","good first issue","good first issue"]}
|
||||
{"id":45,"isActive":true,"balance":"$2,830.36","picture":"http://placehold.it/32x32","age":20,"color":"brown","name":"Irene Bennett","gender":"female","email":"irenebennett@chorizon.com","phone":"+1 (904) 431-2211","address":"353 Ridgecrest Terrace, Springdale, Marshall Islands, 2686","about":"Consectetur Lorem dolor reprehenderit sunt duis. Pariatur non velit velit veniam elit reprehenderit in. Aute quis Lorem quis pariatur Lorem incididunt nulla magna adipisicing. Et id occaecat labore officia occaecat occaecat adipisicing.\r\n","registered":"2018-04-17T05:18:51 -02:00","latitude":-36.435177,"longitude":-127.552573,"tags":["bug","wontfix"]}
|
||||
{"id":46,"isActive":true,"balance":"$1,348.04","picture":"http://placehold.it/32x32","age":34,"color":"Green","name":"Lawson Curtis","gender":"male","email":"lawsoncurtis@chorizon.com","phone":"+1 (896) 532-2172","address":"942 Gerritsen Avenue, Southmont, Kansas, 8915","about":"Amet consectetur minim aute nostrud excepteur sint labore in culpa. Mollit qui quis ea amet sint ex incididunt nulla. Elit id esse ea consectetur laborum consequat occaecat aute consectetur ex. Commodo duis aute elit occaecat cupidatat non consequat ad officia qui dolore nostrud reprehenderit. Occaecat velit velit adipisicing exercitation consectetur. Incididunt et amet nostrud tempor do esse ullamco est Lorem irure. Eu aliqua eu exercitation sint.\r\n","registered":"2016-08-23T01:41:09 -02:00","latitude":-48.783539,"longitude":20.492944,"tags":[]}
|
||||
{"id":47,"isActive":true,"balance":"$1,132.41","picture":"http://placehold.it/32x32","age":38,"color":"Green","name":"Goff May","gender":"male","email":"goffmay@chorizon.com","phone":"+1 (859) 453-3415","address":"225 Rutledge Street, Boonville, Massachusetts, 4081","about":"Sint occaecat velit anim sint reprehenderit est. Adipisicing ea pariatur amet id non ex. Aute id laborum tempor aliquip magna ex eu incididunt aliquip eiusmod elit quis dolor. Anim est minim deserunt amet exercitation nulla elit nulla nulla culpa ullamco. Velit consectetur ipsum amet proident labore excepteur ut id excepteur voluptate commodo. Exercitation et laboris labore esse est laboris consectetur et sint.\r\n","registered":"2014-10-25T07:32:30 -02:00","latitude":13.079225,"longitude":76.215086,"tags":["bug"]}
|
||||
{"id":48,"isActive":true,"balance":"$1,201.87","picture":"http://placehold.it/32x32","age":38,"color":"Green","name":"Goodman Becker","gender":"male","email":"goodmanbecker@chorizon.com","phone":"+1 (825) 470-3437","address":"388 Seigel Street, Sisquoc, Kentucky, 8231","about":"Velit excepteur aute esse fugiat laboris aliqua magna. Est ex sit do labore ullamco aliquip. Duis ea commodo nostrud in fugiat. Aliqua consequat mollit dolore excepteur nisi ullamco commodo ea nostrud ea minim. Minim occaecat ut laboris ea consectetur veniam ipsum qui sit tempor incididunt anim amet eu. Velit sint incididunt eu adipisicing ipsum qui labore. Anim commodo labore reprehenderit aliquip labore elit minim deserunt amet exercitation officia non ea consectetur.\r\n","registered":"2019-09-05T04:49:03 -02:00","latitude":-23.792094,"longitude":-13.621221,"tags":["bug","bug","wontfix","new issue"]}
|
||||
{"id":49,"isActive":true,"balance":"$1,476.39","picture":"http://placehold.it/32x32","age":28,"color":"brown","name":"Maureen Dale","gender":"female","email":"maureendale@chorizon.com","phone":"+1 (984) 538-3684","address":"817 Newton Street, Bannock, Wyoming, 1468","about":"Tempor mollit exercitation excepteur cupidatat reprehenderit ad ex. Nulla laborum proident incididunt quis. Esse laborum deserunt qui anim. Sunt incididunt pariatur cillum anim proident eu ullamco dolor excepteur. Ullamco amet culpa nostrud adipisicing duis aliqua consequat duis non eu id mollit velit. Deserunt ullamco amet in occaecat.\r\n","registered":"2018-04-26T06:04:40 -02:00","latitude":-64.196802,"longitude":-117.396238,"tags":["wontfix"]}
|
||||
{"id":50,"isActive":true,"balance":"$1,947.08","picture":"http://placehold.it/32x32","age":21,"color":"Green","name":"Guerra Mcintyre","gender":"male","email":"guerramcintyre@chorizon.com","phone":"+1 (951) 536-2043","address":"423 Lombardy Street, Stewart, West Virginia, 908","about":"Sunt proident proident deserunt exercitation consectetur deserunt labore non commodo amet. Duis aute aliqua amet deserunt consectetur velit. Quis Lorem dolore occaecat deserunt reprehenderit non esse ullamco nostrud enim sunt ea fugiat. Elit amet veniam eu magna tempor. Mollit cupidatat laboris ex deserunt et labore sit tempor nostrud anim. Tempor aliqua occaecat voluptate reprehenderit eiusmod aliqua incididunt officia.\r\n","registered":"2015-07-16T05:11:42 -02:00","latitude":79.733743,"longitude":-20.602356,"tags":["bug","good first issue","good first issue"]}
|
||||
{"id":51,"isActive":true,"balance":"$2,960.90","picture":"http://placehold.it/32x32","age":23,"color":"blue","name":"Key Cervantes","gender":"male","email":"keycervantes@chorizon.com","phone":"+1 (931) 474-3865","address":"410 Barbey Street, Vernon, Oregon, 2328","about":"Duis amet minim eu consectetur laborum ad exercitation eiusmod nulla velit cillum consectetur. Nostrud aliqua cillum minim veniam quis do cupidatat mollit laborum. Culpa fugiat consectetur cillum non occaecat tempor non fugiat esse pariatur in ullamco. Occaecat amet officia et culpa officia deserunt in qui magna aute consequat eiusmod.\r\n","registered":"2019-12-15T12:13:35 -01:00","latitude":47.627647,"longitude":117.049918,"tags":["new issue"]}
|
||||
{"id":52,"isActive":false,"balance":"$1,884.02","picture":"http://placehold.it/32x32","age":35,"color":"blue","name":"Karen Nelson","gender":"female","email":"karennelson@chorizon.com","phone":"+1 (993) 528-3607","address":"930 Frank Court, Dunbar, New York, 8810","about":"Occaecat officia veniam consectetur aliqua laboris dolor irure nulla. Lorem ipsum sit nisi veniam mollit ea sint nisi irure. Eiusmod officia do laboris nostrud enim ullamco nulla officia in Lorem qui. Sint sunt incididunt quis reprehenderit incididunt. Sit dolore nulla consequat ea magna.\r\n","registered":"2014-06-23T09:21:44 -02:00","latitude":-59.059033,"longitude":76.565373,"tags":["new issue","bug"]}
|
||||
{"id":53,"isActive":true,"balance":"$3,559.55","picture":"http://placehold.it/32x32","age":32,"color":"brown","name":"Caitlin Burnett","gender":"female","email":"caitlinburnett@chorizon.com","phone":"+1 (945) 480-2796","address":"516 Senator Street, Emory, Iowa, 4145","about":"In aliqua ea esse in. Magna aute cupidatat culpa enim proident ad adipisicing laborum consequat exercitation nisi. Qui esse aliqua duis anim nulla esse enim nostrud ipsum tempor. Lorem deserunt ullamco do mollit culpa ipsum duis Lorem velit duis occaecat.\r\n","registered":"2019-01-09T02:26:31 -01:00","latitude":-82.774237,"longitude":42.316194,"tags":["bug","good first issue"]}
|
||||
{"id":54,"isActive":true,"balance":"$2,113.29","picture":"http://placehold.it/32x32","age":28,"color":"Green","name":"Richards Walls","gender":"male","email":"richardswalls@chorizon.com","phone":"+1 (865) 517-2982","address":"959 Brightwater Avenue, Stevens, Nevada, 2968","about":"Ad aute Lorem non pariatur anim ullamco ad amet eiusmod tempor velit. Mollit et tempor nisi aute adipisicing exercitation mollit do amet amet est fugiat enim. Ex voluptate nulla id tempor officia ullamco cillum dolor irure irure mollit et magna nisi. Pariatur voluptate qui laboris dolor id. Eu ipsum nulla dolore aute voluptate deserunt anim aliqua. Ut enim enim velit officia est nisi. Duis amet ut veniam aliquip minim tempor Lorem amet Lorem dolor duis.\r\n","registered":"2014-09-25T06:51:22 -02:00","latitude":80.09202,"longitude":87.49759,"tags":["wontfix","wontfix","bug"]}
|
||||
{"id":55,"isActive":true,"balance":"$1,977.66","picture":"http://placehold.it/32x32","age":36,"color":"brown","name":"Combs Stanley","gender":"male","email":"combsstanley@chorizon.com","phone":"+1 (827) 419-2053","address":"153 Beverley Road, Siglerville, South Carolina, 3666","about":"Commodo ullamco consequat eu ipsum eiusmod aute voluptate in. Ea laboris id deserunt nostrud pariatur et laboris minim tempor quis qui consequat non esse. Magna elit commodo mollit veniam Lorem enim nisi pariatur. Nisi non nisi adipisicing ea ipsum laborum dolore cillum. Amet do nisi esse laboris ipsum proident non veniam ullamco ea cupidatat sunt. Aliquip aute cillum quis laboris consectetur enim eiusmod nisi non id ullamco cupidatat sunt.\r\n","registered":"2019-08-22T07:53:15 -02:00","latitude":78.386181,"longitude":143.661058,"tags":[]}
|
||||
{"id":56,"isActive":false,"balance":"$3,886.12","picture":"http://placehold.it/32x32","age":23,"color":"brown","name":"Tucker Barry","gender":"male","email":"tuckerbarry@chorizon.com","phone":"+1 (808) 544-3433","address":"805 Jamaica Avenue, Cornfields, Minnesota, 3689","about":"Enim est sunt ullamco nulla aliqua commodo. Enim minim veniam non fugiat id tempor ad velit quis velit ad sunt consectetur laborum. Cillum deserunt tempor est adipisicing Lorem esse qui. Magna quis sunt cillum ea officia adipisicing eiusmod eu et nisi consectetur.\r\n","registered":"2016-08-29T07:28:00 -02:00","latitude":71.701551,"longitude":9.903068,"tags":[]}
|
||||
{"id":57,"isActive":false,"balance":"$1,844.56","picture":"http://placehold.it/32x32","age":20,"color":"Green","name":"Kaitlin Conner","gender":"female","email":"kaitlinconner@chorizon.com","phone":"+1 (862) 467-2666","address":"501 Knight Court, Joppa, Rhode Island, 274","about":"Occaecat id reprehenderit pariatur ea. Incididunt laborum reprehenderit ipsum velit labore excepteur nostrud voluptate officia ut culpa. Sint sunt in qui duis cillum aliqua do ullamco. Non do aute excepteur non labore sint consectetur tempor ad ea fugiat commodo labore. Dolor tempor culpa Lorem voluptate esse nostrud anim tempor irure reprehenderit. Deserunt ipsum cillum fugiat ut labore labore anim. In aliqua sunt dolore irure reprehenderit voluptate commodo consequat mollit amet laboris sit anim.\r\n","registered":"2019-05-30T06:38:24 -02:00","latitude":15.613464,"longitude":171.965629,"tags":[]}
|
||||
{"id":58,"isActive":true,"balance":"$2,876.10","picture":"http://placehold.it/32x32","age":38,"color":"Green","name":"Mamie Fischer","gender":"female","email":"mamiefischer@chorizon.com","phone":"+1 (948) 545-3901","address":"599 Hunterfly Place, Haena, Georgia, 6005","about":"Cillum eu aliquip ipsum anim in dolore labore ea. Laboris velit esse ea ea aute do adipisicing ullamco elit laborum aute tempor. Esse consectetur quis irure occaecat nisi cillum et consectetur cillum cillum quis quis commodo.\r\n","registered":"2019-05-27T05:07:10 -02:00","latitude":70.915079,"longitude":-48.813584,"tags":["bug","wontfix","wontfix","good first issue"]}
|
||||
{"id":59,"isActive":true,"balance":"$1,921.58","picture":"http://placehold.it/32x32","age":31,"color":"Green","name":"Harper Carson","gender":"male","email":"harpercarson@chorizon.com","phone":"+1 (912) 430-3243","address":"883 Dennett Place, Knowlton, New Mexico, 9219","about":"Exercitation minim esse proident cillum velit et deserunt incididunt adipisicing minim. Cillum Lorem consectetur laborum id consequat exercitation velit. Magna dolor excepteur sunt deserunt dolor ullamco non sint proident ipsum. Reprehenderit voluptate sit veniam consectetur ea sunt duis labore deserunt ipsum aute. Eiusmod aliqua anim voluptate id duis tempor aliqua commodo sunt. Do officia ea consectetur nostrud eiusmod laborum.\r\n","registered":"2019-12-07T07:33:15 -01:00","latitude":-60.812605,"longitude":-27.129016,"tags":["bug","new issue"]}
|
||||
{"id":60,"isActive":true,"balance":"$1,770.93","picture":"http://placehold.it/32x32","age":23,"color":"brown","name":"Jody Herrera","gender":"female","email":"jodyherrera@chorizon.com","phone":"+1 (890) 583-3222","address":"261 Jay Street, Strykersville, Ohio, 9248","about":"Sit adipisicing pariatur irure non sint cupidatat ex ipsum pariatur exercitation ea. Enim consequat enim eu eu sint eu elit ex esse aliquip. Pariatur ipsum dolore veniam nisi id tempor elit exercitation dolore ad fugiat labore velit.\r\n","registered":"2016-05-21T01:00:02 -02:00","latitude":-36.846586,"longitude":131.156223,"tags":[]}
|
||||
{"id":61,"isActive":false,"balance":"$2,813.41","picture":"http://placehold.it/32x32","age":37,"color":"Green","name":"Charles Castillo","gender":"male","email":"charlescastillo@chorizon.com","phone":"+1 (934) 467-2108","address":"675 Morton Street, Rew, Pennsylvania, 137","about":"Velit amet laborum amet sunt sint sit cupidatat deserunt dolor laborum consectetur veniam. Minim cupidatat amet exercitation nostrud ex deserunt ad Lorem amet aute consectetur labore reprehenderit. Minim mollit aliqua et deserunt ex nisi. Id irure dolor labore consequat ipsum consectetur.\r\n","registered":"2019-06-10T02:54:22 -02:00","latitude":-16.423202,"longitude":-146.293752,"tags":["new issue","new issue"]}
|
||||
{"id":62,"isActive":true,"balance":"$3,341.35","picture":"http://placehold.it/32x32","age":33,"color":"blue","name":"Estelle Ramirez","gender":"female","email":"estelleramirez@chorizon.com","phone":"+1 (816) 459-2073","address":"636 Nolans Lane, Camptown, California, 7794","about":"Dolor proident incididunt ex labore quis ullamco duis. Sit esse laboris nisi eu voluptate nulla cupidatat nulla fugiat veniam. Culpa cillum est esse dolor consequat. Pariatur ex sit irure qui do fugiat. Fugiat culpa veniam est nisi excepteur quis cupidatat et minim in esse minim dolor et. Anim aliquip labore dolor occaecat nisi sunt dolore pariatur veniam nostrud est ut.\r\n","registered":"2015-02-14T01:05:50 -01:00","latitude":-46.591249,"longitude":-83.385587,"tags":["good first issue","bug"]}
|
||||
{"id":63,"isActive":true,"balance":"$2,478.30","picture":"http://placehold.it/32x32","age":21,"color":"blue","name":"Knowles Hebert","gender":"male","email":"knowleshebert@chorizon.com","phone":"+1 (819) 409-2308","address":"361 Kathleen Court, Gratton, Connecticut, 7254","about":"Esse mollit nulla eiusmod esse duis non proident excepteur labore. Nisi ex culpa do mollit dolor ea deserunt elit anim ipsum nostrud. Cupidatat nostrud duis ipsum dolore amet et. Veniam in cillum ea cillum deserunt excepteur officia laboris nulla. Commodo incididunt aliquip qui sunt dolore occaecat labore do laborum irure. Labore culpa duis pariatur reprehenderit ad laboris occaecat anim cillum et fugiat ea.\r\n","registered":"2016-03-08T08:34:52 -01:00","latitude":71.042482,"longitude":152.460406,"tags":["good first issue","wontfix"]}
|
||||
{"id":64,"isActive":false,"balance":"$2,559.09","picture":"http://placehold.it/32x32","age":28,"color":"brown","name":"Thelma Mckenzie","gender":"female","email":"thelmamckenzie@chorizon.com","phone":"+1 (941) 596-2777","address":"202 Leonard Street, Riverton, Illinois, 8577","about":"Non ad ipsum elit commodo fugiat Lorem ipsum reprehenderit. Commodo incididunt officia cillum eiusmod officia proident ea incididunt ullamco magna commodo consectetur dolor. Nostrud esse nisi ea laboris. Veniam et dolore nulla excepteur pariatur laborum non. Eiusmod reprehenderit do tempor esse eu eu aliquip. Magna quis consectetur ipsum adipisicing mollit elit ad elit.\r\n","registered":"2020-04-14T12:43:06 -02:00","latitude":16.026129,"longitude":105.464476,"tags":[]}
|
||||
{"id":65,"isActive":true,"balance":"$1,025.08","picture":"http://placehold.it/32x32","age":34,"color":"blue","name":"Carole Rowland","gender":"female","email":"carolerowland@chorizon.com","phone":"+1 (862) 558-3448","address":"941 Melba Court, Bluetown, Florida, 9555","about":"Ullamco occaecat ipsum aliqua sit proident eu. Occaecat ut consectetur proident culpa aliqua excepteur quis qui anim irure sit proident mollit irure. Proident cupidatat deserunt dolor adipisicing.\r\n","registered":"2014-12-01T05:55:35 -01:00","latitude":-0.191998,"longitude":43.389652,"tags":["wontfix"]}
|
||||
{"id":66,"isActive":true,"balance":"$1,061.49","picture":"http://placehold.it/32x32","age":35,"color":"brown","name":"Higgins Aguilar","gender":"male","email":"higginsaguilar@chorizon.com","phone":"+1 (911) 540-3791","address":"132 Sackman Street, Layhill, Guam, 8729","about":"Anim ea dolore exercitation minim. Proident cillum non deserunt cupidatat veniam non occaecat aute ullamco irure velit laboris ex aliquip. Voluptate incididunt non ex nulla est ipsum. Amet anim do velit sunt irure sint minim nisi occaecat proident tempor elit exercitation nostrud.\r\n","registered":"2015-04-05T02:10:07 -02:00","latitude":74.702813,"longitude":151.314972,"tags":["bug"]}
|
||||
{"id":67,"isActive":true,"balance":"$3,510.14","picture":"http://placehold.it/32x32","age":28,"color":"brown","name":"Ilene Gillespie","gender":"female","email":"ilenegillespie@chorizon.com","phone":"+1 (937) 575-2676","address":"835 Lake Street, Naomi, Alabama, 4131","about":"Quis laborum consequat id cupidatat exercitation aute ad ex nulla dolore velit qui proident minim. Et do consequat nisi eiusmod exercitation exercitation enim voluptate elit ullamco. Cupidatat ut adipisicing consequat aute est voluptate sit ipsum culpa ullamco. Ex pariatur ex qui quis qui.\r\n","registered":"2015-06-28T09:41:45 -02:00","latitude":71.573342,"longitude":-95.295989,"tags":["wontfix","wontfix"]}
|
||||
{"id":68,"isActive":false,"balance":"$1,539.98","picture":"http://placehold.it/32x32","age":24,"color":"Green","name":"Angelina Dyer","gender":"female","email":"angelinadyer@chorizon.com","phone":"+1 (948) 574-3949","address":"575 Division Place, Gorham, Louisiana, 3458","about":"Cillum magna eu est veniam incididunt laboris laborum elit mollit incididunt proident non mollit. Dolor mollit culpa ullamco dolore aliqua adipisicing culpa officia. Reprehenderit minim nisi fugiat consectetur dolore.\r\n","registered":"2014-07-08T06:34:36 -02:00","latitude":-85.649593,"longitude":66.126018,"tags":["good first issue"]}
|
||||
{"id":69,"isActive":true,"balance":"$3,367.69","picture":"http://placehold.it/32x32","age":30,"color":"brown","name":"Marks Burt","gender":"male","email":"marksburt@chorizon.com","phone":"+1 (895) 497-3138","address":"819 Village Road, Wadsworth, Delaware, 6099","about":"Fugiat tempor aute voluptate proident exercitation tempor esse dolor id. Duis aliquip exercitation Lorem elit magna sint sit. Culpa adipisicing occaecat aliqua officia reprehenderit laboris sint aliquip. Magna do sunt consequat excepteur nisi do commodo non. Cillum officia nostrud consequat excepteur elit proident in. Tempor ipsum in ut qui cupidatat exercitation est nulla exercitation voluptate.\r\n","registered":"2014-08-31T06:12:18 -02:00","latitude":26.854112,"longitude":-143.313948,"tags":["good first issue"]}
|
||||
{"id":70,"isActive":false,"balance":"$3,755.72","picture":"http://placehold.it/32x32","age":23,"color":"blue","name":"Glass Perkins","gender":"male","email":"glassperkins@chorizon.com","phone":"+1 (923) 486-3725","address":"899 Roosevelt Court, Belleview, Idaho, 1737","about":"Esse magna id labore sunt qui eu enim esse cillum consequat enim eu culpa enim. Duis veniam cupidatat deserunt sunt irure ad Lorem proident aliqua mollit. Laborum mollit aute nulla est. Sunt id proident incididunt ipsum et dolor consectetur laborum enim dolor officia dolore laborum. Est commodo duis et ea consequat labore id id eu aliqua. Qui veniam sit eu aliquip ad sit dolor ullamco et laborum voluptate quis fugiat ex. Exercitation dolore cillum amet ad nisi consectetur occaecat sit aliqua laborum qui proident aliqua exercitation.\r\n","registered":"2015-05-22T05:44:33 -02:00","latitude":54.27147,"longitude":-65.065604,"tags":["wontfix"]}
|
||||
{"id":71,"isActive":true,"balance":"$3,381.63","picture":"http://placehold.it/32x32","age":38,"color":"Green","name":"Candace Sawyer","gender":"female","email":"candacesawyer@chorizon.com","phone":"+1 (830) 404-2636","address":"334 Arkansas Drive, Bordelonville, Tennessee, 8449","about":"Et aliqua elit incididunt et aliqua. Deserunt ut elit proident ullamco ut. Ex exercitation amet non eu reprehenderit ea voluptate qui sit reprehenderit ad sint excepteur.\r\n","registered":"2014-04-04T08:45:00 -02:00","latitude":6.484262,"longitude":-37.054928,"tags":["new issue","new issue"]}
|
||||
{"id":72,"isActive":true,"balance":"$1,640.98","picture":"http://placehold.it/32x32","age":27,"color":"Green","name":"Hendricks Martinez","gender":"male","email":"hendricksmartinez@chorizon.com","phone":"+1 (857) 566-3245","address":"636 Agate Court, Newry, Utah, 3304","about":"Do sit culpa amet incididunt officia enim occaecat incididunt excepteur enim tempor deserunt qui. Excepteur adipisicing anim consectetur adipisicing proident anim laborum qui. Aliquip nostrud cupidatat sit ullamco.\r\n","registered":"2018-06-15T10:36:11 -02:00","latitude":86.746034,"longitude":10.347893,"tags":["new issue"]}
|
||||
{"id":73,"isActive":false,"balance":"$1,239.74","picture":"http://placehold.it/32x32","age":38,"color":"blue","name":"Eleanor Shepherd","gender":"female","email":"eleanorshepherd@chorizon.com","phone":"+1 (894) 567-2617","address":"670 Lafayette Walk, Darlington, Palau, 8803","about":"Adipisicing ad incididunt id veniam magna cupidatat et labore eu deserunt mollit. Lorem voluptate exercitation elit eu aliquip cupidatat occaecat anim excepteur reprehenderit est est. Ipsum excepteur ea mollit qui nisi laboris ex qui. Cillum velit culpa culpa commodo laboris nisi Lorem non elit deserunt incididunt. Officia quis velit nulla sint incididunt duis mollit tempor adipisicing qui officia eu nisi Lorem. Do proident pariatur ex enim nostrud eu aute esse deserunt eu velit quis culpa exercitation. Occaecat ad cupidatat ullamco consequat duis anim deserunt occaecat aliqua sunt consectetur ipsum magna.\r\n","registered":"2020-02-29T12:15:28 -01:00","latitude":35.749621,"longitude":-94.40842,"tags":["good first issue","new issue","new issue","bug"]}
|
||||
{"id":74,"isActive":true,"balance":"$1,180.90","picture":"http://placehold.it/32x32","age":36,"color":"Green","name":"Stark Wong","gender":"male","email":"starkwong@chorizon.com","phone":"+1 (805) 575-3055","address":"522 Bond Street, Bawcomville, Wisconsin, 324","about":"Aute qui sit incididunt eu adipisicing exercitation sunt nostrud. Id laborum incididunt proident ipsum est cillum esse. Officia ullamco eu ut Lorem do minim ea dolor consequat sit eu est voluptate. Id commodo cillum enim culpa aliquip ullamco nisi Lorem cillum ipsum cupidatat anim officia eu. Dolore sint elit labore pariatur. Officia duis nulla voluptate et nulla ut voluptate laboris eu commodo veniam qui veniam.\r\n","registered":"2020-01-25T10:47:48 -01:00","latitude":-80.452139,"longitude":160.72546,"tags":["wontfix"]}
|
||||
{"id":75,"isActive":false,"balance":"$1,913.42","picture":"http://placehold.it/32x32","age":24,"color":"Green","name":"Emma Jacobs","gender":"female","email":"emmajacobs@chorizon.com","phone":"+1 (899) 554-3847","address":"173 Tapscott Street, Esmont, Maine, 7450","about":"Laboris consequat consectetur tempor labore ullamco ullamco voluptate quis quis duis ut ad. In est irure quis amet sunt nulla ad ut sit labore ut eu quis duis. Nostrud cupidatat aliqua sunt occaecat minim id consequat officia deserunt laborum. Ea dolor reprehenderit laborum veniam exercitation est nostrud excepteur laborum minim id qui et.\r\n","registered":"2019-03-29T06:24:13 -01:00","latitude":-35.53722,"longitude":155.703874,"tags":[]}
|
||||
{"id":76,"isActive":false,"balance":"$1,274.29","picture":"http://placehold.it/32x32","age":25,"color":"Green","name":"Clarice Gardner","gender":"female","email":"claricegardner@chorizon.com","phone":"+1 (810) 407-3258","address":"894 Brooklyn Road, Utting, New Hampshire, 6404","about":"Elit occaecat aute ea adipisicing mollit cupidatat aliquip excepteur veniam minim. Sunt quis dolore in commodo aute esse quis. Lorem in cillum commodo eu anim commodo mollit. Adipisicing enim sunt adipisicing cupidatat adipisicing eiusmod eu do sit nisi.\r\n","registered":"2014-10-20T10:13:32 -02:00","latitude":17.11935,"longitude":65.38197,"tags":["new issue","wontfix"]}
|
||||
59
meilisearch-http/tests/assets/dumps/v1/test/settings.json
Normal file
59
meilisearch-http/tests/assets/dumps/v1/test/settings.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"rankingRules": [
|
||||
"typo",
|
||||
"words",
|
||||
"proximity",
|
||||
"attribute",
|
||||
"wordsPosition",
|
||||
"exactness"
|
||||
],
|
||||
"distinctAttribute": "email",
|
||||
"searchableAttributes": [
|
||||
"balance",
|
||||
"picture",
|
||||
"age",
|
||||
"color",
|
||||
"name",
|
||||
"gender",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
"about",
|
||||
"registered",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"tags"
|
||||
],
|
||||
"displayedAttributes": [
|
||||
"id",
|
||||
"isActive",
|
||||
"balance",
|
||||
"picture",
|
||||
"age",
|
||||
"color",
|
||||
"name",
|
||||
"gender",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
"about",
|
||||
"registered",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"tags"
|
||||
],
|
||||
"stopWords": [
|
||||
"in",
|
||||
"ad"
|
||||
],
|
||||
"synonyms": {
|
||||
"wolverine": ["xmen", "logan"],
|
||||
"logan": ["wolverine", "xmen"]
|
||||
},
|
||||
"filterableAttributes": [
|
||||
"gender",
|
||||
"color",
|
||||
"tags",
|
||||
"isActive"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"status": "processed","updateId": 0,"type": {"name":"Settings","settings":{"ranking_rules":{"Update":["Typo","Words","Proximity","Attribute","WordsPosition","Exactness"]},"distinct_attribute":"Nothing","primary_key":"Nothing","searchable_attributes":{"Update":["balance","picture","age","color","name","gender","email","phone","address","about","registered","latitude","longitude","tags"]},"displayed_attributes":{"Update":["about","address","age","balance","color","email","gender","id","isActive","latitude","longitude","name","phone","picture","registered","tags"]},"stop_words":"Nothing","synonyms":"Nothing","filterable_attributes":"Nothing"}}}
|
||||
{"status": "processed", "updateId": 1, "type": { "name": "DocumentsAddition"}}
|
||||
1613
meilisearch-http/tests/assets/test_set.json
Normal file
1613
meilisearch-http/tests/assets/test_set.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
meilisearch-http/tests/assets/v1_v0.20.0_movies.dump
Normal file
BIN
meilisearch-http/tests/assets/v1_v0.20.0_movies.dump
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
meilisearch-http/tests/assets/v2_v0.21.1_movies.dump
Normal file
BIN
meilisearch-http/tests/assets/v2_v0.21.1_movies.dump
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
meilisearch-http/tests/assets/v3_v0.24.0_movies.dump
Normal file
BIN
meilisearch-http/tests/assets/v3_v0.24.0_movies.dump
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
meilisearch-http/tests/assets/v4_v0.25.2_movies.dump
Normal file
BIN
meilisearch-http/tests/assets/v4_v0.25.2_movies.dump
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
meilisearch-http/tests/assets/v5_v0.28.0_test_dump.dump
Normal file
BIN
meilisearch-http/tests/assets/v5_v0.28.0_test_dump.dump
Normal file
Binary file not shown.
1461
meilisearch-http/tests/auth/api_keys.rs
Normal file
1461
meilisearch-http/tests/auth/api_keys.rs
Normal file
File diff suppressed because it is too large
Load Diff
652
meilisearch-http/tests/auth/authorization.rs
Normal file
652
meilisearch-http/tests/auth/authorization.rs
Normal file
@@ -0,0 +1,652 @@
|
||||
use crate::common::Server;
|
||||
use ::time::format_description::well_known::Rfc3339;
|
||||
use maplit::{hashmap, hashset};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
pub static AUTHORIZATIONS: Lazy<HashMap<(&'static str, &'static str), HashSet<&'static str>>> =
|
||||
Lazy::new(|| {
|
||||
hashmap! {
|
||||
("POST", "/indexes/products/search") => hashset!{"search", "*"},
|
||||
("GET", "/indexes/products/search") => hashset!{"search", "*"},
|
||||
("POST", "/indexes/products/documents") => hashset!{"documents.add", "documents.*", "*"},
|
||||
("GET", "/indexes/products/documents") => hashset!{"documents.get", "documents.*", "*"},
|
||||
("GET", "/indexes/products/documents/0") => hashset!{"documents.get", "documents.*", "*"},
|
||||
("DELETE", "/indexes/products/documents/0") => hashset!{"documents.delete", "documents.*", "*"},
|
||||
("GET", "/tasks") => hashset!{"tasks.get", "tasks.*", "*"},
|
||||
("GET", "/tasks?indexUid=products") => hashset!{"tasks.get", "tasks.*", "*"},
|
||||
("GET", "/tasks/0") => hashset!{"tasks.get", "tasks.*", "*"},
|
||||
("PATCH", "/indexes/products/") => hashset!{"indexes.update", "indexes.*", "*"},
|
||||
("GET", "/indexes/products/") => hashset!{"indexes.get", "indexes.*", "*"},
|
||||
("DELETE", "/indexes/products/") => hashset!{"indexes.delete", "indexes.*", "*"},
|
||||
("POST", "/indexes") => hashset!{"indexes.create", "indexes.*", "*"},
|
||||
("GET", "/indexes") => hashset!{"indexes.get", "indexes.*", "*"},
|
||||
("GET", "/indexes/products/settings") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/displayed-attributes") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/distinct-attribute") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/filterable-attributes") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/ranking-rules") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/searchable-attributes") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/sortable-attributes") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/stop-words") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("GET", "/indexes/products/settings/synonyms") => hashset!{"settings.get", "settings.*", "*"},
|
||||
("DELETE", "/indexes/products/settings") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PATCH", "/indexes/products/settings") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PATCH", "/indexes/products/settings/typo-tolerance") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/displayed-attributes") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/distinct-attribute") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/filterable-attributes") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/ranking-rules") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/searchable-attributes") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/sortable-attributes") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/stop-words") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("PUT", "/indexes/products/settings/synonyms") => hashset!{"settings.update", "settings.*", "*"},
|
||||
("GET", "/indexes/products/stats") => hashset!{"stats.get", "stats.*", "*"},
|
||||
("GET", "/stats") => hashset!{"stats.get", "stats.*", "*"},
|
||||
("POST", "/dumps") => hashset!{"dumps.create", "dumps.*", "*"},
|
||||
("GET", "/version") => hashset!{"version", "*"},
|
||||
("PATCH", "/keys/mykey/") => hashset!{"keys.update", "*"},
|
||||
("GET", "/keys/mykey/") => hashset!{"keys.get", "*"},
|
||||
("DELETE", "/keys/mykey/") => hashset!{"keys.delete", "*"},
|
||||
("POST", "/keys") => hashset!{"keys.create", "*"},
|
||||
("GET", "/keys") => hashset!{"keys.get", "*"},
|
||||
}
|
||||
});
|
||||
|
||||
pub static ALL_ACTIONS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
|
||||
AUTHORIZATIONS
|
||||
.values()
|
||||
.cloned()
|
||||
.reduce(|l, r| l.union(&r).cloned().collect())
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
static INVALID_RESPONSE: Lazy<Value> = Lazy::new(|| {
|
||||
json!({"message": "The provided API key is invalid.",
|
||||
"code": "invalid_api_key",
|
||||
"type": "auth",
|
||||
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
|
||||
})
|
||||
});
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_access_expired_key() {
|
||||
use std::{thread, time};
|
||||
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": ALL_ACTIONS.clone(),
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::seconds(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
// wait until the key is expired.
|
||||
thread::sleep(time::Duration::new(1, 0));
|
||||
|
||||
for (method, route) in AUTHORIZATIONS.keys() {
|
||||
let (response, code) = server.dummy_request(method, route).await;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
INVALID_RESPONSE.clone(),
|
||||
"on route: {:?} - {:?}",
|
||||
method,
|
||||
route
|
||||
);
|
||||
assert_eq!(403, code, "{:?}", &response);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_access_unauthorized_index() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["sales"],
|
||||
"actions": ALL_ACTIONS.clone(),
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
for (method, route) in AUTHORIZATIONS
|
||||
.keys()
|
||||
// filter `products` index routes
|
||||
.filter(|(_, route)| route.starts_with("/indexes/products"))
|
||||
{
|
||||
let (response, code) = server.dummy_request(method, route).await;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
INVALID_RESPONSE.clone(),
|
||||
"on route: {:?} - {:?}",
|
||||
method,
|
||||
route
|
||||
);
|
||||
assert_eq!(403, code, "{:?}", &response);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_access_unauthorized_action() {
|
||||
let mut server = Server::new_auth().await;
|
||||
|
||||
for ((method, route), action) in AUTHORIZATIONS.iter() {
|
||||
// create a new API key letting only the needed action.
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": ALL_ACTIONS.difference(action).collect::<Vec<_>>(),
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
let (response, code) = server.dummy_request(method, route).await;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
INVALID_RESPONSE.clone(),
|
||||
"on route: {:?} - {:?}",
|
||||
method,
|
||||
route
|
||||
);
|
||||
assert_eq!(403, code, "{:?}", &response);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn access_authorized_master_key() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
// master key must have access to all routes.
|
||||
for ((method, route), _) in AUTHORIZATIONS.iter() {
|
||||
let (response, code) = server.dummy_request(method, route).await;
|
||||
|
||||
assert_ne!(
|
||||
response,
|
||||
INVALID_RESPONSE.clone(),
|
||||
"on route: {:?} - {:?}",
|
||||
method,
|
||||
route
|
||||
);
|
||||
assert_ne!(code, 403);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn access_authorized_restricted_index() {
|
||||
let mut server = Server::new_auth().await;
|
||||
for ((method, route), actions) in AUTHORIZATIONS.iter() {
|
||||
for action in actions {
|
||||
// create a new API key letting only the needed action.
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": [action],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.dummy_request(method, route).await;
|
||||
|
||||
assert_ne!(
|
||||
response,
|
||||
INVALID_RESPONSE.clone(),
|
||||
"on route: {:?} - {:?} with action: {:?}",
|
||||
method,
|
||||
route,
|
||||
action
|
||||
);
|
||||
assert_ne!(code, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn access_authorized_no_index_restriction() {
|
||||
let mut server = Server::new_auth().await;
|
||||
|
||||
for ((method, route), actions) in AUTHORIZATIONS.iter() {
|
||||
for action in actions {
|
||||
// create a new API key letting only the needed action.
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": [action],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.dummy_request(method, route).await;
|
||||
|
||||
assert_ne!(
|
||||
response,
|
||||
INVALID_RESPONSE.clone(),
|
||||
"on route: {:?} - {:?} with action: {:?}",
|
||||
method,
|
||||
route,
|
||||
action
|
||||
);
|
||||
assert_ne!(code, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn access_authorized_stats_restricted_index() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
|
||||
// create index `test`
|
||||
let index = server.index("test");
|
||||
let (response, code) = index.create(Some("id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
// create index `products`
|
||||
let index = server.index("products");
|
||||
let (response, code) = index.create(Some("product_id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
index.wait_task(0).await;
|
||||
|
||||
// create key with access on `products` index only.
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": ["stats.get"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.stats().await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
|
||||
// key should have access on `products` index.
|
||||
assert!(response["indexes"].get("products").is_some());
|
||||
|
||||
// key should not have access on `test` index.
|
||||
assert!(response["indexes"].get("test").is_none());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn access_authorized_stats_no_index_restriction() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
|
||||
// create index `test`
|
||||
let index = server.index("test");
|
||||
let (response, code) = index.create(Some("id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
// create index `products`
|
||||
let index = server.index("products");
|
||||
let (response, code) = index.create(Some("product_id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
index.wait_task(0).await;
|
||||
|
||||
// create key with access on all indexes.
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["stats.get"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.stats().await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
|
||||
// key should have access on `products` index.
|
||||
assert!(response["indexes"].get("products").is_some());
|
||||
|
||||
// key should have access on `test` index.
|
||||
assert!(response["indexes"].get("test").is_some());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn list_authorized_indexes_restricted_index() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
|
||||
// create index `test`
|
||||
let index = server.index("test");
|
||||
let (response, code) = index.create(Some("id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
// create index `products`
|
||||
let index = server.index("products");
|
||||
let (response, code) = index.create(Some("product_id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
index.wait_task(0).await;
|
||||
|
||||
// create key with access on `products` index only.
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": ["indexes.get"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.list_indexes(None, None).await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
|
||||
let response = response["results"].as_array().unwrap();
|
||||
// key should have access on `products` index.
|
||||
assert!(response.iter().any(|index| index["uid"] == "products"));
|
||||
|
||||
// key should not have access on `test` index.
|
||||
assert!(!response.iter().any(|index| index["uid"] == "test"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn list_authorized_indexes_no_index_restriction() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
|
||||
// create index `test`
|
||||
let index = server.index("test");
|
||||
let (response, code) = index.create(Some("id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
// create index `products`
|
||||
let index = server.index("products");
|
||||
let (response, code) = index.create(Some("product_id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
index.wait_task(0).await;
|
||||
|
||||
// create key with access on all indexes.
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["indexes.get"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.list_indexes(None, None).await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
|
||||
let response = response["results"].as_array().unwrap();
|
||||
// key should have access on `products` index.
|
||||
assert!(response.iter().any(|index| index["uid"] == "products"));
|
||||
|
||||
// key should have access on `test` index.
|
||||
assert!(response.iter().any(|index| index["uid"] == "test"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn list_authorized_tasks_restricted_index() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
|
||||
// create index `test`
|
||||
let index = server.index("test");
|
||||
let (response, code) = index.create(Some("id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
// create index `products`
|
||||
let index = server.index("products");
|
||||
let (response, code) = index.create(Some("product_id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
index.wait_task(0).await;
|
||||
|
||||
// create key with access on `products` index only.
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": ["tasks.get"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.service.get("/tasks").await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
println!("{}", response);
|
||||
let response = response["results"].as_array().unwrap();
|
||||
// key should have access on `products` index.
|
||||
assert!(response.iter().any(|task| task["indexUid"] == "products"));
|
||||
|
||||
// key should not have access on `test` index.
|
||||
assert!(!response.iter().any(|task| task["indexUid"] == "test"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn list_authorized_tasks_no_index_restriction() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
|
||||
// create index `test`
|
||||
let index = server.index("test");
|
||||
let (response, code) = index.create(Some("id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
// create index `products`
|
||||
let index = server.index("products");
|
||||
let (response, code) = index.create(Some("product_id")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
index.wait_task(0).await;
|
||||
|
||||
// create key with access on all indexes.
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["tasks.get"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let (response, code) = server.service.get("/tasks").await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
|
||||
let response = response["results"].as_array().unwrap();
|
||||
// key should have access on `products` index.
|
||||
assert!(response.iter().any(|task| task["indexUid"] == "products"));
|
||||
|
||||
// key should have access on `test` index.
|
||||
assert!(response.iter().any(|task| task["indexUid"] == "test"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_creating_index_without_action() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
// create key with access on all indexes.
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
// Give all action but the ones allowing to create an index.
|
||||
"actions": ALL_ACTIONS.iter().cloned().filter(|a| !AUTHORIZATIONS.get(&("POST","/indexes")).unwrap().contains(a)).collect::<Vec<_>>(),
|
||||
"expiresAt": "2050-11-13T00:00:00Z"
|
||||
});
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
let expected_error = json!({
|
||||
"message": "Index `test` not found.",
|
||||
"code": "index_not_found",
|
||||
"type": "invalid_request",
|
||||
"link": "https://docs.meilisearch.com/errors#index_not_found"
|
||||
});
|
||||
|
||||
// try to create a index via add documents route
|
||||
let index = server.index("test");
|
||||
let documents = json!([
|
||||
{
|
||||
"id": 1,
|
||||
"content": "foo",
|
||||
}
|
||||
]);
|
||||
|
||||
let (response, code) = index.add_documents(documents, None).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
let task_id = response["taskUid"].as_u64().unwrap();
|
||||
|
||||
let response = index.wait_task(task_id).await;
|
||||
assert_eq!(response["status"], "failed");
|
||||
assert_eq!(response["error"], expected_error.clone());
|
||||
|
||||
// try to create a index via add settings route
|
||||
let settings = json!({ "distinctAttribute": "test"});
|
||||
|
||||
let (response, code) = index.update_settings(settings).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
let task_id = response["taskUid"].as_u64().unwrap();
|
||||
|
||||
let response = index.wait_task(task_id).await;
|
||||
|
||||
assert_eq!(response["status"], "failed");
|
||||
assert_eq!(response["error"], expected_error.clone());
|
||||
|
||||
// try to create a index via add specialized settings route
|
||||
let (response, code) = index.update_distinct_attribute(json!("test")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
let task_id = response["taskUid"].as_u64().unwrap();
|
||||
|
||||
let response = index.wait_task(task_id).await;
|
||||
|
||||
assert_eq!(response["status"], "failed");
|
||||
assert_eq!(response["error"], expected_error.clone());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn lazy_create_index() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
// create key with access on all indexes.
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["*"],
|
||||
"expiresAt": "2050-11-13T00:00:00Z"
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(201, code, "{:?}", &response);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
// use created key.
|
||||
let key = response["key"].as_str().unwrap();
|
||||
server.use_api_key(&key);
|
||||
|
||||
// try to create a index via add documents route
|
||||
let index = server.index("test");
|
||||
let documents = json!([
|
||||
{
|
||||
"id": 1,
|
||||
"content": "foo",
|
||||
}
|
||||
]);
|
||||
|
||||
let (response, code) = index.add_documents(documents, None).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
let task_id = response["taskUid"].as_u64().unwrap();
|
||||
|
||||
index.wait_task(task_id).await;
|
||||
|
||||
let (response, code) = index.get_task(task_id).await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
assert_eq!(response["status"], "succeeded");
|
||||
|
||||
// try to create a index via add settings route
|
||||
let index = server.index("test1");
|
||||
let settings = json!({ "distinctAttribute": "test"});
|
||||
|
||||
let (response, code) = index.update_settings(settings).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
let task_id = response["taskUid"].as_u64().unwrap();
|
||||
|
||||
index.wait_task(task_id).await;
|
||||
|
||||
let (response, code) = index.get_task(task_id).await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
assert_eq!(response["status"], "succeeded");
|
||||
|
||||
// try to create a index via add specialized settings route
|
||||
let index = server.index("test2");
|
||||
let (response, code) = index.update_distinct_attribute(json!("test")).await;
|
||||
assert_eq!(202, code, "{:?}", &response);
|
||||
let task_id = response["taskUid"].as_u64().unwrap();
|
||||
|
||||
index.wait_task(task_id).await;
|
||||
|
||||
let (response, code) = index.get_task(task_id).await;
|
||||
assert_eq!(200, code, "{:?}", &response);
|
||||
assert_eq!(response["status"], "succeeded");
|
||||
}
|
||||
64
meilisearch-http/tests/auth/mod.rs
Normal file
64
meilisearch-http/tests/auth/mod.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
mod api_keys;
|
||||
mod authorization;
|
||||
mod payload;
|
||||
mod tenant_token;
|
||||
|
||||
use crate::common::Server;
|
||||
use actix_web::http::StatusCode;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
impl Server {
|
||||
pub fn use_api_key(&mut self, api_key: impl AsRef<str>) {
|
||||
self.service.api_key = Some(api_key.as_ref().to_string());
|
||||
}
|
||||
|
||||
/// Fetch and use the default admin key for nexts http requests.
|
||||
pub async fn use_admin_key(&mut self, master_key: impl AsRef<str>) {
|
||||
self.use_api_key(master_key);
|
||||
let (response, code) = self.list_api_keys().await;
|
||||
assert_eq!(200, code, "{:?}", response);
|
||||
let admin_key = &response["results"][1]["key"];
|
||||
self.use_api_key(admin_key.as_str().unwrap());
|
||||
}
|
||||
|
||||
pub async fn add_api_key(&self, content: Value) -> (Value, StatusCode) {
|
||||
let url = "/keys";
|
||||
self.service.post(url, content).await
|
||||
}
|
||||
|
||||
pub async fn get_api_key(&self, key: impl AsRef<str>) -> (Value, StatusCode) {
|
||||
let url = format!("/keys/{}", key.as_ref());
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn patch_api_key(&self, key: impl AsRef<str>, content: Value) -> (Value, StatusCode) {
|
||||
let url = format!("/keys/{}", key.as_ref());
|
||||
self.service.patch(url, content).await
|
||||
}
|
||||
|
||||
pub async fn list_api_keys(&self) -> (Value, StatusCode) {
|
||||
let url = "/keys";
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(&self, key: impl AsRef<str>) -> (Value, StatusCode) {
|
||||
let url = format!("/keys/{}", key.as_ref());
|
||||
self.service.delete(url).await
|
||||
}
|
||||
|
||||
pub async fn dummy_request(
|
||||
&self,
|
||||
method: impl AsRef<str>,
|
||||
url: impl AsRef<str>,
|
||||
) -> (Value, StatusCode) {
|
||||
match method.as_ref() {
|
||||
"POST" => self.service.post(url, json!({})).await,
|
||||
"PUT" => self.service.put(url, json!({})).await,
|
||||
"PATCH" => self.service.patch(url, json!({})).await,
|
||||
"GET" => self.service.get(url).await,
|
||||
"DELETE" => self.service.delete(url).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
340
meilisearch-http/tests/auth/payload.rs
Normal file
340
meilisearch-http/tests/auth/payload.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
use crate::common::Server;
|
||||
use actix_web::test;
|
||||
use meilisearch_http::{analytics, create_app};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_api_key_bad_content_types() {
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": [
|
||||
"documents.add"
|
||||
],
|
||||
"expiresAt": "2050-11-13T00:00:00Z"
|
||||
});
|
||||
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
let app = test::init_service(create_app!(
|
||||
&server.service.meilisearch,
|
||||
&server.service.auth,
|
||||
true,
|
||||
&server.service.options,
|
||||
analytics::MockAnalytics::new(&server.service.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
// post
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/keys")
|
||||
.set_payload(content.to_string())
|
||||
.insert_header(("content-type", "text/plain"))
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"The Content-Type `text/plain` is invalid. Accepted values for the Content-Type header are: `application/json`"#
|
||||
)
|
||||
);
|
||||
assert_eq!(response["code"], "invalid_content_type");
|
||||
assert_eq!(response["type"], "invalid_request");
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
"https://docs.meilisearch.com/errors#invalid_content_type"
|
||||
);
|
||||
|
||||
// patch
|
||||
let req = test::TestRequest::patch()
|
||||
.uri("/keys/d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4")
|
||||
.set_payload(content.to_string())
|
||||
.insert_header(("content-type", "text/plain"))
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"The Content-Type `text/plain` is invalid. Accepted values for the Content-Type header are: `application/json`"#
|
||||
)
|
||||
);
|
||||
assert_eq!(response["code"], "invalid_content_type");
|
||||
assert_eq!(response["type"], "invalid_request");
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
"https://docs.meilisearch.com/errors#invalid_content_type"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_api_key_empty_content_types() {
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": [
|
||||
"documents.add"
|
||||
],
|
||||
"expiresAt": "2050-11-13T00:00:00Z"
|
||||
});
|
||||
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
let app = test::init_service(create_app!(
|
||||
&server.service.meilisearch,
|
||||
&server.service.auth,
|
||||
true,
|
||||
&server.service.options,
|
||||
analytics::MockAnalytics::new(&server.service.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
// post
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/keys")
|
||||
.set_payload(content.to_string())
|
||||
.insert_header(("content-type", ""))
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"The Content-Type `` is invalid. Accepted values for the Content-Type header are: `application/json`"#
|
||||
)
|
||||
);
|
||||
assert_eq!(response["code"], "invalid_content_type");
|
||||
assert_eq!(response["type"], "invalid_request");
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
"https://docs.meilisearch.com/errors#invalid_content_type"
|
||||
);
|
||||
|
||||
// patch
|
||||
let req = test::TestRequest::patch()
|
||||
.uri("/keys/d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4")
|
||||
.set_payload(content.to_string())
|
||||
.insert_header(("content-type", ""))
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"The Content-Type `` is invalid. Accepted values for the Content-Type header are: `application/json`"#
|
||||
)
|
||||
);
|
||||
assert_eq!(response["code"], "invalid_content_type");
|
||||
assert_eq!(response["type"], "invalid_request");
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
"https://docs.meilisearch.com/errors#invalid_content_type"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_api_key_missing_content_types() {
|
||||
let content = json!({
|
||||
"indexes": ["products"],
|
||||
"actions": [
|
||||
"documents.add"
|
||||
],
|
||||
"expiresAt": "2050-11-13T00:00:00Z"
|
||||
});
|
||||
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
let app = test::init_service(create_app!(
|
||||
&server.service.meilisearch,
|
||||
&server.service.auth,
|
||||
true,
|
||||
&server.service.options,
|
||||
analytics::MockAnalytics::new(&server.service.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
// post
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/keys")
|
||||
.set_payload(content.to_string())
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"A Content-Type header is missing. Accepted values for the Content-Type header are: `application/json`"#
|
||||
)
|
||||
);
|
||||
assert_eq!(response["code"], "missing_content_type");
|
||||
assert_eq!(response["type"], "invalid_request");
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
"https://docs.meilisearch.com/errors#missing_content_type"
|
||||
);
|
||||
|
||||
// patch
|
||||
let req = test::TestRequest::patch()
|
||||
.uri("/keys/d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4")
|
||||
.set_payload(content.to_string())
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"A Content-Type header is missing. Accepted values for the Content-Type header are: `application/json`"#
|
||||
)
|
||||
);
|
||||
assert_eq!(response["code"], "missing_content_type");
|
||||
assert_eq!(response["type"], "invalid_request");
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
"https://docs.meilisearch.com/errors#missing_content_type"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_api_key_empty_payload() {
|
||||
let content = "";
|
||||
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
let app = test::init_service(create_app!(
|
||||
&server.service.meilisearch,
|
||||
&server.service.auth,
|
||||
true,
|
||||
&server.service.options,
|
||||
analytics::MockAnalytics::new(&server.service.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
// post
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/keys")
|
||||
.set_payload(content)
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.insert_header(("content-type", "application/json"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 400);
|
||||
assert_eq!(response["code"], json!("missing_payload"));
|
||||
assert_eq!(response["type"], json!("invalid_request"));
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
json!("https://docs.meilisearch.com/errors#missing_payload")
|
||||
);
|
||||
assert_eq!(response["message"], json!(r#"A json payload is missing."#));
|
||||
|
||||
// patch
|
||||
let req = test::TestRequest::patch()
|
||||
.uri("/keys/d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4")
|
||||
.set_payload(content)
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.insert_header(("content-type", "application/json"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 400);
|
||||
assert_eq!(response["code"], json!("missing_payload"));
|
||||
assert_eq!(response["type"], json!("invalid_request"));
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
json!("https://docs.meilisearch.com/errors#missing_payload")
|
||||
);
|
||||
assert_eq!(response["message"], json!(r#"A json payload is missing."#));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_api_key_malformed_payload() {
|
||||
let content = r#"{"malormed": "payload""#;
|
||||
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
let app = test::init_service(create_app!(
|
||||
&server.service.meilisearch,
|
||||
&server.service.auth,
|
||||
true,
|
||||
&server.service.options,
|
||||
analytics::MockAnalytics::new(&server.service.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
// post
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/keys")
|
||||
.set_payload(content)
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.insert_header(("content-type", "application/json"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 400);
|
||||
assert_eq!(response["code"], json!("malformed_payload"));
|
||||
assert_eq!(response["type"], json!("invalid_request"));
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
json!("https://docs.meilisearch.com/errors#malformed_payload")
|
||||
);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"The json payload provided is malformed. `EOF while parsing an object at line 1 column 22`."#
|
||||
)
|
||||
);
|
||||
|
||||
// patch
|
||||
let req = test::TestRequest::patch()
|
||||
.uri("/keys/d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4")
|
||||
.set_payload(content)
|
||||
.insert_header(("Authorization", "Bearer MASTER_KEY"))
|
||||
.insert_header(("content-type", "application/json"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 400);
|
||||
assert_eq!(response["code"], json!("malformed_payload"));
|
||||
assert_eq!(response["type"], json!("invalid_request"));
|
||||
assert_eq!(
|
||||
response["link"],
|
||||
json!("https://docs.meilisearch.com/errors#malformed_payload")
|
||||
);
|
||||
assert_eq!(
|
||||
response["message"],
|
||||
json!(
|
||||
r#"The json payload provided is malformed. `EOF while parsing an object at line 1 column 22`."#
|
||||
)
|
||||
);
|
||||
}
|
||||
584
meilisearch-http/tests/auth/tenant_token.rs
Normal file
584
meilisearch-http/tests/auth/tenant_token.rs
Normal file
@@ -0,0 +1,584 @@
|
||||
use crate::common::Server;
|
||||
use ::time::format_description::well_known::Rfc3339;
|
||||
use maplit::hashmap;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use super::authorization::{ALL_ACTIONS, AUTHORIZATIONS};
|
||||
|
||||
fn generate_tenant_token(
|
||||
parent_uid: impl AsRef<str>,
|
||||
parent_key: impl AsRef<str>,
|
||||
mut body: HashMap<&str, Value>,
|
||||
) -> String {
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
|
||||
let parent_uid = parent_uid.as_ref();
|
||||
body.insert("apiKeyUid", json!(parent_uid));
|
||||
encode(
|
||||
&Header::default(),
|
||||
&body,
|
||||
&EncodingKey::from_secret(parent_key.as_ref().as_bytes()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
static DOCUMENTS: Lazy<Value> = Lazy::new(|| {
|
||||
json!([
|
||||
{
|
||||
"title": "Shazam!",
|
||||
"id": "287947",
|
||||
"color": ["green", "blue"]
|
||||
},
|
||||
{
|
||||
"title": "Captain Marvel",
|
||||
"id": "299537",
|
||||
"color": ["yellow", "blue"]
|
||||
},
|
||||
{
|
||||
"title": "Escape Room",
|
||||
"id": "522681",
|
||||
"color": ["yellow", "red"]
|
||||
},
|
||||
{
|
||||
"title": "How to Train Your Dragon: The Hidden World",
|
||||
"id": "166428",
|
||||
"color": ["green", "red"]
|
||||
},
|
||||
{
|
||||
"title": "Glass",
|
||||
"id": "450465",
|
||||
"color": ["blue", "red"]
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
static INVALID_RESPONSE: Lazy<Value> = Lazy::new(|| {
|
||||
json!({"message": "The provided API key is invalid.",
|
||||
"code": "invalid_api_key",
|
||||
"type": "auth",
|
||||
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
|
||||
})
|
||||
});
|
||||
|
||||
static ACCEPTED_KEYS: Lazy<Vec<Value>> = Lazy::new(|| {
|
||||
vec![
|
||||
json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["*"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["search"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
json!({
|
||||
"indexes": ["sales"],
|
||||
"actions": ["*"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
json!({
|
||||
"indexes": ["sales"],
|
||||
"actions": ["search"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
]
|
||||
});
|
||||
|
||||
static REFUSED_KEYS: Lazy<Vec<Value>> = Lazy::new(|| {
|
||||
vec![
|
||||
// no search action
|
||||
json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ALL_ACTIONS.iter().cloned().filter(|a| *a != "search" && *a != "*").collect::<Vec<_>>(),
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
json!({
|
||||
"indexes": ["sales"],
|
||||
"actions": ALL_ACTIONS.iter().cloned().filter(|a| *a != "search" && *a != "*").collect::<Vec<_>>(),
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
// bad index
|
||||
json!({
|
||||
"indexes": ["products"],
|
||||
"actions": ["*"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
json!({
|
||||
"indexes": ["products"],
|
||||
"actions": ["search"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap()
|
||||
}),
|
||||
]
|
||||
});
|
||||
|
||||
macro_rules! compute_authorized_search {
|
||||
($tenant_tokens:expr, $filter:expr, $expected_count:expr) => {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
let index = server.index("sales");
|
||||
let documents = DOCUMENTS.clone();
|
||||
index.add_documents(documents, None).await;
|
||||
index.wait_task(0).await;
|
||||
index
|
||||
.update_settings(json!({"filterableAttributes": ["color"]}))
|
||||
.await;
|
||||
index.wait_task(1).await;
|
||||
drop(index);
|
||||
|
||||
for key_content in ACCEPTED_KEYS.iter() {
|
||||
server.use_api_key("MASTER_KEY");
|
||||
let (response, code) = server.add_api_key(key_content.clone()).await;
|
||||
assert_eq!(code, 201);
|
||||
let key = response["key"].as_str().unwrap();
|
||||
let uid = response["uid"].as_str().unwrap();
|
||||
|
||||
for tenant_token in $tenant_tokens.iter() {
|
||||
let web_token = generate_tenant_token(&uid, &key, tenant_token.clone());
|
||||
server.use_api_key(&web_token);
|
||||
let index = server.index("sales");
|
||||
index
|
||||
.search(json!({ "filter": $filter }), |response, code| {
|
||||
assert_eq!(
|
||||
code, 200,
|
||||
"{} using tenant_token: {:?} generated with parent_key: {:?}",
|
||||
response, tenant_token, key_content
|
||||
);
|
||||
assert_eq!(
|
||||
response["hits"].as_array().unwrap().len(),
|
||||
$expected_count,
|
||||
"{} using tenant_token: {:?} generated with parent_key: {:?}",
|
||||
response,
|
||||
tenant_token,
|
||||
key_content
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! compute_forbidden_search {
|
||||
($tenant_tokens:expr, $parent_keys:expr) => {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_admin_key("MASTER_KEY").await;
|
||||
let index = server.index("sales");
|
||||
let documents = DOCUMENTS.clone();
|
||||
index.add_documents(documents, None).await;
|
||||
index.wait_task(0).await;
|
||||
drop(index);
|
||||
|
||||
for key_content in $parent_keys.iter() {
|
||||
server.use_api_key("MASTER_KEY");
|
||||
let (response, code) = server.add_api_key(key_content.clone()).await;
|
||||
assert_eq!(code, 201, "{:?}", response);
|
||||
let key = response["key"].as_str().unwrap();
|
||||
let uid = response["uid"].as_str().unwrap();
|
||||
|
||||
for tenant_token in $tenant_tokens.iter() {
|
||||
let web_token = generate_tenant_token(&uid, &key, tenant_token.clone());
|
||||
server.use_api_key(&web_token);
|
||||
let index = server.index("sales");
|
||||
index
|
||||
.search(json!({}), |response, code| {
|
||||
assert_eq!(
|
||||
response,
|
||||
INVALID_RESPONSE.clone(),
|
||||
"{} using tenant_token: {:?} generated with parent_key: {:?}",
|
||||
response,
|
||||
tenant_token,
|
||||
key_content
|
||||
);
|
||||
assert_eq!(
|
||||
code, 403,
|
||||
"{} using tenant_token: {:?} generated with parent_key: {:?}",
|
||||
response, tenant_token, key_content
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn search_authorized_simple_token() {
|
||||
let tenant_tokens = vec![
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["*"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["sales"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {}}),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": Value::Null}),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["*"]),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {}}),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": Value::Null}),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["sales"]),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
];
|
||||
|
||||
compute_authorized_search!(tenant_tokens, {}, 5);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn search_authorized_filter_token() {
|
||||
let tenant_tokens = vec![
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {"filter": "color = blue"}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {"filter": "color = blue"}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {"filter": ["color = blue"]}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {"filter": ["color = blue"]}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
// filter on sales should override filters on *
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {"filter": "color = green"},
|
||||
"sales": {"filter": "color = blue"}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {},
|
||||
"sales": {"filter": "color = blue"}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {"filter": "color = green"},
|
||||
"sales": {"filter": ["color = blue"]}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {},
|
||||
"sales": {"filter": ["color = blue"]}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
];
|
||||
|
||||
compute_authorized_search!(tenant_tokens, {}, 3);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn filter_search_authorized_filter_token() {
|
||||
let tenant_tokens = vec![
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {"filter": "color = blue"}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {"filter": "color = blue"}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {"filter": ["color = blue"]}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {"filter": ["color = blue"]}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
// filter on sales should override filters on *
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {"filter": "color = green"},
|
||||
"sales": {"filter": "color = blue"}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {},
|
||||
"sales": {"filter": "color = blue"}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {"filter": "color = green"},
|
||||
"sales": {"filter": ["color = blue"]}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({
|
||||
"*": {},
|
||||
"sales": {"filter": ["color = blue"]}
|
||||
}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
];
|
||||
|
||||
compute_authorized_search!(tenant_tokens, "color = yellow", 1);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_search_token_forbidden_parent_key() {
|
||||
let tenant_tokens = vec![
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": Value::Null}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["*"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": Value::Null}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["sales"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
];
|
||||
|
||||
compute_forbidden_search!(tenant_tokens, REFUSED_KEYS);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_search_forbidden_token() {
|
||||
let tenant_tokens = vec![
|
||||
// bad index
|
||||
hashmap! {
|
||||
"searchRules" => json!({"products": {}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["products"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"products": {}}),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"products": Value::Null}),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["products"]),
|
||||
"exp" => Value::Null
|
||||
},
|
||||
// expired token
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": {}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() - Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"*": Value::Null}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() - Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["*"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() - Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": {}}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() - Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!({"sales": Value::Null}),
|
||||
"exp" => json!((OffsetDateTime::now_utc() - Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
hashmap! {
|
||||
"searchRules" => json!(["sales"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() - Duration::hours(1)).unix_timestamp())
|
||||
},
|
||||
];
|
||||
|
||||
compute_forbidden_search!(tenant_tokens, ACCEPTED_KEYS);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_access_forbidden_routes() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["*"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(code, 201);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
let uid = response["uid"].as_str().unwrap();
|
||||
|
||||
let tenant_token = hashmap! {
|
||||
"searchRules" => json!(["*"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
};
|
||||
let web_token = generate_tenant_token(&uid, &key, tenant_token);
|
||||
server.use_api_key(&web_token);
|
||||
|
||||
for ((method, route), actions) in AUTHORIZATIONS.iter() {
|
||||
if !actions.contains("search") {
|
||||
let (response, code) = server.dummy_request(method, route).await;
|
||||
assert_eq!(response, INVALID_RESPONSE.clone());
|
||||
assert_eq!(code, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_access_expired_parent_key() {
|
||||
use std::{thread, time};
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["*"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::seconds(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(code, 201);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
let uid = response["uid"].as_str().unwrap();
|
||||
|
||||
let tenant_token = hashmap! {
|
||||
"searchRules" => json!(["*"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
};
|
||||
let web_token = generate_tenant_token(&uid, &key, tenant_token);
|
||||
server.use_api_key(&web_token);
|
||||
|
||||
// test search request while parent_key is not expired
|
||||
let (response, code) = server
|
||||
.dummy_request("POST", "/indexes/products/search")
|
||||
.await;
|
||||
assert_ne!(response, INVALID_RESPONSE.clone());
|
||||
assert_ne!(code, 403);
|
||||
|
||||
// wait until the key is expired.
|
||||
thread::sleep(time::Duration::new(1, 0));
|
||||
|
||||
let (response, code) = server
|
||||
.dummy_request("POST", "/indexes/products/search")
|
||||
.await;
|
||||
assert_eq!(response, INVALID_RESPONSE.clone());
|
||||
assert_eq!(code, 403);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)]
|
||||
async fn error_access_modified_token() {
|
||||
let mut server = Server::new_auth().await;
|
||||
server.use_api_key("MASTER_KEY");
|
||||
|
||||
let content = json!({
|
||||
"indexes": ["*"],
|
||||
"actions": ["*"],
|
||||
"expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(),
|
||||
});
|
||||
|
||||
let (response, code) = server.add_api_key(content).await;
|
||||
assert_eq!(code, 201);
|
||||
assert!(response["key"].is_string());
|
||||
|
||||
let key = response["key"].as_str().unwrap();
|
||||
let uid = response["uid"].as_str().unwrap();
|
||||
|
||||
let tenant_token = hashmap! {
|
||||
"searchRules" => json!(["products"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
};
|
||||
let web_token = generate_tenant_token(&uid, &key, tenant_token);
|
||||
server.use_api_key(&web_token);
|
||||
|
||||
// test search request while web_token is valid
|
||||
let (response, code) = server
|
||||
.dummy_request("POST", "/indexes/products/search")
|
||||
.await;
|
||||
assert_ne!(response, INVALID_RESPONSE.clone());
|
||||
assert_ne!(code, 403);
|
||||
|
||||
let tenant_token = hashmap! {
|
||||
"searchRules" => json!(["*"]),
|
||||
"exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp())
|
||||
};
|
||||
|
||||
let alt = generate_tenant_token(&uid, &key, tenant_token);
|
||||
let altered_token = [
|
||||
web_token.split('.').next().unwrap(),
|
||||
alt.split('.').nth(1).unwrap(),
|
||||
web_token.split('.').nth(2).unwrap(),
|
||||
]
|
||||
.join(".");
|
||||
|
||||
server.use_api_key(&altered_token);
|
||||
let (response, code) = server
|
||||
.dummy_request("POST", "/indexes/products/search")
|
||||
.await;
|
||||
assert_eq!(response, INVALID_RESPONSE.clone());
|
||||
assert_eq!(code, 403);
|
||||
}
|
||||
258
meilisearch-http/tests/common/index.rs
Normal file
258
meilisearch-http/tests/common/index.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::{
|
||||
fmt::Write,
|
||||
panic::{catch_unwind, resume_unwind, UnwindSafe},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use actix_web::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::time::sleep;
|
||||
use urlencoding::encode;
|
||||
|
||||
use super::service::Service;
|
||||
|
||||
pub struct Index<'a> {
|
||||
pub uid: String,
|
||||
pub service: &'a Service,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Index<'_> {
|
||||
pub async fn get(&self) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}", encode(self.uid.as_ref()));
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn load_test_set(&self) -> u64 {
|
||||
let url = format!("/indexes/{}/documents", encode(self.uid.as_ref()));
|
||||
let (response, code) = self
|
||||
.service
|
||||
.post_str(url, include_str!("../assets/test_set.json"))
|
||||
.await;
|
||||
assert_eq!(code, 202);
|
||||
let update_id = response["taskUid"].as_i64().unwrap();
|
||||
self.wait_task(update_id as u64).await;
|
||||
update_id as u64
|
||||
}
|
||||
|
||||
pub async fn create(&self, primary_key: Option<&str>) -> (Value, StatusCode) {
|
||||
let body = json!({
|
||||
"uid": self.uid,
|
||||
"primaryKey": primary_key,
|
||||
});
|
||||
self.service.post("/indexes", body).await
|
||||
}
|
||||
|
||||
pub async fn update(&self, primary_key: Option<&str>) -> (Value, StatusCode) {
|
||||
let body = json!({
|
||||
"primaryKey": primary_key,
|
||||
});
|
||||
let url = format!("/indexes/{}", encode(self.uid.as_ref()));
|
||||
|
||||
self.service.patch(url, body).await
|
||||
}
|
||||
|
||||
pub async fn delete(&self) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}", encode(self.uid.as_ref()));
|
||||
self.service.delete(url).await
|
||||
}
|
||||
|
||||
pub async fn add_documents(
|
||||
&self,
|
||||
documents: Value,
|
||||
primary_key: Option<&str>,
|
||||
) -> (Value, StatusCode) {
|
||||
let url = match primary_key {
|
||||
Some(key) => format!(
|
||||
"/indexes/{}/documents?primaryKey={}",
|
||||
encode(self.uid.as_ref()),
|
||||
key
|
||||
),
|
||||
None => format!("/indexes/{}/documents", encode(self.uid.as_ref())),
|
||||
};
|
||||
self.service.post(url, documents).await
|
||||
}
|
||||
|
||||
pub async fn update_documents(
|
||||
&self,
|
||||
documents: Value,
|
||||
primary_key: Option<&str>,
|
||||
) -> (Value, StatusCode) {
|
||||
let url = match primary_key {
|
||||
Some(key) => format!(
|
||||
"/indexes/{}/documents?primaryKey={}",
|
||||
encode(self.uid.as_ref()),
|
||||
key
|
||||
),
|
||||
None => format!("/indexes/{}/documents", encode(self.uid.as_ref())),
|
||||
};
|
||||
self.service.put(url, documents).await
|
||||
}
|
||||
|
||||
pub async fn wait_task(&self, update_id: u64) -> Value {
|
||||
// try several times to get status, or panic to not wait forever
|
||||
let url = format!("/tasks/{}", update_id);
|
||||
for _ in 0..100 {
|
||||
let (response, status_code) = self.service.get(&url).await;
|
||||
assert_eq!(200, status_code, "response: {}", response);
|
||||
|
||||
if response["status"] == "succeeded" || response["status"] == "failed" {
|
||||
return response;
|
||||
}
|
||||
|
||||
// wait 0.5 second.
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
panic!("Timeout waiting for update id");
|
||||
}
|
||||
|
||||
pub async fn get_task(&self, update_id: u64) -> (Value, StatusCode) {
|
||||
let url = format!("/tasks/{}", update_id);
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn list_tasks(&self) -> (Value, StatusCode) {
|
||||
let url = format!("/tasks?indexUid={}", self.uid);
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn filtered_tasks(&self, type_: &[&str], status: &[&str]) -> (Value, StatusCode) {
|
||||
let mut url = format!("/tasks?indexUid={}", self.uid);
|
||||
if !type_.is_empty() {
|
||||
let _ = write!(url, "&type={}", type_.join(","));
|
||||
}
|
||||
if !status.is_empty() {
|
||||
let _ = write!(url, "&status={}", status.join(","));
|
||||
}
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn get_document(
|
||||
&self,
|
||||
id: u64,
|
||||
options: Option<GetDocumentOptions>,
|
||||
) -> (Value, StatusCode) {
|
||||
let mut url = format!("/indexes/{}/documents/{}", encode(self.uid.as_ref()), id);
|
||||
if let Some(fields) = options.and_then(|o| o.fields) {
|
||||
let _ = write!(url, "?fields={}", fields.join(","));
|
||||
}
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn get_all_documents(&self, options: GetAllDocumentsOptions) -> (Value, StatusCode) {
|
||||
let mut url = format!("/indexes/{}/documents?", encode(self.uid.as_ref()));
|
||||
if let Some(limit) = options.limit {
|
||||
let _ = write!(url, "limit={}&", limit);
|
||||
}
|
||||
|
||||
if let Some(offset) = options.offset {
|
||||
let _ = write!(url, "offset={}&", offset);
|
||||
}
|
||||
|
||||
if let Some(attributes_to_retrieve) = options.attributes_to_retrieve {
|
||||
let _ = write!(url, "fields={}&", attributes_to_retrieve.join(","));
|
||||
}
|
||||
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn delete_document(&self, id: u64) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}/documents/{}", encode(self.uid.as_ref()), id);
|
||||
self.service.delete(url).await
|
||||
}
|
||||
|
||||
pub async fn clear_all_documents(&self) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}/documents", encode(self.uid.as_ref()));
|
||||
self.service.delete(url).await
|
||||
}
|
||||
|
||||
pub async fn delete_batch(&self, ids: Vec<u64>) -> (Value, StatusCode) {
|
||||
let url = format!(
|
||||
"/indexes/{}/documents/delete-batch",
|
||||
encode(self.uid.as_ref())
|
||||
);
|
||||
self.service
|
||||
.post(url, serde_json::to_value(&ids).unwrap())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn settings(&self) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}/settings", encode(self.uid.as_ref()));
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn update_settings(&self, settings: Value) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}/settings", encode(self.uid.as_ref()));
|
||||
self.service.patch(url, settings).await
|
||||
}
|
||||
|
||||
pub async fn delete_settings(&self) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}/settings", encode(self.uid.as_ref()));
|
||||
self.service.delete(url).await
|
||||
}
|
||||
|
||||
pub async fn stats(&self) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}/stats", encode(self.uid.as_ref()));
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
/// Performs both GET and POST search queries
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: Value,
|
||||
test: impl Fn(Value, StatusCode) + UnwindSafe + Clone,
|
||||
) {
|
||||
let (response, code) = self.search_post(query.clone()).await;
|
||||
let t = test.clone();
|
||||
if let Err(e) = catch_unwind(move || t(response, code)) {
|
||||
eprintln!("Error with post search");
|
||||
resume_unwind(e);
|
||||
}
|
||||
|
||||
let (response, code) = self.search_get(query).await;
|
||||
if let Err(e) = catch_unwind(move || test(response, code)) {
|
||||
eprintln!("Error with get search");
|
||||
resume_unwind(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_post(&self, query: Value) -> (Value, StatusCode) {
|
||||
let url = format!("/indexes/{}/search", encode(self.uid.as_ref()));
|
||||
self.service.post(url, query).await
|
||||
}
|
||||
|
||||
pub async fn search_get(&self, query: Value) -> (Value, StatusCode) {
|
||||
let params = yaup::to_string(&query).unwrap();
|
||||
let url = format!("/indexes/{}/search?{}", encode(self.uid.as_ref()), params);
|
||||
self.service.get(url).await
|
||||
}
|
||||
|
||||
pub async fn update_distinct_attribute(&self, value: Value) -> (Value, StatusCode) {
|
||||
let url = format!(
|
||||
"/indexes/{}/settings/{}",
|
||||
encode(self.uid.as_ref()),
|
||||
"distinct-attribute"
|
||||
);
|
||||
self.service.put(url, value).await
|
||||
}
|
||||
|
||||
pub async fn get_distinct_attribute(&self) -> (Value, StatusCode) {
|
||||
let url = format!(
|
||||
"/indexes/{}/settings/{}",
|
||||
encode(self.uid.as_ref()),
|
||||
"distinct-attribute"
|
||||
);
|
||||
self.service.get(url).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetDocumentOptions {
|
||||
pub fields: Option<Vec<&'static str>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GetAllDocumentsOptions {
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
pub attributes_to_retrieve: Option<Vec<&'static str>>,
|
||||
}
|
||||
31
meilisearch-http/tests/common/mod.rs
Normal file
31
meilisearch-http/tests/common/mod.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
pub mod index;
|
||||
pub mod server;
|
||||
pub mod service;
|
||||
|
||||
pub use index::{GetAllDocumentsOptions, GetDocumentOptions};
|
||||
pub use server::{default_settings, Server};
|
||||
|
||||
/// Performs a search test on both post and get routes
|
||||
#[macro_export]
|
||||
macro_rules! test_post_get_search {
|
||||
($server:expr, $query:expr, |$response:ident, $status_code:ident | $block:expr) => {
|
||||
let post_query: meilisearch_http::routes::search::SearchQueryPost =
|
||||
serde_json::from_str(&$query.clone().to_string()).unwrap();
|
||||
let get_query: meilisearch_http::routes::search::SearchQuery = post_query.into();
|
||||
let get_query = ::serde_url_params::to_string(&get_query).unwrap();
|
||||
let ($response, $status_code) = $server.search_get(&get_query).await;
|
||||
let _ = ::std::panic::catch_unwind(|| $block).map_err(|e| {
|
||||
panic!(
|
||||
"panic in get route: {:?}",
|
||||
e.downcast_ref::<&str>().unwrap()
|
||||
)
|
||||
});
|
||||
let ($response, $status_code) = $server.search_post($query).await;
|
||||
let _ = ::std::panic::catch_unwind(|| $block).map_err(|e| {
|
||||
panic!(
|
||||
"panic in post route: {:?}",
|
||||
e.downcast_ref::<&str>().unwrap()
|
||||
)
|
||||
});
|
||||
};
|
||||
}
|
||||
167
meilisearch-http/tests/common/server.rs
Normal file
167
meilisearch-http/tests/common/server.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use clap::Parser;
|
||||
use std::path::Path;
|
||||
|
||||
use actix_web::http::StatusCode;
|
||||
use byte_unit::{Byte, ByteUnit};
|
||||
use meilisearch_auth::AuthController;
|
||||
use meilisearch_http::setup_meilisearch;
|
||||
use meilisearch_lib::options::{IndexerOpts, MaxMemory};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use meilisearch_http::option::Opt;
|
||||
|
||||
use super::index::Index;
|
||||
use super::service::Service;
|
||||
|
||||
pub struct Server {
|
||||
pub service: Service,
|
||||
// hold ownership to the tempdir while we use the server instance.
|
||||
_dir: Option<TempDir>,
|
||||
}
|
||||
|
||||
pub static TEST_TEMP_DIR: Lazy<TempDir> = Lazy::new(|| TempDir::new().unwrap());
|
||||
|
||||
impl Server {
|
||||
pub async fn new() -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
if cfg!(windows) {
|
||||
std::env::set_var("TMP", TEST_TEMP_DIR.path());
|
||||
} else {
|
||||
std::env::set_var("TMPDIR", TEST_TEMP_DIR.path());
|
||||
}
|
||||
|
||||
let options = default_settings(dir.path());
|
||||
|
||||
let meilisearch = setup_meilisearch(&options).unwrap();
|
||||
let auth = AuthController::new(&options.db_path, &options.master_key).unwrap();
|
||||
let service = Service {
|
||||
meilisearch,
|
||||
auth,
|
||||
options,
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
Server {
|
||||
service,
|
||||
_dir: Some(dir),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_auth_with_options(mut options: Opt, dir: TempDir) -> Self {
|
||||
if cfg!(windows) {
|
||||
std::env::set_var("TMP", TEST_TEMP_DIR.path());
|
||||
} else {
|
||||
std::env::set_var("TMPDIR", TEST_TEMP_DIR.path());
|
||||
}
|
||||
|
||||
options.master_key = Some("MASTER_KEY".to_string());
|
||||
|
||||
let meilisearch = setup_meilisearch(&options).unwrap();
|
||||
let auth = AuthController::new(&options.db_path, &options.master_key).unwrap();
|
||||
let service = Service {
|
||||
meilisearch,
|
||||
auth,
|
||||
options,
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
Server {
|
||||
service,
|
||||
_dir: Some(dir),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_auth() -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let options = default_settings(dir.path());
|
||||
Self::new_auth_with_options(options, dir).await
|
||||
}
|
||||
|
||||
pub async fn new_with_options(options: Opt) -> Result<Self, anyhow::Error> {
|
||||
let meilisearch = setup_meilisearch(&options)?;
|
||||
let auth = AuthController::new(&options.db_path, &options.master_key)?;
|
||||
let service = Service {
|
||||
meilisearch,
|
||||
auth,
|
||||
options,
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
Ok(Server {
|
||||
service,
|
||||
_dir: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a view to an index. There is no guarantee that the index exists.
|
||||
pub fn index(&self, uid: impl AsRef<str>) -> Index<'_> {
|
||||
Index {
|
||||
uid: uid.as_ref().to_string(),
|
||||
service: &self.service,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_indexes(
|
||||
&self,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) -> (Value, StatusCode) {
|
||||
let (offset, limit) = (
|
||||
offset.map(|offset| format!("offset={offset}")),
|
||||
limit.map(|limit| format!("limit={limit}")),
|
||||
);
|
||||
let query_parameter = offset
|
||||
.as_ref()
|
||||
.zip(limit.as_ref())
|
||||
.map(|(offset, limit)| format!("{offset}&{limit}"))
|
||||
.or_else(|| offset.xor(limit));
|
||||
if let Some(query_parameter) = query_parameter {
|
||||
self.service
|
||||
.get(format!("/indexes?{query_parameter}"))
|
||||
.await
|
||||
} else {
|
||||
self.service.get("/indexes").await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn version(&self) -> (Value, StatusCode) {
|
||||
self.service.get("/version").await
|
||||
}
|
||||
|
||||
pub async fn stats(&self) -> (Value, StatusCode) {
|
||||
self.service.get("/stats").await
|
||||
}
|
||||
|
||||
pub async fn tasks(&self) -> (Value, StatusCode) {
|
||||
self.service.get("/tasks").await
|
||||
}
|
||||
|
||||
pub async fn get_dump_status(&self, uid: &str) -> (Value, StatusCode) {
|
||||
self.service.get(format!("/dumps/{}/status", uid)).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_settings(dir: impl AsRef<Path>) -> Opt {
|
||||
Opt {
|
||||
db_path: dir.as_ref().join("db"),
|
||||
dumps_dir: dir.as_ref().join("dump"),
|
||||
env: "development".to_owned(),
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
no_analytics: true,
|
||||
max_index_size: Byte::from_unit(100.0, ByteUnit::MiB).unwrap(),
|
||||
max_task_db_size: Byte::from_unit(1.0, ByteUnit::GiB).unwrap(),
|
||||
http_payload_size_limit: Byte::from_unit(10.0, ByteUnit::MiB).unwrap(),
|
||||
snapshot_dir: ".".into(),
|
||||
indexer_options: IndexerOpts {
|
||||
// memory has to be unlimited because several meilisearch are running in test context.
|
||||
max_indexing_memory: MaxMemory::unlimited(),
|
||||
..Parser::parse_from(None as Option<&str>)
|
||||
},
|
||||
..Parser::parse_from(None as Option<&str>)
|
||||
}
|
||||
}
|
||||
161
meilisearch-http/tests/common/service.rs
Normal file
161
meilisearch-http/tests/common/service.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use actix_web::{http::StatusCode, test};
|
||||
use meilisearch_auth::AuthController;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use serde_json::Value;
|
||||
|
||||
use meilisearch_http::{analytics, create_app, Opt};
|
||||
|
||||
pub struct Service {
|
||||
pub meilisearch: MeiliSearch,
|
||||
pub auth: AuthController,
|
||||
pub options: Opt,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub async fn post(&self, url: impl AsRef<str>, body: Value) -> (Value, StatusCode) {
|
||||
let app = test::init_service(create_app!(
|
||||
&self.meilisearch,
|
||||
&self.auth,
|
||||
true,
|
||||
&self.options,
|
||||
analytics::MockAnalytics::new(&self.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut req = test::TestRequest::post().uri(url.as_ref()).set_json(&body);
|
||||
if let Some(api_key) = &self.api_key {
|
||||
req = req.insert_header(("Authorization", ["Bearer ", api_key].concat()));
|
||||
}
|
||||
let req = req.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
|
||||
let body = test::read_body(res).await;
|
||||
let response = serde_json::from_slice(&body).unwrap_or_default();
|
||||
(response, status_code)
|
||||
}
|
||||
|
||||
/// Send a test post request from a text body, with a `content-type:application/json` header.
|
||||
pub async fn post_str(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
body: impl AsRef<str>,
|
||||
) -> (Value, StatusCode) {
|
||||
let app = test::init_service(create_app!(
|
||||
&self.meilisearch,
|
||||
&self.auth,
|
||||
true,
|
||||
&self.options,
|
||||
analytics::MockAnalytics::new(&self.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut req = test::TestRequest::post()
|
||||
.uri(url.as_ref())
|
||||
.set_payload(body.as_ref().to_string())
|
||||
.insert_header(("content-type", "application/json"));
|
||||
if let Some(api_key) = &self.api_key {
|
||||
req = req.insert_header(("Authorization", ["Bearer ", api_key].concat()));
|
||||
}
|
||||
let req = req.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
|
||||
let body = test::read_body(res).await;
|
||||
let response = serde_json::from_slice(&body).unwrap_or_default();
|
||||
(response, status_code)
|
||||
}
|
||||
|
||||
pub async fn get(&self, url: impl AsRef<str>) -> (Value, StatusCode) {
|
||||
let app = test::init_service(create_app!(
|
||||
&self.meilisearch,
|
||||
&self.auth,
|
||||
true,
|
||||
&self.options,
|
||||
analytics::MockAnalytics::new(&self.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut req = test::TestRequest::get().uri(url.as_ref());
|
||||
if let Some(api_key) = &self.api_key {
|
||||
req = req.insert_header(("Authorization", ["Bearer ", api_key].concat()));
|
||||
}
|
||||
let req = req.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
|
||||
let body = test::read_body(res).await;
|
||||
let response = serde_json::from_slice(&body).unwrap_or_default();
|
||||
(response, status_code)
|
||||
}
|
||||
|
||||
pub async fn put(&self, url: impl AsRef<str>, body: Value) -> (Value, StatusCode) {
|
||||
let app = test::init_service(create_app!(
|
||||
&self.meilisearch,
|
||||
&self.auth,
|
||||
true,
|
||||
&self.options,
|
||||
analytics::MockAnalytics::new(&self.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut req = test::TestRequest::put().uri(url.as_ref()).set_json(&body);
|
||||
if let Some(api_key) = &self.api_key {
|
||||
req = req.insert_header(("Authorization", ["Bearer ", api_key].concat()));
|
||||
}
|
||||
let req = req.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
|
||||
let body = test::read_body(res).await;
|
||||
let response = serde_json::from_slice(&body).unwrap_or_default();
|
||||
(response, status_code)
|
||||
}
|
||||
|
||||
pub async fn patch(&self, url: impl AsRef<str>, body: Value) -> (Value, StatusCode) {
|
||||
let app = test::init_service(create_app!(
|
||||
&self.meilisearch,
|
||||
&self.auth,
|
||||
true,
|
||||
&self.options,
|
||||
analytics::MockAnalytics::new(&self.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut req = test::TestRequest::patch().uri(url.as_ref()).set_json(&body);
|
||||
if let Some(api_key) = &self.api_key {
|
||||
req = req.insert_header(("Authorization", ["Bearer ", api_key].concat()));
|
||||
}
|
||||
let req = req.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
|
||||
let body = test::read_body(res).await;
|
||||
let response = serde_json::from_slice(&body).unwrap_or_default();
|
||||
(response, status_code)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, url: impl AsRef<str>) -> (Value, StatusCode) {
|
||||
let app = test::init_service(create_app!(
|
||||
&self.meilisearch,
|
||||
&self.auth,
|
||||
true,
|
||||
&self.options,
|
||||
analytics::MockAnalytics::new(&self.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut req = test::TestRequest::delete().uri(url.as_ref());
|
||||
if let Some(api_key) = &self.api_key {
|
||||
req = req.insert_header(("Authorization", ["Bearer ", api_key].concat()));
|
||||
}
|
||||
let req = req.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
|
||||
let body = test::read_body(res).await;
|
||||
let response = serde_json::from_slice(&body).unwrap_or_default();
|
||||
(response, status_code)
|
||||
}
|
||||
}
|
||||
175
meilisearch-http/tests/content_type.rs
Normal file
175
meilisearch-http/tests/content_type.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod common;
|
||||
|
||||
use crate::common::Server;
|
||||
use actix_web::test;
|
||||
use meilisearch_http::{analytics, create_app};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
enum HttpVerb {
|
||||
Put,
|
||||
Patch,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl HttpVerb {
|
||||
fn test_request(&self) -> test::TestRequest {
|
||||
match self {
|
||||
HttpVerb::Put => test::TestRequest::put(),
|
||||
HttpVerb::Patch => test::TestRequest::patch(),
|
||||
HttpVerb::Post => test::TestRequest::post(),
|
||||
HttpVerb::Get => test::TestRequest::get(),
|
||||
HttpVerb::Delete => test::TestRequest::delete(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_json_bad_content_type() {
|
||||
use HttpVerb::{Patch, Post, Put};
|
||||
|
||||
let routes = [
|
||||
// all the routes except the dumps that can be created without any body or content-type
|
||||
// and the search that is not a strict json
|
||||
(Post, "/indexes"),
|
||||
(Post, "/indexes/doggo/documents/delete-batch"),
|
||||
(Post, "/indexes/doggo/search"),
|
||||
(Patch, "/indexes/doggo/settings"),
|
||||
(Put, "/indexes/doggo/settings/displayed-attributes"),
|
||||
(Put, "/indexes/doggo/settings/distinct-attribute"),
|
||||
(Put, "/indexes/doggo/settings/filterable-attributes"),
|
||||
(Put, "/indexes/doggo/settings/ranking-rules"),
|
||||
(Put, "/indexes/doggo/settings/searchable-attributes"),
|
||||
(Put, "/indexes/doggo/settings/sortable-attributes"),
|
||||
(Put, "/indexes/doggo/settings/stop-words"),
|
||||
(Put, "/indexes/doggo/settings/synonyms"),
|
||||
];
|
||||
let bad_content_types = [
|
||||
"application/csv",
|
||||
"application/x-ndjson",
|
||||
"application/x-www-form-urlencoded",
|
||||
"text/plain",
|
||||
"json",
|
||||
"application",
|
||||
"json/application",
|
||||
];
|
||||
|
||||
let document = "{}";
|
||||
let server = Server::new().await;
|
||||
let app = test::init_service(create_app!(
|
||||
&server.service.meilisearch,
|
||||
&server.service.auth,
|
||||
true,
|
||||
&server.service.options,
|
||||
analytics::MockAnalytics::new(&server.service.options).0
|
||||
))
|
||||
.await;
|
||||
for (verb, route) in routes {
|
||||
// Good content-type, we probably have an error since we didn't send anything in the json
|
||||
// so we only ensure we didn't get a bad media type error.
|
||||
let req = verb
|
||||
.test_request()
|
||||
.uri(route)
|
||||
.set_payload(document)
|
||||
.insert_header(("content-type", "application/json"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
assert_ne!(status_code, 415,
|
||||
"calling the route `{}` with a content-type of json isn't supposed to throw a bad media type error", route);
|
||||
|
||||
// No content-type.
|
||||
let req = verb
|
||||
.test_request()
|
||||
.uri(route)
|
||||
.set_payload(document)
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415, "calling the route `{}` without content-type is supposed to throw a bad media type error", route);
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"message": r#"A Content-Type header is missing. Accepted values for the Content-Type header are: `application/json`"#,
|
||||
"code": "missing_content_type",
|
||||
"type": "invalid_request",
|
||||
"link": "https://docs.meilisearch.com/errors#missing_content_type",
|
||||
}),
|
||||
"when calling the route `{}` with no content-type",
|
||||
route,
|
||||
);
|
||||
|
||||
for bad_content_type in bad_content_types {
|
||||
// Always bad content-type
|
||||
let req = verb
|
||||
.test_request()
|
||||
.uri(route)
|
||||
.set_payload(document.to_string())
|
||||
.insert_header(("content-type", bad_content_type))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
let body = test::read_body(res).await;
|
||||
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
assert_eq!(status_code, 415);
|
||||
let expected_error_message = format!(
|
||||
r#"The Content-Type `{}` is invalid. Accepted values for the Content-Type header are: `application/json`"#,
|
||||
bad_content_type
|
||||
);
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"message": expected_error_message,
|
||||
"code": "invalid_content_type",
|
||||
"type": "invalid_request",
|
||||
"link": "https://docs.meilisearch.com/errors#invalid_content_type",
|
||||
}),
|
||||
"when calling the route `{}` with a content-type of `{}`",
|
||||
route,
|
||||
bad_content_type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn extract_actual_content_type() {
|
||||
let route = "/indexes/doggo/documents";
|
||||
let documents = "[{}]";
|
||||
let server = Server::new().await;
|
||||
let app = test::init_service(create_app!(
|
||||
&server.service.meilisearch,
|
||||
&server.service.auth,
|
||||
true,
|
||||
&server.service.options,
|
||||
analytics::MockAnalytics::new(&server.service.options).0
|
||||
))
|
||||
.await;
|
||||
|
||||
// Good content-type, we probably have an error since we didn't send anything in the json
|
||||
// so we only ensure we didn't get a bad media type error.
|
||||
let req = test::TestRequest::post()
|
||||
.uri(route)
|
||||
.set_payload(documents)
|
||||
.insert_header(("content-type", "application/json; charset=utf-8"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
assert_ne!(status_code, 415,
|
||||
"calling the route `{}` with a content-type of json isn't supposed to throw a bad media type error", route);
|
||||
|
||||
let req = test::TestRequest::put()
|
||||
.uri(route)
|
||||
.set_payload(documents)
|
||||
.insert_header(("content-type", "application/json; charset=latin-1"))
|
||||
.to_request();
|
||||
let res = test::call_service(&app, req).await;
|
||||
let status_code = res.status();
|
||||
assert_ne!(status_code, 415,
|
||||
"calling the route `{}` with a content-type of json isn't supposed to throw a bad media type error", route);
|
||||
}
|
||||
24
meilisearch-http/tests/dashboard/mod.rs
Normal file
24
meilisearch-http/tests/dashboard/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::common::Server;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn dashboard_assets_load() {
|
||||
let server = Server::new().await;
|
||||
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
|
||||
}
|
||||
|
||||
let generated = generated::generate();
|
||||
|
||||
for (path, _) in generated.into_iter() {
|
||||
let path = if path == "index.html" {
|
||||
// "index.html" redirects to "/"
|
||||
"/".to_owned()
|
||||
} else {
|
||||
"/".to_owned() + path
|
||||
};
|
||||
|
||||
let (_, status_code) = server.service.get(&path).await;
|
||||
assert_eq!(status_code, 200);
|
||||
}
|
||||
}
|
||||
1101
meilisearch-http/tests/documents/add_documents.rs
Normal file
1101
meilisearch-http/tests/documents/add_documents.rs
Normal file
File diff suppressed because it is too large
Load Diff
147
meilisearch-http/tests/documents/delete_documents.rs
Normal file
147
meilisearch-http/tests/documents/delete_documents.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::{GetAllDocumentsOptions, Server};
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn delete_one_document_unexisting_index() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
let (_response, code) = index.delete_document(0).await;
|
||||
assert_eq!(code, 202);
|
||||
|
||||
let response = index.wait_task(0).await;
|
||||
|
||||
assert_eq!(response["status"], "failed");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn delete_one_unexisting_document() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.create(None).await;
|
||||
let (response, code) = index.delete_document(0).await;
|
||||
assert_eq!(code, 202, "{}", response);
|
||||
let update = index.wait_task(0).await;
|
||||
assert_eq!(update["status"], "succeeded");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn delete_one_document() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index
|
||||
.add_documents(json!([{ "id": 0, "content": "foobar" }]), None)
|
||||
.await;
|
||||
index.wait_task(0).await;
|
||||
let (_response, code) = server.index("test").delete_document(0).await;
|
||||
assert_eq!(code, 202);
|
||||
index.wait_task(1).await;
|
||||
|
||||
let (_response, code) = index.get_document(0, None).await;
|
||||
assert_eq!(code, 404);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn clear_all_documents_unexisting_index() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
let (_response, code) = index.clear_all_documents().await;
|
||||
assert_eq!(code, 202);
|
||||
|
||||
let response = index.wait_task(0).await;
|
||||
|
||||
assert_eq!(response["status"], "failed");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn clear_all_documents() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index
|
||||
.add_documents(
|
||||
json!([{ "id": 1, "content": "foobar" }, { "id": 0, "content": "foobar" }]),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
index.wait_task(0).await;
|
||||
let (_response, code) = index.clear_all_documents().await;
|
||||
assert_eq!(code, 202);
|
||||
|
||||
let _update = index.wait_task(1).await;
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert!(response["results"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn clear_all_documents_empty_index() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.create(None).await;
|
||||
|
||||
let (_response, code) = index.clear_all_documents().await;
|
||||
assert_eq!(code, 202);
|
||||
|
||||
let _update = index.wait_task(0).await;
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert!(response["results"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_delete_batch_unexisting_index() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
let (_, code) = index.delete_batch(vec![]).await;
|
||||
let expected_response = json!({
|
||||
"message": "Index `test` not found.",
|
||||
"code": "index_not_found",
|
||||
"type": "invalid_request",
|
||||
"link": "https://docs.meilisearch.com/errors#index_not_found"
|
||||
});
|
||||
assert_eq!(code, 202);
|
||||
|
||||
let response = index.wait_task(0).await;
|
||||
|
||||
assert_eq!(response["status"], "failed");
|
||||
assert_eq!(response["error"], expected_response);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn delete_batch() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.add_documents(json!([{ "id": 1, "content": "foobar" }, { "id": 0, "content": "foobar" }, { "id": 3, "content": "foobar" }]), Some("id")).await;
|
||||
index.wait_task(0).await;
|
||||
let (_response, code) = index.delete_batch(vec![1, 0]).await;
|
||||
assert_eq!(code, 202);
|
||||
|
||||
let _update = index.wait_task(1).await;
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(response["results"][0]["id"], json!(3));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn delete_no_document_batch() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.add_documents(json!([{ "id": 1, "content": "foobar" }, { "id": 0, "content": "foobar" }, { "id": 3, "content": "foobar" }]), Some("id")).await;
|
||||
index.wait_task(0).await;
|
||||
let (_response, code) = index.delete_batch(vec![]).await;
|
||||
assert_eq!(code, 202, "{}", _response);
|
||||
|
||||
let _update = index.wait_task(1).await;
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
402
meilisearch-http/tests/documents/get_documents.rs
Normal file
402
meilisearch-http/tests/documents/get_documents.rs
Normal file
@@ -0,0 +1,402 @@
|
||||
use crate::common::{GetAllDocumentsOptions, GetDocumentOptions, Server};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// TODO: partial test since we are testing error, amd error is not yet fully implemented in
|
||||
// transplant
|
||||
#[actix_rt::test]
|
||||
async fn get_unexisting_index_single_document() {
|
||||
let server = Server::new().await;
|
||||
let (_response, code) = server.index("test").get_document(1, None).await;
|
||||
assert_eq!(code, 404);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_get_unexisting_document() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.create(None).await;
|
||||
index.wait_task(0).await;
|
||||
let (response, code) = index.get_document(1, None).await;
|
||||
|
||||
let expected_response = json!({
|
||||
"message": "Document `1` not found.",
|
||||
"code": "document_not_found",
|
||||
"type": "invalid_request",
|
||||
"link": "https://docs.meilisearch.com/errors#document_not_found"
|
||||
});
|
||||
|
||||
assert_eq!(response, expected_response);
|
||||
assert_eq!(code, 404);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_document() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.create(None).await;
|
||||
let documents = serde_json::json!([
|
||||
{
|
||||
"id": 0,
|
||||
"nested": { "content": "foobar" },
|
||||
}
|
||||
]);
|
||||
let (_, code) = index.add_documents(documents, None).await;
|
||||
assert_eq!(code, 202);
|
||||
index.wait_task(1).await;
|
||||
let (response, code) = index.get_document(0, None).await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(
|
||||
response,
|
||||
serde_json::json!({
|
||||
"id": 0,
|
||||
"nested": { "content": "foobar" },
|
||||
})
|
||||
);
|
||||
|
||||
let (response, code) = index
|
||||
.get_document(
|
||||
0,
|
||||
Some(GetDocumentOptions {
|
||||
fields: Some(vec!["id"]),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(
|
||||
response,
|
||||
serde_json::json!({
|
||||
"id": 0,
|
||||
})
|
||||
);
|
||||
|
||||
let (response, code) = index
|
||||
.get_document(
|
||||
0,
|
||||
Some(GetDocumentOptions {
|
||||
fields: Some(vec!["nested.content"]),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(
|
||||
response,
|
||||
serde_json::json!({
|
||||
"nested": { "content": "foobar" },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn error_get_unexisting_index_all_documents() {
|
||||
let server = Server::new().await;
|
||||
let (response, code) = server
|
||||
.index("test")
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
|
||||
let expected_response = json!({
|
||||
"message": "Index `test` not found.",
|
||||
"code": "index_not_found",
|
||||
"type": "invalid_request",
|
||||
"link": "https://docs.meilisearch.com/errors#index_not_found"
|
||||
});
|
||||
|
||||
assert_eq!(response, expected_response);
|
||||
assert_eq!(code, 404);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_no_document() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
let (_, code) = index.create(None).await;
|
||||
assert_eq!(code, 202);
|
||||
|
||||
index.wait_task(0).await;
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert!(response["results"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_all_documents_no_options() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.load_test_set().await;
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
let arr = response["results"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 20);
|
||||
let first = serde_json::json!({
|
||||
"id":0,
|
||||
"isActive":false,
|
||||
"balance":"$2,668.55",
|
||||
"picture":"http://placehold.it/32x32",
|
||||
"age":36,
|
||||
"color":"Green",
|
||||
"name":"Lucas Hess",
|
||||
"gender":"male",
|
||||
"email":"lucashess@chorizon.com",
|
||||
"phone":"+1 (998) 478-2597",
|
||||
"address":"412 Losee Terrace, Blairstown, Georgia, 2825",
|
||||
"about":"Mollit ad in exercitation quis. Anim est ut consequat fugiat duis magna aliquip velit nisi. Commodo eiusmod est consequat proident consectetur aliqua enim fugiat. Aliqua adipisicing laboris elit proident enim veniam laboris mollit. Incididunt fugiat minim ad nostrud deserunt tempor in. Id irure officia labore qui est labore nulla nisi. Magna sit quis tempor esse consectetur amet labore duis aliqua consequat.\r\n",
|
||||
"registered":"2016-06-21T09:30:25 -02:00",
|
||||
"latitude":-44.174957,
|
||||
"longitude":-145.725388,
|
||||
"tags":["bug"
|
||||
,"bug"]});
|
||||
assert_eq!(first, arr[0]);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_get_all_documents_limit() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.load_test_set().await;
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
limit: Some(5),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 5);
|
||||
assert_eq!(response["results"][0]["id"], json!(0));
|
||||
assert_eq!(response["offset"], json!(0));
|
||||
assert_eq!(response["limit"], json!(5));
|
||||
assert_eq!(response["total"], json!(77));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_get_all_documents_offset() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.load_test_set().await;
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
offset: Some(5),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
assert_eq!(response["results"][0]["id"], json!(5));
|
||||
assert_eq!(response["offset"], json!(5));
|
||||
assert_eq!(response["limit"], json!(20));
|
||||
assert_eq!(response["total"], json!(77));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_get_all_documents_attributes_to_retrieve() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.load_test_set().await;
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
attributes_to_retrieve: Some(vec!["name"]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
for results in response["results"].as_array().unwrap() {
|
||||
assert_eq!(results.as_object().unwrap().keys().count(), 1);
|
||||
assert!(results["name"] != json!(null));
|
||||
}
|
||||
assert_eq!(response["offset"], json!(0));
|
||||
assert_eq!(response["limit"], json!(20));
|
||||
assert_eq!(response["total"], json!(77));
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
attributes_to_retrieve: Some(vec![]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
for results in response["results"].as_array().unwrap() {
|
||||
assert_eq!(results.as_object().unwrap().keys().count(), 0);
|
||||
}
|
||||
assert_eq!(response["offset"], json!(0));
|
||||
assert_eq!(response["limit"], json!(20));
|
||||
assert_eq!(response["total"], json!(77));
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
attributes_to_retrieve: Some(vec!["wrong"]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
for results in response["results"].as_array().unwrap() {
|
||||
assert_eq!(results.as_object().unwrap().keys().count(), 0);
|
||||
}
|
||||
assert_eq!(response["offset"], json!(0));
|
||||
assert_eq!(response["limit"], json!(20));
|
||||
assert_eq!(response["total"], json!(77));
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
attributes_to_retrieve: Some(vec!["name", "tags"]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
for results in response["results"].as_array().unwrap() {
|
||||
assert_eq!(results.as_object().unwrap().keys().count(), 2);
|
||||
assert!(results["name"] != json!(null));
|
||||
assert!(results["tags"] != json!(null));
|
||||
}
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
attributes_to_retrieve: Some(vec!["*"]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
for results in response["results"].as_array().unwrap() {
|
||||
assert_eq!(results.as_object().unwrap().keys().count(), 16);
|
||||
}
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions {
|
||||
attributes_to_retrieve: Some(vec!["*", "wrong"]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
for results in response["results"].as_array().unwrap() {
|
||||
assert_eq!(results.as_object().unwrap().keys().count(), 16);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_document_s_nested_attributes_to_retrieve() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index.create(None).await;
|
||||
let documents = json!([
|
||||
{
|
||||
"id": 0,
|
||||
"content.truc": "foobar",
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"content": {
|
||||
"truc": "foobar",
|
||||
"machin": "bidule",
|
||||
},
|
||||
},
|
||||
]);
|
||||
let (_, code) = index.add_documents(documents, None).await;
|
||||
assert_eq!(code, 202);
|
||||
index.wait_task(1).await;
|
||||
|
||||
let (response, code) = index
|
||||
.get_document(
|
||||
0,
|
||||
Some(GetDocumentOptions {
|
||||
fields: Some(vec!["content"]),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response, json!({}));
|
||||
let (response, code) = index
|
||||
.get_document(
|
||||
1,
|
||||
Some(GetDocumentOptions {
|
||||
fields: Some(vec!["content"]),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"content": {
|
||||
"truc": "foobar",
|
||||
"machin": "bidule",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
let (response, code) = index
|
||||
.get_document(
|
||||
0,
|
||||
Some(GetDocumentOptions {
|
||||
fields: Some(vec!["content.truc"]),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"content.truc": "foobar",
|
||||
})
|
||||
);
|
||||
let (response, code) = index
|
||||
.get_document(
|
||||
1,
|
||||
Some(GetDocumentOptions {
|
||||
fields: Some(vec!["content.truc"]),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"content": {
|
||||
"truc": "foobar",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_documents_displayed_attributes_is_ignored() {
|
||||
let server = Server::new().await;
|
||||
let index = server.index("test");
|
||||
index
|
||||
.update_settings(json!({"displayedAttributes": ["gender"]}))
|
||||
.await;
|
||||
index.load_test_set().await;
|
||||
|
||||
let (response, code) = index
|
||||
.get_all_documents(GetAllDocumentsOptions::default())
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response["results"].as_array().unwrap().len(), 20);
|
||||
assert_eq!(
|
||||
response["results"][0].as_object().unwrap().keys().count(),
|
||||
16
|
||||
);
|
||||
assert!(response["results"][0]["gender"] != json!(null));
|
||||
|
||||
assert_eq!(response["offset"], json!(0));
|
||||
assert_eq!(response["limit"], json!(20));
|
||||
assert_eq!(response["total"], json!(77));
|
||||
|
||||
let (response, code) = index.get_document(0, None).await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(response.as_object().unwrap().keys().count(), 16);
|
||||
assert!(response.as_object().unwrap().get("gender").is_some());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user