import { strings } from '@angular-devkit/core'; import { MergeStrategy, Rule, SchematicContext, apply, applyTemplates, chain, mergeWith, move, url, } from '@angular-devkit/schematics'; import { buildDefaultPath } from '@schematics/angular/utility/workspace'; import { createProjectSchematic } from '@schematics/angular/utility/project'; import * as path from 'node:path'; import ts from 'typescript'; import { InterceptorRecipe, Schema } from './schema'; const RECIPES: readonly InterceptorRecipe[] = [ 'correlation-id', 'problem-details', 'auth-token', 'logging', ]; export function interceptor(options: Schema): Rule { return createProjectSchematic((resolvedOptions, { project }) => { if (!RECIPES.includes(resolvedOptions.name)) { throw new Error(`Unsupported interceptor recipe "${resolvedOptions.name}".`); } resolvedOptions.path ??= path.posix.join(buildDefaultPath(project), 'http'); const targetPath = path.posix.join(resolvedOptions.path, resolvedOptions.name); const source = apply(url(`./files/${resolvedOptions.name}`), [ applyTemplates({ ...strings, name: resolvedOptions.name }), move(targetPath), ]); return chain([ mergeWith(source, resolvedOptions.force ? MergeStrategy.Overwrite : MergeStrategy.Default), resolvedOptions.register ? registerInterceptor(project.sourceRoot ?? 'src', resolvedOptions.name, targetPath) : (tree) => tree, ]); })(options); } function registerInterceptor(sourceRoot: string, recipe: InterceptorRecipe, targetPath: string): Rule { return (tree, context) => { const configPath = `/${path.posix.join(sourceRoot, 'app/app.config.ts')}`; const interceptorName = `${strings.camelize(recipe)}Interceptor`; const importTarget = path.posix.join(targetPath, `${recipe}.interceptor`); let importPath = path.posix.relative(path.posix.dirname(configPath), importTarget); if (!importPath.startsWith('.')) { importPath = `./${importPath}`; } if (!tree.exists(configPath)) { warnManualRegistration(context, interceptorName, importPath, `No app.config.ts was found at ${configPath}.`); return; } const original = tree.readText(configPath); if (original.includes(interceptorName)) { return; } const sourceFile = ts.createSourceFile(configPath, original, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const configObject = findAppConfigObject(sourceFile); if (!configObject) { warnManualRegistration(context, interceptorName, importPath, 'The appConfig providers array could not be recognized safely.'); return; } const providers = findProvidersArray(configObject); if (!providers) { warnManualRegistration(context, interceptorName, importPath, 'The appConfig providers array could not be recognized safely.'); return; } let updated = original; const httpProvider = findProvideHttpClientCall(providers); if (httpProvider) { const replacement = addInterceptorToHttpProvider(httpProvider, interceptorName); if (!replacement) { warnManualRegistration(context, interceptorName, importPath, 'The existing provideHttpClient configuration is not a supported literal form.'); return; } updated = replaceRange(updated, httpProvider.getStart(sourceFile), httpProvider.getEnd(), replacement); } else { const insertion = `${providers.elements.length ? ',' : ''}\n provideHttpClient(withInterceptors([${interceptorName}]))\n `; updated = replaceRange(updated, providers.elements.end, providers.elements.end, insertion); } updated = addNamedImport(updated, '@angular/common/http', ['provideHttpClient', 'withInterceptors']); updated = addNamedImport(updated, importPath, [interceptorName]); tree.overwrite(configPath, updated); }; } function findAppConfigObject(sourceFile: ts.SourceFile): ts.ObjectLiteralExpression | undefined { for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) continue; for (const declaration of statement.declarationList.declarations) { if ( ts.isIdentifier(declaration.name) && declaration.name.text === 'appConfig' && declaration.initializer && ts.isObjectLiteralExpression(declaration.initializer) ) { return declaration.initializer; } } } return undefined; } function findProvidersArray(config: ts.ObjectLiteralExpression): ts.ArrayLiteralExpression | undefined { for (const property of config.properties) { if ( ts.isPropertyAssignment(property) && property.name.getText() === 'providers' && ts.isArrayLiteralExpression(property.initializer) ) { return property.initializer; } } return undefined; } function findProvideHttpClientCall(providers: ts.ArrayLiteralExpression): ts.CallExpression | undefined { return providers.elements.find( (element): element is ts.CallExpression => ts.isCallExpression(element) && ts.isIdentifier(element.expression) && element.expression.text === 'provideHttpClient', ); } function addInterceptorToHttpProvider(call: ts.CallExpression, interceptorName: string): string | undefined { const argumentsText = call.arguments.map((argument) => argument.getText()); const existingIndex = call.arguments.findIndex( (argument) => ts.isCallExpression(argument) && ts.isIdentifier(argument.expression) && argument.expression.text === 'withInterceptors', ); if (existingIndex >= 0) { const withInterceptors = call.arguments[existingIndex]; if ( !ts.isCallExpression(withInterceptors) || withInterceptors.arguments.length !== 1 || !ts.isArrayLiteralExpression(withInterceptors.arguments[0]) ) { return undefined; } const names = withInterceptors.arguments[0].elements.map((element) => element.getText()); argumentsText[existingIndex] = `withInterceptors([${[...names, interceptorName].join(', ')}])`; } else { argumentsText.push(`withInterceptors([${interceptorName}])`); } return `provideHttpClient(${argumentsText.join(', ')})`; } function addNamedImport(source: string, moduleName: string, names: readonly string[]): string { const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const importPattern = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${escapedModule}['"];?`); const existing = source.match(importPattern); if (existing) { const current = existing[1].split(',').map((name) => name.trim()).filter(Boolean); const combined = [...new Set([...current, ...names])].sort(); return source.replace(importPattern, `import { ${combined.join(', ')} } from '${moduleName}';`); } const lastImport = [...source.matchAll(/^import .*;$/gm)].at(-1); const insertionPoint = lastImport ? (lastImport.index ?? 0) + lastImport[0].length : 0; return replaceRange(source, insertionPoint, insertionPoint, `${insertionPoint ? '\n' : ''}import { ${names.join(', ')} } from '${moduleName}';`); } function replaceRange(source: string, start: number, end: number, value: string): string { return `${source.slice(0, start)}${value}${source.slice(end)}`; } function warnManualRegistration( context: SchematicContext, interceptorName: string, importPath: string, reason: string, ): void { context.logger.warn( `${reason}\nRegister manually:\n` + `import { provideHttpClient, withInterceptors } from '@angular/common/http';\n` + `import { ${interceptorName} } from '${importPath}';\n` + `provideHttpClient(withInterceptors([${interceptorName}]))`, ); }