@geekmidas/api
v0.1.0
Published
A comprehensive REST API framework for building type-safe HTTP endpoints with built-in support for AWS Lambda, validation, error handling, and service dependency injection.
Downloads
237
Readme
@geekmidas/api
A comprehensive REST API framework for building type-safe HTTP endpoints with built-in support for AWS Lambda, validation, error handling, and service dependency injection.
Features
- 🔒 Type-safe endpoints: Full TypeScript support with automatic type inference
- ✅ Schema validation: Uses StandardSchema specification (Zod, Valibot, etc.)
- 🚀 Multiple runtime support: AWS Lambda adapters and test adapter included
- 💉 Dependency injection: Built-in service discovery and registration
- 🔐 Authorization: Flexible authorization system with session management
- 📄 OpenAPI generation: Automatic OpenAPI schema generation with reusable components
- 🚨 Error handling: Comprehensive HTTP error classes and handling
- 📊 Structured logging: Built-in logger with context propagation
- 📡 Event publishing: Automatic event publishing after successful endpoint execution
- 🎯 Zero config: Works out of the box with sensible defaults
- 🔍 Advanced query parameters: Support for nested objects and arrays in query strings
- ♾️ Infinite queries: Built-in React Query infinite pagination support
- 🪝 OpenAPI hooks: Generate type-safe hooks from operation IDs
Available Adapters
- AmazonApiGatewayV1Endpoint: For AWS API Gateway v1 (REST API)
- AmazonApiGatewayV2Endpoint: For AWS API Gateway v2 (HTTP API)
- TestEndpointAdaptor: For unit testing endpoints
Installation
npm install @geekmidas/api zod
# or
yarn add @geekmidas/api zod
# or
pnpm add @geekmidas/api zodFor AWS Lambda support:
npm install @geekmidas/api @geekmidas/envkit @types/aws-lambda aws-lambdaQuick Start
Basic Endpoint
import { e } from '@geekmidas/api/server';
import { z } from 'zod';
// Define a simple GET endpoint
const getUser = e
.get('/users/:id')
.params(z.object({ id: z.string().uuid() }))
.output(z.object({
id: z.string(),
name: z.string(),
email: z.string().email()
}))
.handle(async ({ params, logger }) => {
logger.info({ userId: params.id }, 'Fetching user');
// Your logic here
return {
id: params.id,
name: 'John Doe',
email: '[email protected]'
};
});AWS Lambda Handler
import { AmazonApiGatewayV1Endpoint } from '@geekmidas/api/aws-apigateway';
import { EnvironmentParser } from '@geekmidas/envkit';
const envParser = new EnvironmentParser(process.env);
const endpoint = e
.get('/health')
.output(z.object({ status: z.string() }))
.handle(() => ({ status: 'ok' }));
const adapter = new AmazonApiGatewayV1Endpoint(envParser, endpoint);
export const handler = adapter.handler;API Gateway v2 (HTTP API)
For AWS API Gateway v2 (HTTP APIs), use the AmazonApiGatewayV2Endpoint adapter:
import { AmazonApiGatewayV2Endpoint } from '@geekmidas/api/aws-apigateway';
import { EnvironmentParser } from '@geekmidas/envkit';
const envParser = new EnvironmentParser(process.env);
const endpoint = e
.post('/users')
.body(z.object({ name: z.string(), email: z.string().email() }))
.output(z.object({ id: z.string(), created: z.boolean() }))
.handle(async ({ body }) => ({
id: crypto.randomUUID(),
created: true
}));
const adapter = new AmazonApiGatewayV2Endpoint(envParser, endpoint);
export const handler = adapter.handler;The v2 adapter automatically handles the differences in the API Gateway v2 event format, including:
- Different request context structure
- Simplified path and query parameter handling
- HTTP API-specific features
Core Concepts
Endpoint vs Function
This package provides two main constructs:
RestEndpoint (
eexport) - For HTTP REST APIs- Handler receives destructured parameters:
{ params, query, body, headers, services, logger, session } - Designed for web services with HTTP-specific concepts
- Handler receives destructured parameters:
Function - For general-purpose functions
- Handler receives an
inputobject:{ input, services, logger } - More generic construct for non-HTTP use cases
- Handler receives an
Endpoint Builder
The endpoint builder provides a fluent API for defining HTTP endpoints:
import { e } from '@geekmidas/api/server';
import { z } from 'zod';
const endpoint = e
.post('/users') // HTTP method and path
.params(z.object({ orgId: z.string() })) // URL parameters
.query(z.object({ role: z.string().optional() })) // Query parameters
.headers(z.object({ 'x-api-key': z.string() })) // Headers validation
.body(z.object({ // Request body
name: z.string(),
email: z.string().email()
}))
.output(z.object({ // Response schema
id: z.string(),
name: z.string(),
email: z.string()
}))
.handle(async ({ params, query, headers, body, logger }) => {
// Your endpoint logic
return { id: '123', ...body };
});Service Dependency Injection
Define and use services across your endpoints:
import { HermodService } from '@geekmidas/api/server';
// Define a service
class DatabaseService extends HermodService<Database> {
static readonly serviceName = 'Database';
async register() {
const db = new Database(process.env.DATABASE_URL);
await db.connect();
return db;
}
async cleanup(db: Database) {
await db.disconnect();
}
}
// Use in endpoints
const endpoint = e
.services([DatabaseService])
.post('/users')
.body(userSchema)
.handle(async ({ body, services }) => {
const db = services.Database;
const user = await db.users.create(body);
return user;
});Error Handling
Comprehensive error handling with HTTP-specific error classes:
import {
NotFoundError,
BadRequestError,
UnauthorizedError,
createError,
createHttpError
} from '@geekmidas/api/errors';
const endpoint = e
.get('/users/:id')
.handle(async ({ params }) => {
const user = await findUser(params.id);
if (!user) {
throw new NotFoundError('User not found');
}
if (!user.isActive) {
throw createError.forbidden('User account is inactive');
}
// Custom error with additional data
throw createHttpError(422, 'Validation failed', {
validationErrors: {
email: 'Invalid format'
}
});
return user;
});Route Grouping
Organize endpoints with route prefixes:
// Create API version prefix
const api = e.route('/api/v1');
// Group user endpoints
const users = api.route('/users');
const listUsers = users.get('/').handle(/* ... */); // GET /api/v1/users
const getUser = users.get('/:id').handle(/* ... */); // GET /api/v1/users/:id
const createUser = users.post('/').handle(/* ... */); // POST /api/v1/users
const updateUser = users.put('/:id').handle(/* ... */); // PUT /api/v1/users/:id
// Group admin endpoints
const admin = api.route('/admin');
const getStats = admin.get('/stats').handle(/* ... */); // GET /api/v1/admin/statsAuthorization
Implement authorization at the route or endpoint level:
// Route-level authorization
const protectedApi = e.authorize(async ({ req, logger }) => {
const token = req.headers.get('authorization');
if (!token) {
return false; // Unauthorized
}
try {
const user = await verifyToken(token);
return { userId: user.id }; // Pass data to endpoints
} catch {
return false;
}
});
// All endpoints under protectedApi require authorization
const endpoint = protectedApi
.get('/profile')
.handle(({ auth }) => {
// auth contains { userId: string }
return { userId: auth.userId };
});
// Endpoint-specific authorization
const adminEndpoint = e
.get('/admin/users')
.authorize(async ({ req }) => {
const user = await getUser(req);
return user.role === 'admin';
})
.handle(() => {
// Only admins can access
});Session Management
Add session data to all endpoints:
interface SessionData {
userId: string;
organizationId: string;
}
const api = e.session<SessionData>(async ({ req }) => {
const token = req.headers.get('authorization');
if (!token) {
return null;
}
return await decodeSessionToken(token);
});
const endpoint = api
.get('/my-organization')
.handle(({ session }) => {
// session is SessionData | null
if (!session) {
throw new UnauthorizedError();
}
return { orgId: session.organizationId };
});Advanced Query Parameters
The framework supports complex query parameter structures including nested objects and arrays:
const endpoint = e
.get('/users')
.query(z.object({
// Simple parameters
page: z.number().optional(),
limit: z.number().optional(),
// Arrays
ids: z.array(z.string()).optional(),
// Nested objects using dot notation
'filter.status': z.enum(['active', 'inactive']).optional(),
'filter.role': z.string().optional(),
// Nested arrays
'user.roles': z.array(z.string()).optional()
}))
.handle(async ({ query }) => {
// Query: ?ids=1&ids=2&filter.status=active&user.roles=admin&user.roles=moderator
// Parsed as:
// {
// ids: ['1', '2'],
// filter: { status: 'active' },
// user: { roles: ['admin', 'moderator'] }
// }
return queryUsers(query);
});Logging
Built-in structured logging with context:
const endpoint = e
.get('/users/:id')
.handle(async ({ params, logger }) => {
// Logger includes request context
logger.info({ userId: params.id }, 'Fetching user');
try {
const user = await getUser(params.id);
logger.debug({ user }, 'User found');
return user;
} catch (error) {
logger.error({ error, userId: params.id }, 'Failed to fetch user');
throw error;
}
});Advanced Usage
Custom Services
Create reusable services for your endpoints:
// Cache service
class CacheService extends HermodService<Redis> {
static readonly serviceName = 'Cache';
async register() {
const redis = new Redis(process.env.REDIS_URL);
return redis;
}
}
// Email service
class EmailService extends HermodService<EmailClient> {
static readonly serviceName = 'Email';
async register() {
return new EmailClient({
apiKey: process.env.SENDGRID_API_KEY
});
}
}
// Use multiple services
const endpoint = e
.services([DatabaseService, CacheService, EmailService])
.post('/users')
.handle(async ({ input, services }) => {
const { Database, Cache, Email } = services;
// Check cache first
const cached = await Cache.get(`user:${input.body.email}`);
if (cached) return cached;
// Create user
const user = await Database.users.create(input.body);
// Cache result
await Cache.set(`user:${user.email}`, user, 3600);
// Send welcome email
await Email.send({
to: user.email,
subject: 'Welcome!',
body: 'Thanks for signing up!'
});
return user;
});OpenAPI Generation
Generate OpenAPI schemas from your endpoints:
Basic OpenAPI Generation
const endpoint = e
.get('/users/:id')
.params(z.object({ id: z.string().uuid() }))
.query(z.object({
include: z.enum(['profile', 'posts']).optional()
}))
.output(z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
profile: z.object({
bio: z.string(),
avatar: z.string().url()
}).optional()
}))
.openapi({
summary: 'Get user by ID',
description: 'Retrieves a user by their unique identifier',
tags: ['Users'],
security: [{ bearerAuth: [] }]
})
.handle(async ({ params, query }) => {
// Implementation
});
// Generate OpenAPI document
const openApiDoc = generateOpenApiDocument([endpoint]);OpenAPI Components (Reusable Schemas)
You can extract schemas to the OpenAPI components section for reuse:
import { z } from 'zod';
// Mark schema for extraction to components
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email()
}).describe('User').openapi('User');
const ProfileSchema = z.object({
bio: z.string(),
avatar: z.string().url()
}).describe('UserProfile').openapi('UserProfile');
// Use in endpoints - will generate $ref in OpenAPI
const endpoint = e
.get('/users/:id')
.output(UserSchema.extend({
profile: ProfileSchema.optional()
}))
.handle(async ({ params }) => {
// Implementation
});
// Generated OpenAPI will include:
// components:
// schemas:
// User:
// type: object
// properties: ...
// UserProfile:
// type: object
// properties: ...Middleware Pattern
While the framework doesn't have traditional middleware, you can compose functionality:
// Create a base endpoint with common functionality
const authenticatedApi = e
.session(getSession)
.authorize(requireAuth)
.services([DatabaseService, LoggerService]);
// Build on top of the base
const userEndpoints = authenticatedApi.route('/users');
const getProfile = userEndpoints
.get('/profile')
.handle(({ session, services }) => {
// Has session, auth, and services from base
});Testing
The framework provides a dedicated test adapter for unit testing endpoints:
import { TestEndpointAdaptor } from '@geekmidas/api/testing';
import { EnvironmentParser } from '@geekmidas/envkit';
describe('User endpoint', () => {
const envParser = new EnvironmentParser({});
it('should return user by ID', async () => {
const endpoint = e
.get('/users/:id')
.params(z.object({ id: z.string() }))
.output(userSchema)
.handle(async ({ params }) => {
return { id: params.id, name: 'Test User' };
});
const adapter = new TestEndpointAdaptor(envParser, endpoint);
const response = await adapter.request({
method: 'GET',
url: '/users/123'
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
id: '123',
name: 'Test User'
});
});
it('should handle POST requests with body', async () => {
const createEndpoint = e
.post('/users')
.body(z.object({ name: z.string(), email: z.string().email() }))
.output(z.object({ id: z.string(), name: z.string() }))
.handle(async ({ body }) => ({
id: '123',
name: body.name
}));
const adapter = new TestEndpointAdaptor(envParser, createEndpoint);
const response = await adapter.request({
method: 'POST',
url: '/users',
body: { name: 'John Doe', email: '[email protected]' }
});
expect(response.status).toBe(200);
expect(response.body.name).toBe('John Doe');
});
});Typed API Client
A fully type-safe API client for TypeScript that uses OpenAPI specifications to provide automatic type inference for requests and responses.
Features
- 🚀 Full TypeScript support with automatic type inference
- 🔒 Type-safe request parameters (path, query, body)
- 📦 Built-in React Query integration
- 🛡️ Request/response interceptors
- 🔄 Automatic OpenAPI types generation
- 💪 Zero runtime overhead - all types are compile-time only
Installation
npm install @geekmidas/api
# or
pnpm add @geekmidas/apiQuick Start
1. Generate Types from OpenAPI Spec
First, generate TypeScript types from your OpenAPI specification using openapi-typescript:
# Install openapi-typescript
npm install -D openapi-typescript
# Generate types from URL
npx openapi-typescript https://api.example.com/openapi.json -o ./src/openapi-types.d.ts
# Or generate types from local file
npx openapi-typescript ./openapi.yaml -o ./src/openapi-types.d.tsThis will create a file with your API types that looks like:
export interface paths {
"/users": {
get: {
responses: {
200: {
content: {
"application/json": User[];
};
};
};
};
post: {
requestBody: {
content: {
"application/json": {
name: string;
email: string;
};
};
};
responses: {
201: {
content: {
"application/json": User;
};
};
};
};
};
// ... more endpoints
}2. Create a Typed Fetcher
import { createTypedFetcher } from '@geekmidas/api/client';
import type { paths } from './openapi-types';
const client = createTypedFetcher<paths>({
baseURL: 'https://api.example.com',
headers: {
'Authorization': 'Bearer your-token',
},
});
// TypeScript automatically infers the response type!
const user = await client('GET /users/{id}', {
params: { id: '123' },
});
console.log(user.name); // TypeScript knows this is a string3. Use with React Query
import { createTypedQueryClient } from '@geekmidas/api/client';
import type { paths } from './openapi-types';
const queryClient = createTypedQueryClient<paths>({
baseURL: 'https://api.example.com',
});
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = queryClient.useQuery(
'GET /users/{id}',
{ params: { id: userId } }
);
if (isLoading) return <div>Loading...</div>;
// TypeScript knows user has properties: id, name, email
return <div>{user?.name}</div>;
}
// Query Invalidation
async function refreshUser(userId: string) {
// Invalidate specific user query
await queryClient.invalidateQueries('GET /users/{id}', {
params: { id: userId }
});
// Invalidate all user queries
await queryClient.invalidateQueries('GET /users');
// Invalidate all queries
await queryClient.invalidateAllQueries();
}
// Mutations
function CreateUser() {
const { mutate: createUser } = queryClient.useMutation(
'POST /users',
{
onSuccess: (data) => {
console.log('User created:', data);
}
}
);
const handleSubmit = () => {
createUser({
body: { name: 'New User', email: '[email protected]' }
});
};
return <button onClick={handleSubmit}>Create User</button>;
}API Reference
createTypedFetcher<Paths>(options)
Creates a typed fetcher instance.
Type Parameters
Paths: Your OpenAPI paths type (generated from your OpenAPI spec)
Options
baseURL: Base URL for all requestsheaders: Default headers to include with every requestonRequest: Request interceptoronResponse: Response interceptoronError: Error handler
createTypedQueryClient<Paths>(options)
Creates a typed React Query client.
Type Parameters
Paths: Your OpenAPI paths type (generated from your OpenAPI spec)
Options
Extends FetcherOptions
Request Configuration
The second parameter accepts a configuration object with the following properties (only available properties based on the endpoint will be accepted):
params: Path parameters (e.g.,{id}in/users/{id})query: Query parametersbody: Request body (for POST, PUT, PATCH requests)headers: Additional headers for this request
Advanced Usage
Interceptors
import type { paths } from './your-openapi-types';
const client = createTypedFetcher<paths>({
baseURL: 'https://api.example.com',
onRequest: async (config) => {
// Modify request before sending
config.headers['X-Request-ID'] = generateRequestId();
return config;
},
onResponse: async (response) => {
// Process response
if (response.headers.get('X-Refresh-Token')) {
await refreshAuth();
}
return response;
},
onError: async (error) => {
// Handle errors globally
if (error.response?.status === 401) {
window.location.href = '/login';
}
},
});Infinite Queries (Pagination)
The client supports React Query's infinite queries for pagination:
function PostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = queryClient.useInfiniteQuery(
'GET /posts',
{
query: { limit: 20 }, // Will be merged with pageParam
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: undefined,
}
);
return (
<div>
{data?.pages.map((page) =>
page.items.map((post) => <PostCard key={post.id} post={post} />)
)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}OpenAPI Hooks (Operation ID-based)
Generate hooks using OpenAPI operation IDs for better organization:
import { createOpenAPIHooks } from '@geekmidas/api/client';
import type { paths } from './openapi-types';
// Create hooks based on operation IDs
const api = createOpenAPIHooks<paths>({
baseURL: 'https://api.example.com',
});
// Use hooks with operation IDs
function UserProfile() {
// Assumes your OpenAPI spec has operationId: "getUser"
const { data: user } = api.useGetUser({
params: { id: '123' }
});
// Assumes operationId: "updateUser"
const { mutate: updateUser } = api.useUpdateUser();
return <div>{user?.name}</div>;
}Type-Safe Error Handling
try {
const user = await client('GET /users/{id}', {
params: { id: userId },
});
// Handle success
} catch (error) {
if (error.response?.status === 404) {
// User not found
}
}How It Works
- OpenAPI Types: The
openapi-typescripttool generates TypeScript interfaces from your OpenAPI spec - Type Magic: Our client uses TypeScript's template literal types and conditional types to:
- Parse the endpoint string (e.g.,
'GET /users/{id}') - Extract the HTTP method and path
- Look up the corresponding types from the OpenAPI definitions
- Infer request parameters and response types
- Provide VS Code autocomplete for all valid endpoints
- Parse the endpoint string (e.g.,
- Runtime Fetching: At runtime, the client constructs and executes the HTTP request
VS Code Autocomplete
When you type endpoint strings, you get full autocomplete showing all available endpoints:
// Start typing: client('
// VS Code shows:
// ✓ 'GET /users'
// ✓ 'POST /users'
// ✓ 'GET /users/{id}'
// ✓ 'PUT /users/{id}'
// ✓ 'DELETE /users/{id}'
// ✓ 'GET /posts'
const user = await client('GET /users/{id}', {
params: { id: '123' } // ← TypeScript enforces required params
});Best Practices
- Keep OpenAPI Spec Updated: Regenerate types whenever your API changes
npx openapi-typescript https://api.example.com/openapi.json -o ./src/openapi-types.d.ts - Use Specific Endpoints: Let TypeScript autocomplete guide you to valid endpoints
- Handle Errors: Always handle potential errors, especially for mutations
- Cache Wisely: Configure React Query's
staleTimeandcacheTimeappropriately - Commit Generated Types: Include the generated types file in your repository for team consistency
TypeScript Support
This library requires TypeScript 4.5+ for full template literal type support.
License
MIT
Error Reference
The framework provides a comprehensive set of HTTP error classes:
| Error Class | Status Code | Usage |
|------------|-------------|--------|
| BadRequestError | 400 | Invalid request data |
| UnauthorizedError | 401 | Missing or invalid authentication |
| PaymentRequiredError | 402 | Payment required |
| ForbiddenError | 403 | Authenticated but not authorized |
| NotFoundError | 404 | Resource not found |
| MethodNotAllowedError | 405 | HTTP method not allowed |
| ConflictError | 409 | Resource conflict |
| UnprocessableEntityError | 422 | Validation errors |
| TooManyRequestsError | 429 | Rate limit exceeded |
| InternalServerError | 500 | Server errors |
| BadGatewayError | 502 | Gateway errors |
| ServiceUnavailableError | 503 | Service unavailable |
Documentation
Event Publishing
The framework includes powerful event publishing capabilities for building event-driven architectures:
- Events Quick Start - Get started with event publishing in 5 minutes
- Complete Events Guide - Comprehensive documentation with examples and best practices
const createUserEndpoint = e
.publisher(eventPublisher)
.post('/users')
.body(userSchema)
.event({
type: 'user.created',
payload: (response) => ({ userId: response.id, email: response.email }),
})
.handle(async ({ body }) => {
const user = await createUser(body);
return user; // Event published automatically after success
});Best Practices
- Use structured services: Keep endpoint handlers thin by moving logic to services
- Validate everything: Use schemas for params, query, headers, body, and output
- Handle errors gracefully: Use appropriate HTTP error classes
- Log strategically: Use structured logging with context
- Group related endpoints: Use route prefixes to organize your API
- Document with OpenAPI: Add OpenAPI metadata to your endpoints
- Test thoroughly: Use the testing utilities to test endpoints in isolation
- Publish events wisely: Use events for cross-service communication and audit trails
License
MIT
