Angular Schematics – Add Method to an HTTP Service

Angular Schematic to add http call

We saw how to generate a basic Angular http service with a schematic in the previous post. What if we want to easily add a new call to an existing service the same way? Just using the basic Angular schematic for http services would replace the file. Then we probably loose some other methods or imports.

This is why we will explore in this article how to update existing files. Specifically we will add a new method to an existing service class.

But first we need to understand what an Abstract Syntax Tree is.

AST Basics

An Abstract Syntax Tree (AST) describes a programming language’s source file. As the name suggests, constructs like classes or methods are represented as a tree. Let’s check the AST of the following simple TypeScript class to understand it:

export class Service {
    public call(): Observable<Response> {
        return of(null);
    }
}

The tree behind this source “file” looks as follows:

Source: https://ts-ast-viewer.com

On the left hand side, we see the high-level view. There is a ClassDeclaration with an Identifier as child. Another child of the ClassDeclaration is of course the MethodDeclaration. Since the class Service has a method call there is a direct parent-child relation between those two language constructs. All those children (and also the SourceFile itself) are Nodes.

Furthermore, as we can see on the right hand side of above picture, the Identifier is another interesting child of the ClassDeclaration. The Identifier contains the name of the class in the escapedText, here “Service”. So, basically the AST gives you the access to everything in the code in a structured way.

How do we read a tree programmatically? We have to traverse it and check the attributes of the nodes to find a node we’re interested in.

Reading an Existing File

Our goal is to add a method for an HTTP call into an existing Angular http service. As an example we take the following service that already contains two methods to load customers by GET.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class CustomerHttpService {
  private readonly baseUrl = '';

  constructor(private http: HttpClient) {
  }

  public getAll(): Observable<Customer[]> {
    return this.http.get<Customer[]>(this.baseUrl);
  }

  public getById(id: number): Observable<Customer> {
    return this.http.get<Customer>(`${this.baseUrl}/${id}`);
  }
}

If we now want to add a method to the CustomerHttpService class we need to know a few things:

  • What is the source text of the customer.http-service.ts file in an Angular project?
  • Where is our existing Service class in the tree, i.e. CustomerHttpService?
  • What is the last position in the file for a certain method within CustomerHttpService, e.g. what is the position of the closing } for the getById method?

Basic Angular add http call schematic function

The high-level view of our schematic function is shown in the code below.

export function addHttpCall(_options: any): Rule {
    return (tree: Tree, _context: SchematicContext) => {
        const workspaceConfigBuffer = tree.read('angular.json');
        if (!workspaceConfigBuffer) {
            throw new SchematicsException('Not an Angular CLI workspace! The angular.json file is missing');
        }
        const methodName = 'getById';

        // steps 1 to 5:
        const filePath = determineTargetFilePath(workspaceConfigBuffer, _options);
        const nodes: ts.Node[] = getASTFromSourceFilePath(tree, filePath);
        const lastPositionOfMethod = getLastPositionOfMethod(nodes, methodName);
        const methodAddChange = createInsertChange(filePath, lastPositionOfMethod!);
        updateTree(tree, filePath, methodAddChange);

        return tree;
    };
}

To get some helper methods we install a schematics package:

npm i @schematics/angular

Please also install @angular-devkit/schematics and @angular-devkit/core if not yet done.

We want to keep things simple at the moment. So, we skip a few inputs for the schematic, like the HTTP method or the placeMethodAfterExistingMethodNamed. So, the schematic can be called as follows:

ng generate add-http-call shared/services/Customer

We have divided the overall functionality into 5 steps:

  1. Determining the file path in the Angular project dependent on the input (shared/services/Customer is mapped to the correct path in the project)
  2. Having the file path we can read the existing file, create a temporary source file and create the AST for that file
  3. In the AST we search for a method after which we want to position our new method. In this example I simplified it by hardcoding ‘getById’. So, we will return the last position of the getById method.
  4. Based on the position and file path we can create an InsertChange object to modify the actual file. In the InsertChange we hardcode the method to a create() method using POST.
  5. Finally the tree is updated with the InsertChange for the new method

These 5 steps are described in the rest of this section.

1. Determine target file path

We use the workspace config together with the options to find the file path:

function  determineTargetFilePath(workspaceConfigBuffer: Buffer, _options: any): string {
    const workspaceConfig = JSON.parse(workspaceConfigBuffer.toString());
    const projectName = _options.project || workspaceConfig.defaultProject;
    const project = workspaceConfig.projects[projectName];

    const defaultProjectPath = buildDefaultPath(project);
    const parsedPath = parseName(defaultProjectPath, _options.name);
    const {name, path} = parsedPath;

    const filePath = `${path}/${name.toLowerCase()}.http-service.ts`;
    return filePath;
}

