@startsimpli/api 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +2235 -0
- package/dist/index.d.ts +2235 -0
- package/dist/index.js +2092 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2010 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -14
- package/src/__tests__/drf-transforms.test.ts +99 -0
- package/src/__tests__/entity-query-builder.test.ts +197 -0
- package/src/__tests__/funnels-api.test.ts +237 -0
- package/src/__tests__/jwt-refresh.test.ts +11 -14
- package/src/__tests__/url-builder.test.ts +27 -1
- package/src/__tests__/validate-response.test.ts +68 -0
- package/src/constants/endpoints.ts +13 -0
- package/src/constants/options.ts +83 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-server-detail.ts +71 -0
- package/src/hooks/use-server-list.ts +169 -0
- package/src/index.ts +36 -2
- package/src/lib/api-client.ts +10 -19
- package/src/lib/cache-manager.ts +103 -0
- package/src/lib/cache-store.ts +113 -0
- package/src/lib/error-handler.ts +1 -1
- package/src/lib/fetch-wrapper.ts +38 -26
- package/src/lib/funnels-api.ts +221 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/with-rate-limit.ts +54 -0
- package/src/utils/drf-transforms.ts +64 -0
- package/src/utils/entity-query-builder.ts +174 -0
- package/src/utils/index.ts +11 -1
- package/src/utils/url-builder.ts +27 -0
- package/src/utils/validate-response.ts +39 -0
- package/src/lib/messages-api.ts.backup +0 -273
package/src/lib/fetch-wrapper.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { buildUrl, buildQueryString } from '../utils';
|
|
|
7
7
|
import { ApiException, parseErrorResponse, handleFetchError } from './error-handler';
|
|
8
8
|
|
|
9
9
|
export interface FetchWrapperConfig {
|
|
10
|
-
baseUrl
|
|
10
|
+
baseUrl?: string;
|
|
11
11
|
getToken?: () => Promise<string | null> | string | null;
|
|
12
12
|
onUnauthorized?: () => void;
|
|
13
13
|
onTokenRefresh?: () => Promise<string | null>;
|
|
@@ -66,7 +66,7 @@ export class FetchWrapper {
|
|
|
66
66
|
|
|
67
67
|
// Build URL
|
|
68
68
|
const url = buildUrl({
|
|
69
|
-
baseUrl: this.config.baseUrl,
|
|
69
|
+
baseUrl: this.config.baseUrl ?? '',
|
|
70
70
|
endpoint,
|
|
71
71
|
params,
|
|
72
72
|
});
|
|
@@ -83,31 +83,36 @@ export class FetchWrapper {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
// Handle 401 Unauthorized - attempt token refresh
|
|
86
|
-
if (response.status === 401
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
this.isRefreshing
|
|
90
|
-
|
|
91
|
-
this.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
86
|
+
if (response.status === 401) {
|
|
87
|
+
if (this.config.onTokenRefresh) {
|
|
88
|
+
// Prevent multiple simultaneous refresh attempts
|
|
89
|
+
if (!this.isRefreshing) {
|
|
90
|
+
this.isRefreshing = true;
|
|
91
|
+
this.refreshPromise = this.config.onTokenRefresh().finally(() => {
|
|
92
|
+
this.isRefreshing = false;
|
|
93
|
+
this.refreshPromise = null;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const newToken = await this.refreshPromise;
|
|
98
|
+
|
|
99
|
+
if (newToken) {
|
|
100
|
+
// Retry original request with new token
|
|
101
|
+
const retryHeaders = new Headers(headers);
|
|
102
|
+
retryHeaders.set('Authorization', `Bearer ${newToken}`);
|
|
103
|
+
|
|
104
|
+
response = await fetch(url, {
|
|
105
|
+
method,
|
|
106
|
+
headers: retryHeaders,
|
|
107
|
+
credentials: 'include',
|
|
108
|
+
...fetchOptions,
|
|
109
|
+
});
|
|
110
|
+
} else if (this.config.onUnauthorized) {
|
|
111
|
+
// Refresh failed - call unauthorized callback
|
|
112
|
+
this.config.onUnauthorized();
|
|
113
|
+
}
|
|
109
114
|
} else if (this.config.onUnauthorized) {
|
|
110
|
-
//
|
|
115
|
+
// No refresh callback - call unauthorized directly
|
|
111
116
|
this.config.onUnauthorized();
|
|
112
117
|
}
|
|
113
118
|
}
|
|
@@ -130,6 +135,13 @@ export class FetchWrapper {
|
|
|
130
135
|
}
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Return a new FetchWrapper with selected config fields overridden.
|
|
140
|
+
*/
|
|
141
|
+
reconfigure(partial: Partial<FetchWrapperConfig>): FetchWrapper {
|
|
142
|
+
return new FetchWrapper({ ...this.config, ...partial });
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
/**
|
|
134
146
|
* GET request
|
|
135
147
|
*/
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funnels API wrapper for /api/v1/funnels/
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Funnel,
|
|
7
|
+
FunnelFilters,
|
|
8
|
+
FunnelRun,
|
|
9
|
+
FunnelResult,
|
|
10
|
+
CreateFunnelInput,
|
|
11
|
+
UpdateFunnelInput,
|
|
12
|
+
ExecuteFunnelInput,
|
|
13
|
+
PaginatedResponse,
|
|
14
|
+
PaginationParams,
|
|
15
|
+
} from '../types';
|
|
16
|
+
import { ENDPOINTS } from '../constants/endpoints';
|
|
17
|
+
import type { ApiClient } from './api-client';
|
|
18
|
+
import { isApiException } from './error-handler';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns true when the error is a 409 Conflict from trying to run a funnel that's already running.
|
|
22
|
+
*/
|
|
23
|
+
export function isFunnelRunConflict(error: unknown): boolean {
|
|
24
|
+
return isApiException(error) && error.status === 409;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true when the error is a 400 with validation errors (bad stage rules, missing entityType, etc.)
|
|
29
|
+
*/
|
|
30
|
+
export function isFunnelValidationError(error: unknown): boolean {
|
|
31
|
+
return isApiException(error) && error.status === 400 && !!error.errors;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FunnelPreviewResult {
|
|
35
|
+
total: number;
|
|
36
|
+
matched: number;
|
|
37
|
+
stages: Array<{
|
|
38
|
+
stageId: string;
|
|
39
|
+
stageName: string;
|
|
40
|
+
inputCount: number;
|
|
41
|
+
outputCount: number;
|
|
42
|
+
excluded: number;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FunnelRunFilters {
|
|
47
|
+
page?: number;
|
|
48
|
+
pageSize?: number;
|
|
49
|
+
status?: FunnelRun['status'];
|
|
50
|
+
ordering?: string;
|
|
51
|
+
funnel?: string;
|
|
52
|
+
startedAfter?: string;
|
|
53
|
+
startedBefore?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FunnelTemplate {
|
|
57
|
+
id: string;
|
|
58
|
+
slug: string;
|
|
59
|
+
name: string;
|
|
60
|
+
description: string;
|
|
61
|
+
category: string;
|
|
62
|
+
icon: string;
|
|
63
|
+
isFeatured: boolean;
|
|
64
|
+
stageCount: number;
|
|
65
|
+
usageCount: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class FunnelsApi {
|
|
69
|
+
constructor(private client: ApiClient) {}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List funnels with optional filters.
|
|
73
|
+
* Tags are passed as repeated query params: ?tags=campaign:abc&tags=product:market-simpli
|
|
74
|
+
*/
|
|
75
|
+
async list(filters?: FunnelFilters): Promise<PaginatedResponse<Funnel>> {
|
|
76
|
+
const params = new URLSearchParams();
|
|
77
|
+
|
|
78
|
+
if (filters?.status) params.append('status', filters.status);
|
|
79
|
+
if (filters?.entityType) params.append('entityType', filters.entityType);
|
|
80
|
+
if (filters?.search) params.append('search', filters.search);
|
|
81
|
+
if (filters?.createdBy) params.append('createdBy', filters.createdBy);
|
|
82
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
83
|
+
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
|
84
|
+
if (filters?.ordering) params.append('ordering', filters.ordering);
|
|
85
|
+
|
|
86
|
+
// tags is a multi-value param
|
|
87
|
+
if (filters?.tags?.length) {
|
|
88
|
+
for (const tag of filters.tags) {
|
|
89
|
+
params.append('tags', tag);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const query = params.toString();
|
|
94
|
+
const endpoint = query ? `${ENDPOINTS.FUNNELS}?${query}` : ENDPOINTS.FUNNELS;
|
|
95
|
+
return this.client.get<PaginatedResponse<Funnel>>(endpoint);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get funnel by ID
|
|
100
|
+
*/
|
|
101
|
+
async get(id: string): Promise<Funnel> {
|
|
102
|
+
return this.client.get<Funnel>(ENDPOINTS.FUNNEL(id));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create a new funnel
|
|
107
|
+
*/
|
|
108
|
+
async create(data: CreateFunnelInput): Promise<Funnel> {
|
|
109
|
+
return this.client.post<Funnel>(ENDPOINTS.FUNNELS, data);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Update funnel (partial)
|
|
114
|
+
*/
|
|
115
|
+
async update(id: string, data: UpdateFunnelInput): Promise<Funnel> {
|
|
116
|
+
return this.client.patch<Funnel>(ENDPOINTS.FUNNEL(id), data);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Delete funnel
|
|
121
|
+
*/
|
|
122
|
+
async delete(id: string): Promise<void> {
|
|
123
|
+
return this.client.delete<void>(ENDPOINTS.FUNNEL(id));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Execute a funnel run
|
|
128
|
+
*/
|
|
129
|
+
async run(id: string, input?: ExecuteFunnelInput): Promise<FunnelRun> {
|
|
130
|
+
return this.client.post<FunnelRun>(ENDPOINTS.FUNNEL_RUN(id), input ?? {});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* List run history for a funnel
|
|
135
|
+
*/
|
|
136
|
+
async getRuns(
|
|
137
|
+
funnelId: string,
|
|
138
|
+
filters?: FunnelRunFilters
|
|
139
|
+
): Promise<PaginatedResponse<FunnelRun>> {
|
|
140
|
+
const params = new URLSearchParams();
|
|
141
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
142
|
+
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
|
143
|
+
if (filters?.status) params.append('status', filters.status);
|
|
144
|
+
if (filters?.ordering) params.append('ordering', filters.ordering);
|
|
145
|
+
|
|
146
|
+
const query = params.toString();
|
|
147
|
+
const endpoint = query
|
|
148
|
+
? `${ENDPOINTS.FUNNEL_RUNS(funnelId)}?${query}`
|
|
149
|
+
: ENDPOINTS.FUNNEL_RUNS(funnelId);
|
|
150
|
+
return this.client.get<PaginatedResponse<FunnelRun>>(endpoint);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get a specific run by ID. funnelId param is ignored (runs are accessed globally).
|
|
155
|
+
*/
|
|
156
|
+
async getRun(_funnelId: string, runId: string): Promise<FunnelRun> {
|
|
157
|
+
return this.client.get<FunnelRun>(ENDPOINTS.FUNNEL_RUN_BY_ID(runId));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// BEAD: fund-your-startup-rgi4 - funnels/{id}/results endpoint missing from Django FunnelViewSet
|
|
161
|
+
async getResults(
|
|
162
|
+
_funnelId: string,
|
|
163
|
+
_pagination?: PaginationParams
|
|
164
|
+
): Promise<PaginatedResponse<FunnelResult>> {
|
|
165
|
+
throw new Error('Not implemented - BEAD: fund-your-startup-rgi4. funnels/{id}/results action does not exist in Django.');
|
|
166
|
+
// const params = new URLSearchParams();
|
|
167
|
+
// if (_pagination?.page) params.append('page', String(_pagination.page));
|
|
168
|
+
// if (_pagination?.pageSize) params.append('pageSize', String(_pagination.pageSize));
|
|
169
|
+
// const query = params.toString();
|
|
170
|
+
// const endpoint = query ? `${ENDPOINTS.FUNNEL_RESULTS(_funnelId)}?${query}` : ENDPOINTS.FUNNEL_RESULTS(_funnelId);
|
|
171
|
+
// return this.client.get<PaginatedResponse<FunnelResult>>(endpoint);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// BEAD: fund-your-startup-rgi4 - funnels/{id}/preview endpoint missing. Django has /funnels/preview-icp/ (different signature).
|
|
175
|
+
async preview(_funnelId: string, _stages?: Funnel['stages']): Promise<FunnelPreviewResult> {
|
|
176
|
+
throw new Error('Not implemented - BEAD: fund-your-startup-rgi4. funnels/{id}/preview action does not exist in Django.');
|
|
177
|
+
// return this.client.post<FunnelPreviewResult>(ENDPOINTS.FUNNEL_PREVIEW(_funnelId), { stages: _stages });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* List all funnel runs globally (across all funnels, scoped to current user)
|
|
182
|
+
*/
|
|
183
|
+
async listRunsGlobal(filters?: FunnelRunFilters): Promise<PaginatedResponse<FunnelRun>> {
|
|
184
|
+
const params = new URLSearchParams();
|
|
185
|
+
if (filters?.funnel) params.append('funnel', filters.funnel);
|
|
186
|
+
if (filters?.status) params.append('status', filters.status);
|
|
187
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
188
|
+
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
|
189
|
+
if (filters?.startedAfter) params.append('startedAfter', filters.startedAfter);
|
|
190
|
+
if (filters?.startedBefore) params.append('startedBefore', filters.startedBefore);
|
|
191
|
+
|
|
192
|
+
const query = params.toString();
|
|
193
|
+
const endpoint = query
|
|
194
|
+
? `${ENDPOINTS.FUNNEL_RUNS_GLOBAL}?${query}`
|
|
195
|
+
: ENDPOINTS.FUNNEL_RUNS_GLOBAL;
|
|
196
|
+
return this.client.get<PaginatedResponse<FunnelRun>>(endpoint);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Cancel a running funnel run.
|
|
201
|
+
*/
|
|
202
|
+
async cancelRun(runId: string): Promise<FunnelRun> {
|
|
203
|
+
return this.client.post<FunnelRun>(ENDPOINTS.FUNNEL_RUN_CANCEL(runId), {});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* List available funnel templates
|
|
208
|
+
*/
|
|
209
|
+
async listTemplates(filters?: { category?: string; page?: number; pageSize?: number }): Promise<PaginatedResponse<FunnelTemplate>> {
|
|
210
|
+
const params = new URLSearchParams();
|
|
211
|
+
if (filters?.category) params.append('category', filters.category);
|
|
212
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
213
|
+
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
|
214
|
+
|
|
215
|
+
const query = params.toString();
|
|
216
|
+
const endpoint = query
|
|
217
|
+
? `${ENDPOINTS.FUNNEL_TEMPLATES}?${query}`
|
|
218
|
+
: ENDPOINTS.FUNNEL_TEMPLATES;
|
|
219
|
+
return this.client.get<PaginatedResponse<FunnelTemplate>>(endpoint);
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/middleware/index.ts
CHANGED
|
@@ -10,3 +10,6 @@ export type { ErrorApiHandler, ErrorResponseBody } from './with-error-handling';
|
|
|
10
10
|
|
|
11
11
|
export { withValidation, composeMiddleware } from './with-validation';
|
|
12
12
|
export type { ValidatedApiHandler, ValidationSchemas } from './with-validation';
|
|
13
|
+
|
|
14
|
+
export { withRateLimit } from './with-rate-limit';
|
|
15
|
+
export type { RateLimitCheckFn, WithRateLimitOptions } from './with-rate-limit';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limit middleware for Next.js API routes.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a pre-configured rate-limiter function (from createRateLimiter)
|
|
5
|
+
* and a key extractor. Returns 429 when the limit is exceeded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NextRequest } from 'next/server'
|
|
9
|
+
import { NextResponse } from 'next/server'
|
|
10
|
+
import type { RateLimitResult } from '../lib/rate-limit'
|
|
11
|
+
|
|
12
|
+
export type RateLimitCheckFn = (key: string) => RateLimitResult
|
|
13
|
+
|
|
14
|
+
export interface WithRateLimitOptions {
|
|
15
|
+
/** Extract the rate limit key from the request (e.g. user ID or IP) */
|
|
16
|
+
getKey: (request: NextRequest) => string | Promise<string>
|
|
17
|
+
/** Human-readable message returned in the 429 error body */
|
|
18
|
+
message?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function withRateLimit<T = unknown>(
|
|
22
|
+
handler: (request: NextRequest) => Promise<NextResponse<T>> | NextResponse<T>,
|
|
23
|
+
limiter: RateLimitCheckFn,
|
|
24
|
+
options: WithRateLimitOptions
|
|
25
|
+
): (request: NextRequest) => Promise<NextResponse<T>> {
|
|
26
|
+
return async (request: NextRequest) => {
|
|
27
|
+
const key = await options.getKey(request)
|
|
28
|
+
const result = limiter(key)
|
|
29
|
+
|
|
30
|
+
if (!result.success) {
|
|
31
|
+
const retryAfter = result.retryAfter ?? Math.ceil((result.resetAt - Date.now()) / 1000)
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{
|
|
34
|
+
success: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: 'RATE_LIMITED',
|
|
37
|
+
message: options.message ?? 'Rate limit exceeded. Please try again later.',
|
|
38
|
+
retryable: true,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
status: 429,
|
|
43
|
+
headers: {
|
|
44
|
+
'Retry-After': String(retryAfter),
|
|
45
|
+
'X-RateLimit-Remaining': '0',
|
|
46
|
+
'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
) as NextResponse<T>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return handler(request)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Django REST Framework response transformations
|
|
3
|
+
*
|
|
4
|
+
* Normalizes DRF paginated responses into a frontend-friendly shape.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Raw DRF paginated response shape
|
|
9
|
+
*/
|
|
10
|
+
export interface DRFPaginatedResponse<T> {
|
|
11
|
+
count: number;
|
|
12
|
+
next: string | null;
|
|
13
|
+
previous: string | null;
|
|
14
|
+
results: T[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalized paginated response for frontend consumption
|
|
19
|
+
*/
|
|
20
|
+
export interface NormalizedPaginatedResponse<T> {
|
|
21
|
+
items: T[];
|
|
22
|
+
total: number;
|
|
23
|
+
page: number;
|
|
24
|
+
pageSize: number;
|
|
25
|
+
hasNext: boolean;
|
|
26
|
+
hasPrev: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize a DRF paginated response into a frontend-friendly format.
|
|
31
|
+
*
|
|
32
|
+
* @param response - Raw DRF paginated response
|
|
33
|
+
* @param page - Current page number (1-based)
|
|
34
|
+
* @param pageSize - Items per page
|
|
35
|
+
*/
|
|
36
|
+
export function normalizePaginated<T>(
|
|
37
|
+
response: DRFPaginatedResponse<T>,
|
|
38
|
+
page: number,
|
|
39
|
+
pageSize: number
|
|
40
|
+
): NormalizedPaginatedResponse<T> {
|
|
41
|
+
const total = response.count || 0;
|
|
42
|
+
return {
|
|
43
|
+
items: response.results || [],
|
|
44
|
+
total,
|
|
45
|
+
page,
|
|
46
|
+
pageSize,
|
|
47
|
+
hasNext: response.next !== null,
|
|
48
|
+
hasPrev: response.previous !== null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if a response is a DRF paginated response
|
|
54
|
+
*/
|
|
55
|
+
export function isDRFPaginatedResponse<T = unknown>(
|
|
56
|
+
response: unknown
|
|
57
|
+
): response is DRFPaginatedResponse<T> {
|
|
58
|
+
return (
|
|
59
|
+
typeof response === 'object' &&
|
|
60
|
+
response !== null &&
|
|
61
|
+
'results' in response &&
|
|
62
|
+
'count' in response
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Entity Query Builder
|
|
3
|
+
*
|
|
4
|
+
* Builds server-side query parameters for entity filtering.
|
|
5
|
+
* Supports tags, metrics, profiles, attributes, pagination, search, sorting, and date ranges.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const params = new EntityQueryBuilder()
|
|
9
|
+
* .withTags('quality:tier_1', 'status:prospect')
|
|
10
|
+
* .withMetrics('financial:aum__gte:100000000')
|
|
11
|
+
* .paginate(1, 25)
|
|
12
|
+
* .search('acme')
|
|
13
|
+
* .sort('name', 'asc')
|
|
14
|
+
* .build()
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class EntityQueryBuilder {
|
|
18
|
+
private params: Map<string, string> = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Filter by entity type
|
|
22
|
+
*/
|
|
23
|
+
entityType(type: 'CONTACT' | 'ORGANIZATION'): this {
|
|
24
|
+
this.params.set('entity_type', type);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Add tag filters (compact format: "category:name")
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* .withTags('quality:tier_1', 'status:prospect')
|
|
33
|
+
* // produces: tags=quality:tier_1,status:prospect
|
|
34
|
+
*/
|
|
35
|
+
withTags(...tags: string[]): this {
|
|
36
|
+
if (tags.length > 0) {
|
|
37
|
+
const existing = this.params.get('tags');
|
|
38
|
+
const combined = existing ? `${existing},${tags.join(',')}` : tags.join(',');
|
|
39
|
+
this.params.set('tags', combined);
|
|
40
|
+
}
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Add metric filters (compact format: "type:subtype__operator:value")
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* .withMetrics('financial:aum__gte:100000000', 'check_size:min__gte:1000000')
|
|
49
|
+
*/
|
|
50
|
+
withMetrics(...metrics: string[]): this {
|
|
51
|
+
if (metrics.length > 0) {
|
|
52
|
+
const existing = this.params.get('metrics');
|
|
53
|
+
const combined = existing ? `${existing},${metrics.join(',')}` : metrics.join(',');
|
|
54
|
+
this.params.set('metrics', combined);
|
|
55
|
+
}
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Add profile filters (compact format: "type:subtype")
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* .withProfiles('professional:linkedin', 'social:twitter')
|
|
64
|
+
*/
|
|
65
|
+
withProfiles(...profiles: string[]): this {
|
|
66
|
+
if (profiles.length > 0) {
|
|
67
|
+
const existing = this.params.get('profiles');
|
|
68
|
+
const combined = existing ? `${existing},${profiles.join(',')}` : profiles.join(',');
|
|
69
|
+
this.params.set('profiles', combined);
|
|
70
|
+
}
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Add attribute filters (compact format: "type:subtype:value")
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* .withAttributes('demographic:location:san_francisco')
|
|
79
|
+
*/
|
|
80
|
+
withAttributes(...attrs: string[]): this {
|
|
81
|
+
if (attrs.length > 0) {
|
|
82
|
+
const existing = this.params.get('attributes');
|
|
83
|
+
const combined = existing ? `${existing},${attrs.join(',')}` : attrs.join(',');
|
|
84
|
+
this.params.set('attributes', combined);
|
|
85
|
+
}
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set pagination parameters
|
|
91
|
+
*/
|
|
92
|
+
paginate(page: number, pageSize: number): this {
|
|
93
|
+
this.params.set('page', String(page));
|
|
94
|
+
this.params.set('page_size', String(pageSize));
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Set search query
|
|
100
|
+
*/
|
|
101
|
+
search(query: string): this {
|
|
102
|
+
const trimmed = query.trim();
|
|
103
|
+
if (trimmed) {
|
|
104
|
+
this.params.set('search', trimmed);
|
|
105
|
+
}
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Set sort field and direction (maps to Django's `ordering` param)
|
|
111
|
+
*/
|
|
112
|
+
sort(field: string, direction: 'asc' | 'desc' = 'asc'): this {
|
|
113
|
+
const prefix = direction === 'desc' ? '-' : '';
|
|
114
|
+
this.params.set('ordering', `${prefix}${field}`);
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Add a date range filter on a given field
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* .withDateRange('created', new Date('2024-01-01'), new Date('2024-12-31'))
|
|
123
|
+
* // produces: created_after=2024-01-01&created_before=2024-12-31
|
|
124
|
+
*/
|
|
125
|
+
withDateRange(field: string, from?: Date, to?: Date): this {
|
|
126
|
+
if (from) {
|
|
127
|
+
this.params.set(`${field}_after`, from.toISOString().split('T')[0]);
|
|
128
|
+
}
|
|
129
|
+
if (to) {
|
|
130
|
+
this.params.set(`${field}_before`, to.toISOString().split('T')[0]);
|
|
131
|
+
}
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Set an arbitrary query parameter
|
|
137
|
+
*/
|
|
138
|
+
param(key: string, value: string): this {
|
|
139
|
+
this.params.set(key, value);
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build as a plain object (suitable for URLSearchParams or fetch helpers)
|
|
145
|
+
*/
|
|
146
|
+
build(): Record<string, string> {
|
|
147
|
+
const result: Record<string, string> = {};
|
|
148
|
+
this.params.forEach((value, key) => {
|
|
149
|
+
result[key] = value;
|
|
150
|
+
});
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build as a query string (includes leading `?`)
|
|
156
|
+
* Returns empty string if no params.
|
|
157
|
+
*/
|
|
158
|
+
toQueryString(): string {
|
|
159
|
+
if (this.params.size === 0) return '';
|
|
160
|
+
const searchParams = new URLSearchParams();
|
|
161
|
+
this.params.forEach((value, key) => {
|
|
162
|
+
searchParams.set(key, value);
|
|
163
|
+
});
|
|
164
|
+
return `?${searchParams.toString()}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reset all parameters
|
|
169
|
+
*/
|
|
170
|
+
clear(): this {
|
|
171
|
+
this.params.clear();
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Utility exports for @startsimpli/api
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export { buildUrl, buildQueryString, normalizeId } from './url-builder';
|
|
5
|
+
export { buildUrl, buildQueryString, normalizeId, resolveApiUrl } from './url-builder';
|
|
6
6
|
export type { UrlBuilderOptions } from './url-builder';
|
|
7
7
|
|
|
8
8
|
export {
|
|
@@ -11,3 +11,13 @@ export {
|
|
|
11
11
|
mergeQueryParams,
|
|
12
12
|
} from './query-params';
|
|
13
13
|
export type { DjangoFilterParams } from './query-params';
|
|
14
|
+
|
|
15
|
+
export { validateApiResponse } from './validate-response';
|
|
16
|
+
|
|
17
|
+
export { EntityQueryBuilder } from './entity-query-builder';
|
|
18
|
+
|
|
19
|
+
export { normalizePaginated, isDRFPaginatedResponse } from './drf-transforms';
|
|
20
|
+
export type {
|
|
21
|
+
DRFPaginatedResponse,
|
|
22
|
+
NormalizedPaginatedResponse,
|
|
23
|
+
} from './drf-transforms';
|
package/src/utils/url-builder.ts
CHANGED
|
@@ -66,6 +66,33 @@ export function buildQueryString(params: Record<string, unknown>): string {
|
|
|
66
66
|
return searchParams.toString();
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a full or partial API path to a final URL.
|
|
71
|
+
*
|
|
72
|
+
* Handles four cases:
|
|
73
|
+
* - Absolute input URL → returned unchanged
|
|
74
|
+
* - Absolute baseUrl → joined via URL constructor
|
|
75
|
+
* - Relative baseUrl → string concatenation
|
|
76
|
+
* - Empty / undefined baseUrl → path returned as-is (suits Next.js proxy pattern)
|
|
77
|
+
*/
|
|
78
|
+
export function resolveApiUrl(path: string, baseUrl: string = ''): string {
|
|
79
|
+
// Already a full URL — pass through unchanged
|
|
80
|
+
if (/^https?:\/\//i.test(path)) return path;
|
|
81
|
+
|
|
82
|
+
const normalized = path.startsWith('/') ? path : `/${path}`;
|
|
83
|
+
|
|
84
|
+
if (!baseUrl) return normalized;
|
|
85
|
+
|
|
86
|
+
// Absolute base: use URL constructor for correct joining
|
|
87
|
+
if (/^https?:\/\//i.test(baseUrl)) {
|
|
88
|
+
return new URL(normalized, baseUrl).toString();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Relative base: strip trailing slash, prepend
|
|
92
|
+
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
93
|
+
return `${cleanBase}${normalized}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
69
96
|
/**
|
|
70
97
|
* Extract ID from URL or return as-is
|
|
71
98
|
*/
|