resora 0.1.11 → 0.2.1

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.
package/dist/index.mjs CHANGED
@@ -14,14 +14,347 @@ function ApiResource(instance) {
14
14
  }
15
15
 
16
16
  //#endregion
17
- //#region src/utility.ts
17
+ //#region src/utilities/state.ts
18
+ let globalPreferredCase;
19
+ let globalResponseStructure;
20
+ let globalPaginatedExtras = ["meta", "links"];
21
+ let globalPaginatedLinks = {
22
+ first: "first",
23
+ last: "last",
24
+ prev: "prev",
25
+ next: "next"
26
+ };
27
+ let globalBaseUrl = "https://localhost";
28
+ let globalPageName = "page";
29
+ let globalPaginatedMeta = {
30
+ to: "to",
31
+ from: "from",
32
+ links: "links",
33
+ path: "path",
34
+ total: "total",
35
+ per_page: "per_page",
36
+ last_page: "last_page",
37
+ current_page: "current_page"
38
+ };
39
+ let globalCursorMeta = {
40
+ previous: "previous",
41
+ next: "next"
42
+ };
43
+ const setGlobalCase = (style) => {
44
+ globalPreferredCase = style;
45
+ };
46
+ const getGlobalCase = () => {
47
+ return globalPreferredCase;
48
+ };
49
+ const setGlobalResponseStructure = (config) => {
50
+ globalResponseStructure = config;
51
+ };
52
+ const getGlobalResponseStructure = () => {
53
+ return globalResponseStructure;
54
+ };
55
+ const setGlobalResponseRootKey = (rootKey) => {
56
+ globalResponseStructure = {
57
+ ...globalResponseStructure || {},
58
+ rootKey
59
+ };
60
+ };
61
+ const setGlobalResponseWrap = (wrap) => {
62
+ globalResponseStructure = {
63
+ ...globalResponseStructure || {},
64
+ wrap
65
+ };
66
+ };
67
+ const getGlobalResponseWrap = () => {
68
+ return globalResponseStructure?.wrap;
69
+ };
70
+ const getGlobalResponseRootKey = () => {
71
+ return globalResponseStructure?.rootKey;
72
+ };
73
+ const setGlobalResponseFactory = (factory) => {
74
+ globalResponseStructure = {
75
+ ...globalResponseStructure || {},
76
+ factory
77
+ };
78
+ };
79
+ const getGlobalResponseFactory = () => {
80
+ return globalResponseStructure?.factory;
81
+ };
82
+ const setGlobalPaginatedExtras = (extras) => {
83
+ globalPaginatedExtras = extras;
84
+ };
85
+ const getGlobalPaginatedExtras = () => {
86
+ return globalPaginatedExtras;
87
+ };
88
+ const setGlobalPaginatedLinks = (links) => {
89
+ globalPaginatedLinks = {
90
+ ...globalPaginatedLinks,
91
+ ...links
92
+ };
93
+ };
94
+ const getGlobalPaginatedLinks = () => {
95
+ return globalPaginatedLinks;
96
+ };
97
+ const setGlobalBaseUrl = (baseUrl) => {
98
+ globalBaseUrl = baseUrl;
99
+ };
100
+ const getGlobalBaseUrl = () => {
101
+ return globalBaseUrl;
102
+ };
103
+ const setGlobalPageName = (pageName) => {
104
+ globalPageName = pageName;
105
+ };
106
+ const getGlobalPageName = () => {
107
+ return globalPageName;
108
+ };
109
+ const setGlobalPaginatedMeta = (meta) => {
110
+ globalPaginatedMeta = {
111
+ ...globalPaginatedMeta,
112
+ ...meta
113
+ };
114
+ };
115
+ const getGlobalPaginatedMeta = () => {
116
+ return globalPaginatedMeta;
117
+ };
118
+ const setGlobalCursorMeta = (meta) => {
119
+ globalCursorMeta = {
120
+ ...globalCursorMeta,
121
+ ...meta
122
+ };
123
+ };
124
+ const getGlobalCursorMeta = () => {
125
+ return globalCursorMeta;
126
+ };
127
+
128
+ //#endregion
129
+ //#region src/utilities/pagination.ts
130
+ const getPaginationExtraKeys = () => {
131
+ const extras = getGlobalPaginatedExtras();
132
+ if (Array.isArray(extras)) return {
133
+ metaKey: extras.includes("meta") ? "meta" : void 0,
134
+ linksKey: extras.includes("links") ? "links" : void 0,
135
+ cursorKey: extras.includes("cursor") ? "cursor" : void 0
136
+ };
137
+ return {
138
+ metaKey: extras.meta,
139
+ linksKey: extras.links,
140
+ cursorKey: extras.cursor
141
+ };
142
+ };
143
+ const buildPageUrl = (page, pathName) => {
144
+ if (typeof page === "undefined") return;
145
+ const rawPath = pathName || "";
146
+ const base = getGlobalBaseUrl() || "";
147
+ const isAbsolutePath = /^https?:\/\//i.test(rawPath);
148
+ const normalizedBase = base.replace(/\/$/, "");
149
+ const normalizedPath = rawPath.replace(/^\//, "");
150
+ const root = isAbsolutePath ? rawPath : normalizedBase ? normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase : "";
151
+ if (!root) return;
152
+ const url = new URL(root);
153
+ url.searchParams.set(getGlobalPageName() || "page", String(page));
154
+ return url.toString();
155
+ };
156
+ const buildPaginationExtras = (resource) => {
157
+ const { metaKey, linksKey, cursorKey } = getPaginationExtraKeys();
158
+ const extra = {};
159
+ const pagination = resource?.pagination;
160
+ const cursor = resource?.cursor;
161
+ const metaBlock = {};
162
+ const linksBlock = {};
163
+ if (pagination) {
164
+ const metaSource = {
165
+ to: pagination.to,
166
+ from: pagination.from,
167
+ links: pagination.links,
168
+ path: pagination.path,
169
+ total: pagination.total,
170
+ per_page: pagination.perPage,
171
+ last_page: pagination.lastPage,
172
+ current_page: pagination.currentPage
173
+ };
174
+ for (const [sourceKey, outputKey] of Object.entries(getGlobalPaginatedMeta())) {
175
+ if (!outputKey) continue;
176
+ const value = metaSource[sourceKey];
177
+ if (typeof value !== "undefined") metaBlock[outputKey] = value;
178
+ }
179
+ const linksSource = {
180
+ first: buildPageUrl(pagination.firstPage, pagination.path),
181
+ last: buildPageUrl(pagination.lastPage, pagination.path),
182
+ prev: buildPageUrl(pagination.prevPage, pagination.path),
183
+ next: buildPageUrl(pagination.nextPage, pagination.path)
184
+ };
185
+ for (const [sourceKey, outputKey] of Object.entries(getGlobalPaginatedLinks())) {
186
+ if (!outputKey) continue;
187
+ const value = linksSource[sourceKey];
188
+ if (typeof value !== "undefined") linksBlock[outputKey] = value;
189
+ }
190
+ }
191
+ if (cursor) {
192
+ const cursorBlock = {};
193
+ const cursorSource = {
194
+ previous: cursor.previous,
195
+ next: cursor.next
196
+ };
197
+ for (const [sourceKey, outputKey] of Object.entries(getGlobalCursorMeta())) {
198
+ if (!outputKey) continue;
199
+ const value = cursorSource[sourceKey];
200
+ if (typeof value !== "undefined") cursorBlock[outputKey] = value;
201
+ }
202
+ if (cursorKey && Object.keys(cursorBlock).length > 0) extra[cursorKey] = cursorBlock;
203
+ else if (Object.keys(cursorBlock).length > 0) metaBlock.cursor = cursorBlock;
204
+ }
205
+ if (metaKey && Object.keys(metaBlock).length > 0) extra[metaKey] = metaBlock;
206
+ if (linksKey && Object.keys(linksBlock).length > 0) extra[linksKey] = linksBlock;
207
+ return extra;
208
+ };
209
+
210
+ //#endregion
211
+ //#region src/utilities/objects.ts
212
+ const isPlainObject = (value) => {
213
+ if (typeof value !== "object" || value === null) return false;
214
+ if (Array.isArray(value) || value instanceof Date || value instanceof RegExp) return false;
215
+ const proto = Object.getPrototypeOf(value);
216
+ return proto === Object.prototype || proto === null;
217
+ };
218
+ const appendRootProperties = (body, extra, rootKey = "data") => {
219
+ if (!extra || Object.keys(extra).length === 0) return body;
220
+ if (Array.isArray(body)) return {
221
+ [rootKey]: body,
222
+ ...extra
223
+ };
224
+ if (isPlainObject(body)) return {
225
+ ...body,
226
+ ...extra
227
+ };
228
+ return {
229
+ [rootKey]: body,
230
+ ...extra
231
+ };
232
+ };
233
+ const mergeMetadata = (base, incoming) => {
234
+ if (!incoming) return base;
235
+ if (!base) return incoming;
236
+ const merged = { ...base };
237
+ for (const [key, value] of Object.entries(incoming)) {
238
+ const existing = merged[key];
239
+ if (isPlainObject(existing) && isPlainObject(value)) merged[key] = mergeMetadata(existing, value);
240
+ else merged[key] = value;
241
+ }
242
+ return merged;
243
+ };
244
+
245
+ //#endregion
246
+ //#region src/utilities/response.ts
247
+ const buildResponseEnvelope = ({ payload, meta, metaKey = "meta", wrap = true, rootKey = "data", factory, context }) => {
248
+ if (factory) return factory(payload, {
249
+ ...context,
250
+ rootKey,
251
+ meta
252
+ });
253
+ if (!wrap) {
254
+ if (typeof meta === "undefined") return payload;
255
+ if (isPlainObject(payload)) return {
256
+ ...payload,
257
+ [metaKey]: meta
258
+ };
259
+ return {
260
+ [rootKey]: payload,
261
+ [metaKey]: meta
262
+ };
263
+ }
264
+ const body = { [rootKey]: payload };
265
+ if (typeof meta !== "undefined") body[metaKey] = meta;
266
+ return body;
267
+ };
268
+
269
+ //#endregion
270
+ //#region src/utilities/metadata.ts
271
+ const resolveWithHookMetadata = (instance, baseWithMethod) => {
272
+ const candidate = instance?.with;
273
+ if (typeof candidate !== "function" || candidate === baseWithMethod) return;
274
+ if (candidate.length > 0) return;
275
+ const result = candidate.call(instance);
276
+ return isPlainObject(result) ? result : void 0;
277
+ };
278
+
279
+ //#endregion
280
+ //#region src/utilities/conditional.ts
281
+ const CONDITIONAL_ATTRIBUTE_MISSING = Symbol("resora.conditional.missing");
282
+ const resolveWhen = (condition, value) => {
283
+ if (!condition) return CONDITIONAL_ATTRIBUTE_MISSING;
284
+ return typeof value === "function" ? value() : value;
285
+ };
286
+ const resolveWhenNotNull = (value) => {
287
+ return value === null || typeof value === "undefined" ? CONDITIONAL_ATTRIBUTE_MISSING : value;
288
+ };
289
+ const resolveMergeWhen = (condition, value) => {
290
+ if (!condition) return {};
291
+ const resolved = typeof value === "function" ? value() : value;
292
+ return isPlainObject(resolved) ? resolved : {};
293
+ };
294
+ const sanitizeConditionalAttributes = (value) => {
295
+ if (value === CONDITIONAL_ATTRIBUTE_MISSING) return CONDITIONAL_ATTRIBUTE_MISSING;
296
+ if (Array.isArray(value)) return value.map((item) => sanitizeConditionalAttributes(item)).filter((item) => item !== CONDITIONAL_ATTRIBUTE_MISSING);
297
+ if (isPlainObject(value)) {
298
+ const result = {};
299
+ for (const [key, nestedValue] of Object.entries(value)) {
300
+ const sanitizedValue = sanitizeConditionalAttributes(nestedValue);
301
+ if (sanitizedValue === CONDITIONAL_ATTRIBUTE_MISSING) continue;
302
+ result[key] = sanitizedValue;
303
+ }
304
+ return result;
305
+ }
306
+ return value;
307
+ };
308
+
309
+ //#endregion
310
+ //#region src/utilities/case.ts
311
+ const splitWords = (str) => {
312
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/[-_\s]+/g, " ").trim().toLowerCase().split(" ").filter(Boolean);
313
+ };
314
+ const toCamelCase = (str) => {
315
+ return splitWords(str).map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)).join("");
316
+ };
317
+ const toSnakeCase = (str) => {
318
+ return splitWords(str).join("_");
319
+ };
320
+ const toPascalCase = (str) => {
321
+ return splitWords(str).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
322
+ };
323
+ const toKebabCase = (str) => {
324
+ return splitWords(str).join("-");
325
+ };
326
+ const getCaseTransformer = (style) => {
327
+ if (typeof style === "function") return style;
328
+ switch (style) {
329
+ case "camel": return toCamelCase;
330
+ case "snake": return toSnakeCase;
331
+ case "pascal": return toPascalCase;
332
+ case "kebab": return toKebabCase;
333
+ }
334
+ };
335
+ const transformKeys = (obj, transformer) => {
336
+ if (obj === null || obj === void 0) return obj;
337
+ if (Array.isArray(obj)) return obj.map((item) => transformKeys(item, transformer));
338
+ if (obj instanceof Date || obj instanceof RegExp) return obj;
339
+ if (typeof obj === "object") return Object.fromEntries(Object.entries(obj).map(([key, value]) => [transformer(key), transformKeys(value, transformer)]));
340
+ return obj;
341
+ };
342
+
343
+ //#endregion
344
+ //#region src/utilities/config.ts
18
345
  let stubsDir = path.resolve(process.cwd(), "node_modules/resora/stubs");
