@voyagerpoland/proxy
v0.1.0
Published
HTTP service proxy with Result Pattern integration for Angular
Downloads
118
Readme
@voyagerpoland/proxy
HTTP service proxy with Result Pattern integration for Angular. Wraps Angular HttpClient calls into ResultAsync<T> with built-in retry and circuit breaker support.
Installation
npm install @voyagerpoland/proxyPeer dependencies
npm install @voyagerpoland/results neverthrow rxjs @angular/commonAPI Reference
fromHttp<T>(observable, errorMapper?)
Converts an Angular HTTP Observable<T> into a ResultAsync<T>.
import { fromHttp } from '@voyagerpoland/proxy';
const result = await fromHttp(httpClient.get<User>('/api/users/1'));
// Result<User> — Ok({ id: 1, name: 'Alice' }) or Err(AppError)fromHttpError(error)
Converts an HttpErrorResponse (or compatible object) to an AppError with HTTP context.
import { fromHttpError } from '@voyagerpoland/proxy';
const appError = fromHttpError(httpErrorResponse);
// appError.type === ErrorType.NotFound (for 404)
// appError.context.get('url') === '/api/users/42'
// appError.context.get('statusText') === 'Not Found'ServiceProxy (abstract class)
Abstract base class for Angular HTTP service proxies with built-in resilience. Retry and circuit breaker are enabled by default — no configuration needed.
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ServiceProxy } from '@voyagerpoland/proxy';
import type { ResultAsync } from '@voyagerpoland/results';
import { API_BASE_URL } from '../api-config';
// DTO: automatically generated from OpenAPI
import type { Order, CreateOrderRequest, OrderListResponse } from '../generated/models';
@Injectable({ providedIn: 'root' })
class OrderService extends ServiceProxy {
constructor() {
super(inject(HttpClient), inject(API_BASE_URL));
}
getOrder(id: number): ResultAsync<Order> {
return this.get<Order>(`/orders/${id}`);
}
listOrders(status?: string, limit?: number): ResultAsync<OrderListResponse> {
return this.get<OrderListResponse>('/orders', {
status,
limit: limit?.toString(),
});
}
createOrder(request: CreateOrderRequest): ResultAsync<Order> {
return this.post<Order>('/orders', request);
}
deleteOrder(id: number): ResultAsync {
return this.del(`/orders/${id}`);
}
}3 lines per endpoint. DTO automatic from OpenAPI. Retry + circuit breaker for free.
Using in Angular components
@Component({
/* ... */
})
class UserComponent {
private readonly orders = inject(OrderService);
loadOrder(id: number): void {
this.orders.getOrder(id).match(
(order) => this.order.set(order),
(error) => this.error.set(error.message),
);
}
}ServiceProxyOptions
All fields are optional — when omitted, sensible defaults are used:
interface ServiceProxyOptions {
maxRetryAttempts?: number; // default: 3
retryBaseDelayMs?: number; // default: 1000
circuitBreakerThreshold?: number; // default: 5
circuitBreakerTimeoutMs?: number; // default: 30000
authHandler?: AuthHandler; // default: undefined (no auth handling)
}Custom configuration example:
super(inject(HttpClient), inject(API_BASE_URL), {
maxRetryAttempts: 5,
retryBaseDelayMs: 500,
circuitBreakerThreshold: 10,
circuitBreakerTimeoutMs: 60_000,
});AuthHandler
Optional strategy for reacting to HTTP 401 (Unauthorized) errors. When configured, ServiceProxy calls onUnauthorized() before returning an error, giving the handler a chance to refresh credentials and retry the request.
interface AuthHandler {
onUnauthorized(): Promise<{ retry: boolean }>;
}Flow:
Request → 401
→ authHandler defined?
→ NO: Err(Unauthorized) immediately (default behavior)
→ YES: await authHandler.onUnauthorized()
→ { retry: true } → retry request once (with refreshed credentials)
→ success → Ok(T)
→ 401 again → Err(Unauthorized) — prevents infinite loop
→ { retry: false } → Err(Unauthorized)Key design decisions:
- Max 1 retry after auth refresh — prevents infinite loops
- Only 401, not 403 — Permission errors are not token problems
- Handler owns coordination — single-refresh guard is the handler's responsibility
- Framework-agnostic — works with BFF, token refresh, or any auth strategy
Example: BFF (Duende IdentityServer)
class BffAuthHandler implements AuthHandler {
constructor(private window: Window) {}
async onUnauthorized(): Promise<{ retry: boolean }> {
const returnUrl = this.window.location.pathname;
this.window.location.href = `/bff/login?returnUrl=${encodeURIComponent(returnUrl)}`;
// Redirect leaves the SPA — never resolves
return { retry: false };
}
}
@Injectable({ providedIn: 'root' })
class OrderService extends ServiceProxy {
constructor() {
super(inject(HttpClient), inject(API_BASE_URL), {
authHandler: new BffAuthHandler(window),
});
}
}Example: Token refresh (legacy)
class TokenAuthHandler implements AuthHandler {
private refreshInProgress: Promise<{ retry: boolean }> | null = null;
constructor(
private authService: AuthService,
private router: Router,
) {}
async onUnauthorized(): Promise<{ retry: boolean }> {
// Single-refresh guard — concurrent 401s share one refresh
if (this.refreshInProgress) {
return this.refreshInProgress;
}
this.refreshInProgress = this.doRefresh();
try {
return await this.refreshInProgress;
} finally {
this.refreshInProgress = null;
}
}
private async doRefresh(): Promise<{ retry: boolean }> {
try {
await this.authService.refreshToken();
return { retry: true };
} catch {
this.router.navigate(['/login'], {
queryParams: { returnUrl: this.router.url },
});
return { retry: false };
}
}
}After a successful refresh, ServiceProxy retries the original HttpClient call. Angular interceptors run again on the new request, automatically attaching the refreshed Bearer token.
ProxyDiagnostics
Optional callback-based telemetry hooks. When configured, ServiceProxy emits events for request lifecycle, retry attempts, and circuit breaker state changes. Vendor-neutral — wire to Application Insights, OpenTelemetry, or any provider.
interface ProxyDiagnostics {
onRequestStart?: (event: RequestStartEvent) => void;
onRequestComplete?: (event: RequestCompleteEvent) => void;
onRequestFail?: (event: RequestFailEvent) => void;
onRetryAttempt?: (event: RetryAttemptEvent) => void;
onCircuitBreakerStateChanged?: (event: CircuitBreakerStateChangedEvent) => void;
}Configuration:
super(inject(HttpClient), inject(API_BASE_URL), {
diagnostics: {
onRequestStart: (e) => console.log(`${e.method} ${e.url}`),
onRequestComplete: (e) =>
console.log(`${e.method} ${e.url} ${e.durationMs}ms (${e.retryAttempts} retries)`),
onRequestFail: (e) => console.error(`${e.method} ${e.url} failed: ${e.error.type}`),
onRetryAttempt: (e) => console.warn(`Retry #${e.attempt} for ${e.url}, delay ${e.delayMs}ms`),
onCircuitBreakerStateChanged: (e) => console.warn(`Circuit: ${e.oldState} → ${e.newState}`),
},
});Event types
| Event | Fields | When |
| --------------------------------- | ------------------------------------------------------- | --------------------------------------------- |
| RequestStartEvent | method, url, startedAt | Before first HTTP attempt |
| RequestCompleteEvent | method, url, durationMs, retryAttempts | After successful response (including retries) |
| RequestFailEvent | method, url, durationMs, retryAttempts, error | After all retries exhausted |
| RetryAttemptEvent | method, url, attempt, delayMs, error | On each retry attempt |
| CircuitBreakerStateChangedEvent | oldState, newState, failureCount, lastError | When circuit breaker transitions |
Example: Application Insights adapter
import { ApplicationInsights } from '@microsoft/applicationinsights-web';
import type { ProxyDiagnostics } from '@voyagerpoland/proxy';
export function createAppInsightsDiagnostics(appInsights: ApplicationInsights): ProxyDiagnostics {
return {
onRequestComplete: (event) => {
appInsights.trackDependencyData({
id: crypto.randomUUID(),
name: `${event.method} ${event.url}`,
duration: event.durationMs,
success: true,
responseCode: 200,
properties: { retryAttempts: String(event.retryAttempts) },
});
},
onRequestFail: (event) => {
appInsights.trackDependencyData({
id: crypto.randomUUID(),
name: `${event.method} ${event.url}`,
duration: event.durationMs,
success: false,
responseCode: Number(event.error.code.replace('HTTP_', '')) || 0,
properties: {
retryAttempts: String(event.retryAttempts),
errorType: event.error.type,
},
});
},
onRetryAttempt: (event) => {
appInsights.trackEvent({
name: 'ServiceProxy.RetryAttempt',
properties: {
url: event.url,
attempt: String(event.attempt),
errorType: event.error.type,
},
});
},
onCircuitBreakerStateChanged: (event) => {
appInsights.trackEvent({
name: 'ServiceProxy.CircuitBreakerStateChanged',
properties: {
oldState: event.oldState,
newState: event.newState,
failureCount: String(event.failureCount),
},
});
},
};
}composeDiagnostics(...providers)
Combines multiple ProxyDiagnostics providers into one. Each event is forwarded to all providers that define the corresponding callback.
import { composeDiagnostics } from '@voyagerpoland/proxy';
import type { ProxyDiagnostics } from '@voyagerpoland/proxy';
// Provider 1: console logging (dev)
function consoleDiagnostics(): ProxyDiagnostics {
return {
onRequestStart: (e) => console.log(`→ ${e.method} ${e.url}`),
onRequestComplete: (e) => console.log(`✓ ${e.method} ${e.url} ${e.durationMs}ms`),
onRequestFail: (e) => console.error(`✗ ${e.method} ${e.url} failed: ${e.error.type}`),
onRetryAttempt: (e) => console.warn(`↻ Retry #${e.attempt} for ${e.url}`),
};
}
// Provider 2: Application Insights (prod) — see adapter example above
// Compose both
const diagnostics = composeDiagnostics(
consoleDiagnostics(),
createAppInsightsDiagnostics(appInsights),
);
super(inject(HttpClient), inject(API_BASE_URL), { diagnostics });Providers that don't define a callback for a given event are simply skipped. You can compose any number of providers.
Integration Guide: OpenAPI DTO Generation + ServiceProxy
@voyagerpoland/proxy provides the ServiceProxy base class. The consumer project is responsible for generating TypeScript DTOs from the backend's OpenAPI spec. Together they give 3 lines per endpoint with full type safety.
Architecture overview
┌─────────────────────────────────────────────────┐
│ Backend .NET │
│ ASP.NET Core → swagger.json (automatically) │
└──────────────────────┬──────────────────────────-┘
│
▼
┌──────────────────────────────────────────────────┐
│ ng-openapi-gen (build-time) │
│ swagger.json → TypeScript DTO (models only) │
│ │
│ Generated: │
│ - models/order.ts (interface Order) │
│ - models/create-order-request.ts │
│ - models/order-list-response.ts │
└──────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Service facade (hand-written, ~3 lines/endpoint)│
│ │
│ class OrderService extends ServiceProxy { │
│ getOrder(id) = this.get<Order>(`/orders/${id}`)│
│ } │
└──────────────────────────────────────────────────┘Step 1: Install ng-openapi-gen
npm install -D ng-openapi-genStep 2: Get the OpenAPI spec
Download swagger.json from the backend (or point to a URL):
curl -o swagger.json https://api.example.com/swagger/v1/swagger.jsonStep 3: Configure ng-openapi-gen
Create ng-openapi-gen.json in the project root:
{
"$schema": "node_modules/ng-openapi-gen/ng-openapi-gen-schema.json",
"input": "swagger.json",
"output": "src/app/api/generated",
"ignoreUnusedModels": false,
"services": false
}
"services": false— generate only models, not services. Service facades are hand-written withServiceProxy.
Step 4: Add npm scripts
{
"scripts": {
"api:generate": "ng-openapi-gen --config ng-openapi-gen.json",
"api:update": "curl -o swagger.json https://api.example.com/swagger/v1/swagger.json && npm run api:generate",
"prebuild": "npm run api:generate"
}
}Step 5: Run the generator
npm run api:generateThis produces files under src/app/api/generated/models/:
src/app/api/generated/
models/
order.ts ← interface Order { id: number; ... }
create-order-request.ts ← interface CreateOrderRequest { ... }
order-list-response.ts ← interface OrderListResponse { ... }Step 6: Provide API_BASE_URL token
// src/app/api/api-config.ts
import { InjectionToken } from '@angular/core';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');// app.config.ts
import { API_BASE_URL } from './api/api-config';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(), { provide: API_BASE_URL, useValue: 'https://api.example.com' }],
};Step 7: Write the service facade
// src/app/api/order.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import type { ResultAsync } from '@voyagerpoland/results';
import { ServiceProxy } from '@voyagerpoland/proxy';
import { API_BASE_URL } from './api-config';
// Types generated from OpenAPI (Step 5)
import type { Order, CreateOrderRequest, OrderListResponse } from './generated/models';
@Injectable({ providedIn: 'root' })
export class OrderService extends ServiceProxy {
constructor() {
super(inject(HttpClient), inject(API_BASE_URL));
}
getOrder(id: number): ResultAsync<Order> {
return this.get<Order>(`/orders/${id}`);
}
listOrders(status?: string, limit?: number): ResultAsync<OrderListResponse> {
return this.get<OrderListResponse>('/orders', {
status,
limit: limit?.toString(),
});
}
createOrder(request: CreateOrderRequest): ResultAsync<Order> {
return this.post<Order>('/orders', request);
}
deleteOrder(id: number): ResultAsync {
return this.del(`/orders/${id}`);
}
}Step 8: Use in components
@Component({
/* ... */
})
export class OrderListComponent {
private readonly orderService = inject(OrderService);
orders = signal<Order[]>([]);
error = signal<string | null>(null);
loadOrders(): void {
this.orderService.listOrders('active', 50).match(
(response) => this.orders.set(response.items),
(error) => this.error.set(error.message),
);
}
}Target project structure
src/app/api/
generated/ ← ng-openapi-gen output (git-ignored or committed)
models/
order.ts
create-order-request.ts
order-list-response.ts
...
api-config.ts ← API_BASE_URL injection token
order.service.ts ← extends ServiceProxy + generated DTO
payment.service.ts
user.service.tsCost per new endpoint
| Step | Time | Who |
| ---------------------------------------- | ---------- | ------------------ |
| Add endpoint in C# | 5 min | Backend developer |
| swagger.json regenerates automatically | 0 min | CI/CD |
| npm run api:generate regenerates DTO | 0 min | npm script |
| Add 1 method in service facade (3 lines) | 1 min | Frontend developer |
| Total | ~6 min | |
Alternative generators
If ng-openapi-gen doesn't fit your needs:
| Generator | Angular HttpClient | DTO | Requires | Notes | | --------------------- | ------------------ | --- | -------- | ------------------------ | | ng-openapi-gen | Yes | Yes | Node.js | Recommended, lightweight | | openapi-generator | Yes | Yes | JVM | More mature, heavier | | NSwag | Yes | Yes | .NET SDK | Best .NET integration |
All generators work the same way: generate only models ("services": false), write service facades with ServiceProxy.
HTTP Status Code Mapping
| Status | ErrorType | Classification | | ------ | --------------- | -------------- | | 0 | Unavailable | Transient | | 400 | Validation | Business | | 401 | Unauthorized | Business* | | 403 | Permission | Business | | 404 | NotFound | Business | | 409 | Conflict | Business | | 422 | Business | Business | | 429 | TooManyRequests | Transient | | 503 | Unavailable | Transient | | 504 | Timeout | Transient | | Other | Unexpected | Infrastructure |
* When authHandler is configured, 401 triggers onUnauthorized() before returning Err. See AuthHandler.
License
MIT
