/** * @angular-modernizer/plugin-angular - Promise Service Transform Orchestrator * * Transforms @Injectable service methods from Promise to Observable. * * Philosophy: "Thin Rule, Thick Orchestrator" * All business logic lives here; the rule is a minimal protocol wrapper. * * Transformations performed: * 1. Remove async keyword from matching methods * 2. Change return type Promise → Observable * 3. Remove .toPromise() calls via AST node deletion * 4. Add .pipe(retry(n), catchError(this.handleError)) to HttpClient calls * 5. Generate private handleError() method if not present * 6. Update imports (Observable, throwError, retry, catchError, HttpErrorResponse) * * Three guarantees: * - Idempotent: checks for Observable return type before modifying * - Atomic: rollback to original text on any failure * - Reversible: stores originalText in metadata */ import type { TransformContext, TransformResult, } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import type { ClassDeclaration, MethodDeclaration, SourceFile } from 'ts-morph'; import { SyntaxKind, Scope } from 'ts-morph'; export interface PromiseServiceTransformOptions { /** Transform only this specific method name */ methodName?: string; /** Transform all eligible methods */ transformAll?: boolean; } export class PromiseServiceTransformOrchestrator { readonly ruleId = 'angular:promise-service-to-observable'; run( context: TransformContext, options: PromiseServiceTransformOptions, ): TransformResult { const { sourceFile, filePath } = context; // Guard: must be a service (@Injectable decorator present) const serviceClass = this.findServiceClass(sourceFile); if (!serviceClass) { return { ruleId: this.ruleId, modified: false, message: 'No @Injectable class found', filePath, }; } const promiseMethods = this.findPromiseMethods(serviceClass); if (promiseMethods.length === 0) { return { ruleId: this.ruleId, modified: false, message: 'No Promise-returning methods found', filePath, }; } // Filter to specific method if requested let methodsToTransform = promiseMethods; if (options.methodName && !options.transformAll) { methodsToTransform = promiseMethods.filter( (m) => m.getName() === options.methodName, ); if (methodsToTransform.length === 0) { return { ruleId: this.ruleId, modified: false, message: `Method '${options.methodName}' not found or already Observable`, filePath, }; } } const originalText = sourceFile.getFullText(); try { for (const method of methodsToTransform) { this.transformMethod(method); } this.ensureErrorHandler(serviceClass); this.updateImports(sourceFile); return { ruleId: this.ruleId, modified: true, message: `Transformed ${methodsToTransform.length} service method(s) from Promise to Observable`, filePath, metadata: { methodsTransformed: methodsToTransform.map((m) => m.getName()), originalText, }, } as TransformResult & { metadata: Record }; } catch (error) { // Atomic rollback sourceFile.replaceWithText(originalText); return { ruleId: this.ruleId, modified: false, message: `Transformation failed: ${error instanceof Error ? error.message : String(error)}`, filePath, }; } } private transformMethod(method: MethodDeclaration): void { // 1. Remove async modifier method.setIsAsync(false); // 2. Change return type: Promise → Observable const returnTypeNode = method.getReturnTypeNode(); if (returnTypeNode) { const typeText = returnTypeNode.getText(); if (typeText.startsWith('Promise<') && typeText.endsWith('>')) { const inner = typeText.slice('Promise<'.length, -1); method.setReturnType(`Observable<${inner}>`); } else if ( typeText === 'Promise' || typeText === 'Promise' ) { method.setReturnType(`Observable<${typeText.slice(8, -1)}>`); } } else { // Inferred from async — set explicit Observable method.setReturnType('Observable'); } // 3. Remove .toPromise() calls via AST — find and remove from the call chain this.removeToPromiseCalls(method); // 4. Add .pipe() with retry+catchError to HttpClient calls this.addPipeToHttpCalls(method); // 5. Remove leading 'return await' → 'return' this.removeReturnAwait(method); } private removeToPromiseCalls(method: MethodDeclaration): void { // Repeatedly find and remove .toPromise() to handle all occurrences let found = true; while (found) { found = false; const calls = method.getDescendantsOfKind(SyntaxKind.CallExpression); for (const call of calls) { const expr = call.getExpression(); if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) { continue; } const propAccess = expr.asKindOrThrow( SyntaxKind.PropertyAccessExpression, ); if (propAccess.getName() !== 'toPromise') { continue; } // Replace call.getText() (e.g. "this.http.get('/x').toPromise()") // with just the object expression (e.g. "this.http.get('/x')") const objectExpr = propAccess.getExpression(); call.replaceWithText(objectExpr.getText()); found = true; break; // restart after mutation } } } private addPipeToHttpCalls(method: MethodDeclaration): void { // Handle explicit return statements (non-void methods) const returnStatements = method.getDescendantsOfKind( SyntaxKind.ReturnStatement, ); for (const ret of returnStatements) { const expr = ret.getExpression(); if (!expr) { continue; } const exprText = expr.getText(); if (!exprText.includes('this.http.')) { continue; } if (exprText.includes('.pipe(')) { continue; } const isWriteOp = /this\.http\.(post|put|patch|delete)\(/.test(exprText); const retryCount = isWriteOp ? 2 : 3; expr.replaceWithText( `${exprText}.pipe(\n retry(${retryCount}),\n catchError(this.handleError.bind(this))\n )`, ); return; // done for this method } // Handle void methods with expression statements (e.g. await http.delete()) // After removeToPromiseCalls + removeReturnAwait these become bare expression stmts const exprStatements = method.getDescendantsOfKind( SyntaxKind.ExpressionStatement, ); for (const stmt of exprStatements) { const exprText = stmt.getExpression().getText(); if (!exprText.includes('this.http.')) { continue; } if (exprText.includes('.pipe(')) { continue; } const isWriteOp = /this\.http\.(post|put|patch|delete)\(/.test(exprText); const retryCount = isWriteOp ? 2 : 3; // For void methods, convert to a return of the piped observable stmt.replaceWithText( `return ${exprText}.pipe(\n retry(${retryCount}),\n catchError(this.handleError.bind(this))\n );`, ); return; } } private removeReturnAwait(method: MethodDeclaration): void { // Remove 'await' from return await expressions const awaitExprs = method.getDescendantsOfKind(SyntaxKind.AwaitExpression); for (const awaitExpr of awaitExprs) { const inner = awaitExpr.getExpression(); awaitExpr.replaceWithText(inner.getText()); } } private ensureErrorHandler(serviceClass: ClassDeclaration): void { const hasHandler = serviceClass .getMethods() .some((m) => m.getName() === 'handleError'); if (hasHandler) { return; } serviceClass.addMethod({ name: 'handleError', scope: Scope.Private, parameters: [{ name: 'error', type: 'HttpErrorResponse' }], returnType: 'Observable', statements: [ `console.error('API Error:', error);`, `return throwError(() => error);`, ], }); } private updateImports(sourceFile: SourceFile): void { // Collect what already exists const existingRxjsImports = this.getNamedImports(sourceFile, 'rxjs'); const existingHttpImports = this.getNamedImports( sourceFile, '@angular/common/http', ); // Add rxjs imports: Observable, throwError, retry, catchError const rxjsNeeded = [ 'Observable', 'throwError', 'retry', 'catchError', ].filter((name) => !existingRxjsImports.has(name)); if (rxjsNeeded.length > 0) { const existing = sourceFile.getImportDeclaration( (d) => d.getModuleSpecifierValue() === 'rxjs', ); if (existing) { for (const name of rxjsNeeded) { existing.addNamedImport(name); } } else { sourceFile.addImportDeclaration({ moduleSpecifier: 'rxjs', namedImports: rxjsNeeded, }); } } // Add HttpErrorResponse if missing if (!existingHttpImports.has('HttpErrorResponse')) { const existing = sourceFile.getImportDeclaration( (d) => d.getModuleSpecifierValue() === '@angular/common/http', ); if (existing) { existing.addNamedImport('HttpErrorResponse'); } else { sourceFile.addImportDeclaration({ moduleSpecifier: '@angular/common/http', namedImports: ['HttpErrorResponse'], }); } } } private getNamedImports( sourceFile: SourceFile, moduleSpecifier: string, ): Set { const decl = sourceFile.getImportDeclaration( (d) => d.getModuleSpecifierValue() === moduleSpecifier, ); if (!decl) { return new Set(); } return new Set(decl.getNamedImports().map((ni) => ni.getName())); } private findServiceClass( sourceFile: SourceFile, ): ClassDeclaration | undefined { return sourceFile .getClasses() .find((cls) => cls.getDecorators().some((d) => d.getName() === 'Injectable'), ); } private findPromiseMethods( serviceClass: ClassDeclaration, ): MethodDeclaration[] { return serviceClass.getMethods().filter((method) => { // Already Observable — skip const returnTypeText = method.getReturnTypeNode()?.getText() ?? ''; if (returnTypeText.startsWith('Observable<')) { return false; } // Must be async or explicitly return Promise const isAsync = method.isAsync(); const returnsPromise = returnTypeText.includes('Promise<'); if (!isAsync && !returnsPromise) { return false; } // Must use HttpClient or .toPromise() const body = method.getBodyText() ?? ''; return body.includes('this.http.') || body.includes('.toPromise()'); }); } }