NestJSでREST APIを構築する際、HTTPメソッドデコレータとリクエストデコレータを活用することで、クリーンで型安全なエンドポイントを実装できます。本記事では、@Get@Post@Put@Deleteの各HTTPメソッドデコレータと、@Param@Query@Body@Headersによるリクエストデータの取得方法、そしてDTOパターンを用いた堅牢なAPI設計を実践します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Node.js 20以上
npm 10以上
NestJS 11.x
OS Windows / macOS / Linux
エディタ VS Code(推奨)

事前に以下の準備を完了してください。

  • NestJS CLIのインストール済み
  • NestJSプロジェクトの作成済み

プロジェクトの作成方法はNestJS入門記事、Module・Controller・Providerの基本はアーキテクチャ解説記事を参照してください。

HTTPメソッドデコレータの基本

NestJSは、RESTful APIの各操作に対応するHTTPメソッドデコレータを提供しています。これらのデコレータをControllerのメソッドに付与することで、特定のHTTPリクエストを処理するエンドポイントを定義します。

利用可能なHTTPメソッドデコレータ一覧

デコレータ HTTPメソッド 主な用途 推奨ステータスコード
@Get() GET リソースの取得 200
@Post() POST リソースの作成 201
@Put() PUT リソースの完全更新 200
@Patch() PATCH リソースの部分更新 200
@Delete() DELETE リソースの削除 200または204
@Options() OPTIONS 通信オプションの取得 200
@Head() HEAD ヘッダー情報の取得 200
@All() 全メソッド すべてのHTTPメソッドを処理 -

ルートパスの指定方法

HTTPメソッドデコレータには、オプションでパスを指定できます。このパスは@Controller()で指定したプレフィックスと組み合わされます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Controller, Get, Post, Put, Delete } from '@nestjs/common';

@Controller('articles')
export class ArticlesController {
  // GET /articles
  @Get()
  findAll() {
    return 'すべての記事を取得';
  }

  // GET /articles/featured
  @Get('featured')
  findFeatured() {
    return '注目記事を取得';
  }

  // GET /articles/category/:categoryId
  @Get('category/:categoryId')
  findByCategory() {
    return 'カテゴリ別の記事を取得';
  }
}

リクエストデコレータによるデータ取得

NestJSは、HTTPリクエストから様々なデータを取得するための専用デコレータを提供しています。これらを活用することで、Expressのreqオブジェクトに直接アクセスすることなく、必要なデータを型安全に取得できます。

リクエストデコレータ一覧

graph LR
    A[HTTPリクエスト] --> B{デコレータ}
    B --> C["@Param() - URLパス"]
    B --> D["@Query() - クエリ文字列"]
    B --> E["@Body() - リクエストボディ"]
    B --> F["@Headers() - ヘッダー"]
    B --> G["@Ip() - IPアドレス"]
    B --> H["@Req() - リクエスト全体"]
デコレータ 取得元 Express相当 使用例
@Param(key?) URLパスパラメータ req.params /users/:idのidを取得
@Query(key?) クエリ文字列 req.query ?page=1&limit=10を取得
@Body(key?) リクエストボディ req.body JSONペイロードを取得
@Headers(name?) リクエストヘッダー req.headers Authorizationなどを取得
@Ip() クライアントIP req.ip IPアドレスを取得
@Req() リクエスト全体 req 生のリクエストオブジェクト

@Param - URLパスパラメータの取得

@Param()デコレータは、URLパスに含まれる動的なパラメータを取得します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { Controller, Get, Param } from '@nestjs/common';

@Controller('users')
export class UsersController {
  // すべてのパラメータをオブジェクトとして取得
  @Get(':id')
  findOne(@Param() params: { id: string }) {
    console.log(params.id);
    return `ユーザーID: ${params.id}`;
  }

  // 特定のパラメータのみ取得
  @Get(':userId/posts/:postId')
  findUserPost(
    @Param('userId') userId: string,
    @Param('postId') postId: string,
  ) {
    return `ユーザー${userId}の投稿${postId}を取得`;
  }
}

