mcpField
A
api-endpoint-creator

api-endpoint-creator

github.com/c0x12c開発109110 回インストール19 週間前に更新
リポジトリ

api-endpoint-creator について

プロジェクトのレイヤード Kotlin アーキテクチャに沿って、RPC 形式の API エンドポイントを作成するスキルです。リクエスト・レスポンスモデルから Controller、Manager、Factory、Retrofit クライアント、統合テストまでを扱い、プロジェクト固有の規約に従います。

主な機能

  1. 01エンドポイント一式とテストの生成
  2. 02Controller・Manager・Repository の階層化
  3. 03識別子にクエリパラメーターを使用
  4. 04Either によるエラー処理
  5. 05Retrofit 統合クライアントの作成

活用シーン

  1. 01ドメインエンティティの CRUD 追加
  2. 02REST API エンドポイントの新規作成
  3. 03エンドポイントの統合テスト作成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/c0x12c/ai-toolkit --skill api-endpoint-creator

インストール後、Claude Code で/api-endpoint-creatorを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

error-handling-guide.md
examples.md
testing-patterns.md

詳細

API エンドポイント作成スキル

厳格なレイヤードアーキテクチャのパターンに従って、RPC スタイルの API エンドポイントを完全に作成します。

使用するタイミング

  • REST API エンドポイントをゼロから新規作成するとき
  • 新しいドメインエンティティに CRUD 操作を追加するとき
  • Controller → Manager → Repository → Tests のフルスタックをセットアップするとき
  • 統合テスト用の Retrofit クライアントが必要なとき

手順

1. Response/Request モデルを作成する

場所:app/module-client/src/main/kotlin/com/yourcompany/client/response/{domain}/

package com.yourcompany.client.response.{domain}

import com.yourcompany.postgresql.entity.{Domain}Entity
import java.time.Instant
import java.util.UUID

data class {Domain}Response(
  val id: UUID,
  val name: String,
  val status: String,
  val createdAt: Instant,
  val updatedAt: Instant?
) {
  companion object {
    fun from(entity: {Domain}Entity): {Domain}Response = {Domain}Response(
      id = entity.id,
      name = entity.name,
      status = entity.status,
      createdAt = entity.createdAt,
      updatedAt = entity.updatedAt
    )
  }
}

data class {Domain}ListResponse(
  val items: List<{Domain}Response>,
  val total: Int,
  val page: Int,
  val limit: Int,
  val hasMore: Boolean
)

場所:app/module-client/src/main/kotlin/com/yourcompany/client/request/{domain}/

package com.yourcompany.client.request.{domain}

data class Create{Domain}Request(
  val name: String,
  val description: String? = null
)

data class Update{Domain}Request(
  val name: String? = null,
  val description: String? = null,
  val status: String? = null
)

重要:すべてのモデルは module-client に配置し、コントローラーやマネージャーには配置しません。

2. Controller を作成する

場所:app/api-application/src/main/kotlin/com/yourcompany/controller/{Domain}Controller.kt

package com.yourcompany.controller

import com.yourcompany.{domain}.contract.{Domain}Manager
import com.yourcompany.client.request.{domain}.Create{Domain}Request
import com.yourcompany.client.request.{domain}.Update{Domain}Request
import com.yourcompany.client.response.{domain}.{Domain}Response
import com.yourcompany.client.response.{domain}.{Domain}ListResponse
import com.yourcompany.exception.throwOrValue
import io.micronaut.http.annotation.*
import io.micronaut.scheduling.TaskExecutors
import io.micronaut.scheduling.annotation.ExecuteOn
import io.micronaut.security.annotation.Secured
import io.micronaut.validation.Validated
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import java.util.UUID

@ExecuteOn(TaskExecutors.IO)
@Validated
@Controller("/api/v1/{domain}")
@Tag(name = "{Domain}", description = "{Domain} API")
@Secured(SecurityRule.IS_AUTHENTICATED)
class {Domain}Controller(
  private val {domain}Manager: {Domain}Manager
) {

  @Get("/{domain}s")
  suspend fun list(
    @QueryValue page: Int?,
    @QueryValue limit: Int?,
    @QueryValue status: String?
  ): {Domain}ListResponse {
    return {domain}Manager.list(
      page = page ?: 1,
      limit = limit ?: 20,
      status = status
    ).throwOrValue()
  }

  @Get("/{domain}")
  suspend fun getById(
    @QueryValue id: UUID
  ): {Domain}Response {
    return {domain}Manager.byId(id).throwOrValue()
  }

  @Post("/{domain}")
  suspend fun create(
    @Valid @Body request: Create{Domain}Request
  ): {Domain}Response {
    return {domain}Manager.create(request).throwOrValue()
  }

  @Post("/{domain}/update")
  suspend fun update(
    @QueryValue id: UUID,
    @Valid @Body request: Update{Domain}Request
  ): {Domain}Response {
    return {domain}Manager.update(id, request).throwOrValue()
  }

  @Post("/{domain}/delete")
  suspend fun delete(
    @QueryValue id: UUID
  ): Boolean {
    return {domain}Manager.deleteById(id).throwOrValue()
  }
}

