Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .codex/skills/api-development/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: api-development
description: Implement or modify HTTP API endpoints in this profanity-filter-api repository. Use this skill whenever a request adds a controller endpoint, changes an API contract, adds OpenAPI documentation, decides whether an endpoint should appear in /openapi.json, or introduces dashboard, administrator, system, health, or duplicate transport APIs. Classify visibility before coding and keep controllers, composed OpenAPI annotations, public docs, architecture tests, and E2E tests aligned.
---

# API Development

## Core Policy

Treat `/openapi.json` as the allowlist of APIs intended for external integrators, not as an inventory of every implemented endpoint.

Classify the endpoint before implementation:

- External integrator API: document it with one detailed composed annotation from `app.openapi`.
- Dashboard, administrator, or system API: put `@Hidden` directly on the controller class or endpoint method.
- Duplicate transport representation of an already documented operation: use `@Hidden` and keep one canonical public operation.
- Ambiguous audience: stop and ask whether external integrators must implement against it.

Do not infer visibility from authentication alone. An authenticated API can still be public, while a technically public login callback can still be dashboard-internal.

## Implementation Workflow

1. Inspect the matching controller, security rule, DTOs, response types, existing `app.openapi` holder, README API list, and `OpenApiSpecE2ETest`.
2. State the audience classification and its code evidence before editing.
3. Preserve runtime mappings, security, and behavior unless the user explicitly asks to change them.
4. Apply exactly one documentation decision:
- Public: add one runtime-retained composed method annotation under the matching `FooOpenApi` holder.
- Hidden: add direct `@Hidden`; do not create or retain a composed operation annotation for that endpoint.
5. For a public operation, document the complete external contract:
- summary and integration-focused description
- request body, path/query/header parameters, media types, and validation constraints
- concrete success and error response schemas
- realistic but obviously fake examples
- `ApiKeyAuth` when the endpoint requires an external API Key
6. Remove unused holder annotations and imports when an existing endpoint becomes hidden. Delete an empty holder class.
7. Align public surfaces. Remove hidden dashboard, administrator, and system paths from README and the Markdown assembled into `/overview.md`. Keep internal ADRs unless the user asks to delete them.
8. Update architecture and E2E coverage in the same change.

## Architecture Contract

Every non-document controller endpoint must satisfy one exclusive state:

- one `app.openapi` composed annotation and no effective `@Hidden`, or
- effective `@Hidden` on the method or owning controller and no `app.openapi` method annotation.

Allow direct Swagger dependency in controllers only for `@Hidden`. Keep all other Swagger operation details in `app.openapi` holders. Apply holder-name matching and `ApiKeyAuth` checks only to documented endpoints.

## Verification

Use MockMvc-based checks first so another worktree using port 8080 cannot interfere.

```bash
./gradlew --no-daemon staticCheck
./gradlew --no-daemon :profanity-api:unitTest --tests app.architecture.OpenApiArchitectureTest
./gradlew --no-daemon :profanity-api:e2eTest --tests app.e2e.OpenApiSpecE2ETest
```

The OpenAPI E2E test must assert both sides of the boundary:

- required public operation and security/schema details exist
- every newly hidden path is absent
- the total public operation count changes only when intentionally approved
- `/overview.md` exactly matches its classpath source fragments

If an actual server smoke test is necessary, avoid port 8080:

```bash
SERVER_PORT=18081 ./gradlew :profanity-api:bootRun
```

Then check `/openapi.json`, `/overview.md`, `/api/v1/health`, and `/api/v1/ping` on port 18081. Stop the process after verification.

## Current Visibility Examples

- Public: JSON filter, advanced filter, client/API Key APIs, word change request, health, ping.
- Hidden: dashboard authentication/session APIs, manual sync, word request approval, duplicate form-urlencoded filter operation.

These examples describe the current repository, but the audience rule is authoritative for future endpoints.
4 changes: 4 additions & 0 deletions .codex/skills/api-development/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "API Development"
short_description: "외부 공개 API와 내부 Hidden 경계를 지키며 endpoint를 개발합니다."
default_prompt: "신규 API의 외부 공개 여부를 먼저 분류하고 controller, 상세 OpenAPI 합성 어노테이션 또는 @Hidden, 공개 문서, ArchUnit과 E2E 테스트까지 일관되게 구현해줘."
23 changes: 23 additions & 0 deletions .codex/skills/api-development/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"skill_name": "api-development",
"evals": [
{
"id": 1,
"prompt": "외부 고객이 API Key로 호출할 수 있는 GET /api/v1/filter/history endpoint를 추가해줘. 기간과 페이지 파라미터가 있고 필터링 이력 목록을 반환해야 해.",
"expected_output": "외부 API로 분류하고 matching OpenApi holder에 상세 합성 annotation, ApiKeyAuth, concrete schema와 예제를 추가하며 공개 문서와 양방향 E2E를 갱신한다.",
"files": []
},
{
"id": 2,
"prompt": "운영자가 장애 대응 때 캐시를 강제로 비우는 POST /api/v1/admin/cache/evict API를 추가해줘.",
"expected_output": "관리자 API로 분류해 controller 또는 method에 @Hidden만 사용하고 OpenApi holder를 만들지 않으며 스펙 비노출 E2E를 추가한다.",
"files": []
},
{
"id": 3,
"prompt": "대시보드에서 통계 조회할 API 하나 추가해줘. GET /api/v1/stats로 하면 될 것 같아.",
"expected_output": "외부 연동 대상인지 자사 대시보드 전용인지 불명확하므로 구현 전에 공개 여부를 사용자에게 확인한다.",
"files": []
}
]
}
33 changes: 3 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,10 @@ Aho-Corasick 알고리즘을 기반으로 한국어 비속어를 검출하고

