Simple API

Setup Your Project

First, create a new directory for your project and initialize it with soap cli:

mkdir bookstore-api
cd bookstore-api
soap new project -n "Bookstore" -l "typescript" -s "src" -f "express" -d "mongo" -i "inversify"

After creating your project, your file structure (assuming default configuration settings) should look like this:

.soap/
  - cli.config.js
  - plugin-map.json
  - plugin.config.json
  - project.config.json
  - texts.json
node_modules/
src/
  - index.ts
  - dependencies.ts
  - routes.ts
package.json
package-lock.json
tsconfig.json

Create components

Ok, now let's look at the source of our API, you can create each component one by one using the soap new <component_name> command, but we will use a JSON file first. Let's assume that we have planned everything before we start coding.

books-api.json
{
  "controllers":[
    {
      "name": "Books",
      "endpoint": "books",
      "handlers": [
        {
          "name": "getAllBooks",
          "output": "Array<Entity<Book>>"
        },
        {
          "name": "getBook",
          "input": { "title":"string" }
          "output": "Entity<Book>"
        },
        {
          "name": "addBook",
          "output": "boolean"
        },
        {
          "name": "removeBook",
          "output": "boolean"
        }
      ]
    }
  ],
  "routes": [
    {
      "name": "GetAllBooks",
      "endpoint": "books",
      "handler": {
        "controller": "books",
        "name": "getAllBooks"
      },
      "request": {
        "method": "GET",
        "path": "/list"
      },
      "response": {
        "200": "Array<RouteModel<Book>>",
        "500": "Error",
        "404": { "message": "string", "error_code": "number" }
      }
    }
  ],
  "entities": [
    {
      "name": "Book",
      "endpoint": "books",
      "props": [
        "title:string",
        "author:string",
        "publishedYear: number"
      ]
    }
  ],
  "models": [
    {
      "name": "Book",
      "endpoint": "books",
      "types": ["route_model"],
      "props": [
        {
          "name": "title",
          "type": "string"
        },
        {
          "name": "author",
          "type": "string"
        },
        {
          "name": "published_year",
          "type": "number"
        }
      ]
    }
  ],
  "use_cases": [
    {
      "name": "GetAllBooks",
      "endpoint": "books"
      "output": "Array<Entity<Book>"
    }
  ],
  "repositories": [
    {
      "name": "Book",
      "endpoint": "books",
      "contexts": ["mongo"]
    }
  ],
  "collections": [
    {
      "name": "Book",
      "endpoint": "books",
      "storages": ["mongo"],
      "model": "Book",
      "table": "book.collection"
    }
  ]
}

With the JSON file ready, we can use the command soap new --json -w to create all the elements included in this JSON. After this operation, if everything went smoothly, we should have the following file structure.

File structure
.soap/
  - cli.config.js
  - plugin-map.json
  - plugin.config.json
  - project.config.json
  - texts.json
node_modules/
src/
  - endpoints/
    - books/
      - data/
        - dtos/
          - book.dto.ts
          - index.ts
        - collections/
          - book.mongo.collection.ts
          - index.ts
        - mappers/
          - book.mongo.mapper.ts
          - index.ts
      - domain/
        - books.controller.ts
        - use-cases/
          - get-all-books.use-case.ts
          - index.ts
        - entities/
          - book.ts
          - index.ts
        - repositories/
          - book.repository.ts
          - index.ts
      - routes/
        - get-all-books.route.ts
        - get-all-books.route-io.ts
        - get-all-books.route-model.ts
        - index.ts
  - index.ts
  - dependencies.ts
  - routes.ts
package.json
package-lock.json
tsconfig.json

This is what the file structure looks like after creating the components. Remember, you can customize this structure by making changes to the .soap/plugin.config.json file.

After generating the files, your task will be to add logic in the controller, you will need to decide which and when use cases to invoke. In the use cases themselves, you will need to implement logic for retrieving information about all books. Since you are using a MongoDB database, you must also define how you will map values from documents to entities and vice versa. For this purpose, you need to edit the mapper file. Finally, don't forget about mapping the result obtained from the controller method and parsing it into a specific request response.

Content of the generated files

The content of the files after generation should look as follows, leaving you room for implementation. Since we didn't use the --skip-tests option, remember to fill out the test templates. By default, they are placed in the __tests__ directories.

Root layer

import { Container } from "inversify";
import bodyParser from "body-parser";
import cors from "cors";
import express from "express";
import { Routes } from "./routes";
import { Dependencies } from "./dependencies";
export * from "./routes";
export * from "./dependencies";

