GameCraftGameCraft

OpenAPI REST API

External REST API with Hono and automatic OpenAPI documentation

OpenAPI REST API

ProductReady includes a REST API built with Hono and automatic OpenAPI documentation. Perfect for:

  • External integrations (mobile apps, third-party services)
  • Public API for your SaaS
  • Webhooks and callbacks
  • Non-TypeScript clients

tRPC vs REST: Use tRPC for your Next.js frontend (type-safe, faster), use REST API for external clients.


API Documentation Options

There are three ways to access API documentation:

1. Swagger UI (/api/v1/doc)

The classic interactive API documentation. Test API calls directly from the browser.

http://localhost:3000/api/v1/doc

📖 Open Swagger UI →

2. OpenAPI JSON (/api/v1/openapi.json)

Raw OpenAPI 3.0 specification. Use this for:

  • Generating SDKs with tools like openapi-generator
  • Importing into Postman, Insomnia, or other API clients
  • CI/CD validation and contract testing
http://localhost:3000/api/v1/openapi.json

📄 View OpenAPI JSON →

3. API Reference (Fumadocs Integration)

Beautiful, searchable API documentation integrated into the docs site using Fumadocs OpenAPI.

📚 Browse API Reference →

The API Reference is auto-generated from your OpenAPI schema. Run pnpm openapi:generate to update it.


Quick Start

2. Make Your First Request

# Health check
curl http://localhost:3000/api/v1/health

# Response
{
  "status": "ok",
  "timestamp": "2025-11-17T00:00:00.000Z",
  "version": "1.0.0"
}

Project Structure

src/
└── app/
    └── api/
        └── v1/
            ├── [[...server]]/
            │   ├── route.ts       # Next.js handler (delegates to Hono)
            │   └── app.ts         # Hono app definition
            └── routes/
                ├── index.ts       # Route registration
                ├── health.ts      # Health check endpoint
                ├── tasks.ts       # Tasks CRUD
                └── auth.ts        # Authentication endpoints

Creating Endpoints

Basic Endpoint

Create src/app/api/v1/routes/hello.ts:

import { createRoute, z } from '@hono/zod-openapi';
import { app } from '../[[...server]]/app';

// Define OpenAPI schema
const route = createRoute({
  method: 'get',
  path: '/hello',
  summary: 'Say hello',
  tags: ['General'],
  responses: {
    200: {
      description: 'Success',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
            timestamp: z.string(),
          }),
        },
      },
    },
  },
});

// Implement handler
app.openapi(route, (c) => {
  return c.json({
    message: 'Hello from ProductReady!',
    timestamp: new Date().toISOString(),
  });
});

Register in src/app/api/v1/routes/index.ts:

import './hello';  // ← Add this

Now visit: http://localhost:3000/api/v1/hello 🎉


Endpoint with Parameters

import { createRoute, z } from '@hono/zod-openapi';

// URL: /api/v1/users/:id
const getUserRoute = createRoute({
  method: 'get',
  path: '/users/{id}',
  summary: 'Get user by ID',
  tags: ['Users'],
  request: {
    params: z.object({
      id: z.string().uuid().openapi({
        param: { name: 'id', in: 'path' },
        example: '123e4567-e89b-12d3-a456-426614174000',
      }),
    }),
  },
  responses: {
    200: {
      description: 'User found',
      content: {
        'application/json': {
          schema: z.object({
            id: z.string().uuid(),
            name: z.string(),
            email: z.string().email(),
          }),
        },
      },
    },
    404: {
      description: 'User not found',
      content: {
        'application/json': {
          schema: z.object({
            error: z.string(),
          }),
        },
      },
    },
  },
});

app.openapi(getUserRoute, async (c) => {
  const { id } = c.req.valid('param');
  
  const user = await db.select().from(users).where(eq(users.id, id)).limit(1);
  
  if (!user[0]) {
    return c.json({ error: 'User not found' }, 404);
  }
  
  return c.json(user[0]);
});

POST Endpoint with Body

const createUserRoute = createRoute({
  method: 'post',
  path: '/users',
  summary: 'Create new user',
  tags: ['Users'],
  request: {
    body: {
      content: {
        'application/json': {
          schema: z.object({
            name: z.string().min(1).max(100),
            email: z.string().email(),
            password: z.string().min(8),
          }),
        },
      },
    },
  },
  responses: {
    201: {
      description: 'User created',
      content: {
        'application/json': {
          schema: z.object({
            id: z.string().uuid(),
            name: z.string(),
            email: z.string(),
            createdAt: z.string(),
          }),
        },
      },
    },
    400: {
      description: 'Invalid input',
      content: {
        'application/json': {
          schema: z.object({
            error: z.string(),
          }),
        },
      },
    },
  },
});

