feat: end to end api type safety with redis rate limiting (#573)
* feat: support hono rpc * add wrangler file * update name of wrangler file * add wrangler as a dependency * add compatibility_flags * trigger ci * core: add health check to docker file * expose port 80 * update custom healthcheck * feat: implement openapi * install dependencies in root * debug yml * update yml * update port * update dockerfile with envs * completely move away from apiClient * rename backend to api * refactor: update server descriptions and comment out local spec saving * update lock file * use api.classroomio.com * add support for redis * fix: add missing line break in WelcomeModal.svelte * refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# API Client
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { apiClient, safeRequest, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
// GET request
|
||||
const result = await safeRequest(async () => {
|
||||
return apiClient.request('/api/users', { method: 'GET' });
|
||||
});
|
||||
|
||||
// POST request
|
||||
const result = await safeRequest(async () => {
|
||||
return apiClient.request('/api/users', {
|
||||
method: 'POST',
|
||||
body: { name: 'John', email: 'john@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
// Handle response
|
||||
if (result.success) {
|
||||
console.log('Data:', result.data);
|
||||
} else {
|
||||
console.error('Error:', result.error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
if (result.success) {
|
||||
// Success
|
||||
console.log(result.data);
|
||||
} else {
|
||||
// Check error type
|
||||
if (result.error.error_code === 'auth') {
|
||||
window.location.href = '/login';
|
||||
} else if (result.error.error_code === 'client') {
|
||||
showError('Please check your input');
|
||||
} else if (result.error.error_code === 'server') {
|
||||
showError('Server error, please try again');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Fetch
|
||||
|
||||
```typescript
|
||||
import { createApiClient } from '$lib/utils/services/api';
|
||||
|
||||
const customClient = createApiClient({
|
||||
customFetch: (input, init) => {
|
||||
// Your custom fetch logic
|
||||
return fetch(input, init);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await safeRequest(async () => {
|
||||
return customClient.request('/api/data', { method: 'GET' });
|
||||
});
|
||||
```
|
||||
|
||||
## RPC Usage
|
||||
|
||||
```typescript
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
// Use with Hono RPC
|
||||
const data = await classroomio.users.$get();
|
||||
const user = await classroomio.users.$post({ json: { name: 'John' } });
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
- `'auth'` - 401 Unauthorized
|
||||
- `'client'` - 400-499 Client errors
|
||||
- `'server'` - 500-599 Server errors
|
||||
- `'timeout'` - 408 Request timeout
|
||||
- `'network'` - 0 Network issues
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ApiClientConfig } from './types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
// Default configuration
|
||||
export const DEFAULT_CONFIG: Required<ApiClientConfig> = {
|
||||
baseURL: env.PUBLIC_SERVER_URL || '',
|
||||
timeout: 30000, // 30 seconds
|
||||
retries: 1,
|
||||
retryDelay: 1000, // 1 second
|
||||
customFetch: fetch,
|
||||
onAuthError: async () => {
|
||||
// Default: redirect to login or refresh token
|
||||
console.warn('Authentication error occurred. Consider redirecting to login.');
|
||||
},
|
||||
onNetworkError: async (error: Error) => {
|
||||
console.error('Network error:', error);
|
||||
},
|
||||
onResponse: async (response: Response) => {
|
||||
// Default: log response for debugging
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`API Response: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
|
||||
|
||||
import { DEFAULT_CONFIG } from './constants';
|
||||
import { delay } from './utils';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import { hcWithType } from '@cio/api/rpc-types';
|
||||
|
||||
class ApiClient {
|
||||
private config: Required<ApiClientConfig>;
|
||||
|
||||
constructor(config: ApiClientConfig = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
private async makeRequest(
|
||||
input: RequestInfo | URL,
|
||||
requestConfig: RequestConfig = {}
|
||||
): Promise<Response> {
|
||||
console.log('makeRequest', input, requestConfig);
|
||||
const {
|
||||
timeout = this.config.timeout,
|
||||
retries = this.config.retries,
|
||||
skipAuth = false,
|
||||
...fetchConfig
|
||||
} = requestConfig;
|
||||
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`;
|
||||
|
||||
// Prepare headers
|
||||
const headers = new Headers(fetchConfig.headers);
|
||||
|
||||
// Add default headers
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
// Handle body serialization and content-type
|
||||
let requestBody = fetchConfig.body;
|
||||
if (
|
||||
fetchConfig.body &&
|
||||
typeof fetchConfig.body === 'object' &&
|
||||
!(fetchConfig.body instanceof FormData)
|
||||
) {
|
||||
// Auto-stringify objects (except FormData)
|
||||
requestBody = JSON.stringify(fetchConfig.body);
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
} else if (fetchConfig.body && !headers.has('Content-Type')) {
|
||||
// Set content-type for other body types if not already set
|
||||
if (typeof fetchConfig.body === 'string') {
|
||||
headers.set('Content-Type', 'text/plain');
|
||||
}
|
||||
}
|
||||
|
||||
// Add authentication if not skipped
|
||||
if (!skipAuth) {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get access token:', error);
|
||||
// Continue without auth if token retrieval fails
|
||||
}
|
||||
}
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
...fetchConfig,
|
||||
headers,
|
||||
body: requestBody,
|
||||
signal: controller.signal
|
||||
};
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// Retry logic with exponential backoff
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const response = await this.config.customFetch(fullUrl, requestInit);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
await this.config.onResponse(response);
|
||||
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
await this.config.onAuthError();
|
||||
throw new ApiError(
|
||||
'Authentication failed',
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new ApiError(
|
||||
`Client error: ${errorText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
const error = new ApiError(
|
||||
`Server error: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
|
||||
if (attempt === retries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
await delay(this.config.retryDelay * Math.pow(2, attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other status codes
|
||||
throw new ApiError(
|
||||
`Unexpected status: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Handle abort (timeout)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new ApiError('Request timeout', 408, 'Request Timeout');
|
||||
}
|
||||
|
||||
// Handle network errors - retryable
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
const networkError = new ApiError('Network error', 0, 'Network Error');
|
||||
|
||||
if (attempt === retries) {
|
||||
await this.config.onNetworkError(networkError);
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
lastError = networkError;
|
||||
await delay(this.config.retryDelay * Math.pow(2, attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-throw non-retryable errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
throw lastError || new ApiError('Request failed after all retries');
|
||||
}
|
||||
|
||||
// Generic request method - handles all HTTP methods
|
||||
async request(input: RequestInfo | URL, config: RequestConfig = {}): Promise<Response> {
|
||||
return this.makeRequest(input, config);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
updateConfig(newConfig: Partial<ApiClientConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
}
|
||||
}
|
||||
|
||||
// Create default instance
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
// RPC client using the new fetch wrapper
|
||||
export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, {
|
||||
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
|
||||
return apiClient.request(input, requestInit);
|
||||
}
|
||||
});
|
||||
|
||||
// Utility functions for common use cases
|
||||
export const createApiClient = (config: ApiClientConfig) => new ApiClient(config);
|
||||
|
||||
// Re-export utility functions for convenience
|
||||
export {
|
||||
handleJsonResponse,
|
||||
handleTextResponse,
|
||||
isApiError,
|
||||
isNetworkError,
|
||||
isAuthError,
|
||||
isClientError,
|
||||
isServerError,
|
||||
isTimeoutError,
|
||||
safeRequest,
|
||||
safeRequestText,
|
||||
safeRequestRaw,
|
||||
type Result
|
||||
} from './utils';
|
||||
|
||||
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
|
||||
@@ -0,0 +1,46 @@
|
||||
// Types for better type safety
|
||||
export interface ApiClientConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
customFetch?: typeof fetch;
|
||||
onAuthError?: () => Promise<void> | void;
|
||||
onNetworkError?: (error: Error) => Promise<void> | void;
|
||||
onResponse?: (response: Response) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface RequestConfig extends RequestInit {
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly error_code: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
public statusText?: string,
|
||||
public response?: Response
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
|
||||
// Determine error code based on status
|
||||
if (status === 401) {
|
||||
this.error_code = 'auth';
|
||||
} else if (status && status >= 400 && status < 500) {
|
||||
this.error_code = 'client';
|
||||
} else if (status && status >= 500) {
|
||||
this.error_code = 'server';
|
||||
} else if (status === 408) {
|
||||
this.error_code = 'timeout';
|
||||
} else if (status === 0 || !status) {
|
||||
this.error_code = 'network';
|
||||
} else {
|
||||
this.error_code = 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ApiError } from './types';
|
||||
|
||||
// Utility function for exponential backoff
|
||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper function to handle JSON responses
|
||||
export const handleJsonResponse = async <T = any>(response: Response): Promise<T> => {
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new ApiError(
|
||||
'Failed to parse JSON response',
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to handle text responses
|
||||
export const handleTextResponse = async (response: Response): Promise<string> => {
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
};
|
||||
|
||||
// Error handling utilities
|
||||
export const isApiError = (error: unknown): error is ApiError => {
|
||||
return error instanceof ApiError;
|
||||
};
|
||||
|
||||
export const isNetworkError = (error: unknown): boolean => {
|
||||
return isApiError(error) && error.status === 0;
|
||||
};
|
||||
|
||||
export const isAuthError = (error: unknown): boolean => {
|
||||
return isApiError(error) && error.status === 401;
|
||||
};
|
||||
|
||||
export const isClientError = (error: unknown): boolean => {
|
||||
return isApiError(error) && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500;
|
||||
};
|
||||
|
||||
export const isServerError = (error: unknown): boolean => {
|
||||
return isApiError(error) && (error.status ?? 0) >= 500;
|
||||
};
|
||||
|
||||
export const isTimeoutError = (error: unknown): boolean => {
|
||||
return isApiError(error) && error.status === 408;
|
||||
};
|
||||
|
||||
// Safe request wrapper that returns Result pattern
|
||||
export type Result<T, E = ApiError> = { success: true; data: T } | { success: false; error: E };
|
||||
|
||||
// Generic safe request wrapper
|
||||
export const safeRequest = async <T = any>(
|
||||
requestFn: () => Promise<Response>
|
||||
): Promise<Result<T>> => {
|
||||
try {
|
||||
const response = await requestFn();
|
||||
const data = await handleJsonResponse<T>(response);
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: new ApiError('Unexpected error', 0, 'Unknown Error')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Safe request wrapper for text responses
|
||||
export const safeRequestText = async (
|
||||
requestFn: () => Promise<Response>
|
||||
): Promise<Result<string>> => {
|
||||
try {
|
||||
const response = await requestFn();
|
||||
const data = await handleTextResponse(response);
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: new ApiError('Unexpected error', 0, 'Unknown Error')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Safe request wrapper that returns the raw response
|
||||
export const safeRequestRaw = async (
|
||||
requestFn: () => Promise<Response>
|
||||
): Promise<Result<Response>> => {
|
||||
try {
|
||||
const response = await requestFn();
|
||||
return { success: true, data: response };
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: new ApiError('Unexpected error', 0, 'Unknown Error')
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user