Skip to content

Getting Started

This section guides you through installing NZOTH, adding it to a NestJS project, and creating your first typed controller.

Install the server dependencies:

Terminal window
pnpm add @lonestone/nzoth

Declare a Zod schema and a controller using NZOTH’s typed decorators.

Generate an OpenAPI document from your NestJS app and NZOTH decorators, enabling easy API documentation. Add zod validations to globals filters.

main.ts

import { createOpenApiDocument, ZodSerializationExceptionFilter, ZodValidationExceptionFilter } from '@lonestone/nzoth/server'
// main.ts
import { NestFactory } from '@nestjs/core'
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
import { AppModule } from './app.module'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  app.useGlobalFilters(
    new ZodValidationExceptionFilter(),
    new ZodSerializationExceptionFilter(),
  )

  const swaggerConfig = new DocumentBuilder()
    .setOpenAPIVersion('3.1.0')
    .setTitle('Lonestone API')
    .setDescription('The Lonestone API description')
    .setVersion('1.0')
    .addTag('@lonestone')
    .build()

  const document = createOpenApiDocument(app, swaggerConfig, {
    override: ({ jsonSchema, zodSchema, io }) => {
      const def = zodSchema._zod.def
      if (def.type === 'date' && io === 'output') {
        jsonSchema.type = 'string'
        jsonSchema.format = 'date-time'
      }
    },
    allowEmptySchema: {
      custom: true,
    },
  })

  SwaggerModule.setup('docs', app, document, {
    jsonDocumentUrl: '/docs-json',
    customSiteTitle: 'Lonestone API Documentation',
    customfavIcon: '/favicon.ico',
    customJs: [
      'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.8/swagger-ui-bundle.min.js',
    ],
    customCssUrl: [
      'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.8/swagger-ui.min.css',
    ],
    swaggerOptions: {
      docExpansion: 'list',
      filter: true,
      showRequestDuration: true,
      persistAuthorization: true,
      displayOperationId: false,
      defaultModelsExpandDepth: 3,
      defaultModelExpandDepth: 3,
      defaultModelRendering: 'model',
      tagsSorter: 'alpha',
      operationsSorter: 'alpha',
    },
  })

  await app.listen(3000)
}
bootstrap()

Defines a Zod schema for a user object, specifying that a user has a UUID id and a valid email address.

modules/post/post.contract.ts

// modules/post/post.contract.ts
import {
  paginatedSchema,
} from '@lonestone/nzoth/server'
import { z } from 'zod'

export const PostSchema = z
  .object({
    id: z.uuid(),
    title: z.string().min(2),
    description: z.string().min(2),
    content: z.string().min(2),
  })
  .meta({
    title: 'Post',
    description: 'Post schema',
  })

export type Post = z.infer<typeof PostSchema>

export const PostsSchema = paginatedSchema(PostSchema.omit({ id: true })).meta({
  title: 'Posts paginated',
  description: 'Posts paginated schema',
})

export type Posts = z.infer<typeof PostsSchema>

export const PostCreateSchema = z
  .object({
    title: z.string().min(2),
    description: z.string().min(2),
    content: z.string().min(2),
  })
  .meta({
    title: 'PostCreate',
    description: 'Post create schema',
  })

export type PostCreate = z.infer<typeof PostCreateSchema>

export const PostUpdateSchema = z
  .object({
    title: z.string().min(2),
    description: z.string().min(2),
    content: z.string().min(2),
  })
  .meta({
    title: 'PostUpdate',
    description: 'Post update schema',
  })

export type PostUpdate = z.infer<typeof PostUpdateSchema>

Use the TypedRoute decorator with schema to add validation and type-safe

modules/post/post.controller.ts

// modules/post/post.controller.ts
import {
  TypedBody,
  TypedController,
  TypedParam,
  TypedQuery,
  TypedRoute,
} from '@lonestone/nzoth/server'
import { z } from 'zod'
import {
  PostCreate,
  PostCreateSchema,
  Posts,
  PostSchema,
  PostsSchema,
  PostUpdate,
  PostUpdateSchema,
} from './post.contract'

@TypedController(
  'posts',
)
export class PostController {
  @TypedRoute.Get('', PostsSchema)
  findAll(
    @TypedQuery('q', z.string().optional()) q: string,
  ): Posts {
    const filteredPosts = [
      {
        id: '1',
        title: 'Post 1',
        description: 'Post 1 description',
        content: 'Post 1 content',
      },
    ].filter(post => post.title.includes(q))
    return {
      data: filteredPosts,
      meta: {
        offset: 0,
        pageSize: 10,
        itemCount: filteredPosts.length,
        hasMore: false,
      },
    }
  }

  @TypedRoute.Get(':id', PostSchema)
  findOne(
    @TypedParam('id', z.uuid()) id: string,
  ) {
    return [
      {
        id: '1',
        title: 'Post 1',
        description: 'Post 1 description',
        content: 'Post 1 content',
      },
    ].find(post => post.id === id)
  }

  @TypedRoute.Post('', PostSchema)
  create(
    @TypedBody(PostCreateSchema) postData: PostCreate,
  ) {
    return {
      id: '123e4567-e89b-12d3-a456-426614174000',
      ...postData,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }
  }

  @TypedRoute.Put(':id', PostUpdateSchema)
  update(
    @TypedParam('id', z.uuid()) id: string,
    @TypedBody(PostUpdateSchema) postData: PostUpdate,
  ) {
    return {
      id,
      ...postData,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }
  }

  @TypedRoute.Delete(':id')
  remove(
    @TypedParam('id', z.uuid()) id: string,
  ) {
    return id
  }
}