app.openapi(createUserRoute, async (c) => {
  const body = c.req.valid('json');
  
  // Hash password
  const hashedPassword = await hash(body.password);
  
  // Insert user
  const [user] = await db
    .insert(users)
    .values({
      name: body.name,
      email: body.email,
      password: hashedPassword,
    })
    .returning();
  
  // Don't return password!
  const { password, ...safeUser } = user;
  
  return c.json(safeUser, 201);
});

Authentication

API Key Auth

Add authentication middleware:

import { createMiddleware } from 'hono/factory';

const apiKeyAuth = createMiddleware(async (c, next) => {
  const apiKey = c.req.header('X-API-Key');
  
  if (!apiKey) {
    return c.json({ error: 'Missing API key' }, 401);
  }
  
  // Verify API key
  const valid = await verifyApiKey(apiKey);
  if (!valid) {
    return c.json({ error: 'Invalid API key' }, 401);
  }
  
  await next();
});

// Use in routes
app.use('/api/v1/protected/*', apiKeyAuth);

Bearer Token Auth

const bearerAuth = createMiddleware(async (c, next) => {
  const authorization = c.req.header('Authorization');
  
  if (!authorization?.startsWith('Bearer ')) {
    return c.json({ error: 'Missing bearer token' }, 401);
  }
  
  const token = authorization.replace('Bearer ', '');
  
  try {
    const session = await verifyToken(token);
    c.set('session', session);
    await next();
  } catch (err) {
    return c.json({ error: 'Invalid token' }, 401);
  }
});

OAuth2 (Advanced)

For OAuth2 flows, use Better Auth's OAuth endpoints:

// src/app/api/v1/routes/auth.ts
import { auth } from '~/lib/auth';

app.get('/auth/login', async (c) => {
  // Redirect to OAuth provider
  return c.redirect(
    `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}`
  );
});

app.get('/auth/callback', async (c) => {
  const code = c.req.query('code');
  
  // Exchange code for token
  const session = await auth.api.signIn.social({
    provider: 'github',
    code,
  });
  
  return c.json({ token: session.token });
});

Validation

Hono uses Zod for validation. All schemas are automatically documented in OpenAPI!

const schema = z.object({
  email: z.string().email('Invalid email format'),
  age: z.number().min(18, 'Must be 18+').max(120),
  role: z.enum(['user', 'admin']).default('user'),
  metadata: z.record(z.string()).optional(),
});

app.openapi(route, async (c) => {
  const data = c.req.valid('json');  // ← Already validated!
  
  // data is typed and validated
  console.log(data.email);  // ✅ string
  console.log(data.role);   // ✅ 'user' | 'admin'
});

Error Handling

Standard Error Responses

// 400 Bad Request
return c.json({ error: 'Invalid input', details: validationErrors }, 400);

// 401 Unauthorized
return c.json({ error: 'Authentication required' }, 401);

// 403 Forbidden
return c.json({ error: 'Insufficient permissions' }, 403);

// 404 Not Found
return c.json({ error: 'Resource not found' }, 404);

// 500 Internal Server Error
return c.json({ error: 'Internal server error' }, 500);

Global Error Handler

// src/app/api/v1/[[...server]]/app.ts
app.onError((err, c) => {
  console.error('API Error:', err);
  
  if (err instanceof ZodError) {
    return c.json({
      error: 'Validation failed',
      details: err.errors,
    }, 400);
  }
  
  return c.json({
    error: 'Internal server error',
    message: process.env.NODE_ENV === 'development' ? err.message : undefined,
  }, 500);
});

CORS

Enable CORS for external clients:

import { cors } from 'hono/cors';

app.use('/api/v1/*', cors({
  origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}));

Development (allow all):

app.use('/api/v1/*', cors({
  origin: '*',  // ⚠️ Only for development!
}));

Rate Limiting

Protect your API from abuse:

import { rateLimiter } from 'hono-rate-limiter';

app.use('/api/v1/*', rateLimiter({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                   // 100 requests per window
  message: 'Too many requests, please try again later',
}));