## 인증 모델

사람의 개발자 포털 로그인과 외부 API 호출 인증은 서로 다른 자격 증명을 사용합니다.

| 용도 | 방식 | 상태 |
|---|---|---|
| 개발자 포털 로그인 | Google/GitHub SSO 및 `LOGIN_JWT` | 제공 중 |
| 외부 API 호출 | `x-api-key: {API_KEY}` | 제공 중 |
| 외부 API 호출 | OAuth2 Client Credentials Bearer token | 준비 중, 현재 비활성화 |

API Key와 OAuth2 Client Credentials는 모두 SSO 로그인 후 발급하는 정책으로 전환합니다. 다만 현재 운영 API에는 기존 공개 발급 경로가 남아 있으므로, 백엔드 전환이 완료되기 전까지 아래 API 목록의 현재 상태를 기준으로 사용해야 합니다.
외부 API는 현재 `x-api-key: {API_KEY}`로 인증합니다. OAuth2 Client Credentials Bearer token은 준비 중이며 아직 사용할 수 없습니다.

외부 API에 아직 지원하지 않는 Bearer token을 보내면 HTTP `401`, business code `4017`로 거부됩니다. 로그인 JWT를 외부 API Key 대신 사용할 수도 없습니다.

자세한 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)과 다음 ADR을 참고하세요.

- [ADR 0005: SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md)
- [ADR 0006: 로그인 기반 API 자격 증명 발급과 OAuth2 인증 도입](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md)

## 빠른 시작

```bash
Expand All @@ -56,7 +43,7 @@ curl --request POST 'https://api.kr-filter.com/api/v1/filter' \
| `NORMAL` | 매칭된 비속어를 모두 반환합니다. |
| `FILTER` | 매칭된 비속어를 `*`로 마스킹합니다. |

`text`와 `mode`는 필수이며, 비동기 결과가 필요하면 `callbackUrl`을 추가할 수 있습니다. 동일한 `/api/v1/filter` 경로에서 JSON과 `application/x-www-form-urlencoded` 요청을 모두 지원합니다.
`text`와 `mode`는 필수이며, 비동기 결과가 필요하면 `callbackUrl`을 추가할 수 있습니다.

## API 목록

Expand All @@ -72,18 +59,6 @@ curl --request POST 'https://api.kr-filter.com/api/v1/filter' \
| `GET` | `/api/v1/health` | 애플리케이션 상태 확인 |
| `GET` | `/api/v1/ping` | 연결 확인 |

### SSO 로그인

| Method | Path | 설명 | 인증 |
|---|---|---|---|
| `GET` | `/oauth2/authorization/{google\|github}` | 소셜 로그인 시작 | 공개 |
| `POST` | `/api/v1/auth/exchange` | 일회용 로그인 코드를 access token으로 교환 | 공개 코드 |
| `GET` | `/api/v1/auth/csrf` | refresh 요청용 CSRF token 조회 | refresh cookie |
| `POST` | `/api/v1/auth/refresh` | 로그인 access token 갱신 | refresh cookie와 CSRF |
| `GET` | `/api/v1/auth/me` | 로그인 사용자 조회 | `LOGIN_JWT` |

로그인 access token은 15분, refresh token은 14일이며 refresh session의 절대 수명은 30일입니다. 브라우저 로그인 흐름은 개발자 포털이 처리하므로 일반 API 사용자가 직접 구현할 필요는 없습니다.

### 비속어 필터

| Method | Path | 설명 | 인증 |
Expand All @@ -103,13 +78,11 @@ curl --request POST 'https://api.kr-filter.com/api/v1/filter' \
| `GET` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 발송 | 공개 |
| `PUT` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 검증 | 공개 |

### 관리 API
### 단어 변경 요청

| Method | Path | 설명 | 인증 |
|---|---|---|---|
| `POST` | `/api/v1/word/request` | 비속어 단어 변경 요청 | API Key |
| `POST` | `/api/v1/word/accept/{requestId}` | 단어 변경 요청 승인 | WRITE 권한 API Key |
| `GET` | `/api/v1/sync` | 비속어 데이터 수동 동기화 | 관리자 API Key 및 password |

## 응답과 오류

Expand Down
21 changes: 6 additions & 15 deletions profanity-api/src/main/java/app/config/OpenApiConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,18 @@
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.security.SecuritySchemes;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@SecuritySchemes({
@SecurityScheme(
name = "ApiKeyAuth",
type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER,
paramName = "x-api-key",
description = "클라이언트 등록 후 발급받은 API Key"),
@SecurityScheme(
name = "LoginJwtAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT",
description = "Google/GitHub SSO 로그인 후 발급된 대시보드 access token")
})
@SecurityScheme(
name = "ApiKeyAuth",
type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER,
paramName = "x-api-key",
description = "클라이언트 등록 후 발급받은 API Key")
public class OpenApiConfig {

@Bean
Expand Down
Loading