diff --git a/AGENTS.md b/AGENTS.md index a5f106e9..60af0255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,15 @@ | [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - | +### API 레퍼런스 진입점 + +> 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시. 공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428)은 진입점 문서 상단에 정리되어 있다. + +| 대상 | 진입점 | 문서/엔드포인트 | +|------|--------|----------------| +| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 | + + ### 확장 API 레퍼런스 (9개 확장, 자동 스캔) > 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음). diff --git a/README.md b/README.md index afb587e2..0c1abe3e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ Status

+

+ 데모 사이트 · + 관리자 데모 +

+ --- [소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스) @@ -462,7 +467,8 @@ cp .env.example .env | 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) | | 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) | | 테스트 | [docs/testing-guide.md](docs/testing-guide.md) | -| API 레퍼런스 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) | +| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) | +| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) | --- diff --git a/app/Console/Commands/ApiDocgenCommand.php b/app/Console/Commands/ApiDocgenCommand.php index 977af410..a6596c00 100644 --- a/app/Console/Commands/ApiDocgenCommand.php +++ b/app/Console/Commands/ApiDocgenCommand.php @@ -228,12 +228,28 @@ class ApiDocgenCommand extends Command // 대상별 API 문서 목차(README.md) 생성/갱신 — 확장 API 레퍼런스 발견성의 규약 진입점. // check/dry-run 은 파일을 쓰지 않으므로 제외. examples-only 는 목차도 최신화한다. if (! $this->option('check')) { + $coreReadme = $this->readmeFile(['type' => 'core', 'id' => null]); + foreach ($readmeIndex as $readmePath => $meta) { $existing = File::exists($readmePath) ? File::get($readmePath) : null; - $content = $scaffolder->readmeIndex($meta['label'], $meta['entries'], $existing); + // 코어 README 는 최상위 README 의 "API 레퍼런스" 진입점이므로 확장 목차를 함께 싣는다. + $extensions = $readmePath === $coreReadme ? $this->scanExtensionApiReadmes() : null; + $content = $scaffolder->readmeIndex($meta['label'], $meta['entries'], $existing, $extensions); File::ensureDirectoryExists(dirname($readmePath)); File::put($readmePath, $content); } + + // 확장 스코프 실행이라 코어 README 를 다시 쓰지 않았어도, 그 확장의 엔드포인트 수가 + // 바뀌었을 수 있으므로 코어 README 의 확장 목차 블록만 in-place 갱신한다. + if (! array_key_exists($coreReadme, $readmeIndex) && File::exists($coreReadme)) { + $updated = $scaffolder->refreshExtensionIndex( + File::get($coreReadme), + $this->scanExtensionApiReadmes() + ); + if ($updated !== null) { + File::put($coreReadme, $updated); + } + } } if ($this->option('check')) { @@ -816,6 +832,56 @@ class ApiDocgenCommand extends Command return base_path("{$base}/_bundled/{$owner['id']}/docs/api/{$domain}.md"); } + /** + * 번들 확장이 소유한 API 문서 목차(README.md)를 파일 시스템 패턴 스캔으로 수집합니다. + * + * 확장명을 하드코딩하지 않고 `{modules,plugins}/_bundled/{*}/docs/api/README.md` 존재 + * 여부만으로 발견한다(동적 로딩 원칙). 문서 수·엔드포인트 수는 스캐폴더가 방출한 + * 집계 라인(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽는다. + * + * 링크 경로는 코어 README(`docs/backend/api/`) 기준 상대경로로 만든다. + * + * @return array 확장 목록 + */ + private function scanExtensionApiReadmes(): array + { + $results = []; + + foreach (['modules' => 'module', 'plugins' => 'plugin'] as $dir => $type) { + $bundled = base_path("{$dir}/_bundled"); + if (! File::isDirectory($bundled)) { + continue; + } + + foreach (File::directories($bundled) as $extDir) { + $readme = $extDir.DIRECTORY_SEPARATOR.'docs'.DIRECTORY_SEPARATOR.'api'.DIRECTORY_SEPARATOR.'README.md'; + if (! File::exists($readme)) { + continue; + } + + $id = basename($extDir); + $content = File::get($readme); + $docs = 0; + $endpoints = 0; + if (preg_match('/\*\*문서 수\*\*:\s*(\d+)\s*·\s*\*\*엔드포인트 수\*\*:\s*(\d+)/u', $content, $m)) { + $docs = (int) $m[1]; + $endpoints = (int) $m[2]; + } + + $results[] = [ + 'id' => $id, + 'type' => $type, + // docs/backend/api/README.md → 프로젝트 루트까지 3단계 상위 + 'path' => "../../../{$dir}/_bundled/{$id}/docs/api/README.md", + 'docs' => $docs, + 'endpoints' => $endpoints, + ]; + } + } + + return $results; + } + /** * 소유 대상의 API 문서 목차(README.md) 경로를 반환합니다. * diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php index 5dba71b0..d1200043 100644 --- a/app/Support/ApiDoc/ApiDocScaffolder.php +++ b/app/Support/ApiDoc/ApiDocScaffolder.php @@ -116,6 +116,16 @@ class ApiDocScaffolder return implode("\n", $lines)."\n"; } + /** + * @var string 코어 README 의 확장 API 목차 생성 블록 키 + */ + private const GEN_KEY_EXTENSIONS = 'api-readme-extensions'; + + /** + * @var string 코어/확장 README 의 도메인 목차 생성 블록 키 + */ + private const GEN_KEY_INDEX = 'api-readme-index'; + /** * 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다. * @@ -124,13 +134,23 @@ class ApiDocScaffolder * 갱신되며, 블록 밖 사람 서술(개요·주의사항)은 보존한다. 확장 API 문서의 발견성은 * 코어 인덱스 생성기가 이 README 를 패턴 스캔(확장명 하드코딩 없이)해 확보한다. * + * 코어 README 는 프로젝트 최상위 `README.md` 의 "API 레퍼런스" 진입점이므로, + * 도메인 목차 뒤에 확장 API 목차 블록을 함께 싣는다. `$extensions` 가 null 이면 + * 확장 블록을 생성하지 않고 기존 블록(있으면)을 그대로 보존한다 — `--scope` 축소 + * 실행이 확장 목차를 소실시키지 않도록 하기 위함이다. + * * @param string $ownerLabel 소유 라벨 (예: '코어', '모듈 `sirsoft-ecommerce`') * @param array $entries 도메인 파일 목록 * @param string|null $existing 기존 README (사람 서술 보존용, 없으면 null) + * @param array|null $extensions 확장 API 목차 항목 (코어 전용, 미갱신 시 null) * @return string README 마크다운 */ - public function readmeIndex(string $ownerLabel, array $entries, ?string $existing = null): string - { + public function readmeIndex( + string $ownerLabel, + array $entries, + ?string $existing = null, + ?array $extensions = null + ): string { usort($entries, fn ($a, $b) => strcmp($a['domain'], $b['domain'])); $total = array_sum(array_column($entries, 'count')); @@ -139,13 +159,21 @@ class ApiDocScaffolder $rows[] = "| [{$e['file']}]({$e['file']}) | `{$e['domain']}` | {$e['count']} |"; } - $genKey = 'api-readme-index'; + $genKey = self::GEN_KEY_INDEX; $lines = []; $lines[] = '# API 레퍼런스 문서 목차'; $lines[] = ''; $lines[] = "> **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반)."; $lines[] = '> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.'; $lines[] = ''; + + // 헤더와 목차 표 사이의 사람 개요(공통 규약 등)를 보존/삽입한다. + $overview = $existing !== null ? $this->extractReadmeOverview($existing, $genKey) : null; + if ($overview !== null && $overview !== '') { + $lines[] = $overview; + $lines[] = ''; + } + $lines[] = self::GEN_START.$genKey.' -->'; $lines[] = '- **문서 수**: '.count($entries)." · **엔드포인트 수**: {$total}"; $lines[] = ''; @@ -156,15 +184,98 @@ class ApiDocScaffolder $generated = implode("\n", $lines)."\n"; - // 블록 밖 사람 서술(개요 등)이 있으면 보존한다. - $prose = $existing !== null ? $this->extractHumanProse($existing, $genKey) : null; + // 목차 블록 밖 사람 서술(주의사항 등)이 있으면 보존한다. + // 확장 목차(`## 확장 API 레퍼런스`) 이후는 별도 블록이므로 삼키지 않는다. + $prose = $existing !== null ? $this->extractReadmeTrailingProse($existing, $genKey) : null; if ($prose !== null && $prose !== '') { $generated .= "\n".$prose."\n"; } + $extensionBlock = $extensions !== null + ? $this->extensionIndexBlock($extensions) + : ($existing !== null ? $this->extractGeneratedBlock($existing, self::GEN_KEY_EXTENSIONS) : null); + + if ($extensionBlock !== null) { + $generated = rtrim($generated)."\n\n".trim($extensionBlock)."\n"; + } + return $generated; } + /** + * 기존 코어 README 에서 확장 API 목차 블록만 in-place 갱신합니다. + * + * 확장 스코프 실행(`--scope=module:...`)이라 코어 도메인 목차를 재생성하지 않는 경우에도, + * 그 확장의 문서 수·엔드포인트 수 변동을 코어 진입점에 반영하기 위해 사용한다. + * 코어 README 에 확장 블록이 아직 없으면 문서 끝에 새로 덧붙인다. + * + * @param string $existing 기존 코어 README + * @param array $extensions 확장 목록 + * @return string|null 갱신된 README (변경 없으면 null) + */ + public function refreshExtensionIndex(string $existing, array $extensions): ?string + { + $block = $this->extensionIndexBlock($extensions); + $current = $this->extractGeneratedBlock($existing, self::GEN_KEY_EXTENSIONS); + + if ($current === null) { + return rtrim($existing)."\n\n".$block; + } + + if (trim($current) === trim($block)) { + return null; + } + + return str_replace($current, trim($block), $existing); + } + + /** + * 코어 README 의 확장 API 목차 블록을 생성합니다. + * + * 확장명을 하드코딩하지 않고, 커맨드가 `{modules,plugins}/_bundled/*\/docs/api/README.md` + * 를 스캔해 넘긴 목록을 그대로 표로 만든다. + * + * @param array $extensions 확장 목록 + * @return string 확장 목차 마크다운 블록 (@generated 경계 포함) + */ + private function extensionIndexBlock(array $extensions): string + { + usort($extensions, function ($a, $b) { + return [$a['type'], $a['id']] <=> [$b['type'], $b['id']]; + }); + + $lines = []; + $lines[] = '## 확장 API 레퍼런스'; + $lines[] = ''; + $lines[] = '> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.'; + $lines[] = ''; + $lines[] = self::GEN_START.self::GEN_KEY_EXTENSIONS.' -->'; + + if ($extensions === []) { + $lines[] = '_설치된 번들 확장 중 API 문서를 소유한 확장이 없습니다._'; + $lines[] = ''; + $lines[] = self::GEN_END; + + return implode("\n", $lines)."\n"; + } + + $lines[] = '- **확장 수**: '.count($extensions) + .' · **엔드포인트 수**: '.array_sum(array_column($extensions, 'endpoints')); + $lines[] = ''; + $lines[] = '| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |'; + $lines[] = '| --- | --- | --- | --- |'; + + foreach ($extensions as $e) { + $type = $e['type'] === 'module' ? '모듈' : '플러그인'; + $lines[] = "| `{$e['id']}` | {$type} | [docs/api/]({$e['path']}) | {$e['docs']} / {$e['endpoints']} |"; + } + + $lines[] = ''; + $lines[] = self::GEN_END; + + return implode("\n", $lines)."\n"; + } + /** * 인증/권한 라인을 구성합니다. * @@ -832,6 +943,99 @@ class ApiDocScaffolder return $merged; } + /** + * README 헤더(인용 블록)와 첫 생성 블록 사이의 사람 개요를 추출합니다. + * + * 개요(공통 규약: 인증·응답 봉투·에러·페이지네이션)는 목차 표보다 먼저 읽혀야 하므로 + * 생성 블록 앞에 둔다. 재생성 시 이 구간을 원문 그대로 되살려 사람 서술을 보존한다. + * + * @param string $existing 기존 README + * @param string $key 목차 생성 블록 키 + * @return string|null 보존할 개요 (없으면 null) + */ + private function extractReadmeOverview(string $existing, string $key): ?string + { + $startPos = strpos($existing, self::GEN_START.$key.' -->'); + if ($startPos === false) { + return null; + } + + $head = substr($existing, 0, $startPos); + + // 헤더 인용 블록(`> ...`) 의 마지막 줄 이후가 사람 개요다. + if (! preg_match_all('/(?:^|\n)>[^\n]*/', $head, $all, PREG_OFFSET_CAPTURE)) { + return null; + } + + $last = end($all[0]); + $overview = trim(substr($head, $last[1] + strlen($last[0]))); + + return $overview === '' ? null : $overview; + } + + /** + * README 목차 블록 뒤 ~ 확장 목차 헤딩 앞의 사람 서술을 추출합니다. + * + * @param string $existing 기존 README + * @param string $key 목차 생성 블록 키 + * @return string|null 보존할 사람 서술 (없으면 null) + */ + private function extractReadmeTrailingProse(string $existing, string $key): ?string + { + $startPos = strpos($existing, self::GEN_START.$key.' -->'); + if ($startPos === false) { + return null; + } + + $endPos = strpos($existing, self::GEN_END, $startPos); + if ($endPos === false) { + return null; + } + + $afterGen = substr($existing, $endPos + strlen(self::GEN_END)); + + // 확장 목차 섹션(`## 확장 API 레퍼런스`)부터는 생성 블록 소관이므로 제외한다. + $nextSection = preg_match('/\n## /', $afterGen, $m, PREG_OFFSET_CAPTURE) + ? $m[0][1] + : strlen($afterGen); + + $prose = trim(substr($afterGen, 0, $nextSection)); + + return $prose === '' ? null : $prose; + } + + /** + * 기존 문서에서 지정 키의 생성 블록을 헤딩·인용문 포함해 원문 그대로 추출합니다. + * + * `--scope` 축소 실행 등으로 이번 회차에 갱신 대상이 아닌 블록을 소실 없이 되살린다. + * + * @param string $existing 기존 문서 + * @param string $key 생성 블록 키 + * @return string|null 블록 원문 (없으면 null) + */ + private function extractGeneratedBlock(string $existing, string $key): ?string + { + $startPos = strpos($existing, self::GEN_START.$key.' -->'); + if ($startPos === false) { + return null; + } + + $endPos = strpos($existing, self::GEN_END, $startPos); + if ($endPos === false) { + return null; + } + + // 블록 앞의 섹션 헤딩(`## ...`)부터 함께 되살린다. + $head = substr($existing, 0, $startPos); + $headingPos = preg_match_all('/(?:^|\n)## [^\n]*/', $head, $all, PREG_OFFSET_CAPTURE) + ? end($all[0])[1] + : null; + + $from = $headingPos !== null ? $headingPos : $startPos; + + return trim(substr($existing, $from, ($endPos + strlen(self::GEN_END)) - $from)); + } + /** * 기존 문서에서 특정 엔드포인트의 사람 서술(생성 블록 밖)을 추출합니다. * diff --git a/docs/README.md b/docs/README.md index e65bf7a5..28674e30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,18 @@ --- +## API 레퍼런스 + +G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시입니다. +템플릿의 `data_sources` 를 작성하거나 외부에서 G7 을 연동할 때 참고하세요. + +| 문서 | 설명 | +|------|------| +| [backend/api/README.md](backend/api/README.md) | **API 레퍼런스 진입점** — 공통 규약(인증·응답 봉투·페이지네이션·에러) + 코어/확장 전체 목차 | +| [backend/api-documentation.md](backend/api-documentation.md) | API 문서 작성·갱신 규정 (기여자용) | + +--- + ## 작업 유형별 필수 문서 ### 레이아웃 JSON 작성 diff --git a/docs/backend/README.md b/docs/backend/README.md index 341c9790..f9b5a83a 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -15,6 +15,16 @@ --- +## API 레퍼런스 + +엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시는 [api/README.md](api/README.md) 에 있습니다. +공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428 에러)도 그 문서 상단에 정리되어 있습니다. +확장(모듈·플러그인)이 소유한 API 문서 목록도 같은 문서에서 찾을 수 있습니다. + +문서 작성·갱신 규정은 [api-documentation.md](api-documentation.md) 를 참고하세요. + +--- + ## 문서 목록 diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md index d8b24869..7e7427e7 100644 --- a/docs/backend/api-documentation.md +++ b/docs/backend/api-documentation.md @@ -54,20 +54,39 @@ G7 의 REST API 는 라우트 `->name()` 규약은 있으나 엔드포인트별 ### 문서 목차와 발견성 (README.md 규약) 각 대상(코어·확장)의 API 문서 디렉토리에는 `README.md` 목차가 있어야 한다. `api:docgen` 이 도메인 파일 -목록·엔드포인트 수를 담아 자동 생성한다(`@generated` 블록, 재생성 멱등, 블록 밖 사람 개요는 보존). +목록·엔드포인트 수를 담아 자동 생성한다(`@generated` 블록, 재생성 멱등, 블록 밖 사람 서술은 보존). - 코어: `docs/backend/api/README.md` - 확장: `{modules|plugins}/_bundled/{id}/docs/api/README.md` -확장 API 문서의 발견성은 이 목차를 통해 확보한다. 코어 인덱스 생성기(`generate-docs-index.cjs`)는 -확장명을 하드코딩하지 않고 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 **패턴 스캔**해, -CLAUDE.md·AGENTS.md·docs-index 의 "확장 API 레퍼런스" 표에 자동 편입한다(동적 로딩 원칙 — 코어는 규약과 -스캔 패턴만, 확장 이름은 파일 시스템에서 발견). 처음 진입하는 개발자/AI 의 도달 경로: +코어 README 는 프로젝트 최상위 `README.md` 의 "API 레퍼런스" 진입점이다. 따라서 확장 목차와 달리 세 +부분으로 구성된다. 이 문서(작성 규정)와 혼동되지 않도록 최상위 `README.md` 는 둘을 "API 레퍼런스" 와 +"API 문서 작성 규정" 으로 분리해 링크한다. + +| 구성 | 위치 | 소유 | +| --- | --- | --- | +| 공통 규약 개요 (인증·응답 봉투·페이지네이션·에러) | 헤더 인용 블록 뒤 ~ 첫 `@generated` 앞 | 사람 (재생성 시 원문 보존) | +| 코어 도메인 목차 | `@generated:start:api-readme-index` | `api:docgen` | +| 확장 API 목차 | `@generated:start:api-readme-extensions` | `api:docgen` (코어 README 전용) | + +개요를 첫 생성 블록 앞에 두는 이유는 목차 표보다 먼저 읽혀야 하기 때문이다. 확장 README 에는 확장 목차 +블록을 넣지 않는다. `--scope` 를 좁혀 실행해도 이번 회차에 갱신하지 않는 블록과 사람 서술은 원문 그대로 +보존된다. + +확장 API 문서의 발견성은 이 목차를 통해 확보한다. `api:docgen` 과 코어 인덱스 생성기 +(`generate-docs-index.cjs`) 는 확장명을 하드코딩하지 않고 +`{modules,plugins}/_bundled/*/docs/api/README.md` 를 **패턴 스캔**해, 코어 README 의 "확장 API 레퍼런스" +표와 CLAUDE.md·AGENTS.md·docs-index 에 자동 편입한다(동적 로딩 원칙 — 코어는 규약과 스캔 패턴만, 확장 +이름은 파일 시스템에서 발견). 문서 수·엔드포인트 수는 각 README 의 집계 라인 +(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽으므로, 이 라인 형식을 바꾸면 두 스캐너를 함께 갱신한다. + +처음 진입하는 개발자/AI 의 도달 경로: ```text -CLAUDE.md "확장 API 레퍼런스" 표 (자동 스캔) - → {확장}/docs/api/README.md (확장 소유 목차) - → {도메인}.md (엔드포인트별 파라미터·응답·예시) +README.md "API 레퍼런스" 또는 CLAUDE.md "API 레퍼런스 진입점" 표 + → docs/backend/api/README.md (공통 규약 + 코어 목차 + 확장 목차) + → {도메인}.md 또는 {확장}/docs/api/README.md + → 엔드포인트별 파라미터·응답·예시 ``` --- diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 91339cb9..43188b71 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -3,6 +3,110 @@ > **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. +G7 의 REST API 레퍼런스입니다. 도메인별 문서에는 엔드포인트마다 메서드·URI·인증/권한, 요청 파라미터 표, +응답 필드 표, 실제 호출로 관측한 요청·응답 예시가 실려 있습니다. 아래 공통 규약은 모든 엔드포인트에 +동일하게 적용되므로 개별 문서에서 반복하지 않습니다. + +문서 작성·갱신 규정은 [api-documentation.md](../api-documentation.md) 를 참고하세요. + +## 공통 규약 + +### 인증 + +Laravel Sanctum 의 **Bearer 토큰 전용**입니다(세션 쿠키 인증 미사용). +`POST /api/auth/login` 또는 `POST /api/auth/admin/login` 으로 토큰을 발급받아 모든 후속 요청에 실어 보냅니다. + +```http +GET /api/me HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +각 문서의 **인증/권한** 줄은 다음 네 가지 중 하나입니다. + +| 표기 | 의미 | +| --- | --- | +| `공개 (인증 불필요)` | 토큰 없이 호출 가능 | +| `optional.sanctum` | 토큰이 있으면 회원, 없으면 비회원으로 처리 (둘 다 접근 가능) | +| `auth:sanctum` | 유효한 토큰 필수 (없거나 만료 시 401) | +| `auth:sanctum + admin + permission:{키}` | 토큰 + 관리자 + 해당 권한 필요 (권한 부족 시 403) | + +### 로케일 + +응답 메시지의 언어는 ① 로그인 사용자의 `users.language` → ② `Accept-Language` 헤더 → +③ 시스템 기본값(`config('app.locale')`) 순으로 결정됩니다. 지원 언어는 `config('app.supported_locales')` 를 따릅니다. + +### 응답 봉투 + +성공·실패 모두 동일한 최상위 구조를 씁니다. 실제 페이로드는 항상 `data` 안에 들어갑니다. + +```json +{ + "success": true, + "message": "요청이 성공했습니다.", + "data": { } +} +``` + +실패 시 `success` 는 `false` 이고, 검증 오류는 `errors` 에 필드별 메시지 배열로 담깁니다. + +```json +{ + "success": false, + "message": "입력값이 올바르지 않습니다.", + "errors": { + "email": ["이메일 형식이 올바르지 않습니다."] + } +} +``` + +### 페이지네이션 + +목록 엔드포인트는 `data.data[]` 에 항목 배열을, `data.pagination` 에 페이지 정보를 담습니다. +요청은 `page` 와 `per_page` 쿼리 파라미터로 제어합니다. + +```json +{ + "pagination": { + "current_page": 1, + "last_page": 4, + "per_page": 25, + "total": 87, + "from": 1, + "to": 25, + "has_more_pages": true + } +} +``` + +일부 목록은 `data.abilities` 에 컬렉션 레벨 권한(`can_create`, `can_delete` 등)을 함께 반환합니다. +화면의 버튼 노출 여부를 이 값으로 판정하세요. + +### 공통 에러 상태코드 + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 엔드포인트가 요구하는 권한이 없는 경우 | +| 404 | Not Found | 대상 리소스가 없거나 접근 범위를 벗어난 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`errors` 에 필드별 메시지) | +| 428 | Precondition Required | 본인인증(IDV)이 선행되어야 하는 경우 | + +428 응답은 `error_code: "identity_verification_required"` 와 함께 `verification` 객체를 반환합니다. +클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다. + +```json +{ + "success": false, + "error_code": "identity_verification_required", + "message": "본인인증이 필요합니다.", + "verification": { } +} +``` + +## 코어 API 레퍼런스 + - **문서 수**: 35 · **엔드포인트 수**: 291 @@ -46,3 +150,23 @@ +## 확장 API 레퍼런스 + +> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. + + +- **확장 수**: 9 · **엔드포인트 수**: 372 + +| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | +| --- | --- | --- | --- | +| `gnuboard7-hello_module` | 모듈 | [docs/api/](../../../modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 | +| `sirsoft-board` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 | +| `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 | +| `sirsoft-page` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 | +| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | +| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | +| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | +| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | +| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | + + diff --git a/tests/Feature/Console/ApiDocgenCommandTest.php b/tests/Feature/Console/ApiDocgenCommandTest.php index 4c10a8f4..dd8d565e 100644 --- a/tests/Feature/Console/ApiDocgenCommandTest.php +++ b/tests/Feature/Console/ApiDocgenCommandTest.php @@ -161,4 +161,132 @@ class ApiDocgenCommandTest extends TestCase } } } + + /** + * README 목차 항목/확장 목록 샘플을 반환합니다. + * + * @return array{0: array, 1: array} + */ + private function readmeFixtures(): array + { + $entries = [ + ['domain' => 'users', 'file' => 'users.md', 'count' => 12], + ['domain' => 'auth', 'file' => 'auth.md', 'count' => 15], + ]; + + $extensions = [ + ['id' => 'sirsoft-page', 'type' => 'module', 'path' => '../../../modules/_bundled/sirsoft-page/docs/api/README.md', 'docs' => 2, 'endpoints' => 17], + ['id' => 'sirsoft-gdpr', 'type' => 'plugin', 'path' => '../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md', 'docs' => 4, 'endpoints' => 15], + ]; + + return [$entries, $extensions]; + } + + /** + * 코어 README 는 도메인 목차와 확장 API 목차를 함께 싣고, 재생성해도 멱등합니다. + * + * 최상위 README 의 "API 레퍼런스" 진입점이 이 문서이므로, 확장 API 문서의 발견 경로가 + * 여기 없으면 개발자/AI 가 확장 엔드포인트에 도달하지 못한다. + */ + #[Test] + public function 코어_readme는_확장_목차를_함께_싣고_멱등하게_재생성된다(): void + { + $scaffolder = new ApiDocScaffolder; + [$entries, $extensions] = $this->readmeFixtures(); + + $first = $scaffolder->readmeIndex('코어', $entries, null, $extensions); + + $this->assertStringContainsString('## 확장 API 레퍼런스', $first); + $this->assertStringContainsString('`sirsoft-page`', $first); + $this->assertStringContainsString('`sirsoft-gdpr`', $first); + $this->assertStringContainsString('**확장 수**: 2 · **엔드포인트 수**: 32', $first); + + $second = $scaffolder->readmeIndex('코어', $entries, $first, $extensions); + $this->assertSame($first, $second, '동일 입력 재생성은 멱등해야 한다.'); + $this->assertSame(1, substr_count($second, '## 확장 API 레퍼런스'), '확장 목차가 중복 생성되면 안 된다.'); + } + + /** + * 코어 README 상단의 사람 개요(공통 규약)는 재생성해도 보존됩니다. + * + * 개요는 목차 표보다 먼저 읽혀야 하므로 @generated 블록 앞에 둔다. 생성기가 이 구간을 + * 덮어쓰면 인증·응답 봉투·에러 규약 서술이 매 재생성마다 소실된다. + */ + #[Test] + public function 코어_readme의_사람_개요는_생성_블록_앞에서_보존된다(): void + { + $scaffolder = new ApiDocScaffolder; + [$entries, $extensions] = $this->readmeFixtures(); + + $base = $scaffolder->readmeIndex('코어', $entries, null, $extensions); + $withOverview = str_replace( + '', + "## 공통 규약\n\nBearer 토큰 전용입니다. (사람 개요)\n\n", + $base + ); + + $regenerated = $scaffolder->readmeIndex('코어', $entries, $withOverview, $extensions); + + $this->assertStringContainsString('Bearer 토큰 전용입니다. (사람 개요)', $regenerated); + $this->assertSame(1, substr_count($regenerated, '## 공통 규약'), '개요가 중복 삽입되면 안 된다.'); + // 개요는 목차 표보다 앞에 있어야 한다. + $this->assertLessThan( + strpos($regenerated, ''), + strpos($regenerated, '## 공통 규약') + ); + + // 재생성 멱등 (개요 포함). + $this->assertSame($regenerated, $scaffolder->readmeIndex('코어', $entries, $regenerated, $extensions)); + } + + /** + * 확장 스코프 실행이라 확장 목록을 넘기지 않아도 기존 확장 목차는 소실되지 않습니다. + * + * `--scope=core` 축소 실행이 코어 README 의 확장 표를 지워버리면, 매 실행마다 진입점이 + * 사라졌다 살아나는 drift 가 생긴다. + */ + #[Test] + public function 확장_목록_미전달_시_기존_확장_목차를_보존한다(): void + { + $scaffolder = new ApiDocScaffolder; + [$entries, $extensions] = $this->readmeFixtures(); + + $withExtensions = $scaffolder->readmeIndex('코어', $entries, null, $extensions); + $regenerated = $scaffolder->readmeIndex('코어', $entries, $withExtensions, null); + + $this->assertStringContainsString('## 확장 API 레퍼런스', $regenerated); + $this->assertStringContainsString('`sirsoft-page`', $regenerated); + $this->assertSame(1, substr_count($regenerated, '## 확장 API 레퍼런스')); + } + + /** + * refreshExtensionIndex 는 코어 목차·개요를 건드리지 않고 확장 표만 갱신합니다. + * + * 확장 스코프 실행(`--scope=module:...`) 시 그 확장의 엔드포인트 수 변동을 코어 진입점에 + * 반영하는 경로다. 변경이 없으면 null 을 반환해 불필요한 파일 쓰기를 피한다. + */ + #[Test] + public function 확장_목차만_in_place_갱신하고_변경_없으면_null을_반환한다(): void + { + $scaffolder = new ApiDocScaffolder; + [$entries, $extensions] = $this->readmeFixtures(); + + $existing = $scaffolder->readmeIndex('코어', $entries, null, $extensions); + + $this->assertNull( + $scaffolder->refreshExtensionIndex($existing, $extensions), + '동일 확장 목록이면 변경 없음(null)이어야 한다.' + ); + + // 엔드포인트 수가 늘어난 경우 표만 갱신된다. + $extensions[0]['endpoints'] = 20; + $updated = $scaffolder->refreshExtensionIndex($existing, $extensions); + + $this->assertNotNull($updated); + $this->assertStringContainsString('2 / 20', $updated); + // 코어 목차와 도메인 항목은 그대로 남는다. + $this->assertStringContainsString('[users.md](users.md)', $updated); + $this->assertStringContainsString('', $updated); + $this->assertSame(1, substr_count($updated, '## 확장 API 레퍼런스')); + } }