19
346
  if (!existsSync(stubsDir)) stubsDir = path.resolve(process.cwd(), "stubs");
20
347
  const getDefaultConfig = () => {
21
348
  return {
22
349
  stubsDir,
23
350
  preferredCase: "camel",
351
+ responseStructure: {
352
+ wrap: true,
353
+ rootKey: "data"
354
+ },
24
355
  paginatedExtras: ["meta", "links"],
356
+ baseUrl: "https://localhost",
357
+ pageName: "page",
25
358
  paginatedLinks: {
26
359
  first: "first",
27
360
  last: "last",
@@ -38,6 +371,10 @@ const getDefaultConfig = () => {
38
371
  last_page: "last_page",
39
372
  current_page: "current_page"
40
373
  },
374
+ cursorMeta: {
375
+ previous: "previous",
376
+ next: "next"
377
+ },
41
378
  resourcesDir: "src/resources",
42
379
  stubs: {
43
380
  config: "resora.config.stub",
@@ -46,12 +383,6 @@ const getDefaultConfig = () => {
46
383
  }
47
384
  };
48
385
  };
49
- /**
50
- * Define the configuration for the package
51
- *
52
- * @param userConfig The user configuration to override the default configuration
53
- * @returns The merged configuration object
54
- */
55
386
  const defineConfig = (userConfig = {}) => {
56
387
  const defaultConfig = getDefaultConfig();
57
388
  return Object.assign(defaultConfig, userConfig, { stubs: Object.assign(defaultConfig.stubs, userConfig.stubs || {}) });
@@ -82,6 +413,13 @@ var CliApp = class {
82
413
  return this;
83
414
  }
84
415
  /**
416
+ * Get the current configuration object
417
+ * @returns
418
+ */
419
+ getConfig() {
420
+ return this.config;
421
+ }
422
+ /**
85
423
  * Initialize Resora by creating a default config file in the current directory
86
424
  *
87
425
  * @returns
@@ -163,6 +501,20 @@ var CliApp = class {
163
501
  }
164
502
  };
165
503
 
504
+ //#endregion
505
+ //#region src/cli/commands/InitCommand.ts
506
+ var InitCommand = class extends Command {
507
+ signature = `init
508
+ {--force : Force overwrite if config file already exists (existing file will be backed up) }
509
+ `;
510
+ description = "Initialize Resora";
511
+ async handle() {
512
+ this.app.command = this;
513
+ this.app.init();
514
+ this.success("Resora initialized");
515
+ }
516
+ };
517
+
166
518
  //#endregion
167
519
  //#region src/cli/commands/MakeResource.ts
168
520
  var MakeResource = class extends Command {
@@ -340,10 +692,21 @@ var ServerResponse = class {
340
692
  /**
341
693
  * GenericResource class to handle API resource transformation and response building
342
694
  */
343
- var GenericResource = class {
695
+ var GenericResource = class GenericResource {
344
696
  body = { data: {} };
345
697
  resource;
346
698
  collects;
699
+ additionalMeta;
700
+ withResponseContext;
701
+ /**
702
+ * Preferred case style for this resource's output keys.
703
+ * Set on a subclass to override the global default.
704
+ */
705
+ static preferredCase;
706
+ /**
707
+ * Response structure override for this generic resource class.
708
+ */
709
+ static responseStructure;
347
710
  called = {};
348
711
  constructor(rsc, res) {
349
712
  this.res = res;
@@ -373,6 +736,52 @@ var GenericResource = class {
373
736
  return this.resource;
374
737
  }
375
738
  /**
739
+ * Get the current serialized output body.
740
+ */
741
+ getBody() {
742
+ this.json();
743
+ return this.body;
744
+ }
745
+ /**
746
+ * Replace the current serialized output body.
747
+ */
748
+ setBody(body) {
749
+ this.body = body;
750
+ return this;
751
+ }
752
+ /**
753
+ * Conditionally include a value in serialized output.
754
+ */
755
+ when(condition, value) {
756
+ return resolveWhen(condition, value);
757
+ }
758
+ /**
759
+ * Include a value only when it is not null/undefined.
760
+ */
761
+ whenNotNull(value) {
762
+ return resolveWhenNotNull(value);
763
+ }
764
+ /**
765
+ * Conditionally merge object attributes into serialized output.
766
+ */
767
+ mergeWhen(condition, value) {
768
+ return resolveMergeWhen(condition, value);
769
+ }
770
+ resolveResponseStructure() {
771
+ const local = this.constructor.responseStructure;
772
+ const collectsLocal = this.collects?.responseStructure;
773
+ const global = getGlobalResponseStructure();
774
+ return {
775
+ wrap: local?.wrap ?? collectsLocal?.wrap ?? global?.wrap ?? true,
776
+ rootKey: local?.rootKey ?? collectsLocal?.rootKey ?? global?.rootKey ?? "data",
777
+ factory: local?.factory ?? collectsLocal?.factory ?? global?.factory
778
+ };
779
+ }
780
+ getPayloadKey() {
781
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
782
+ return factory || !wrap ? void 0 : rootKey;
783
+ }
784
+ /**
376
785
  * Convert resource to JSON response format
377
786
  *
378
787
  * @returns
@@ -387,13 +796,65 @@ var GenericResource = class {
387
796
  this.resource = data;
388
797
  }
389
798
  if (typeof data.data !== "undefined") data = data.data;
390
- if (this.resource.pagination && data.data && Array.isArray(data.data)) delete data.pagination;
391
- this.body = { data };
392
- if (Array.isArray(this.body.data) && this.resource.pagination) this.body.meta = { pagination: this.resource.pagination };
799
+ data = sanitizeConditionalAttributes(data);
800
+ const paginationExtras = buildPaginationExtras(this.resource);
801
+ const { metaKey } = getPaginationExtraKeys();
802
+ const configuredMeta = metaKey ? paginationExtras[metaKey] : void 0;
803
+ if (metaKey) delete paginationExtras[metaKey];
804
+ const caseStyle = this.constructor.preferredCase ?? getGlobalCase();
805
+ if (caseStyle) {
806
+ const transformer = getCaseTransformer(caseStyle);
807
+ data = transformKeys(data, transformer);
808
+ }
809
+ const customMeta = mergeMetadata(resolveWithHookMetadata(this, GenericResource.prototype.with), this.additionalMeta);
810
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
811
+ this.body = buildResponseEnvelope({
812
+ payload: data,
813
+ meta: configuredMeta,
814
+ metaKey,
815
+ wrap,
816
+ rootKey,
817
+ factory,
818
+ context: {
819
+ type: "generic",
820
+ resource: this.resource
821
+ }
822
+ });
823
+ this.body = appendRootProperties(this.body, {
824
+ ...paginationExtras,
825
+ ...customMeta || {}
826
+ }, rootKey);
827
+ }
828
+ return this;
829
+ }
830
+ /**
831
+ * Append structured metadata to the response body.
832
+ *
833
+ * @param meta Metadata object or metadata factory
834
+ * @returns
835
+ */
836
+ with(meta) {
837
+ this.called.with = true;
838
+ if (typeof meta === "undefined") return this.additionalMeta || {};
839
+ const resolvedMeta = typeof meta === "function" ? meta(this.resource) : meta;
840
+ this.additionalMeta = mergeMetadata(this.additionalMeta, resolvedMeta);
841
+ if (this.called.json) {
842
+ const { rootKey } = this.resolveResponseStructure();
843
+ this.body = appendRootProperties(this.body, resolvedMeta, rootKey);
393
844
  }
394
845
  return this;
395
846
  }
396
847
  /**
848
+ * Typed fluent metadata helper.
849
+ *
850
+ * @param meta Metadata object or metadata factory
851
+ * @returns
852
+ */
853
+ withMeta(meta) {
854
+ this.with(meta);
855
+ return this;
856
+ }
857
+ /**
397
858
  * Convert resource to array format (for collections)
398
859
  *
399
860
  * @returns
@@ -414,8 +875,14 @@ var GenericResource = class {
414
875
  additional(extra) {
415
876
  this.called.additional = true;
416
877
  this.json();
878
+ const extraData = extra.data;
417
879
  delete extra.data;
418
880
  delete extra.pagination;
881
+ const payloadKey = this.getPayloadKey();
882
+ if (extraData && payloadKey && typeof this.body[payloadKey] !== "undefined") this.body[payloadKey] = Array.isArray(this.body[payloadKey]) ? [...this.body[payloadKey], ...extraData] : {
883
+ ...this.body[payloadKey],
884
+ ...extraData
885
+ };
419
886
  this.body = {
420
887
  ...this.body,
421
888
  ...extra
@@ -424,7 +891,24 @@ var GenericResource = class {
424
891
  }
425
892
  response(res) {
426
893
  this.called.toResponse = true;
427
- return new ServerResponse(res ?? this.res, this.body);
894
+ this.json();
895
+ const rawResponse = res ?? this.res;
896
+ const response = new ServerResponse(rawResponse, this.body);
897
+ this.withResponseContext = {
898
+ response,
899
+ raw: rawResponse
900
+ };
901
+ this.called.withResponse = true;
902
+ this.withResponse(response, rawResponse);
903
+ return response;
904
+ }
905
+ /**
906
+ * Customize the outgoing transport response right before dispatch.
907
+ *
908
+ * Override in custom classes to mutate headers/status/body.
909
+ */
910
+ withResponse(_response, _rawResponse) {
911
+ return this;
428
912
  }
429
913
  /**
430
914
  * Promise-like then method to allow chaining with async/await or .then() syntax
@@ -436,6 +920,18 @@ var GenericResource = class {
436
920
  then(onfulfilled, onrejected) {
437
921
  this.called.then = true;
438
922
  this.json();
923
+ if (this.res) {
924
+ const response = new ServerResponse(this.res, this.body);
925
+ this.withResponseContext = {
926
+ response,
927
+ raw: this.res
928
+ };
929
+ this.called.withResponse = true;
930
+ this.withResponse(response, this.res);
931
+ } else {
932
+ this.called.withResponse = true;
933
+ this.withResponse();
934
+ }
439
935
  const resolved = Promise.resolve(this.body).then(onfulfilled, onrejected);
440
936
  if (this.res) this.res.send(this.body);
441
937
  return resolved;
@@ -447,10 +943,21 @@ var GenericResource = class {
447
943
  /**
448
944
  * ResourceCollection class to handle API resource transformation and response building for collections
449
945
  */
450
- var ResourceCollection = class {
946
+ var ResourceCollection = class ResourceCollection {
451
947
  body = { data: [] };
452
948
  resource;
453
949
  collects;
950
+ additionalMeta;
951
+ withResponseContext;
952
+ /**
953
+ * Preferred case style for this collection's output keys.
954
+ * Set on a subclass to override the global default.
955
+ */
956
+ static preferredCase;
957
+ /**
958
+ * Response structure override for this collection class.
959
+ */
960
+ static responseStructure;
454
961
  called = {};
455
962
  constructor(rsc, res) {
456
963
  this.res = res;
@@ -463,6 +970,52 @@ var ResourceCollection = class {
463
970
  return this.toArray();
464
971
  }
465
972
  /**
973
+ * Get the current serialized output body.
974
+ */
975
+ getBody() {
976
+ this.json();
977
+ return this.body;
978
+ }
979
+ /**
980
+ * Replace the current serialized output body.
981
+ */
982
+ setBody(body) {
983
+ this.body = body;
984
+ return this;
985
+ }
986
+ /**
987
+ * Conditionally include a value in serialized output.
988
+ */
989
+ when(condition, value) {
990
+ return resolveWhen(condition, value);
991
+ }
992
+ /**
993
+ * Include a value only when it is not null/undefined.
994
+ */
995
+ whenNotNull(value) {
996
+ return resolveWhenNotNull(value);
997
+ }
998
+ /**
999
+ * Conditionally merge object attributes into serialized output.
1000
+ */
1001
+ mergeWhen(condition, value) {
1002
+ return resolveMergeWhen(condition, value);
1003
+ }
1004
+ resolveResponseStructure() {
1005
+ const local = this.constructor.responseStructure;
1006
+ const collectsLocal = this.collects?.responseStructure;
1007
+ const global = getGlobalResponseStructure();
1008
+ return {
1009
+ wrap: local?.wrap ?? collectsLocal?.wrap ?? global?.wrap ?? true,
1010
+ rootKey: local?.rootKey ?? collectsLocal?.rootKey ?? global?.rootKey ?? "data",
1011
+ factory: local?.factory ?? collectsLocal?.factory ?? global?.factory
1012
+ };
1013
+ }
1014
+ getPayloadKey() {
1015
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1016
+ return factory || !wrap ? void 0 : rootKey;
1017
+ }
1018
+ /**
466
1019
  * Convert resource to JSON response format
467
1020
  *
468
1021
  * @returns
@@ -472,19 +1025,65 @@ var ResourceCollection = class {
472
1025
  this.called.json = true;
473
1026
  let data = this.data();
474
1027
  if (this.collects) data = data.map((item) => new this.collects(item).data());
475
- this.body = { data };
476
- if (!Array.isArray(this.resource)) {
477
- if (this.resource.pagination && this.resource.cursor) this.body.meta = {
478
- pagination: this.resource.pagination,
479
- cursor: this.resource.cursor
480
- };
481
- else if (this.resource.pagination) this.body.meta = { pagination: this.resource.pagination };
482
- else if (this.resource.cursor) this.body.meta = { cursor: this.resource.cursor };
1028
+ data = sanitizeConditionalAttributes(data);
1029
+ const paginationExtras = !Array.isArray(this.resource) ? buildPaginationExtras(this.resource) : {};
1030
+ const { metaKey } = getPaginationExtraKeys();
1031
+ const configuredMeta = metaKey ? paginationExtras[metaKey] : void 0;
1032
+ if (metaKey) delete paginationExtras[metaKey];
1033
+ const caseStyle = this.constructor.preferredCase ?? this.collects?.preferredCase ?? getGlobalCase();
1034
+ if (caseStyle) {
1035
+ const transformer = getCaseTransformer(caseStyle);
1036
+ data = transformKeys(data, transformer);
483
1037
  }
1038
+ const customMeta = mergeMetadata(resolveWithHookMetadata(this, ResourceCollection.prototype.with), this.additionalMeta);
1039
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1040
+ this.body = buildResponseEnvelope({
1041
+ payload: data,
1042
+ meta: configuredMeta,
1043
+ metaKey,
1044
+ wrap,
1045
+ rootKey,
1046
+ factory,
1047
+ context: {
1048
+ type: "collection",
1049
+ resource: this.resource
1050
+ }
1051
+ });
1052
+ this.body = appendRootProperties(this.body, {
1053
+ ...paginationExtras,
1054
+ ...customMeta || {}
1055
+ }, rootKey);
484
1056
  }
485
1057
  return this;
486
1058
  }
487
1059
  /**
1060
+ * Append structured metadata to the response body.
1061
+ *
1062
+ * @param meta Metadata object or metadata factory
1063
+ * @returns
1064
+ */
1065
+ with(meta) {
1066
+ this.called.with = true;
1067
+ if (typeof meta === "undefined") return this.additionalMeta || {};
1068
+ const resolvedMeta = typeof meta === "function" ? meta(this.resource) : meta;
1069
+ this.additionalMeta = mergeMetadata(this.additionalMeta, resolvedMeta);
1070
+ if (this.called.json) {
1071
+ const { rootKey } = this.resolveResponseStructure();
1072
+ this.body = appendRootProperties(this.body, resolvedMeta, rootKey);
1073
+ }
1074
+ return this;
1075
+ }
1076
+ /**
1077
+ * Typed fluent metadata helper.
1078
+ *
1079
+ * @param meta Metadata object or metadata factory
1080
+ * @returns
1081
+ */
1082
+ withMeta(meta) {
1083
+ this.with(meta);
1084
+ return this;
1085
+ }
1086
+ /**
488
1087
  * Flatten resource to return original data
489
1088
  *
490
1089
  * @returns
@@ -505,7 +1104,8 @@ var ResourceCollection = class {
505
1104
  this.json();
506
1105
  delete extra.cursor;
507
1106
  delete extra.pagination;
508
- if (extra.data && Array.isArray(this.body.data)) this.body.data = [...this.body.data, ...extra.data];
1107
+ const payloadKey = this.getPayloadKey();
1108
+ if (extra.data && payloadKey && Array.isArray(this.body[payloadKey])) this.body[payloadKey] = [...this.body[payloadKey], ...extra.data];
509
1109
  this.body = {
510
1110
  ...this.body,
511
1111
  ...extra
@@ -514,7 +1114,24 @@ var ResourceCollection = class {
514
1114
  }
515
1115
  response(res) {
516
1116
  this.called.toResponse = true;
517
- return new ServerResponse(res ?? this.res, this.body);
1117
+ this.json();
1118
+ const rawResponse = res ?? this.res;
1119
+ const response = new ServerResponse(rawResponse, this.body);
1120
+ this.withResponseContext = {
1121
+ response,
1122
+ raw: rawResponse
1123
+ };
1124
+ this.called.withResponse = true;
1125
+ this.withResponse(response, rawResponse);
1126
+ return response;
1127
+ }
1128
+ /**
1129
+ * Customize the outgoing transport response right before dispatch.
1130
+ *
1131
+ * Override in custom classes to mutate headers/status/body.
1132
+ */
1133
+ withResponse(_response, _rawResponse) {
1134
+ return this;
518
1135
  }
519
1136
  setCollects(collects) {
520
1137
  this.collects = collects;
@@ -530,6 +1147,18 @@ var ResourceCollection = class {
530
1147
  then(onfulfilled, onrejected) {
531
1148
  this.called.then = true;
532
1149
  this.json();
1150
+ if (this.res) {
1151
+ const response = new ServerResponse(this.res, this.body);
1152
+ this.withResponseContext = {
1153
+ response,
1154
+ raw: this.res
1155
+ };
1156
+ this.called.withResponse = true;
1157
+ this.withResponse(response, this.res);
1158
+ } else {
1159
+ this.called.withResponse = true;
1160
+ this.withResponse();
1161
+ }
533
1162
  const resolved = Promise.resolve(this.body).then(onfulfilled, onrejected);
534
1163
  if (this.res) this.res.send(this.body);
535
1164
  return resolved;
@@ -559,9 +1188,20 @@ var ResourceCollection = class {
559
1188
  /**
560
1189
  * Resource class to handle API resource transformation and response building
561
1190
  */
562
- var Resource = class {
1191
+ var Resource = class Resource {
563
1192
  body = { data: {} };
564
1193
  resource;
1194
+ additionalMeta;
1195
+ withResponseContext;
1196
+ /**
1197
+ * Preferred case style for this resource's output keys.
1198
+ * Set on a subclass to override the global default.
1199
+ */
1200
+ static preferredCase;
1201
+ /**
1202
+ * Response structure override for this resource class.
1203
+ */
1204
+ static responseStructure;
565
1205
  called = {};
566
1206
  constructor(rsc, res) {
567
1207
  this.res = res;
@@ -600,6 +1240,51 @@ var Resource = class {
600
1240
  return this.toArray();
601
1241
  }
602
1242
  /**
1243
+ * Get the current serialized output body.
1244
+ */
1245
+ getBody() {
1246
+ this.json();
1247
+ return this.body;
1248
+ }
1249
+ /**
1250
+ * Replace the current serialized output body.
1251
+ */
1252
+ setBody(body) {
1253
+ this.body = body;
1254
+ return this;
1255
+ }
1256
+ /**
1257
+ * Conditionally include a value in serialized output.
1258
+ */
1259
+ when(condition, value) {
1260
+ return resolveWhen(condition, value);
1261
+ }
1262
+ /**
1263
+ * Include a value only when it is not null/undefined.
1264
+ */
1265
+ whenNotNull(value) {
1266
+ return resolveWhenNotNull(value);
1267
+ }
1268
+ /**
1269
+ * Conditionally merge object attributes into serialized output.
1270
+ */
1271
+ mergeWhen(condition, value) {
1272
+ return resolveMergeWhen(condition, value);
1273
+ }
1274
+ resolveResponseStructure() {
1275
+ const local = this.constructor.responseStructure;
1276
+ const global = getGlobalResponseStructure();
1277
+ return {
1278
+ wrap: local?.wrap ?? global?.wrap ?? true,
1279
+ rootKey: local?.rootKey ?? global?.rootKey ?? "data",
1280
+ factory: local?.factory ?? global?.factory
1281
+ };
1282
+ }
1283
+ getPayloadKey() {
1284
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1285
+ return factory || !wrap ? void 0 : rootKey;
1286
+ }
1287
+ /**
603
1288
  * Convert resource to JSON response format
604
1289
  *
605
1290
  * @returns
@@ -610,11 +1295,56 @@ var Resource = class {
610
1295
  const resource = this.data();
611
1296
  let data = Array.isArray(resource) ? [...resource] : { ...resource };
612
1297
  if (typeof data.data !== "undefined") data = data.data;
613
- this.body = { data };
1298
+ data = sanitizeConditionalAttributes(data);
1299
+ const caseStyle = this.constructor.preferredCase ?? getGlobalCase();
1300
+ if (caseStyle) {
1301
+ const transformer = getCaseTransformer(caseStyle);
1302
+ data = transformKeys(data, transformer);
1303
+ }
1304
+ const customMeta = mergeMetadata(resolveWithHookMetadata(this, Resource.prototype.with), this.additionalMeta);
1305
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1306
+ this.body = buildResponseEnvelope({
1307
+ payload: data,
1308
+ wrap,
1309
+ rootKey,
1310
+ factory,
1311
+ context: {
1312
+ type: "resource",
1313
+ resource: this.resource
1314
+ }
1315
+ });
1316
+ this.body = appendRootProperties(this.body, customMeta, rootKey);
614
1317
  }
615
1318
  return this;
616
1319
  }
617
1320
  /**
1321
+ * Append structured metadata to the response body.
1322
+ *
1323
+ * @param meta Metadata object or metadata factory
1324
+ * @returns
1325
+ */
1326
+ with(meta) {
1327
+ this.called.with = true;
1328
+ if (typeof meta === "undefined") return this.additionalMeta || {};
1329
+ const resolvedMeta = typeof meta === "function" ? meta(this.resource) : meta;
1330
+ this.additionalMeta = mergeMetadata(this.additionalMeta, resolvedMeta);
1331
+ if (this.called.json) {
1332
+ const { rootKey } = this.resolveResponseStructure();
1333
+ this.body = appendRootProperties(this.body, resolvedMeta, rootKey);
1334
+ }
1335
+ return this;
1336
+ }
1337
+ /**
1338
+ * Typed fluent metadata helper.
1339
+ *
1340
+ * @param meta Metadata object or metadata factory
1341
+ * @returns
1342
+ */
1343
+ withMeta(meta) {
1344
+ this.with(meta);
1345
+ return this;
1346
+ }
1347
+ /**
618
1348
  * Flatten resource to array format (for collections) or return original data for single resources
619
1349
  *
620
1350
  * @returns
@@ -635,8 +1365,9 @@ var Resource = class {
635
1365
  additional(extra) {
636
1366
  this.called.additional = true;
637
1367
  this.json();
638
- if (extra.data) this.body.data = Array.isArray(this.body.data) ? [...this.body.data, ...extra.data] : {
639
- ...this.body.data,
1368
+ const payloadKey = this.getPayloadKey();
1369
+ if (extra.data && payloadKey && typeof this.body[payloadKey] !== "undefined") this.body[payloadKey] = Array.isArray(this.body[payloadKey]) ? [...this.body[payloadKey], ...extra.data] : {
1370
+ ...this.body[payloadKey],
640
1371
  ...extra.data
641
1372
  };
642
1373
  this.body = {
@@ -647,7 +1378,24 @@ var Resource = class {
647
1378
  }
648
1379
  response(res) {
649
1380
  this.called.toResponse = true;
650
- return new ServerResponse(res ?? this.res, this.body);
1381
+ this.json();
1382
+ const rawResponse = res ?? this.res;
1383
+ const response = new ServerResponse(rawResponse, this.body);
1384
+ this.withResponseContext = {
1385
+ response,
1386
+ raw: rawResponse
1387
+ };
1388
+ this.called.withResponse = true;
1389
+ this.withResponse(response, rawResponse);
1390
+ return response;
1391
+ }
1392
+ /**
1393
+ * Customize the outgoing transport response right before dispatch.
1394
+ *
1395
+ * Override in custom classes to mutate headers/status/body.
1396
+ */
1397
+ withResponse(_response, _rawResponse) {
1398
+ return this;
651
1399
  }
652
1400
  /**
653
1401
  * Promise-like then method to allow chaining with async/await or .then() syntax
@@ -659,6 +1407,18 @@ var Resource = class {
659
1407
  then(onfulfilled, onrejected) {
660
1408
  this.called.then = true;
661
1409
  this.json();
1410
+ if (this.res) {
1411
+ const response = new ServerResponse(this.res, this.body);
1412
+ this.withResponseContext = {
1413
+ response,
1414
+ raw: this.res
1415
+ };
1416
+ this.called.withResponse = true;
1417
+ this.withResponse(response, this.res);
1418
+ } else {
1419
+ this.called.withResponse = true;
1420
+ this.withResponse();
1421
+ }
662
1422
  const resolved = Promise.resolve(this.body).then(onfulfilled, onrejected);
663
1423
  if (this.res) this.res.send(this.body);
664
1424
  return resolved;
@@ -684,4 +1444,4 @@ var Resource = class {
684
1444
  };
685
1445
 
686
1446
  //#endregion
687
- export { ApiResource, CliApp, GenericResource, MakeResource, Resource, ResourceCollection, ServerResponse, defineConfig, getDefaultConfig };
1447
+ export { ApiResource, CONDITIONAL_ATTRIBUTE_MISSING, CliApp, GenericResource, InitCommand, MakeResource, Resource, ResourceCollection, ServerResponse, appendRootProperties, buildPaginationExtras, buildResponseEnvelope, defineConfig, getCaseTransformer, getDefaultConfig, getGlobalBaseUrl, getGlobalCase, getGlobalCursorMeta, getGlobalPageName, getGlobalPaginatedExtras, getGlobalPaginatedLinks, getGlobalPaginatedMeta, getGlobalResponseFactory, getGlobalResponseRootKey, getGlobalResponseStructure, getGlobalResponseWrap, getPaginationExtraKeys, isPlainObject, mergeMetadata, resolveMergeWhen, resolveWhen, resolveWhenNotNull, resolveWithHookMetadata, sanitizeConditionalAttributes, setGlobalBaseUrl, setGlobalCase, setGlobalCursorMeta, setGlobalPageName, setGlobalPaginatedExtras, setGlobalPaginatedLinks, setGlobalPaginatedMeta, setGlobalResponseFactory, setGlobalResponseRootKey, setGlobalResponseStructure, setGlobalResponseWrap, splitWords, toCamelCase, toKebabCase, toPascalCase, toSnakeCase, transformKeys };