This is somewhat basic code for a lot of schematics. After reading the angular.json config we create the file name. In our case we assume that http service files end with http-service.ts. Like that we find the customer.http-service.ts.

2. Retrieve source code and create the AST

Now it becomes interesting. We create the AST based on the file path:

function getASTFromSourceFilePath(tree: Tree, filePath: string): ts.Node[] {
    const content = tree.read(filePath);
    if (!content) {
        throw new SchematicsException(`File ${filePath} does not exist.`);
    }

    const sourceText = content.toString('utf-8');
    const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
    const nodes = getSourceNodes(sourceFile);
    return nodes;
}

The schematic’s Tree helps us to read the file content. Based on the sourceText we can then create a TypeScript file. Now we are using the getSourceNodes method from @schematics/angular/utility/ast-utils to get the AST of this source file.

3. Find the last position of an existing method

We read a file and got the AST of that file. Now we need to work with this AST. That means we need to traverse the tree and find relevant nodes.

function getLastPositionOfMethod(nodes: ts.Node[], methodName: string) {
    const serviceClassNode = findServiceClassNode(nodes[0]);
    if (!serviceClassNode) {
        throw new SchematicsException(`Did not find a service class node`);
    }

    const methodNode = findMethodNode(serviceClassNode, methodName);
    if (!methodNode) {
        throw new SchematicsException(`Did not find the ${methodName}`);
    }

    const lastPositionOfMethod = getLastPosition(methodNode);
    return lastPositionOfMethod;
}

In simple words we are finding the last position of the ‘getById’ method by:

  • traversing through the nodes to find the service class node (helper method findServiceClassNode)
  • starting from this service class node to find the method’s node (helper method findMethodNode)
  • examining the methodNode and returning the last position of it in the file (helper method getLastPosition)

The helper methods are available here https://github.com/rschaniel/ng-blocks-factory/blob/main/src/add-http-call/ast-utils/ast-utils.ts if you are interested in the details. Probably there are more efficient and safer ways to traverse the tree. Anyway the created utils were good enough to get a basic example running and should be optimised later.

4. Generate the new method

We have found the last position of an existing method. This allows us to create a change for the file because now we know where to insert the new code.

function createInsertChange(filePath: string, lastPositionOfMethod: number) {
    const methodToAdd = 'public create(customer: Customer): Observable<Customer> {\n' +
        '    return this.http.post<Customer>(this.baseUrl, customer);\n' +
        '  }';
    const methodAddChange = new InsertChange(filePath, lastPositionOfMethod! + 1, methodToAdd);
    return methodAddChange;
}

First we create the text for the new method as simple string. The methodToAdd is currently hardcoded to keep things simple. Based on this String we can then create an InsertChange by specifying also the filePath and the position where to insert. In our case we are just inserting one position after the } of our getById.

5. Insert change into an Existing File

Lastly, we can insert the change into our tree. This is what happens in the updateTree method.

function updateTree(tree: Tree, filePath: string, methodAddChange: InsertChange) {
    const declarationRecorder = tree.beginUpdate(filePath);
    declarationRecorder.insertLeft(methodAddChange.pos, methodAddChange.toAdd);
    tree.commitUpdate(declarationRecorder);
}

We start kind of a transaction and add our change to that transaction before committing the update. This transaction is especially helpful of course in case of multiple changes that could go wrong. In the last step the tree is returned from the schematics method and our service file is updated.

Using the add-http-call Angular Schematic to Update a Service

After building the schematic project we can use it in an Angular project:

ng generate ../ng-blocks-factory/src/collection.json:add-http-call shared/services/Customer

If all went well we should see a success message in our terminal:

Our customer.http-service.ts file’s size increased by 696 bytes and if we open it we can find a new method:

Correcting the formatting should be easy then.

Summary and Limitations

You could see above how to do basic file updates with Angular schematics. In our case it was just adding a method. However, the same principles can be reused to change code in other ways. Nevertheless the described approach has some limitations. Hence, the next steps should be:

  • Format the code after inserting the change into a file. Probably it’s a good idea to reuse existing format tools, e.g. prettier
  • Having a more robust and user friendly AST util or find an npm package that already offers such functionality
  • The added method is based on some assumptions, e.g. the HttpClient field is named http. This could also be retrieved dynamically from the existing file by checking the constructor AST.
  • It is not checked if the method already exists in the file or a method with the same name.

Please find the code for this Angular add-http-call schematic on GitHub.

Finally the question: is it worth it? All this complicated tree traversal just to add three lines of code? In this basic example, I’d say no. But what if there is more code involved for such updates? Generating mocks, generating io-ts types, updating related NgRx files, etc.