@bamboo-studio/ground
v0.1.1
Published
A swagger generation tool for bamboo atlas api scaffold
Readme
Ground
Automatic OpenAPI 3.0.3 documentation generator for TypeScript backends. Scans route definitions and controller decorators to produce a complete Swagger spec — no manual YAML needed.
How it works
Route file (AST scan)
→ routes with paths, methods, schema names
→ Controller instances (dynamic import + reflect-metadata)
→ Schema classes (dynamic import + class-validator)
→ OpenAPI 3.0.3 JSON- Route scanning —
scanRoutesFromSource()parses a TypeScript route class withts-morphand extracts paths, HTTP methods, and schema class names fromvalidationMiddleware()calls. - Controller enrichment —
enrichRoutesWithControllerMetadata()dynamically imports and instantiates controller classes, reads decorator metadata viareflect-metadata, and optionally resolves schema class names to JSON Schema objects. - Schema resolution —
scanSchemas()maps schema class names to file paths.classValidatorToObject()convertsclass-validator-decorated classes to JSON Schema. Passing aSchemaRegistry(seecreateSchemaRegistry()) deduplicates each schema intocomponents/schemasand references it with$refinstead of inlining a fresh copy at every usage site. - OpenAPI generation —
routesToOpenAPI()assembles the full OpenAPI 3.0.3 document, emitting the sharedcomponents/schemaswhen a registry is supplied.
Installation
From npm
npm i @bamboo-studio/groundLocal (monorepo / file: reference)
# In the ground project, build first:
npm run build
# In your consumer project:
npm install file:../path/to/groundYour tsconfig.json must enable decorator metadata:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}CLI Usage
The easiest way to use Ground is the CLI. After installing and decorating your controllers and schemas, run:
npx ground generateThis will scan your routes, enrich them with decorator metadata, and write swagger.json to the project root.
Verbose output
Pass -v / --verbose to print every detected route with its controller, tags, auth, body / query / params schemas (including resolved property names) and responses (status code, content type, body shape, description):
npx ground generate -vExample output:
[ground] Scanning routes in /app/src/routes
[ground] src/routes/user.route.ts → 4 route(s)
[ground] Found 4 route(s) in 1 file(s)
[ground] Enriching with controller metadata...
[ground] Routes
GET /users
controller UserController.getUsers
tags users
auth bearer
query UserListRequest → { firstName, email, page }
responses
200 application/json { results, total }
404 application/json { code, message } "Not found"
POST /users
controller UserController.createUser
body CreateUserRequest → { firstName*, email*, age }
responses
201 application/json { id, firstName, email }
400 application/json { code, message } "Bad request"
DELETE /users/:id
controller UserController.deleteUser
params { id }
responses
204 (no body) "No Content"
GET /users/:id/report.pdf
controller UserController.getReport
params { id }
responses
200 application/pdf string "PDF report"
[ground] ✓ Generated /app/swagger.json (4 path(s))Colors and required-field markers (* next to required properties) are emitted when the output is a TTY.
Config file
Create ground.config.ts in your project root to customise paths and metadata:
import type { GroundConfig } from 'ground';
export default {
routesPath: 'src/routes', // default
controllersPath: 'src/controllers', // default
schemasPath: 'src/schemas', // default
output: 'swagger.json', // default
title: 'My API',
version: '1.0.0',
tags: [
{ name: 'Users', description: 'User management' },
{ name: 'Auth', description: 'Authentication' },
],
basePath: '/api', // prefix every path (matches app.use('/api', ...))
servers: [{ url: '/api' }], // OpenAPI servers (Swagger UI base)
} satisfies GroundConfig;All fields are optional — defaults are shown above.
basePath vs servers
Both let you account for a mount prefix (e.g. app.use('/api', ...)), but they work differently:
basePathis prepended to every path key in the spec, so/usersbecomes/api/users. Use this when you want the spec paths to match the real endpoints — generated clients and "Try it out" then hit the right URL. A trailing slash is normalised away, so no double slashes are produced.serversemits an OpenAPIserverslist as-is, without touching path keys. Honored by Swagger UI as the request base URL. Note that some client generators (e.g. Orval) ignoreservers, which is whybasePathexists.
TypeScript runtime
Ground loads your TypeScript files (config and schema classes) at runtime, so the consumer project needs one of these as a devDependency:
| Runtime | Install | Works with |
| --- | --- | --- |
| ts-node | npm install --save-dev ts-node | TypeScript up to 6 |
| @swc/core | npm install --save-dev @swc/core | any TypeScript version, including 7 |
TypeScript 7 is a native compiler and no longer exposes the JavaScript compiler API that ts-node (and @swc-node/register) depend on. The @swc/core runtime is a small require hook built into Ground that calls swc directly, so it does not need the typescript package at all. It always enables legacy decorators and decorator metadata, which class-validator needs.
By default (auto) Ground uses ts-node when it can run, so existing projects behave exactly as before, and falls back to @swc/core otherwise, for example on TypeScript 7. To force one, pass --runtime or set GROUND_RUNTIME:
npx ground generate --runtime=swc
GROUND_RUNTIME=ts-node npx ground generateAn explicit runtime that cannot run is an error: Ground never silently swaps it for the other one. tsconfig-paths is still used for path aliases, whichever runtime is active.
Quick Start
import fs from 'fs';
import { scanRoutesFromSource, enrichRoutesWithControllerMetadata, routesToOpenAPI, createSchemaRegistry } from 'ground';
// 1. Read your route file source
const routeCode = fs.readFileSync('./src/routes/user.route.ts', 'utf-8');
// 2. Extract routes via AST scanning
const routes = scanRoutesFromSource(routeCode);
// 3. Enrich with decorator metadata and resolve schema classes.
// Pass a registry to deduplicate schemas into components/schemas via $ref.
const registry = createSchemaRegistry();
const enrichedRoutes = enrichRoutesWithControllerMetadata(
'./src/controllers', // directory containing controller classes
routes,
'./src/schemas', // optional: directory containing schema classes
registry, // optional: omit to inline every schema instead
);
// 4. Generate OpenAPI spec (pass registry.schemas to emit components/schemas)
const openApi = routesToOpenAPI(
enrichedRoutes,
'My API',
'1.0.0',
[{ name: 'Users', description: 'User management' }],
{ basePath: '/api', servers: [{ url: '/api' }], schemas: registry.schemas }, // optional
);
fs.writeFileSync('openapi.json', JSON.stringify(openApi, null, 2));The CLI (
ground generate) wires the registry up automatically — request/response bodies become$refs intocomponents/schemasand each schema is emitted once. When calling the API directly, theregistryargument is optional: omit it to keep the legacy fully-inlined output.
Route file conventions
Ground expects route classes structured like this:
import { UserController } from '../controllers/user.controller';
import { UserListRequest, CreateUserRequest } from '../schemas/user.schema';
const validationMiddleware = (schema: any, pos: string) => {};
class UserRoute {
public path = '/users';
public router = new Router();
private userController = new UserController();
private initializeRoutes() {
// GET with query schema
this.router.get(`${this.path}`, validationMiddleware(UserListRequest, 'query'), this.userController.getUsers);
// POST with body schema
this.router.post(`${this.path}`, validationMiddleware(CreateUserRequest, 'body'), this.userController.createUser);
// GET with path params
this.router.get(`${this.path}/:id`, validationMiddleware(UserIdRequest, 'params'), this.userController.getUserById);
// @ground-ignore
this.router.get(`${this.path}/internal-only`, this.userController.internalRoute);
}
}Path parameters
Write path parameters in the usual Express style (:id). Ground converts them to the OpenAPI {id} form in the generated spec, so /users/:id becomes /users/{id} — tools like Orval then resolve them correctly. Multiple params per path are supported (/users/:userId/orders/:orderId → /users/{userId}/orders/{orderId}).
// @ground-ignore
Place a // @ground-ignore comment on the line immediately before a route definition to exclude it from the generated spec. No import required — it is a plain comment parsed by the AST scanner.
private initializeRoutes() {
// Included in the spec:
this.router.get(`${this.path}`, this.userController.getUsers);
// @ground-ignore
this.router.post(`${this.path}/internal`, this.userController.internalAction);
// Also included:
this.router.delete(`${this.path}/:id`, this.userController.deleteUser);
}Decorators
Apply decorators to controller methods to enrich the generated documentation.
@Summary(text: string)
Sets the operation summary (short title).
@Summary('Get list of users')
public getUsers = async (req, res, next) => { ... };@Description(text: string)
Sets the operation description (longer explanation).
@Description('Returns a paginated list of users filtered by optional query parameters.')
public getUsers = async (req, res, next) => { ... };@SwaggerResponse(statusCode: number, SchemaClass?: Function, description?: string)
Declares a response. Can be applied multiple times to document multiple status codes.
@SwaggerResponse(200, UserListResponse)
public getUsers = async (req, res, next) => { ... };For body-less responses (e.g. 204 No Content) omit the schema. The generated operation will not include a content field.
@SwaggerResponse(204)
public deleteUser = async (req, res, next) => { ... };
// With description
@SwaggerResponse(204, 'No Content')
public deleteUser = async (req, res, next) => { ... };Non-JSON responses (PDF, CSV, binary, …)
Use the options form to declare a non-JSON content type:
// Binary (PDF, octet-stream, image, …) — default schema: { type: 'string', format: 'binary' }
@SwaggerResponse(200, { contentType: 'application/pdf', description: 'PDF report' })
public exportPdf = async (req, res, next) => { ... };
// Text content type (text/csv, text/plain, …) — default schema: { type: 'string' }
@SwaggerResponse(200, { contentType: 'text/csv' })
public exportCsv = async (req, res, next) => { ... };
// Explicit schema with custom contentType
@SwaggerResponse(200, { schema: ReportEnvelope, contentType: 'application/json' })
public getReport = async (req, res, next) => { ... };Multiple content types per status code
The array form accepts a contentType field. Stacking multiple entries on the same code merges them under one response:
@SwaggerResponse([
{ code: 200, schema: ReportJson, contentType: 'application/json' },
{ code: 200, contentType: 'application/pdf' },
])
public getReport = async (req, res, next) => { ... };Produces:
'200':
content:
application/json: { schema: { ... } }
application/pdf: { schema: { type: string, format: binary } }Stack multiple decorators to document different status codes:
@SwaggerResponse(200, UserListResponse)
@SwaggerResponse(404, ErrorResponse, 'User not found')
@SwaggerResponse(403, ErrorResponse, 'Forbidden')
public getUsers = async (req, res, next) => { ... };Alternatively, pass an array of response objects in a single decorator. schema is optional inside each entry:
@SwaggerResponse([
{ code: 200, schema: UserListResponse },
{ code: 204, description: 'No Content' },
{ code: 404, schema: ErrorResponse, description: 'User not found' },
])
public getUsers = async (req, res, next) => { ... };SchemaClass must be a class decorated with class-validator decorators.
@RequestBody(SchemaClass: Function)
Explicitly sets the request body schema. Takes priority over schemas found via route scanning.
@RequestBody(CreateUserRequest)
public createUser = async (req, res, next) => { ... };@Auth()
Marks the route as requiring Bearer token authentication. Adds security: [{ bearerAuth: [] }] to the operation.
@Auth()
@Summary('Get current user profile')
public getProfile = async (req, res, next) => { ... };@Tags(...tags: string[])
Overrides the auto-inferred tags for the route (default: derived from the route base path).
@Tags('users', 'admin')
public listAllUsers = async (req, res, next) => { ... };@Deprecated()
Marks the operation as deprecated in the OpenAPI spec (deprecated: true).
@Deprecated()
@Summary('Old user endpoint — use /v2/users instead')
public legacyGetUsers = async (req, res, next) => { ... };@Example(value: any) — schema decorator
Adds an example value to a schema class property. Works on any class used as request body, query params, or response schema. The example propagates correctly through class inheritance.
Apply to schema class properties (not controller methods):
import { Example } from 'ground';
class CreateUserRequest {
@IsString()
@Example('John')
firstName: string;
@IsEmail()
@Example('[email protected]')
email: string;
@IsInt()
@IsOptional()
@Example(25)
age?: number;
}Produces:
{
"type": "object",
"properties": {
"firstName": { "type": "string", "example": "John" },
"email": { "type": "string", "format": "email", "example": "[email protected]" },
"age": { "type": "number", "example": 25 }
},
"required": ["firstName", "email"]
}@Example is also inherited — if a parent class has @Example on a property, child classes get it automatically without redeclaring.
@OpenApiOverride(json: Record<string, any>)
Provides a raw OpenAPI operation object that always takes priority over auto-generation. Use this when the generated spec is insufficient or incorrect.
If auto-generation fails and @OpenApiOverride is present, the override is used as a fallback.
@OpenApiOverride({
summary: 'Complex multi-format endpoint',
description: 'Returns data in multiple formats depending on Accept header.',
parameters: [
{ name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] } },
],
responses: {
200: { description: 'Success' },
400: { description: 'Invalid format' },
},
security: [{ bearerAuth: [] }],
})
public exportData = async (req, res, next) => { ... };Schema dynamic import
When Ground scans a route file, schema names are stored as strings (e.g. 'UserListRequest'). To convert them to real JSON Schema objects, pass the schemaPath parameter to enrichRoutesWithControllerMetadata():
const enrichedRoutes = enrichRoutesWithControllerMetadata(
'./src/controllers',
routes,
'./src/schemas', // ← enables dynamic schema import
);Without schemaPath, schemas remain as unresolved strings — fine for manual mocking but will produce invalid OpenAPI output.
Schemas from compiled packages. A class that the route imports from a compiled package (for example a shared entities package in an npm workspace, where only .js + .d.ts exist) is resolved too: Ground finds it through the .d.ts and loads the .js emitted next to it. A class declared in your sources always wins over one with the same name in a declaration file. Packages installed as real folders inside node_modules are not scanned, as before: those classes are skipped with a warning.
Pass a fourth registry argument (from createSchemaRegistry()) to share each resolved schema through components/schemas via $ref instead of inlining a duplicate at every usage — the shape a generator like Orval turns into reusable, non-duplicated models.
Schema classes use class-validator decorators:
import { IsString, IsEmail, IsOptional, IsInt, IsArray, IsEnum, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
export class UserListRequest {
@IsString()
@IsOptional()
firstName?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsInt()
@IsOptional()
page?: number;
}
export class UserListResponse {
@IsArray()
@Type(() => UserDetail)
results: UserDetail[];
@IsInt()
total: number;
}Nested objects are resolved the same way as arrays — combine @IsObject() with @Type(() => Class):
export class PlantStatusResponse {
@IsString()
status: string;
@IsNumber()
humidity: number;
}
export class PlantResponse {
@IsString()
name: string;
@IsObject()
@Type(() => PlantStatusResponse)
status: PlantStatusResponse;
}
// → status property is expanded to the full PlantStatusResponse JSON SchemaSchema inheritance is supported — validators and @Example from parent classes are automatically included in the child schema:
class BasePagination {
@IsInt()
@IsOptional()
@Example(1)
page?: number;
@IsInt()
@IsOptional()
@Example(20)
pageLimit?: number;
}
class UserListRequest extends BasePagination {
@IsString()
@IsOptional()
firstName?: string;
}
// → produces properties: page, pageLimit, firstName (all optional)Supported validators:
| Decorator | JSON Schema output |
| --------------------------------------- | ------------------------------------- |
| @IsString() | { type: 'string' } |
| @IsInt() / @IsNumber() | { type: 'number' } |
| @IsBoolean() | { type: 'boolean' } |
| @IsEmail() | { type: 'string', format: 'email' } |
| @IsEnum(EnumType) | { type: 'string', enum: [...] } |
| @IsIn(any[]) | { type: 'any', enum: [...] } |
| @IsArray() + @Type(() => ItemClass) | { type: 'array', items: {...} } |
| @IsObject() + @Type(() => Class) | nested { type: 'object', ... } |
| @IsOptional() | Removes field from required[] |
@IsIn([]) needs another decorator to get the array item types, for example @IsInt()
Configuration
Use swagger.config.ts at project root for reusable settings:
import path from 'path';
export default {
controllersPath: path.resolve(__dirname, 'src/controllers'),
schemasPath: path.resolve(__dirname, 'src/schemas'),
tags: [
{ name: 'Users', description: 'User management' },
{ name: 'Auth', description: 'Authentication' },
],
info: {
title: 'My API',
version: '1.0.0',
},
};API Reference
scanRoutesFromSource(sourceCode: string): Route[]
Parses a TypeScript route class source string and returns an array of Route objects.
scanControllers(controllersPath: string): Record<string, string>
Returns a map of controller class names to their absolute file paths.
scanSchemas(schemasPath: string): Record<string, string>
Returns a map of schema class names to their absolute file paths. Classes found only in a declaration file map to the .d.ts path; enrichRoutesWithControllerMetadata loads the .js next to it.
enrichRoutesWithControllerMetadata(controllerPath, routes, schemaPath?, registry?): Route[]
Dynamically imports controllers, reads decorator metadata, and optionally resolves schema strings to JSON Schema objects. Pass a SchemaRegistry to emit request/response bodies as $refs into components/schemas (deduplicated by class name); omit it to inline every schema.
createSchemaRegistry(): SchemaRegistry
Creates a registry that collects named schema definitions. Thread it through enrichRoutesWithControllerMetadata() and pass registry.schemas to routesToOpenAPI() so each schema is emitted once and shared via $ref.
classValidatorToObject(SchemaClass: Function, registry?: SchemaRegistry): object
Converts a class-validator-decorated class to a JSON Schema object. With a registry, nested object/array types become $refs to shared components instead of being inlined.
routesToOpenAPI(routes, title, version, tags, options?): object
Generates an OpenAPI 3.0.3 document from enriched routes. The optional options object accepts basePath (string prepended to every path key), servers (an OpenAPI servers list, emitted as-is), and schemas (named definitions emitted under components/schemas, typically registry.schemas).
Route interface
interface Route {
path: string;
method: HttpMethods;
body?: object;
queryParams?: Record<string, object>;
params?: Record<string, object>;
auth?: boolean;
controller?: string;
function?: string;
responses?: { code: number; body: object }[];
summary?: string;
description?: string;
tags?: string[];
deprecated?: boolean;
openApiOverride?: Record<string, any>;
}TODOS
This library is still in what we can call beta stage. Here a list of todos:
- Retrieve auth from route. Override with @Auth and create NoAuth for same reason (or just improva auth options)
- Remove required if empty in schemas
