A social knowledge tool for researchers built on ATProto
1# API Client Architecture
2
3This document outlines the recommended approach for creating an API client that abstracts away the details of interacting with the backend API.
4
5## Recommended Approach: Separate Shared Package
6
7Create a **separate npm package** (e.g., `@yourapp/api-client` or `@yourapp/shared`) that contains:
8
91. **API Client** - The abstraction layer with methods like `addUrlToLibrary()`
102. **Shared Types** - DTOs, response types, error types
113. **Type Guards/Validators** - Runtime type checking utilities
12
13### Why This Approach?
14
15**Pros:**
16
17- **Single source of truth** for API contracts
18- **Type safety** between frontend and backend
19- **Reusable** across multiple frontends (web, mobile, etc.)
20- **Versioned** - can manage API changes cleanly
21- **Testable** in isolation
22
23**Cons:**
24
25- Additional build/publish complexity
26- Need to manage package versioning
27
28## Package Structure
29
30```
31packages/
32├── api-client/
33│ ├── src/
34│ │ ├── client/
35│ │ │ └── ApiClient.ts
36│ │ ├── types/
37│ │ │ ├── cards.ts
38│ │ │ ├── collections.ts
39│ │ │ └── common.ts
40│ │ └── index.ts
41│ └── package.json
42├── backend/
43└── frontend/
44```
45
46## Example API Client Structure
47
48```typescript
49// packages/api-client/src/types/cards.ts
50export interface AddUrlToLibraryRequest {
51 url: string;
52 note?: string;
53 collectionIds?: string[];
54}
55
56export interface AddUrlToLibraryResponse {
57 urlCardId: string;
58 noteCardId?: string;
59}
60
61// packages/api-client/src/client/ApiClient.ts
62export class ApiClient {
63 constructor(
64 private baseUrl: string,
65 private getAuthToken: () => string | null,
66 ) {}
67
68 async addUrlToLibrary(
69 request: AddUrlToLibraryRequest,
70 ): Promise<AddUrlToLibraryResponse> {
71 const response = await this.post('/api/cards/library/urls', request);
72 return response.json();
73 }
74
75 async createCollection(
76 request: CreateCollectionRequest,
77 ): Promise<CreateCollectionResponse> {
78 const response = await this.post('/api/collections', request);
79 return response.json();
80 }
81
82 private async post(endpoint: string, data: any) {
83 const token = this.getAuthToken();
84 return fetch(`${this.baseUrl}${endpoint}`, {
85 method: 'POST',
86 headers: {
87 'Content-Type': 'application/json',
88 ...(token && { Authorization: `Bearer ${token}` }),
89 },
90 body: JSON.stringify(data),
91 });
92 }
93}
94```
95
96## Type Sharing Strategy
97
98**Extract from your existing backend types:**
99
100- Take the DTO interfaces from your use cases
101- Create clean, frontend-friendly versions
102- Add any additional client-side types needed
103
104```typescript
105// Extract these from your backend use cases:
106export interface AddUrlToLibraryDTO {
107 url: string;
108 note?: string;
109 collectionIds?: string[];
110 curatorId: string; // This might be handled automatically by the client
111}
112
113// Transform to client-friendly version:
114export interface AddUrlToLibraryRequest {
115 url: string;
116 note?: string;
117 collectionIds?: string[];
118 // curatorId removed - handled by auth
119}
120```
121
122## Alternative Approaches
123
124### 1. Frontend-Only Client
125
126- Keep API client in frontend repo
127- Duplicate types or use code generation
128- Simpler but less maintainable
129
130### 2. Backend Exports Client
131
132- Export client from backend
133- Frontend imports it
134- Can work but creates coupling
135
136### 3. Code Generation
137
138- Generate client from OpenAPI spec
139- Tools like `openapi-generator` or `swagger-codegen`
140- Great for large APIs but adds complexity
141
142## Implementation Steps
143
1441. **Create the package structure**
1452. **Extract and clean up types** from your use case DTOs
1463. **Build the ApiClient class** with methods matching your endpoints
1474. **Add error handling** and response transformation
1485. **Set up build/publish pipeline**
1496. **Update frontend to use the client**
150
151## Error Handling Example
152
153```typescript
154export class ApiError extends Error {
155 constructor(
156 message: string,
157 public status: number,
158 public code?: string
159 ) {
160 super(message);
161 this.name = 'ApiError';
162 }
163}
164
165// In ApiClient
166private async handleResponse<T>(response: Response): Promise<T> {
167 if (!response.ok) {
168 const error = await response.json().catch(() => ({}));
169 throw new ApiError(
170 error.message || 'Request failed',
171 response.status,
172 error.code
173 );
174 }
175 return response.json();
176}
177```
178
179This approach gives you the best balance of maintainability, type safety, and reusability.