重要なポイント:

  • サスペンド関数には @ExecuteOn(TaskExecutors.IO) が必要です
  • すべての識別子にクエリパラメーターを使用します(@QueryValue id: UUID)
  • メソッドは薄く保ち、マネージャーに委譲するだけにします
  • .throwOrValue() で Either を展開します
  • Manager のみを注入します(Repository は決して注入しません)
  • インラインのデータクラスは使用しません
3. Manager インターフェースを作成する

場所:app/module-{domain}/module-api/src/main/kotlin/com/yourcompany/{domain}/contract/{Domain}Manager.kt

package com.yourcompany.{domain}.contract

import arrow.core.Either
import com.yourcompany.client.request.{domain}.Create{Domain}Request
import com.yourcompany.client.request.{domain}.Update{Domain}Request
import com.yourcompany.client.response.{domain}.{Domain}Response
import com.yourcompany.client.response.{domain}.{Domain}ListResponse
import com.yourcompany.exception.ClientException
import java.util.UUID

interface {Domain}Manager {
  suspend fun list(
    page: Int,
    limit: Int,
    status: String?
  ): Either<ClientException, {Domain}ListResponse>

  suspend fun byId(id: UUID): Either<ClientException, {Domain}Response>

  suspend fun create(
    request: Create{Domain}Request
  ): Either<ClientException, {Domain}Response>

  suspend fun update(
    id: UUID,
    request: Update{Domain}Request
  ): Either<ClientException, {Domain}Response>

  suspend fun deleteById(id: UUID): Either<ClientException, Boolean>
}
4. Manager の実装を作成する

場所:app/module-{domain}/module-impl/src/main/kotlin/com/yourcompany/{domain}/impl/Default{Domain}Manager.kt

package com.yourcompany.{domain}.impl

import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.yourcompany.database.DatabaseContext
import com.yourcompany.{domain}.contract.{Domain}Manager
import com.yourcompany.client.request.{domain}.Create{Domain}Request
import com.yourcompany.client.request.{domain}.Update{Domain}Request
import com.yourcompany.client.response.{domain}.{Domain}Response
import com.yourcompany.client.response.{domain}.{Domain}ListResponse
import com.yourcompany.exception.ClientError
import com.yourcompany.exception.ClientException
import com.yourcompany.postgresql.entity.{Domain}Entity
import com.yourcompany.postgresql.repository.{Domain}Repository
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.UUID

class Default{Domain}Manager(
  private val {domain}Repository: {Domain}Repository,
  private val db: DatabaseContext
) : {Domain}Manager {

  override suspend fun byId(id: UUID): Either<ClientException, {Domain}Response> {
    val entity = {domain}Repository.byId(id)
      ?: return ClientError.{DOMAIN}_NOT_FOUND.asException().left()

    return {Domain}Response.from(entity).right()
  }

  override suspend fun create(
    request: Create{Domain}Request
  ): Either<ClientException, {Domain}Response> {
    val entity = {Domain}Entity(
      name = request.name,
      description = request.description
    )

    val inserted = transaction(db.primary) {
      {domain}Repository.insert(entity)
    }

    return {Domain}Response.from(inserted).right()
  }

  override suspend fun update(
    id: UUID,
    request: Update{Domain}Request
  ): Either<ClientException, {Domain}Response> {
    val existing = {domain}Repository.byId(id)
      ?: return ClientError.{DOMAIN}_NOT_FOUND.asException().left()

    val updated = transaction(db.primary) {
      {domain}Repository.update(
        id = id,
        name = request.name,
        description = request.description,
        status = request.status
      )
    } ?: return ClientError.{DOMAIN}_NOT_FOUND.asException().left()

    return {Domain}Response.from(updated).right()
  }

  override suspend fun deleteById(id: UUID): Either<ClientException, Boolean> {
    val deleted = transaction(db.primary) {
      {domain}Repository.deleteById(id)
    }

    return if (deleted != null) {
      true.right()
    } else {
      ClientError.{DOMAIN}_NOT_FOUND.asException().left()
    }
  }
}

重要なポイント:

  • 変換には {Domain}Response.from(entity) を使用します(コンパニオンオブジェクトのパターン)
  • 書き込みには transaction(db.primary) を使用します
  • エラーには Either.left()、成功には Either.right() を返します
  • !! 演算子は使用しません
5. Factory Bean を登録する

場所:app/module-{domain}/module-impl/src/main/kotlin/com/yourcompany/runtime/factory/{Domain}ManagerFactory.kt

package com.yourcompany.runtime.factory

import com.yourcompany.database.DatabaseContext
import com.yourcompany.{domain}.impl.Default{Domain}Manager
import com.yourcompany.{domain}.contract.{Domain}Manager
import com.yourcompany.postgresql.repository.{Domain}Repository
import io.micronaut.context.annotation.Factory
import jakarta.inject.Singleton

