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.cjs CHANGED
@@ -43,14 +43,347 @@ function ApiResource(instance) {
43
43
  }
44
44
 
45
45
  //#endregion
46
- //#region src/utility.ts
46
+ //#region src/utilities/state.ts
47
+ let globalPreferredCase;
48
+ let globalResponseStructure;
49
+ let globalPaginatedExtras = ["meta", "links"];
50
+ let globalPaginatedLinks = {
51
+ first: "first",
52
+ last: "last",
53
+ prev: "prev",
54
+ next: "next"
55
+ };
56
+ let globalBaseUrl = "https://localhost";
57
+ let globalPageName = "page";
58
+ let globalPaginatedMeta = {
59
+ to: "to",
60
+ from: "from",
61
+ links: "links",
62
+ path: "path",
63
+ total: "total",
64
+ per_page: "per_page",
65
+ last_page: "last_page",
66
+ current_page: "current_page"
67
+ };
68
+ let globalCursorMeta = {
69
+ previous: "previous",
70
+ next: "next"
71
+ };
72
+ const setGlobalCase = (style) => {
73
+ globalPreferredCase = style;
74
+ };
75
+ const getGlobalCase = () => {
76
+ return globalPreferredCase;
77
+ };
78
+ const setGlobalResponseStructure = (config) => {
79
+ globalResponseStructure = config;
80
+ };
81
+ const getGlobalResponseStructure = () => {
82
+ return globalResponseStructure;
83
+ };
84
+ const setGlobalResponseRootKey = (rootKey) => {
85
+ globalResponseStructure = {
86
+ ...globalResponseStructure || {},
87
+ rootKey
88
+ };
89
+ };
90
+ const setGlobalResponseWrap = (wrap) => {
91
+ globalResponseStructure = {
92
+ ...globalResponseStructure || {},
93
+ wrap
94
+ };
95
+ };
96
+ const getGlobalResponseWrap = () => {
97
+ return globalResponseStructure?.wrap;
98
+ };
99
+ const getGlobalResponseRootKey = () => {
100
+ return globalResponseStructure?.rootKey;
101
+ };
102
+ const setGlobalResponseFactory = (factory) => {
103
+ globalResponseStructure = {
104
+ ...globalResponseStructure || {},
105
+ factory
106
+ };
107
+ };
108
+ const getGlobalResponseFactory = () => {
109
+ return globalResponseStructure?.factory;
110
+ };
111
+ const setGlobalPaginatedExtras = (extras) => {
112
+ globalPaginatedExtras = extras;
113
+ };
114
+ const getGlobalPaginatedExtras = () => {
115
+ return globalPaginatedExtras;
116
+ };
117
+ const setGlobalPaginatedLinks = (links) => {
118
+ globalPaginatedLinks = {
119
+ ...globalPaginatedLinks,
120
+ ...links
121
+ };
122
+ };
123
+ const getGlobalPaginatedLinks = () => {
124
+ return globalPaginatedLinks;
125
+ };
126
+ const setGlobalBaseUrl = (baseUrl) => {
127
+ globalBaseUrl = baseUrl;
128
+ };
129
+ const getGlobalBaseUrl = () => {
130
+ return globalBaseUrl;
131
+ };
132
+ const setGlobalPageName = (pageName) => {
133
+ globalPageName = pageName;
134
+ };
135
+ const getGlobalPageName = () => {
136
+ return globalPageName;
137
+ };
138
+ const setGlobalPaginatedMeta = (meta) => {
139
+ globalPaginatedMeta = {
140
+ ...globalPaginatedMeta,
141
+ ...meta
142
+ };
143
+ };
144
+ const getGlobalPaginatedMeta = () => {
145
+ return globalPaginatedMeta;
146
+ };
147
+ const setGlobalCursorMeta = (meta) => {
148
+ globalCursorMeta = {
149
+ ...globalCursorMeta,
150
+ ...meta
151
+ };
152
+ };
153
+ const getGlobalCursorMeta = () => {
154
+ return globalCursorMeta;
155
+ };
156
+
157
+ //#endregion
158
+ //#region src/utilities/pagination.ts
159
+ const getPaginationExtraKeys = () => {
160
+ const extras = getGlobalPaginatedExtras();
161
+ if (Array.isArray(extras)) return {
162
+ metaKey: extras.includes("meta") ? "meta" : void 0,
163
+ linksKey: extras.includes("links") ? "links" : void 0,
164
+ cursorKey: extras.includes("cursor") ? "cursor" : void 0
165
+ };
166
+ return {
167
+ metaKey: extras.meta,
168
+ linksKey: extras.links,
169
+ cursorKey: extras.cursor
170
+ };
171
+ };
172
+ const buildPageUrl = (page, pathName) => {
173
+ if (typeof page === "undefined") return;
174
+ const rawPath = pathName || "";
175
+ const base = getGlobalBaseUrl() || "";
176
+ const isAbsolutePath = /^https?:\/\//i.test(rawPath);
177
+ const normalizedBase = base.replace(/\/$/, "");
178
+ const normalizedPath = rawPath.replace(/^\//, "");
179
+ const root = isAbsolutePath ? rawPath : normalizedBase ? normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase : "";
180
+ if (!root) return;
181
+ const url = new URL(root);
182
+ url.searchParams.set(getGlobalPageName() || "page", String(page));
183
+ return url.toString();
184
+ };
185
+ const buildPaginationExtras = (resource) => {
186
+ const { metaKey, linksKey, cursorKey } = getPaginationExtraKeys();
187
+ const extra = {};
188
+ const pagination = resource?.pagination;
189
+ const cursor = resource?.cursor;
190
+ const metaBlock = {};
191
+ const linksBlock = {};
192
+ if (pagination) {
193
+ const metaSource = {
194
+ to: pagination.to,
195
+ from: pagination.from,
196
+ links: pagination.links,
197
+ path: pagination.path,
198
+ total: pagination.total,
199
+ per_page: pagination.perPage,
200
+ last_page: pagination.lastPage,
201
+ current_page: pagination.currentPage
202
+ };
203
+ for (const [sourceKey, outputKey] of Object.entries(getGlobalPaginatedMeta())) {
204
+ if (!outputKey) continue;
205
+ const value = metaSource[sourceKey];
206
+ if (typeof value !== "undefined") metaBlock[outputKey] = value;
207
+ }
208
+ const linksSource = {
209
+ first: buildPageUrl(pagination.firstPage, pagination.path),
210
+ last: buildPageUrl(pagination.lastPage, pagination.path),
211
+ prev: buildPageUrl(pagination.prevPage, pagination.path),
212
+ next: buildPageUrl(pagination.nextPage, pagination.path)
213
+ };
214
+ for (const [sourceKey, outputKey] of Object.entries(getGlobalPaginatedLinks())) {
215
+ if (!outputKey) continue;
216
+ const value = linksSource[sourceKey];
217
+ if (typeof value !== "undefined") linksBlock[outputKey] = value;
218
+ }
219
+ }
220
+ if (cursor) {
221
+ const cursorBlock = {};
222
+ const cursorSource = {
223
+ previous: cursor.previous,
224
+ next: cursor.next
225
+ };
226
+ for (const [sourceKey, outputKey] of Object.entries(getGlobalCursorMeta())) {
227
+ if (!outputKey) continue;
228
+ const value = cursorSource[sourceKey];
229
+ if (typeof value !== "undefined") cursorBlock[outputKey] = value;
230
+ }
231
+ if (cursorKey && Object.keys(cursorBlock).length > 0) extra[cursorKey] = cursorBlock;
232
+ else if (Object.keys(cursorBlock).length > 0) metaBlock.cursor = cursorBlock;
233
+ }
234
+ if (metaKey && Object.keys(metaBlock).length > 0) extra[metaKey] = metaBlock;
235
+ if (linksKey && Object.keys(linksBlock).length > 0) extra[linksKey] = linksBlock;
236
+ return extra;
237
+ };
238
+
239
+ //#endregion
240
+ //#region src/utilities/objects.ts
241
+ const isPlainObject = (value) => {
242
+ if (typeof value !== "object" || value === null) return false;
243
+ if (Array.isArray(value) || value instanceof Date || value instanceof RegExp) return false;
244
+ const proto = Object.getPrototypeOf(value);
245
+ return proto === Object.prototype || proto === null;
246
+ };
247
+ const appendRootProperties = (body, extra, rootKey = "data") => {
248
+ if (!extra || Object.keys(extra).length === 0) return body;
249
+ if (Array.isArray(body)) return {
250
+ [rootKey]: body,
251
+ ...extra
252
+ };
253
+ if (isPlainObject(body)) return {
254
+ ...body,
255
+ ...extra
256
+ };
257
+ return {
258
+ [rootKey]: body,
259
+ ...extra
260
+ };
261
+ };
262
+ const mergeMetadata = (base, incoming) => {
263
+ if (!incoming) return base;
264
+ if (!base) return incoming;
265
+ const merged = { ...base };
266
+ for (const [key, value] of Object.entries(incoming)) {
267
+ const existing = merged[key];
268
+ if (isPlainObject(existing) && isPlainObject(value)) merged[key] = mergeMetadata(existing, value);
269
+ else merged[key] = value;
270
+ }
271
+ return merged;
272
+ };
273
+
274
+ //#endregion
275
+ //#region src/utilities/response.ts
276
+ const buildResponseEnvelope = ({ payload, meta, metaKey = "meta", wrap = true, rootKey = "data", factory, context }) => {
277
+ if (factory) return factory(payload, {
278
+ ...context,
279
+ rootKey,
280
+ meta
281
+ });
282
+ if (!wrap) {
283
+ if (typeof meta === "undefined") return payload;
284
+ if (isPlainObject(payload)) return {
285
+ ...payload,
286
+ [metaKey]: meta
287
+ };
288
+ return {
289
+ [rootKey]: payload,
290
+ [metaKey]: meta
291
+ };
292
+ }
293
+ const body = { [rootKey]: payload };
294
+ if (typeof meta !== "undefined") body[metaKey] = meta;
295
+ return body;
296
+ };
297
+
298
+ //#endregion
299
+ //#region src/utilities/metadata.ts
300
+ const resolveWithHookMetadata = (instance, baseWithMethod) => {
301
+ const candidate = instance?.with;
302
+ if (typeof candidate !== "function" || candidate === baseWithMethod) return;
303
+ if (candidate.length > 0) return;
304
+ const result = candidate.call(instance);
305
+ return isPlainObject(result) ? result : void 0;
306
+ };
307
+
308
+ //#endregion
309
+ //#region src/utilities/conditional.ts
310
+ const CONDITIONAL_ATTRIBUTE_MISSING = Symbol("resora.conditional.missing");
311
+ const resolveWhen = (condition, value) => {
312
+ if (!condition) return CONDITIONAL_ATTRIBUTE_MISSING;
313
+ return typeof value === "function" ? value() : value;
314
+ };
315
+ const resolveWhenNotNull = (value) => {
316
+ return value === null || typeof value === "undefined" ? CONDITIONAL_ATTRIBUTE_MISSING : value;
317
+ };
318
+ const resolveMergeWhen = (condition, value) => {
319
+ if (!condition) return {};
320
+ const resolved = typeof value === "function" ? value() : value;
321
+ return isPlainObject(resolved) ? resolved : {};
322
+ };
323
+ const sanitizeConditionalAttributes = (value) => {
324
+ if (value === CONDITIONAL_ATTRIBUTE_MISSING) return CONDITIONAL_ATTRIBUTE_MISSING;
325
+ if (Array.isArray(value)) return value.map((item) => sanitizeConditionalAttributes(item)).filter((item) => item !== CONDITIONAL_ATTRIBUTE_MISSING);
326
+ if (isPlainObject(value)) {
327
+ const result = {};
328
+ for (const [key, nestedValue] of Object.entries(value)) {
329
+ const sanitizedValue = sanitizeConditionalAttributes(nestedValue);
330
+ if (sanitizedValue === CONDITIONAL_ATTRIBUTE_MISSING) continue;
331
+ result[key] = sanitizedValue;
332
+ }
333
+ return result;
334
+ }
335
+ return value;
336
+ };
337
+
338
+ //#endregion
339
+ //#region src/utilities/case.ts
340
+ const splitWords = (str) => {
341
+ 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);
342
+ };
343
+ const toCamelCase = (str) => {
344
+ return splitWords(str).map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)).join("");
345
+ };
346
+ const toSnakeCase = (str) => {
347
+ return splitWords(str).join("_");
348
+ };
349
+ const toPascalCase = (str) => {
350
+ return splitWords(str).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
351
+ };
352
+ const toKebabCase = (str) => {
353
+ return splitWords(str).join("-");
354
+ };
355
+ const getCaseTransformer = (style) => {
356
+ if (typeof style === "function") return style;
357
+ switch (style) {
358
+ case "camel": return toCamelCase;
359
+ case "snake": return toSnakeCase;
360
+ case "pascal": return toPascalCase;
361
+ case "kebab": return toKebabCase;
362
+ }
363
+ };
364
+ const transformKeys = (obj, transformer) => {
365
+ if (obj === null || obj === void 0) return obj;
366
+ if (Array.isArray(obj)) return obj.map((item) => transformKeys(item, transformer));
367
+ if (obj instanceof Date || obj instanceof RegExp) return obj;
368
+ if (typeof obj === "object") return Object.fromEntries(Object.entries(obj).map(([key, value]) => [transformer(key), transformKeys(value, transformer)]));
369
+ return obj;
370
+ };
371
+
372
+ //#endregion
373
+ //#region src/utilities/config.ts
47
374
  let stubsDir = path.default.resolve(process.cwd(), "node_modules/resora/stubs");