const start = async () => {
  const app = express();
  app.use(
    cors({
      origin: "*",
    })
  );
  app.use(bodyParser.json());
  const routes = new Routes(app);
  const container = new Container();
  const dependencies = new Dependencies();

  dependencies.configure(container);
  routes.configure(container);

  app.listen(process.env.PORT, () => {
    console.log(`Server is running at http://localhost:${process.env.PORT}`);
  });
};

start();

Routes

import { RouteHandler, GetRoute } from "@soapjs/soap";
import { GetAllBooksRouteIO } from "./get-all-books.route-io";
import { BooksController } from "../domain/controllers";

export class GetAllBooksRoute extends GetRoute {
  public static create(handler: RouteHandler) {
    return new GetAllBooksRoute("/books/list", handler);
  }

  private constructor(handler: RouteHandler) {
    super(handler);
  }
}

Domain layer

import { injectable } from "inversify";
import { Result } from "@soapjs/soap";
import {
  Book,
  GetBookInput,
  AddBookInput,
  RemoveBookInput
} from "./entitites";

@injectable()
export class BooksController {
  public static Token = "BooksController";

  public async getAllBooks(): Promise<Result<Book[]>> {
    // TODO: implement method
  }
  public async getBook(input: GetBookInput): Promise<Result<Book[]>> {
    // TODO: implement method
  }
  public async addBook(input: AddBookInput): Promise<Result<boolean>> {
    // TODO: implement method
  }
  public async removeBook(input: RemoveBookInput): Promise<Result<boolean>> {
    // TODO: implement method
  }
}

Data layer

import { MongoSource, MongoCollection } from "@soapjs/soap-node-mongo";
import { BookMongoModel } from "../dtos";

export class BookMongoCollection extends MongoCollection<BookMongoModel> {
  constructor(source: MongoSource) {
    super(source, 'book.collection');
  }
}

This roughly outlines the generated code. Your task now will be to fill in the code segments to achieve the desired outcome when sending requests to the endpoint. Let's start with mapping the request data to the input for the controller and the controller's output to the response. All this needs to be done in the GetAllBooksRouteIO class. You can, of course, add your mapper, but essentially, RouteIO serves as a mapper itself.

In our example, the request does not contain any parameters, so there is no need to implement fromRequest. However, it's different with toResponse; the Controller always returns a Result object with the content type you choose, here an array of Book entities. We now need to convert this array into an array of BookRouteModel models or return an error if the result contains a failure.

import { RouteIO, Response, Request } from "@soapjs/soap";
import { Book } from "../domain/entities";
import { BookRouteModel } from "./get-all-books.route-model.ts";

export class GetAllBooksRouteIO implements RouteIO {
  public toResponse(output: Result<Book[]>): Response<BookRouteModel[]> {
    if (output.isFailure) {
      return {
        body: output.failure.error.message,
        status: 500
      };
    }
    
    return {
      status: 200,
      body: result.content
        .map(entity => ({
          author: entity.author,
          title: entity.author,
          published_year: entity.publishedYear
        }))
    };
  }
}

Great, now we need to tell the controller what to do when the getAllBooks method is called. Our example is very simple and doesn't require any logic in the controller. It's your code, and you can decide whether to directly call the repository here, but for more complex scenarios, it's better to limit the controller to calling the appropriate use cases. This way, you don't clutter the controller with "helpers" or dedicated private methods (which could be untestable). The controller, by nature, takes a command and passes it on. That's what we'll do in our case, even though it's simple.

...
import { GetAllBooksUseCase } from "./use-ceses";
import { Book } from "./entitites";

@injectable()
export class BooksController {
  public static Token = "BooksController";
  constructor(
    @inject(GetAllBooksUseCase.Token) private getAllBooksUseCase:GetAllBooksUseCase;
  ){}

  public async getAllBooks(): Promise<Result<Book[]>> {
    return this.getAllBooksUseCase.execute();
  }
  ...
}

And now, the most important part, though in our case, it's equally easy. We need to apply the repository in the use case and call the data fetching for the books.

import { injectable } from "inversify";
import { Result } from "@soapjs/soap";
import { Book } from "./entitites";

@injectable()
export class GetAllBooksUseCase implements UseCase<Book[]>{
  public static Token = "GetAllBooksUseCase";
  
  constructor(
    @inject(BookRepository.Token) private bookRepository: BookRepository
  ){}
  public async execute(): Promise<Result<Book[]>> {
    return this.bookRepository.find();
  }
}

And that's all, a simple example but it gives a general idea of what you can do. Remember to complete the unit tests, create the database, and run the application.

Last updated