New Endpoint

Step-by-step guide to creating a new HTTP endpoint

Overview

Creating a new endpoint involves these steps:

  1. Define the route with schemas
  2. Add the route to your router
  3. Create the use case
  4. Create the handler
  5. Wire in bootstrap

Steps

1. Define the Route

Create a route definition with schemas for request validation and response documentation:

// presentation/http/routes/users.routes.ts
import { defineRoute } from '@cosmneo/onion-lasagna/http/route';
import { zodSchema, z } from '@cosmneo/onion-lasagna/http/schema/zod';

export const getUserRoute = defineRoute({
  method: 'GET',
  path: '/api/users/:userId',
  request: {
    params: {
      schema: zodSchema(
        z.object({
          userId: z.string().uuid(),
        }),
      ),
    },
  },
  responses: {
    200: {
      description: 'User found',
      schema: zodSchema(
        z.object({
          id: z.string().uuid(),
          email: z.string().email(),
          name: z.string(),
        }),
      ),
    },
    404: {
      description: 'User not found',
    },
  },
  docs: {
    summary: 'Get user by ID',
    tags: ['Users'],
    operationId: 'getUser',
  },
});

2. Add to Router

Add the route to your router definition:

// presentation/http/router.ts
import { defineRouter } from '@cosmneo/onion-lasagna/http/route';
import { getUserRoute, createUserRoute, listUsersRoute } from './routes/users.routes';

export const userRouter = defineRouter({
  users: {
    get: getUserRoute,
    create: createUserRoute,
    list: listUsersRoute,
  },
});

3. Create the Use Case

Define the use case port and implementation:

// app/ports/get-user.query.port.ts
interface GetUserQueryPort {
  execute(input: GetUserInput): Promise<GetUserOutput>;
}

interface GetUserInput {
  userId: string;
}

interface GetUserOutput {
  id: string;
  email: string;
  name: string;
}
// app/use-cases/get-user.query.ts
import { BaseInboundAdapter, NotFoundError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class GetUserQuery
  extends BaseInboundAdapter<GetUserInput, GetUserOutput>
  implements GetUserQueryPort
{
  constructor(private readonly userRepo: UserRepositoryPort) {
    super();
  }

  protected async handle(input: GetUserInput): Promise<GetUserOutput> {
    const user = await this.userRepo.findById(input.userId);

    if (!user) {
      throw new NotFoundError({
        message: `User ${input.userId} not found`,
        code: 'USER_NOT_FOUND',
      });
    }

    return {
      id: user.id.value,
      email: user.email.value,
      name: user.name,
    };
  }
}

4. Create the Handler

Add the handler using the serverRoutes() builder:

// presentation/http/handlers/users.handlers.ts
import { serverRoutes } from '@cosmneo/onion-lasagna/http/server';
import { userRouter } from '../router';
import type { UseCases } from '../../../bootstrap/use-cases.bootstrap';

export function createUserHandlers(useCases: UseCases) {
  return serverRoutes(userRouter)
    .handle('users.get', {
      requestMapper: (req) => ({
        userId: req.pathParams.userId,
      }),
      useCase: useCases.getUserQuery,
      responseMapper: (output) => ({
        status: 200 as const,
        body: {
          id: output.id,
          email: output.email,
          name: output.name,
        },
      }),
    })
    .buildPartial();
}

5. Wire in Bootstrap

Add the use case to your bootstrap:

// bootstrap/use-cases.bootstrap.ts
import { GetUserQuery } from '../app/use-cases/get-user.query';

export function createUseCases(adapters: Adapters) {
  return {
    // ... other use cases
    getUserQuery: new GetUserQuery(adapters.userRepository),
  };
}

Route Definition Options

Request Configuration

PropertyDescription
request.body.schemaRequest body schema
request.query.schemaQuery parameters schema
request.params.schemaPath parameters schema
request.headers.schemaRequest headers schema
request.context.schemaContext schema (from middleware)

Response Configuration

PropertyDescription
responses[status].descriptionResponse description for OpenAPI
responses[status].schemaResponse body schema

Documentation

PropertyDescription
docs.summaryShort description for OpenAPI
docs.descriptionDetailed description
docs.tagsOpenAPI tags for grouping
docs.operationIdUnique operation identifier

Void Operations (DELETE, UPDATE)

For operations that return no content (HTTP 204):

Route Definition

export const deleteUserRoute = defineRoute({
  method: 'DELETE',
  path: '/api/users/:userId',
  request: {
    params: {
      schema: zodSchema(
        z.object({
          userId: z.string().uuid(),
        }),
      ),
    },
  },
  responses: {
    204: {
      description: 'User deleted',
    },
    404: {
      description: 'User not found',
    },
  },
  docs: {
    summary: 'Delete a user',
    tags: ['Users'],
    operationId: 'deleteUser',
  },
});

Handler

.handle('users.delete', {
  requestMapper: (req) => ({
    userId: req.pathParams.userId,
  }),
  useCase: useCases.deleteUserUseCase,
  responseMapper: () => ({
    status: 204 as const,
    body: undefined,
  }),
})

Protected Endpoints

For endpoints requiring authentication, add a context schema:

Route with Context

export const createPostRoute = defineRoute({
  method: 'POST',
  path: '/api/posts',
  request: {
    body: {
      schema: zodSchema(
        z.object({
          title: z.string().min(1),
          content: z.string(),
        }),
      ),
    },
    context: {
      schema: zodSchema(
        z.object({
          userId: z.string(),
        }),
      ),
    },
  },
  responses: {
    201: {
      description: 'Post created',
      schema: zodSchema(z.object({ postId: z.string() })),
    },
  },
});

Handler with Context

.handle('posts.create', {
  requestMapper: (req, ctx) => ({
    title: req.body.title,
    content: req.body.content,
    authorId: ctx.userId,  // From context schema
  }),
  useCase: useCases.createPostUseCase,
  responseMapper: (output) => ({
    status: 201 as const,
    body: { postId: output.postId },
  }),
})

The context is provided by the contextExtractor in framework registration:

registerHonoRoutes(app, routes, {
  middlewares: [authMiddleware],
  contextExtractor: (c) => ({
    userId: c.get('jwtPayload')?.sub,
  }),
});

Checklist

  • Route definition created with schemas
  • Route added to router
  • Use case port defined
  • Use case implemented with BaseInboundAdapter
  • Handler added using serverRoutes() builder
  • Use case wired in bootstrap
  • Routes exported from bootstrap