パラメータの型変換が必要な場合は、組み込みのPipeを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { Controller, Get, Param, ParseIntPipe, ParseUUIDPipe } from '@nestjs/common';

@Controller('products')
export class ProductsController {
  // 数値への変換(変換失敗時は400エラー)
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return `商品ID: ${id}(数値型)`;
  }

  // UUIDのバリデーション
  @Get('uuid/:uuid')
  findByUuid(@Param('uuid', ParseUUIDPipe) uuid: string) {
    return `UUID: ${uuid}`;
  }
}

@Query - クエリ文字列の取得

@Query()デコレータは、URLのクエリ文字列(?key=value形式)からパラメータを取得します。一覧取得やフィルタリング、ページネーションで頻繁に使用されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { Controller, Get, Query } from '@nestjs/common';

@Controller('articles')
export class ArticlesController {
  // すべてのクエリパラメータを取得
  // GET /articles?page=1&limit=10&sort=createdAt
  @Get()
  findAll(@Query() query: { page?: string; limit?: string; sort?: string }) {
    return `ページ: ${query.page}, 件数: ${query.limit}, ソート: ${query.sort}`;
  }

  // 個別に取得(デフォルト値の設定)
  @Get('search')
  search(
    @Query('keyword') keyword: string,
    @Query('page') page: string = '1',
    @Query('limit') limit: string = '20',
  ) {
    return `キーワード「${keyword}」で検索(${page}ページ目、${limit}件)`;
  }
}

型変換とバリデーションを行う場合は、DTOとValidationPipeを組み合わせます(後述)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { Controller, Get, Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common';

@Controller('posts')
export class PostsController {
  @Get()
  findAll(
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ) {
    return `${page}ページ目、${limit}件取得`;
  }
}

@Body - リクエストボディの取得

@Body()デコレータは、POST、PUT、PATCHリクエストのボディからデータを取得します。JSONペイロードの処理に使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { Controller, Post, Put, Patch, Body, Param } from '@nestjs/common';

interface CreateArticleDto {
  title: string;
  content: string;
  tags: string[];
}

interface UpdateArticleDto {
  title?: string;
  content?: string;
  tags?: string[];
}

@Controller('articles')
export class ArticlesController {
  // リクエストボディ全体を取得
  @Post()
  create(@Body() createArticleDto: CreateArticleDto) {
    return {
      message: '記事を作成しました',
      data: createArticleDto,
    };
  }

  // 完全更新(PUT)
  @Put(':id')
  update(
    @Param('id') id: string,
    @Body() updateArticleDto: UpdateArticleDto,
  ) {
    return {
      message: `記事${id}を更新しました`,
      data: updateArticleDto,
    };
  }

  // 部分更新(PATCH)
  @Patch(':id')
  partialUpdate(
    @Param('id') id: string,
    @Body() updateArticleDto: UpdateArticleDto,
  ) {
    return {
      message: `記事${id}を部分更新しました`,
      data: updateArticleDto,
    };
  }

  // 特定のプロパティのみ取得
  @Post('quick')
  quickCreate(@Body('title') title: string) {
    return `タイトル「${title}」で記事を作成`;
  }
}

@Headers - リクエストヘッダーの取得

@Headers()デコレータは、HTTPリクエストヘッダーから値を取得します。認証トークンやカスタムヘッダーの処理に使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { Controller, Get, Post, Headers } from '@nestjs/common';

@Controller('api')
export class ApiController {
  // すべてのヘッダーを取得
  @Get('headers')
  getAllHeaders(@Headers() headers: Record<string, string>) {
    return {
      userAgent: headers['user-agent'],
      contentType: headers['content-type'],
    };
  }

  // 特定のヘッダーを取得
  @Get('auth')
  checkAuth(@Headers('authorization') authHeader: string) {
    if (!authHeader) {
      return { authenticated: false };
    }
    return {
      authenticated: true,
      token: authHeader.replace('Bearer ', ''),
    };
  }

  // カスタムヘッダーの取得
  @Post('webhook')
  handleWebhook(
    @Headers('x-webhook-signature') signature: string,
    @Headers('x-webhook-timestamp') timestamp: string,
    @Body() payload: unknown,
  ) {
    return {
      signature,
      timestamp,
      payload,
    };
  }
}

DTOパターンによる型定義

DTO(Data Transfer Object)は、クライアントとサーバー間でやり取りされるデータの形式を定義するオブジェクトです。NestJSでは、DTOをクラスとして定義することで、バリデーションとドキュメンテーションの両方に活用できます。

なぜクラスでDTOを定義するのか

TypeScriptのインターフェースはコンパイル時に消去されるため、ランタイムでの型チェックやバリデーションには使用できません。一方、クラスはランタイムでも存在するため、デコレータを使用したバリデーションが可能です。

graph TD
    A[TypeScript Interface] -->|コンパイル| B[消去される]
    C[TypeScript Class] -->|コンパイル| D[JavaScript Classとして残る]
    D --> E[ランタイムバリデーション可能]
    D --> F[リフレクション可能]
    D --> G[Swaggerドキュメント生成可能]

リクエストDTOの定義

リクエストデータを受け取るためのDTOを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// dto/create-article.dto.ts
export class CreateArticleDto {
  title: string;
  content: string;
  summary: string;
  tags: string[];
  categoryId: number;
  isPublished: boolean;
}

// dto/update-article.dto.ts
export class UpdateArticleDto {
  title?: string;
  content?: string;
  summary?: string;
  tags?: string[];
  categoryId?: number;
  isPublished?: boolean;
}

ページネーション用のクエリDTO

一覧取得時のページネーションパラメータもDTOで定義すると便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// dto/pagination-query.dto.ts
export class PaginationQueryDto {
  page?: number;
  limit?: number;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}

// dto/article-filter.dto.ts
export class ArticleFilterDto extends PaginationQueryDto {
  keyword?: string;
  categoryId?: number;
  tags?: string[];
  isPublished?: boolean;
}

レスポンスDTOの定義

APIレスポンスの形式を統一するためのDTOも定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// dto/article-response.dto.ts
export class ArticleResponseDto {
  id: number;
  title: string;
  content: string;
  summary: string;
  tags: string[];
  categoryId: number;
  isPublished: boolean;
  createdAt: Date;
  updatedAt: Date;
}

// dto/paginated-response.dto.ts
export class PaginatedResponseDto<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  };
}