Per-route rate limiting:

const strictLimiter = rateLimiter({
  windowMs: 60 * 1000,  // 1 minute
  max: 5,               // 5 requests per minute
});

app.post('/api/v1/auth/login', strictLimiter, async (c) => {
  // Login logic
});

Versioning

ProductReady uses URL versioning (/api/v1/, /api/v2/, etc.):

/api/v1/users    ← Current version
/api/v2/users    ← New version with breaking changes

Create new version:

# Copy v1 to v2
cp -r src/app/api/v1 src/app/api/v2

# Update paths in v2
# /api/v1/* → /api/v2/*

Testing

Test API endpoints with supertest:

import { testClient } from 'hono/testing';
import app from '~/app/api/v1/[[...server]]/app';

test('GET /hello returns greeting', async () => {
  const client = testClient(app);
  const res = await client.hello.$get();
  
  expect(res.status).toBe(200);
  
  const data = await res.json();
  expect(data.message).toBe('Hello from ProductReady!');
});

Integration tests:

test('POST /users creates user', async () => {
  const client = testClient(app);
  const res = await client.users.$post({
    json: {
      name: 'Test User',
      email: 'test@example.com',
      password: 'password123',
    },
  });
  
  expect(res.status).toBe(201);
  
  const user = await res.json();
  expect(user.email).toBe('test@example.com');
  expect(user.password).toBeUndefined();  // Should not return password!
});

OpenAPI Spec

Customize Documentation

// src/app/api/v1/[[...server]]/app.ts
import { OpenAPIHono } from '@hono/zod-openapi';

const app = new OpenAPIHono();

app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    version: '1.0.0',
    title: 'ProductReady API',
    description: 'RESTful API for ProductReady',
    contact: {
      name: 'API Support',
      email: 'support@productready.com',
    },
    license: {
      name: 'MIT',
      url: 'https://opensource.org/licenses/MIT',
    },
  },
  servers: [
    {
      url: 'http://localhost:3000',
      description: 'Development server',
    },
    {
      url: 'https://api.yourdomain.com',
      description: 'Production server',
    },
  ],
});

Export OpenAPI JSON

Get the raw OpenAPI spec:

# Visit in browser
http://localhost:3000/api/v1/doc

# Or via curl
curl http://localhost:3000/api/v1/doc > openapi.json

Use with code generators:

# Generate TypeScript client
npx openapi-typescript openapi.json -o schema.d.ts

# Generate Python client
pip install openapi-generator-cli
openapi-generator-cli generate -i openapi.json -g python

Best Practices

✅ Do

  • Version your API - Use /api/v1/ prefix
  • Validate all inputs - Use Zod schemas
  • Return consistent errors - Standard error format
  • Document everything - OpenAPI makes it easy
  • Use proper HTTP methods - GET, POST, PUT, DELETE
  • Return proper status codes - 200, 201, 400, 401, 404, 500
// ✅ Good
app.openapi(createRoute({
  method: 'post',
  path: '/users',
  request: { body: { schema: UserSchema } },
  responses: {
    201: { description: 'Created', schema: UserResponse },
    400: { description: 'Invalid input', schema: ErrorSchema },
  },
}), async (c) => {
  const data = c.req.valid('json');
  const user = await createUser(data);
  return c.json(user, 201);
});

❌ Don't

  • Don't skip validation - Always validate inputs
  • Don't expose internal errors - Generic error messages in production
  • Don't return sensitive data - Strip passwords, tokens, etc.
  • Don't ignore security - Use auth, rate limiting, CORS
  • Don't make breaking changes - Version your API instead
// ❌ Bad
app.post('/users', async (c) => {
  const body = await c.req.json();  // ← No validation!
  const user = await db.insert(users).values(body);  // ← Unsafe!
  return c.json(user);  // ← Might return password!
});

Troubleshooting

"Route not found" in Swagger

Issue: New route doesn't appear in /api/v1/doc

Fix: Import route in routes/index.ts:

import './my-new-route';

CORS errors

Issue: Browser blocks requests from frontend

Fix: Enable CORS in app.ts:

import { cors } from 'hono/cors';
app.use('/api/v1/*', cors({ origin: '*' }));

Validation errors unclear

Issue: Zod error messages confusing

Fix: Customize error messages:

z.string().min(8, 'Password must be at least 8 characters')

Next Steps


Resources

On this page