@danceroutine/tango-resources 0.1.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.
- package/dist/context/RequestContext.d.ts +27 -0
- package/dist/context/index.d.ts +5 -0
- package/dist/domain/index.d.ts +8 -0
- package/dist/filters/FilterSet.d.ts +33 -0
- package/dist/filters/FilterType.d.ts +2 -0
- package/dist/filters/RangeOperator.d.ts +2 -0
- package/dist/filters/index.d.ts +7 -0
- package/dist/filters/internal/InternalFilterType.d.ts +7 -0
- package/dist/filters/internal/InternalRangeOperator.d.ts +6 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +516 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination/PaginatedResponse.d.ts +6 -0
- package/dist/pagination/PaginationInput.d.ts +7 -0
- package/dist/pagination/Paginator.d.ts +18 -0
- package/dist/pagination/index.d.ts +9 -0
- package/dist/paginators/CursorPaginator.d.ts +35 -0
- package/dist/paginators/OffsetPaginator.d.ts +48 -0
- package/dist/paginators/index.d.ts +5 -0
- package/dist/viewset/ModelViewSet.d.ts +64 -0
- package/dist/viewset/ModelViewSet.js +143 -0
- package/dist/viewset/index.d.ts +4 -0
- package/package.json +75 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default user shape for RequestContext.
|
|
3
|
+
* Consumers can provide their own user type via the TUser generic parameter.
|
|
4
|
+
*/
|
|
5
|
+
export interface BaseUser {
|
|
6
|
+
id: string | number;
|
|
7
|
+
roles?: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Normalized request context passed through the framework adapter into viewset methods.
|
|
11
|
+
* Generic over the user type so consumers can plug in their own auth infrastructure.
|
|
12
|
+
*/
|
|
13
|
+
export declare class RequestContext<TUser = BaseUser> {
|
|
14
|
+
readonly request: Request;
|
|
15
|
+
user: TUser | null;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
static readonly BRAND: "tango.resources.request_context";
|
|
18
|
+
private state;
|
|
19
|
+
readonly __tangoBrand: typeof RequestContext.BRAND;
|
|
20
|
+
constructor(request: Request, user?: TUser | null, params?: Record<string, string>);
|
|
21
|
+
static isRequestContext<TUser = BaseUser>(value: unknown): value is RequestContext<TUser>;
|
|
22
|
+
setState<T>(key: string | symbol, value: T): void;
|
|
23
|
+
getState<T>(key: string | symbol): T | undefined;
|
|
24
|
+
hasState(key: string | symbol): boolean;
|
|
25
|
+
static create<TUser = BaseUser>(request: Request, user?: TUser | null): RequestContext<TUser>;
|
|
26
|
+
clone(): RequestContext<TUser>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility aggregate barrel. Canonical source-of-truth types now live in
|
|
3
|
+
* `filters`, `pagination`, and `viewset` subdomains.
|
|
4
|
+
*/
|
|
5
|
+
export type { FilterType, RangeOperator } from '../filters/index';
|
|
6
|
+
export type { ModelViewSetConfig } from '../viewset/index';
|
|
7
|
+
export type { PaginatedResponse, Paginator, Page } from '../pagination/index';
|
|
8
|
+
export { PaginationInput } from '../pagination/index';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FilterInput } from '@danceroutine/tango-orm';
|
|
2
|
+
import { InternalFilterType } from './internal/InternalFilterType';
|
|
3
|
+
import type { RangeOperator } from './RangeOperator';
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for how a query parameter should be resolved into a filter.
|
|
6
|
+
* Supports scalar equality, case-insensitive search, range comparisons, IN queries, and custom logic.
|
|
7
|
+
*/
|
|
8
|
+
export type FilterResolver<T> = {
|
|
9
|
+
type: typeof InternalFilterType.SCALAR;
|
|
10
|
+
column: keyof T;
|
|
11
|
+
} | {
|
|
12
|
+
type: typeof InternalFilterType.ILIKE;
|
|
13
|
+
columns: (keyof T)[];
|
|
14
|
+
} | {
|
|
15
|
+
type: typeof InternalFilterType.RANGE;
|
|
16
|
+
column: keyof T;
|
|
17
|
+
op: RangeOperator;
|
|
18
|
+
} | {
|
|
19
|
+
type: typeof InternalFilterType.IN;
|
|
20
|
+
column: keyof T;
|
|
21
|
+
} | {
|
|
22
|
+
type: typeof InternalFilterType.CUSTOM;
|
|
23
|
+
apply: (value: string | string[] | undefined) => FilterInput<T> | undefined;
|
|
24
|
+
};
|
|
25
|
+
export declare class FilterSet<T extends Record<string, unknown>> {
|
|
26
|
+
private spec;
|
|
27
|
+
static readonly BRAND: "tango.resources.filter_set";
|
|
28
|
+
readonly __tangoBrand: typeof FilterSet.BRAND;
|
|
29
|
+
static isFilterSet<T extends Record<string, unknown>>(value: unknown): value is FilterSet<T>;
|
|
30
|
+
constructor(spec: Record<string, FilterResolver<T>>);
|
|
31
|
+
apply(params: URLSearchParams): FilterInput<T>[];
|
|
32
|
+
private resolveFilter;
|
|
33
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain boundary barrel: centralizes this subdomain's public contract.
|
|
3
|
+
*/
|
|
4
|
+
export type { FilterType } from './FilterType';
|
|
5
|
+
export type { RangeOperator } from './RangeOperator';
|
|
6
|
+
export type { FilterResolver } from './FilterSet';
|
|
7
|
+
export { FilterSet } from './FilterSet';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled exports for Django-style domain drill-down imports, plus curated
|
|
3
|
+
* top-level symbols for TS-native ergonomic imports.
|
|
4
|
+
*/
|
|
5
|
+
export * as context from './context/index';
|
|
6
|
+
export * as filters from './filters/index';
|
|
7
|
+
export * as pagination from './pagination/index';
|
|
8
|
+
export * as paginators from './paginators/index';
|
|
9
|
+
export * as viewset from './viewset/index';
|
|
10
|
+
export * as domain from './domain/index';
|
|
11
|
+
export { RequestContext } from './context/index';
|
|
12
|
+
export type { BaseUser } from './context/index';
|
|
13
|
+
export { FilterSet, type FilterResolver } from './filters/index';
|
|
14
|
+
export type { FilterType, RangeOperator } from './filters/index';
|
|
15
|
+
export { CursorPaginator, OffsetPaginator, PaginationInput, type Page, type PaginatedResponse, type Paginator, } from './pagination/index';
|
|
16
|
+
export { ModelViewSet } from './viewset/index';
|
|
17
|
+
export type { ModelViewSetConfig } from './viewset/index';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { toHttpError } from "@danceroutine/tango-core";
|
|
3
|
+
import { Q } from "@danceroutine/tango-orm";
|
|
4
|
+
|
|
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
|
+
//#region src/context/RequestContext.ts
|
|
16
|
+
var RequestContext = class RequestContext {
|
|
17
|
+
static BRAND = "tango.resources.request_context";
|
|
18
|
+
state = new Map();
|
|
19
|
+
__tangoBrand = RequestContext.BRAND;
|
|
20
|
+
constructor(request, user = null, params = {}) {
|
|
21
|
+
this.request = request;
|
|
22
|
+
this.user = user;
|
|
23
|
+
this.params = params;
|
|
24
|
+
}
|
|
25
|
+
static isRequestContext(value) {
|
|
26
|
+
return typeof value === "object" && value !== null && value.__tangoBrand === RequestContext.BRAND;
|
|
27
|
+
}
|
|
28
|
+
setState(key, value) {
|
|
29
|
+
this.state.set(key, value);
|
|
30
|
+
}
|
|
31
|
+
getState(key) {
|
|
32
|
+
return this.state.get(key);
|
|
33
|
+
}
|
|
34
|
+
hasState(key) {
|
|
35
|
+
return this.state.has(key);
|
|
36
|
+
}
|
|
37
|
+
static create(request, user) {
|
|
38
|
+
return new RequestContext(request, user ?? null);
|
|
39
|
+
}
|
|
40
|
+
clone() {
|
|
41
|
+
const cloned = new RequestContext(this.request, this.user, { ...this.params });
|
|
42
|
+
cloned.state = new Map(this.state);
|
|
43
|
+
return cloned;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/context/index.ts
|
|
49
|
+
var context_exports = {};
|
|
50
|
+
__export(context_exports, { RequestContext: () => RequestContext });
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/filters/internal/InternalFilterType.ts
|
|
54
|
+
const InternalFilterType = {
|
|
55
|
+
SCALAR: "scalar",
|
|
56
|
+
ILIKE: "ilike",
|
|
57
|
+
RANGE: "range",
|
|
58
|
+
IN: "in",
|
|
59
|
+
CUSTOM: "custom"
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/filters/FilterSet.ts
|
|
64
|
+
var FilterSet = class FilterSet {
|
|
65
|
+
static BRAND = "tango.resources.filter_set";
|
|
66
|
+
__tangoBrand = FilterSet.BRAND;
|
|
67
|
+
static isFilterSet(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && value.__tangoBrand === FilterSet.BRAND;
|
|
69
|
+
}
|
|
70
|
+
constructor(spec) {
|
|
71
|
+
this.spec = spec;
|
|
72
|
+
}
|
|
73
|
+
apply(params) {
|
|
74
|
+
const filters = [];
|
|
75
|
+
for (const [key, resolver] of Object.entries(this.spec)) {
|
|
76
|
+
const rawValue = params.getAll(key);
|
|
77
|
+
const value = rawValue.length > 1 ? rawValue : params.get(key) ?? undefined;
|
|
78
|
+
if (value === undefined) continue;
|
|
79
|
+
const filter = this.resolveFilter(resolver, value);
|
|
80
|
+
if (filter) filters.push(filter);
|
|
81
|
+
}
|
|
82
|
+
return filters;
|
|
83
|
+
}
|
|
84
|
+
resolveFilter(resolver, value) {
|
|
85
|
+
if (value === undefined) return undefined;
|
|
86
|
+
switch (resolver.type) {
|
|
87
|
+
case InternalFilterType.SCALAR: return { [resolver.column]: value };
|
|
88
|
+
case InternalFilterType.ILIKE: {
|
|
89
|
+
const pattern = `%${value}%`;
|
|
90
|
+
const filter = {};
|
|
91
|
+
resolver.columns.forEach((col) => {
|
|
92
|
+
filter[`${String(col)}__icontains`] = pattern;
|
|
93
|
+
});
|
|
94
|
+
return filter;
|
|
95
|
+
}
|
|
96
|
+
case InternalFilterType.RANGE: {
|
|
97
|
+
const lookupKey = `${String(resolver.column)}__${resolver.op}`;
|
|
98
|
+
return { [lookupKey]: value };
|
|
99
|
+
}
|
|
100
|
+
case InternalFilterType.IN: {
|
|
101
|
+
const arr = Array.isArray(value) ? value : String(value).split(",");
|
|
102
|
+
const lookupKey = `${String(resolver.column)}__in`;
|
|
103
|
+
return { [lookupKey]: arr };
|
|
104
|
+
}
|
|
105
|
+
case InternalFilterType.CUSTOM: return resolver.apply(value);
|
|
106
|
+
default: return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/filters/index.ts
|
|
113
|
+
var filters_exports = {};
|
|
114
|
+
__export(filters_exports, { FilterSet: () => FilterSet });
|
|
115
|
+
|
|
116
|
+
//#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()
|
|
122
|
+
});
|
|
123
|
+
|
|
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
|
+
//#endregion
|
|
232
|
+
//#region src/paginators/CursorPaginator.ts
|
|
233
|
+
var CursorPage = class CursorPage {
|
|
234
|
+
static BRAND = "tango.resources.cursor_page";
|
|
235
|
+
__tangoBrand = CursorPage.BRAND;
|
|
236
|
+
static isCursorPage(value) {
|
|
237
|
+
return typeof value === "object" && value !== null && value.__tangoBrand === CursorPage.BRAND;
|
|
238
|
+
}
|
|
239
|
+
constructor(results, nextCursor, previousCursor) {
|
|
240
|
+
this.results = results;
|
|
241
|
+
this.nextCursor = nextCursor;
|
|
242
|
+
this.previousCursor = previousCursor;
|
|
243
|
+
}
|
|
244
|
+
hasNext() {
|
|
245
|
+
return this.nextCursor !== null;
|
|
246
|
+
}
|
|
247
|
+
hasPrevious() {
|
|
248
|
+
return this.previousCursor !== null;
|
|
249
|
+
}
|
|
250
|
+
nextPageNumber() {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
previousPageNumber() {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
startIndex() {
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
endIndex() {
|
|
260
|
+
return this.results.length;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
var CursorPaginator = class CursorPaginator {
|
|
264
|
+
static BRAND = "tango.resources.cursor_paginator";
|
|
265
|
+
__tangoBrand = CursorPaginator.BRAND;
|
|
266
|
+
limit;
|
|
267
|
+
cursor = null;
|
|
268
|
+
direction = "asc";
|
|
269
|
+
nextCursor = null;
|
|
270
|
+
previousCursor = null;
|
|
271
|
+
constructor(queryset, perPage = 25, cursorField = "id") {
|
|
272
|
+
this.queryset = queryset;
|
|
273
|
+
this.perPage = perPage;
|
|
274
|
+
this.cursorField = cursorField;
|
|
275
|
+
this.limit = perPage;
|
|
276
|
+
}
|
|
277
|
+
static isCursorPaginator(value) {
|
|
278
|
+
return typeof value === "object" && value !== null && value.__tangoBrand === CursorPaginator.BRAND;
|
|
279
|
+
}
|
|
280
|
+
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");
|
|
286
|
+
if (ordering) {
|
|
287
|
+
const parsedDirection = ordering.startsWith("-") ? "desc" : "asc";
|
|
288
|
+
const parsedField = ordering.startsWith("-") ? ordering.slice(1) : ordering;
|
|
289
|
+
this.direction = parsedField === String(this.cursorField) ? parsedDirection : "asc";
|
|
290
|
+
} else this.direction = "asc";
|
|
291
|
+
}
|
|
292
|
+
parseParams(params) {
|
|
293
|
+
this.parse(params);
|
|
294
|
+
return {
|
|
295
|
+
limit: this.limit,
|
|
296
|
+
offset: 0
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
toResponse(results) {
|
|
300
|
+
const response = { results };
|
|
301
|
+
if (this.nextCursor) response.next = this.buildPageLink(this.nextCursor);
|
|
302
|
+
if (this.previousCursor) response.previous = this.buildPageLink(this.previousCursor);
|
|
303
|
+
return response;
|
|
304
|
+
}
|
|
305
|
+
getPaginatedResponse(results, _totalCount) {
|
|
306
|
+
return this.toResponse(results);
|
|
307
|
+
}
|
|
308
|
+
apply(queryset) {
|
|
309
|
+
let qs = queryset.limit(this.limit + 1);
|
|
310
|
+
if (this.cursor) {
|
|
311
|
+
const decoded = this.decodeCursor(this.cursor);
|
|
312
|
+
if (decoded.field !== String(this.cursorField)) throw new Error("Invalid cursor: field mismatch");
|
|
313
|
+
const lookup = this.direction === "asc" ? "__gt" : "__lt";
|
|
314
|
+
const fieldLookup = `${String(this.cursorField)}${lookup}`;
|
|
315
|
+
const filterInput = { [fieldLookup]: decoded.value };
|
|
316
|
+
qs = qs.filter(filterInput);
|
|
317
|
+
}
|
|
318
|
+
const orderToken = this.direction === "asc" ? String(this.cursorField) : `-${String(this.cursorField)}`;
|
|
319
|
+
return qs.orderBy(orderToken);
|
|
320
|
+
}
|
|
321
|
+
async paginate(cursor) {
|
|
322
|
+
const appliedCursor = cursor ?? this.cursor;
|
|
323
|
+
this.cursor = appliedCursor;
|
|
324
|
+
const fetched = await this.apply(this.queryset).fetch();
|
|
325
|
+
const results = [...fetched.results];
|
|
326
|
+
const hasMore = results.length > this.limit;
|
|
327
|
+
if (hasMore) results.pop();
|
|
328
|
+
this.previousCursor = appliedCursor ?? null;
|
|
329
|
+
const last = results.at(-1);
|
|
330
|
+
this.nextCursor = hasMore && last ? this.encodeCursor(last) : null;
|
|
331
|
+
return new CursorPage(results, this.nextCursor, this.previousCursor);
|
|
332
|
+
}
|
|
333
|
+
async getPage(page) {
|
|
334
|
+
if (page !== 1) throw new Error("CursorPaginator only supports getPage(1). Use cursor pagination for subsequent pages.");
|
|
335
|
+
return this.paginate();
|
|
336
|
+
}
|
|
337
|
+
buildPageLink(cursor) {
|
|
338
|
+
const orderingToken = this.direction === "asc" ? String(this.cursorField) : `-${String(this.cursorField)}`;
|
|
339
|
+
return `?limit=${this.limit}&cursor=${encodeURIComponent(cursor)}&ordering=${encodeURIComponent(orderingToken)}`;
|
|
340
|
+
}
|
|
341
|
+
encodeCursor(item) {
|
|
342
|
+
const payload = {
|
|
343
|
+
v: 1,
|
|
344
|
+
field: String(this.cursorField),
|
|
345
|
+
dir: this.direction,
|
|
346
|
+
value: item[this.cursorField]
|
|
347
|
+
};
|
|
348
|
+
return Buffer.from(JSON.stringify(payload), "utf-8").toString("base64");
|
|
349
|
+
}
|
|
350
|
+
decodeCursor(cursor) {
|
|
351
|
+
let parsed;
|
|
352
|
+
try {
|
|
353
|
+
parsed = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
|
|
354
|
+
} catch {
|
|
355
|
+
throw new Error("Invalid cursor: malformed token");
|
|
356
|
+
}
|
|
357
|
+
if (!parsed || typeof parsed !== "object" || parsed.v !== 1 || typeof parsed.field !== "string" || parsed.dir !== "asc" && parsed.dir !== "desc" || !("value" in parsed)) throw new Error("Invalid cursor: unsupported payload");
|
|
358
|
+
return parsed;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/pagination/index.ts
|
|
364
|
+
var pagination_exports = {};
|
|
365
|
+
__export(pagination_exports, {
|
|
366
|
+
CursorPaginator: () => CursorPaginator,
|
|
367
|
+
OffsetPaginator: () => OffsetPaginator,
|
|
368
|
+
PaginationInput: () => PaginationInput
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/paginators/index.ts
|
|
373
|
+
var paginators_exports = {};
|
|
374
|
+
__export(paginators_exports, {
|
|
375
|
+
CursorPaginator: () => CursorPaginator,
|
|
376
|
+
OffsetPaginator: () => OffsetPaginator
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/viewset/ModelViewSet.ts
|
|
381
|
+
var ModelViewSet = class ModelViewSet {
|
|
382
|
+
static BRAND = "tango.resources.model_view_set";
|
|
383
|
+
repository;
|
|
384
|
+
readSchema;
|
|
385
|
+
writeSchema;
|
|
386
|
+
updateSchema;
|
|
387
|
+
filters;
|
|
388
|
+
orderingFields;
|
|
389
|
+
searchFields;
|
|
390
|
+
__tangoBrand = ModelViewSet.BRAND;
|
|
391
|
+
static isModelViewSet(value) {
|
|
392
|
+
return typeof value === "object" && value !== null && value.__tangoBrand === ModelViewSet.BRAND;
|
|
393
|
+
}
|
|
394
|
+
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();
|
|
399
|
+
this.filters = config.filters;
|
|
400
|
+
this.orderingFields = config.orderingFields ?? [];
|
|
401
|
+
this.searchFields = config.searchFields ?? [];
|
|
402
|
+
}
|
|
403
|
+
async list(ctx) {
|
|
404
|
+
try {
|
|
405
|
+
const params = new URL(ctx.request.url).searchParams;
|
|
406
|
+
const paginator = new OffsetPaginator(this.repository.query());
|
|
407
|
+
paginator.parse(params);
|
|
408
|
+
let qs = this.repository.query();
|
|
409
|
+
if (this.filters) {
|
|
410
|
+
const filterInputs = this.filters.apply(params);
|
|
411
|
+
if (filterInputs.length > 0) qs = qs.filter(Q.and(...filterInputs));
|
|
412
|
+
}
|
|
413
|
+
const search = params.get("search");
|
|
414
|
+
if (search && this.searchFields.length > 0) {
|
|
415
|
+
const searchFilters = this.searchFields.map((field) => {
|
|
416
|
+
const lookup = `${String(field)}__icontains`;
|
|
417
|
+
return { [lookup]: search };
|
|
418
|
+
});
|
|
419
|
+
qs = qs.filter(Q.or(...searchFilters));
|
|
420
|
+
}
|
|
421
|
+
const ordering = params.get("ordering");
|
|
422
|
+
if (ordering) {
|
|
423
|
+
const orderTokens = ordering.split(",").filter((field) => {
|
|
424
|
+
const cleanField = field.startsWith("-") ? field.slice(1) : field;
|
|
425
|
+
return this.orderingFields.includes(cleanField);
|
|
426
|
+
});
|
|
427
|
+
if (orderTokens.length > 0) qs = qs.orderBy(...orderTokens.map((token) => token));
|
|
428
|
+
}
|
|
429
|
+
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
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
return this.handleError(error);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async retrieve(_ctx, id) {
|
|
441
|
+
try {
|
|
442
|
+
const pk = this.repository.meta.pk;
|
|
443
|
+
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
|
+
});
|
|
453
|
+
} catch (error) {
|
|
454
|
+
return this.handleError(error);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async create(ctx) {
|
|
458
|
+
try {
|
|
459
|
+
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
|
+
});
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return this.handleError(error);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async update(ctx, id) {
|
|
472
|
+
try {
|
|
473
|
+
const body = await ctx.request.json();
|
|
474
|
+
const validated = this.updateSchema.parse(body);
|
|
475
|
+
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
|
+
});
|
|
482
|
+
} catch (error) {
|
|
483
|
+
return this.handleError(error);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async destroy(_ctx, id) {
|
|
487
|
+
try {
|
|
488
|
+
const pkValue = id;
|
|
489
|
+
await this.repository.delete(pkValue);
|
|
490
|
+
return new Response(null, { status: 204 });
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return this.handleError(error);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
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
|
+
});
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region src/viewset/index.ts
|
|
506
|
+
var viewset_exports = {};
|
|
507
|
+
__export(viewset_exports, { ModelViewSet: () => ModelViewSet });
|
|
508
|
+
|
|
509
|
+
//#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 };
|
|
516
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["request: Request","user: TUser | null","params: Record<string, string>","value: unknown","key: string | symbol","value: T","user?: TUser | null","value: unknown","spec: Record<string, FilterResolver<T>>","params: URLSearchParams","filters: FilterInput<T>[]","resolver: FilterResolver<T>","value: string | string[] | undefined","filter: Partial<Record<FilterKey<T>, FilterValue>>","PaginationInput: z.ZodType<PaginationInputValue>","value: unknown","results: T[]","pageNumber: number","perPage: number","totalCount?: number","queryset: QuerySet<T>","params: URLSearchParams","results: TResult[]","context?: { totalCount?: number }","response: PaginatedResponse<TResult>","page: number","value: unknown","results: T[]","nextCursor: string | null","previousCursor: string | null","queryset: QuerySet<T>","perPage: number","cursorField: keyof T","params: URLSearchParams","parsedDirection: CursorDirection","results: TResult[]","response: PaginatedResponse<TResult>","_totalCount?: number","cursor?: string","page: number","cursor: string","item: T","payload: CursorPayload","parsed: unknown","value: unknown","config: ModelViewSetConfig<TModel, TRead, TWrite>","ctx: RequestContext","searchFilters: FilterInput<TModel>[]","_ctx: RequestContext","id: string","error: unknown"],"sources":["../src/context/RequestContext.ts","../src/context/index.ts","../src/filters/internal/InternalFilterType.ts","../src/filters/FilterSet.ts","../src/filters/index.ts","../src/pagination/PaginationInput.ts","../src/paginators/OffsetPaginator.ts","../src/paginators/CursorPaginator.ts","../src/pagination/index.ts","../src/paginators/index.ts","../src/viewset/ModelViewSet.ts","../src/viewset/index.ts","../src/domain/index.ts"],"sourcesContent":["/**\n * Default user shape for RequestContext.\n * Consumers can provide their own user type via the TUser generic parameter.\n */\nexport interface BaseUser {\n id: string | number;\n roles?: string[];\n}\n\n/**\n * Normalized request context passed through the framework adapter into viewset methods.\n * Generic over the user type so consumers can plug in their own auth infrastructure.\n */\nexport class RequestContext<TUser = BaseUser> {\n static readonly BRAND = 'tango.resources.request_context' as const;\n private state: Map<string | symbol, unknown> = new Map();\n readonly __tangoBrand: typeof RequestContext.BRAND = RequestContext.BRAND;\n\n constructor(\n public readonly request: Request,\n public user: TUser | null = null,\n public params: Record<string, string> = {}\n ) {}\n\n static isRequestContext<TUser = BaseUser>(value: unknown): value is RequestContext<TUser> {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { __tangoBrand?: unknown }).__tangoBrand === RequestContext.BRAND\n );\n }\n\n setState<T>(key: string | symbol, value: T): void {\n this.state.set(key, value);\n }\n\n getState<T>(key: string | symbol): T | undefined {\n return this.state.get(key) as T | undefined;\n }\n\n hasState(key: string | symbol): boolean {\n return this.state.has(key);\n }\n\n static create<TUser = BaseUser>(request: Request, user?: TUser | null): RequestContext<TUser> {\n return new RequestContext<TUser>(request, user ?? null);\n }\n\n clone(): RequestContext<TUser> {\n const cloned = new RequestContext<TUser>(this.request, this.user, { ...this.params });\n cloned.state = new Map(this.state);\n return cloned;\n }\n}\n","/**\n * Domain boundary barrel: centralizes this subdomain's public contract.\n */\n\nexport type { BaseUser } from './RequestContext';\nexport { RequestContext } from './RequestContext';\n","export const InternalFilterType = {\n SCALAR: 'scalar',\n ILIKE: 'ilike',\n RANGE: 'range',\n IN: 'in',\n CUSTOM: 'custom',\n} as const;\n","import type { FilterInput, FilterKey, FilterValue } from '@danceroutine/tango-orm';\nimport { InternalFilterType } from './internal/InternalFilterType';\nimport type { RangeOperator } from './RangeOperator';\n\n/**\n * Configuration for how a query parameter should be resolved into a filter.\n * Supports scalar equality, case-insensitive search, range comparisons, IN queries, and custom logic.\n */\nexport type FilterResolver<T> =\n | { type: typeof InternalFilterType.SCALAR; column: keyof T }\n | { type: typeof InternalFilterType.ILIKE; columns: (keyof T)[] }\n | { type: typeof InternalFilterType.RANGE; column: keyof T; op: RangeOperator }\n | { type: typeof InternalFilterType.IN; column: keyof T }\n | {\n type: typeof InternalFilterType.CUSTOM;\n apply: (value: string | string[] | undefined) => FilterInput<T> | undefined;\n };\n\nexport class FilterSet<T extends Record<string, unknown>> {\n static readonly BRAND = 'tango.resources.filter_set' as const;\n readonly __tangoBrand: typeof FilterSet.BRAND = FilterSet.BRAND;\n\n static isFilterSet<T extends Record<string, unknown>>(value: unknown): value is FilterSet<T> {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { __tangoBrand?: unknown }).__tangoBrand === FilterSet.BRAND\n );\n }\n\n constructor(private spec: Record<string, FilterResolver<T>>) {}\n\n apply(params: URLSearchParams): FilterInput<T>[] {\n const filters: FilterInput<T>[] = [];\n\n for (const [key, resolver] of Object.entries(this.spec)) {\n const rawValue = params.getAll(key);\n const value = rawValue.length > 1 ? rawValue : (params.get(key) ?? undefined);\n\n if (value === undefined) continue;\n\n const filter = this.resolveFilter(resolver, value);\n if (filter) {\n filters.push(filter);\n }\n }\n\n return filters;\n }\n\n private resolveFilter(\n resolver: FilterResolver<T>,\n value: string | string[] | undefined\n ): FilterInput<T> | undefined {\n if (value === undefined) return undefined;\n\n switch (resolver.type) {\n case InternalFilterType.SCALAR:\n return { [resolver.column]: value } as FilterInput<T>;\n\n case InternalFilterType.ILIKE: {\n const pattern = `%${value}%`;\n const filter: Partial<Record<FilterKey<T>, FilterValue>> = {};\n resolver.columns.forEach((col) => {\n filter[`${String(col)}__icontains` as FilterKey<T>] = pattern;\n });\n return filter;\n }\n\n case InternalFilterType.RANGE: {\n const lookupKey = `${String(resolver.column)}__${resolver.op}` as keyof FilterInput<T>;\n return { [lookupKey]: value } as FilterInput<T>;\n }\n\n case InternalFilterType.IN: {\n const arr = Array.isArray(value) ? value : String(value).split(',');\n const lookupKey = `${String(resolver.column)}__in` as keyof FilterInput<T>;\n return { [lookupKey]: arr } as FilterInput<T>;\n }\n\n case InternalFilterType.CUSTOM:\n return resolver.apply(value);\n\n default:\n return undefined;\n }\n }\n}\n","/**\n * Domain boundary barrel: centralizes this subdomain's public contract.\n */\n\nexport type { FilterType } from './FilterType';\nexport type { RangeOperator } from './RangeOperator';\nexport type { FilterResolver } from './FilterSet';\nexport { FilterSet } from './FilterSet';\n","import { z } from 'zod';\n\nexport type PaginationInputValue = {\n limit: number;\n offset: number;\n page?: number;\n};\n\nexport const PaginationInput: z.ZodType<PaginationInputValue> = z.object({\n limit: z.coerce\n .number()\n .int()\n .min(1)\n .default(25)\n .transform((value) => Math.min(value, 100)),\n offset: z.coerce.number().int().min(0).default(0),\n page: z.coerce.number().int().min(1).optional(),\n});\n","import type { QuerySet } from '@danceroutine/tango-orm';\nimport type { Paginator, Page } from '../pagination/Paginator';\nimport type { PaginatedResponse } from '../pagination/PaginatedResponse';\nimport { PaginationInput } from '../pagination/PaginationInput';\n\nclass OffsetPage<T> implements Page<T> {\n static readonly BRAND = 'tango.resources.offset_page' as const;\n readonly __tangoBrand: typeof OffsetPage.BRAND = OffsetPage.BRAND;\n\n static isOffsetPage<T>(value: unknown): value is OffsetPage<T> {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { __tangoBrand?: unknown }).__tangoBrand === OffsetPage.BRAND\n );\n }\n\n constructor(\n public readonly results: T[],\n private readonly pageNumber: number,\n private readonly perPage: number,\n private readonly totalCount?: number\n ) {}\n\n hasNext(): boolean {\n if (this.totalCount === undefined) {\n return false;\n }\n return this.endIndex() < this.totalCount;\n }\n\n hasPrevious(): boolean {\n return this.pageNumber > 1;\n }\n\n nextPageNumber(): number | null {\n return this.hasNext() ? this.pageNumber + 1 : null;\n }\n\n previousPageNumber(): number | null {\n return this.hasPrevious() ? this.pageNumber - 1 : null;\n }\n\n startIndex(): number {\n return (this.pageNumber - 1) * this.perPage;\n }\n\n endIndex(): number {\n return this.startIndex() + this.results.length;\n }\n}\n\n/**\n * Offset/limit paginator modelled after DRF's LimitOffsetPagination.\n * Handles parsing limit/offset/page from URL query params and building\n * the paginated response envelope with next/previous links.\n *\n * @example\n * ```typescript\n * const paginator = new OffsetPaginator(queryset);\n * const { limit, offset } = paginator.parseParams(searchParams);\n * const results = await queryset.limit(limit).offset(offset).fetchAll();\n * const response = paginator.getPaginatedResponse(results, totalCount);\n * ```\n */\nexport class OffsetPaginator<T extends Record<string, unknown>> implements Paginator<T> {\n static readonly BRAND = 'tango.resources.offset_paginator' as const;\n readonly __tangoBrand: typeof OffsetPaginator.BRAND = OffsetPaginator.BRAND;\n private limit = 25;\n private offset = 0;\n\n constructor(\n private queryset: QuerySet<T>,\n private perPage: number = 25\n ) {\n this.limit = perPage;\n }\n\n static isOffsetPaginator<T extends Record<string, unknown>>(value: unknown): value is OffsetPaginator<T> {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { __tangoBrand?: unknown }).__tangoBrand === OffsetPaginator.BRAND\n );\n }\n\n /**\n * Parse limit, offset, and page from URL search params.\n * If `page` is provided, it's converted to an offset.\n * Stores parsed values for use by getPaginatedResponse.\n */\n parse(params: URLSearchParams): void {\n const input = {\n limit: params.get('limit') ?? undefined,\n offset: params.get('offset') ?? undefined,\n page: params.get('page') ?? undefined,\n };\n\n const parsed = PaginationInput.parse(input);\n\n if (parsed.page) {\n parsed.offset = (parsed.page - 1) * parsed.limit;\n }\n\n this.limit = parsed.limit;\n this.offset = parsed.offset;\n }\n\n parseParams(params: URLSearchParams): { limit: number; offset: number } {\n this.parse(params);\n return { limit: this.limit, offset: this.offset };\n }\n\n /**\n * Build a DRF-style paginated response with count, next, and previous links.\n * Uses the limit/offset stored from the most recent parseParams call.\n */\n toResponse<TResult>(results: TResult[], context?: { totalCount?: number }): PaginatedResponse<TResult> {\n const totalCount = context?.totalCount;\n const response: PaginatedResponse<TResult> = { results };\n\n if (totalCount !== undefined) {\n response.count = totalCount;\n\n if (this.offset + this.limit < totalCount) {\n response.next = `?limit=${this.limit}&offset=${this.offset + this.limit}`;\n }\n\n if (this.offset > 0) {\n const prevOffset = Math.max(0, this.offset - this.limit);\n response.previous = `?limit=${this.limit}&offset=${prevOffset}`;\n }\n }\n\n return response;\n }\n\n getPaginatedResponse<TResult>(results: TResult[], totalCount?: number): PaginatedResponse<TResult> {\n return this.toResponse(results, { totalCount });\n }\n\n apply(queryset: QuerySet<T>): QuerySet<T> {\n return queryset.limit(this.limit).offset(this.offset);\n }\n\n async paginate(page: number): Promise<Page<T>> {\n return this.getPage(page);\n }\n\n async getPage(page: number): Promise<Page<T>> {\n const offset = (page - 1) * this.perPage;\n const results = await this.queryset.offset(offset).limit(this.perPage).fetch();\n\n const totalCount = await this.count();\n\n return new OffsetPage(results.results, page, this.perPage, totalCount);\n }\n\n async count(): Promise<number> {\n return this.queryset.count();\n }\n}\n","import type { FilterInput, QuerySet } from '@danceroutine/tango-orm';\nimport type { Paginator, Page } from '../pagination/Paginator';\nimport type { PaginatedResponse } from '../pagination/PaginatedResponse';\n\ntype CursorDirection = 'asc' | 'desc';\n\ntype CursorPayload = {\n v: 1;\n field: string;\n dir: CursorDirection;\n value: unknown;\n};\n\n/**\n * Represents a single cursor page of results.\n * Cursor pages do not expose numeric page navigation like offset pagination.\n */\nclass CursorPage<T> implements Page<T> {\n static readonly BRAND = 'tango.resources.cursor_page' as const;\n readonly __tangoBrand: typeof CursorPage.BRAND = CursorPage.BRAND;\n\n static isCursorPage<T>(value: unknown): value is CursorPage<T> {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { __tangoBrand?: unknown }).__tangoBrand === CursorPage.BRAND\n );\n }\n\n constructor(\n public readonly results: T[],\n public readonly nextCursor: string | null,\n public readonly previousCursor: string | null\n ) {}\n\n hasNext(): boolean {\n return this.nextCursor !== null;\n }\n\n hasPrevious(): boolean {\n return this.previousCursor !== null;\n }\n\n nextPageNumber(): number | null {\n return null;\n }\n\n previousPageNumber(): number | null {\n return null;\n }\n\n startIndex(): number {\n return 0;\n }\n\n endIndex(): number {\n return this.results.length;\n }\n}\n\n/**\n * Cursor-based paginator for stable forward navigation with opaque cursor tokens.\n * It supports `limit`, `cursor`, and `ordering` query params and returns DRF-style\n * paginated envelopes with cursor links.\n */\nexport class CursorPaginator<T extends Record<string, unknown>> implements Paginator<T> {\n static readonly BRAND = 'tango.resources.cursor_paginator' as const;\n readonly __tangoBrand: typeof CursorPaginator.BRAND = CursorPaginator.BRAND;\n private limit: number;\n private cursor: string | null = null;\n private direction: CursorDirection = 'asc';\n private nextCursor: string | null = null;\n private previousCursor: string | null = null;\n\n constructor(\n private queryset: QuerySet<T>,\n private perPage: number = 25,\n private cursorField: keyof T = 'id' as keyof T\n ) {\n this.limit = perPage;\n }\n\n static isCursorPaginator<T extends Record<string, unknown>>(value: unknown): value is CursorPaginator<T> {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { __tangoBrand?: unknown }).__tangoBrand === CursorPaginator.BRAND\n );\n }\n\n parse(params: URLSearchParams): void {\n const rawLimit = Number.parseInt(params.get('limit') ?? '', 10);\n if (Number.isFinite(rawLimit) && rawLimit > 0) {\n this.limit = Math.min(rawLimit, 100);\n } else {\n this.limit = this.perPage;\n }\n\n this.cursor = params.get('cursor');\n\n const ordering = params.get('ordering');\n if (ordering) {\n const parsedDirection: CursorDirection = ordering.startsWith('-') ? 'desc' : 'asc';\n const parsedField = ordering.startsWith('-') ? ordering.slice(1) : ordering;\n this.direction = parsedField === String(this.cursorField) ? parsedDirection : 'asc';\n } else {\n this.direction = 'asc';\n }\n }\n\n parseParams(params: URLSearchParams): { limit: number; offset: number } {\n this.parse(params);\n return { limit: this.limit, offset: 0 };\n }\n\n toResponse<TResult>(results: TResult[]): PaginatedResponse<TResult> {\n const response: PaginatedResponse<TResult> = { results };\n if (this.nextCursor) {\n response.next = this.buildPageLink(this.nextCursor);\n }\n if (this.previousCursor) {\n response.previous = this.buildPageLink(this.previousCursor);\n }\n return response;\n }\n\n getPaginatedResponse<TResult>(results: TResult[], _totalCount?: number): PaginatedResponse<TResult> {\n return this.toResponse(results);\n }\n\n apply(queryset: QuerySet<T>): QuerySet<T> {\n let qs = queryset.limit(this.limit + 1);\n if (this.cursor) {\n const decoded = this.decodeCursor(this.cursor);\n if (decoded.field !== String(this.cursorField)) {\n throw new Error('Invalid cursor: field mismatch');\n }\n const lookup = this.direction === 'asc' ? '__gt' : '__lt';\n const fieldLookup = `${String(this.cursorField)}${lookup}`;\n const filterInput = { [fieldLookup]: decoded.value } as FilterInput<T>;\n qs = qs.filter(filterInput);\n }\n const orderToken = this.direction === 'asc' ? String(this.cursorField) : `-${String(this.cursorField)}`;\n return qs.orderBy(orderToken as keyof T | `-${string}`);\n }\n\n async paginate(cursor?: string): Promise<Page<T>> {\n const appliedCursor = cursor ?? this.cursor;\n this.cursor = appliedCursor;\n const fetched = await this.apply(this.queryset).fetch();\n const results = [...fetched.results];\n const hasMore = results.length > this.limit;\n\n if (hasMore) {\n results.pop();\n }\n\n this.previousCursor = appliedCursor ?? null;\n const last = results.at(-1);\n this.nextCursor = hasMore && last ? this.encodeCursor(last) : null;\n\n return new CursorPage(results, this.nextCursor, this.previousCursor);\n }\n\n async getPage(page: number): Promise<Page<T>> {\n if (page !== 1) {\n throw new Error('CursorPaginator only supports getPage(1). Use cursor pagination for subsequent pages.');\n }\n return this.paginate();\n }\n\n private buildPageLink(cursor: string): string {\n const orderingToken = this.direction === 'asc' ? String(this.cursorField) : `-${String(this.cursorField)}`;\n return `?limit=${this.limit}&cursor=${encodeURIComponent(cursor)}&ordering=${encodeURIComponent(orderingToken)}`;\n }\n\n private encodeCursor(item: T): string {\n const payload: CursorPayload = {\n v: 1,\n field: String(this.cursorField),\n dir: this.direction,\n value: item[this.cursorField],\n };\n return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64');\n }\n\n private decodeCursor(cursor: string): CursorPayload {\n let parsed: unknown;\n try {\n parsed = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));\n } catch {\n throw new Error('Invalid cursor: malformed token');\n }\n\n if (\n !parsed ||\n typeof parsed !== 'object' ||\n (parsed as { v?: unknown }).v !== 1 ||\n typeof (parsed as { field?: unknown }).field !== 'string' ||\n ((parsed as { dir?: unknown }).dir !== 'asc' && (parsed as { dir?: unknown }).dir !== 'desc') ||\n !('value' in parsed)\n ) {\n throw new Error('Invalid cursor: unsupported payload');\n }\n\n return parsed as CursorPayload;\n }\n}\n","/**\n * Domain boundary barrel: centralizes this subdomain's public contract.\n */\n\nexport { OffsetPaginator } from '../paginators/OffsetPaginator';\nexport { CursorPaginator } from '../paginators/CursorPaginator';\nexport { PaginationInput } from './PaginationInput';\nexport type { PaginationInputValue } from './PaginationInput';\nexport type { Paginator, Page } from './Paginator';\nexport type { PaginatedResponse } from './PaginatedResponse';\n","/**\n * Domain boundary barrel: centralizes this subdomain's public contract.\n */\n\nexport { CursorPaginator } from './CursorPaginator';\nexport { OffsetPaginator } from './OffsetPaginator';\n","import type { FilterInput, Repository } from '@danceroutine/tango-orm';\nimport type { z } from 'zod';\nimport type { RequestContext } from '../context/index';\nimport type { FilterSet } from '../filters/index';\nimport { OffsetPaginator } from '../paginators/OffsetPaginator';\nimport { toHttpError } from '@danceroutine/tango-core';\nimport { Q } from '@danceroutine/tango-orm';\n\n/**\n * Configuration for a ModelViewSet, defining how a model is exposed as an API resource.\n *\n * @template TModel - The record type of the underlying database model\n * @template TRead - Zod schema used to validate and shape read (GET) responses\n * @template TWrite - Zod schema used to validate incoming write (POST/PUT) request bodies\n *\n * @example\n * ```typescript\n * const config: ModelViewSetConfig<Post, typeof PostReadSchema, typeof PostWriteSchema> = {\n * repository: postRepository,\n * readSchema: PostReadSchema,\n * writeSchema: PostWriteSchema,\n * updateSchema: PostPatchSchema,\n * filters: postFilterSet,\n * orderingFields: ['createdAt', 'title'],\n * searchFields: ['title', 'body'],\n * };\n * ```\n */\nexport interface ModelViewSetConfig<\n TModel extends Record<string, unknown>,\n TRead extends z.ZodType,\n TWrite extends z.ZodObject<z.ZodRawShape>,\n> {\n /** The repository instance used to query and persist model data */\n repository: Repository<TModel>;\n\n /** Zod schema applied to outgoing data (list and detail responses) */\n readSchema: TRead;\n\n /** Zod schema applied to incoming data for create operations */\n writeSchema: TWrite;\n\n /** Optional Zod schema for partial updates (PATCH). Falls back to writeSchema if omitted */\n updateSchema?: z.ZodType<Partial<TModel>>;\n\n /** Optional filter set defining which query parameters can filter the list endpoint */\n filters?: FilterSet<TModel>;\n\n /** Fields that clients are allowed to sort by via query parameters */\n orderingFields?: (keyof TModel)[];\n\n /** Fields that are searched when a free-text search query parameter is provided */\n searchFields?: (keyof TModel)[];\n}\n\n/**\n * Base class for creating RESTful API viewsets with built-in CRUD operations.\n * Provides list, retrieve, create, update, and delete methods with filtering,\n * search, pagination, and ordering support.\n */\nexport abstract class ModelViewSet<\n TModel extends Record<string, unknown>,\n TRead extends z.ZodType,\n TWrite extends z.ZodObject<z.ZodRawShape>,\n> {\n static readonly BRAND = 'tango.resources.model_view_set' as const;\n protected repository: Repository<TModel>;\n protected readSchema: TRead;\n protected writeSchema: TWrite;\n protected updateSchema: z.ZodType<Partial<TModel>>;\n protected filters?: FilterSet<TModel>;\n protected orderingFields: (keyof TModel)[];\n protected searchFields: (keyof TModel)[];\n readonly __tangoBrand: typeof ModelViewSet.BRAND = ModelViewSet.BRAND;\n\n static isModelViewSet(\n value: unknown\n ): value is ModelViewSet<Record<string, unknown>, z.ZodType, z.ZodObject<z.ZodRawShape>> {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { __tangoBrand?: unknown }).__tangoBrand === ModelViewSet.BRAND\n );\n }\n\n constructor(config: ModelViewSetConfig<TModel, TRead, TWrite>) {\n this.repository = config.repository;\n this.readSchema = config.readSchema;\n this.writeSchema = config.writeSchema;\n this.updateSchema = config.updateSchema ?? (config.writeSchema.partial() as z.ZodType<Partial<TModel>>);\n this.filters = config.filters;\n this.orderingFields = config.orderingFields ?? [];\n this.searchFields = config.searchFields ?? [];\n }\n\n async list(ctx: RequestContext): Promise<Response> {\n try {\n const params = new URL(ctx.request.url).searchParams;\n const paginator = new OffsetPaginator<TModel>(this.repository.query());\n paginator.parse(params);\n\n let qs = this.repository.query();\n\n if (this.filters) {\n const filterInputs = this.filters.apply(params);\n if (filterInputs.length > 0) {\n qs = qs.filter(Q.and(...filterInputs));\n }\n }\n\n const search = params.get('search');\n if (search && this.searchFields.length > 0) {\n const searchFilters: FilterInput<TModel>[] = this.searchFields.map((field) => {\n const lookup = `${String(field)}__icontains`;\n return { [lookup]: search } as FilterInput<TModel>;\n });\n qs = qs.filter(Q.or(...searchFilters));\n }\n\n const ordering = params.get('ordering');\n if (ordering) {\n const orderTokens = ordering.split(',').filter((field) => {\n const cleanField = field.startsWith('-') ? field.slice(1) : field;\n return this.orderingFields.includes(cleanField as keyof TModel);\n });\n if (orderTokens.length > 0) {\n qs = qs.orderBy(...orderTokens.map((token) => token as keyof TModel | `-${string & keyof TModel}`));\n }\n }\n\n qs = paginator.apply(qs);\n\n const [result, totalCount] = await Promise.all([qs.fetch(this.readSchema), qs.count()]);\n const response = paginator.toResponse<z.output<TRead>>(result.results, { totalCount });\n\n return new Response(JSON.stringify(response), {\n status: 200,\n headers: { 'Content-Type': 'application/json' },\n });\n } catch (error) {\n return this.handleError(error);\n }\n }\n\n async retrieve(_ctx: RequestContext, id: string): Promise<Response> {\n try {\n const pk = this.repository.meta.pk;\n const filterById = { [pk]: id } as FilterInput<TModel>;\n const result = await this.repository\n .query()\n .filter(filterById)\n .fetchOne(this.readSchema);\n\n if (!result) {\n return new Response(JSON.stringify({ error: 'Not found' }), {\n status: 404,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n\n return new Response(JSON.stringify(result), {\n status: 200,\n headers: { 'Content-Type': 'application/json' },\n });\n } catch (error) {\n return this.handleError(error);\n }\n }\n\n async create(ctx: RequestContext): Promise<Response> {\n try {\n const body = await ctx.request.json();\n const validated = this.writeSchema.parse(body);\n\n const created = await this.repository.create(validated as Partial<TModel>);\n const result = this.readSchema.parse(created);\n\n return new Response(JSON.stringify(result), {\n status: 201,\n headers: { 'Content-Type': 'application/json' },\n });\n } catch (error) {\n return this.handleError(error);\n }\n }\n\n async update(ctx: RequestContext, id: string): Promise<Response> {\n try {\n const body = await ctx.request.json();\n const validated = this.updateSchema.parse(body);\n\n const pkValue = id as TModel[keyof TModel];\n\n const updated = await this.repository.update(pkValue, validated as Partial<TModel>);\n const result = this.readSchema.parse(updated);\n\n return new Response(JSON.stringify(result), {\n status: 200,\n headers: { 'Content-Type': 'application/json' },\n });\n } catch (error) {\n return this.handleError(error);\n }\n }\n\n async destroy(_ctx: RequestContext, id: string): Promise<Response> {\n try {\n const pkValue = id as TModel[keyof TModel];\n\n await this.repository.delete(pkValue);\n\n return new Response(null, { status: 204 });\n } catch (error) {\n return this.handleError(error);\n }\n }\n\n protected handleError(error: unknown): Response {\n const httpError = toHttpError(error);\n return new Response(JSON.stringify(httpError.body), {\n status: httpError.status,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n}\n","/**\n * Domain boundary barrel: centralizes this subdomain's public contract.\n */\n\nexport { ModelViewSet, type ModelViewSetConfig } from './ModelViewSet';\n","/**\n * Compatibility aggregate barrel. Canonical source-of-truth types now live in\n * `filters`, `pagination`, and `viewset` subdomains.\n */\n\nexport type { FilterType, RangeOperator } from '../filters/index';\nexport type { ModelViewSetConfig } from '../viewset/index';\nexport type { PaginatedResponse, Paginator, Page } from '../pagination/index';\nexport { PaginationInput } from '../pagination/index';\n"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PaginatedResponse } from './PaginatedResponse';
|
|
2
|
+
import type { QuerySet } from '@danceroutine/tango-orm';
|
|
3
|
+
export interface Page<T> {
|
|
4
|
+
results: T[];
|
|
5
|
+
hasNext(): boolean;
|
|
6
|
+
hasPrevious(): boolean;
|
|
7
|
+
nextPageNumber(): number | null;
|
|
8
|
+
previousPageNumber(): number | null;
|
|
9
|
+
startIndex(): number;
|
|
10
|
+
endIndex(): number;
|
|
11
|
+
}
|
|
12
|
+
export interface Paginator<TModel extends Record<string, unknown>, TResult = TModel> {
|
|
13
|
+
parse(params: URLSearchParams): void;
|
|
14
|
+
apply(queryset: QuerySet<TModel>): QuerySet<TModel>;
|
|
15
|
+
toResponse(results: TResult[], context?: {
|
|
16
|
+
totalCount?: number;
|
|
17
|
+
}): PaginatedResponse<TResult>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain boundary barrel: centralizes this subdomain's public contract.
|
|
3
|
+
*/
|
|
4
|
+
export { OffsetPaginator } from '../paginators/OffsetPaginator';
|
|
5
|
+
export { CursorPaginator } from '../paginators/CursorPaginator';
|
|
6
|
+
export { PaginationInput } from './PaginationInput';
|
|
7
|
+
export type { PaginationInputValue } from './PaginationInput';
|
|
8
|
+
export type { Paginator, Page } from './Paginator';
|
|
9
|
+
export type { PaginatedResponse } from './PaginatedResponse';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { QuerySet } from '@danceroutine/tango-orm';
|
|
2
|
+
import type { Paginator, Page } from '../pagination/Paginator';
|
|
3
|
+
import type { PaginatedResponse } from '../pagination/PaginatedResponse';
|
|
4
|
+
/**
|
|
5
|
+
* Cursor-based paginator for stable forward navigation with opaque cursor tokens.
|
|
6
|
+
* It supports `limit`, `cursor`, and `ordering` query params and returns DRF-style
|
|
7
|
+
* paginated envelopes with cursor links.
|
|
8
|
+
*/
|
|
9
|
+
export declare class CursorPaginator<T extends Record<string, unknown>> implements Paginator<T> {
|
|
10
|
+
private queryset;
|
|
11
|
+
private perPage;
|
|
12
|
+
private cursorField;
|
|
13
|
+
static readonly BRAND: "tango.resources.cursor_paginator";
|
|
14
|
+
readonly __tangoBrand: typeof CursorPaginator.BRAND;
|
|
15
|
+
private limit;
|
|
16
|
+
private cursor;
|
|
17
|
+
private direction;
|
|
18
|
+
private nextCursor;
|
|
19
|
+
private previousCursor;
|
|
20
|
+
constructor(queryset: QuerySet<T>, perPage?: number, cursorField?: keyof T);
|
|
21
|
+
static isCursorPaginator<T extends Record<string, unknown>>(value: unknown): value is CursorPaginator<T>;
|
|
22
|
+
parse(params: URLSearchParams): void;
|
|
23
|
+
parseParams(params: URLSearchParams): {
|
|
24
|
+
limit: number;
|
|
25
|
+
offset: number;
|
|
26
|
+
};
|
|
27
|
+
toResponse<TResult>(results: TResult[]): PaginatedResponse<TResult>;
|
|
28
|
+
getPaginatedResponse<TResult>(results: TResult[], _totalCount?: number): PaginatedResponse<TResult>;
|
|
29
|
+
apply(queryset: QuerySet<T>): QuerySet<T>;
|
|
30
|
+
paginate(cursor?: string): Promise<Page<T>>;
|
|
31
|
+
getPage(page: number): Promise<Page<T>>;
|
|
32
|
+
private buildPageLink;
|
|
33
|
+
private encodeCursor;
|
|
34
|
+
private decodeCursor;
|
|
35
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { QuerySet } from '@danceroutine/tango-orm';
|
|
2
|
+
import type { Paginator, Page } from '../pagination/Paginator';
|
|
3
|
+
import type { PaginatedResponse } from '../pagination/PaginatedResponse';
|
|
4
|
+
/**
|
|
5
|
+
* Offset/limit paginator modelled after DRF's LimitOffsetPagination.
|
|
6
|
+
* Handles parsing limit/offset/page from URL query params and building
|
|
7
|
+
* the paginated response envelope with next/previous links.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const paginator = new OffsetPaginator(queryset);
|
|
12
|
+
* const { limit, offset } = paginator.parseParams(searchParams);
|
|
13
|
+
* const results = await queryset.limit(limit).offset(offset).fetchAll();
|
|
14
|
+
* const response = paginator.getPaginatedResponse(results, totalCount);
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare class OffsetPaginator<T extends Record<string, unknown>> implements Paginator<T> {
|
|
18
|
+
private queryset;
|
|
19
|
+
private perPage;
|
|
20
|
+
static readonly BRAND: "tango.resources.offset_paginator";
|
|
21
|
+
readonly __tangoBrand: typeof OffsetPaginator.BRAND;
|
|
22
|
+
private limit;
|
|
23
|
+
private offset;
|
|
24
|
+
constructor(queryset: QuerySet<T>, perPage?: number);
|
|
25
|
+
static isOffsetPaginator<T extends Record<string, unknown>>(value: unknown): value is OffsetPaginator<T>;
|
|
26
|
+
/**
|
|
27
|
+
* Parse limit, offset, and page from URL search params.
|
|
28
|
+
* If `page` is provided, it's converted to an offset.
|
|
29
|
+
* Stores parsed values for use by getPaginatedResponse.
|
|
30
|
+
*/
|
|
31
|
+
parse(params: URLSearchParams): void;
|
|
32
|
+
parseParams(params: URLSearchParams): {
|
|
33
|
+
limit: number;
|
|
34
|
+
offset: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Build a DRF-style paginated response with count, next, and previous links.
|
|
38
|
+
* Uses the limit/offset stored from the most recent parseParams call.
|
|
39
|
+
*/
|
|
40
|
+
toResponse<TResult>(results: TResult[], context?: {
|
|
41
|
+
totalCount?: number;
|
|
42
|
+
}): PaginatedResponse<TResult>;
|
|
43
|
+
getPaginatedResponse<TResult>(results: TResult[], totalCount?: number): PaginatedResponse<TResult>;
|
|
44
|
+
apply(queryset: QuerySet<T>): QuerySet<T>;
|
|
45
|
+
paginate(page: number): Promise<Page<T>>;
|
|
46
|
+
getPage(page: number): Promise<Page<T>>;
|
|
47
|
+
count(): Promise<number>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Repository } from '@danceroutine/tango-orm';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import type { RequestContext } from '../context/index';
|
|
4
|
+
import type { FilterSet } from '../filters/index';
|
|
5
|
+
/**
|
|
6
|
+
* Configuration for a ModelViewSet, defining how a model is exposed as an API resource.
|
|
7
|
+
*
|
|
8
|
+
* @template TModel - The record type of the underlying database model
|
|
9
|
+
* @template TRead - Zod schema used to validate and shape read (GET) responses
|
|
10
|
+
* @template TWrite - Zod schema used to validate incoming write (POST/PUT) request bodies
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const config: ModelViewSetConfig<Post, typeof PostReadSchema, typeof PostWriteSchema> = {
|
|
15
|
+
* repository: postRepository,
|
|
16
|
+
* readSchema: PostReadSchema,
|
|
17
|
+
* writeSchema: PostWriteSchema,
|
|
18
|
+
* updateSchema: PostPatchSchema,
|
|
19
|
+
* filters: postFilterSet,
|
|
20
|
+
* orderingFields: ['createdAt', 'title'],
|
|
21
|
+
* searchFields: ['title', 'body'],
|
|
22
|
+
* };
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export interface ModelViewSetConfig<TModel extends Record<string, unknown>, TRead extends z.ZodType, TWrite extends z.ZodObject<z.ZodRawShape>> {
|
|
26
|
+
/** The repository instance used to query and persist model data */
|
|
27
|
+
repository: Repository<TModel>;
|
|
28
|
+
/** Zod schema applied to outgoing data (list and detail responses) */
|
|
29
|
+
readSchema: TRead;
|
|
30
|
+
/** Zod schema applied to incoming data for create operations */
|
|
31
|
+
writeSchema: TWrite;
|
|
32
|
+
/** Optional Zod schema for partial updates (PATCH). Falls back to writeSchema if omitted */
|
|
33
|
+
updateSchema?: z.ZodType<Partial<TModel>>;
|
|
34
|
+
/** Optional filter set defining which query parameters can filter the list endpoint */
|
|
35
|
+
filters?: FilterSet<TModel>;
|
|
36
|
+
/** Fields that clients are allowed to sort by via query parameters */
|
|
37
|
+
orderingFields?: (keyof TModel)[];
|
|
38
|
+
/** Fields that are searched when a free-text search query parameter is provided */
|
|
39
|
+
searchFields?: (keyof TModel)[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Base class for creating RESTful API viewsets with built-in CRUD operations.
|
|
43
|
+
* Provides list, retrieve, create, update, and delete methods with filtering,
|
|
44
|
+
* search, pagination, and ordering support.
|
|
45
|
+
*/
|
|
46
|
+
export declare abstract class ModelViewSet<TModel extends Record<string, unknown>, TRead extends z.ZodType, TWrite extends z.ZodObject<z.ZodRawShape>> {
|
|
47
|
+
static readonly BRAND: "tango.resources.model_view_set";
|
|
48
|
+
protected repository: Repository<TModel>;
|
|
49
|
+
protected readSchema: TRead;
|
|
50
|
+
protected writeSchema: TWrite;
|
|
51
|
+
protected updateSchema: z.ZodType<Partial<TModel>>;
|
|
52
|
+
protected filters?: FilterSet<TModel>;
|
|
53
|
+
protected orderingFields: (keyof TModel)[];
|
|
54
|
+
protected searchFields: (keyof TModel)[];
|
|
55
|
+
readonly __tangoBrand: typeof ModelViewSet.BRAND;
|
|
56
|
+
static isModelViewSet(value: unknown): value is ModelViewSet<Record<string, unknown>, z.ZodType, z.ZodObject<z.ZodRawShape>>;
|
|
57
|
+
constructor(config: ModelViewSetConfig<TModel, TRead, TWrite>);
|
|
58
|
+
list(ctx: RequestContext): Promise<Response>;
|
|
59
|
+
retrieve(_ctx: RequestContext, id: string): Promise<Response>;
|
|
60
|
+
create(ctx: RequestContext): Promise<Response>;
|
|
61
|
+
update(ctx: RequestContext, id: string): Promise<Response>;
|
|
62
|
+
destroy(_ctx: RequestContext, id: string): Promise<Response>;
|
|
63
|
+
protected handleError(error: unknown): Response;
|
|
64
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { OffsetPaginator } from '../paginators/OffsetPaginator';
|
|
2
|
+
import { toHttpError } from '@danceroutine/tango-core';
|
|
3
|
+
import { Q } from '@danceroutine/tango-orm';
|
|
4
|
+
/**
|
|
5
|
+
* Base class for creating RESTful API viewsets with built-in CRUD operations.
|
|
6
|
+
* Provides list, retrieve, create, update, and delete methods with filtering,
|
|
7
|
+
* search, pagination, and ordering support.
|
|
8
|
+
*/
|
|
9
|
+
export class ModelViewSet {
|
|
10
|
+
static BRAND = 'tango.resources.model_view_set';
|
|
11
|
+
repository;
|
|
12
|
+
readSchema;
|
|
13
|
+
writeSchema;
|
|
14
|
+
updateSchema;
|
|
15
|
+
filters;
|
|
16
|
+
orderingFields;
|
|
17
|
+
searchFields;
|
|
18
|
+
__tangoBrand = ModelViewSet.BRAND;
|
|
19
|
+
static isModelViewSet(value) {
|
|
20
|
+
return (typeof value === 'object' &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
value.__tangoBrand === ModelViewSet.BRAND);
|
|
23
|
+
}
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.repository = config.repository;
|
|
26
|
+
this.readSchema = config.readSchema;
|
|
27
|
+
this.writeSchema = config.writeSchema;
|
|
28
|
+
this.updateSchema = config.updateSchema ?? config.writeSchema.partial();
|
|
29
|
+
this.filters = config.filters;
|
|
30
|
+
this.orderingFields = config.orderingFields ?? [];
|
|
31
|
+
this.searchFields = config.searchFields ?? [];
|
|
32
|
+
}
|
|
33
|
+
async list(ctx) {
|
|
34
|
+
try {
|
|
35
|
+
const params = new URL(ctx.request.url).searchParams;
|
|
36
|
+
const paginator = new OffsetPaginator(this.repository.query());
|
|
37
|
+
paginator.parse(params);
|
|
38
|
+
let qs = this.repository.query();
|
|
39
|
+
if (this.filters) {
|
|
40
|
+
const filterInputs = this.filters.apply(params);
|
|
41
|
+
if (filterInputs.length > 0) {
|
|
42
|
+
qs = qs.filter(Q.and(...filterInputs));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const search = params.get('search');
|
|
46
|
+
if (search && this.searchFields.length > 0) {
|
|
47
|
+
const searchFilters = this.searchFields.map((field) => {
|
|
48
|
+
const lookup = `${String(field)}__icontains`;
|
|
49
|
+
return { [lookup]: search };
|
|
50
|
+
});
|
|
51
|
+
qs = qs.filter(Q.or(...searchFilters));
|
|
52
|
+
}
|
|
53
|
+
const ordering = params.get('ordering');
|
|
54
|
+
if (ordering) {
|
|
55
|
+
const orderTokens = ordering.split(',').filter((field) => {
|
|
56
|
+
const cleanField = field.startsWith('-') ? field.slice(1) : field;
|
|
57
|
+
return this.orderingFields.includes(cleanField);
|
|
58
|
+
});
|
|
59
|
+
if (orderTokens.length > 0) {
|
|
60
|
+
qs = qs.orderBy(...orderTokens.map((token) => token));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
qs = paginator.apply(qs);
|
|
64
|
+
const [result, totalCount] = await Promise.all([qs.fetch(this.readSchema), qs.count()]);
|
|
65
|
+
const response = paginator.toResponse(result.results, { totalCount });
|
|
66
|
+
return new Response(JSON.stringify(response), {
|
|
67
|
+
status: 200,
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return this.handleError(error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async retrieve(_ctx, id) {
|
|
76
|
+
try {
|
|
77
|
+
const pk = this.repository.meta.pk;
|
|
78
|
+
const filterById = { [pk]: id };
|
|
79
|
+
const result = await this.repository.query().filter(filterById).fetchOne(this.readSchema);
|
|
80
|
+
if (!result) {
|
|
81
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
82
|
+
status: 404,
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return new Response(JSON.stringify(result), {
|
|
87
|
+
status: 200,
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return this.handleError(error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async create(ctx) {
|
|
96
|
+
try {
|
|
97
|
+
const body = await ctx.request.json();
|
|
98
|
+
const validated = this.writeSchema.parse(body);
|
|
99
|
+
const created = await this.repository.create(validated);
|
|
100
|
+
const result = this.readSchema.parse(created);
|
|
101
|
+
return new Response(JSON.stringify(result), {
|
|
102
|
+
status: 201,
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return this.handleError(error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async update(ctx, id) {
|
|
111
|
+
try {
|
|
112
|
+
const body = await ctx.request.json();
|
|
113
|
+
const validated = this.updateSchema.parse(body);
|
|
114
|
+
const pkValue = id;
|
|
115
|
+
const updated = await this.repository.update(pkValue, validated);
|
|
116
|
+
const result = this.readSchema.parse(updated);
|
|
117
|
+
return new Response(JSON.stringify(result), {
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return this.handleError(error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async destroy(_ctx, id) {
|
|
127
|
+
try {
|
|
128
|
+
const pkValue = id;
|
|
129
|
+
await this.repository.delete(pkValue);
|
|
130
|
+
return new Response(null, { status: 204 });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return this.handleError(error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
handleError(error) {
|
|
137
|
+
const httpError = toHttpError(error);
|
|
138
|
+
return new Response(JSON.stringify(httpError.body), {
|
|
139
|
+
status: httpError.status,
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danceroutine/tango-resources",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ModelViewSet, mixins, filters, and pagination for Tango",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./context": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./filters": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./pagination": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./paginators": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./viewset": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./domain": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"test": "vitest run --coverage",
|
|
44
|
+
"test:watch": "vitest",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"tango",
|
|
49
|
+
"resources",
|
|
50
|
+
"viewset",
|
|
51
|
+
"crud",
|
|
52
|
+
"rest"
|
|
53
|
+
],
|
|
54
|
+
"author": "Pedro Del Moral Lopez",
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "https://github.com/danceroutine/tango.git",
|
|
59
|
+
"directory": "packages/resources"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@danceroutine/tango-core": "workspace:*",
|
|
63
|
+
"@danceroutine/tango-orm": "workspace:*",
|
|
64
|
+
"zod": "^4.0.0"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@danceroutine/tango-schema": "workspace:*",
|
|
68
|
+
"@danceroutine/tango-testing": "workspace:*",
|
|
69
|
+
"@types/node": "^22.9.0",
|
|
70
|
+
"tsdown": "^0.4.0",
|
|
71
|
+
"typescript": "^5.6.3",
|
|
72
|
+
"vitest": "^4.0.6",
|
|
73
|
+
"zod": "^4.0.0"
|
|
74
|
+
}
|
|
75
|
+
}
|