1.1.9 • Published 8 months ago

hono-plus v1.1.9

Weekly downloads
-
License
MIT
Repository
-
Last release
8 months ago

hono-plus šŸš€

Smart, lightweight DI & decorator toolkit for Hono. Build enterprise-grade APIs with elegance and robust security.

🌟 Overview

Hono-plus supercharges your Hono experience with decorator-driven development, dependency injection, and powerful guard systems. Create maintainable, testable APIs with minimal boilerplate while leveraging Hono's blazing-fast performance.

šŸ“¦ Installation

Using npm:

npm install hono-plus hono reflect-metadata

Using yarn:

yarn add hono-plus hono reflect-metadata

Using bun:

bun add hono-plus hono reflect-metadata

šŸ”§ Required Setup

Configure TypeScript (tsconfig.json):

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Features ⚔

šŸ›”ļø Powerful Guards System

  • Flexible route protection with stackable guards
  • Simple async functions returning true/false
  • Built-in error handling and status codes

šŸŽÆ Smart Dependency Injection

  • Constructor-based injection with decorators
  • Hierarchical service provider system
  • Automatic instance lifecycle management

šŸ”Œ Type-Safe Controllers

  • Decorator-based routing (@Get, @Post, etc.)
  • Automatic parameter parsing and validation
  • Intuitive error handling

šŸš€ Zero-Config Response Handling

  • Automatic content negotiation
  • Smart status code inference
  • Built-in support for JSON, Text, HTML, and Streams

šŸ” Intelligent Request Parsing

  • Automatic body parsing based on Content-Type
  • Built-in support for JSON, FormData, and raw content
  • Easy access to headers, query params, and URL data

⚔ Lightweight & Fast

  • No unnecessary dependencies
  • Minimal runtime overhead
  • Built on Hono's ultra-fast foundation

Guards Made Simple

Quick Start

import { Hono } from 'hono';
import { Controller, Get, Post, Injectable, Router, Guards } from 'hono-plus';

// Simple auth guard
const authGuard = async (header) => {
  const token = header('authorization');
  if (!token) {
    return false;  // Will return 401 Unauthorized
  }
  return true;
};

// Simple role guard
const adminGuard = async (header) => {
  const role = header('x-role');
  if (role !== 'admin') {
    throw new HttpError(403, 'Admin access required');
  }
  return true;
};

@Injectable()
class UserService {
  getUsers() {
    return [
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' }
    ];
  }
}

@Controller('/users')
@Guards(authGuard)  // Apply auth to all routes
class UserController {
  constructor(private userService: UserService) {}

  @Get()
  @Guards(adminGuard)  // Stack guards - requires both auth and admin
  async getUsers() {
    return {
      data: this.userService.getUsers()
    };
  }

  @Get('/:id')
  async getUser(params) {
    const id = params.id;
    const users = this.userService.getUsers();
    const user = users.find(u => u.id === parseInt(id));
    
    if (!user) {
      throw new HttpError(404, 'User not found');
    }
    return { data: user };
  }

  @Post()
  async createUser(body, header) {
    const role = header('x-role');
    return {
      status: 201,
      data: { created: true, role }
    };
  }
}

const app = new Hono();

Router.init({
  app,
  controllers: [UserController],
  providers: [UserService]
});

export default app;

Guards Made Simple

Guards are just async functions that return true or false:

// Basic auth guard
const authGuard = async (header) => {
  return header('authorization') ? true : false;
};

// Guard with custom error
const adminGuard = async (header) => {
  if (header('x-role') !== 'admin') {
    throw new HttpError(403, 'Admin only');
  }
  return true;
};

// Guard using multiple parameters
const validateGuard = async (body, header) => {
  if (!body.name) {
    throw new HttpError(400, 'Name required');
  }
  if (!header('x-api-key')) {
    return false;  // 401 Unauthorized
  }
  return true;
};

Using guards in controllers:

@Controller('/api')
@Guards(authGuard)  // Controller-level guard
class ApiController {
  @Post('/admin')
  @Guards(adminGuard)  // Route-level guard
  async adminOnly(body) {
    return { status: 'success' };
  }
}

Available Parameters

Both guards and controllers can access these parameters by name:

async function handler(
  // URL and Route Information
  params,          // URL parameters
  query,           // Single query parameters
  queries,         // Multiple query parameters
  path,            // Request path
  url,             // Full URL
  routePath,       // Matched route path
  matchedRoutes,   // All matched routes
  routeIndex,      // Current route index
  method,          // HTTP method
  
  // Request Data
  body,            // Parsed request body
  header,          // Function to get header value
  
  // Parsing Methods
  json,            // Function to parse body as JSON
  text,            // Function to get body as text
  arrayBuffer,     // Function to get body as ArrayBuffer
  blob,            // Function to get body as Blob
  formData,        // Function to parse form data
  
  // Validation
  valid,           // Request validation function
  
  // Context
  c,               // Hono context (also available as 'ctx' or 'context')
) {
  // Use only the parameters you need
}