完全なCRUD APIの実装例

ここまでの内容を統合し、記事管理APIの完全な実装例を示します。

ディレクトリ構成

src/
├── articles/
│   ├── dto/
│   │   ├── create-article.dto.ts
│   │   ├── update-article.dto.ts
│   │   ├── article-filter.dto.ts
│   │   └── article-response.dto.ts
│   ├── articles.controller.ts
│   ├── articles.service.ts
│   └── articles.module.ts
└── app.module.ts

DTOファイル

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// articles/dto/create-article.dto.ts
export class CreateArticleDto {
  title: string;
  content: string;
  summary: string;
  tags: string[];
  categoryId: number;
}

// articles/dto/update-article.dto.ts
export class UpdateArticleDto {
  title?: string;
  content?: string;
  summary?: string;
  tags?: string[];
  categoryId?: number;
  isPublished?: boolean;
}

// articles/dto/article-filter.dto.ts
export class ArticleFilterDto {
  page?: number;
  limit?: number;
  keyword?: string;
  categoryId?: number;
  sortBy?: 'createdAt' | 'updatedAt' | 'title';
  sortOrder?: 'asc' | 'desc';
}

Serviceの実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// articles/articles.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { ArticleFilterDto } from './dto/article-filter.dto';

interface Article {
  id: number;
  title: string;
  content: string;
  summary: string;
  tags: string[];
  categoryId: number;
  isPublished: boolean;
  createdAt: Date;
  updatedAt: Date;
}

@Injectable()
export class ArticlesService {
  private articles: Article[] = [];
  private idCounter = 1;

