관리자 환경설정 저장값이 config 를 덮는 기존 설계는 공유호스팅을 1차 대상으로 삼은 결과다. .env 를 배포 기준값으로 관리하는 설치에서는 그 설계가 반대로 작동해, 설치기가 defaults 를 시드하는 순간부터 .env 의 28개 항목이 오류도 로그도 없이 사문화된다. G7_ENV_PRIORITY 옵트인으로 그 소유권을 키 단위로 되돌린다. 스위치를 켜지 않은 설치는 전 경로가 조기 return 이라 동작이 100% 동일하다. 명시 여부는 config 빌드 시점에 캡처한다 — 런타임 env 는 config:cache 환경에서 null 로 고정되어 판정이 영구 미발동한다(attachment.disk_explicit 와 같은 함정). 판정은 strict 라 APP_DEBUG=false·REDIS_DB=0 같은 falsy 명시도 명시로 본다. 잠금은 그 키의 주입만 건너뛰는 것이지만, 제거된 키의 부재를 값으로 읽는 자리가 있으면 형제 설정까지 무너진다. 게이트(웹소켓 토글·메일 드라이버·디버그 모드)와 기본값 주입(mailgun.endpoint·ses.region), 형제 폴백(reverb server endpoint), 파생 판정(storage_driver→attachment.disk)을 유효값으로 보정했다.
218 lines
7.8 KiB
PHP
218 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace App\Support\ApiDoc;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Sanctum\PersonalAccessToken;
|
|
|
|
/**
|
|
* API 엔드포인트 실측 프로브
|
|
*
|
|
* 임시 Sanctum 토큰을 발급해 실제 HTTP 요청으로 엔드포인트를 호출하고,
|
|
* 실제 응답 JSON 에서 필드 스키마(키·타입·샘플값)를 관측합니다.
|
|
* 쓰기 메서드는 기본적으로 실호출하지 않으며 GET/HEAD 만 read-only 로 실측합니다.
|
|
*/
|
|
class ApiEndpointProbe
|
|
{
|
|
/**
|
|
* @var string 실측 대상 기준 URL
|
|
*/
|
|
private string $baseUrl;
|
|
|
|
/**
|
|
* @var string|null 발급된 임시 토큰 평문
|
|
*/
|
|
private ?string $token = null;
|
|
|
|
/**
|
|
* @var string 임시 토큰 식별용 이름
|
|
*/
|
|
private string $tokenName = 'api-docgen-probe';
|
|
|
|
/**
|
|
* @var User|null 실측 인증 사용자 (in-process 쓰기 실측 시 로그인 대상)
|
|
*/
|
|
private ?User $user = null;
|
|
|
|
/**
|
|
* @param string|null $baseUrl 기준 URL (null 이면 config('app.url') 사용)
|
|
*/
|
|
public function __construct(?string $baseUrl = null)
|
|
{
|
|
// 기준 URL 은 config('app.url') 에서 해석한다. 이전에는 `.env` 를 우선 신뢰하려고
|
|
// env('APP_URL') 을 먼저 읽었는데, config:cache 환경에서 env() 는 null 로 고정되므로
|
|
// 그 우선순위는 실제로 성립한 적이 없었다(그대로 config 폴백으로 떨어졌다).
|
|
// 실측 대상을 지정해야 하면 명시 인자를 넘긴다 — 그쪽이 최우선이다.
|
|
$resolved = $baseUrl
|
|
?: (string) config('app.url');
|
|
|
|
$this->baseUrl = rtrim($resolved, '/');
|
|
}
|
|
|
|
/**
|
|
* 실측 기준 URL 을 반환합니다.
|
|
*
|
|
* @return string 기준 URL
|
|
*/
|
|
public function baseUrl(): string
|
|
{
|
|
return $this->baseUrl;
|
|
}
|
|
|
|
/**
|
|
* 실측용 관리자 토큰을 발급합니다.
|
|
*
|
|
* @param int|null $userId 토큰 발급 대상 사용자 ID (null 이면 첫 관리자)
|
|
* @return bool 발급 성공 여부
|
|
*/
|
|
public function authenticate(?int $userId = null): bool
|
|
{
|
|
$user = $userId
|
|
? User::find($userId)
|
|
: User::query()->orderBy('id')->first();
|
|
|
|
if (! $user) {
|
|
return false;
|
|
}
|
|
|
|
$this->user = $user;
|
|
$this->cleanupTokens();
|
|
$this->token = $user->createToken($this->tokenName)->plainTextToken;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 엔드포인트를 실호출하여 응답을 관측합니다.
|
|
*
|
|
* GET/HEAD 는 외부 HTTP 로 read-only 실측하고, 쓰기 메서드(POST/PUT/PATCH/DELETE)는
|
|
* **DB 트랜잭션 안에서 in-process 로 dispatch 한 뒤 롤백**하여(영속 안 함) 응답 shape 만
|
|
* 관측한다. 외부 HTTP 는 별도 프로세스라 롤백이 불가하므로 쓰기는 in-process 경로를 쓴다.
|
|
*
|
|
* @param string $method HTTP 메서드
|
|
* @param string $uri 라우트 URI (path 파라미터 치환 완료된 실제 경로)
|
|
* @param array<string, mixed> $body 쓰기 요청 바디 (쓰기 메서드에서만 사용)
|
|
* @return array{ok: bool, status: int|null, body: array<string, mixed>|null, skipped_reason: string|null}
|
|
*/
|
|
public function probe(string $method, string $uri, array $body = []): array
|
|
{
|
|
$method = strtoupper($method);
|
|
|
|
// path 파라미터가 남아 있으면(치환 실패) 실호출 불가.
|
|
if (Str::contains($uri, '{')) {
|
|
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'unresolved-path-param'];
|
|
}
|
|
|
|
if (! $this->token) {
|
|
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token'];
|
|
}
|
|
|
|
if (in_array($method, ['GET', 'HEAD'], true)) {
|
|
return $this->probeRead($method, $uri);
|
|
}
|
|
|
|
return $this->probeWrite($method, $uri, $body);
|
|
}
|
|
|
|
/**
|
|
* GET/HEAD 를 외부 HTTP 로 read-only 실측합니다.
|
|
*
|
|
* @param string $method HTTP 메서드
|
|
* @param string $uri 치환된 URI
|
|
* @return array{ok: bool, status: int|null, body: array<string, mixed>|null, skipped_reason: string|null}
|
|
*/
|
|
private function probeRead(string $method, string $uri): array
|
|
{
|
|
try {
|
|
$response = Http::withoutVerifying()
|
|
->withToken($this->token)
|
|
->acceptJson()
|
|
->timeout(15)
|
|
->get($this->baseUrl.$uri);
|
|
|
|
$json = $response->json();
|
|
|
|
return [
|
|
'ok' => $response->successful() && is_array($json),
|
|
'status' => $response->status(),
|
|
'body' => is_array($json) ? $json : null,
|
|
'skipped_reason' => null,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'request-failed: '.$e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 쓰기 메서드를 DB 트랜잭션 안에서 in-process dispatch 후 롤백하여 실측합니다.
|
|
*
|
|
* 응답 shape 만 관측하고 부수효과(레코드 생성/수정/삭제)는 롤백으로 영속시키지 않는다.
|
|
* 인증은 샘플 사용자로 로그인(guard)해 수행하며, DELETE 등 바디 없는 메서드도 처리한다.
|
|
*
|
|
* @param string $method HTTP 메서드
|
|
* @param string $uri 치환된 URI
|
|
* @param array<string, mixed> $body 요청 바디
|
|
* @return array{ok: bool, status: int|null, body: array<string, mixed>|null, skipped_reason: string|null}
|
|
*/
|
|
private function probeWrite(string $method, string $uri, array $body): array
|
|
{
|
|
if (! $this->user) {
|
|
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token'];
|
|
}
|
|
|
|
DB::beginTransaction();
|
|
|
|
try {
|
|
// in-process dispatch 는 실제 Bearer 토큰 헤더로 인증한다(guard 종류 무관).
|
|
// Auth::login() 은 API 기본 guard 가 RequestGuard(토큰 전용)라 사용할 수 없다.
|
|
$request = Request::create($uri, $method, $body);
|
|
$request->headers->set('Accept', 'application/json');
|
|
$request->headers->set('Authorization', 'Bearer '.$this->token);
|
|
|
|
$response = app()->handle($request);
|
|
$content = $response->getContent();
|
|
$json = json_decode(is_string($content) ? $content : '', true);
|
|
$status = $response->getStatusCode();
|
|
|
|
return [
|
|
'ok' => $status >= 200 && $status < 300 && is_array($json),
|
|
'status' => $status,
|
|
'body' => is_array($json) ? $json : null,
|
|
'skipped_reason' => (is_array($json) && $status >= 200 && $status < 300) ? null : 'http-'.$status,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'write-probe-failed: '.$e->getMessage()];
|
|
} finally {
|
|
// 쓰기 부수효과를 영속시키지 않는다 (문서 생성 목적).
|
|
DB::rollBack();
|
|
// 다음 실측에 인증 컨텍스트가 새지 않도록 해석된 guard 를 초기화한다
|
|
// (API guard 는 RequestGuard 라 logout() 이 없으므로 forgetGuards 사용).
|
|
Auth::forgetGuards();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 발급한 임시 토큰을 정리합니다.
|
|
*/
|
|
public function cleanup(): void
|
|
{
|
|
$this->cleanupTokens();
|
|
$this->token = null;
|
|
}
|
|
|
|
/**
|
|
* 실측용 토큰 레코드를 모두 삭제합니다.
|
|
*/
|
|
private function cleanupTokens(): void
|
|
{
|
|
PersonalAccessToken::query()
|
|
->where('name', $this->tokenName)
|
|
->delete();
|
|
}
|
|
}
|