@data-client/rest 0.15.6 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/dist/index.js +61 -18
  3. package/dist/index.umd.min.js +1 -1
  4. package/legacy/NetworkError.js +16 -1
  5. package/legacy/RestEndpoint.js +17 -4
  6. package/legacy/RestEndpointTypes.js +1 -1
  7. package/legacy/RestHelpers.js +29 -16
  8. package/legacy/pathTypes.js +1 -1
  9. package/legacy/resource.js +3 -1
  10. package/legacy/resourceTypes.js +1 -1
  11. package/lib/NetworkError.d.ts +12 -0
  12. package/lib/NetworkError.d.ts.map +1 -1
  13. package/lib/NetworkError.js +16 -1
  14. package/lib/RestEndpoint.js +17 -4
  15. package/lib/RestEndpointTypes.d.ts +43 -9
  16. package/lib/RestEndpointTypes.d.ts.map +1 -1
  17. package/lib/RestEndpointTypes.js +1 -1
  18. package/lib/RestHelpers.d.ts +2 -2
  19. package/lib/RestHelpers.d.ts.map +1 -1
  20. package/lib/RestHelpers.js +29 -16
  21. package/lib/pathTypes.d.ts +14 -13
  22. package/lib/pathTypes.d.ts.map +1 -1
  23. package/lib/pathTypes.js +1 -1
  24. package/lib/resource.d.ts.map +1 -1
  25. package/lib/resource.js +3 -1
  26. package/lib/resourceTypes.d.ts +5 -1
  27. package/lib/resourceTypes.d.ts.map +1 -1
  28. package/lib/resourceTypes.js +1 -1
  29. package/package.json +5 -5
  30. package/src/NetworkError.ts +15 -0
  31. package/src/RestEndpoint.js +21 -3
  32. package/src/RestEndpointTypes.ts +33 -9
  33. package/src/RestHelpers.ts +34 -26
  34. package/src/__tests__/Resource.test.ts +131 -0
  35. package/src/__tests__/RestEndpoint.ts +212 -4
  36. package/src/__tests__/__snapshots__/RestEndpoint.ts.snap +2 -0
  37. package/src/__tests__/__snapshots__/resource-construction.test.ts.snap +1 -1
  38. package/src/__tests__/hookifyResource.ts +7 -0
  39. package/src/__tests__/resource-construction.test.ts +543 -0
  40. package/src/pathTypes.ts +35 -23
  41. package/src/resource.ts +2 -0
  42. package/src/resourceTypes.ts +3 -1
  43. package/ts4.0/NetworkError.d.ts +12 -0
  44. package/ts4.0/RestEndpointTypes.d.ts +61 -9
  45. package/ts4.0/RestHelpers.d.ts +2 -2
  46. package/ts4.0/pathTypes.d.ts +1 -1
  47. package/ts4.0/resourceExtendable.d.ts +1 -1
  48. package/ts4.0/resourceTypes.d.ts +7 -1
  49. package/ts4.1/NetworkError.d.ts +12 -0
  50. package/ts4.1/RestEndpointTypes.d.ts +61 -9
  51. package/ts4.1/RestHelpers.d.ts +2 -2
  52. package/ts4.1/pathTypes.d.ts +14 -13
  53. package/ts4.1/resourceExtendable.d.ts +1 -1
  54. package/ts4.1/resourceTypes.d.ts +7 -1