  create(createArticleDto: CreateArticleDto): Article {
    const article: Article = {
      id: this.idCounter++,
      ...createArticleDto,
      isPublished: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    this.articles.push(article);
    return article;
  }

  findAll(filterDto: ArticleFilterDto) {
    let result = [...this.articles];
    const page = filterDto.page ?? 1;
    const limit = filterDto.limit ?? 10;

    // キーワード検索
    if (filterDto.keyword) {
      const keyword = filterDto.keyword.toLowerCase();
      result = result.filter(
        (article) =>
          article.title.toLowerCase().includes(keyword) ||
          article.content.toLowerCase().includes(keyword),
      );
    }

    // カテゴリフィルタ
    if (filterDto.categoryId) {
      result = result.filter(
        (article) => article.categoryId === filterDto.categoryId,
      );
    }

    // ソート
    const sortBy = filterDto.sortBy ?? 'createdAt';
    const sortOrder = filterDto.sortOrder ?? 'desc';
    result.sort((a, b) => {
      const aValue = a[sortBy];
      const bValue = b[sortBy];
      if (sortOrder === 'asc') {
        return aValue > bValue ? 1 : -1;
      }
      return aValue < bValue ? 1 : -1;
    });

    // ページネーション
    const total = result.length;
    const totalPages = Math.ceil(total / limit);
    const offset = (page - 1) * limit;
    const data = result.slice(offset, offset + limit);

    return {
      data,
      meta: {
        total,
        page,
        limit,
        totalPages,
      },
    };
  }

  findOne(id: number): Article {
    const article = this.articles.find((a) => a.id === id);
    if (!article) {
      throw new NotFoundException(`Article with ID ${id} not found`);
    }
    return article;
  }

  update(id: number, updateArticleDto: UpdateArticleDto): Article {
    const articleIndex = this.articles.findIndex((a) => a.id === id);
    if (articleIndex === -1) {
      throw new NotFoundException(`Article with ID ${id} not found`);
    }

    this.articles[articleIndex] = {
      ...this.articles[articleIndex],
      ...updateArticleDto,
      updatedAt: new Date(),
    };

    return this.articles[articleIndex];
  }

  remove(id: number): void {
    const articleIndex = this.articles.findIndex((a) => a.id === id);
    if (articleIndex === -1) {
      throw new NotFoundException(`Article with ID ${id} not found`);
    }
    this.articles.splice(articleIndex, 1);
  }
}

Controllerの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// articles/articles.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  Headers,
  HttpCode,
  HttpStatus,
  ParseIntPipe,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { ArticleFilterDto } from './dto/article-filter.dto';

@Controller('articles')
export class ArticlesController {
  constructor(private readonly articlesService: ArticlesService) {}

  /**
   * 記事を作成する
   * POST /articles
   */
  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(
    @Body() createArticleDto: CreateArticleDto,
    @Headers('x-user-id') userId: string,
  ) {
    console.log(`User ${userId} is creating an article`);
    return this.articlesService.create(createArticleDto);
  }

  /**
   * 記事一覧を取得する(フィルタリング・ページネーション対応)
   * GET /articles?page=1&limit=10&keyword=nestjs
   */
  @Get()
  findAll(@Query() filterDto: ArticleFilterDto) {
    return this.articlesService.findAll(filterDto);
  }

  /**
   * 特定の記事を取得する
   * GET /articles/:id
   */
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.articlesService.findOne(id);
  }

  /**
   * 記事を更新する
   * PUT /articles/:id
   */
  @Put(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateArticleDto: UpdateArticleDto,
  ) {
    return this.articlesService.update(id, updateArticleDto);
  }

  /**
   * 記事を削除する
   * DELETE /articles/:id
   */
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    this.articlesService.remove(id);
  }
}

Moduleの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// articles/articles.module.ts
import { Module } from '@nestjs/common';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';

@Module({
  controllers: [ArticlesController],
  providers: [ArticlesService],
  exports: [ArticlesService],
})
export class ArticlesModule {}

レスポンスのカスタマイズ