Examples of using different parameters:

@Controller('/api')
class ApiController {
  // Basic URL parameters
  @Get('/users/:id')
  async getUser(params) {
    return { userId: params.id };
  }

  // Query parameters
  @Get('/search')
  async search(query, queries) {
    // Single value: ?tag=node
    const tag = query.tag;
    
    // Multiple values: ?tags=node&tags=javascript
    const allTags = queries().tags;
    
    return { tag, allTags };
  }

  // Request body with validation
  @Post('/data')
  async createData(body, valid) {
    const isValid = valid((data) => {
      return data.name && data.email;
    });
    
    if (!isValid) {
      throw new HttpError(400, 'Invalid data');
    }
    
    return { created: body };
  }

  // Manual body parsing
  @Post('/upload')
  async handleUpload(formData, text) {
    // Choose how to parse the body
    if (header('content-type').includes('form')) {
      const form = await formData();
      return { file: form.get('file') };
    } else {
      const content = await text();
      return { content };
    }
  }

  // Route information
  @Get('/info')
  async getInfo(routePath, matchedRoutes, routeIndex) {
    return {
      currentRoute: routePath,
      allRoutes: matchedRoutes,
      index: routeIndex
    };
  }

  // Using Hono context directly
  @Get('/context')
  async withContext(c) {
    return c.json({ message: 'Using context' });
  }
}

Examples:

// Get specific URL parameter
@Get('/users/:id')
async getUser(params) {
  const userId = params.id;
  return { id: userId };
}

// Access query string
@Get('/search')
async search(query) {
  const term = query.q;
  return { searching: term };
}

// Check authorization
@Post('/secure')
async secure(header) {
  const token = header('authorization');
  return { authorized: !!token };
}

Simple Error Handling

// Custom error with status
throw new HttpError(400, 'Invalid input');

// Error with code
throw new HttpError(403, 'Not allowed', 'FORBIDDEN');

// In controllers/guards
try {
  // Your logic
} catch (error) {
  if (error instanceof HttpError) {
    // Framework handles the response
    throw error;
  }
  // Unexpected errors become 500
  throw new HttpError(500, 'Server error');
}

Response Handling

The framework automatically handles different types of responses:

@Controller('/api')
class ApiController {
  // JSON Response (Default)
  @Get('/json')
  async getJson() {
    return {
      data: { id: 1, name: 'Test' }
    };
  }

  // Empty Response (204 No Content)
  @Post('/empty')
  async empty() {
    return null;  // or undefined
  }

  // Text Response
  @Get('/text')
  async getText() {
    return 'Hello World';  // Content-Type: text/plain
  }

  // HTML Response
  @Get('/html')
  async getHtml() {
    return '<h1>Hello World</h1>';  // Content-Type: text/html
  }

  // Custom Status Code
  @Post('/created')
  async create() {
    return {
      status: 201,
      data: { created: true }
    };
  }

  // Redirect Response
  @Get('/redirect')
  async redirect() {
    return {
      redirect: '/new-location',
      status: 302  // Optional, defaults to 302
    };
  }

  // Error Response
  @Get('/error')
  async error() {
    throw new HttpError(400, 'Bad Request');
  }

  // Custom Error Response
  @Get('/custom-error')
  async customError() {
    throw new HttpError(403, 'Not allowed', 'FORBIDDEN');
  }

  // Structured Error
  @Get('/structured')
  async structured() {
    return {
      status: 'error',
      code: 'VALIDATION_FAILED',
      message: 'Invalid input',
      details: {
        field: 'email',
        reason: 'Invalid format'
      }
    };
  }

  // Stream Response
  @Get('/stream')
  async getStream(c) {
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue('Hello');
        controller.enqueue(' World');
        controller.close();
      }
    });
    return new Response(stream);
  }

  // File Response
  @Get('/file')
  async getFile() {
    return {
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': 'attachment; filename="file.pdf"'
      },
      body: fileContent  // Your file content
    };
  }

  // Conditional Response
  @Get('/conditional')
  async conditional(header) {
    const etag = '"123"';
    if (header('if-none-match') === etag) {
      return {
        status: 304  // Not Modified
      };
    }
    return {
      data: { id: 1 },
      headers: {
        'ETag': etag
      }
    };
  }
}

Response with Headers:

@Get('/with-headers')
async withHeaders() {
  return {
    data: { success: true },
    headers: {
      'X-Custom-Header': 'value',
      'Cache-Control': 'max-age=3600'
    }
  };
}