48
375
  if (!(0, fs.existsSync)(stubsDir)) stubsDir = path.default.resolve(process.cwd(), "stubs");
49
376
  const getDefaultConfig = () => {
50
377
  return {
51
378
  stubsDir,
52
379
  preferredCase: "camel",
380
+ responseStructure: {
381
+ wrap: true,
382
+ rootKey: "data"
383
+ },
53
384
  paginatedExtras: ["meta", "links"],
385
+ baseUrl: "https://localhost",
386
+ pageName: "page",
54
387
  paginatedLinks: {
55
388
  first: "first",
56
389
  last: "last",
@@ -67,6 +400,10 @@ const getDefaultConfig = () => {
67
400
  last_page: "last_page",
68
401
  current_page: "current_page"
69
402
  },
403
+ cursorMeta: {
404
+ previous: "previous",
405
+ next: "next"
406
+ },
70
407
  resourcesDir: "src/resources",
71
408
  stubs: {
72
409
  config: "resora.config.stub",
@@ -75,12 +412,6 @@ const getDefaultConfig = () => {
75
412
  }
76
413
  };
77
414
  };
78
- /**
79
- * Define the configuration for the package
80
- *
81
- * @param userConfig The user configuration to override the default configuration
82
- * @returns The merged configuration object
83
- */
84
415
  const defineConfig = (userConfig = {}) => {
85
416
  const defaultConfig = getDefaultConfig();
86
417
  return Object.assign(defaultConfig, userConfig, { stubs: Object.assign(defaultConfig.stubs, userConfig.stubs || {}) });
@@ -111,6 +442,13 @@ var CliApp = class {
111
442
  return this;
112
443
  }
113
444
  /**
445
+ * Get the current configuration object
446
+ * @returns
447
+ */
448
+ getConfig() {
449
+ return this.config;
450
+ }
451
+ /**
114
452
  * Initialize Resora by creating a default config file in the current directory
115
453
  *
116
454
  * @returns
@@ -192,6 +530,20 @@ var CliApp = class {
192
530
  }
193
531
  };
194
532
 
533
+ //#endregion
534
+ //#region src/cli/commands/InitCommand.ts
535
+ var InitCommand = class extends _h3ravel_musket.Command {
536
+ signature = `init
537
+ {--force : Force overwrite if config file already exists (existing file will be backed up) }
538
+ `;
539
+ description = "Initialize Resora";
540
+ async handle() {
541
+ this.app.command = this;
542
+ this.app.init();
543
+ this.success("Resora initialized");
544
+ }
545
+ };
546
+
195
547
  //#endregion
196
548
  //#region src/cli/commands/MakeResource.ts
197
549
  var MakeResource = class extends _h3ravel_musket.Command {
@@ -369,10 +721,21 @@ var ServerResponse = class {
369
721
  /**
370
722
  * GenericResource class to handle API resource transformation and response building
371
723
  */
372
- var GenericResource = class {
724
+ var GenericResource = class GenericResource {
373
725
  body = { data: {} };
374
726
  resource;
375
727
  collects;
728
+ additionalMeta;
729
+ withResponseContext;
730
+ /**
731
+ * Preferred case style for this resource's output keys.
732
+ * Set on a subclass to override the global default.
733
+ */
734
+ static preferredCase;
735
+ /**
736
+ * Response structure override for this generic resource class.
737
+ */
738
+ static responseStructure;
376
739
  called = {};
377
740
  constructor(rsc, res) {
378
741
  this.res = res;
@@ -402,6 +765,52 @@ var GenericResource = class {
402
765
  return this.resource;
403
766
  }
404
767
  /**
768
+ * Get the current serialized output body.
769
+ */
770
+ getBody() {
771
+ this.json();
772
+ return this.body;
773
+ }
774
+ /**
775
+ * Replace the current serialized output body.
776
+ */
777
+ setBody(body) {
778
+ this.body = body;
779
+ return this;
780
+ }
781
+ /**
782
+ * Conditionally include a value in serialized output.
783
+ */
784
+ when(condition, value) {
785
+ return resolveWhen(condition, value);
786
+ }
787
+ /**
788
+ * Include a value only when it is not null/undefined.
789
+ */
790
+ whenNotNull(value) {
791
+ return resolveWhenNotNull(value);
792
+ }
793
+ /**
794
+ * Conditionally merge object attributes into serialized output.
795
+ */
796
+ mergeWhen(condition, value) {
797
+ return resolveMergeWhen(condition, value);
798
+ }
799
+ resolveResponseStructure() {
800
+ const local = this.constructor.responseStructure;
801
+ const collectsLocal = this.collects?.responseStructure;
802
+ const global = getGlobalResponseStructure();
803
+ return {
804
+ wrap: local?.wrap ?? collectsLocal?.wrap ?? global?.wrap ?? true,
805
+ rootKey: local?.rootKey ?? collectsLocal?.rootKey ?? global?.rootKey ?? "data",
806
+ factory: local?.factory ?? collectsLocal?.factory ?? global?.factory
807
+ };
808
+ }
809
+ getPayloadKey() {
810
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
811
+ return factory || !wrap ? void 0 : rootKey;
812
+ }
813
+ /**
405
814
  * Convert resource to JSON response format
406
815
  *
407
816
  * @returns
@@ -416,13 +825,65 @@ var GenericResource = class {
416
825
  this.resource = data;
417
826
  }
418
827
  if (typeof data.data !== "undefined") data = data.data;
419
- if (this.resource.pagination && data.data && Array.isArray(data.data)) delete data.pagination;
420
- this.body = { data };
421
- if (Array.isArray(this.body.data) && this.resource.pagination) this.body.meta = { pagination: this.resource.pagination };
828
+ data = sanitizeConditionalAttributes(data);
829
+ const paginationExtras = buildPaginationExtras(this.resource);
830
+ const { metaKey } = getPaginationExtraKeys();
831
+ const configuredMeta = metaKey ? paginationExtras[metaKey] : void 0;
832
+ if (metaKey) delete paginationExtras[metaKey];
833
+ const caseStyle = this.constructor.preferredCase ?? getGlobalCase();
834
+ if (caseStyle) {
835
+ const transformer = getCaseTransformer(caseStyle);
836
+ data = transformKeys(data, transformer);
837
+ }
838
+ const customMeta = mergeMetadata(resolveWithHookMetadata(this, GenericResource.prototype.with), this.additionalMeta);
839
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
840
+ this.body = buildResponseEnvelope({
841
+ payload: data,
842
+ meta: configuredMeta,
843
+ metaKey,
844
+ wrap,
845
+ rootKey,
846
+ factory,
847
+ context: {
848
+ type: "generic",
849
+ resource: this.resource
850
+ }
851
+ });
852
+ this.body = appendRootProperties(this.body, {
853
+ ...paginationExtras,
854
+ ...customMeta || {}
855
+ }, rootKey);
856
+ }
857
+ return this;
858
+ }
859
+ /**
860
+ * Append structured metadata to the response body.
861
+ *
862
+ * @param meta Metadata object or metadata factory
863
+ * @returns
864
+ */
865
+ with(meta) {
866
+ this.called.with = true;
867
+ if (typeof meta === "undefined") return this.additionalMeta || {};
868
+ const resolvedMeta = typeof meta === "function" ? meta(this.resource) : meta;
869
+ this.additionalMeta = mergeMetadata(this.additionalMeta, resolvedMeta);
870
+ if (this.called.json) {
871
+ const { rootKey } = this.resolveResponseStructure();
872
+ this.body = appendRootProperties(this.body, resolvedMeta, rootKey);
422
873
  }
423
874
  return this;
424
875
  }
425
876
  /**
877
+ * Typed fluent metadata helper.
878
+ *
879
+ * @param meta Metadata object or metadata factory
880
+ * @returns
881
+ */
882
+ withMeta(meta) {
883
+ this.with(meta);
884
+ return this;
885
+ }
886
+ /**
426
887
  * Convert resource to array format (for collections)
427
888
  *
428
889
  * @returns
@@ -443,8 +904,14 @@ var GenericResource = class {
443
904
  additional(extra) {
444
905
  this.called.additional = true;
445
906
  this.json();
907
+ const extraData = extra.data;
446
908
  delete extra.data;
447
909
  delete extra.pagination;
910
+ const payloadKey = this.getPayloadKey();
911
+ if (extraData && payloadKey && typeof this.body[payloadKey] !== "undefined") this.body[payloadKey] = Array.isArray(this.body[payloadKey]) ? [...this.body[payloadKey], ...extraData] : {
912
+ ...this.body[payloadKey],
913
+ ...extraData
914
+ };
448
915
  this.body = {
449
916
  ...this.body,
450
917
  ...extra
@@ -453,7 +920,24 @@ var GenericResource = class {
453
920
  }
454
921
  response(res) {
455
922
  this.called.toResponse = true;
456
- return new ServerResponse(res ?? this.res, this.body);
923
+ this.json();
924
+ const rawResponse = res ?? this.res;
925
+ const response = new ServerResponse(rawResponse, this.body);
926
+ this.withResponseContext = {
927
+ response,
928
+ raw: rawResponse
929
+ };
930
+ this.called.withResponse = true;
931
+ this.withResponse(response, rawResponse);
932
+ return response;
933
+ }
934
+ /**
935
+ * Customize the outgoing transport response right before dispatch.
936
+ *
937
+ * Override in custom classes to mutate headers/status/body.
938
+ */
939
+ withResponse(_response, _rawResponse) {
940
+ return this;
457
941
  }
458
942
  /**
459
943
  * Promise-like then method to allow chaining with async/await or .then() syntax
@@ -465,6 +949,18 @@ var GenericResource = class {
465
949
  then(onfulfilled, onrejected) {
466
950
  this.called.then = true;
467
951
  this.json();
952
+ if (this.res) {
953
+ const response = new ServerResponse(this.res, this.body);
954
+ this.withResponseContext = {
955
+ response,
956
+ raw: this.res
957
+ };
958
+ this.called.withResponse = true;
959
+ this.withResponse(response, this.res);
960
+ } else {
961
+ this.called.withResponse = true;
962
+ this.withResponse();
963
+ }
468
964
  const resolved = Promise.resolve(this.body).then(onfulfilled, onrejected);
469
965
  if (this.res) this.res.send(this.body);
470
966
  return resolved;
@@ -476,10 +972,21 @@ var GenericResource = class {
476
972
  /**
477
973
  * ResourceCollection class to handle API resource transformation and response building for collections
478
974
  */
479
- var ResourceCollection = class {
975
+ var ResourceCollection = class ResourceCollection {
480
976
  body = { data: [] };
481
977
  resource;
482
978
  collects;
979
+ additionalMeta;
980
+ withResponseContext;
981
+ /**
982
+ * Preferred case style for this collection's output keys.
983
+ * Set on a subclass to override the global default.
984
+ */
985
+ static preferredCase;
986
+ /**
987
+ * Response structure override for this collection class.
988
+ */
989
+ static responseStructure;
483
990
  called = {};
484
991
  constructor(rsc, res) {
485
992
  this.res = res;
@@ -492,6 +999,52 @@ var ResourceCollection = class {
492
999
  return this.toArray();
493
1000
  }
494
1001
  /**
1002
+ * Get the current serialized output body.
1003
+ */
1004
+ getBody() {
1005
+ this.json();
1006
+ return this.body;
1007
+ }
1008
+ /**
1009
+ * Replace the current serialized output body.
1010
+ */
1011
+ setBody(body) {
1012
+ this.body = body;
1013
+ return this;
1014
+ }
1015
+ /**
1016
+ * Conditionally include a value in serialized output.
1017
+ */
1018
+ when(condition, value) {
1019
+ return resolveWhen(condition, value);
1020
+ }
1021
+ /**
1022
+ * Include a value only when it is not null/undefined.
1023
+ */
1024
+ whenNotNull(value) {
1025
+ return resolveWhenNotNull(value);
1026
+ }
1027
+ /**
1028
+ * Conditionally merge object attributes into serialized output.
1029
+ */
1030
+ mergeWhen(condition, value) {
1031
+ return resolveMergeWhen(condition, value);
1032
+ }
1033
+ resolveResponseStructure() {
1034
+ const local = this.constructor.responseStructure;
1035
+ const collectsLocal = this.collects?.responseStructure;
1036
+ const global = getGlobalResponseStructure();
1037
+ return {
1038
+ wrap: local?.wrap ?? collectsLocal?.wrap ?? global?.wrap ?? true,
1039
+ rootKey: local?.rootKey ?? collectsLocal?.rootKey ?? global?.rootKey ?? "data",
1040
+ factory: local?.factory ?? collectsLocal?.factory ?? global?.factory
1041
+ };
1042
+ }
1043
+ getPayloadKey() {
1044
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1045
+ return factory || !wrap ? void 0 : rootKey;
1046
+ }
1047
+ /**
495
1048
  * Convert resource to JSON response format
496
1049
  *
497
1050
  * @returns
@@ -501,19 +1054,65 @@ var ResourceCollection = class {
501
1054
  this.called.json = true;
502
1055
  let data = this.data();
503
1056
  if (this.collects) data = data.map((item) => new this.collects(item).data());
504
- this.body = { data };
505
- if (!Array.isArray(this.resource)) {
506
- if (this.resource.pagination && this.resource.cursor) this.body.meta = {
507
- pagination: this.resource.pagination,
508
- cursor: this.resource.cursor
509
- };
510
- else if (this.resource.pagination) this.body.meta = { pagination: this.resource.pagination };
511
- else if (this.resource.cursor) this.body.meta = { cursor: this.resource.cursor };
1057
+ data = sanitizeConditionalAttributes(data);
1058
+ const paginationExtras = !Array.isArray(this.resource) ? buildPaginationExtras(this.resource) : {};
1059
+ const { metaKey } = getPaginationExtraKeys();
1060
+ const configuredMeta = metaKey ? paginationExtras[metaKey] : void 0;
1061
+ if (metaKey) delete paginationExtras[metaKey];
1062
+ const caseStyle = this.constructor.preferredCase ?? this.collects?.preferredCase ?? getGlobalCase();
1063
+ if (caseStyle) {
1064
+ const transformer = getCaseTransformer(caseStyle);
1065
+ data = transformKeys(data, transformer);
512
1066
  }
1067
+ const customMeta = mergeMetadata(resolveWithHookMetadata(this, ResourceCollection.prototype.with), this.additionalMeta);
1068
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1069
+ this.body = buildResponseEnvelope({
1070
+ payload: data,
1071
+ meta: configuredMeta,
1072
+ metaKey,
1073
+ wrap,
1074
+ rootKey,
1075
+ factory,
1076
+ context: {
1077
+ type: "collection",
1078
+ resource: this.resource
1079
+ }
1080
+ });
1081
+ this.body = appendRootProperties(this.body, {
1082
+ ...paginationExtras,
1083
+ ...customMeta || {}
1084
+ }, rootKey);
513
1085
  }
514
1086
  return this;
515
1087
  }
516
1088
  /**
1089
+ * Append structured metadata to the response body.
1090
+ *
1091
+ * @param meta Metadata object or metadata factory
1092
+ * @returns
1093
+ */
1094
+ with(meta) {
1095
+ this.called.with = true;
1096
+ if (typeof meta === "undefined") return this.additionalMeta || {};
1097
+ const resolvedMeta = typeof meta === "function" ? meta(this.resource) : meta;
1098
+ this.additionalMeta = mergeMetadata(this.additionalMeta, resolvedMeta);
1099
+ if (this.called.json) {
1100
+ const { rootKey } = this.resolveResponseStructure();
1101
+ this.body = appendRootProperties(this.body, resolvedMeta, rootKey);
1102
+ }
1103
+ return this;
1104
+ }
1105
+ /**
1106
+ * Typed fluent metadata helper.
1107
+ *
1108
+ * @param meta Metadata object or metadata factory
1109
+ * @returns
1110
+ */
1111
+ withMeta(meta) {
1112
+ this.with(meta);
1113
+ return this;
1114
+ }
1115
+ /**
517
1116
  * Flatten resource to return original data
518
1117
  *
519
1118
  * @returns
@@ -534,7 +1133,8 @@ var ResourceCollection = class {
534
1133
  this.json();
535
1134
  delete extra.cursor;
536
1135
  delete extra.pagination;
537
- if (extra.data && Array.isArray(this.body.data)) this.body.data = [...this.body.data, ...extra.data];
1136
+ const payloadKey = this.getPayloadKey();
1137
+ if (extra.data && payloadKey && Array.isArray(this.body[payloadKey])) this.body[payloadKey] = [...this.body[payloadKey], ...extra.data];
538
1138
  this.body = {
539
1139
  ...this.body,
540
1140
  ...extra
@@ -543,7 +1143,24 @@ var ResourceCollection = class {
543
1143
  }
544
1144
  response(res) {
545
1145
  this.called.toResponse = true;
546
- return new ServerResponse(res ?? this.res, this.body);
1146
+ this.json();
1147
+ const rawResponse = res ?? this.res;
1148
+ const response = new ServerResponse(rawResponse, this.body);
1149
+ this.withResponseContext = {
1150
+ response,
1151
+ raw: rawResponse
1152
+ };
1153
+ this.called.withResponse = true;
1154
+ this.withResponse(response, rawResponse);
1155
+ return response;
1156
+ }
1157
+ /**
1158
+ * Customize the outgoing transport response right before dispatch.
1159
+ *
1160
+ * Override in custom classes to mutate headers/status/body.
1161
+ */
1162
+ withResponse(_response, _rawResponse) {
1163
+ return this;
547
1164
  }
548
1165
  setCollects(collects) {
549
1166
  this.collects = collects;
@@ -559,6 +1176,18 @@ var ResourceCollection = class {
559
1176
  then(onfulfilled, onrejected) {
560
1177
  this.called.then = true;
561
1178
  this.json();
1179
+ if (this.res) {
1180
+ const response = new ServerResponse(this.res, this.body);
1181
+ this.withResponseContext = {
1182
+ response,
1183
+ raw: this.res
1184
+ };
1185
+ this.called.withResponse = true;
1186
+ this.withResponse(response, this.res);
1187
+ } else {
1188
+ this.called.withResponse = true;
1189
+ this.withResponse();
1190
+ }
562
1191
  const resolved = Promise.resolve(this.body).then(onfulfilled, onrejected);
563
1192
  if (this.res) this.res.send(this.body);
564
1193
  return resolved;
@@ -588,9 +1217,20 @@ var ResourceCollection = class {
588
1217
  /**
589
1218
  * Resource class to handle API resource transformation and response building
590
1219
  */
591
- var Resource = class {
1220
+ var Resource = class Resource {
592
1221
  body = { data: {} };
593
1222
  resource;
1223
+ additionalMeta;
1224
+ withResponseContext;
1225
+ /**
1226
+ * Preferred case style for this resource's output keys.
1227
+ * Set on a subclass to override the global default.
1228
+ */
1229
+ static preferredCase;
1230
+ /**
1231
+ * Response structure override for this resource class.
1232
+ */
1233
+ static responseStructure;
594
1234
  called = {};
595
1235
  constructor(rsc, res) {
596
1236
  this.res = res;
@@ -629,6 +1269,51 @@ var Resource = class {
629
1269
  return this.toArray();
630
1270
  }
631
1271
  /**
1272
+ * Get the current serialized output body.
1273
+ */
1274
+ getBody() {
1275
+ this.json();
1276
+ return this.body;
1277
+ }
1278
+ /**
1279
+ * Replace the current serialized output body.
1280
+ */
1281
+ setBody(body) {
1282
+ this.body = body;
1283
+ return this;
1284
+ }
1285
+ /**
1286
+ * Conditionally include a value in serialized output.
1287
+ */
1288
+ when(condition, value) {
1289
+ return resolveWhen(condition, value);
1290
+ }
1291
+ /**
1292
+ * Include a value only when it is not null/undefined.
1293
+ */
1294
+ whenNotNull(value) {
1295
+ return resolveWhenNotNull(value);
1296
+ }
1297
+ /**
1298
+ * Conditionally merge object attributes into serialized output.
1299
+ */
1300
+ mergeWhen(condition, value) {
1301
+ return resolveMergeWhen(condition, value);
1302
+ }
1303
+ resolveResponseStructure() {
1304
+ const local = this.constructor.responseStructure;
1305
+ const global = getGlobalResponseStructure();
1306
+ return {
1307
+ wrap: local?.wrap ?? global?.wrap ?? true,
1308
+ rootKey: local?.rootKey ?? global?.rootKey ?? "data",
1309
+ factory: local?.factory ?? global?.factory
1310
+ };
1311
+ }
1312
+ getPayloadKey() {
1313
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1314
+ return factory || !wrap ? void 0 : rootKey;
1315
+ }
1316
+ /**
632
1317
  * Convert resource to JSON response format
633
1318
  *
634
1319
  * @returns
@@ -639,11 +1324,56 @@ var Resource = class {
639
1324
  const resource = this.data();
640
1325
  let data = Array.isArray(resource) ? [...resource] : { ...resource };
641
1326
  if (typeof data.data !== "undefined") data = data.data;
642
- this.body = { data };
1327
+ data = sanitizeConditionalAttributes(data);
1328
+ const caseStyle = this.constructor.preferredCase ?? getGlobalCase();
1329
+ if (caseStyle) {
1330
+ const transformer = getCaseTransformer(caseStyle);
1331
+ data = transformKeys(data, transformer);
1332
+ }
1333
+ const customMeta = mergeMetadata(resolveWithHookMetadata(this, Resource.prototype.with), this.additionalMeta);
1334
+ const { wrap, rootKey, factory } = this.resolveResponseStructure();
1335
+ this.body = buildResponseEnvelope({
1336
+ payload: data,
1337
+ wrap,
1338
+ rootKey,
1339
+ factory,
1340
+ context: {
1341
+ type: "resource",
1342
+ resource: this.resource
1343
+ }
1344
+ });
1345
+ this.body = appendRootProperties(this.body, customMeta, rootKey);
643
1346
  }
644
1347
  return this;
645
1348
  }
646
1349
  /**
1350
+ * Append structured metadata to the response body.
1351
+ *
1352
+ * @param meta Metadata object or metadata factory
1353
+ * @returns
1354
+ */
1355
+ with(meta) {
1356
+ this.called.with = true;
1357
+ if (typeof meta === "undefined") return this.additionalMeta || {};
1358
+ const resolvedMeta = typeof meta === "function" ? meta(this.resource) : meta;
1359
+ this.additionalMeta = mergeMetadata(this.additionalMeta, resolvedMeta);
1360
+ if (this.called.json) {
1361
+ const { rootKey } = this.resolveResponseStructure();
1362
+ this.body = appendRootProperties(this.body, resolvedMeta, rootKey);
1363
+ }
1364
+ return this;
1365
+ }
1366
+ /**
1367
+ * Typed fluent metadata helper.
1368
+ *
1369
+ * @param meta Metadata object or metadata factory
1370
+ * @returns
1371
+ */
1372
+ withMeta(meta) {
1373
+ this.with(meta);
1374
+ return this;
1375
+ }
1376
+ /**
647
1377
  * Flatten resource to array format (for collections) or return original data for single resources
648
1378
  *
649
1379
  * @returns
@@ -664,8 +1394,9 @@ var Resource = class {
664
1394
  additional(extra) {
665
1395
  this.called.additional = true;
666
1396
  this.json();
667
- if (extra.data) this.body.data = Array.isArray(this.body.data) ? [...this.body.data, ...extra.data] : {
668
- ...this.body.data,
1397
+ const payloadKey = this.getPayloadKey();
1398
+ if (extra.data && payloadKey && typeof this.body[payloadKey] !== "undefined") this.body[payloadKey] = Array.isArray(this.body[payloadKey]) ? [...this.body[payloadKey], ...extra.data] : {
1399
+ ...this.body[payloadKey],
669
1400
  ...extra.data
670
1401
  };
671
1402
  this.body = {
@@ -676,7 +1407,24 @@ var Resource = class {
676
1407
  }
677
1408
  response(res) {
678
1409
  this.called.toResponse = true;
679
- return new ServerResponse(res ?? this.res, this.body);
1410
+ this.json();
1411
+ const rawResponse = res ?? this.res;
1412
+ const response = new ServerResponse(rawResponse, this.body);
1413
+ this.withResponseContext = {
1414
+ response,
1415
+ raw: rawResponse
1416
+ };
1417
+ this.called.withResponse = true;
1418
+ this.withResponse(response, rawResponse);
1419
+ return response;
1420
+ }
1421
+ /**
1422
+ * Customize the outgoing transport response right before dispatch.
1423
+ *
1424
+ * Override in custom classes to mutate headers/status/body.
1425
+ */
1426
+ withResponse(_response, _rawResponse) {
1427
+ return this;
680
1428
  }
681
1429
  /**
682
1430
  * Promise-like then method to allow chaining with async/await or .then() syntax
@@ -688,6 +1436,18 @@ var Resource = class {
688
1436
  then(onfulfilled, onrejected) {
689
1437
  this.called.then = true;
690
1438
  this.json();
1439
+ if (this.res) {
1440
+ const response = new ServerResponse(this.res, this.body);
1441
+ this.withResponseContext = {
1442
+ response,
1443
+ raw: this.res
1444
+ };
1445
+ this.called.withResponse = true;
1446
+ this.withResponse(response, this.res);
1447
+ } else {
1448
+ this.called.withResponse = true;
1449
+ this.withResponse();
1450
+ }
691
1451
  const resolved = Promise.resolve(this.body).then(onfulfilled, onrejected);
692
1452
  if (this.res) this.res.send(this.body);
693
1453
  return resolved;
@@ -714,11 +1474,53 @@ var Resource = class {
714
1474
 
715
1475
  //#endregion
716
1476
  exports.ApiResource = ApiResource;
1477
+ exports.CONDITIONAL_ATTRIBUTE_MISSING = CONDITIONAL_ATTRIBUTE_MISSING;
717
1478
  exports.CliApp = CliApp;
718
1479
  exports.GenericResource = GenericResource;
1480
+ exports.InitCommand = InitCommand;
719
1481
  exports.MakeResource = MakeResource;
720
1482
  exports.Resource = Resource;
721
1483
  exports.ResourceCollection = ResourceCollection;
722
1484
  exports.ServerResponse = ServerResponse;
1485
+ exports.appendRootProperties = appendRootProperties;
1486
+ exports.buildPaginationExtras = buildPaginationExtras;
1487
+ exports.buildResponseEnvelope = buildResponseEnvelope;
723
1488
  exports.defineConfig = defineConfig;
724
- exports.getDefaultConfig = getDefaultConfig;
1489
+ exports.getCaseTransformer = getCaseTransformer;
1490
+ exports.getDefaultConfig = getDefaultConfig;
1491
+ exports.getGlobalBaseUrl = getGlobalBaseUrl;
1492
+ exports.getGlobalCase = getGlobalCase;
1493
+ exports.getGlobalCursorMeta = getGlobalCursorMeta;
1494
+ exports.getGlobalPageName = getGlobalPageName;
1495
+ exports.getGlobalPaginatedExtras = getGlobalPaginatedExtras;
1496
+ exports.getGlobalPaginatedLinks = getGlobalPaginatedLinks;
1497
+ exports.getGlobalPaginatedMeta = getGlobalPaginatedMeta;
1498
+ exports.getGlobalResponseFactory = getGlobalResponseFactory;
1499
+ exports.getGlobalResponseRootKey = getGlobalResponseRootKey;
1500
+ exports.getGlobalResponseStructure = getGlobalResponseStructure;
1501
+ exports.getGlobalResponseWrap = getGlobalResponseWrap;
1502
+ exports.getPaginationExtraKeys = getPaginationExtraKeys;
1503
+ exports.isPlainObject = isPlainObject;
1504
+ exports.mergeMetadata = mergeMetadata;
1505
+ exports.resolveMergeWhen = resolveMergeWhen;
1506
+ exports.resolveWhen = resolveWhen;
1507
+ exports.resolveWhenNotNull = resolveWhenNotNull;
1508
+ exports.resolveWithHookMetadata = resolveWithHookMetadata;
1509
+ exports.sanitizeConditionalAttributes = sanitizeConditionalAttributes;
1510
+ exports.setGlobalBaseUrl = setGlobalBaseUrl;
1511
+ exports.setGlobalCase = setGlobalCase;
1512
+ exports.setGlobalCursorMeta = setGlobalCursorMeta;
1513
+ exports.setGlobalPageName = setGlobalPageName;
1514
+ exports.setGlobalPaginatedExtras = setGlobalPaginatedExtras;
1515
+ exports.setGlobalPaginatedLinks = setGlobalPaginatedLinks;
1516
+ exports.setGlobalPaginatedMeta = setGlobalPaginatedMeta;
1517
+ exports.setGlobalResponseFactory = setGlobalResponseFactory;
1518
+ exports.setGlobalResponseRootKey = setGlobalResponseRootKey;
1519
+ exports.setGlobalResponseStructure = setGlobalResponseStructure;
1520
+ exports.setGlobalResponseWrap = setGlobalResponseWrap;
1521
+ exports.splitWords = splitWords;
1522
+ exports.toCamelCase = toCamelCase;
1523
+ exports.toKebabCase = toKebabCase;
1524
+ exports.toPascalCase = toPascalCase;
1525
+ exports.toSnakeCase = toSnakeCase;
1526
+ exports.transformKeys = transformKeys;