NestJSでは、レスポンスのステータスコードやヘッダーをカスタマイズするためのデコレータが用意されています。

@HttpCodeによるステータスコードの変更

デフォルトでは、GETリクエストは200、POSTリクエストは201を返します。これを変更するには@HttpCode()デコレータを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { Controller, Post, Delete, HttpCode, HttpStatus } from '@nestjs/common';

@Controller('examples')
export class ExamplesController {
  // 202 Acceptedを返す(非同期処理の受付など)
  @Post('async-task')
  @HttpCode(HttpStatus.ACCEPTED)
  createAsyncTask() {
    return { message: 'タスクを受け付けました' };
  }

  // 204 No Contentを返す(ボディなし)
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove() {
    // 何も返さない
  }
}

@Headerによるレスポンスヘッダーの設定

カスタムヘッダーを追加するには@Header()デコレータを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { Controller, Get, Header } from '@nestjs/common';

@Controller('files')
export class FilesController {
  @Get('download')
  @Header('Content-Type', 'application/octet-stream')
  @Header('Content-Disposition', 'attachment; filename="data.csv"')
  downloadFile() {
    return 'id,name,email\n1,John,john@example.com';
  }

  @Get('no-cache')
  @Header('Cache-Control', 'no-store, no-cache, must-revalidate')
  getNoCacheData() {
    return { timestamp: new Date().toISOString() };
  }
}

@Redirectによるリダイレクト

別のURLへリダイレクトするには@Redirect()デコレータを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { Controller, Get, Redirect, Query } from '@nestjs/common';

@Controller('redirect')
export class RedirectController {
  // 固定URLへのリダイレクト
  @Get('old-page')
  @Redirect('https://example.com/new-page', 301)
  redirectToNewPage() {
    // リダイレクト先は@Redirectで指定済み
  }

  // 動的なリダイレクト
  @Get('docs')
  @Redirect('https://docs.nestjs.com', 302)
  redirectToDocs(@Query('version') version: string) {
    if (version === '10') {
      return { url: 'https://docs.nestjs.com/v10/' };
    }
    // デフォルトは@Redirectで指定したURLへ
  }
}

REST API設計のベストプラクティス

NestJSでREST APIを設計する際に押さえておくべきポイントをまとめます。

リソース指向のURL設計

良い例:
  GET    /articles          - 記事一覧取得
  GET    /articles/:id      - 記事詳細取得
  POST   /articles          - 記事作成
  PUT    /articles/:id      - 記事更新
  DELETE /articles/:id      - 記事削除

避けるべき例:
  GET    /getArticles
  POST   /createArticle
  POST   /articles/delete/:id

適切なHTTPステータスコードの使用

操作 成功時 失敗時
作成(POST) 201 Created 400 Bad Request, 409 Conflict
取得(GET) 200 OK 404 Not Found
更新(PUT/PATCH) 200 OK 400 Bad Request, 404 Not Found
削除(DELETE) 200 OK または 204 No Content 404 Not Found

一貫性のあるレスポンス形式

成功時もエラー時も、一貫したレスポンス構造を維持します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 成功レスポンスの例
{
  "data": { ... },
  "meta": { "total": 100, "page": 1 }
}

// エラーレスポンスの例
{
  "statusCode": 400,
  "message": ["title should not be empty"],
  "error": "Bad Request"
}

まとめ

本記事では、NestJSにおけるREST APIエンドポイントの実装方法を解説しました。

学習した内容は以下のとおりです。

  • HTTPメソッドデコレータ@Get@Post@Put@Delete)によるエンドポイント定義
  • リクエストデコレータ@Param@Query@Body@Headers)によるデータ取得
  • DTOパターンを使用した型安全なリクエスト・レスポンス設計
  • レスポンスカスタマイズ@HttpCode@Header@Redirect
  • REST API設計のベストプラクティス

次のステップとして、Pipeを使用したバリデーションの実装や、Exception Filterによるエラーハンドリングの統一化を学ぶことで、より堅牢なAPIを構築できるようになります。

参考リンク