Response Structure Options:

// Simple data response
return { data: someData };

// Full response control
return {
  status: 200,                    // HTTP status code
  data: someData,                 // Response data
  headers: {                      // Custom headers
    'X-Custom-Header': 'value'
  },
  code: 'SUCCESS',               // Optional response code
  message: 'Operation complete'  // Optional message
};

// Error response
return {
  status: 'error',
  code: 'VALIDATION_ERROR',
  message: 'Invalid input',
  details: { ... }  // Optional error details
};

Body Parsing

The framework automatically parses request bodies based on Content-Type header:

@Controller('/api')
class ApiController {
  // Automatic JSON parsing
  @Post('/json')
  async handleJson(body) {
    // Content-Type: application/json
    // Body is automatically parsed as JSON
    return { received: body };
  }

  // Form data parsing
  @Post('/form')
  async handleForm(body) {
    // Content-Type: multipart/form-data
    // or application/x-www-form-urlencoded
    // Body is automatically parsed as form data
    return { received: body };
  }

  // Text content
  @Post('/text')
  async handleText(body) {
    // Content-Type: text/plain
    // Body is received as string
    return { received: body };
  }

  // Manual parsing if needed
  @Post('/manual')
  async handleManual(json, text, formData) {
    // Choose your parser based on needs
    const jsonData = await json();
    // or
    const textData = await text();
    // or
    const formContent = await formData();
    
    return { parsed: jsonData };
  }

  // File upload handling
  @Post('/upload')
  async handleUpload(formData) {
    const form = await formData();
    const file = form.get('file');
    const name = form.get('name');
    
    return {
      fileName: file.name,
      fileType: file.type,
      name: name
    };
  }
}

Dependency Injection

Hono-plus provides a powerful dependency injection system:

// Define injectable services
@Injectable()
class AuthService {
  validateToken(token) {
    return token.startsWith('Bearer ');
  }
}

@Injectable()
class UserService {
  constructor(private authService: AuthService) {
    // Services can inject other services
  }

  async getUser(id) {
    return { id, name: 'Test' };
  }
}

// Use services in controllers
@Controller('/api')
class ApiController {
  constructor(
    private userService: UserService,
    private authService: AuthService
  ) {}

  @Get('/user/:id')
  async getUser(params) {
    return this.userService.getUser(params.id);
  }
}

// Register everything
const app = new Hono();

Router.init({
  app,
  controllers: [ApiController],
  providers: [
    UserService,
    AuthService,
    // You can also register with options:
    { 
      provide: ConfigService,
      useClass: DevConfigService 
    }
  ]
});

Example with more complex services:

@Injectable()
class LoggerService {
  log(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

@Injectable()
class DatabaseService {
  constructor(private logger: LoggerService) {}

  async query(sql) {
    this.logger.log(`Executing: ${sql}`);
    // Database logic here
  }
}

@Injectable()
class UserRepository {
  constructor(
    private db: DatabaseService,
    private logger: LoggerService
  ) {}

  async findUser(id) {
    this.logger.log(`Finding user: ${id}`);
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

@Controller('/users')
class UserController {
  constructor(
    private users: UserRepository,
    private logger: LoggerService
  ) {}

  @Get('/:id')
  async getUser(params) {
    this.logger.log(`Request for user: ${params.id}`);
    return this.users.findUser(params.id);
  }
}

// Register everything
Router.init({
  app,
  controllers: [UserController],
  providers: [
    UserRepository,
    DatabaseService,
    LoggerService
  ]
});

Testing Made Easy

// Test a guard
test('auth guard', async () => {
  const header = (name) => name === 'authorization' ? 'Bearer token' : null;
  const result = await authGuard(header);
  expect(result).toBe(true);
});

// Test a controller
test('get user', async () => {
  const params = { id: '1' };
  const result = await controller.getUser(params);
  expect(result.data.id).toBe(1);
});

šŸ¤ Contriuting

Contributions are welcome! soon.

šŸ“ License

This project is licensed under the MIT License - see the LICENSE file for details.

šŸ™ Acknowledgments

Special thanks to the Hono team for creating an amazing foundation for this project.

1.1.9

8 months ago

1.1.8

8 months ago

1.1.7

8 months ago

1.1.6

8 months ago

1.1.5

8 months ago

1.1.4

8 months ago

1.1.3

8 months ago

1.1.2

8 months ago

1.1.1

8 months ago

1.1.0

8 months ago

1.0.9

8 months ago

1.0.8

8 months ago

1.0.7

8 months ago

1.0.6

8 months ago

1.0.5

8 months ago

1.0.4

8 months ago

1.0.3

8 months ago

1.0.2

8 months ago

1.0.1

8 months ago

1.0.0

8 months ago