@danceroutine/tango-resources 0.1.0 → 1.0.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/dist/context/RequestContext.d.ts +23 -4
  4. package/dist/filters/FilterSet.d.ts +59 -4
  5. package/dist/filters/index.d.ts +1 -1
  6. package/dist/index.d.ts +10 -4
  7. package/dist/index.js +515 -205
  8. package/dist/index.js.map +1 -1
  9. package/dist/pagination/CursorPaginationInput.d.ts +7 -0
  10. package/dist/pagination/OffsetPaginationInput.d.ts +7 -0
  11. package/dist/pagination/PaginatedResponse.d.ts +8 -2
  12. package/dist/pagination/Paginator.d.ts +5 -3
  13. package/dist/pagination/index.d.ts +5 -3
  14. package/dist/paginators/CursorPaginator.d.ts +32 -6
  15. package/dist/paginators/OffsetPaginator.d.ts +30 -7
  16. package/dist/resource/OpenAPIDescription.d.ts +21 -0
  17. package/dist/resource/ResourceModelLike.d.ts +16 -0
  18. package/dist/resource/index.d.ts +5 -0
  19. package/dist/serializer/ModelSerializer.d.ts +47 -0
  20. package/dist/serializer/Serializer.d.ts +52 -0
  21. package/dist/serializer/index.d.ts +5 -0
  22. package/dist/view/APIView.d.ts +26 -0
  23. package/dist/view/GenericAPIView.d.ts +57 -0
  24. package/dist/view/generics/CreateAPIView.d.ts +10 -0
  25. package/dist/view/generics/ListAPIView.d.ts +10 -0
  26. package/dist/view/generics/ListCreateAPIView.d.ts +11 -0
  27. package/dist/view/generics/RetrieveAPIView.d.ts +10 -0
  28. package/dist/view/generics/RetrieveDestroyAPIView.d.ts +11 -0
  29. package/dist/view/generics/RetrieveUpdateAPIView.d.ts +12 -0
  30. package/dist/view/generics/RetrieveUpdateDestroyAPIView.d.ts +13 -0
  31. package/dist/view/generics/index.d.ts +10 -0
  32. package/dist/view/index.d.ts +8 -0
  33. package/dist/view/index.js +3 -0
  34. package/dist/view/mixins/CreateModelMixin.d.ts +11 -0
  35. package/dist/view/mixins/DestroyModelMixin.d.ts +11 -0
  36. package/dist/view/mixins/ListModelMixin.d.ts +11 -0
  37. package/dist/view/mixins/RetrieveModelMixin.d.ts +11 -0
  38. package/dist/view/mixins/UpdateModelMixin.d.ts +12 -0
  39. package/dist/view/mixins/index.d.ts +8 -0
  40. package/dist/view-BNGEURL_.js +547 -0
  41. package/dist/view-BNGEURL_.js.map +1 -0
  42. package/dist/viewset/ModelViewSet.d.ts +91 -45
  43. package/dist/viewset/index.d.ts +2 -1
  44. package/package.json +75 -69
  45. package/dist/domain/index.d.ts +0 -8
  46. package/dist/pagination/PaginationInput.d.ts +0 -7
  47. package/dist/viewset/ModelViewSet.js +0 -143
package/dist/index.js CHANGED
@@ -1,42 +1,51 @@
1
- import { z } from "zod";
2
- import { toHttpError } from "@danceroutine/tango-core";
1
+ import { APIView, CreateAPIView, CreateModelMixin, DestroyModelMixin, GenericAPIView, ListAPIView, ListCreateAPIView, ListModelMixin, OffsetPaginationInput, OffsetPaginator, RetrieveAPIView, RetrieveDestroyAPIView, RetrieveModelMixin, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, UpdateModelMixin, __export, view_exports } from "./view-BNGEURL_.js";
2
+ import { HttpErrorFactory, NotFoundError, TangoRequest, TangoResponse } from "@danceroutine/tango-core";
3
+ import { z, z as z$1 } from "zod";
3
4
  import { Q } from "@danceroutine/tango-orm";
4
5
 
5
- //#region rolldown:runtime
6
- var __defProp = Object.defineProperty;
7
- var __export = (target, all) => {
8
- for (var name in all) __defProp(target, name, {
9
- get: all[name],
10
- enumerable: true
11
- });
12
- };
13
-
14
- //#endregion
15
6
  //#region src/context/RequestContext.ts
