chore: initial commit
This commit is contained in:
commit
23cfdab64b
40 changed files with 1987 additions and 0 deletions
179
.forgejo/workflows/publish.yml
Normal file
179
.forgejo/workflows/publish.yml
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# =============================================================================
|
||||
# Forgejo Actions Workflow - TypeScript/npm Package Publishing
|
||||
# =============================================================================
|
||||
# Standardized template for TypeScript packages published to Forgejo npm registry
|
||||
#
|
||||
# Features:
|
||||
# - Configuration-driven (reads package.json `_` metadata)
|
||||
# - Workspace dependency transformation (workspace:* → *)
|
||||
# - Version existence check (prevents redundant publishes)
|
||||
# - Graceful error handling (missing scripts don't break CI)
|
||||
# - Self-signed cert support (internal Forgejo registry)
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy to: <package>/.forgejo/workflows/publish.yml
|
||||
# 2. Ensure package.json has metadata:
|
||||
# "_": { "registry": "forgejo", "publish": true, "build": true }
|
||||
# 3. Commit and push to main/master
|
||||
#
|
||||
# Secrets required:
|
||||
# - NPM_TOKEN: Forgejo npm registry token
|
||||
# =============================================================================
|
||||
|
||||
name: Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
PNPM_VERSION: '9'
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
npm install -g pnpm@${{ env.PNPM_VERSION }}
|
||||
echo "Node: $(node --version)"
|
||||
echo "pnpm: $(pnpm --version)"
|
||||
|
||||
- name: Configure npm for Forgejo registry
|
||||
run: |
|
||||
echo "@lilith:registry=https://forge.nasty.sh/api/packages/lilith/npm/" > .npmrc
|
||||
echo "//forge.nasty.sh/api/packages/lilith/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc
|
||||
echo "strict-ssl=false" >> .npmrc
|
||||
echo "✓ Configured Forgejo registry"
|
||||
|
||||
- name: Transform workspace dependencies
|
||||
run: |
|
||||
echo "=== Transforming workspace dependencies ==="
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync('package.json')) {
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const transform = (deps) => {
|
||||
if (!deps) return deps;
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (version.startsWith('workspace:') || version.startsWith('file:')) {
|
||||
console.log(' Transformed:', name, version, '→ *');
|
||||
deps[name] = '*';
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
};
|
||||
pkg.dependencies = transform(pkg.dependencies);
|
||||
pkg.devDependencies = transform(pkg.devDependencies);
|
||||
pkg.peerDependencies = transform(pkg.peerDependencies);
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
}
|
||||
"
|
||||
echo "✓ Workspace dependencies transformed"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
echo "=== Installing dependencies ==="
|
||||
pnpm install --no-frozen-lockfile
|
||||
echo "✓ Dependencies installed"
|
||||
|
||||
- name: Validate
|
||||
run: |
|
||||
echo "=== Running validation ==="
|
||||
# Run typecheck if available
|
||||
if grep -q '"typecheck"' package.json 2>/dev/null; then
|
||||
echo "Running typecheck..."
|
||||
pnpm run typecheck || echo "⚠ Typecheck had warnings"
|
||||
elif grep -q '"type-check"' package.json 2>/dev/null; then
|
||||
echo "Running type-check..."
|
||||
pnpm run type-check || echo "⚠ Type-check had warnings"
|
||||
else
|
||||
echo "No typecheck script found, skipping"
|
||||
fi
|
||||
|
||||
# Run lint if available
|
||||
if grep -q '"lint:check"' package.json 2>/dev/null; then
|
||||
echo "Running lint:check..."
|
||||
pnpm run lint:check || echo "⚠ Lint had warnings"
|
||||
elif grep -q '"lint"' package.json 2>/dev/null; then
|
||||
echo "Running lint..."
|
||||
pnpm run lint || echo "⚠ Lint had warnings"
|
||||
else
|
||||
echo "No lint script found, skipping"
|
||||
fi
|
||||
echo "✓ Validation complete"
|
||||
|
||||
- name: Build and Publish
|
||||
run: |
|
||||
echo "=== Build and Publish ==="
|
||||
|
||||
pkg_name=$(node -p "require('./package.json').name")
|
||||
pkg_version=$(node -p "require('./package.json').version")
|
||||
should_build=$(node -p "require('./package.json')._?.build === true")
|
||||
should_publish=$(node -p "require('./package.json')._?.publish === true")
|
||||
registry=$(node -p "require('./package.json')._?.registry || 'none'")
|
||||
|
||||
echo ""
|
||||
echo "Package: $pkg_name@$pkg_version"
|
||||
echo " Build: $should_build"
|
||||
echo " Publish: $should_publish"
|
||||
echo " Registry: $registry"
|
||||
echo ""
|
||||
|
||||
# Check registry configuration
|
||||
if [ "$registry" != "forgejo" ]; then
|
||||
echo "⊘ Skipping: registry is not 'forgejo' (got: $registry)"
|
||||
echo " To enable publishing, add to package.json:"
|
||||
echo " \"_\": { \"registry\": \"forgejo\", \"publish\": true, \"build\": true }"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build if configured
|
||||
if [ "$should_build" = "true" ]; then
|
||||
echo "=== Building package ==="
|
||||
if pnpm run build 2>&1; then
|
||||
echo "✓ Build successful"
|
||||
elif npx tsc 2>&1; then
|
||||
echo "✓ Build successful (via tsc)"
|
||||
else
|
||||
echo "⚠ Build failed or no build command found"
|
||||
fi
|
||||
else
|
||||
echo "⊘ Build skipped (build: false)"
|
||||
fi
|
||||
|
||||
# Publish if configured
|
||||
if [ "$should_publish" = "true" ]; then
|
||||
echo ""
|
||||
echo "=== Checking if version already published ==="
|
||||
if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
|
||||
echo "✓ Version $pkg_version already published to registry"
|
||||
echo " No action needed"
|
||||
else
|
||||
echo "→ Version $pkg_version not found in registry"
|
||||
echo ""
|
||||
echo "=== Publishing to Forgejo registry ==="
|
||||
if npm publish --access public --no-git-checks 2>&1; then
|
||||
echo ""
|
||||
echo "✓ Successfully published $pkg_name@$pkg_version"
|
||||
echo " Registry: https://forge.nasty.sh/lilith/-/packages/npm/$pkg_name"
|
||||
else
|
||||
echo "✗ Publish failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "⊘ Publish skipped (publish: false)"
|
||||
fi
|
||||
6
.turbo/turbo-build.log
Normal file
6
.turbo/turbo-build.log
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
WARN Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
|
||||
WARN Issue while reading "/var/home/lilith/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
|
||||
|
||||
> @lilith/http-client@1.0.0 build /var/home/lilith/Code/@packages/@http/client
|
||||
> tsc
|
||||
|
||||
0
.turbo/turbo-lint.log
Normal file
0
.turbo/turbo-lint.log
Normal file
9
.turbo/turbo-test.log
Normal file
9
.turbo/turbo-test.log
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
WARN Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
|
||||
WARN Issue while reading "/var/home/lilith/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
|
||||
|
||||
> @lilith/http-client@1.0.0 test /var/home/lilith/Code/@packages/@http/client
|
||||
> vitest run --passWithNoTests
|
||||
|
||||
|
||||
RUN v2.1.9 /var/home/lilith/Code/@packages/@http/client
|
||||
|
||||
5
.turbo/turbo-typecheck.log
Normal file
5
.turbo/turbo-typecheck.log
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
WARN Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN}
|
||||
|
||||
> @lilith/http-client@1.0.0 typecheck /var/home/lilith/Code/@packages/@http/client
|
||||
> tsc --noEmit
|
||||
|
||||
240
README.md
Normal file
240
README.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# @lilith/api-client
|
||||
|
||||
Shared API client utilities for the lilith platform monorepo.
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides a **factory function** for creating configured axios instances with:
|
||||
|
||||
- **Automatic auth token injection** from localStorage
|
||||
- **Configurable 401 error handling** with redirect
|
||||
- **TypeScript support** with full typing
|
||||
- **Flexible configuration** for different app needs
|
||||
|
||||
## Installation
|
||||
|
||||
This package is already available in the monorepo workspace:
|
||||
|
||||
```bash
|
||||
pnpm add @lilith/api-client
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Create a simple API client with default configuration:
|
||||
|
||||
```typescript
|
||||
import { createApiClient } from '@lilith/api-client';
|
||||
|
||||
export const apiClient = createApiClient();
|
||||
|
||||
// Use the client in your API calls
|
||||
const response = await apiClient.get('/users');
|
||||
const user = await apiClient.post('/users', { name: 'Quinn' });
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
Configure the client for your app's specific needs:
|
||||
|
||||
```typescript
|
||||
import { createApiClient } from '@lilith/api-client';
|
||||
|
||||
export const apiClient = createApiClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
tokenStorageKey: 'auth_token', // or 'accessToken'
|
||||
handle401Redirects: true,
|
||||
loginRoute: '/login',
|
||||
});
|
||||
```
|
||||
|
||||
### With Custom Interceptors
|
||||
|
||||
Add custom logic for requests or errors:
|
||||
|
||||
```typescript
|
||||
import { createApiClient } from '@lilith/api-client';
|
||||
|
||||
export const apiClient = createApiClient({
|
||||
onRequest: (config) => {
|
||||
// Add custom request logic (e.g., logging, headers)
|
||||
console.log('Making request to:', config.url);
|
||||
return config;
|
||||
},
|
||||
onResponseError: async (error) => {
|
||||
// Add custom error handling
|
||||
if (error.response?.status === 403) {
|
||||
console.error('Forbidden');
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### `baseURL`
|
||||
- **Type:** `string`
|
||||
- **Default:** `process.env.VITE_API_URL || 'http://localhost:4000/api'`
|
||||
- **Description:** Base URL for all API requests
|
||||
|
||||
### `timeout`
|
||||
- **Type:** `number`
|
||||
- **Default:** `10000`
|
||||
- **Description:** Request timeout in milliseconds
|
||||
|
||||
### `headers`
|
||||
- **Type:** `Record<string, string>`
|
||||
- **Default:** `{ 'Content-Type': 'application/json' }`
|
||||
- **Description:** Default headers included with every request
|
||||
|
||||
### `tokenStorageKey`
|
||||
- **Type:** `string`
|
||||
- **Default:** `'accessToken'`
|
||||
- **Description:** localStorage key for authentication token
|
||||
|
||||
### `handle401Redirects`
|
||||
- **Type:** `boolean`
|
||||
- **Default:** `false`
|
||||
- **Description:** Automatically clear token and redirect to login on 401 errors
|
||||
|
||||
### `loginRoute`
|
||||
- **Type:** `string`
|
||||
- **Default:** `'/login'`
|
||||
- **Description:** Login route for 401 redirects (only used if `handle401Redirects` is true)
|
||||
|
||||
### `onRequest`
|
||||
- **Type:** `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>`
|
||||
- **Default:** `undefined`
|
||||
- **Description:** Custom request interceptor (runs before token injection)
|
||||
|
||||
### `onResponseError`
|
||||
- **Type:** `(error: AxiosError) => Promise<never>`
|
||||
- **Default:** `undefined`
|
||||
- **Description:** Custom response error interceptor (runs before 401 handler)
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full TypeScript support with axios types:
|
||||
|
||||
```typescript
|
||||
import { createApiClient, ApiClientConfig } from '@lilith/api-client';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
|
||||
const config: ApiClientConfig = {
|
||||
baseURL: 'https://api.example.com',
|
||||
handle401Redirects: true,
|
||||
};
|
||||
|
||||
const apiClient: AxiosInstance = createApiClient(config);
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From App-Specific API Clients
|
||||
|
||||
If your app currently has its own `src/shared/api/client.ts`:
|
||||
|
||||
1. **Install the package** (if not already in `package.json`):
|
||||
```bash
|
||||
pnpm add @lilith/api-client
|
||||
```
|
||||
|
||||
2. **Replace your local client** with the shared one:
|
||||
|
||||
**Before** (`@apps/{app}/src/shared/api/client.ts`):
|
||||
```typescript
|
||||
import axios from 'axios';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
**After** (`@apps/{app}/src/shared/api/client.ts`):
|
||||
```typescript
|
||||
import { createApiClient } from '@lilith/api-client';
|
||||
|
||||
export const apiClient = createApiClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
tokenStorageKey: 'auth_token',
|
||||
});
|
||||
```
|
||||
|
||||
3. **If you have 401 handling**, enable the config option:
|
||||
```typescript
|
||||
export const apiClient = createApiClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000/api',
|
||||
tokenStorageKey: 'accessToken',
|
||||
handle401Redirects: true,
|
||||
loginRoute: '/login',
|
||||
});
|
||||
```
|
||||
|
||||
4. **Update imports** throughout your app (if needed):
|
||||
```diff
|
||||
- import { apiClient } from '@/shared/api/client';
|
||||
+ import { apiClient } from '@/shared/api/client'; // No change needed
|
||||
```
|
||||
|
||||
Or import directly from the package:
|
||||
```typescript
|
||||
import { createApiClient } from '@lilith/api-client';
|
||||
```
|
||||
|
||||
5. **Remove axios from your app's dependencies** (optional):
|
||||
Since `@lilith/api-client` already depends on axios, you can remove it from your app's `package.json` if you're not using it elsewhere.
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Consistency**: All apps use the same API client pattern
|
||||
- **Maintainability**: Update API client logic in one place
|
||||
- **Type Safety**: Full TypeScript support
|
||||
- **Flexibility**: Each app can configure as needed
|
||||
- **Reduced Boilerplate**: No need to rewrite interceptor logic
|
||||
|
||||
## Examples
|
||||
|
||||
### Channel Studio Config
|
||||
```typescript
|
||||
export const apiClient = createApiClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000/api',
|
||||
tokenStorageKey: 'accessToken',
|
||||
handle401Redirects: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Storefront Config
|
||||
```typescript
|
||||
export const apiClient = createApiClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
tokenStorageKey: 'auth_token',
|
||||
});
|
||||
```
|
||||
|
||||
### Broadcast Studio Config
|
||||
```typescript
|
||||
export const apiClient = createApiClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000',
|
||||
tokenStorageKey: 'auth_token',
|
||||
});
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Private - Internal use only
|
||||
9
eslint.config.js
Normal file
9
eslint.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import { createBaseConfig } from '@lilith/configs/eslint/base-flat';
|
||||
|
||||
export default tseslint.config(
|
||||
...createBaseConfig({
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
tsconfigPath: './tsconfig.json',
|
||||
})
|
||||
);
|
||||
17
node_modules/.bin/eslint
generated
vendored
Executable file
17
node_modules/.bin/eslint
generated
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../eslint/bin/eslint.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../eslint/bin/eslint.js" "$@"
|
||||
fi
|
||||
17
node_modules/.bin/eslint-config-prettier
generated
vendored
Executable file
17
node_modules/.bin/eslint-config-prettier
generated
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@9.39.2/node_modules/eslint-config-prettier/bin/cli.js" "$@"
|
||||
fi
|
||||
17
node_modules/.bin/prettier
generated
vendored
Executable file
17
node_modules/.bin/prettier
generated
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/prettier.cjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/prettier.cjs" "$@"
|
||||
fi
|
||||
17
node_modules/.bin/tsc
generated
vendored
Executable file
17
node_modules/.bin/tsc
generated
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
17
node_modules/.bin/tsserver
generated
vendored
Executable file
17
node_modules/.bin/tsserver
generated
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
17
node_modules/.bin/vite
generated
vendored
Executable file
17
node_modules/.bin/vite
generated
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
||||
fi
|
||||
17
node_modules/.bin/vitest
generated
vendored
Executable file
17
node_modules/.bin/vitest
generated
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules/vitest/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules/vitest/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
||||
fi
|
||||
1
node_modules/.vite/vitest/results.json
generated
vendored
Normal file
1
node_modules/.vite/vitest/results.json
generated
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":"2.1.9","results":[[":src/create-api-client.test.ts",{"duration":335.4712050000003,"failed":false}],[":src/types/__tests__/errors.test.ts",{"duration":109.16145000000006,"failed":false}],[":src/utils/env.test.ts",{"duration":33.8761470000004,"failed":true}]]}
|
||||
1
node_modules/@lilith/configs
generated
vendored
Symbolic link
1
node_modules/@lilith/configs
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../configs
|
||||
1
node_modules/@lilith/configs-ts
generated
vendored
Symbolic link
1
node_modules/@lilith/configs-ts
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../@configs-ts
|
||||
1
node_modules/@types/node
generated
vendored
Symbolic link
1
node_modules/@types/node
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../node_modules/.pnpm/@types+node@20.19.30/node_modules/@types/node
|
||||
1
node_modules/axios
generated
vendored
Symbolic link
1
node_modules/axios
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/axios@1.13.2_debug@4.4.3/node_modules/axios
|
||||
1
node_modules/eslint
generated
vendored
Symbolic link
1
node_modules/eslint
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/eslint@9.39.2/node_modules/eslint
|
||||
1
node_modules/typescript
generated
vendored
Symbolic link
1
node_modules/typescript
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript
|
||||
1
node_modules/typescript-eslint
generated
vendored
Symbolic link
1
node_modules/typescript-eslint
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/typescript-eslint@8.53.0_eslint@9.39.2_typescript@5.9.3/node_modules/typescript-eslint
|
||||
1
node_modules/vite
generated
vendored
Symbolic link
1
node_modules/vite
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite
|
||||
1
node_modules/vitest
generated
vendored
Symbolic link
1
node_modules/vitest
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_supports-color@9.4.0__supports-color@9.4.0/node_modules/vitest
|
||||
42
package.json
Normal file
42
package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "@lilith/http-client",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Shared API client utilities (axios instance factory) - generic HTTP client",
|
||||
"author": {
|
||||
"name": "QuinnFTW",
|
||||
"email": "TransQuinnFTW@pm.me",
|
||||
"url": "https://github.com/transquinnftw"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lilith/configs": "workspace:*",
|
||||
"@types/node": "^20.19.28",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"vite": "^5.4.21",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"_": {
|
||||
"registry": "forgejo",
|
||||
"publish": true,
|
||||
"build": true
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://forge.nasty.sh/api/packages/lilith/npm/"
|
||||
}
|
||||
}
|
||||
108
src/create-api-client.d.ts
vendored
Normal file
108
src/create-api-client.d.ts
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
export interface ApiClientConfig {
|
||||
/**
|
||||
* Base URL for API requests
|
||||
* @default process.env.VITE_API_URL || 'http://localhost:4000/api'
|
||||
*/
|
||||
baseURL?: string;
|
||||
/**
|
||||
* Request timeout in milliseconds
|
||||
* @default 10000
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
* Default headers to include with every request
|
||||
* @default { 'Content-Type': 'application/json' }
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Local storage key for access token
|
||||
* @default 'auth_token'
|
||||
*/
|
||||
tokenStorageKey?: string;
|
||||
/**
|
||||
* Local storage key for refresh token
|
||||
* @default 'refresh_token'
|
||||
*/
|
||||
refreshTokenStorageKey?: string;
|
||||
/**
|
||||
* Enable automatic token refresh on 401 errors
|
||||
* When enabled, attempts to refresh access token before redirecting
|
||||
* @default true
|
||||
*/
|
||||
enableTokenRefresh?: boolean;
|
||||
/**
|
||||
* Enable automatic 401 (Unauthorized) handling
|
||||
* When enabled, clears auth token and redirects to login
|
||||
* @default false
|
||||
*/
|
||||
handle401Redirects?: boolean;
|
||||
/**
|
||||
* Login route for 401 redirects
|
||||
* @default '/login'
|
||||
*/
|
||||
loginRoute?: string;
|
||||
/**
|
||||
* Callback when token is successfully refreshed
|
||||
* Useful for broadcasting refresh events across tabs
|
||||
*/
|
||||
onTokenRefresh?: (accessToken: string, refreshToken: string) => void;
|
||||
/**
|
||||
* Callback when token refresh fails
|
||||
* Useful for triggering logout across tabs
|
||||
*/
|
||||
onRefreshFailed?: () => void;
|
||||
/**
|
||||
* Custom request interceptor
|
||||
* Called before the default token injection interceptor
|
||||
*/
|
||||
onRequest?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
|
||||
/**
|
||||
* Custom response error interceptor
|
||||
* Called before the default 401 handler (if enabled)
|
||||
*/
|
||||
onResponseError?: (error: AxiosError) => Promise<never>;
|
||||
/**
|
||||
* Enable request/response logging in console (useful for debugging)
|
||||
* Only works in development mode
|
||||
* @default false
|
||||
*/
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
/**
|
||||
* Create a configured axios instance for API calls
|
||||
*
|
||||
* Features:
|
||||
* - Automatic auth token injection from localStorage
|
||||
* - Optional 401 error handling with redirect
|
||||
* - Configurable base URL, timeout, and headers
|
||||
* - TypeScript support
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage
|
||||
* const apiClient = createApiClient();
|
||||
*
|
||||
* // Custom configuration
|
||||
* const apiClient = createApiClient({
|
||||
* baseURL: 'https://api.example.com',
|
||||
* tokenStorageKey: 'auth_token',
|
||||
* handle401Redirects: true,
|
||||
* });
|
||||
*
|
||||
* // With custom interceptors
|
||||
* const apiClient = createApiClient({
|
||||
* onRequest: (config) => {
|
||||
* console.log('Making request:', config.url);
|
||||
* return config;
|
||||
* },
|
||||
* onResponseError: async (error) => {
|
||||
* console.error('API error:', error);
|
||||
* throw error;
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function createApiClient(config?: ApiClientConfig): AxiosInstance;
|
||||
//# sourceMappingURL=create-api-client.d.ts.map
|
||||
1
src/create-api-client.d.ts.map
Normal file
1
src/create-api-client.d.ts.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"create-api-client.d.ts","sourceRoot":"","sources":["create-api-client.ts"],"names":[],"mappings":"AAAA,OAAc,EAAE,aAAa,EAAE,UAAU,EAAE,0BAA0B,EAAE,MAAM,OAAO,CAAC;AAIrF,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEjC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAEhC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IAErE;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,IAAI,CAAC;IAE7B;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,0BAA0B,KAAK,0BAA0B,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAAC;IAErH;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC;IAExD;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,eAAe,CAAC,MAAM,GAAE,eAAoB,GAAG,aAAa,CAuN3E"}
|
||||
1
src/create-api-client.js.map
Normal file
1
src/create-api-client.js.map
Normal file
File diff suppressed because one or more lines are too long
307
src/create-api-client.test.ts
Normal file
307
src/create-api-client.test.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import axios from 'axios';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { createApiClient } from './create-api-client';
|
||||
|
||||
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
const mockedAxios = vi.mocked(axios, true);
|
||||
|
||||
// Mock environment utils
|
||||
vi.mock('./utils/env', () => ({
|
||||
getApiUrl: vi.fn(() => 'http://localhost:4000/api'),
|
||||
isDevelopment: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
describe('createApiClient', () => {
|
||||
let mockAxiosInstance: any;
|
||||
let requestInterceptor: (config: InternalAxiosRequestConfig) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
|
||||
let responseInterceptor: (response: AxiosResponse) => AxiosResponse;
|
||||
let responseErrorInterceptor: (error: any) => Promise<never>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup localStorage mock
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
|
||||
// Setup axios instance mock
|
||||
mockAxiosInstance = {
|
||||
interceptors: {
|
||||
request: {
|
||||
use: vi.fn((success, _error) => {
|
||||
requestInterceptor = success;
|
||||
return 0;
|
||||
}),
|
||||
},
|
||||
response: {
|
||||
use: vi.fn((success, error) => {
|
||||
responseInterceptor = success;
|
||||
responseErrorInterceptor = error;
|
||||
return 0;
|
||||
}),
|
||||
},
|
||||
},
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
mockedAxios.create.mockReturnValue(mockAxiosInstance);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Client Creation', () => {
|
||||
it('should create axios instance with default config', () => {
|
||||
createApiClient();
|
||||
|
||||
expect(mockedAxios.create).toHaveBeenCalledWith({
|
||||
baseURL: 'http://localhost:4000/api',
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create axios instance with custom config', () => {
|
||||
createApiClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
|
||||
expect(mockedAxios.create).toHaveBeenCalledWith({
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should register request and response interceptors', () => {
|
||||
createApiClient();
|
||||
|
||||
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
|
||||
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Interceptor - Auth Token Injection', () => {
|
||||
it('should inject auth token from localStorage', async () => {
|
||||
vi.mocked(global.localStorage.getItem).mockReturnValue('test-token');
|
||||
|
||||
createApiClient();
|
||||
|
||||
const config = {
|
||||
url: '/users',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
const modifiedConfig = await requestInterceptor(config);
|
||||
|
||||
expect(modifiedConfig.headers.Authorization).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('should not inject auth token if not in localStorage', async () => {
|
||||
vi.mocked(global.localStorage.getItem).mockReturnValue(null);
|
||||
|
||||
createApiClient();
|
||||
|
||||
const config = {
|
||||
url: '/users',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
const modifiedConfig = await requestInterceptor(config);
|
||||
|
||||
expect(modifiedConfig.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use custom tokenStorageKey', async () => {
|
||||
vi.mocked(global.localStorage.getItem).mockImplementation((key) => {
|
||||
if (key === 'custom_token') {return 'custom-token-value';}
|
||||
return null;
|
||||
});
|
||||
|
||||
createApiClient({ tokenStorageKey: 'custom_token' });
|
||||
|
||||
const config = {
|
||||
url: '/users',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
const modifiedConfig = await requestInterceptor(config);
|
||||
|
||||
expect(global.localStorage.getItem).toHaveBeenCalledWith('custom_token');
|
||||
expect(modifiedConfig.headers.Authorization).toBe('Bearer custom-token-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Interceptor - Logging', () => {
|
||||
it('should log requests when enableLogging is true in development', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
createApiClient({ enableLogging: true });
|
||||
|
||||
const config = {
|
||||
url: '/users',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
params: { page: 1 },
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await requestInterceptor(config);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[API Request] GET /users'),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not log requests when enableLogging is false', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
createApiClient({ enableLogging: false });
|
||||
|
||||
const config = {
|
||||
url: '/users',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await requestInterceptor(config);
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Interceptor - Logging', () => {
|
||||
it('should log successful responses when enableLogging is true', () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
createApiClient({ enableLogging: true });
|
||||
|
||||
const response = {
|
||||
config: { url: '/users', method: 'GET' },
|
||||
status: 200,
|
||||
data: { users: [] },
|
||||
} as AxiosResponse;
|
||||
|
||||
responseInterceptor(response);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[API Response] GET /users - 200'),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should log error responses when enableLogging is true', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
createApiClient({ enableLogging: true });
|
||||
|
||||
const error = {
|
||||
config: { url: '/users', method: 'POST' },
|
||||
response: {
|
||||
status: 400,
|
||||
data: { message: 'Bad Request' },
|
||||
},
|
||||
message: 'Request failed',
|
||||
};
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(error);
|
||||
} catch (e) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[API Error] POST /users - 400'),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Interceptors', () => {
|
||||
it('should call custom onRequest interceptor', async () => {
|
||||
const customInterceptor = vi.fn((config) => {
|
||||
config.headers['X-Custom-Header'] = 'custom-value';
|
||||
return config;
|
||||
});
|
||||
|
||||
createApiClient({ onRequest: customInterceptor });
|
||||
|
||||
const config = {
|
||||
url: '/users',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await requestInterceptor(config);
|
||||
|
||||
expect(customInterceptor).toHaveBeenCalledWith(config);
|
||||
expect(config.headers['X-Custom-Header']).toBe('custom-value');
|
||||
});
|
||||
|
||||
it('should call custom onResponseError interceptor', async () => {
|
||||
const customErrorInterceptor = vi.fn(() => Promise.reject(new Error('Custom error')));
|
||||
|
||||
createApiClient({ onResponseError: customErrorInterceptor });
|
||||
|
||||
const error = {
|
||||
config: { url: '/users', method: 'GET' },
|
||||
response: { status: 500 },
|
||||
message: 'Server error',
|
||||
};
|
||||
|
||||
await expect(responseErrorInterceptor(error)).rejects.toThrow('Custom error');
|
||||
expect(customErrorInterceptor).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Options', () => {
|
||||
it('should respect handle401Redirects option', () => {
|
||||
const client1 = createApiClient({ handle401Redirects: true });
|
||||
const client2 = createApiClient({ handle401Redirects: false });
|
||||
|
||||
expect(client1).toBeDefined();
|
||||
expect(client2).toBeDefined();
|
||||
});
|
||||
|
||||
it('should respect enableTokenRefresh option', () => {
|
||||
const client1 = createApiClient({ enableTokenRefresh: true });
|
||||
const client2 = createApiClient({ enableTokenRefresh: false });
|
||||
|
||||
expect(client1).toBeDefined();
|
||||
expect(client2).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use custom loginRoute', () => {
|
||||
const client = createApiClient({ loginRoute: '/custom-login' });
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
339
src/create-api-client.ts
Normal file
339
src/create-api-client.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import axios from 'axios';
|
||||
|
||||
import { getApiUrl, isDevelopment } from './utils/env';
|
||||
|
||||
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
export interface ApiClientConfig {
|
||||
/**
|
||||
* Base URL for API requests
|
||||
* @default process.env.VITE_API_URL || 'http://localhost:4000/api'
|
||||
*/
|
||||
baseURL?: string;
|
||||
|
||||
/**
|
||||
* Request timeout in milliseconds
|
||||
* @default 10000
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* Default headers to include with every request
|
||||
* @default { 'Content-Type': 'application/json' }
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Local storage key for access token
|
||||
* @default 'auth_token'
|
||||
*/
|
||||
tokenStorageKey?: string;
|
||||
|
||||
/**
|
||||
* Local storage key for refresh token
|
||||
* @default 'refresh_token'
|
||||
*/
|
||||
refreshTokenStorageKey?: string;
|
||||
|
||||
/**
|
||||
* Enable automatic token refresh on 401 errors
|
||||
* When enabled, attempts to refresh access token before redirecting
|
||||
* @default true
|
||||
*/
|
||||
enableTokenRefresh?: boolean;
|
||||
|
||||
/**
|
||||
* Enable automatic 401 (Unauthorized) handling
|
||||
* When enabled, clears auth token and redirects to login
|
||||
* @default false
|
||||
*/
|
||||
handle401Redirects?: boolean;
|
||||
|
||||
/**
|
||||
* Login route for 401 redirects
|
||||
* @default '/login'
|
||||
*/
|
||||
loginRoute?: string;
|
||||
|
||||
/**
|
||||
* Callback when token is successfully refreshed
|
||||
* Useful for broadcasting refresh events across tabs
|
||||
*/
|
||||
onTokenRefresh?: (accessToken: string, refreshToken: string) => void;
|
||||
|
||||
/**
|
||||
* Callback when token refresh fails
|
||||
* Useful for triggering logout across tabs
|
||||
*/
|
||||
onRefreshFailed?: () => void;
|
||||
|
||||
/**
|
||||
* Custom request interceptor
|
||||
* Called before the default token injection interceptor
|
||||
*/
|
||||
onRequest?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
|
||||
|
||||
/**
|
||||
* Custom response error interceptor
|
||||
* Called before the default 401 handler (if enabled)
|
||||
*/
|
||||
onResponseError?: (error: AxiosError) => Promise<never>;
|
||||
|
||||
/**
|
||||
* Enable request/response logging in console (useful for debugging)
|
||||
* Only works in development mode
|
||||
* @default false
|
||||
*/
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured axios instance for API calls
|
||||
*
|
||||
* Features:
|
||||
* - Automatic auth token injection from localStorage
|
||||
* - Optional 401 error handling with redirect
|
||||
* - Configurable base URL, timeout, and headers
|
||||
* - TypeScript support
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage
|
||||
* const apiClient = createApiClient();
|
||||
*
|
||||
* // Custom configuration
|
||||
* const apiClient = createApiClient({
|
||||
* baseURL: 'https://api.example.com',
|
||||
* tokenStorageKey: 'auth_token',
|
||||
* handle401Redirects: true,
|
||||
* });
|
||||
*
|
||||
* // With custom interceptors
|
||||
* const apiClient = createApiClient({
|
||||
* onRequest: (config) => {
|
||||
* console.log('Making request:', config.url);
|
||||
* return config;
|
||||
* },
|
||||
* onResponseError: async (error) => {
|
||||
* console.error('API error:', error);
|
||||
* throw error;
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createApiClient(config: ApiClientConfig = {}): AxiosInstance {
|
||||
const {
|
||||
baseURL = getApiUrl(),
|
||||
timeout = 10000,
|
||||
headers = { 'Content-Type': 'application/json' },
|
||||
tokenStorageKey = 'auth_token',
|
||||
refreshTokenStorageKey = 'refresh_token',
|
||||
enableTokenRefresh = true,
|
||||
handle401Redirects = false,
|
||||
loginRoute = '/login',
|
||||
onTokenRefresh,
|
||||
onRefreshFailed,
|
||||
onRequest,
|
||||
onResponseError,
|
||||
enableLogging = false,
|
||||
} = config;
|
||||
|
||||
// Track refresh token promise to prevent multiple simultaneous refreshes
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
/**
|
||||
* Add request to queue while refreshing token
|
||||
*/
|
||||
const subscribeTokenRefresh = (callback: (token: string) => void) => {
|
||||
refreshSubscribers.push(callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute all queued requests with new token
|
||||
*/
|
||||
const onTokenRefreshed = (token: string) => {
|
||||
refreshSubscribers.forEach((callback) => callback(token));
|
||||
refreshSubscribers = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to refresh the access token
|
||||
*/
|
||||
const refreshAccessToken = async (): Promise<string | null> => {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshToken = localStorage.getItem(refreshTokenStorageKey);
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a new axios instance without interceptors to avoid infinite loop
|
||||
const refreshClient = axios.create({
|
||||
baseURL,
|
||||
timeout,
|
||||
headers,
|
||||
});
|
||||
|
||||
const response = await refreshClient.post('/auth/refresh', { refreshToken });
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
|
||||
// Update tokens in localStorage
|
||||
localStorage.setItem(tokenStorageKey, accessToken);
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem(refreshTokenStorageKey, newRefreshToken);
|
||||
}
|
||||
|
||||
// Notify callbacks
|
||||
if (onTokenRefresh) {
|
||||
onTokenRefresh(accessToken, newRefreshToken || refreshToken);
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
} catch {
|
||||
// Refresh failed - clear tokens
|
||||
localStorage.removeItem(tokenStorageKey);
|
||||
localStorage.removeItem(refreshTokenStorageKey);
|
||||
|
||||
if (onRefreshFailed) {
|
||||
onRefreshFailed();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Create axios instance
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
timeout,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Request interceptor: Add custom logic first, then auth token, then logging
|
||||
client.interceptors.request.use(
|
||||
async (requestConfig) => {
|
||||
// Run custom interceptor first (if provided)
|
||||
let modifiedConfig = requestConfig;
|
||||
if (onRequest) {
|
||||
modifiedConfig = await onRequest(requestConfig);
|
||||
}
|
||||
|
||||
// Inject auth token from localStorage
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const token = localStorage.getItem(tokenStorageKey);
|
||||
if (token) {
|
||||
modifiedConfig.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Log request in development mode (if enabled)
|
||||
if (enableLogging && isDevelopment()) {
|
||||
const method = modifiedConfig.method?.toUpperCase() || 'GET';
|
||||
const url = modifiedConfig.url || '';
|
||||
console.log(`[API Request] ${method} ${url}`, {
|
||||
params: modifiedConfig.params,
|
||||
data: modifiedConfig.data,
|
||||
});
|
||||
}
|
||||
|
||||
return modifiedConfig;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor: Log responses, handle token refresh and 401s
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
// Log successful response in development mode (if enabled)
|
||||
if (enableLogging && isDevelopment()) {
|
||||
const method = response.config.method?.toUpperCase() || 'GET';
|
||||
const url = response.config.url || '';
|
||||
const {status} = response;
|
||||
console.log(`[API Response] ${method} ${url} - ${status}`, {
|
||||
data: response.data,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
// Log error response in development mode (if enabled)
|
||||
if (enableLogging && isDevelopment()) {
|
||||
const method = error.config?.method?.toUpperCase() || 'GET';
|
||||
const url = error.config?.url || '';
|
||||
const status = error.response?.status || 'Network Error';
|
||||
console.error(`[API Error] ${method} ${url} - ${status}`, {
|
||||
error: error.response?.data || error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Run custom error interceptor first (if provided)
|
||||
if (onResponseError) {
|
||||
return onResponseError(error);
|
||||
}
|
||||
|
||||
// Handle 401 errors
|
||||
if (error.response?.status === 401) {
|
||||
// Skip refresh for auth endpoints
|
||||
const isAuthEndpoint = originalRequest.url?.includes('/auth/login') ||
|
||||
originalRequest.url?.includes('/auth/register') ||
|
||||
originalRequest.url?.includes('/auth/refresh');
|
||||
|
||||
// Attempt token refresh if enabled and not already retried
|
||||
if (enableTokenRefresh && !isAuthEndpoint && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
// Token refresh already in progress - queue this request
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((token: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
resolve(client(originalRequest));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
|
||||
if (newToken) {
|
||||
// Token refreshed successfully - retry original request
|
||||
isRefreshing = false;
|
||||
onTokenRefreshed(newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return client(originalRequest);
|
||||
}
|
||||
} catch {
|
||||
isRefreshing = false;
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to login if 401 handling is enabled
|
||||
if (handle401Redirects && typeof window !== 'undefined') {
|
||||
const isAuthPage = window.location.pathname.includes(loginRoute) ||
|
||||
window.location.pathname.includes('/register');
|
||||
|
||||
if (!isAuthPage) {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(tokenStorageKey);
|
||||
localStorage.removeItem(refreshTokenStorageKey);
|
||||
}
|
||||
window.location.href = loginRoute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
29
src/index.d.ts
vendored
Normal file
29
src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* @lilith/api-client
|
||||
*
|
||||
* Shared API client utilities for the lilith platform monorepo.
|
||||
* Provides a factory function for creating configured axios instances.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createApiClient } from '@lilith/api-client';
|
||||
*
|
||||
* // Create API client with default config
|
||||
* export const apiClient = createApiClient();
|
||||
*
|
||||
* // Create API client with custom config
|
||||
* export const apiClient = createApiClient({
|
||||
* baseURL: 'https://api.example.com',
|
||||
* tokenStorageKey: 'auth_token',
|
||||
* handle401Redirects: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export { createApiClient } from './create-api-client';
|
||||
export type { ApiClientConfig } from './create-api-client';
|
||||
/**
|
||||
* Error handling types and utilities
|
||||
*/
|
||||
export type { ApiError, ApiErrorResponse } from './types/errors';
|
||||
export { isApiError, getErrorMessage } from './types/errors';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
src/index.d.ts.map
Normal file
1
src/index.d.ts.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D;;GAEG;AACH,YAAY,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
1
src/index.js.map
Normal file
1
src/index.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAOtD,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
30
src/index.ts
Normal file
30
src/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* @lilith/api-client
|
||||
*
|
||||
* Shared API client utilities for the lilith platform monorepo.
|
||||
* Provides a factory function for creating configured axios instances.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createApiClient } from '@lilith/api-client';
|
||||
*
|
||||
* // Create API client with default config
|
||||
* export const apiClient = createApiClient();
|
||||
*
|
||||
* // Create API client with custom config
|
||||
* export const apiClient = createApiClient({
|
||||
* baseURL: 'https://api.example.com',
|
||||
* tokenStorageKey: 'auth_token',
|
||||
* handle401Redirects: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { createApiClient } from './create-api-client';
|
||||
export type { ApiClientConfig } from './create-api-client';
|
||||
|
||||
/**
|
||||
* Error handling types and utilities
|
||||
*/
|
||||
export type { ApiError, ApiErrorResponse } from './types/errors';
|
||||
export { isApiError, getErrorMessage } from './types/errors';
|
||||
269
src/types/__tests__/errors.test.ts
Normal file
269
src/types/__tests__/errors.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* @lilith/api-client - Error Utilities Tests
|
||||
*
|
||||
* Comprehensive tests for error type guards and message extraction.
|
||||
*/
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { isApiError, getErrorMessage, ApiErrorResponse } from '../errors';
|
||||
|
||||
describe('isApiError', () => {
|
||||
it('should return true for AxiosError instances', () => {
|
||||
const axiosError = new AxiosError('Test error');
|
||||
expect(isApiError(axiosError)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for AxiosError with response data', () => {
|
||||
const axiosError = new AxiosError('Test error');
|
||||
axiosError.response = {
|
||||
data: {
|
||||
message: 'API error',
|
||||
statusCode: 400,
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
};
|
||||
expect(isApiError(axiosError)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for standard Error objects', () => {
|
||||
const error = new Error('Standard error');
|
||||
expect(isApiError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for string errors', () => {
|
||||
expect(isApiError('Something went wrong')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isApiError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isApiError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for plain objects', () => {
|
||||
expect(isApiError({ message: 'error' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for objects with isAxiosError: false', () => {
|
||||
expect(isApiError({ isAxiosError: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for numbers', () => {
|
||||
expect(isApiError(123)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for booleans', () => {
|
||||
expect(isApiError(true)).toBe(false);
|
||||
expect(isApiError(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
describe('AxiosError handling', () => {
|
||||
it('should extract message from ApiErrorResponse', () => {
|
||||
const axiosError = new AxiosError('Network error');
|
||||
axiosError.response = {
|
||||
data: {
|
||||
message: 'User not found',
|
||||
statusCode: 404,
|
||||
} as ApiErrorResponse,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
};
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('User not found');
|
||||
});
|
||||
|
||||
it('should fall back to error.message if response.data.message is missing', () => {
|
||||
const axiosError = new AxiosError('Network timeout');
|
||||
axiosError.response = {
|
||||
data: {
|
||||
statusCode: 500,
|
||||
} as any,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
};
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('Network timeout');
|
||||
});
|
||||
|
||||
it('should use error.message if response is undefined', () => {
|
||||
const axiosError = new AxiosError('Connection refused');
|
||||
expect(getErrorMessage(axiosError)).toBe('Connection refused');
|
||||
});
|
||||
|
||||
it('should handle validation errors (400 with errors array)', () => {
|
||||
const axiosError = new AxiosError('Validation failed');
|
||||
axiosError.response = {
|
||||
data: {
|
||||
message: 'Validation failed',
|
||||
statusCode: 400,
|
||||
errors: [
|
||||
{ field: 'email', message: 'Invalid email format' },
|
||||
{ field: 'password', message: 'Password too short' },
|
||||
],
|
||||
} as ApiErrorResponse,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
};
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('Validation failed');
|
||||
});
|
||||
|
||||
it('should handle 401 Unauthorized errors', () => {
|
||||
const axiosError = new AxiosError('Unauthorized');
|
||||
axiosError.response = {
|
||||
data: {
|
||||
message: 'Invalid credentials',
|
||||
statusCode: 401,
|
||||
error: 'Unauthorized',
|
||||
} as ApiErrorResponse,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
};
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('Invalid credentials');
|
||||
});
|
||||
|
||||
it('should handle 403 Forbidden errors', () => {
|
||||
const axiosError = new AxiosError('Forbidden');
|
||||
axiosError.response = {
|
||||
data: {
|
||||
message: 'Access denied',
|
||||
statusCode: 403,
|
||||
error: 'Forbidden',
|
||||
} as ApiErrorResponse,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
};
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('Access denied');
|
||||
});
|
||||
|
||||
it('should handle 500 Internal Server Error', () => {
|
||||
const axiosError = new AxiosError('Server error');
|
||||
axiosError.response = {
|
||||
data: {
|
||||
message: 'Internal server error',
|
||||
statusCode: 500,
|
||||
error: 'Internal Server Error',
|
||||
} as ApiErrorResponse,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
};
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('Internal server error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard Error handling', () => {
|
||||
it('should extract message from Error instances', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
expect(getErrorMessage(error)).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should handle TypeError', () => {
|
||||
const error = new TypeError('Cannot read property of undefined');
|
||||
expect(getErrorMessage(error)).toBe('Cannot read property of undefined');
|
||||
});
|
||||
|
||||
it('should handle ReferenceError', () => {
|
||||
const error = new ReferenceError('Variable is not defined');
|
||||
expect(getErrorMessage(error)).toBe('Variable is not defined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('String error handling', () => {
|
||||
it('should return string errors as-is', () => {
|
||||
expect(getErrorMessage('Simple error message')).toBe('Simple error message');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(getErrorMessage('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown error handling', () => {
|
||||
it('should return default message for null', () => {
|
||||
expect(getErrorMessage(null)).toBe('An unknown error occurred');
|
||||
});
|
||||
|
||||
it('should return default message for undefined', () => {
|
||||
expect(getErrorMessage(undefined)).toBe('An unknown error occurred');
|
||||
});
|
||||
|
||||
it('should return default message for numbers', () => {
|
||||
expect(getErrorMessage(123)).toBe('An unknown error occurred');
|
||||
});
|
||||
|
||||
it('should return default message for booleans', () => {
|
||||
expect(getErrorMessage(true)).toBe('An unknown error occurred');
|
||||
expect(getErrorMessage(false)).toBe('An unknown error occurred');
|
||||
});
|
||||
|
||||
it('should return default message for plain objects', () => {
|
||||
expect(getErrorMessage({ foo: 'bar' })).toBe('An unknown error occurred');
|
||||
});
|
||||
|
||||
it('should return default message for arrays', () => {
|
||||
expect(getErrorMessage([1, 2, 3])).toBe('An unknown error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle error objects without message property', () => {
|
||||
const weirdError = { isAxiosError: false, foo: 'bar' };
|
||||
expect(getErrorMessage(weirdError)).toBe('An unknown error occurred');
|
||||
});
|
||||
|
||||
it('should handle AxiosError with malformed response', () => {
|
||||
const axiosError = new AxiosError('Request failed');
|
||||
axiosError.response = {
|
||||
data: 'Not a JSON response',
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
} as any;
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('Request failed');
|
||||
});
|
||||
|
||||
it('should handle AxiosError with null response data', () => {
|
||||
const axiosError = new AxiosError('Request failed');
|
||||
axiosError.response = {
|
||||
data: null,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
} as any;
|
||||
|
||||
expect(getErrorMessage(axiosError)).toBe('Request failed');
|
||||
});
|
||||
|
||||
it('should handle empty Error message', () => {
|
||||
const error = new Error('');
|
||||
expect(getErrorMessage(error)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
117
src/types/errors.ts
Normal file
117
src/types/errors.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* @lilith/api-client - Error Types
|
||||
*
|
||||
* Standardized error types and utilities for API error handling.
|
||||
* Provides type-safe error checking and consistent error message extraction.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { isApiError, getErrorMessage } from '@lilith/api-client';
|
||||
*
|
||||
* try {
|
||||
* await apiClient.post('/users', userData);
|
||||
* } catch (error) {
|
||||
* if (isApiError(error)) {
|
||||
* // TypeScript knows this is an AxiosError with ApiErrorResponse
|
||||
* console.error(error.response?.data.message);
|
||||
* }
|
||||
* toast.error(getErrorMessage(error));
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
/**
|
||||
* Standard API error response structure.
|
||||
* Matches NestJS exception filter output format.
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
/**
|
||||
* Human-readable error message
|
||||
*/
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* HTTP status code (400, 401, 403, 404, 500, etc.)
|
||||
*/
|
||||
statusCode: number;
|
||||
|
||||
/**
|
||||
* Error type/category (optional, e.g., "Bad Request", "Unauthorized")
|
||||
*/
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* Validation errors (optional, for 400 Bad Request responses)
|
||||
*/
|
||||
errors?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for Axios errors with our standardized error response.
|
||||
*/
|
||||
export type ApiError = AxiosError<ApiErrorResponse>;
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is an API error (AxiosError).
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if error is an AxiosError with ApiErrorResponse
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (isApiError(error)) {
|
||||
* // TypeScript knows error.response exists
|
||||
* console.log(error.response?.data.statusCode);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isApiError(error: unknown): error is ApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'isAxiosError' in error &&
|
||||
(error as any).isAxiosError === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable error message from any error type.
|
||||
* Handles API errors, standard Error objects, strings, and unknown types.
|
||||
*
|
||||
* @param error - The error to extract message from
|
||||
* @returns Human-readable error message
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* await apiClient.post('/users', data);
|
||||
* } catch (error) {
|
||||
* // Works with any error type
|
||||
* toast.error(getErrorMessage(error));
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
// API errors with structured response
|
||||
if (isApiError(error)) {
|
||||
return error.response?.data?.message || error.message;
|
||||
}
|
||||
|
||||
// Standard JavaScript Error objects
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// String errors (e.g., throw "Something went wrong")
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Unknown error types
|
||||
return 'An unknown error occurred';
|
||||
}
|
||||
78
src/utils/env.test.ts
Normal file
78
src/utils/env.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { getApiUrl, getAppName, isDevelopment, isProduction, getEnv } from './env'
|
||||
|
||||
describe('Environment Utilities', () => {
|
||||
describe('getApiUrl', () => {
|
||||
it('should return API URL from environment or fallback to default', () => {
|
||||
const url = getApiUrl()
|
||||
// URL should be valid and contain localhost:4000
|
||||
expect(url).toMatch(/^https?:\/\/localhost:4000/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAppName', () => {
|
||||
it('should return app name from environment or fallback to "unknown"', () => {
|
||||
const appName = getAppName()
|
||||
expect(appName).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDevelopment', () => {
|
||||
it('should detect development mode correctly', () => {
|
||||
const isDev = isDevelopment()
|
||||
// In test environment, this will vary based on NODE_ENV
|
||||
expect(typeof isDev).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isProduction', () => {
|
||||
it('should detect production mode correctly', () => {
|
||||
const isProd = isProduction()
|
||||
// In test environment, this will vary based on NODE_ENV
|
||||
expect(typeof isProd).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should be opposite of isDevelopment in most cases', () => {
|
||||
const isDev = isDevelopment()
|
||||
const isProd = isProduction()
|
||||
// They should not both be true
|
||||
expect(isDev && isProd).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEnv', () => {
|
||||
it('should retrieve environment variable or return undefined', () => {
|
||||
const value = getEnv('NONEXISTENT_VAR')
|
||||
expect(value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should use fallback when variable does not exist', () => {
|
||||
const value = getEnv('NONEXISTENT_VAR', 'fallback')
|
||||
expect(value).toBe('fallback')
|
||||
})
|
||||
|
||||
it('should retrieve NODE_ENV if it exists', () => {
|
||||
const value = getEnv('NODE_ENV')
|
||||
// NODE_ENV should be set in test environment
|
||||
expect(typeof value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-environment compatibility', () => {
|
||||
it('should handle Vite environment gracefully', () => {
|
||||
// If import.meta.env exists, these functions should work
|
||||
expect(() => getApiUrl()).not.toThrow()
|
||||
expect(() => getAppName()).not.toThrow()
|
||||
expect(() => isDevelopment()).not.toThrow()
|
||||
expect(() => isProduction()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle Node.js environment gracefully', () => {
|
||||
// Even if process.env doesn't have values, should not throw
|
||||
expect(() => getEnv('ANY_VAR')).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
72
src/utils/env.ts
Normal file
72
src/utils/env.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* Environment variable helpers for API client configuration
|
||||
* Supports both Vite (import.meta.env) and Node.js (process.env) environments
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the API base URL from environment variables
|
||||
* Falls back to development URL if not specified
|
||||
*/
|
||||
export function getApiUrl(): string {
|
||||
// Check Vite environment first (browser/Vite apps)
|
||||
if (import.meta?.env?.VITE_API_URL) {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
// Fallback to Node.js environment (server-side or tests)
|
||||
return process.env?.VITE_API_URL || process.env?.API_URL || 'http://localhost:4002';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application name from environment variables
|
||||
*/
|
||||
export function getAppName(): string {
|
||||
if (import.meta?.env?.VITE_APP_NAME) {
|
||||
return import.meta.env.VITE_APP_NAME;
|
||||
}
|
||||
|
||||
return process.env?.VITE_APP_NAME || process.env?.APP_NAME || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in development mode
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
if (typeof import.meta !== 'undefined') {
|
||||
return import.meta.env?.MODE === 'development' || import.meta.env?.DEV === true;
|
||||
}
|
||||
|
||||
return process.env?.NODE_ENV === 'development';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
export function isProduction(): boolean {
|
||||
if (typeof import.meta !== 'undefined') {
|
||||
return import.meta.env?.MODE === 'production' || import.meta.env?.PROD === true;
|
||||
}
|
||||
|
||||
return process.env?.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variable value with optional fallback
|
||||
*/
|
||||
export function getEnv(key: string, fallback?: string): string | undefined {
|
||||
// Try Vite environment
|
||||
if (typeof import.meta !== 'undefined') {
|
||||
const value = import.meta.env?.[key];
|
||||
if (value !== undefined) {return String(value);}
|
||||
}
|
||||
|
||||
// Try Node.js environment
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined) {return value;}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "@lilith/configs/typescript/react.json",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue