@startsimpli/api 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/README.md +329 -0
- package/package.json +42 -0
- package/src/__tests__/jwt-refresh.test.ts +195 -0
- package/src/__tests__/query-params.test.ts +144 -0
- package/src/__tests__/url-builder.test.ts +121 -0
- package/src/constants/endpoints.ts +39 -0
- package/src/index.ts +109 -0
- package/src/lib/api-client.ts +89 -0
- package/src/lib/contacts-api.ts +111 -0
- package/src/lib/cors.ts +122 -0
- package/src/lib/entities-api.ts +123 -0
- package/src/lib/env.ts +35 -0
- package/src/lib/error-handler.ts +138 -0
- package/src/lib/errors.ts +381 -0
- package/src/lib/fetch-wrapper.ts +188 -0
- package/src/lib/llm-sanitize.ts +145 -0
- package/src/lib/messages-api.ts +273 -0
- package/src/lib/messages-api.ts.backup +273 -0
- package/src/lib/organizations-api.ts +132 -0
- package/src/lib/rate-limit.ts +91 -0
- package/src/lib/sanitize.ts +39 -0
- package/src/lib/workflows-api.ts +159 -0
- package/src/middleware/index.ts +12 -0
- package/src/middleware/with-auth.ts +90 -0
- package/src/middleware/with-error-handling.ts +83 -0
- package/src/middleware/with-validation.ts +110 -0
- package/src/types/api.ts +38 -0
- package/src/types/contact.ts +49 -0
- package/src/types/entity.ts +153 -0
- package/src/types/error.ts +129 -0
- package/src/types/funnel.ts +133 -0
- package/src/types/index.ts +95 -0
- package/src/types/organization.ts +49 -0
- package/src/types/response.ts +44 -0
- package/src/types/workflow.ts +69 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/query-params.ts +79 -0
- package/src/utils/url-builder.ts +78 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for URL builder utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { buildUrl, buildQueryString, normalizeId } from '../utils/url-builder';
|
|
7
|
+
|
|
8
|
+
describe('buildUrl', () => {
|
|
9
|
+
it('should build URL with base and endpoint', () => {
|
|
10
|
+
const url = buildUrl({
|
|
11
|
+
baseUrl: 'http://localhost:8000/api/v1',
|
|
12
|
+
endpoint: 'contacts',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(url).toBe('http://localhost:8000/api/v1/contacts/');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should handle trailing slashes correctly', () => {
|
|
19
|
+
const url = buildUrl({
|
|
20
|
+
baseUrl: 'http://localhost:8000/api/v1/',
|
|
21
|
+
endpoint: '/contacts/',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(url).toBe('http://localhost:8000/api/v1/contacts/');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should build URL with ID', () => {
|
|
28
|
+
const url = buildUrl({
|
|
29
|
+
baseUrl: 'http://localhost:8000/api/v1',
|
|
30
|
+
endpoint: 'contacts',
|
|
31
|
+
id: '123',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(url).toBe('http://localhost:8000/api/v1/contacts/123/');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should build URL with query params', () => {
|
|
38
|
+
const url = buildUrl({
|
|
39
|
+
baseUrl: 'http://localhost:8000/api/v1',
|
|
40
|
+
endpoint: 'contacts',
|
|
41
|
+
params: { page: 1, page_size: 20, tier: 1 },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(url).toContain('http://localhost:8000/api/v1/contacts/?');
|
|
45
|
+
expect(url).toContain('page=1');
|
|
46
|
+
expect(url).toContain('page_size=20');
|
|
47
|
+
expect(url).toContain('tier=1');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should build URL with ID and params', () => {
|
|
51
|
+
const url = buildUrl({
|
|
52
|
+
baseUrl: 'http://localhost:8000/api/v1',
|
|
53
|
+
endpoint: 'contacts',
|
|
54
|
+
id: '123',
|
|
55
|
+
params: { include_tags: true },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(url).toContain('http://localhost:8000/api/v1/contacts/123/?');
|
|
59
|
+
expect(url).toContain('include_tags=true');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('buildQueryString', () => {
|
|
64
|
+
it('should build query string from params', () => {
|
|
65
|
+
const query = buildQueryString({
|
|
66
|
+
page: 1,
|
|
67
|
+
page_size: 20,
|
|
68
|
+
search: 'test',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(query).toContain('page=1');
|
|
72
|
+
expect(query).toContain('page_size=20');
|
|
73
|
+
expect(query).toContain('search=test');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should skip undefined and null values', () => {
|
|
77
|
+
const query = buildQueryString({
|
|
78
|
+
page: 1,
|
|
79
|
+
search: undefined,
|
|
80
|
+
filter: null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(query).toBe('page=1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should handle array params', () => {
|
|
87
|
+
const query = buildQueryString({
|
|
88
|
+
tags: ['tag1', 'tag2', 'tag3'],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(query).toContain('tags=tag1');
|
|
92
|
+
expect(query).toContain('tags=tag2');
|
|
93
|
+
expect(query).toContain('tags=tag3');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should serialize object params as JSON', () => {
|
|
97
|
+
const query = buildQueryString({
|
|
98
|
+
filter: { tier: 1, status: 'active' },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const decoded = decodeURIComponent(query);
|
|
102
|
+
expect(decoded).toContain('filter=');
|
|
103
|
+
expect(decoded).toContain('"tier":1');
|
|
104
|
+
expect(decoded).toContain('"status":"active"');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('normalizeId', () => {
|
|
109
|
+
it('should return number ID as-is', () => {
|
|
110
|
+
expect(normalizeId(123)).toBe(123);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return string ID as-is', () => {
|
|
114
|
+
expect(normalizeId('abc-123')).toBe('abc-123');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should extract ID from URL', () => {
|
|
118
|
+
expect(normalizeId('/api/v1/contacts/123/')).toBe('123');
|
|
119
|
+
expect(normalizeId('http://localhost/api/v1/contacts/abc-123/')).toBe('abc-123');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Django REST API endpoint constants
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const ENDPOINTS = {
|
|
6
|
+
// Contacts
|
|
7
|
+
CONTACTS: 'contacts',
|
|
8
|
+
CONTACTS_BULK: 'contacts/bulk',
|
|
9
|
+
CONTACT: (id: string) => `contacts/${id}`,
|
|
10
|
+
|
|
11
|
+
// Organizations
|
|
12
|
+
ORGANIZATIONS: 'organizations',
|
|
13
|
+
ORGANIZATIONS_BULK: 'organizations/bulk',
|
|
14
|
+
ORGANIZATION: (id: string) => `organizations/${id}`,
|
|
15
|
+
|
|
16
|
+
// Core entities
|
|
17
|
+
TAGS: 'core/tags',
|
|
18
|
+
TAG: (id: string) => `core/tags/${id}`,
|
|
19
|
+
ENTITY_TAGS: 'core/entity-tags',
|
|
20
|
+
ENTITY_TAG: (id: string) => `core/entity-tags/${id}`,
|
|
21
|
+
METRICS: 'core/metrics',
|
|
22
|
+
METRIC: (id: string) => `core/metrics/${id}`,
|
|
23
|
+
PROFILES: 'core/profiles',
|
|
24
|
+
PROFILE: (id: string) => `core/profiles/${id}`,
|
|
25
|
+
ATTRIBUTES: 'core/attributes',
|
|
26
|
+
ATTRIBUTE: (id: string) => `core/attributes/${id}`,
|
|
27
|
+
RELATIONSHIPS: 'core/relationships',
|
|
28
|
+
RELATIONSHIP: (id: string) => `core/relationships/${id}`,
|
|
29
|
+
|
|
30
|
+
// Workflows
|
|
31
|
+
WORKFLOWS: 'workflows',
|
|
32
|
+
WORKFLOW: (id: string) => `workflows/${id}`,
|
|
33
|
+
WORKFLOW_EXECUTE: (id: string) => `workflows/${id}/execute`,
|
|
34
|
+
|
|
35
|
+
// Messages
|
|
36
|
+
MESSAGES: 'messages',
|
|
37
|
+
MESSAGE: (id: string) => `messages/${id}`,
|
|
38
|
+
MESSAGE_SEND: (id: string) => `messages/${id}/send`,
|
|
39
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/api - Type-safe Django REST API client
|
|
3
|
+
*
|
|
4
|
+
* This package provides type-safe access to the Django backend API.
|
|
5
|
+
* NO Prisma, NO database code - all data lives in Django.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Core client
|
|
9
|
+
export { ApiClient, createApiClient } from './lib/api-client';
|
|
10
|
+
export type { ApiClientConfig } from './lib/api-client';
|
|
11
|
+
|
|
12
|
+
// API wrappers
|
|
13
|
+
export { ContactsApi } from './lib/contacts-api';
|
|
14
|
+
export { OrganizationsApi } from './lib/organizations-api';
|
|
15
|
+
export { EntitiesApi } from './lib/entities-api';
|
|
16
|
+
export { WorkflowsApi } from './lib/workflows-api';
|
|
17
|
+
export { MessagesApi } from './lib/messages-api';
|
|
18
|
+
export type { Message, MessageStatus as MessageApiStatus, MessageRecipient, MessagingChannel as MessagingChannelType } from './lib/messages-api';
|
|
19
|
+
|
|
20
|
+
// Fetch wrapper
|
|
21
|
+
export { FetchWrapper } from './lib/fetch-wrapper';
|
|
22
|
+
export type { FetchWrapperConfig } from './lib/fetch-wrapper';
|
|
23
|
+
|
|
24
|
+
// Error handling (API client errors — fetch/HTTP layer)
|
|
25
|
+
export {
|
|
26
|
+
ApiException,
|
|
27
|
+
parseErrorResponse,
|
|
28
|
+
handleFetchError,
|
|
29
|
+
isApiException,
|
|
30
|
+
isValidationError,
|
|
31
|
+
isAuthError,
|
|
32
|
+
isNotFoundError,
|
|
33
|
+
} from './lib/error-handler';
|
|
34
|
+
|
|
35
|
+
// Application error hierarchy (server-side domain errors)
|
|
36
|
+
export {
|
|
37
|
+
AppErrorCode,
|
|
38
|
+
AppError,
|
|
39
|
+
ValidationError,
|
|
40
|
+
AuthenticationError,
|
|
41
|
+
AuthorizationError,
|
|
42
|
+
NotFoundError,
|
|
43
|
+
ConflictError,
|
|
44
|
+
RateLimitError,
|
|
45
|
+
DatabaseError,
|
|
46
|
+
ExternalServiceError,
|
|
47
|
+
isPrismaError,
|
|
48
|
+
toAppError,
|
|
49
|
+
} from './lib/errors';
|
|
50
|
+
export type { AppErrorResponse } from './lib/errors';
|
|
51
|
+
|
|
52
|
+
// Sanitization utilities
|
|
53
|
+
export { sanitizeHtml, sanitizeSearchQuery, validateIdentifier } from './lib/sanitize';
|
|
54
|
+
|
|
55
|
+
// LLM prompt sanitization
|
|
56
|
+
export {
|
|
57
|
+
sanitizeUserInput,
|
|
58
|
+
sanitizeChatMessage,
|
|
59
|
+
MAX_PROMPT_LENGTH,
|
|
60
|
+
MAX_CHAT_MESSAGE_LENGTH,
|
|
61
|
+
} from './lib/llm-sanitize';
|
|
62
|
+
|
|
63
|
+
// CORS utilities (framework-agnostic, safe to import anywhere)
|
|
64
|
+
export { getCorsHeaders, applyCorsHeaders, createCorsMiddleware } from './lib/cors';
|
|
65
|
+
export type { CorsOptions } from './lib/cors';
|
|
66
|
+
|
|
67
|
+
// NOTE: Middleware is NOT exported from main entry to avoid Next.js server dependencies in tests
|
|
68
|
+
// Import middleware explicitly via '@startsimpli/api/middleware'
|
|
69
|
+
|
|
70
|
+
// Types
|
|
71
|
+
export * from './types';
|
|
72
|
+
|
|
73
|
+
// Rate limiting
|
|
74
|
+
export { createRateLimiter, getClientIP } from './lib/rate-limit';
|
|
75
|
+
export type { RateLimitOptions, RateLimitResult } from './lib/rate-limit';
|
|
76
|
+
|
|
77
|
+
// Utils
|
|
78
|
+
export * from './utils';
|
|
79
|
+
|
|
80
|
+
// Constants
|
|
81
|
+
export { ENDPOINTS } from './constants/endpoints';
|
|
82
|
+
|
|
83
|
+
// Environment variable utilities
|
|
84
|
+
export { getRequiredEnv, getOptionalEnv, validateEnvVars } from './lib/env';
|
|
85
|
+
|
|
86
|
+
import { createApiClient } from './lib/api-client';
|
|
87
|
+
import { ContactsApi } from './lib/contacts-api';
|
|
88
|
+
import { OrganizationsApi } from './lib/organizations-api';
|
|
89
|
+
import { EntitiesApi } from './lib/entities-api';
|
|
90
|
+
import { WorkflowsApi } from './lib/workflows-api';
|
|
91
|
+
import { MessagesApi } from './lib/messages-api';
|
|
92
|
+
|
|
93
|
+
import type { ApiClientConfig } from './lib/api-client';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a complete API client with all endpoints
|
|
97
|
+
*/
|
|
98
|
+
export function createStartSimpliApi(config: ApiClientConfig) {
|
|
99
|
+
const client = createApiClient(config);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
client,
|
|
103
|
+
contacts: new ContactsApi(client),
|
|
104
|
+
organizations: new OrganizationsApi(client),
|
|
105
|
+
entities: new EntitiesApi(client),
|
|
106
|
+
workflows: new WorkflowsApi(client),
|
|
107
|
+
messages: new MessagesApi(client),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main API client for Django REST API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { FetchWrapper, type FetchWrapperConfig } from './fetch-wrapper';
|
|
6
|
+
|
|
7
|
+
export interface ApiClientConfig {
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
getToken?: () => Promise<string | null> | string | null;
|
|
10
|
+
onUnauthorized?: () => void;
|
|
11
|
+
onTokenRefresh?: () => Promise<string | null>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ApiClient {
|
|
15
|
+
private fetcher: FetchWrapper;
|
|
16
|
+
public readonly baseUrl: string;
|
|
17
|
+
|
|
18
|
+
constructor(config: ApiClientConfig) {
|
|
19
|
+
this.baseUrl = config.baseUrl;
|
|
20
|
+
|
|
21
|
+
this.fetcher = new FetchWrapper({
|
|
22
|
+
baseUrl: config.baseUrl,
|
|
23
|
+
getToken: config.getToken,
|
|
24
|
+
onUnauthorized: config.onUnauthorized,
|
|
25
|
+
onTokenRefresh: config.onTokenRefresh,
|
|
26
|
+
defaultHeaders: {
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
'Accept': 'application/json',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the fetch wrapper instance for direct access
|
|
35
|
+
*/
|
|
36
|
+
get fetch(): FetchWrapper {
|
|
37
|
+
return this.fetcher;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Update auth token getter
|
|
42
|
+
*/
|
|
43
|
+
setTokenGetter(getToken: () => Promise<string | null> | string | null): void {
|
|
44
|
+
this.fetcher = new FetchWrapper({
|
|
45
|
+
baseUrl: this.baseUrl,
|
|
46
|
+
getToken,
|
|
47
|
+
onUnauthorized: this.fetcher['config'].onUnauthorized,
|
|
48
|
+
defaultHeaders: this.fetcher['config'].defaultHeaders,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Update unauthorized handler
|
|
54
|
+
*/
|
|
55
|
+
setUnauthorizedHandler(onUnauthorized: () => void): void {
|
|
56
|
+
this.fetcher = new FetchWrapper({
|
|
57
|
+
baseUrl: this.baseUrl,
|
|
58
|
+
getToken: this.fetcher['config'].getToken,
|
|
59
|
+
onUnauthorized,
|
|
60
|
+
defaultHeaders: this.fetcher['config'].defaultHeaders,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convenience HTTP methods that delegate to FetchWrapper
|
|
66
|
+
*/
|
|
67
|
+
async get<T>(endpoint: string, options?: any): Promise<T> {
|
|
68
|
+
return this.fetcher.get<T>(endpoint, options);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async post<T, D = unknown>(endpoint: string, data?: D, options?: any): Promise<T> {
|
|
72
|
+
return this.fetcher.post<T, D>(endpoint, data, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async patch<T, D = unknown>(endpoint: string, data?: D, options?: any): Promise<T> {
|
|
76
|
+
return this.fetcher.patch<T, D>(endpoint, data, options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async delete<T>(endpoint: string, options?: any): Promise<T> {
|
|
80
|
+
return this.fetcher.delete<T>(endpoint, options);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create API client instance
|
|
86
|
+
*/
|
|
87
|
+
export function createApiClient(config: ApiClientConfig): ApiClient {
|
|
88
|
+
return new ApiClient(config);
|
|
89
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contacts API wrapper for /api/v1/contacts/
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Contact,
|
|
7
|
+
CreateContactRequest,
|
|
8
|
+
UpdateContactRequest,
|
|
9
|
+
ContactFilters,
|
|
10
|
+
PaginatedResponse,
|
|
11
|
+
PaginationParams,
|
|
12
|
+
SortParams,
|
|
13
|
+
} from '../types';
|
|
14
|
+
import { buildFilterParams, mergeQueryParams } from '../utils';
|
|
15
|
+
import { ENDPOINTS } from '../constants/endpoints';
|
|
16
|
+
import type { ApiClient } from './api-client';
|
|
17
|
+
|
|
18
|
+
export class ContactsApi {
|
|
19
|
+
constructor(private client: ApiClient) {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* List contacts with pagination and filters
|
|
23
|
+
*/
|
|
24
|
+
async list(
|
|
25
|
+
filters?: ContactFilters,
|
|
26
|
+
pagination?: PaginationParams,
|
|
27
|
+
sorting?: SortParams
|
|
28
|
+
): Promise<PaginatedResponse<Contact>> {
|
|
29
|
+
const params = mergeQueryParams(
|
|
30
|
+
pagination,
|
|
31
|
+
sorting,
|
|
32
|
+
filters ? buildFilterParams(filters) : undefined
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return this.client.fetch.get<PaginatedResponse<Contact>>(ENDPOINTS.CONTACTS, { params });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get contact by ID
|
|
40
|
+
*/
|
|
41
|
+
async get(id: string): Promise<Contact> {
|
|
42
|
+
return this.client.fetch.get<Contact>(ENDPOINTS.CONTACT(id));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create new contact
|
|
47
|
+
*/
|
|
48
|
+
async create(data: CreateContactRequest): Promise<Contact> {
|
|
49
|
+
return this.client.fetch.post<Contact>(ENDPOINTS.CONTACTS, data);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Update contact
|
|
54
|
+
*/
|
|
55
|
+
async update(id: string, data: UpdateContactRequest): Promise<Contact> {
|
|
56
|
+
return this.client.fetch.patch<Contact>(ENDPOINTS.CONTACT(id), data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Delete contact
|
|
61
|
+
*/
|
|
62
|
+
async delete(id: string): Promise<void> {
|
|
63
|
+
return this.client.fetch.delete<void>(ENDPOINTS.CONTACT(id));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bulk create contacts
|
|
68
|
+
*/
|
|
69
|
+
async bulkCreate(data: CreateContactRequest[]): Promise<Contact[]> {
|
|
70
|
+
return this.client.fetch.post<Contact[]>(ENDPOINTS.CONTACTS_BULK, data);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Search contacts by name, email, or company
|
|
75
|
+
*/
|
|
76
|
+
async search(
|
|
77
|
+
query: string,
|
|
78
|
+
pagination?: PaginationParams
|
|
79
|
+
): Promise<PaginatedResponse<Contact>> {
|
|
80
|
+
return this.list({ search: query }, pagination);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get contacts by tier
|
|
85
|
+
*/
|
|
86
|
+
async getByTier(
|
|
87
|
+
tier: number,
|
|
88
|
+
pagination?: PaginationParams
|
|
89
|
+
): Promise<PaginatedResponse<Contact>> {
|
|
90
|
+
return this.list({ tier }, pagination);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get contacts by firm
|
|
95
|
+
*/
|
|
96
|
+
async getByFirm(
|
|
97
|
+
firmId: string,
|
|
98
|
+
pagination?: PaginationParams
|
|
99
|
+
): Promise<PaginatedResponse<Contact>> {
|
|
100
|
+
return this.list({ firmId }, pagination);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get contacts with LinkedIn profiles
|
|
105
|
+
*/
|
|
106
|
+
async getWithLinkedIn(
|
|
107
|
+
pagination?: PaginationParams
|
|
108
|
+
): Promise<PaginatedResponse<Contact>> {
|
|
109
|
+
return this.list({ hasLinkedin: true }, pagination);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/lib/cors.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS utilities for Next.js API routes
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic header generation with an optional Next.js
|
|
5
|
+
* middleware wrapper for apps that need it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface CorsOptions {
|
|
9
|
+
/** Allowed origins. Use '*' for wildcard. */
|
|
10
|
+
origins: string[] | '*';
|
|
11
|
+
/** Allowed HTTP methods. Defaults to common REST methods. */
|
|
12
|
+
methods?: string[];
|
|
13
|
+
/** Allowed request headers. Defaults to common headers. */
|
|
14
|
+
headers?: string[];
|
|
15
|
+
/** Whether to allow credentials. Defaults to true when origins is a list. */
|
|
16
|
+
credentials?: boolean;
|
|
17
|
+
/** Preflight cache duration in seconds. Defaults to 86400 (24h). */
|
|
18
|
+
maxAge?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
|
|
22
|
+
const DEFAULT_HEADERS = ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'X-CSRF-Token'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Determine whether an incoming origin is permitted under the given options.
|
|
26
|
+
*/
|
|
27
|
+
function isAllowed(origin: string | null | undefined, options: CorsOptions): boolean {
|
|
28
|
+
if (!origin) return false;
|
|
29
|
+
if (options.origins === '*') return true;
|
|
30
|
+
return options.origins.includes(origin);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the CORS headers for a given request origin.
|
|
35
|
+
*
|
|
36
|
+
* Returns an empty object when the origin is not permitted so callers
|
|
37
|
+
* can decide whether to reject or silently omit the headers.
|
|
38
|
+
*/
|
|
39
|
+
export function getCorsHeaders(
|
|
40
|
+
origin: string | null | undefined,
|
|
41
|
+
options: CorsOptions
|
|
42
|
+
): Record<string, string> {
|
|
43
|
+
if (!isAllowed(origin, options)) {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const methods = options.methods ?? DEFAULT_METHODS;
|
|
48
|
+
const headers = options.headers ?? DEFAULT_HEADERS;
|
|
49
|
+
// credentials only make sense with an explicit origin list
|
|
50
|
+
const credentials = options.credentials ?? options.origins !== '*';
|
|
51
|
+
const maxAge = options.maxAge ?? 86400;
|
|
52
|
+
|
|
53
|
+
const result: Record<string, string> = {
|
|
54
|
+
'Access-Control-Allow-Origin': options.origins === '*' ? '*' : (origin as string),
|
|
55
|
+
'Access-Control-Allow-Methods': methods.join(', '),
|
|
56
|
+
'Access-Control-Allow-Headers': headers.join(', '),
|
|
57
|
+
'Access-Control-Max-Age': String(maxAge),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (credentials && options.origins !== '*') {
|
|
61
|
+
result['Access-Control-Allow-Credentials'] = 'true';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Apply CORS headers to an existing Headers object in-place.
|
|
69
|
+
*/
|
|
70
|
+
export function applyCorsHeaders(
|
|
71
|
+
responseHeaders: Headers,
|
|
72
|
+
origin: string | null | undefined,
|
|
73
|
+
options: CorsOptions
|
|
74
|
+
): void {
|
|
75
|
+
const corsHeaders = getCorsHeaders(origin, options);
|
|
76
|
+
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
77
|
+
responseHeaders.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a CORS middleware function for use in Next.js middleware.ts files.
|
|
83
|
+
*
|
|
84
|
+
* Returns a function that accepts a Request and returns a Response (for
|
|
85
|
+
* preflight OPTIONS) or null (for all other requests, letting the chain
|
|
86
|
+
* continue after headers are applied via getCorsHeaders).
|
|
87
|
+
*
|
|
88
|
+
* Usage in middleware.ts:
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* import { createCorsMiddleware } from '@startsimpli/api';
|
|
92
|
+
* import { NextResponse } from 'next/server';
|
|
93
|
+
*
|
|
94
|
+
* const corsMiddleware = createCorsMiddleware({
|
|
95
|
+
* origins: [process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'],
|
|
96
|
+
* });
|
|
97
|
+
*
|
|
98
|
+
* export function middleware(request: Request) {
|
|
99
|
+
* const preflightResponse = corsMiddleware(request);
|
|
100
|
+
* if (preflightResponse) return preflightResponse;
|
|
101
|
+
*
|
|
102
|
+
* const response = NextResponse.next();
|
|
103
|
+
* const origin = request.headers.get('origin');
|
|
104
|
+
* applyCorsHeaders(response.headers, origin, corsOptions);
|
|
105
|
+
* return response;
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export function createCorsMiddleware(options: CorsOptions) {
|
|
110
|
+
return function corsMiddleware(request: Request): Response | null {
|
|
111
|
+
const origin = request.headers.get('origin');
|
|
112
|
+
|
|
113
|
+
// Preflight: respond immediately with CORS headers and 204
|
|
114
|
+
if (request.method === 'OPTIONS') {
|
|
115
|
+
const corsHeaders = getCorsHeaders(origin, options);
|
|
116
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Non-preflight: caller is responsible for attaching headers via getCorsHeaders
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
}
|