@Factory
class {Domain}ManagerFactory {

  @Singleton
  fun provide{Domain}Manager(
    {domain}Repository: {Domain}Repository,
    db: DatabaseContext
  ): {Domain}Manager {
    return Default{Domain}Manager({domain}Repository, db)
  }
}
6. Retrofit クライアントを作成する

場所:app/module-client/src/main/kotlin/com/yourcompany/client/{Domain}Client.kt

package com.yourcompany.client

import com.yourcompany.client.request.{domain}.Create{Domain}Request
import com.yourcompany.client.request.{domain}.Update{Domain}Request
import com.yourcompany.client.response.{domain}.{Domain}Response
import com.yourcompany.client.response.{domain}.{Domain}ListResponse
import retrofit2.http.*
import java.util.UUID

interface {Domain}Client {

  @GET("/api/v1/{domain}s")
  suspend fun list(
    @Header("Authorization") authorization: String,
    @Query("page") page: Int? = null,
    @Query("limit") limit: Int? = null,
    @Query("status") status: String? = null
  ): {Domain}ListResponse

  @GET("/api/v1/{domain}")
  suspend fun getById(
    @Header("Authorization") authorization: String,
    @Query("id") id: UUID
  ): {Domain}Response

  @POST("/api/v1/{domain}")
  suspend fun create(
    @Header("Authorization") authorization: String,
    @Body request: Create{Domain}Request
  ): {Domain}Response

  @POST("/api/v1/{domain}/update")
  suspend fun update(
    @Header("Authorization") authorization: String,
    @Query("id") id: UUID,
    @Body request: Update{Domain}Request
  ): {Domain}Response

  @POST("/api/v1/{domain}/delete")
  suspend fun delete(
    @Header("Authorization") authorization: String,
    @Query("id") id: UUID
  ): Boolean
}
7. 統合テストを作成する

場所:app/api-application/src/test/kotlin/com/yourcompany/{Domain}ControllerTest.kt

完全なテスト例については、testing-patterns.md を参照してください。

8. テストを実行する
./gradlew :app:api-application:test --tests "{Domain}ControllerTest"
./gradlew test

対話スタイル

  • 常に、モデル、コントローラー、マネージャー、ファクトリー、クライアント、テストを含むフルスタックを生成します
  • プロジェクトの正確なパターンに従い、近道や独自の代替案は使いません
  • すべての ID にクエリパラメーターを使用し、パスパラメーターは決して使いません
  • コンテキストから明らかでない場合は、開始前に対象のドメインエンティティを尋ねます

ルール

RPC スタイル API(変更には POST、クエリパラメーターのみ)

このプロジェクトでは、RPC スタイルのエンドポイントを使用します。読み取りには @Get、すべての変更には @Post を使用し、すべての ID にはクエリパラメーターを使用します。

GET  /api/v1/employees              # List employees (plural)
GET  /api/v1/employee               # Get one employee (?id=xxx)
POST /api/v1/employee               # Create employee
POST /api/v1/employee/update        # Update employee
POST /api/v1/employee/delete        # Delete employee (soft)

API_RULES.md のルール:

  • パスパラメーター(/{id})は決して使用しません
  • クエリパラメーターを使用します:@QueryValue id: UUID
  • 単一リソースには単数形、コレクションには複数形を使用します
  • アクションには動詞のサブパスを使用します(/delete、/restore)
レイヤードアーキテクチャ
HTTP Request → Controller → Manager → Repository → Database

Controller:薄く保ち(委譲のみ)、HTTP アノテーション、@ExecuteOn(TaskExecutors.IO)、@Secured を使用し、.throwOrValue() で Either を展開します

Manager:すべてのビジネスロジックを担い、Either<ClientException, T> を返し、DB 操作をトランザクションで囲み、例外をスローしません

Repository:データアクセスのみ(すでに存在します)

!! 演算子は使用しない
val employee = employeeRepository.byId(id)
  ?: return ClientError.EMPLOYEE_NOT_FOUND.asException().left()
エラーハンドリングのパターン
見つからない場合
val entity = repository.byId(id)
  ?: return ClientError.{DOMAIN}_NOT_FOUND.asException().left()
すでに存在する場合
val existing = repository.byEmail(email)
if (existing != null) {
  return ClientError.EMAIL_ALREADY_IN_USE.asException().left()
}

出力

  • Controller は薄く保たれています(委譲のみ)
  • Controller に @ExecuteOn(TaskExecutors.IO) が付いています
  • パスパラメーターはありません(すべての ID に @QueryValue を使用します)
  • Manager は Either を返します(例外をスローしません)
  • DB 操作はトランザクションで囲まれています
  • !! 演算子は使用していません
  • Response モデルは module-client に配置され、companion object { fun from() } を備えています
  • コントローラーにインラインのデータクラスはありません
  • 統合テストでは Retrofit クライアントを使用します
  • すべてのテストが成功します

コメント