16
7
  var RequestContext = class RequestContext {
17
8
  static BRAND = "tango.resources.request_context";
18
- state = new Map();
19
9
  __tangoBrand = RequestContext.BRAND;
10
+ state = new Map();
20
11
  constructor(request, user = null, params = {}) {
21
12
  this.request = request;
22
13
  this.user = user;
23
14
  this.params = params;
24
15
  }
16
+ /**
17
+ * Narrow an unknown value to `RequestContext`.
18
+ */
25
19
  static isRequestContext(value) {
26
20
  return typeof value === "object" && value !== null && value.__tangoBrand === RequestContext.BRAND;
27
21
  }
22
+ /**
23
+ * Construct a context with optional user payload.
24
+ */
25
+ static create(request, user) {
26
+ return new RequestContext(TangoRequest.isTangoRequest(request) ? request : new TangoRequest(request), user ?? null);
27
+ }
28
+ /**
29
+ * Store arbitrary per-request state for downstream middleware/handlers.
30
+ */
28
31
  setState(key, value) {
29
32
  this.state.set(key, value);
30
33
  }
34
+ /**
35
+ * Retrieve previously stored request state.
36
+ */
31
37
  getState(key) {
32
38
  return this.state.get(key);
33
39
  }
40
+ /**
41
+ * Check whether a state key has been set.
42
+ */
34
43
  hasState(key) {
35
44
  return this.state.has(key);
36
45
  }
37
- static create(request, user) {
38
- return new RequestContext(request, user ?? null);
39
- }
46
+ /**
47
+ * Clone the context, including route params and request-local state.
48
+ */
40
49
  clone() {
41
50
  const cloned = new RequestContext(this.request, this.user, { ...this.params });
42
51
  cloned.state = new Map(this.state);
@@ -64,29 +73,200 @@ const InternalFilterType = {
64
73
  var FilterSet = class FilterSet {
65
74
  static BRAND = "tango.resources.filter_set";
66
75
  __tangoBrand = FilterSet.BRAND;
76
+ /**
77
+ * Resolve matching query parameters into ORM filter inputs.
78
+ */
79
+ constructor(spec, allowAllParams = false) {
80
+ this.spec = spec;
81
+ this.allowAllParams = allowAllParams;
82
+ }
83
+ /**
84
+ * Build a filter set from Django-style field declarations.
85
+ */
86
+ static define(config) {
87
+ const spec = FilterSet.normalizeDefineConfig(config);
88
+ return new FilterSet(spec, config.all === "__all__");
89
+ }
90
+ /**
91
+ * Narrow an unknown value to `FilterSet`.
92
+ */
67
93
  static isFilterSet(value) {
68
94
  return typeof value === "object" && value !== null && value.__tangoBrand === FilterSet.BRAND;
69
95
  }
70
- constructor(spec) {
71
- this.spec = spec;
96
+ static normalizeDefineConfig(config) {
97
+ const spec = {};
98
+ const fieldDeclarations = config.fields ?? {};
99
+ const fieldParsers = config.parsers ?? {};
100
+ for (const rawField of Object.keys(fieldDeclarations)) {
101
+ const declaration = fieldDeclarations[rawField];
102
+ if (declaration === undefined) continue;
103
+ const parser = fieldParsers[rawField];
104
+ FilterSet.addFieldDeclaration(spec, rawField, declaration, parser);
105
+ }
106
+ const aliases = config.aliases ?? {};
107
+ for (const [param, declaration] of Object.entries(aliases)) spec[param] = FilterSet.normalizeAliasDeclaration(declaration);
108
+ return spec;
109
+ }
110
+ static addFieldDeclaration(spec, field, declaration, parser) {
111
+ if (declaration === true) {
112
+ spec[String(field)] = FilterSet.createLookupResolver(field, "exact", parser);
113
+ return;
114
+ }
115
+ if (FilterSet.isLookupArray(declaration)) {
116
+ for (const lookup of declaration) {
117
+ const param = FilterSet.resolveLookupParam(String(field), lookup);
118
+ spec[param] = FilterSet.createLookupResolver(field, lookup, parser);
119
+ }
120
+ return;
121
+ }
122
+ const lookups = declaration.lookups ?? ["exact"];
123
+ const baseParam = declaration.param ?? String(field);
124
+ const effectiveParser = declaration.parse ?? parser;
125
+ for (const lookup of lookups) {
126
+ const param = FilterSet.resolveLookupParam(baseParam, lookup);
127
+ spec[param] = FilterSet.createLookupResolver(field, lookup, effectiveParser);
128
+ }
72
129
  }
130
+ static isLookupArray(value) {
131
+ return Array.isArray(value);
132
+ }
133
+ static normalizeAliasDeclaration(declaration) {
134
+ if (FilterSet.isFilterResolverDeclaration(declaration)) return declaration;
135
+ if ("fields" in declaration) {
136
+ const lookup = declaration.lookup ?? "icontains";
137
+ return FilterSet.createMultiFieldResolver(declaration.fields, lookup, declaration.parse);
138
+ }
139
+ return FilterSet.createLookupResolver(declaration.field, declaration.lookup ?? "exact", declaration.parse);
140
+ }
141
+ static isFilterResolverDeclaration(value) {
142
+ if (typeof value !== "object" || value === null || !("type" in value)) return false;
143
+ return [
144
+ InternalFilterType.SCALAR,
145
+ InternalFilterType.ILIKE,
146
+ InternalFilterType.RANGE,
147
+ InternalFilterType.IN,
148
+ InternalFilterType.CUSTOM
149
+ ].includes(value.type);
150
+ }
151
+ static createMultiFieldResolver(fields, lookup, parser) {
152
+ if (lookup === "icontains" && parser === undefined) return {
153
+ type: InternalFilterType.ILIKE,
154
+ columns: [...fields]
155
+ };
156
+ return {
157
+ type: InternalFilterType.CUSTOM,
158
+ apply: (raw) => {
159
+ const parsed = FilterSet.resolveParserValue(raw, parser);
160
+ if (parsed === undefined) return undefined;
161
+ const composed = {};
162
+ for (const field of fields) {
163
+ const segment = FilterSet.resolveLookupFilter(field, lookup, parsed);
164
+ if (!segment) continue;
165
+ Object.assign(composed, segment);
166
+ }
167
+ return Object.keys(composed).length > 0 ? composed : undefined;
168
+ }
169
+ };
170
+ }
171
+ static createLookupResolver(field, lookup, parser) {
172
+ if (parser !== undefined) return {
173
+ type: InternalFilterType.CUSTOM,
174
+ apply: (raw) => {
175
+ const parsed = FilterSet.resolveParserValue(raw, parser);
176
+ if (parsed === undefined) return undefined;
177
+ return FilterSet.resolveLookupFilter(field, lookup, parsed);
178
+ }
179
+ };
180
+ switch (lookup) {
181
+ case "exact": return {
182
+ type: InternalFilterType.SCALAR,
183
+ column: field
184
+ };
185
+ case "in": return {
186
+ type: InternalFilterType.IN,
187
+ column: field
188
+ };
189
+ case "lt":
190
+ case "lte":
191
+ case "gt":
192
+ case "gte": return {
193
+ type: InternalFilterType.RANGE,
194
+ column: field,
195
+ op: lookup
196
+ };
197
+ case "icontains": return {
198
+ type: InternalFilterType.ILIKE,
199
+ columns: [field]
200
+ };
201
+ default: return {
202
+ type: InternalFilterType.CUSTOM,
203
+ apply: (raw) => FilterSet.resolveLookupFilter(field, lookup, raw)
204
+ };
205
+ }
206
+ }
207
+ static resolveLookupFilter(field, lookup, value) {
208
+ if (value === undefined) return undefined;
209
+ if (lookup === "exact") return { [field]: value };
210
+ if (lookup === "in") {
211
+ const arr = Array.isArray(value) ? value : String(value).split(",");
212
+ const lookupKey$1 = `${String(field)}__in`;
213
+ return { [lookupKey$1]: arr };
214
+ }
215
+ if (lookup === "icontains") {
216
+ const lookupKey$1 = `${String(field)}__icontains`;
217
+ return { [lookupKey$1]: `%${FilterSet.toScalarString(value)}%` };
218
+ }
219
+ const lookupKey = `${String(field)}__${lookup}`;
220
+ return { [lookupKey]: value };
221
+ }
222
+ static resolveLookupParam(baseParam, lookup) {
223
+ if (lookup === "exact") return baseParam;
224
+ return `${baseParam}__${lookup}`;
225
+ }
226
+ static resolveParserValue(value, parser) {
227
+ if (value === undefined) return undefined;
228
+ if (parser === undefined) return value;
229
+ return parser(value);
230
+ }
231
+ static toScalarString(value) {
232
+ if (Array.isArray(value)) return value.join(",");
233
+ return String(value);
234
+ }
235
+ /**
236
+ * Apply all configured resolvers against query params.
237
+ */
73
238
  apply(params) {
74
239
  const filters = [];
75
- for (const [key, resolver] of Object.entries(this.spec)) {
240
+ const keys = new Set();
241
+ for (const [key] of params.entries()) keys.add(key);
242
+ for (const key of keys) {
243
+ const resolver = this.spec[key] ?? (this.allowAllParams ? this.buildAllResolver(key) : undefined);
244
+ if (!resolver) continue;
76
245
  const rawValue = params.getAll(key);
77
- const value = rawValue.length > 1 ? rawValue : params.get(key) ?? undefined;
246
+ const value = rawValue.length > 1 ? rawValue : rawValue[0];
78
247
  if (value === undefined) continue;
79
248
  const filter = this.resolveFilter(resolver, value);
80
249
  if (filter) filters.push(filter);
81
250
  }
82
251
  return filters;
83
252
  }
253
+ buildAllResolver(param) {
254
+ const [rawField, ...rawLookupParts] = param.split("__");
255
+ if (!rawField) return undefined;
256
+ const field = rawField;
257
+ if (rawLookupParts.length === 0) return {
258
+ type: InternalFilterType.SCALAR,
259
+ column: field
260
+ };
261
+ const lookup = rawLookupParts.join("__");
262
+ return FilterSet.createLookupResolver(field, lookup);
263
+ }
84
264
  resolveFilter(resolver, value) {
85
265
  if (value === undefined) return undefined;
86
266
  switch (resolver.type) {
87
267
  case InternalFilterType.SCALAR: return { [resolver.column]: value };
88
268
  case InternalFilterType.ILIKE: {
89
- const pattern = `%${value}%`;
269
+ const pattern = `%${FilterSet.toScalarString(value)}%`;
90
270
  const filter = {};
91
271
  resolver.columns.forEach((col) => {
92
272
  filter[`${String(col)}__icontains`] = pattern;
@@ -114,136 +294,36 @@ var filters_exports = {};
114
294
  __export(filters_exports, { FilterSet: () => FilterSet });
115
295
 
116
296
  //#endregion
117
- //#region src/pagination/PaginationInput.ts
118
- const PaginationInput = z.object({
119
- limit: z.coerce.number().int().min(1).default(25).transform((value) => Math.min(value, 100)),
120
- offset: z.coerce.number().int().min(0).default(0),
121
- page: z.coerce.number().int().min(1).optional()
297
+ //#region src/pagination/CursorPaginationInput.ts
298
+ const CursorPaginationInput = z$1.object({
299
+ limit: z$1.preprocess((value) => {
300
+ if (value === undefined || value === null || value === "") return undefined;
301
+ const parsed = Number.parseInt(String(value), 10);
302
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
303
+ return parsed;
304
+ }, z$1.number().int().min(1).transform((value) => Math.min(value, 100)).optional()),
305
+ cursor: z$1.string().nullable().default(null),
306
+ ordering: z$1.string().optional()
122
307
  });
123
308
 
124
- //#endregion
125
- //#region src/paginators/OffsetPaginator.ts
126
- var OffsetPage = class OffsetPage {
127
- static BRAND = "tango.resources.offset_page";
128
- __tangoBrand = OffsetPage.BRAND;
129
- static isOffsetPage(value) {
130
- return typeof value === "object" && value !== null && value.__tangoBrand === OffsetPage.BRAND;
131
- }
132
- constructor(results, pageNumber, perPage, totalCount) {
133
- this.results = results;
134
- this.pageNumber = pageNumber;
135
- this.perPage = perPage;
136
- this.totalCount = totalCount;
137
- }
138
- hasNext() {
139
- if (this.totalCount === undefined) return false;
140
- return this.endIndex() < this.totalCount;
141
- }
142
- hasPrevious() {
143
- return this.pageNumber > 1;
144
- }
145
- nextPageNumber() {
146
- return this.hasNext() ? this.pageNumber + 1 : null;
147
- }
148
- previousPageNumber() {
149
- return this.hasPrevious() ? this.pageNumber - 1 : null;
150
- }
151
- startIndex() {
152
- return (this.pageNumber - 1) * this.perPage;
153
- }
154
- endIndex() {
155
- return this.startIndex() + this.results.length;
156
- }
157
- };
158
- var OffsetPaginator = class OffsetPaginator {
159
- static BRAND = "tango.resources.offset_paginator";
160
- __tangoBrand = OffsetPaginator.BRAND;
161
- limit = 25;
162
- offset = 0;
163
- constructor(queryset, perPage = 25) {
164
- this.queryset = queryset;
165
- this.perPage = perPage;
166
- this.limit = perPage;
167
- }
168
- static isOffsetPaginator(value) {
169
- return typeof value === "object" && value !== null && value.__tangoBrand === OffsetPaginator.BRAND;
170
- }
171
- /**
172
- * Parse limit, offset, and page from URL search params.
173
- * If `page` is provided, it's converted to an offset.
174
- * Stores parsed values for use by getPaginatedResponse.
175
- */
176
- parse(params) {
177
- const input = {
178
- limit: params.get("limit") ?? undefined,
179
- offset: params.get("offset") ?? undefined,
180
- page: params.get("page") ?? undefined
181
- };
182
- const parsed = PaginationInput.parse(input);
183
- if (parsed.page) parsed.offset = (parsed.page - 1) * parsed.limit;
184
- this.limit = parsed.limit;
185
- this.offset = parsed.offset;
186
- }
187
- parseParams(params) {
188
- this.parse(params);
189
- return {
190
- limit: this.limit,
191
- offset: this.offset
192
- };
193
- }
194
- /**
195
- * Build a DRF-style paginated response with count, next, and previous links.
196
- * Uses the limit/offset stored from the most recent parseParams call.
197
- */
198
- toResponse(results, context) {
199
- const totalCount = context?.totalCount;
200
- const response = { results };
201
- if (totalCount !== undefined) {
202
- response.count = totalCount;
203
- if (this.offset + this.limit < totalCount) response.next = `?limit=${this.limit}&offset=${this.offset + this.limit}`;
204
- if (this.offset > 0) {
205
- const prevOffset = Math.max(0, this.offset - this.limit);
206
- response.previous = `?limit=${this.limit}&offset=${prevOffset}`;
207
- }
208
- }
209
- return response;
210
- }
211
- getPaginatedResponse(results, totalCount) {
212
- return this.toResponse(results, { totalCount });
213
- }
214
- apply(queryset) {
215
- return queryset.limit(this.limit).offset(this.offset);
216
- }
217
- async paginate(page) {
218
- return this.getPage(page);
219
- }
220
- async getPage(page) {
221
- const offset = (page - 1) * this.perPage;
222
- const results = await this.queryset.offset(offset).limit(this.perPage).fetch();
223
- const totalCount = await this.count();
224
- return new OffsetPage(results.results, page, this.perPage, totalCount);
225
- }
226
- async count() {
227
- return this.queryset.count();
228
- }
229
- };
230
-
231
309
  //#endregion
232
310
  //#region src/paginators/CursorPaginator.ts
233
311
  var CursorPage = class CursorPage {
234
312
  static BRAND = "tango.resources.cursor_page";
235
313
  __tangoBrand = CursorPage.BRAND;
236
- static isCursorPage(value) {
237
- return typeof value === "object" && value !== null && value.__tangoBrand === CursorPage.BRAND;
238
- }
239
314
  constructor(results, nextCursor, previousCursor) {
240
315
  this.results = results;
241
316
  this.nextCursor = nextCursor;
242
317
  this.previousCursor = previousCursor;
243
318
  }
319
+ static isCursorPage(value) {
320
+ return typeof value === "object" && value !== null && value.__tangoBrand === CursorPage.BRAND;
321
+ }
322
+ /** Whether a next cursor token exists. */
244
323
  hasNext() {
245
324
  return this.nextCursor !== null;
246
325
  }
326
+ /** Whether a previous cursor token exists. */
247
327
  hasPrevious() {
248
328
  return this.previousCursor !== null;
249
329
  }
@@ -274,21 +354,33 @@ var CursorPaginator = class CursorPaginator {
274
354
  this.cursorField = cursorField;
275
355
  this.limit = perPage;
276
356
  }
357
+ /**
358
+ * Narrow an unknown value to `CursorPaginator`.
359
+ */
277
360
  static isCursorPaginator(value) {
278
361
  return typeof value === "object" && value !== null && value.__tangoBrand === CursorPaginator.BRAND;
279
362
  }
363
+ /**
364
+ * Parse cursor pagination parameters from Tango query params.
365
+ */
280
366
  parse(params) {
281
- const rawLimit = Number.parseInt(params.get("limit") ?? "", 10);
282
- if (Number.isFinite(rawLimit) && rawLimit > 0) this.limit = Math.min(rawLimit, 100);
283
- else this.limit = this.perPage;
284
- this.cursor = params.get("cursor");
285
- const ordering = params.get("ordering");
367
+ const parsed = CursorPaginationInput.parse({
368
+ limit: params.get("limit") ?? undefined,
369
+ cursor: params.get("cursor"),
370
+ ordering: params.get("ordering") ?? undefined
371
+ });
372
+ this.limit = parsed.limit ?? this.perPage;
373
+ this.cursor = parsed.cursor;
374
+ const ordering = parsed.ordering;
286
375
  if (ordering) {
287
376
  const parsedDirection = ordering.startsWith("-") ? "desc" : "asc";
288
377
  const parsedField = ordering.startsWith("-") ? ordering.slice(1) : ordering;
289
378
  this.direction = parsedField === String(this.cursorField) ? parsedDirection : "asc";
290
379
  } else this.direction = "asc";
291
380
  }
381
+ /**
382
+ * Parse params and return compatibility `{ limit, offset }` shape.
383
+ */
292
384
  parseParams(params) {
293
385
  this.parse(params);
294
386
  return {
@@ -296,15 +388,27 @@ else this.limit = this.perPage;
296
388
  offset: 0
297
389
  };
298
390
  }
391
+ /**
392
+ * Build a paginated response payload with cursor links.
393
+ */
394
+ needsTotalCount() {
395
+ return false;
396
+ }
299
397
  toResponse(results) {
300
398
  const response = { results };
301
399
  if (this.nextCursor) response.next = this.buildPageLink(this.nextCursor);
302
400
  if (this.previousCursor) response.previous = this.buildPageLink(this.previousCursor);
303
401
  return response;
304
402
  }
403
+ /**
404
+ * Backward-compatible alias for `toResponse`.
405
+ */
305
406
  getPaginatedResponse(results, _totalCount) {
306
407
  return this.toResponse(results);
307
408
  }
409
+ /**
410
+ * Apply cursor constraints and ordering to a queryset.
411
+ */
308
412
  apply(queryset) {
309
413
  let qs = queryset.limit(this.limit + 1);
310
414
  if (this.cursor) {
@@ -318,6 +422,9 @@ else this.limit = this.perPage;
318
422
  const orderToken = this.direction === "asc" ? String(this.cursorField) : `-${String(this.cursorField)}`;
319
423
  return qs.orderBy(orderToken);
320
424
  }
425
+ /**
426
+ * Fetch the next cursor page.
427
+ */
321
428
  async paginate(cursor) {
322
429
  const appliedCursor = cursor ?? this.cursor;
323
430
  this.cursor = appliedCursor;
@@ -330,6 +437,9 @@ else this.limit = this.perPage;
330
437
  this.nextCursor = hasMore && last ? this.encodeCursor(last) : null;
331
438
  return new CursorPage(results, this.nextCursor, this.previousCursor);
332
439
  }
440
+ /**
441
+ * Cursor paginators only support page `1` as an entry point.
442
+ */
333
443
  async getPage(page) {
334
444
  if (page !== 1) throw new Error("CursorPaginator only supports getPage(1). Use cursor pagination for subsequent pages.");
335
445
  return this.paginate();
@@ -363,9 +473,10 @@ else this.limit = this.perPage;
363
473
  //#region src/pagination/index.ts
364
474
  var pagination_exports = {};
365
475
  __export(pagination_exports, {
476
+ CursorPaginationInput: () => CursorPaginationInput,
366
477
  CursorPaginator: () => CursorPaginator,
367
- OffsetPaginator: () => OffsetPaginator,
368
- PaginationInput: () => PaginationInput
478
+ OffsetPaginationInput: () => OffsetPaginationInput,
479
+ OffsetPaginator: () => OffsetPaginator
369
480
  });
370
481
 
371
482
  //#endregion
@@ -376,41 +487,229 @@ __export(paginators_exports, {
376
487
  OffsetPaginator: () => OffsetPaginator
377
488
  });
378
489
 
490
+ //#endregion
491
+ //#region src/resource/index.ts
492
+ var resource_exports = {};
493
+
494
+ //#endregion
495
+ //#region src/serializer/Serializer.ts
496
+ var Serializer = class {
497
+ static createSchema = z.unknown();
498
+ static updateSchema = z.unknown();
499
+ static outputSchema = z.unknown();
500
+ /**
501
+ * Return the serializer class for the current instance.
502
+ */
503
+ getSerializerClass() {
504
+ return this.constructor;
505
+ }
506
+ /**
507
+ * Return the Zod schema used for create payloads.
508
+ */
509
+ getCreateSchema() {
510
+ return this.getSerializerClass().createSchema;
511
+ }
512
+ /**
513
+ * Return the Zod schema used for update payloads.
514
+ */
515
+ getUpdateSchema() {
516
+ return this.getSerializerClass().updateSchema;
517
+ }
518
+ /**
519
+ * Return the Zod schema used for serialized output.
520
+ */
521
+ getOutputSchema() {
522
+ return this.getSerializerClass().outputSchema;
523
+ }
524
+ /**
525
+ * Validate unknown input for create workflows.
526
+ */
527
+ deserializeCreate(input) {
528
+ return this.getCreateSchema().parse(input);
529
+ }
530
+ /**
531
+ * Validate unknown input for update workflows.
532
+ */
533
+ deserializeUpdate(input) {
534
+ return this.getUpdateSchema().parse(input);
535
+ }
536
+ /**
537
+ * Convert a persisted record into its outward-facing representation.
538
+ */
539
+ toRepresentation(record) {
540
+ return this.getOutputSchema().parse(record);
541
+ }
542
+ };
543
+
544
+ //#endregion
545
+ //#region src/serializer/ModelSerializer.ts
546
+ var ModelSerializer = class extends Serializer {
547
+ static model;
548
+ /**
549
+ * Return the Tango model backing this serializer.
550
+ */
551
+ getModel() {
552
+ const model = this.constructor.model;
553
+ if (!model) throw new Error(`${this.constructor.name} must define a static model or override getModel().`);
554
+ return model;
555
+ }
556
+ /**
557
+ * Return the manager used for create and update workflows.
558
+ */
559
+ getManager() {
560
+ return this.getModel().objects;
561
+ }
562
+ /**
563
+ * Validate, enrich, persist, and serialize a create workflow.
564
+ */
565
+ async create(input) {
566
+ const validated = this.deserializeCreate(input);
567
+ const prepared = await this.beforeCreate(validated);
568
+ const created = await this.getManager().create(prepared);
569
+ return this.toRepresentation(created);
570
+ }
571
+ /**
572
+ * Validate, enrich, persist, and serialize an update workflow.
573
+ */
574
+ async update(id, input) {
575
+ const validated = this.deserializeUpdate(input);
576
+ const prepared = await this.beforeUpdate(id, validated);
577
+ const updated = await this.getManager().update(id, prepared);
578
+ return this.toRepresentation(updated);
579
+ }
580
+ /**
581
+ * Override to normalize create input for this resource workflow before the
582
+ * manager call.
583
+ *
584
+ * Model-owned persistence rules belong in model hooks so they also run for
585
+ * scripts and direct manager usage.
586
+ */
587
+ async beforeCreate(data) {
588
+ return data;
589
+ }
590
+ /**
591
+ * Override to normalize update input for this resource workflow before the
592
+ * manager call.
593
+ *
594
+ * Model-owned persistence rules belong in model hooks so they also run for
595
+ * scripts and direct manager usage.
596
+ */
597
+ async beforeUpdate(_id, data) {
598
+ return data;
599
+ }
600
+ };
601
+
602
+ //#endregion
603
+ //#region src/serializer/index.ts
604
+ var serializer_exports = {};
605
+ __export(serializer_exports, {
606
+ ModelSerializer: () => ModelSerializer,
607
+ Serializer: () => Serializer
608
+ });
609
+
379
610
  //#endregion
380
611
  //#region src/viewset/ModelViewSet.ts
381
612
  var ModelViewSet = class ModelViewSet {
382
613
  static BRAND = "tango.resources.model_view_set";
383
- repository;
384
- readSchema;
385
- writeSchema;
386
- updateSchema;
614
+ static actions = [];
615
+ __tangoBrand = ModelViewSet.BRAND;
616
+ serializerClass;
387
617
  filters;
388
618
  orderingFields;
389
619
  searchFields;
390
- __tangoBrand = ModelViewSet.BRAND;
391
- static isModelViewSet(value) {
392
- return typeof value === "object" && value !== null && value.__tangoBrand === ModelViewSet.BRAND;
393
- }
620
+ paginatorFactory;
621
+ serializer;
394
622
  constructor(config) {
395
- this.repository = config.repository;
396
- this.readSchema = config.readSchema;
397
- this.writeSchema = config.writeSchema;
398
- this.updateSchema = config.updateSchema ?? config.writeSchema.partial();
623
+ this.serializerClass = config.serializer;
399
624
  this.filters = config.filters;
400
625
  this.orderingFields = config.orderingFields ?? [];
401
626
  this.searchFields = config.searchFields ?? [];
627
+ this.paginatorFactory = config.paginatorFactory;
628
+ }
629
+ /**
630
+ * Return the custom action descriptors declared by a viewset or constructor.
631
+ */
632
+ static getActions(viewsetOrConstructor) {
633
+ const viewset = ModelViewSet.isModelViewSet(viewsetOrConstructor) ? viewsetOrConstructor : null;
634
+ const constructorValue = viewset ? viewset.constructor : viewsetOrConstructor;
635
+ const actions = Array.isArray(constructorValue.actions) ? constructorValue.actions : [];
636
+ return actions.map((action) => ({
637
+ ...action,
638
+ path: viewset ? viewset.resolveActionPath(action) : ModelViewSet.resolvePathFromDescriptor(action.name, action.path)
639
+ }));
640
+ }
641
+ /**
642
+ * Narrow an unknown value to `ModelViewSet`.
643
+ */
644
+ static isModelViewSet(value) {
645
+ return typeof value === "object" && value !== null && value.__tangoBrand === ModelViewSet.BRAND;
646
+ }
647
+ /**
648
+ * Preserve literal action inference while validating the descriptor shape.
649
+ */
650
+ static defineViewSetActions(actions) {
651
+ return actions;
652
+ }
653
+ static resolvePathFromDescriptor(name, explicitPath) {
654
+ const normalized = (explicitPath?.trim() || ModelViewSet.toKebabCase(name)).replace(/^\/+|\/+$/g, "");
655
+ if (!normalized) throw new Error(`Invalid custom action path for '${name}'.`);
656
+ return normalized;
657
+ }
658
+ static toKebabCase(input) {
659
+ return input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
660
+ }
661
+ /**
662
+ * Return the serializer class that owns this resource contract.
663
+ */
664
+ getSerializerClass() {
665
+ return this.serializerClass;
402
666
  }
667
+ /**
668
+ * Return the serializer instance for the current resource.
669
+ */
670
+ getSerializer() {
671
+ if (!this.serializer) this.serializer = new this.serializerClass();
672
+ return this.serializer;
673
+ }
674
+ /**
675
+ * Describe the public HTTP contract that this resource contributes to OpenAPI generation.
676
+ */
677
+ describeOpenAPI() {
678
+ return {
679
+ model: this.requireModelMetadata(),
680
+ outputSchema: this.getSerializer().getOutputSchema(),
681
+ createSchema: this.getSerializer().getCreateSchema(),
682
+ updateSchema: this.getSerializer().getUpdateSchema(),
683
+ searchFields: this.searchFields,
684
+ orderingFields: this.orderingFields,
685
+ lookupField: this.getManager().meta.pk,
686
+ lookupParam: "id",
687
+ allowedMethods: [
688
+ "GET",
689
+ "POST",
690
+ "PUT",
691
+ "PATCH",
692
+ "DELETE"
693
+ ],
694
+ usesDefaultOffsetPagination: !this.paginatorFactory,
695
+ actions: ModelViewSet.getActions(this)
696
+ };
697
+ }
698
+ /**
699
+ * List endpoint with filtering, search, ordering, and offset pagination.
700
+ */
403
701
  async list(ctx) {
404
702
  try {
405
- const params = new URL(ctx.request.url).searchParams;
406
- const paginator = new OffsetPaginator(this.repository.query());
703
+ const params = ctx.request.queryParams;
704
+ const baseQueryset = this.getManager().query();
705
+ const paginator = this.getPaginator(baseQueryset);
407
706
  paginator.parse(params);
408
- let qs = this.repository.query();
707
+ let qs = baseQueryset;
409
708
  if (this.filters) {
410
709
  const filterInputs = this.filters.apply(params);
411
710
  if (filterInputs.length > 0) qs = qs.filter(Q.and(...filterInputs));
412
711
  }
413
- const search = params.get("search");
712
+ const search = params.getSearch();
414
713
  if (search && this.searchFields.length > 0) {
415
714
  const searchFilters = this.searchFields.map((field) => {
416
715
  const lookup = `${String(field)}__icontains`;
@@ -418,86 +717,102 @@ var ModelViewSet = class ModelViewSet {
418
717
  });
419
718
  qs = qs.filter(Q.or(...searchFilters));
420
719
  }
421
- const ordering = params.get("ordering");
422
- if (ordering) {
423
- const orderTokens = ordering.split(",").filter((field) => {
720
+ const ordering = params.getOrdering();
721
+ if (ordering.length > 0) {
722
+ const orderTokens = ordering.filter((field) => {
424
723
  const cleanField = field.startsWith("-") ? field.slice(1) : field;
425
724
  return this.orderingFields.includes(cleanField);
426
725
  });
427
726
  if (orderTokens.length > 0) qs = qs.orderBy(...orderTokens.map((token) => token));
428
727
  }
429
728
  qs = paginator.apply(qs);
430
- const [result, totalCount] = await Promise.all([qs.fetch(this.readSchema), qs.count()]);
431
- const response = paginator.toResponse(result.results, { totalCount });
432
- return new Response(JSON.stringify(response), {
433
- status: 200,
434
- headers: { "Content-Type": "application/json" }
435
- });
729
+ const resultPromise = qs.fetch();
730
+ const totalCountPromise = paginator.needsTotalCount() ? qs.count() : Promise.resolve(undefined);
731
+ const [result, totalCount] = await Promise.all([resultPromise, totalCountPromise]);
732
+ const serializer = this.getSerializer();
733
+ const response = paginator.toResponse(result.results.map((row) => serializer.toRepresentation(row)), { totalCount });
734
+ return TangoResponse.json(response, { status: 200 });
436
735
  } catch (error) {
437
736
  return this.handleError(error);
438
737
  }
439
738
  }
739
+ /**
740
+ * Retrieve endpoint for a single resource by id.
741
+ */
440
742
  async retrieve(_ctx, id) {
441
743
  try {
442
- const pk = this.repository.meta.pk;
744
+ const manager = this.getManager();
745
+ const pk = manager.meta.pk;
443
746
  const filterById = { [pk]: id };
444
- const result = await this.repository.query().filter(filterById).fetchOne(this.readSchema);
445
- if (!result) return new Response(JSON.stringify({ error: "Not found" }), {
446
- status: 404,
447
- headers: { "Content-Type": "application/json" }
448
- });
449
- return new Response(JSON.stringify(result), {
450
- status: 200,
451
- headers: { "Content-Type": "application/json" }
452
- });
747
+ const result = await manager.query().filter(filterById).fetchOne();
748
+ if (!result) throw new NotFoundError(`No ${manager.meta.table} record found for ${String(pk)}=${id}.`);
749
+ return TangoResponse.json(this.getSerializer().toRepresentation(result), { status: 200 });
453
750
  } catch (error) {
454
751
  return this.handleError(error);
455
752
  }
456
753
  }
754
+ /**
755
+ * Create endpoint: validate input, persist, and return serialized output.
756
+ */
457
757
  async create(ctx) {
458
758
  try {
459
759
  const body = await ctx.request.json();
460
- const validated = this.writeSchema.parse(body);
461
- const created = await this.repository.create(validated);
462
- const result = this.readSchema.parse(created);
463
- return new Response(JSON.stringify(result), {
464
- status: 201,
465
- headers: { "Content-Type": "application/json" }
466
- });
760
+ const result = await this.getSerializer().create(body);
761
+ return TangoResponse.created(undefined, result);
467
762
  } catch (error) {
468
763
  return this.handleError(error);
469
764
  }
470
765
  }
766
+ /**
767
+ * Update endpoint: validate partial payload and persist by id.
768
+ */
471
769
  async update(ctx, id) {
472
770
  try {
473
771
  const body = await ctx.request.json();
474
- const validated = this.updateSchema.parse(body);
475
772
  const pkValue = id;
476
- const updated = await this.repository.update(pkValue, validated);
477
- const result = this.readSchema.parse(updated);
478
- return new Response(JSON.stringify(result), {
479
- status: 200,
480
- headers: { "Content-Type": "application/json" }
481
- });
773
+ const result = await this.getSerializer().update(pkValue, body);
774
+ return TangoResponse.json(result, { status: 200 });
482
775
  } catch (error) {
483
776
  return this.handleError(error);
484
777
  }
485
778
  }
779
+ /**
780
+ * Destroy endpoint: delete a resource by id.
781
+ */
486
782
  async destroy(_ctx, id) {
487
783
  try {
488
784
  const pkValue = id;
489
- await this.repository.delete(pkValue);
490
- return new Response(null, { status: 204 });
785
+ await this.getManager().delete(pkValue);
786
+ return TangoResponse.noContent();
491
787
  } catch (error) {
492
788
  return this.handleError(error);
493
789
  }
494
790
  }
791
+ getPaginator(queryset) {
792
+ if (this.paginatorFactory) return this.paginatorFactory(queryset);
793
+ return new OffsetPaginator(queryset);
794
+ }
795
+ getManager() {
796
+ return this.getSerializer().getManager();
797
+ }
798
+ /**
799
+ * Convert thrown errors into normalized HTTP responses.
800
+ */
495
801
  handleError(error) {
496
- const httpError = toHttpError(error);
497
- return new Response(JSON.stringify(httpError.body), {
498
- status: httpError.status,
499
- headers: { "Content-Type": "application/json" }
500
- });
802
+ const httpError = HttpErrorFactory.toHttpError(error);
803
+ return TangoResponse.json(httpError.body, { status: httpError.status });
804
+ }
805
+ /**
806
+ * Resolve route path segment(s) for a custom action.
807
+ * Override this in subclasses to customize path derivation globally.
808
+ */
809
+ resolveActionPath(action) {
810
+ return ModelViewSet.resolvePathFromDescriptor(action.name, action.path);
811
+ }
812
+ requireModelMetadata() {
813
+ const model = this.getSerializer().getModel();
814
+ if (!model.metadata) throw new Error("OpenAPI generation requires Tango model metadata on ModelViewSet models.");
815
+ return model;
501
816
  }
502
817
  };
503
818
 
@@ -507,10 +822,5 @@ var viewset_exports = {};
507
822
  __export(viewset_exports, { ModelViewSet: () => ModelViewSet });
508
823
 
509
824
  //#endregion
510
- //#region src/domain/index.ts
511
- var domain_exports = {};
512
- __export(domain_exports, { PaginationInput: () => PaginationInput });
513
-
514
- //#endregion
515
- export { CursorPaginator, FilterSet, ModelViewSet, OffsetPaginator, PaginationInput, RequestContext, context_exports as context, domain_exports as domain, filters_exports as filters, pagination_exports as pagination, paginators_exports as paginators, viewset_exports as viewset };
825
+ export { APIView, CreateAPIView, CreateModelMixin, CursorPaginationInput, CursorPaginator, DestroyModelMixin, FilterSet, GenericAPIView, ListAPIView, ListCreateAPIView, ListModelMixin, ModelSerializer, ModelViewSet, OffsetPaginationInput, OffsetPaginator, RequestContext, RetrieveAPIView, RetrieveDestroyAPIView, RetrieveModelMixin, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, Serializer, UpdateModelMixin, context_exports as context, filters_exports as filters, pagination_exports as pagination, paginators_exports as paginators, resource_exports as resource, serializer_exports as serializer, view_exports as view, viewset_exports as viewset };
516
826
  //# sourceMappingURL=index.js.map