use crate::client_generator::{
ClientGenerationResult, ClientGeneratorConfig, ClientGeneratorPlugin, GeneratedFile,
GenerationMetadata, OpenApiSpec,
};
use crate::types::{PluginError, PluginMetadata, Result};
use handlebars::Handlebars;
use serde_json::{json, Value};
use std::collections::HashMap;
pub struct AngularClientGenerator {
templates: Handlebars<'static>,
}
impl AngularClientGenerator {
pub fn new() -> Result<Self> {
let mut templates = Handlebars::new();
Self::register_templates(&mut templates)?;
Ok(Self { templates })
}
fn process_schema_with_required_flags(mut schema: Value) -> Value {
let required_fields: Vec<String> = schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
.unwrap_or_default();
if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
for (prop_name, prop_value) in properties.iter_mut() {
if let Some(prop_obj) = prop_value.as_object_mut() {
prop_obj.insert(
"required".to_string(),
Value::Bool(required_fields.contains(prop_name)),
);
}
}
}
schema
}
fn register_templates(templates: &mut Handlebars<'static>) -> Result<()> {
templates.register_helper(
"json",
Box::new(
|h: &handlebars::Helper,
_: &Handlebars,
_: &handlebars::Context,
_: &mut handlebars::RenderContext,
out: &mut dyn handlebars::Output|
-> handlebars::HelperResult {
let value = h.param(0).ok_or_else(|| {
handlebars::RenderError::new("json helper requires a parameter")
})?;
let json_str = serde_json::to_string(&value.value()).map_err(|e| {
handlebars::RenderError::new(format!("Failed to serialize to JSON: {}", e))
})?;
out.write(&json_str)?;
Ok(())
},
),
);
templates.register_helper(
"eq",
Box::new(
|h: &handlebars::Helper,
_: &Handlebars,
_: &handlebars::Context,
_: &mut handlebars::RenderContext,
out: &mut dyn handlebars::Output|
-> handlebars::HelperResult {
let a = h.param(0).and_then(|v| v.value().as_str());
let b = h.param(1).and_then(|v| v.value().as_str());
if a == b {
out.write("true")?;
}
Ok(())
},
),
);
templates.register_helper(
"toLowerCase",
Box::new(
|h: &handlebars::Helper,
_: &Handlebars,
_: &handlebars::Context,
_: &mut handlebars::RenderContext,
out: &mut dyn handlebars::Output|
-> handlebars::HelperResult {
let param = h.param(0).and_then(|v| v.value().as_str());
if let Some(s) = param {
out.write(&s.to_lowercase())?;
}
Ok(())
},
),
);
templates
.register_template_string(
"types",
r#"// Generated TypeScript types for {{api_title}}
// API Version: {{api_version}}
{{#each schemas}}
export interface {{@key}} {
{{#each this.properties}}
{{#if (lookup ../this.required @key)}}
{{@key}}: {{> typescript_type this}};
{{else}}
{{@key}}?: {{> typescript_type this}};
{{/if}}
{{/each}}
}
{{/each}}
// API Response types
{{#each operations}}
export interface {{response_type_name}} {
{{#each responses}}
{{#if (eq @key "200")}}
{{#if this.content}}
{{#each this.content}}
{{#if (eq @key "application/json")}}
{{#if this.schema}}
{{#if this.schema.properties}}
{{#each this.schema.properties}}
{{@key}}{{#unless this.required}}?{{/unless}}: {{> typescript_type this}};
{{/each}}
{{else}}
{{#if (eq this.schema.type "object")}}
[key: string]: any;
{{else}}
{{#if this.schema.type}}
value: {{> typescript_type this.schema}};
{{/if}}
{{/if}}
{{/if}}
{{/if}}
{{/if}}
{{/each}}
{{/if}}
{{/if}}
{{/each}}
}
{{/each}}
// API Request types
{{#each operations}}
{{#if request_body}}
export interface {{request_type_name}} {
{{#each request_body.content}}
{{#if (eq @key "application/json")}}
{{#if this.schema}}
{{#if this.schema.properties}}
{{#each this.schema.properties}}
{{@key}}{{#unless this.required}}?{{/unless}}: {{> typescript_type this}};
{{/each}}
{{else}}
{{> typescript_type this.schema}}
{{/if}}
{{/if}}
{{/if}}
{{/each}}
}
{{/if}}
{{/each}}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register types template: {}", e))
})?;
templates
.register_template_string(
"typescript_type",
r#"{{#if (eq type "string")}}string{{/if}}{{#if (eq type "integer")}}number{{/if}}{{#if (eq type "number")}}number{{/if}}{{#if (eq type "boolean")}}boolean{{/if}}{{#if (eq type "array")}}{{#if items}}Array<{{> typescript_type items}}>{{else}}any[]{{/if}}{{/if}}{{#if (eq type "object")}}{{#if properties}}{
{{#each properties}}
{{@key}}{{#unless (lookup ../required @key)}}?{{/unless}}: {{> typescript_type this}};{{#unless @last}}
{{/unless}}{{/each}}
}{{else}}Record<string, any>{{/if}}{{/if}}{{#unless type}}any{{/unless}}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register typescript_type template: {}", e))
})?;
templates
.register_template_string(
"core",
r#"// Generated Angular core infrastructure for {{api_title}}
// API Version: {{api_version}}
import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
// ============================================================================
// Error Handling
// ============================================================================
/**
* Base API Error class with structured error information
* Compatible with Angular HttpErrorResponse
*/
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public body?: any,
message?: string
) {
super(message || `API Error: ${status} ${statusText}`);
this.name = 'ApiError';
Object.setPrototypeOf(this, ApiError.prototype);
}
/**
* Create ApiError from Angular HttpErrorResponse
*/
static fromHttpErrorResponse(error: HttpErrorResponse): ApiError {
return new ApiError(
error.status,
error.statusText,
error.error,
error.message
);
}
/**
* Check if error is a client error (4xx)
*/
isClientError(): boolean {
return this.status >= 400 && this.status < 500;
}
/**
* Check if error is a server error (5xx)
*/
isServerError(): boolean {
return this.status >= 500;
}
/**
* Get error details from response body if available
*/
getErrorDetails(): any {
return this.body;
}
/**
* Get verbose error message with validation details
*/
getVerboseMessage(): string {
let message = `${this.status} ${this.statusText}`;
if (this.body) {
if (typeof this.body === 'object') {
// Handle validation errors
if (this.body.errors && Array.isArray(this.body.errors)) {
const validationErrors = this.body.errors
.map((err: any) => {
if (typeof err === 'string') return err;
if (err.field && err.message) return `${err.field}: ${err.message}`;
if (err.message) return err.message;
return JSON.stringify(err);
})
.join('; ');
message += ` - Validation errors: ${validationErrors}`;
} else if (this.body.message) {
message += ` - ${this.body.message}`;
} else if (this.body.error) {
message += ` - ${this.body.error}`;
if (this.body.error_description) {
message += ` (${this.body.error_description})`;
}
} else {
message += ` - ${JSON.stringify(this.body)}`;
}
} else if (typeof this.body === 'string') {
message += ` - ${this.body}`;
}
}
return message;
}
}
/**
* Error thrown when a required parameter is missing
*/
export class RequiredError extends Error {
constructor(public field: string, message?: string) {
super(message || `Required parameter '${field}' was null or undefined`);
this.name = 'RequiredError';
Object.setPrototypeOf(this, RequiredError.prototype);
}
}
/**
* Contract validation error with schema path and contract diff reference
*
* This error type provides detailed information about validation failures
* and can link back to contract diff entries for tracking breaking changes.
*/
export class ContractValidationError extends ApiError {
constructor(
status: number,
statusText: string,
public schemaPath: string,
public expectedType: string,
public actualValue?: any,
public contractDiffId?: string,
public isBreakingChange: boolean = false,
body?: any,
message?: string
) {
super(
status,
statusText,
body,
message || `Contract validation failed at '${schemaPath}': expected ${expectedType}${actualValue !== undefined ? `, got ${JSON.stringify(actualValue)}` : ''}`
);
this.name = 'ContractValidationError';
Object.setPrototypeOf(this, ContractValidationError.prototype);
}
/**
* Get a detailed error message with contract diff information
*/
getDetailedMessage(): string {
let msg = `Validation failed at '${this.schemaPath}': expected ${this.expectedType}`;
if (this.actualValue !== undefined) {
msg += `, got ${typeof this.actualValue === 'object' ? JSON.stringify(this.actualValue) : String(this.actualValue)}`;
}
if (this.contractDiffId) {
msg += ` (Contract Diff ID: ${this.contractDiffId})`;
}
if (this.isBreakingChange) {
msg += ' [BREAKING CHANGE]';
}
return msg;
}
}
// ============================================================================
// Configuration Interfaces
// ============================================================================
/**
* OAuth2 Flow Configuration
*
* ⚠️ SECURITY WARNING:
* - NEVER include clientSecret in browser/client-side code
* - Client secrets should only be used in server-side applications
* - For browser apps, use authorization_code flow with PKCE (recommended)
*/
export interface OAuth2Config {
/** OAuth2 client ID */
clientId: string;
/**
* OAuth2 client secret (for client_credentials flow)
* ⚠️ SECURITY: Only use in server-side apps. NEVER expose in browser code!
*/
clientSecret?: string;
/** Authorization URL (for authorization_code flow) */
authorizationUrl?: string;
/** Token URL for obtaining access tokens */
tokenUrl: string;
/** Redirect URI (for authorization_code flow) */
redirectUri?: string;
/** Scopes to request */
scopes?: string[];
/** OAuth2 flow type */
flow?: 'authorization_code' | 'client_credentials' | 'implicit' | 'password';
/** Token storage key (default: 'oauth2_token') */
tokenStorageKey?: string;
/** Callback for token refresh */
onTokenRefresh?: (token: string) => void | Promise<void>;
/** State parameter for CSRF protection (auto-generated if not provided) */
state?: string;
/** PKCE code verifier for authorization_code flow (recommended for browser apps) */
codeVerifier?: string;
}
/**
* JWT Token Configuration
* Handles JWT token refresh on 401 errors
*/
export interface JwtConfig {
/** Refresh endpoint URL (default: '/api/v1/auth/refresh') */
refreshEndpoint?: string;
/** Refresh token (static or dynamic function) */
refreshToken?: string | (() => string | Promise<string>);
/** Callback invoked when token is refreshed */
onTokenRefresh?: (token: string) => void | Promise<void>;
/** Callback invoked when authentication fails (refresh token invalid/expired) */
onAuthError?: () => void | Promise<void>;
/** Refresh token if it expires within this many seconds (default: 300) */
refreshThreshold?: number;
/** Check token expiration before making requests (default: true) */
checkExpirationBeforeRequest?: boolean;
}
/**
* Retry Configuration
* Configures automatic retry behavior for failed requests
*/
export interface RetryConfig {
/** Maximum number of retry attempts (default: 3) */
maxRetries?: number;
/** Base delay in milliseconds for exponential backoff (default: 1000) */
baseDelay?: number;
/** Maximum delay in milliseconds (default: 10000) */
maxDelay?: number;
/** HTTP status codes that should be retried (default: [408, 429, 500, 502, 503, 504]) */
retryableStatusCodes?: number[];
/** Whether to retry on network errors (default: true) */
retryOnNetworkError?: boolean;
}
/**
* API Configuration with support for authentication and interceptors
*/
export interface ApiConfig {
/** Base URL for API requests */
baseUrl: string;
/** Default headers to include with every request */
headers?: Record<string, string>;
/** Bearer token for authentication */
accessToken?: string | (() => string | Promise<string>);
/** API key for authentication (supports function for dynamic keys) */
apiKey?: string | ((name: string) => string | Promise<string>);
/** Username for basic authentication */
username?: string;
/** Password for basic authentication */
password?: string;
/** OAuth2 configuration for OAuth flows */
oauth2?: OAuth2Config;
/** JWT token configuration for automatic refresh on 401 */
jwt?: JwtConfig;
/** Retry configuration for automatic retry on failures */
retry?: RetryConfig;
/** Timeout in milliseconds (default: 30000) */
timeout?: number;
/** Enable request/response validation (default: false) */
validateRequests?: boolean;
/** Enable response validation (default: false) */
validateResponses?: boolean;
/** Enable verbose error messages (default: false) */
verboseErrors?: boolean;
/** Automatically unwrap ApiResponse<T> format to return data directly (default: false) */
unwrapResponse?: boolean;
/** Schema registry for runtime validation (schema_id -> JSON Schema) */
schemas?: Record<string, any>;
/** Whether to include contract diff references in validation errors */
includeContractDiffs?: boolean;
}
/**
* Token Storage Interface
* Allows different storage backends (localStorage, httpOnly cookies, secure storage)
*/
export interface TokenStorage {
/** Get access token from storage */
getAccessToken(): string | null | Promise<string | null>;
/** Store access token with optional expiration (in seconds) */
setAccessToken(token: string, expiresIn?: number): void | Promise<void>;
/** Get refresh token from storage */
getRefreshToken(): string | null | Promise<string | null>;
/** Store refresh token */
setRefreshToken(token: string): void | Promise<void>;
/** Clear all tokens from storage */
clearTokens(): void | Promise<void>;
}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register core template: {}", e))
})?;
templates
.register_template_string(
"token-storage",
r#"// Generated Angular Token Storage Service for {{api_title}}
// API Version: {{api_version}}
import { Injectable } from '@angular/core';
import { TokenStorage } from './core';
/**
* LocalStorage-based token storage service implementation
* ⚠️ SECURITY: localStorage is vulnerable to XSS attacks
* Consider using httpOnly cookies or secure storage for production apps
*/
@Injectable({
providedIn: 'root'
})
export class LocalStorageTokenStorageService implements TokenStorage {
private accessTokenKey: string;
private refreshTokenKey: string;
constructor(
accessTokenKey: string = 'access_token',
refreshTokenKey: string = 'refresh_token'
) {
this.accessTokenKey = accessTokenKey;
this.refreshTokenKey = refreshTokenKey;
}
getAccessToken(): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
const stored = localStorage.getItem(this.accessTokenKey);
if (!stored) return null;
try {
// Try to parse as JSON (with expiration) or use as plain string
const parsed = JSON.parse(stored);
if (parsed.token && parsed.expiresAt) {
// Check if token is expired
if (Date.now() >= parsed.expiresAt * 1000) {
localStorage.removeItem(this.accessTokenKey);
return null;
}
return parsed.token;
}
// Legacy format (plain string) - return as token
return typeof parsed === 'string' ? parsed : parsed.token || parsed;
} catch {
// Plain string format
return stored;
}
}
setAccessToken(token: string, expiresIn?: number): void {
if (typeof localStorage === 'undefined') {
return;
}
// Store token with expiration if provided (expiresIn is in seconds)
const tokenData = expiresIn
? JSON.stringify({
token,
expiresAt: Math.floor(Date.now() / 1000) + expiresIn,
})
: token;
localStorage.setItem(this.accessTokenKey, tokenData);
}
getRefreshToken(): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(this.refreshTokenKey);
}
setRefreshToken(token: string): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(this.refreshTokenKey, token);
}
clearTokens(): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(this.accessTokenKey);
localStorage.removeItem(this.refreshTokenKey);
}
}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register token-storage template: {}", e))
})?;
templates
.register_template_string(
"auth-interceptor",
r#"// Generated Angular Auth Interceptor for {{api_title}}
// API Version: {{api_version}}
import { Injectable, Optional } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent,
HttpErrorResponse,
} from '@angular/common/http';
import { Observable, throwError, from, firstValueFrom } from 'rxjs';
import { switchMap, catchError, tap } from 'rxjs/operators';
import { ApiError } from './core';
import { LocalStorageTokenStorageService } from './token-storage.service';
import { OAuth2TokenManagerService } from './oauth2-token-manager.service';
import { ApiConfigService } from './api-config.service';
/**
* HTTP Interceptor for authentication (Bearer tokens, OAuth2, JWT refresh)
* Automatically adds authentication headers and handles token refresh on 401
*/
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private refreshPromise: Promise<string> | null = null;
constructor(
private tokenStorage: LocalStorageTokenStorageService,
@Optional() private oauth2Manager: OAuth2TokenManagerService | null,
private apiConfig: ApiConfigService
) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Get config
const config = this.apiConfig.getConfig();
// Check if this is a refresh request (avoid infinite loop)
const isRefreshRequest = config.jwt?.refreshEndpoint &&
req.url.includes(config.jwt.refreshEndpoint!);
// Get authentication headers
return from(this.getAuthHeaders(config, isRefreshRequest)).pipe(
switchMap((authHeaders) => {
// Clone request with auth headers
let clonedReq = req;
if (Object.keys(authHeaders).length > 0) {
clonedReq = req.clone({
setHeaders: authHeaders,
});
}
// Handle request
return next.handle(clonedReq).pipe(
catchError((error: HttpErrorResponse) => {
// Handle 401 errors with JWT refresh
if (error.status === 401 && config.jwt && !isRefreshRequest) {
return this.handle401Error(req, next, config);
}
// Convert to ApiError
throw ApiError.fromHttpErrorResponse(error);
})
);
})
);
}
/**
* Get authentication headers based on config
*/
private async getAuthHeaders(
config: any,
isRefreshRequest: boolean
): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
// Skip auth headers for refresh requests
if (isRefreshRequest) {
return headers;
}
// OAuth2 authentication (takes priority)
if (config.oauth2 && this.oauth2Manager) {
const token = await firstValueFrom(this.oauth2Manager.getAccessToken(config.oauth2));
if (token) {
headers['Authorization'] = `Bearer ${token}`;
return headers;
}
}
// Bearer token authentication
if (config.accessToken) {
const token = typeof config.accessToken === 'function'
? await config.accessToken()
: config.accessToken;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
// JWT from token storage
if (config.jwt && !headers['Authorization']) {
const token = await Promise.resolve(this.tokenStorage.getAccessToken());
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
// API key authentication
if (config.apiKey) {
const apiKey = typeof config.apiKey === 'function'
? await config.apiKey('X-API-Key')
: config.apiKey;
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
}
// Basic authentication
if (config.username && config.password) {
const credentials = btoa(`${config.username}:${config.password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
return headers;
}
/**
* Handle 401 error with JWT token refresh
*/
private handle401Error(
req: HttpRequest<any>,
next: HttpHandler,
config: any
): Observable<HttpEvent<any>> {
// If refresh is already in progress, wait for it
if (this.refreshPromise) {
return from(this.refreshPromise).pipe(
switchMap(() => {
// Retry original request with new token
return this.retryRequest(req, next, config);
}),
catchError((error) => {
// Refresh failed
if (config.jwt?.onAuthError) {
config.jwt.onAuthError();
}
return throwError(() => error);
})
);
}
// Start refresh
this.refreshPromise = this.refreshJwtToken(config);
return from(this.refreshPromise).pipe(
switchMap(() => {
// Retry original request with new token
return this.retryRequest(req, next, config);
}),
catchError((error) => {
// Refresh failed
if (config.jwt?.onAuthError) {
config.jwt.onAuthError();
}
return throwError(() => error);
}),
tap(() => {
// Clear refresh promise after success
this.refreshPromise = null;
})
);
}
/**
* Refresh JWT token
*/
private async refreshJwtToken(config: any): Promise<string> {
const jwtConfig = config.jwt;
if (!jwtConfig) {
throw new Error('JWT configuration not found');
}
// Get refresh token
const refreshTokenValue = typeof jwtConfig.refreshToken === 'function'
? await jwtConfig.refreshToken()
: jwtConfig.refreshToken || await Promise.resolve(this.tokenStorage.getRefreshToken());
if (!refreshTokenValue) {
throw new Error('Refresh token not available');
}
// Get refresh endpoint
const refreshEndpoint = jwtConfig.refreshEndpoint || '/api/v1/auth/refresh';
const refreshUrl = `${config.baseUrl}${refreshEndpoint}`;
// Make refresh request
const response = await fetch(refreshUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...config.headers,
},
body: JSON.stringify({ refreshToken: refreshTokenValue }),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new ApiError(
response.status,
response.statusText,
errorBody,
`JWT refresh failed: ${response.status} ${response.statusText}`
);
}
// Parse response
const responseData = await response.json();
let tokenData: any;
// Check if response is wrapped in ApiResponse format
if (responseData.success === true && responseData.data) {
tokenData = responseData.data;
} else {
tokenData = responseData;
}
// Extract tokens (support both camelCase and snake_case)
const accessToken = tokenData.accessToken || tokenData.access_token;
const refreshToken = tokenData.refreshToken || tokenData.refresh_token;
const expiresIn = tokenData.expiresIn || tokenData.expires_in;
if (!accessToken) {
throw new Error('No access token in refresh response');
}
// Store tokens
await Promise.resolve(this.tokenStorage.setAccessToken(accessToken, expiresIn));
if (refreshToken) {
await Promise.resolve(this.tokenStorage.setRefreshToken(refreshToken));
}
// Call onTokenRefresh callback if provided
if (jwtConfig.onTokenRefresh) {
await jwtConfig.onTokenRefresh(accessToken);
}
return accessToken;
}
/**
* Retry original request with new token
*/
private retryRequest(
req: HttpRequest<any>,
next: HttpHandler,
config: any
): Observable<HttpEvent<any>> {
// Get new token and add to headers
return from(this.getAuthHeaders(config, false)).pipe(
switchMap((authHeaders) => {
const clonedReq = req.clone({
setHeaders: authHeaders,
});
return next.handle(clonedReq);
})
);
}
}"#,
)
.map_err(|e| {
PluginError::execution(format!(
"Failed to register auth-interceptor template: {}",
e
))
})?;
templates
.register_template_string(
"retry-interceptor",
r#"// Generated Angular Retry Interceptor for {{api_title}}
// API Version: {{api_version}}
import { Injectable } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent,
HttpErrorResponse,
} from '@angular/common/http';
import { Observable, throwError, timer } from 'rxjs';
import { retryWhen, mergeMap, take } from 'rxjs/operators';
import { ApiError } from './core';
import { ApiConfigService } from './api-config.service';
/**
* HTTP Interceptor for retry logic with exponential backoff
* Automatically retries failed requests based on configuration
*/
@Injectable()
export class RetryInterceptor implements HttpInterceptor {
constructor(private apiConfig: ApiConfigService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const config = this.apiConfig.getConfig();
const retryConfig = config.retry || {};
const maxRetries = retryConfig.maxRetries ?? 3;
const retryableStatusCodes = retryConfig.retryableStatusCodes || [408, 429, 500, 502, 503, 504];
return next.handle(req).pipe(
retryWhen((errors: Observable<HttpErrorResponse>) => {
return errors.pipe(
mergeMap((error: HttpErrorResponse, index: number) => {
// Don't retry if exceeded max retries
if (index >= maxRetries) {
return throwError(() => ApiError.fromHttpErrorResponse(error));
}
// Check if error is retryable
const isRetryable = this.isRetryableError(error, retryConfig, retryableStatusCodes);
if (!isRetryable) {
return throwError(() => ApiError.fromHttpErrorResponse(error));
}
// Don't retry 401 errors (handled by AuthInterceptor)
if (error.status === 401) {
return throwError(() => ApiError.fromHttpErrorResponse(error));
}
// Don't retry 403 errors (authorization failure)
if (error.status === 403) {
return throwError(() => ApiError.fromHttpErrorResponse(error));
}
// Calculate backoff delay
const delay = this.calculateBackoffDelay(index, retryConfig);
// Wait before retrying
return timer(delay);
}),
take(maxRetries + 1)
);
})
);
}
/**
* Check if error is retryable
*/
private isRetryableError(
error: HttpErrorResponse,
retryConfig: any,
retryableStatusCodes: number[]
): boolean {
// Check status code
if (error.status && retryableStatusCodes.includes(error.status)) {
return true;
}
// Check network errors
if (retryConfig.retryOnNetworkError !== false) {
if (error.status === 0 || error.status === undefined) {
return true; // Network error
}
}
return false;
}
/**
* Calculate exponential backoff delay with jitter
*/
private calculateBackoffDelay(retryCount: number, retryConfig: any): number {
const baseDelay = retryConfig.baseDelay || 1000;
const maxDelay = retryConfig.maxDelay || 10000;
// Exponential backoff: baseDelay * 2^retryCount
const exponentialDelay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
// Add jitter: random(0, 0.3 * delay)
const jitter = Math.random() * 0.3 * exponentialDelay;
return Math.floor(exponentialDelay + jitter);
}
}"#,
)
.map_err(|e| {
PluginError::execution(format!(
"Failed to register retry-interceptor template: {}",
e
))
})?;
templates
.register_template_string(
"api-config",
r#"// Generated Angular API Config Service for {{api_title}}
// API Version: {{api_version}}
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { ApiConfig } from './core';
/**
* Service for managing API configuration
* Provides reactive configuration updates
*/
@Injectable({
providedIn: 'root'
})
export class ApiConfigService {
private configSubject: BehaviorSubject<ApiConfig>;
public config$: Observable<ApiConfig>;
constructor() {
// Base URL resolver for environment-based switching
const getEnvVar = (name: string): string | null => {
if (typeof process !== 'undefined' && process.env) {
if (process.env[name]) return process.env[name];
const prefixes = ['VITE_', 'REACT_APP_', 'NEXT_PUBLIC_', 'NUXT_PUBLIC_'];
for (const prefix of prefixes) {
if (process.env[prefix + name]) return process.env[prefix + name];
}
}
return null;
};
const resolveBaseUrl = (mockBaseUrl: string, realBaseUrl: string, explicitBaseUrl?: string): string => {
const envBaseUrl = getEnvVar('MOCKFORGE_BASE_URL');
if (envBaseUrl) return envBaseUrl;
if (explicitBaseUrl) return explicitBaseUrl;
const mode = getEnvVar('MOCKFORGE_MODE');
if (mode === 'real') return realBaseUrl;
if (mode === 'hybrid') return mockBaseUrl;
return mockBaseUrl;
};
// Bundled schemas for runtime validation
const bundledSchemas: Record<string, any> = {{#if bundled_schemas}}{
{{#each bundled_schemas}}
'{{@key}}': {{json this}},
{{/each}}
}{{else}}{}{{/if}};
const defaultConfig: ApiConfig = {
baseUrl: resolveBaseUrl(
'{{base_url}}',
'{{real_base_url}}',
undefined
),
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
schemas: bundledSchemas, // Include bundled schemas for runtime validation
includeContractDiffs: true, // Enable contract diff references in errors
};
this.configSubject = new BehaviorSubject<ApiConfig>(defaultConfig);
this.config$ = this.configSubject.asObservable();
}
/**
* Get current configuration
*/
getConfig(): ApiConfig {
return this.configSubject.value;
}
/**
* Update configuration
*/
updateConfig(updates: Partial<ApiConfig>): void {
const currentConfig = this.configSubject.value;
const newConfig = { ...currentConfig, ...updates };
this.configSubject.next(newConfig);
}
/**
* Reset configuration to defaults
*/
resetConfig(): void {
const defaultConfig: ApiConfig = {
baseUrl: '{{base_url}}',
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
};
this.configSubject.next(defaultConfig);
}
}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register api-config template: {}", e))
})?;
templates
.register_template_string(
"api-service",
r#"// Generated Angular API Service for {{api_title}}
// API Version: {{api_version}}
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { map, catchError, timeout } from 'rxjs/operators';
import { ApiError, ApiConfig } from './core';
import { ApiConfigService } from './api-config.service';
{{#each operations}}
import type { {{response_type_name}}{{#if request_type_name}}, {{request_type_name}}{{/if}} } from './types';
{{/each}}
/**
* Main API service with all generated operations
* Uses Angular HttpClient with RxJS Observables
*/
@Injectable({
providedIn: 'root'
})
export class {{api_title}}Service {
private baseUrl: string;
constructor(
private http: HttpClient,
private apiConfig: ApiConfigService
) {
this.baseUrl = this.apiConfig.getConfig().baseUrl;
// Subscribe to config changes
this.apiConfig.config$.subscribe(config => {
this.baseUrl = config.baseUrl;
});
}
/**
* Unwrap ApiResponse<T> format if configured
*/
private unwrapResponse<T>(data: any, config: ApiConfig): T {
if (!config.unwrapResponse) {
return data;
}
// Check if response matches ApiResponse<T> format
if (data && typeof data === 'object' && data.success === true && 'data' in data) {
return data.data;
}
// Return as-is if not wrapped
return data;
}
/**
* Validate request data against schema (if validation enabled)
* Supports both basic validation (required fields) and full JSON Schema validation
*/
private validateRequest(data: any, requiredFields?: string[], schemaId?: string, config?: ApiConfig): void {
const cfg = config || this.apiConfig.getConfig();
if (!cfg.validateRequests) {
return;
}
if (!data || typeof data !== 'object') {
return;
}
// Check required fields (basic validation)
if (requiredFields && Array.isArray(requiredFields)) {
const missingFields: string[] = [];
for (const field of requiredFields) {
if (!(field in data) || data[field] === undefined || data[field] === null) {
missingFields.push(field);
}
}
if (missingFields.length > 0) {
throw new RequiredError(
missingFields.join(', '),
`Missing required fields: ${missingFields.join(', ')}`
);
}
}
// Full JSON Schema validation (if schema provided and ajv available)
if (schemaId && cfg.schemas && cfg.schemas[schemaId]) {
this.validateAgainstSchema(data, cfg.schemas[schemaId], schemaId, 'request', cfg);
}
}
/**
* Validate data against JSON Schema using ajv (if available)
* Falls back to basic validation if ajv is not available
*/
private validateAgainstSchema(
data: any,
schema: any,
schemaId: string,
context: 'request' | 'response',
config: ApiConfig
): void {
try {
let Ajv: any;
if (typeof window !== 'undefined' && (window as any).ajv) {
Ajv = (window as any).ajv;
} else if (typeof require !== 'undefined') {
Ajv = require('ajv');
} else {
console.warn('ajv not available, using basic validation only. Install ajv for full schema validation: npm install ajv');
return;
}
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid && validate.errors) {
const firstError = validate.errors[0];
const schemaPath = firstError.instancePath || firstError.schemaPath || '';
const expectedType = firstError.schema?.type || firstError.params?.type || 'unknown';
const actualValue = firstError.data;
const contractDiffId = schema['x-contract-diff-id'] || schema.contractDiffId;
const isBreakingChange = schema['x-breaking-change'] || schema.isBreakingChange || false;
throw new ContractValidationError(
400,
'Validation Error',
schemaPath || `${context}.${schemaId}`,
expectedType,
actualValue,
contractDiffId,
isBreakingChange,
{ errors: validate.errors },
`Schema validation failed for ${context}`
);
}
} catch (e) {
if (e instanceof ContractValidationError) {
throw e;
}
console.warn('ajv not available, using basic validation only. Install ajv for full schema validation: npm install ajv');
}
}
/**
* Validate response data against schema (if validation enabled)
*/
private validateResponse(data: any, schemaId?: string, config?: ApiConfig): void {
const cfg = config || this.apiConfig.getConfig();
if (!cfg.validateResponses) {
return;
}
if (!data) {
return;
}
// Full JSON Schema validation (if schema provided)
if (schemaId && cfg.schemas && cfg.schemas[schemaId]) {
this.validateAgainstSchema(data, cfg.schemas[schemaId], schemaId, 'response', cfg);
}
}
{{#each operations}}
/**
* {{summary}}
* {{#if description}}
* {{description}}
* {{/if}}
*/
{{operation_id}}({{method_params}}): Observable<{{response_type_name}}> {
const config = this.apiConfig.getConfig();
{{#if path_params}}
const endpoint = `{{endpoint_path}}`;
{{else}}
const endpoint = '{{endpoint_path}}';
{{/if}}
{{#if request_body}}
// Validate request data if validation is enabled
if (data && config.validateRequests) {
const schemaId = '{{operation_id}}Request';
this.validateRequest(data, {{#if required_fields}}[{{#each required_fields}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]{{else}}undefined{{/if}}, schemaId, config);
}
{{/if}}
{{#if (eq method "GET")}}
{{#if query_params}}
// Build query parameters
let params = new HttpParams();
if (queryParams) {
Object.keys(queryParams).forEach(key => {
const value = queryParams[key];
if (value !== undefined && value !== null) {
params = params.set(key, String(value));
}
});
}
{{/if}}
return this.http.get<{{response_type_name}}>(`${this.baseUrl}${endpoint}`{{#if query_params}}, { params }{{/if}}).pipe(
timeout(config.timeout || 30000),
map(response => {
const unwrapped = this.unwrapResponse(response, config);
if (config.validateResponses) {
const schemaId = '{{operation_id}}Response';
this.validateResponse(unwrapped, schemaId, config);
}
return unwrapped;
}),
catchError((error) => {
return throwError(() => ApiError.fromHttpErrorResponse(error));
})
);
{{else}}
{{#if query_params}}
// Build query parameters
let params = new HttpParams();
if (queryParams) {
Object.keys(queryParams).forEach(key => {
const value = queryParams[key];
if (value !== undefined && value !== null) {
params = params.set(key, String(value));
}
});
}
{{/if}}
{{#if request_body}}
{{#if query_params}}
return this.http.{{toLowerCase method}}<{{response_type_name}}>(`${this.baseUrl}${endpoint}`, data, { params }).pipe(
{{else}}
return this.http.{{toLowerCase method}}<{{response_type_name}}>(`${this.baseUrl}${endpoint}`, data).pipe(
{{/if}}
{{else}}
{{#if query_params}}
return this.http.{{toLowerCase method}}<{{response_type_name}}>(`${this.baseUrl}${endpoint}`, null, { params }).pipe(
{{else}}
return this.http.{{toLowerCase method}}<{{response_type_name}}>(`${this.baseUrl}${endpoint}`).pipe(
{{/if}}
{{/if}}
timeout(config.timeout || 30000),
map(response => {
const unwrapped = this.unwrapResponse(response, config);
if (config.validateResponses) {
const schemaId = '{{operation_id}}Response';
this.validateResponse(unwrapped, schemaId, config);
}
return unwrapped;
}),
catchError((error) => {
return throwError(() => ApiError.fromHttpErrorResponse(error));
})
);
{{/if}}
}
{{/each}}
}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register api-service template: {}", e))
})?;
templates
.register_template_string(
"api-module",
r#"// Generated Angular API Module for {{api_title}}
// API Version: {{api_version}}
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptor } from './auth-interceptor';
import { RetryInterceptor } from './retry-interceptor';
import { ApiConfigService } from './api-config.service';
import { LocalStorageTokenStorageService } from './token-storage.service';
import { OAuth2TokenManagerService } from './oauth2-token-manager.service';
import { {{api_title}}Service } from './{{api_title_lowercase}}.service';
/**
* API Module - Import this module in your AppModule or use standalone components
*
* Usage in AppModule:
* ```typescript
* import { ApiModule } from './api/api-module';
*
* @NgModule({
* imports: [
* HttpClientModule,
* ApiModule
* ],
* ...
* })
* export class AppModule { }
* ```
*
* Or for standalone components:
* ```typescript
* import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
* import { ApiConfigService, AuthInterceptor, RetryInterceptor } from './api';
*
* bootstrapApplication(AppComponent, {
* providers: [
* provideHttpClient(withInterceptorsFromDi()),
* ApiConfigService,
* LocalStorageTokenStorageService,
* { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
* { provide: HTTP_INTERCEPTORS, useClass: RetryInterceptor, multi: true },
* {{api_title}}Service
* ]
* });
* ```
*/
@NgModule({
imports: [
HttpClientModule
],
providers: [
ApiConfigService,
LocalStorageTokenStorageService,
OAuth2TokenManagerService, // Optional - can be removed if OAuth2 not needed
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: RetryInterceptor,
multi: true
},
{{api_title}}Service
]
})
export class ApiModule {
constructor(@Optional() @SkipSelf() parentModule?: ApiModule) {
if (parentModule) {
throw new Error('ApiModule is already loaded. Import it in the AppModule only.');
}
}
}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register api-module template: {}", e))
})?;
templates
.register_template_string(
"oauth2-token-manager",
r#"// Generated Angular OAuth2 Token Manager Service for {{api_title}}
// API Version: {{api_version}}
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, from, throwError } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { OAuth2Config, ApiError } from './core';
import { TokenStorage } from './core';
import { LocalStorageTokenStorageService } from './token-storage.service';
/**
* OAuth2 Token Manager Service
* Handles OAuth2 flows and token refresh
*/
@Injectable({
providedIn: 'root'
})
export class OAuth2TokenManagerService {
private tokenStorage: TokenStorage;
constructor(
private http: HttpClient,
tokenStorage?: TokenStorage
) {
// Use provided token storage or create default
this.tokenStorage = tokenStorage || new LocalStorageTokenStorageService();
}
/**
* Get access token via client_credentials flow
* ⚠️ SECURITY WARNING: Only use in server-side applications!
*/
getClientCredentialsToken(config: OAuth2Config): Observable<string> {
if (!config.clientSecret) {
console.warn('⚠️ SECURITY WARNING: client_credentials flow with client secret in browser code is insecure.');
return throwError(() => new Error('Client secret required for client_credentials flow'));
}
const params = new URLSearchParams();
params.set('grant_type', 'client_credentials');
params.set('client_id', config.clientId);
params.set('client_secret', config.clientSecret);
if (config.scopes && config.scopes.length > 0) {
params.set('scope', config.scopes.join(' '));
}
return this.http.post<any>(config.tokenUrl, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}).pipe(
map(response => {
const token = response.access_token;
if (!token) {
throw new Error('No access_token in OAuth2 response');
}
// Store token with expiration
this.tokenStorage.setAccessToken(token, response.expires_in);
if (config.onTokenRefresh) {
config.onTokenRefresh(token);
}
return token;
}),
catchError((error) => {
return throwError(() => ApiError.fromHttpErrorResponse(error));
})
);
}
/**
* Get current access token (from storage or fetch new for client_credentials)
*/
getAccessToken(config?: OAuth2Config): Observable<string | null> {
// Try to get stored token first
const stored = this.tokenStorage.getAccessToken();
if (stored) {
return new Observable(observer => {
observer.next(stored);
observer.complete();
});
}
// If no stored token and client_credentials flow, fetch new token
if (config && config.flow === 'client_credentials') {
return this.getClientCredentialsToken(config);
}
return new Observable(observer => {
observer.next(null);
observer.complete();
});
}
}"#,
)
.map_err(|e| {
PluginError::execution(format!("Failed to register oauth2-token-manager template: {}", e))
})?;
Ok(())
}
fn generate_angular_client(
&self,
spec: &OpenApiSpec,
config: &ClientGeneratorConfig,
) -> Result<ClientGenerationResult> {
let mut files = Vec::new();
let warnings = Vec::new();
let context = self.prepare_template_context(spec, config)?;
let types_content = self.templates.render("types", &context).map_err(|e| {
PluginError::execution(format!("Failed to render types template: {}", e))
})?;
files.push(GeneratedFile {
path: "types.ts".to_string(),
content: types_content,
file_type: "typescript".to_string(),
});
let core_content = self.templates.render("core", &context).map_err(|e| {
PluginError::execution(format!("Failed to render core template: {}", e))
})?;
files.push(GeneratedFile {
path: "core.ts".to_string(),
content: core_content,
file_type: "typescript".to_string(),
});
let token_storage_content =
self.templates.render("token-storage", &context).map_err(|e| {
PluginError::execution(format!("Failed to render token-storage template: {}", e))
})?;
files.push(GeneratedFile {
path: "token-storage.service.ts".to_string(),
content: token_storage_content,
file_type: "typescript".to_string(),
});
let api_config_content = self.templates.render("api-config", &context).map_err(|e| {
PluginError::execution(format!("Failed to render api-config template: {}", e))
})?;
files.push(GeneratedFile {
path: "api-config.service.ts".to_string(),
content: api_config_content,
file_type: "typescript".to_string(),
});
let oauth2_content =
self.templates.render("oauth2-token-manager", &context).map_err(|e| {
PluginError::execution(format!(
"Failed to render oauth2-token-manager template: {}",
e
))
})?;
files.push(GeneratedFile {
path: "oauth2-token-manager.service.ts".to_string(),
content: oauth2_content,
file_type: "typescript".to_string(),
});
let auth_interceptor_content =
self.templates.render("auth-interceptor", &context).map_err(|e| {
PluginError::execution(format!("Failed to render auth-interceptor template: {}", e))
})?;
files.push(GeneratedFile {
path: "auth-interceptor.ts".to_string(),
content: auth_interceptor_content,
file_type: "typescript".to_string(),
});
let retry_interceptor_content =
self.templates.render("retry-interceptor", &context).map_err(|e| {
PluginError::execution(format!(
"Failed to render retry-interceptor template: {}",
e
))
})?;
files.push(GeneratedFile {
path: "retry-interceptor.ts".to_string(),
content: retry_interceptor_content,
file_type: "typescript".to_string(),
});
let api_title_lowercase = spec.info.title.to_lowercase().replace(' ', "-");
let api_service_content = self.templates.render("api-service", &context).map_err(|e| {
PluginError::execution(format!("Failed to render api-service template: {}", e))
})?;
files.push(GeneratedFile {
path: format!("{}.service.ts", api_title_lowercase),
content: api_service_content,
file_type: "typescript".to_string(),
});
let api_module_content = self.templates.render("api-module", &context).map_err(|e| {
PluginError::execution(format!("Failed to render api-module template: {}", e))
})?;
files.push(GeneratedFile {
path: "api-module.ts".to_string(),
content: api_module_content,
file_type: "typescript".to_string(),
});
let package_json = self.generate_package_json(spec, config)?;
files.push(GeneratedFile {
path: "package.json".to_string(),
content: package_json,
file_type: "json".to_string(),
});
let readme = self.generate_readme(spec, config)?;
files.push(GeneratedFile {
path: "README.md".to_string(),
content: readme,
file_type: "markdown".to_string(),
});
let metadata = GenerationMetadata {
framework: "angular".to_string(),
client_name: format!("{}-client", spec.info.title.to_lowercase().replace(' ', "-")),
api_title: spec.info.title.clone(),
api_version: spec.info.version.clone(),
operation_count: self.count_operations(spec),
schema_count: self.count_schemas(spec),
};
Ok(ClientGenerationResult {
files,
warnings,
metadata,
})
}
fn prepare_template_context(
&self,
spec: &OpenApiSpec,
config: &ClientGeneratorConfig,
) -> Result<Value> {
let mut operations = Vec::new();
let mut schemas = HashMap::new();
let mut operation_id_counts = HashMap::new();
for (path, path_item) in &spec.paths {
for (method, operation) in &path_item.operations {
let mut normalized_op =
crate::client_generator::helpers::normalize_operation(method, path, operation);
let base_operation_id = normalized_op.operation_id.clone();
let count = operation_id_counts.entry(base_operation_id.clone()).or_insert(0);
*count += 1;
if *count > 1 {
normalized_op.operation_id = format!("{}{}", base_operation_id, *count);
}
let path_params = crate::client_generator::helpers::extract_path_parameters(path);
let mut endpoint_path = normalized_op.path.clone();
for param in &path_params {
endpoint_path = endpoint_path
.replace(&format!("{{{}}}", param), &format!("${{{}}}", param));
}
let mut query_params = Vec::new();
let mut query_param_types = Vec::new();
for param in &normalized_op.parameters {
if param.r#in == "query" {
let param_type = if let Some(schema) = ¶m.schema {
crate::client_generator::helpers::schema_to_typescript_type(schema)
} else {
"string".to_string()
};
let required = param.required.unwrap_or(false);
query_params.push(json!({
"name": param.name,
"required": required,
"type": param_type.clone(),
}));
if required {
query_param_types.push(format!("{}: {}", param.name, param_type));
} else {
query_param_types.push(format!("{}?: {}", param.name, param_type));
}
}
}
let mut method_params_parts = Vec::new();
for param in &path_params {
method_params_parts.push(format!("{}: string", param));
}
if !query_params.is_empty() {
method_params_parts
.push(format!("queryParams?: {{ {} }}", query_param_types.join(", ")));
}
let has_json_request_body = normalized_op
.request_body
.as_ref()
.is_some_and(|rb| rb.content.contains_key("application/json"));
if has_json_request_body && normalized_op.method != "GET" {
let type_name = crate::client_generator::helpers::generate_type_name(
&normalized_op.operation_id,
"Request",
);
method_params_parts.push(format!("data: {}", type_name));
}
let method_params = if method_params_parts.is_empty() {
String::new()
} else {
method_params_parts.join(", ")
};
let response_type_name = crate::client_generator::helpers::generate_type_name(
&normalized_op.operation_id,
"Response",
);
let request_type_name = if has_json_request_body {
crate::client_generator::helpers::generate_type_name(
&normalized_op.operation_id,
"Request",
)
} else {
String::new()
};
let mut required_fields: Vec<String> = Vec::new();
let processed_request_body = if has_json_request_body {
if let Some(ref rb) = normalized_op.request_body {
let mut processed_rb = json!(rb);
if let Some(content) = processed_rb.get_mut("content") {
if let Some(json_content) = content.get_mut("application/json") {
if let Some(schema) = json_content.get_mut("schema") {
if let Some(required) =
schema.get("required").and_then(|r| r.as_array())
{
required_fields = required
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
*schema =
Self::process_schema_with_required_flags(schema.take());
}
}
}
Some(processed_rb)
} else {
None
}
} else {
None
};
let mut processed_responses: HashMap<String, Value> =
normalized_op.responses.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
for (_status_code, response) in processed_responses.iter_mut() {
if let Some(content) =
response.get_mut("content").and_then(|c| c.as_object_mut())
{
if let Some(json_content) = content.get_mut("application/json") {
if let Some(schema) = json_content.get_mut("schema") {
*schema = Self::process_schema_with_required_flags(schema.take());
}
}
}
}
operations.push(json!({
"method": normalized_op.method,
"path": normalized_op.path,
"endpoint_path": endpoint_path,
"operation_id": normalized_op.operation_id,
"response_type_name": response_type_name,
"request_type_name": request_type_name,
"summary": normalized_op.summary,
"description": normalized_op.description,
"parameters": normalized_op.parameters,
"path_params": path_params,
"query_params": query_params,
"query_param_types": query_param_types,
"method_params": method_params,
"request_body": processed_request_body,
"required_fields": if !required_fields.is_empty() { Some(required_fields) } else { None },
"responses": processed_responses,
"tags": normalized_op.tags,
}));
}
}
if let Some(components) = &spec.components {
if let Some(spec_schemas) = &components.schemas {
for (name, schema) in spec_schemas {
schemas.insert(name.clone(), schema.clone());
}
}
}
let api_title_lowercase = spec.info.title.to_lowercase().replace(' ', "-");
let real_base_url = spec
.servers
.as_ref()
.and_then(|servers| servers.first())
.map(|server| server.url.clone())
.unwrap_or_else(|| "https://api.production.com".to_string());
let mut bundled_schemas = serde_json::Map::new();
for (schema_name, schema) in &schemas {
let mut schema_json = json!(schema);
if let Some(schema_obj) = schema_json.as_object_mut() {
schema_obj.insert("$id".to_string(), json!(schema_name));
schema_obj.insert("x-schema-id".to_string(), json!(schema_name));
}
bundled_schemas.insert(schema_name.clone(), schema_json);
}
Ok(json!({
"api_title": spec.info.title,
"api_title_lowercase": api_title_lowercase,
"api_version": spec.info.version,
"api_description": spec.info.description,
"base_url": config.base_url.as_ref().unwrap_or(&"http://localhost:3000".to_string()),
"real_base_url": real_base_url,
"operations": operations,
"schemas": schemas,
"bundled_schemas": bundled_schemas,
}))
}
fn count_operations(&self, spec: &OpenApiSpec) -> usize {
spec.paths.values().map(|path_item| path_item.operations.len()).sum()
}
fn count_schemas(&self, spec: &OpenApiSpec) -> usize {
spec.components
.as_ref()
.and_then(|c| c.schemas.as_ref())
.map(|s| s.len())
.unwrap_or(0)
}
fn generate_package_json(
&self,
spec: &OpenApiSpec,
_config: &ClientGeneratorConfig,
) -> Result<String> {
let package_name = format!("{}-client", spec.info.title.to_lowercase().replace(' ', "-"));
let package_json = json!({
"name": package_name,
"version": "1.0.0",
"description": format!("Angular client for {}", spec.info.title),
"main": format!("{}.service.ts", spec.info.title.to_lowercase().replace(' ', "-")),
"types": "types.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@angular/common": "^17.0.0",
"@angular/core": "^17.0.0",
"rxjs": "^7.8.0"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"peerDependencies": {
"@angular/common": ">=17.0.0",
"@angular/core": ">=17.0.0",
"rxjs": ">=7.0.0"
}
});
serde_json::to_string_pretty(&package_json)
.map_err(|e| PluginError::execution(format!("Failed to serialize package.json: {}", e)))
}
fn generate_readme(
&self,
spec: &OpenApiSpec,
config: &ClientGeneratorConfig,
) -> Result<String> {
let api_description = spec
.info
.description
.as_ref()
.map(|d| format!("\n\n{}\n", d))
.unwrap_or_default();
let operation_count = self.count_operations(spec);
let schema_count = self.count_schemas(spec);
let default_url = "http://localhost:3000".to_string();
let base_url = config.base_url.as_ref().unwrap_or(&default_url);
let api_title_lowercase = spec.info.title.to_lowercase().replace(' ', "-");
let service_name = spec.info.title.replace(' ', "");
let service_name_import = format!("{{ {} }}", service_name);
let operations_list = self.generate_operations_list(spec);
let readme = format!(
r#"# {} Angular Client
Generated Angular client for {} API (v{}).{}
## Features
✅ **Auto-generated Angular service** - {} operations with RxJS Observables
✅ **Enterprise error handling** - Structured ApiError class compatible with HttpErrorResponse
✅ **OAuth2 flow support** - Authorization code, client credentials flows
✅ **Authentication support** - Bearer tokens, API keys, Basic auth, OAuth2
✅ **JWT token refresh** - Automatic refresh on 401 with promise deduplication
✅ **Retry logic** - Exponential backoff with jitter using RxJS operators
✅ **HTTP Interceptors** - Auth and retry interceptors for global request handling
✅ **Token storage service** - Injectable service for secure token management
✅ **TypeScript types** - {} fully-typed interfaces
✅ **Timeout handling** - Configurable request timeouts
✅ **ApiResponse wrapper** - Support for unwrapping wrapped API responses
✅ **100% endpoint coverage** - All {} API operations included
## Installation
```bash
npm install
```
## Quick Start
### Using Angular Service
```typescript
import {{ Component, OnInit }} from '@angular/core';
import {{ {}Service }} from './{}.service';
import {{ ApiError }} from './core';
@Component({{
selector: 'app-my-component',
template: `
<div *ngIf="loading">Loading...</div>
<div *ngIf="error">Error: {{{{error.message}}}}</div>
<div *ngIf="data">
<div *ngFor="let item of data">
{{{{item.name}}}}
</div>
</div>
`,
}})
export class MyComponent implements OnInit {{
data: any[] | null = null;
loading: boolean = false;
error: ApiError | null = null;
constructor(private apiService: MyApiService) {{}}
ngOnInit(): void {{
this.loading = true;
this.apiService.getUsers().subscribe({{
next: (data) => {{
this.data = data;
this.loading = false;
}},
error: (err) => {{
this.error = err instanceof ApiError ? err : new ApiError(0, 'Unknown Error', err);
this.loading = false;
}}
}});
}}
}}
```
### Module Setup
Import the `ApiModule` in your `AppModule`:
```typescript
import {{ NgModule }} from '@angular/core';
import {{ BrowserModule }} from '@angular/platform-browser';
import {{ HttpClientModule }} from '@angular/common/http';
import {{ ApiModule }} from './api/api-module';
import {{ AppComponent }} from './app.component';
@NgModule({{
declarations: [AppComponent],
imports: [
BrowserModule,
HttpClientModule,
ApiModule
],
bootstrap: [AppComponent]
}})
export class AppModule {{ }}
```
### Standalone Components Setup
For standalone components (Angular 14+):
```typescript
import {{ provideHttpClient, withInterceptorsFromDi }} from '@angular/common/http';
import {{ bootstrapApplication }} from '@angular/platform-browser';
import {{ ApiConfigService, LocalStorageTokenStorageService, AuthInterceptor, RetryInterceptor }} from './api';
import {{ {}Service }} from './api/{}.service';
import {{ HTTP_INTERCEPTORS }} from '@angular/common/http';
bootstrapApplication(AppComponent, {{
providers: [
provideHttpClient(withInterceptorsFromDi()),
ApiConfigService,
LocalStorageTokenStorageService,
{{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
}},
{{
provide: HTTP_INTERCEPTORS,
useClass: RetryInterceptor,
multi: true
}},
{}Service
]
}});
```
## Configuration
The client is configured to use the following base URL: `{}`
### Basic Configuration
```typescript
import {{ ApiConfigService }} from './api/api-config.service';
constructor(private apiConfig: ApiConfigService) {{}}
ngOnInit() {{
// Update configuration
this.apiConfig.updateConfig({{
baseUrl: 'https://api.production.com'
}});
}}
```
### Authentication
```typescript
import {{ ApiConfigService }} from './api/api-config.service';
// Bearer token authentication
this.apiConfig.updateConfig({{
accessToken: 'your-jwt-token'
}});
// Dynamic token
this.apiConfig.updateConfig({{
accessToken: () => localStorage.getItem('authToken') || ''
}});
// API key authentication
this.apiConfig.updateConfig({{
apiKey: 'your-api-key'
}});
// Basic authentication
this.apiConfig.updateConfig({{
username: 'user',
password: 'pass'
}});
```
### JWT Token Refresh
```typescript
import {{ ApiConfigService }} from './api/api-config.service';
import {{ LocalStorageTokenStorageService }} from './api/token-storage.service';
// Configure JWT token refresh
this.apiConfig.updateConfig({{
jwt: {{
refreshEndpoint: '/api/v1/auth/refresh',
refreshToken: () => this.tokenStorage.getRefreshToken() || '',
onTokenRefresh: (token) => {{
console.log('Token refreshed:', token);
}},
onAuthError: () => {{
// Redirect to login on auth failure
this.router.navigate(['/login']);
}},
refreshThreshold: 300, // Refresh if expires within 5 minutes
checkExpirationBeforeRequest: true
}}
}});
```
### Retry Logic
```typescript
import {{ ApiConfigService }} from './api/api-config.service';
// Configure retry behavior
this.apiConfig.updateConfig({{
retry: {{
maxRetries: 3,
baseDelay: 1000, // 1 second
maxDelay: 10000, // 10 seconds
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
retryOnNetworkError: true
}}
}});
```
## Error Handling
```typescript
import {{ ApiError }} from './api/core';
this.apiService.getUsers().subscribe({{
next: (data) => {{
console.log(data);
}},
error: (err) => {{
if (err instanceof ApiError) {{
// Check error type
if (err.isClientError()) {{
console.error('Client error:', err.status);
}} else if (err.isServerError()) {{
console.error('Server error:', err.status);
}}
// Get detailed error message
console.error('Verbose message:', err.getVerboseMessage());
// Get error details
const details = err.getErrorDetails();
console.error('Error details:', details);
}}
}}
}});
```
## Generated Files
- `types.ts` - TypeScript type definitions ({} schemas)
- `core.ts` - Core infrastructure (ApiError, Config interfaces)
- `token-storage.service.ts` - Token storage service
- `api-config.service.ts` - API configuration service
- `oauth2-token-manager.service.ts` - OAuth2 token manager
- `auth-interceptor.ts` - HTTP interceptor for authentication
- `retry-interceptor.ts` - HTTP interceptor for retry logic
- `{}.service.ts` - Main API service ({} operations)
- `api-module.ts` - Angular module for easy setup
- `package.json` - Package configuration
- `README.md` - This documentation
## API Operations
{}
## Development
```bash
# Build TypeScript
npm run build
# Watch mode
npm run dev
```
"#,
spec.info.title,
spec.info.title,
spec.info.version,
api_description,
operation_count,
schema_count,
operation_count,
service_name_import,
api_title_lowercase,
service_name_import,
api_title_lowercase,
service_name,
base_url,
schema_count,
api_title_lowercase,
operation_count,
operations_list
);
Ok(readme)
}
fn generate_operations_list(&self, spec: &OpenApiSpec) -> String {
let mut operations = Vec::new();
for (path, path_item) in &spec.paths {
for (method, operation) in &path_item.operations {
let fallback_summary = format!("{} {}", method.to_uppercase(), path);
let summary = operation
.summary
.as_ref()
.unwrap_or(operation.operation_id.as_ref().unwrap_or(&fallback_summary));
operations.push(format!("- **{} {}** - {}", method.to_uppercase(), path, summary));
}
}
operations.join("\n")
}
}
#[async_trait::async_trait]
impl ClientGeneratorPlugin for AngularClientGenerator {
fn framework_name(&self) -> &str {
"angular"
}
fn supported_extensions(&self) -> Vec<&str> {
vec!["ts"]
}
async fn generate_client(
&self,
spec: &OpenApiSpec,
config: &ClientGeneratorConfig,
) -> Result<ClientGenerationResult> {
self.generate_angular_client(spec, config)
}
async fn get_metadata(&self) -> PluginMetadata {
PluginMetadata::new("Angular Client Generator").with_capability("client_generator")
}
}
impl Default for AngularClientGenerator {
fn default() -> Self {
Self::new().expect("Failed to create AngularClientGenerator")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client_generator::{ApiInfo, OpenApiSpec};
use std::collections::HashMap;
#[test]
fn test_angular_client_generator_creation() {
let generator = AngularClientGenerator::new();
assert!(generator.is_ok());
}
#[test]
fn test_framework_name() {
let generator = AngularClientGenerator::new().unwrap();
assert_eq!(generator.framework_name(), "angular");
}
#[test]
fn test_supported_extensions() {
let generator = AngularClientGenerator::new().unwrap();
let extensions = generator.supported_extensions();
assert!(extensions.contains(&"ts"));
}
#[tokio::test]
async fn test_generate_client() {
let generator = AngularClientGenerator::new().unwrap();
let spec = OpenApiSpec {
openapi: "3.0.0".to_string(),
info: ApiInfo {
title: "Test API".to_string(),
version: "1.0.0".to_string(),
description: Some("Test API".to_string()),
},
servers: None,
paths: HashMap::new(),
components: None,
};
let config = ClientGeneratorConfig {
output_dir: "./output".to_string(),
base_url: Some("http://localhost:3000".to_string()),
include_types: true,
include_mocks: false,
template_dir: None,
options: HashMap::new(),
};
let result = generator.generate_client(&spec, &config).await;
assert!(result.is_ok());
let result = result.unwrap();
assert!(!result.files.is_empty());
assert_eq!(result.metadata.framework, "angular");
}
}