Step 5 of 12 42% complete

Step 5: Controller Layer with API Endpoints

What You'll Learn

  • Create REST controllers with @Component decorator
  • Use @Inject to connect controllers with services
  • Define API routes in OpenAPI YAML with x-controller
  • Handle request body, path params, and response status
  • Add JSON Schema validation for automatic request validation

What You'll Build

A TodosController with validated REST endpoints for CRUD operations.

Understanding Controllers

Controllers are the HTTP layer of your application. They receive requests, delegate to services, and return responses. In NoEgo, controllers are defined as classes with methods that map to OpenAPI operations.

Project Files Overview

Here's an overview of the controller and API route files you'll create. Click on files in the tree to explore. Files marked with NEW are the ones you'll create.

my-noego-app
server/controller/todos_controller.ts NEW
server
controller
ui
openapi
api
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { Component, Inject } from "@noego/ioc";
import type { Context } from "@noego/forge";
import TodosService from "../service/todos_service";

@Component()
export default class TodosController {
  constructor(
    @Inject(TodosService) private service: TodosService
  ) {}

  async getTodos(ctx: Context) {
    const todos = await this.service.getTodos();
    return { todos };
  }

  async getTodoById(ctx: Context) {
    const id = Number(ctx.params.id);
    const todo = await this.service.getTodoById(id);
    if (!todo) {
      ctx.res?.status?.(404);
      return { error: "Todo not found" };
    }
    return todo;
  }

  async createTodo(ctx: Context) {
    const { title, description } = ctx.req.body;
    const id = await this.service.addTodo(title, description);
    ctx.res?.status?.(201);
    return { id };
  }

  async deleteTodo(ctx: Context) {
    const id = Number(ctx.params.id);
    try {
      await this.service.deleteTodo(id);
      ctx.res?.status?.(204);
      return;
    } catch (error) {
      ctx.res?.status?.(404);
      return { error: "Todo not found" };
    }
  }
}

Creating the Controller

Create a controller that injects the service and handles HTTP requests:

server/controller/todos_controller.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { Component, Inject } from "@noego/ioc";
import type { Context } from "@noego/forge";
import TodosService from "../service/todos_service";

@Component()
export default class TodosController {
  constructor(
    @Inject(TodosService) private service: TodosService
  ) {}

  async getTodos(ctx: Context) {
    const todos = await this.service.getTodos();
    return { todos };
  }

  async getTodoById(ctx: Context) {
    const id = Number(ctx.params.id);
    const todo = await this.service.getTodoById(id);
    if (!todo) {
      ctx.res?.status?.(404);
      return { error: "Todo not found" };
    }
    return todo;
  }

  async createTodo(ctx: Context) {
    const { title, description } = ctx.req.body;
    const id = await this.service.addTodo(title, description);
    ctx.res?.status?.(201);
    return { id };
  }

  async deleteTodo(ctx: Context) {
    const id = Number(ctx.params.id);
    try {
      await this.service.deleteTodo(id);
      ctx.res?.status?.(204);
      return;
    } catch (error) {
      ctx.res?.status?.(404);
      return { error: "Todo not found" };
    }
  }
}

Understanding the Context

The Context type from @noego/forge provides access to request and response:

  • ctx.req.body - Parsed request body (for POST, PUT, PATCH)
  • ctx.params - Path parameters (e.g., /todos/:id)
  • ctx.query - Query string parameters
  • ctx.res?.status?.(code) - Set HTTP response status

Defining Routes in OpenAPI

Connect your controller to routes using OpenAPI YAML. The x-controller extension points to your controller file, and operationId specifies the method name:

ui/openapi/api/todos.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
modules:
  todos-api:
    basePath: /api/todos
    paths:
      /:
        get:
          summary: Get all todos
          operationId: getTodos
          x-controller: controller/todos_controller
          responses:
            '200':
              description: List of todos
        post:
          summary: Create a new todo
          operationId: createTodo
          x-controller: controller/todos_controller
          requestBody:
            required: true
            content:
              application/json:
                schema:
                  type: object
                  required:
                    - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 200
                    description:
                      type: string
          responses:
            '201':
              description: Todo created
      /{id}:
        get:
          summary: Get a todo by ID
          operationId: getTodoById
          x-controller: controller/todos_controller
          responses:
            '200':
              description: Todo found
            '404':
              description: Todo not found
        delete:
          summary: Delete a todo
          operationId: deleteTodo
          x-controller: controller/todos_controller
          responses:
            '204':
              description: Todo deleted
            '404':
              description: Todo not found

How x-controller Works

The x-controller extension tells NoEgo which file contains the controller class. The path is relative to your server directory:

  • x-controller: controller/todos_controllerserver/controller/todos_controller.ts
  • The operationId maps directly to a method name on the controller class
  • The controller must export a default class decorated with @Component()

Automatic Request Validation

NoEgo automatically validates incoming requests against your OpenAPI schema. If validation fails, a 400 error is returned before your controller code runs.

JSON Schema validation in requestBody
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
requestBody:
  required: true
  content:
    application/json:
      schema:
        type: object
        required:
          - title           # title is required
        properties:
          title:
            type: string
            minLength: 1    # cannot be empty
            maxLength: 200  # max 200 chars
          description:
            type: string    # optional

With this schema, requests like these would be automatically rejected:

  • {} → Missing required field "title"
  • {"title": ""} → title is too short (minLength: 1)
  • {"title": 123} → title must be a string

Testing Your Endpoints

Use curl or fetch to test your API endpoints:

terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
# Get all todos
curl http://localhost:3000/api/todos

# Create a new todo
curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn NoEgo", "description": "Build an awesome app"}'

# Get a specific todo
curl http://localhost:3000/api/todos/1

# Delete a todo
curl -X DELETE http://localhost:3000/api/todos/1

Controller Best Practices

  • Keep controllers thin: Delegate business logic to services
  • Handle errors gracefully: Return appropriate status codes
  • Use operationId consistently: Match method names exactly
  • Validate with schemas: Let OpenAPI handle input validation
  • Return JSON objects: NoEgo automatically serializes return values

What's Next?

Now that we have controllers to handle HTTP requests, let's create the service layer to add business logic and validation.

Troubleshooting

NoEgo

© 2025 NoEgo. All rights reserved.