@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.
Files changed (38) hide show
  1. package/README.md +329 -0
  2. package/package.json +42 -0
  3. package/src/__tests__/jwt-refresh.test.ts +195 -0
  4. package/src/__tests__/query-params.test.ts +144 -0
  5. package/src/__tests__/url-builder.test.ts +121 -0
  6. package/src/constants/endpoints.ts +39 -0
  7. package/src/index.ts +109 -0
  8. package/src/lib/api-client.ts +89 -0
  9. package/src/lib/contacts-api.ts +111 -0
  10. package/src/lib/cors.ts +122 -0
  11. package/src/lib/entities-api.ts +123 -0
  12. package/src/lib/env.ts +35 -0
  13. package/src/lib/error-handler.ts +138 -0
  14. package/src/lib/errors.ts +381 -0
  15. package/src/lib/fetch-wrapper.ts +188 -0
  16. package/src/lib/llm-sanitize.ts +145 -0
  17. package/src/lib/messages-api.ts +273 -0
  18. package/src/lib/messages-api.ts.backup +273 -0
  19. package/src/lib/organizations-api.ts +132 -0
  20. package/src/lib/rate-limit.ts +91 -0
  21. package/src/lib/sanitize.ts +39 -0
  22. package/src/lib/workflows-api.ts +159 -0
  23. package/src/middleware/index.ts +12 -0
  24. package/src/middleware/with-auth.ts +90 -0
  25. package/src/middleware/with-error-handling.ts +83 -0
  26. package/src/middleware/with-validation.ts +110 -0
  27. package/src/types/api.ts +38 -0
  28. package/src/types/contact.ts +49 -0
  29. package/src/types/entity.ts +153 -0
  30. package/src/types/error.ts +129 -0
  31. package/src/types/funnel.ts +133 -0
  32. package/src/types/index.ts +95 -0
  33. package/src/types/organization.ts +49 -0
  34. package/src/types/response.ts +44 -0
  35. package/src/types/workflow.ts +69 -0
  36. package/src/utils/index.ts +13 -0
  37. package/src/utils/query-params.ts +79 -0
  38. 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
+ }
@@ -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
+ }