@@ -64,6 +64,13 @@ describe('resource()', () => {
64
64
  isAdmin: true,
65
65
  };
66
66
 
67
+ let errorSpy: jest.SpyInstance;
68
+ beforeAll(() => {
69
+ errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
70
+ });
71
+ afterAll(() => {
72
+ errorSpy.mockRestore();
73
+ });
67
74
  beforeEach(() => {
68
75
  nock(/.*/)
69
76
  .persist()
@@ -818,6 +825,542 @@ describe('resource()', () => {
818
825
  );
819
826
  });
820
827
 
828
+ it('UserResource.getList.move should work', async () => {
829
+ mynock.patch(`/groups/five/users/2`).reply(200, (uri, body: any) => ({
830
+ id: 2,
831
+ username: 'user2',
832
+ email: 'user2@example.com',
833
+ group: 'ten',
834
+ ...body,
835
+ }));
836
+
837
+ const { result, controller } = renderDataClient(
838
+ () => {
839
+ return {
840
+ user2: useQuery(User, { id: 2 }),
841
+ groupFive: useCache(UserResource.getList, { group: 'five' }),
842
+ groupTen: useCache(UserResource.getList, { group: 'ten' }),
843
+ };
844
+ },
845
+ {
846
+ initialFixtures: [
847
+ {
848
+ endpoint: UserResource.getList,
849
+ args: [{ group: 'five' }],
850
+ response: [
851
+ {
852
+ id: 1,
853
+ username: 'user1',
854
+ email: 'user1@example.com',
855
+ group: 'five',
856
+ },
857
+ {
858
+ id: 2,
859
+ username: 'user2',
860
+ email: 'user2@example.com',
861
+ group: 'five',
862
+ },
863
+ ],
864
+ },
865
+ {
866
+ endpoint: UserResource.getList,
867
+ args: [{ group: 'ten' }],
868
+ response: [
869
+ {
870
+ id: 3,
871
+ username: 'user3',
872
+ email: 'user3@example.com',
873
+ group: 'ten',
874
+ },
875
+ ],
876
+ },
877
+ ],
878
+ },
879
+ );
880
+
881
+ expect(result.current.groupFive).toHaveLength(2);
882
+ expect(result.current.groupTen).toHaveLength(1);
883
+
884
+ // PATCH /groups/five/users/2 - moves user 2 from 'five' to 'ten'
885
+ await act(async () => {
886
+ const response = await controller.fetch(
887
+ UserResource.getList.move,
888
+ { group: 'five', id: '2' },
889
+ {
890
+ id: 2,
891
+ group: 'ten',
892
+ },
893
+ );
894
+ expect(response.id).toEqual(2);
895
+ });
896
+
897
+ // user should be removed from group 'five'
898
+ expect(result.current.groupFive).toHaveLength(1);
899
+ expect(result.current.groupFive?.[0]?.username).toBe('user1');
900
+
901
+ // user should be added to group 'ten'
902
+ expect(result.current.groupTen).toHaveLength(2);
903
+ expect(result.current.groupTen?.map((u: any) => u.username)).toEqual(
904
+ expect.arrayContaining(['user3', 'user2']),
905
+ );
906
+
907
+ // entity should be updated
908
+ expect(result.current.user2?.group).toEqual('ten');
909
+
910
+ // move uses full path params (group + id), no searchParams
911
+ () =>
912
+ controller.fetch(
913
+ UserResource.getList.move,
914
+ { group: 'five', id: 2 },
915
+ { id: 1, group: 'ten' },
916
+ );
917
+ () =>
918
+ controller.fetch(
919
+ UserResource.getList.move,
920
+ // @ts-expect-error - missing required group
921
+ { id: 2 },
922
+ { id: 1 },
923
+ );
924
+ // @ts-expect-error
925
+ () => controller.fetch(UserResource.getList.move, { username: 'never' });
926
+ // @ts-expect-error
927
+ () => controller.fetch(UserResource.getList.move, 1, 'hi');
928
+ () =>
929
+ controller.fetch(
930
+ UserResource.getList.move,
931
+ { group: 'five', id: 2 },
932
+ // @ts-expect-error
933
+ { sdf: 'never' },
934
+ );
935
+ () =>
936
+ controller.fetch(
937
+ UserResource.getList.move,
938
+ // @ts-expect-error
939
+ { sdf: 'five', id: 2 },
940
+ { id: 1 },
941
+ );
942
+ });
943
+
944
+ it('getList.move should work when entity lacks path param field', async () => {
945
+ // Entity has 'status' but NOT 'team' - team is only a URL path param
946
+ // Collection keys include both team (from path) and status (from searchParams)
947
+ class Task extends Entity {
948
+ readonly id: number | undefined = undefined;
949
+ readonly title: string = '';
950
+ readonly status: string = 'backlog';
951
+
952
+ pk() {
953
+ return this.id?.toString();
954
+ }
955
+ }
956
+
957
+ const TeamTaskResource = resource({
958
+ path: 'http\\://test.com/teams/:team/tasks/:id',
959
+ searchParams: {} as { status: string },
960
+ schema: Task,
961
+ });
962
+
963
+ mynock.patch(`/teams/alpha/tasks/3`).reply(200, (uri, body: any) => ({
964
+ id: 3,
965
+ title: 'My Task',
966
+ status: 'in-progress',
967
+ ...body,
968
+ }));
969
+
970
+ const { result, controller } = renderDataClient(
971
+ () => {
972
+ return {
973
+ task3: useQuery(Task, { id: 3 }),
974
+ backlog: useCache(TeamTaskResource.getList, {
975
+ team: 'alpha',
976
+ status: 'backlog',
977
+ }),
978
+ inProgress: useCache(TeamTaskResource.getList, {
979
+ team: 'alpha',
980
+ status: 'in-progress',
981
+ }),
982
+ };
983
+ },
984
+ {
985
+ initialFixtures: [
986
+ {
987
+ endpoint: TeamTaskResource.getList,
988
+ args: [{ team: 'alpha', status: 'backlog' }],
989
+ response: [
990
+ { id: 1, title: 'Task 1', status: 'backlog' },
991
+ { id: 3, title: 'My Task', status: 'backlog' },
992
+ ],
993
+ },
994
+ {
995
+ endpoint: TeamTaskResource.getList,
996
+ args: [{ team: 'alpha', status: 'in-progress' }],
997
+ response: [{ id: 2, title: 'Task 2', status: 'in-progress' }],
998
+ },
999
+ ],
1000
+ },
1001
+ );
1002
+
1003
+ expect(result.current.backlog).toHaveLength(2);
1004
+ expect(result.current.inProgress).toHaveLength(1);
1005
+
1006
+ // PATCH /teams/alpha/tasks/3 - move task 3 from backlog to in-progress
1007
+ // 'team' is only in URL params (not on entity); 'status' is in body and on entity
1008
+ await act(async () => {
1009
+ const response = await controller.fetch(
1010
+ TeamTaskResource.getList.move,
1011
+ { team: 'alpha', id: '3' },
1012
+ { id: 3, status: 'in-progress' },
1013
+ );
1014
+ expect(response.id).toEqual(3);
1015
+ });
1016
+
1017
+ // task should be removed from backlog
1018
+ expect(result.current.backlog).toHaveLength(1);
1019
+ expect(result.current.backlog?.[0]?.title).toBe('Task 1');
1020
+
1021
+ // task should be added to in-progress
1022
+ expect(result.current.inProgress).toHaveLength(2);
1023
+ expect(result.current.inProgress?.map((t: any) => t.title)).toEqual(
1024
+ expect.arrayContaining(['Task 2', 'My Task']),
1025
+ );
1026
+
1027
+ // entity should be updated
1028
+ expect(result.current.task3?.status).toEqual('in-progress');
1029
+ });
1030
+
1031
+ it('getList.move should work with searchParams-based collections', async () => {
1032
+ class Task extends Entity {
1033
+ readonly id: number | undefined = undefined;
1034
+ readonly title: string = '';
1035
+ readonly status: string = 'backlog';
1036
+
1037
+ pk() {
1038
+ return this.id?.toString();
1039
+ }
1040
+ }
1041
+
1042
+ const TaskResource = resource({
1043
+ path: 'http\\://test.com/tasks/:id',
1044
+ searchParams: {} as { status: string },
1045
+ schema: Task,
1046
+ });
1047
+
1048
+ mynock.patch(`/tasks/3`).reply(200, (uri, body: any) => ({
1049
+ id: 3,
1050
+ title: 'My Task',
1051
+ status: 'in-progress',
1052
+ ...body,
1053
+ }));
1054
+
1055
+ const { result, controller } = renderDataClient(
1056
+ () => {
1057
+ return {
1058
+ task3: useQuery(Task, { id: 3 }),
1059
+ backlog: useCache(TaskResource.getList, { status: 'backlog' }),
1060
+ inProgress: useCache(TaskResource.getList, {
1061
+ status: 'in-progress',
1062
+ }),
1063
+ };
1064
+ },
1065
+ {
1066
+ initialFixtures: [
1067
+ {
1068
+ endpoint: TaskResource.getList,
1069
+ args: [{ status: 'backlog' }],
1070
+ response: [
1071
+ { id: 1, title: 'Task 1', status: 'backlog' },
1072
+ { id: 3, title: 'My Task', status: 'backlog' },
1073
+ ],
1074
+ },
1075
+ {
1076
+ endpoint: TaskResource.getList,
1077
+ args: [{ status: 'in-progress' }],
1078
+ response: [{ id: 2, title: 'Task 2', status: 'in-progress' }],
1079
+ },
1080
+ ],
1081
+ },
1082
+ );
1083
+
1084
+ expect(result.current.backlog).toHaveLength(2);
1085
+ expect(result.current.inProgress).toHaveLength(1);
1086
+
1087
+ // PATCH /tasks/3 - moves task 3 from 'backlog' to 'in-progress'
1088
+ await act(async () => {
1089
+ const response = await controller.fetch(
1090
+ TaskResource.getList.move,
1091
+ { id: '3' },
1092
+ { id: 3, status: 'in-progress' },
1093
+ );
1094
+ expect(response.id).toEqual(3);
1095
+ });
1096
+
1097
+ // task should be removed from backlog
1098
+ expect(result.current.backlog).toHaveLength(1);
1099
+ expect(result.current.backlog?.[0]?.title).toBe('Task 1');
1100
+
1101
+ // task should be added to in-progress
1102
+ expect(result.current.inProgress).toHaveLength(2);
1103
+ expect(result.current.inProgress?.map((t: any) => t.title)).toEqual(
1104
+ expect.arrayContaining(['Task 2', 'My Task']),
1105
+ );
1106
+
1107
+ // entity should be updated
1108
+ expect(result.current.task3?.status).toEqual('in-progress');
1109
+ });
1110
+
1111
+ it('getList.move should work with FormData body', async () => {
1112
+ class Task extends Entity {
1113
+ readonly id: number | undefined = undefined;
1114
+ readonly title: string = '';
1115
+ readonly status: string = 'backlog';
1116
+
1117
+ pk() {
1118
+ return this.id?.toString();
1119
+ }
1120
+ }
1121
+
1122
+ const TaskResource = resource({
1123
+ path: 'http\\://test.com/tasks/:id',
1124
+ searchParams: {} as { status: string },
1125
+ schema: Task,
1126
+ });
1127
+
1128
+ mynock.patch(`/tasks/3`).reply(200, (uri, body: any) => ({
1129
+ id: 3,
1130
+ title: 'My Task',
1131
+ status: 'in-progress',
1132
+ ...(body instanceof FormData ? Object.fromEntries(body.entries()) : body),
1133
+ }));
1134
+
1135
+ const { result, controller } = renderDataClient(
1136
+ () => {
1137
+ return {
1138
+ task3: useQuery(Task, { id: 3 }),
1139
+ backlog: useCache(TaskResource.getList, { status: 'backlog' }),
1140
+ inProgress: useCache(TaskResource.getList, {
1141
+ status: 'in-progress',
1142
+ }),
1143
+ };
1144
+ },
1145
+ {
1146
+ initialFixtures: [
1147
+ {
1148
+ endpoint: TaskResource.getList,
1149
+ args: [{ status: 'backlog' }],
1150
+ response: [
1151
+ { id: 1, title: 'Task 1', status: 'backlog' },
1152
+ { id: 3, title: 'My Task', status: 'backlog' },
1153
+ ],
1154
+ },
1155
+ {
1156
+ endpoint: TaskResource.getList,
1157
+ args: [{ status: 'in-progress' }],
1158
+ response: [{ id: 2, title: 'Task 2', status: 'in-progress' }],
1159
+ },
1160
+ ],
1161
+ },
1162
+ );
1163
+
1164
+ expect(result.current.backlog).toHaveLength(2);
1165
+ expect(result.current.inProgress).toHaveLength(1);
1166
+
1167
+ // Use FormData as the body (simulates multipart form submission)
1168
+ const formData = new FormData();
1169
+ formData.append('id', '3');
1170
+ formData.append('status', 'in-progress');
1171
+
1172
+ await act(async () => {
1173
+ const response = await controller.fetch(
1174
+ TaskResource.getList.move,
1175
+ { id: '3' },
1176
+ formData,
1177
+ );
1178
+ expect(response.id).toEqual(3);
1179
+ });
1180
+
1181
+ // task should be removed from backlog
1182
+ expect(result.current.backlog).toHaveLength(1);
1183
+ expect(result.current.backlog?.[0]?.title).toBe('Task 1');
1184
+
1185
+ // task should be added to in-progress
1186
+ expect(result.current.inProgress).toHaveLength(2);
1187
+ expect(result.current.inProgress?.map((t: any) => t.title)).toEqual(
1188
+ expect.arrayContaining(['Task 2', 'My Task']),
1189
+ );
1190
+
1191
+ // entity should be updated
1192
+ expect(result.current.task3?.status).toEqual('in-progress');
1193
+ });
1194
+
1195
+ it('getList.move should work optimistically with path-based collections', async () => {
1196
+ const OptUserResource = resource({
1197
+ path: 'http\\://test.com/groups/:group/users/:id',
1198
+ schema: User,
1199
+ optimistic: true,
1200
+ });
1201
+
1202
+ mynock.patch(`/groups/five/users/2`).reply(200, () => ({
1203
+ id: 2,
1204
+ username: 'user2',
1205
+ email: 'user2@example.com',
1206
+ group: 'ten',
1207
+ }));
1208
+
1209
+ const { result, controller } = renderDataClient(
1210
+ () => {
1211
+ return {
1212
+ user2: useQuery(User, { id: 2 }),
1213
+ groupFive: useCache(OptUserResource.getList, { group: 'five' }),
1214
+ groupTen: useCache(OptUserResource.getList, { group: 'ten' }),
1215
+ };
1216
+ },
1217
+ {
1218
+ initialFixtures: [
1219
+ {
1220
+ endpoint: OptUserResource.getList,
1221
+ args: [{ group: 'five' }],
1222
+ response: [
1223
+ {
1224
+ id: 1,
1225
+ username: 'user1',
1226
+ email: 'user1@example.com',
1227
+ group: 'five',
1228
+ },
1229
+ {
1230
+ id: 2,
1231
+ username: 'user2',
1232
+ email: 'user2@example.com',
1233
+ group: 'five',
1234
+ },
1235
+ ],
1236
+ },
1237
+ {
1238
+ endpoint: OptUserResource.getList,
1239
+ args: [{ group: 'ten' }],
1240
+ response: [
1241
+ {
1242
+ id: 3,
1243
+ username: 'user3',
1244
+ email: 'user3@example.com',
1245
+ group: 'ten',
1246
+ },
1247
+ ],
1248
+ },
1249
+ ],
1250
+ },
1251
+ );
1252
+
1253
+ expect(OptUserResource.getList.move.getOptimisticResponse).toBeDefined();
1254
+ expect(result.current.groupFive).toHaveLength(2);
1255
+ expect(result.current.groupTen).toHaveLength(1);
1256
+
1257
+ let promise: any;
1258
+ act(() => {
1259
+ promise = controller.fetch(
1260
+ OptUserResource.getList.move,
1261
+ { group: 'five', id: '2' },
1262
+ { id: 2, group: 'ten' },
1263
+ );
1264
+ });
1265
+
1266
+ // optimistic: should update immediately before network responds
1267
+ expect(result.current.groupFive).toHaveLength(1);
1268
+ expect(result.current.groupFive?.[0]?.username).toBe('user1');
1269
+ expect(result.current.groupTen).toHaveLength(2);
1270
+ expect(result.current.groupTen?.map((u: any) => u.username)).toEqual(
1271
+ expect.arrayContaining(['user3', 'user2']),
1272
+ );
1273
+ expect(result.current.user2?.group).toEqual('ten');
1274
+
1275
+ // after server response, should still be correct
1276
+ await act(() => promise);
1277
+ expect(result.current.groupFive).toHaveLength(1);
1278
+ expect(result.current.groupTen).toHaveLength(2);
1279
+ expect(result.current.user2?.group).toEqual('ten');
1280
+ });
1281
+
1282
+ it('getList.move should work optimistically with searchParams-based collections', async () => {
1283
+ class Task extends Entity {
1284
+ readonly id: number | undefined = undefined;
1285
+ readonly title: string = '';
1286
+ readonly status: string = 'backlog';
1287
+
1288
+ pk() {
1289
+ return this.id?.toString();
1290
+ }
1291
+ }
1292
+
1293
+ const TaskResource = resource({
1294
+ path: 'http\\://test.com/tasks/:id',
1295
+ searchParams: {} as { status: string },
1296
+ schema: Task,
1297
+ optimistic: true,
1298
+ });
1299
+
1300
+ mynock.patch(`/tasks/3`).reply(200, () => ({
1301
+ id: 3,
1302
+ title: 'My Task',
1303
+ status: 'in-progress',
1304
+ }));
1305
+
1306
+ const { result, controller } = renderDataClient(
1307
+ () => {
1308
+ return {
1309
+ task3: useQuery(Task, { id: 3 }),
1310
+ backlog: useCache(TaskResource.getList, { status: 'backlog' }),
1311
+ inProgress: useCache(TaskResource.getList, {
1312
+ status: 'in-progress',
1313
+ }),
1314
+ };
1315
+ },
1316
+ {
1317
+ initialFixtures: [
1318
+ {
1319
+ endpoint: TaskResource.getList,
1320
+ args: [{ status: 'backlog' }],
1321
+ response: [
1322
+ { id: 1, title: 'Task 1', status: 'backlog' },
1323
+ { id: 3, title: 'My Task', status: 'backlog' },
1324
+ ],
1325
+ },
1326
+ {
1327
+ endpoint: TaskResource.getList,
1328
+ args: [{ status: 'in-progress' }],
1329
+ response: [{ id: 2, title: 'Task 2', status: 'in-progress' }],
1330
+ },
1331
+ ],
1332
+ },
1333
+ );
1334
+
1335
+ expect(TaskResource.getList.move.getOptimisticResponse).toBeDefined();
1336
+ expect(result.current.backlog).toHaveLength(2);
1337
+ expect(result.current.inProgress).toHaveLength(1);
1338
+
1339
+ let promise: any;
1340
+ act(() => {
1341
+ promise = controller.fetch(
1342
+ TaskResource.getList.move,
1343
+ { id: '3' },
1344
+ { id: 3, status: 'in-progress' },
1345
+ );
1346
+ });
1347
+
1348
+ // optimistic: should update immediately before network responds
1349
+ expect(result.current.backlog).toHaveLength(1);
1350
+ expect(result.current.backlog?.[0]?.title).toBe('Task 1');
1351
+ expect(result.current.inProgress).toHaveLength(2);
1352
+ expect(result.current.inProgress?.map((t: any) => t.title)).toEqual(
1353
+ expect.arrayContaining(['Task 2', 'My Task']),
1354
+ );
1355
+ expect(result.current.task3?.status).toEqual('in-progress');
1356
+
1357
+ // after server response, should still be correct
1358
+ await act(() => promise);
1359
+ expect(result.current.backlog).toHaveLength(1);
1360
+ expect(result.current.inProgress).toHaveLength(2);
1361
+ expect(result.current.task3?.status).toEqual('in-progress');
1362
+ });
1363
+
821
1364
  it.each([
822
1365
  {
823
1366
  response: {
package/src/pathTypes.ts CHANGED
@@ -1,8 +1,14 @@
1
- type OnlyOptional<S extends string> =
2
- S extends `${infer K}}?` ? K
3
- : S extends `${infer K}?` ? K
4
- : never;
5
- type OnlyRequired<S extends string> = S extends `${string}?` ? never : S;
1
+ type CleanKey<S extends string> = S extends `"${infer K}"` ? K : S;
2
+
3
+ type KeyName<K extends string> = CleanKey<
4
+ K extends `*${infer N}}` ? N
5
+ : K extends `*${infer N}` ? N
6
+ : K extends `${infer N}}` ? N
7
+ : K
8
+ >;
9
+
10
+ type KeyVal<K extends string> =
11
+ K extends `*${string}` ? string[] : string | number;
6
12
 
7
13
  /** Parameters for a given path */
8
14
  export type PathArgs<S extends string> =
@@ -14,43 +20,49 @@ export type PathArgs<S extends string> =
14
20
  /** Computes the union of keys for a path string */
15
21
  export type PathKeys<S extends string> =
16
22
  string extends S ? string
17
- : S extends `${infer A}\\${':' | '?' | '+' | '*' | '{' | '}'}${infer B}` ?
23
+ : S extends `${infer A}\\${':' | '*' | '}'}${infer B}` ?
18
24
  PathKeys<A> | PathKeys<B>
19
- : PathSplits<S>;
25
+ : Splits<S, ':'> | Splits<S, '*'>;
20
26
 
21
- type PathSplits<S extends string> =
22
- S extends (
23
- `${string}:${infer K}${'/' | ',' | '%' | '&' | '+' | '*' | '{'}${infer R}`
27
+ type Splits<S extends string, M extends ':' | '*'> =
28
+ S extends `${string}${M}${infer K}${M}${infer R}` ?
29
+ Splits<`${M}${K}`, M> | Splits<`${M}${R}`, M>
30
+ : S extends (
31
+ `${string}${M}${infer K}${'/' | '\\' | '%' | '&' | '*' | ':' | '{' | ';' | ',' | '!' | '@'}${infer R}`
24
32
  ) ?
25
- PathSplits<`:${K}`> | PathSplits<R>
26
- : S extends `${string}:${infer K}:${infer R}` ?
27
- PathSplits<`:${K}`> | PathSplits<`:${R}`>
28
- : S extends `${string}:${infer K}` ? K
33
+ Splits<`${M}${K}`, M> | Splits<R, M>
34
+ : S extends `${string}${M}${infer K}` ?
35
+ M extends '*' ?
36
+ `*${K}`
37
+ : K
29
38
  : never;
30
39
 
31
40
  export type KeysToArgs<Key extends string> = {
32
- [K in Key as OnlyOptional<K>]?: string | number;
33
- } & (OnlyRequired<Key> extends never ? unknown
41
+ [K in Key as K extends `${string}}` ? KeyName<K> : never]?: KeyVal<K>;
42
+ } & (Exclude<Key, `${string}}`> extends never ? unknown
34
43
  : {
35
- [K in Key as OnlyRequired<K>]: string | number;
44
+ [K in Key as K extends `${string}}` ? never : KeyName<K>]: KeyVal<K>;
36
45
  });
37
46
 
38
47
  export type PathArgsAndSearch<S extends string> =
39
- OnlyRequired<PathKeys<S>> extends never ?
48
+ Exclude<PathKeys<S>, `${string}}`> extends never ?
40
49
  Record<string, number | string | boolean> | undefined
41
50
  : {
42
- [K in PathKeys<S> as OnlyRequired<K>]: string | number;
43
- } & Record<string, number | string>;
51
+ [K in PathKeys<S> as K extends `${string}}` ? never
52
+ : KeyName<K>]: KeyVal<K>;
53
+ } & Record<string, number | string | string[]>;
44
54
 
45
- /** Removes the last :token */
55
+ /** Removes the last :param or *wildcard token */
46
56
  export type ShortenPath<S extends string> =
47
57
  string extends S ? string
48
- : S extends `${infer B}:${infer R}` ? TrimColon<`${B}:${ShortenPath<R>}`>
58
+ : S extends `${infer B}:${infer R}` ? TrimToken<`${B}:${ShortenPath<R>}`>
59
+ : S extends `${infer B}*${infer R}` ? TrimToken<`${B}*${ShortenPath<R>}`>
49
60
  : '';
50
61
 
51
- type TrimColon<S extends string> =
62
+ type TrimToken<S extends string> =
52
63
  string extends S ? string
53
64
  : S extends `${infer R}:` ? R
65
+ : S extends `${infer R}*` ? R
54
66
  : S;
55
67
 
56
68
  export type ResourcePath = string; // `${string}:${string}`; TODO: Maybe do this in the future? Seems to hard to understand for now
package/src/resource.ts CHANGED
@@ -96,6 +96,7 @@ This warning will not show in production.`,
96
96
  const getList = new Endpoint({
97
97
  ...extraMutateOptions,
98
98
  paginationField: paginationField as string,
99
+ movePath: path,
99
100
  path: shortenedPath,
100
101
  schema: new Collection([schema as any]),
101
102
  name: getName('getList'),
@@ -112,6 +113,7 @@ This warning will not show in production.`,
112
113
  method: 'PUT',
113
114
  name: getName('update'),
114
115
  }),
116
+ // TODO(breaking): Move to getList.move
115
117
  partialUpdate: new Endpoint({
116
118
  ...extraPartialOptions,
117
119
  path,
@@ -84,7 +84,7 @@ export interface Resource<
84
84
  schema: schema.Collection<
85
85
  [O['schema']],
86
86
  ParamToArgs<
87
- O['searchParams'] extends undefined ?
87
+ [O['searchParams']] extends [undefined] ?
88
88
  KeysToArgs<ShortenPath<O['path']>>
89
89
  : O['searchParams'] & PathArgs<ShortenPath<O['path']>>
90
90
  >
@@ -92,6 +92,7 @@ export interface Resource<
92
92
  body: 'body' extends keyof O ? O['body']
93
93
  : Partial<Denormalize<O['schema']>>;
94
94
  searchParams: O['searchParams'];
95
+ movePath: O['path'];
95
96
  } & Pick<O, 'paginationField'>
96
97
  >
97
98
  : GetEndpoint<
@@ -107,6 +108,7 @@ export interface Resource<
107
108
  body: 'body' extends keyof O ? O['body']
108
109
  : Partial<Denormalize<O['schema']>>;
109
110
  searchParams: Record<string, number | string | boolean> | undefined;
111
+ movePath: O['path'];
110
112
  } & Pick<O, 'paginationField'>
111
113
  >;
112
114
  /** Create a new item (POST)