create-entity-server 0.0.15 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create.js +15 -7
- package/package.json +1 -1
- package/template/.env.example +8 -7
- package/template/configs/database.json +173 -10
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +28 -22
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/02_types_and_defaults.json +15 -16
- package/template/samples/entities/04_fk_and_composite_unique.json +0 -2
- package/template/samples/entities/05_cache.json +9 -8
- package/template/samples/entities/06_history_and_hard_delete.json +27 -9
- package/template/samples/entities/07_license_scope.json +40 -31
- package/template/samples/entities/09_hook_entity.json +0 -6
- package/template/samples/entities/10_hook_submit_delete.json +5 -2
- package/template/samples/entities/11_hook_webhook.json +9 -7
- package/template/samples/entities/12_hook_push.json +3 -3
- package/template/samples/entities/13_read_only.json +13 -10
- package/template/samples/entities/15_reset_defaults.json +0 -1
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +36 -39
- package/template/samples/flutter/lib/entity_server_client.dart +170 -48
- package/template/samples/java/EntityServerClient.java +208 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +175 -45
- package/template/samples/node/src/EntityServerClient.js +232 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +0 -1
- package/template/samples/php/ci4/Libraries/EntityServer.php +206 -53
- package/template/samples/php/laravel/Services/EntityServerService.php +190 -41
- package/template/samples/python/entity_server.py +181 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +143 -37
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +12 -8
- package/template/scripts/update-server.ps1 +68 -2
- package/template/scripts/update-server.sh +59 -2
- package/template/samples/entities/order_notification.json +0 -51
- package/template/samples/react/src/api/entityServerClient.ts +0 -413
- package/template/samples/react/src/hooks/useEntity.ts +0 -173
|
@@ -15,7 +15,6 @@ use Illuminate\Http\Request;
|
|
|
15
15
|
* ENTITY_SERVER_URL=http://localhost:47200
|
|
16
16
|
* ENTITY_SERVER_API_KEY=your-api-key
|
|
17
17
|
* ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
|
|
18
|
-
* ENTITY_PACKET_MAGIC_LEN=4
|
|
19
18
|
*
|
|
20
19
|
* 서비스 프로바이더 등록:
|
|
21
20
|
* $this->app->singleton(EntityServerService::class);
|
|
@@ -40,8 +39,10 @@ class EntityServerService
|
|
|
40
39
|
private string $baseUrl;
|
|
41
40
|
private string $apiKey;
|
|
42
41
|
private string $hmacSecret;
|
|
43
|
-
private
|
|
42
|
+
private string $token = '';
|
|
44
43
|
private bool $requireEncryptedRequest;
|
|
44
|
+
private bool $encryptRequests;
|
|
45
|
+
private bool $packetEncryption = false;
|
|
45
46
|
private ?string $activeTxId = null;
|
|
46
47
|
|
|
47
48
|
public function __construct()
|
|
@@ -49,32 +50,115 @@ class EntityServerService
|
|
|
49
50
|
$this->baseUrl = rtrim(config('services.entity_server.url', env('ENTITY_SERVER_URL', 'http://localhost:47200')), '/');
|
|
50
51
|
$this->apiKey = config('services.entity_server.api_key', env('ENTITY_SERVER_API_KEY', ''));
|
|
51
52
|
$this->hmacSecret = config('services.entity_server.hmac_secret', env('ENTITY_SERVER_HMAC_SECRET', ''));
|
|
52
|
-
$this->
|
|
53
|
+
$this->token = config('services.entity_server.token', env('ENTITY_SERVER_TOKEN', ''));
|
|
53
54
|
$this->requireEncryptedRequest = (bool) config('services.entity_server.require_encrypted_request', env('ENTITY_REQUIRE_ENCRYPTED_REQUEST', true));
|
|
55
|
+
$this->encryptRequests = (bool) config('services.entity_server.encrypt_requests', env('ENTITY_ENCRYPT_REQUESTS', false));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
|
|
59
|
+
public function setToken(string $token): void
|
|
60
|
+
{
|
|
61
|
+
$this->token = $token;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
// ─── CRUD ────────────────────────────────────────────────────────────────
|
|
57
65
|
|
|
58
|
-
|
|
66
|
+
/**
|
|
67
|
+
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
68
|
+
* 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
69
|
+
*/
|
|
70
|
+
public function checkHealth(): array
|
|
71
|
+
{
|
|
72
|
+
$response = Http::timeout(10)->get($this->baseUrl . '/v1/health');
|
|
73
|
+
$decoded = $response->json() ?? [];
|
|
74
|
+
if (!empty($decoded['packet_encryption'])) {
|
|
75
|
+
$this->packetEncryption = true;
|
|
76
|
+
}
|
|
77
|
+
return $decoded;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 단건 조회
|
|
82
|
+
*
|
|
83
|
+
* @param bool $skipHooks true 이면 after_get 훅 미실행
|
|
84
|
+
*/
|
|
85
|
+
public function get(string $entity, int $seq, bool $skipHooks = false): array
|
|
86
|
+
{
|
|
87
|
+
$q = $skipHooks ? '?skipHooks=true' : '';
|
|
88
|
+
return $this->request('GET', "/v1/entity/{$entity}/{$seq}{$q}");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 조건으로 단건 조회 (POST + conditions body)
|
|
93
|
+
*
|
|
94
|
+
* @param array $conditions 필터 조건. index/hash/unique 필드만 사용 가능.
|
|
95
|
+
* 예: ['email' => 'user@example.com']
|
|
96
|
+
* @param bool $skipHooks true 이면 after_find 훅 미실행
|
|
97
|
+
*/
|
|
98
|
+
public function find(string $entity, array $conditions, bool $skipHooks = false): array
|
|
59
99
|
{
|
|
60
|
-
|
|
100
|
+
$q = $skipHooks ? '?skipHooks=true' : '';
|
|
101
|
+
return $this->request('POST', "/v1/entity/{$entity}/find{$q}", $conditions);
|
|
61
102
|
}
|
|
62
103
|
|
|
63
|
-
|
|
104
|
+
/**
|
|
105
|
+
* 목록 조회 (POST + conditions body)
|
|
106
|
+
*
|
|
107
|
+
* @param array $params 펙이지/정렬 파라미터 (page, limit, orderBy, orderDir, fields)
|
|
108
|
+
* @param array $conditions 필터 조건 POST body. index/hash/unique 필드만 사용 가능.
|
|
109
|
+
* fields 예: ['*'] 시 전체 필드 반환, 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름)
|
|
110
|
+
* fields 예: ['name','email'] 또는 미지정
|
|
111
|
+
*/
|
|
112
|
+
public function list(string $entity, array $params = [], array $conditions = []): array
|
|
64
113
|
{
|
|
65
|
-
$
|
|
66
|
-
|
|
114
|
+
$queryParams = array_merge(['page' => 1, 'limit' => 20], $params);
|
|
115
|
+
|
|
116
|
+
if (isset($queryParams['orderDir'])) {
|
|
117
|
+
$dir = strtoupper((string) $queryParams['orderDir']);
|
|
118
|
+
$orderBy = (string) ($queryParams['orderBy'] ?? '');
|
|
119
|
+
if ($dir === 'DESC' && $orderBy !== '') {
|
|
120
|
+
$queryParams['orderBy'] = '-' . ltrim($orderBy, '-');
|
|
121
|
+
}
|
|
122
|
+
unset($queryParams['orderDir']);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isset($queryParams['fields']) && is_array($queryParams['fields'])) {
|
|
126
|
+
$queryParams['fields'] = implode(',', $queryParams['fields']);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
$query = http_build_query($queryParams);
|
|
130
|
+
return $this->request('POST', "/v1/entity/{$entity}/list?{$query}", $conditions);
|
|
67
131
|
}
|
|
68
132
|
|
|
69
|
-
|
|
133
|
+
/**
|
|
134
|
+
* 건수 조회
|
|
135
|
+
*
|
|
136
|
+
* @param array $conditions 필터 조건 (list() 와 동일 규칙)
|
|
137
|
+
*/
|
|
138
|
+
public function count(string $entity, array $conditions = []): array
|
|
70
139
|
{
|
|
71
|
-
return $this->request('
|
|
140
|
+
return $this->request('POST', "/v1/entity/{$entity}/count", $conditions);
|
|
72
141
|
}
|
|
73
142
|
|
|
74
|
-
|
|
143
|
+
/**
|
|
144
|
+
* 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
145
|
+
*
|
|
146
|
+
* - SELECT 쿼리만 허용 (INSERT/UPDATE/DELETE 불가)
|
|
147
|
+
* - SELECT * 불가. 최대 반환 건수 1000
|
|
148
|
+
* - 사용자 입력은 반드시 $params 로 바인딩 (SQL Injection 방지)
|
|
149
|
+
*
|
|
150
|
+
* @param string $entity URL 라우트용 기본 엔티티명
|
|
151
|
+
* @param string $sql SELECT SQL
|
|
152
|
+
* @param array $params ? 플레이스홀더 바인딩 값
|
|
153
|
+
* @param int|null $limit 최대 반환 건수 (최대 1000)
|
|
154
|
+
*/
|
|
155
|
+
public function query(string $entity, string $sql, array $params = [], ?int $limit = null): array
|
|
75
156
|
{
|
|
76
|
-
$
|
|
77
|
-
|
|
157
|
+
$body = ['sql' => $sql, 'params' => $params];
|
|
158
|
+
if ($limit !== null) {
|
|
159
|
+
$body['limit'] = $limit;
|
|
160
|
+
}
|
|
161
|
+
return $this->request('POST', "/v1/entity/{$entity}/query", $body);
|
|
78
162
|
}
|
|
79
163
|
|
|
80
164
|
/**
|
|
@@ -115,20 +199,34 @@ class EntityServerService
|
|
|
115
199
|
}
|
|
116
200
|
|
|
117
201
|
/** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
|
|
118
|
-
public function submit(string $entity, array $data, ?string $transactionId = null): array
|
|
202
|
+
public function submit(string $entity, array $data, ?string $transactionId = null, bool $skipHooks = false): array
|
|
119
203
|
{
|
|
120
204
|
$txId = $transactionId ?? $this->activeTxId;
|
|
121
205
|
$extra = $txId ? ['X-Transaction-ID' => $txId] : [];
|
|
122
|
-
|
|
206
|
+
$q = $skipHooks ? '?skipHooks=true' : '';
|
|
207
|
+
return $this->request('POST', "/v1/entity/{$entity}/submit{$q}", $data, $extra);
|
|
123
208
|
}
|
|
124
209
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
210
|
+
/**
|
|
211
|
+
* 삭제 (서버는 POST /delete/:seq 로만 처리)
|
|
212
|
+
*
|
|
213
|
+
* @param bool $hard true 이면 하드(물리) 삭제. false(기본) 소프트 삭제 (rollback 복원 가능)
|
|
214
|
+
* @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동)
|
|
215
|
+
* @param bool $skipHooks true 이면 before/after_delete 훅 미실행
|
|
216
|
+
*/
|
|
217
|
+
public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false, bool $skipHooks = false): array
|
|
127
218
|
{
|
|
128
|
-
$
|
|
219
|
+
$queryParams = [];
|
|
220
|
+
if ($hard) {
|
|
221
|
+
$queryParams[] = 'hard=true';
|
|
222
|
+
}
|
|
223
|
+
if ($skipHooks) {
|
|
224
|
+
$queryParams[] = 'skipHooks=true';
|
|
225
|
+
}
|
|
226
|
+
$q = $queryParams ? '?' . implode('&', $queryParams) : '';
|
|
129
227
|
$txId = $transactionId ?? $this->activeTxId;
|
|
130
228
|
$extra = $txId ? ['X-Transaction-ID' => $txId] : [];
|
|
131
|
-
return $this->request('
|
|
229
|
+
return $this->request('POST', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
|
|
132
230
|
}
|
|
133
231
|
|
|
134
232
|
public function history(string $entity, int $seq, int $page = 1, int $limit = 50): array
|
|
@@ -198,30 +296,44 @@ class EntityServerService
|
|
|
198
296
|
|
|
199
297
|
private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
|
|
200
298
|
{
|
|
201
|
-
|
|
202
|
-
$
|
|
203
|
-
$
|
|
204
|
-
$
|
|
205
|
-
|
|
206
|
-
$
|
|
207
|
-
|
|
208
|
-
'
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
299
|
+
// 요청 바디 결정: encryptRequests 시 POST 바디를 암호화
|
|
300
|
+
$bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
|
|
301
|
+
$bodyData = $bodyJson;
|
|
302
|
+
$contentType = 'application/json';
|
|
303
|
+
|
|
304
|
+
if (($this->encryptRequests || $this->packetEncryption) && $bodyJson !== '') {
|
|
305
|
+
$bodyData = $this->encryptPacket($bodyJson);
|
|
306
|
+
$contentType = 'application/octet-stream';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
$isHmacMode = $this->apiKey !== '' && $this->hmacSecret !== '';
|
|
310
|
+
|
|
311
|
+
$http = Http::timeout(10);
|
|
312
|
+
if ($isHmacMode) {
|
|
313
|
+
$timestamp = (string) time();
|
|
314
|
+
$nonce = (string) Str::uuid();
|
|
315
|
+
$signature = $this->sign($method, $path, $timestamp, $nonce, $bodyData);
|
|
316
|
+
$http = $http->withHeaders(array_merge([
|
|
317
|
+
'X-API-Key' => $this->apiKey,
|
|
318
|
+
'X-Timestamp' => $timestamp,
|
|
319
|
+
'X-Nonce' => $nonce,
|
|
320
|
+
'X-Signature' => $signature,
|
|
321
|
+
], $extraHeaders));
|
|
322
|
+
} else {
|
|
323
|
+
$authHeaders = $this->token !== '' ? ['Authorization' => 'Bearer ' . $this->token] : [];
|
|
324
|
+
$http = $http->withHeaders(array_merge($authHeaders, $extraHeaders));
|
|
325
|
+
}
|
|
212
326
|
|
|
213
327
|
$response = match ($method) {
|
|
214
328
|
'GET' => $http->get($this->baseUrl . $path),
|
|
215
|
-
'POST' => $http->withBody($
|
|
329
|
+
'POST' => $http->withBody($bodyData, $contentType)->post($this->baseUrl . $path),
|
|
216
330
|
'DELETE' => $http->delete($this->baseUrl . $path),
|
|
217
331
|
default => throw new \InvalidArgumentException("Unsupported method: {$method}"),
|
|
218
332
|
};
|
|
219
333
|
|
|
220
|
-
$decoded = $response->json();
|
|
221
|
-
|
|
222
334
|
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
223
|
-
$
|
|
224
|
-
if (str_contains($
|
|
335
|
+
$respContentType = $response->header('Content-Type') ?? '';
|
|
336
|
+
if (str_contains($respContentType, 'application/octet-stream')) {
|
|
225
337
|
$jsonStr = $this->decryptPacket($response->body());
|
|
226
338
|
$decoded = json_decode($jsonStr, true);
|
|
227
339
|
} else {
|
|
@@ -238,18 +350,51 @@ class EntityServerService
|
|
|
238
350
|
return $decoded;
|
|
239
351
|
}
|
|
240
352
|
|
|
353
|
+
/**
|
|
354
|
+
* 패킷 암호화 키를 유도합니다.
|
|
355
|
+
* - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
356
|
+
* - JWT 모드: SHA256(token)
|
|
357
|
+
*/
|
|
358
|
+
private function derivePacketKey(): string
|
|
359
|
+
{
|
|
360
|
+
if ($this->token !== '' && $this->hmacSecret === '') {
|
|
361
|
+
return hash('sha256', $this->token, true);
|
|
362
|
+
}
|
|
363
|
+
$salt = 'entity-server:hkdf:v1';
|
|
364
|
+
$info = 'entity-server:packet-encryption';
|
|
365
|
+
// HKDF-Extract: PRK = HMAC-SHA256(salt, IKM)
|
|
366
|
+
$prk = hash_hmac('sha256', $this->hmacSecret, $salt, true);
|
|
367
|
+
// HKDF-Expand(PRK, info, 32): T(1) = HMAC-SHA256(PRK, info || 0x01)
|
|
368
|
+
return substr(hash_hmac('sha256', $info . chr(1), $prk, true), 0, 32);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* XChaCha20-Poly1305 패킷 암호화
|
|
373
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
374
|
+
*/
|
|
375
|
+
private function encryptPacket(string $plaintext): string
|
|
376
|
+
{
|
|
377
|
+
$key = $this->derivePacketKey();
|
|
378
|
+
$magicLen = 2 + (ord($key[31]) % 14);
|
|
379
|
+
$magic = random_bytes($magicLen);
|
|
380
|
+
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
|
|
381
|
+
$ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $key);
|
|
382
|
+
return $magic . $nonce . $ct;
|
|
383
|
+
}
|
|
384
|
+
|
|
241
385
|
/**
|
|
242
386
|
* XChaCha20-Poly1305 패킷 복호화
|
|
243
387
|
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
244
|
-
* 키:
|
|
388
|
+
* 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
245
389
|
*
|
|
246
390
|
* ext-sodium 사용 (PHP 7.2+ 내장)
|
|
247
391
|
*/
|
|
248
392
|
private function decryptPacket(string $data): string
|
|
249
393
|
{
|
|
250
|
-
$key =
|
|
251
|
-
$
|
|
252
|
-
$
|
|
394
|
+
$key = $this->derivePacketKey();
|
|
395
|
+
$magicLen = 2 + (ord($key[31]) % 14);
|
|
396
|
+
$nonce = substr($data, $magicLen, 24);
|
|
397
|
+
$ciphertext = substr($data, $magicLen + 24);
|
|
253
398
|
|
|
254
399
|
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
|
|
255
400
|
if ($plaintext === false) {
|
|
@@ -258,9 +403,13 @@ class EntityServerService
|
|
|
258
403
|
return $plaintext;
|
|
259
404
|
}
|
|
260
405
|
|
|
406
|
+
/**
|
|
407
|
+
* HMAC-SHA256 서명. $body 는 JSON 스트링 또는 바이너리 암호화 페이로드 모두 지원합니다.
|
|
408
|
+
* prefix = "METHOD|path|timestamp|nonce|" 뒤에 $body 를 바로 이어 붙여 서명합니다.
|
|
409
|
+
*/
|
|
261
410
|
private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
|
|
262
411
|
{
|
|
263
|
-
$
|
|
264
|
-
return hash_hmac('sha256', $
|
|
412
|
+
$prefix = implode('|', [$method, $path, $timestamp, $nonce]) . '|';
|
|
413
|
+
return hash_hmac('sha256', $prefix . $body, $this->hmacSecret);
|
|
265
414
|
}
|
|
266
415
|
}
|
|
@@ -8,7 +8,6 @@ Entity Server 클라이언트 (Python)
|
|
|
8
8
|
ENTITY_SERVER_URL http://localhost:47200
|
|
9
9
|
ENTITY_SERVER_API_KEY your-api-key
|
|
10
10
|
ENTITY_SERVER_HMAC_SECRET your-hmac-secret
|
|
11
|
-
ENTITY_PACKET_MAGIC_LEN 4 (서버 packet_magic_len 과 동일)
|
|
12
11
|
|
|
13
12
|
사용 예:
|
|
14
13
|
es = EntityServerClient()
|
|
@@ -40,25 +39,36 @@ from typing import Any
|
|
|
40
39
|
|
|
41
40
|
import requests
|
|
42
41
|
from cryptography.hazmat.primitives.ciphers.aead import XChaCha20Poly1305
|
|
42
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
43
|
+
from cryptography.hazmat.primitives import hashes
|
|
44
|
+
|
|
45
|
+
import secrets as _secrets
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
class EntityServerClient:
|
|
46
49
|
def __init__(
|
|
47
50
|
self,
|
|
48
|
-
base_url:
|
|
49
|
-
api_key:
|
|
50
|
-
hmac_secret:
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
base_url: str = "",
|
|
52
|
+
api_key: str = "",
|
|
53
|
+
hmac_secret: str = "",
|
|
54
|
+
token: str = "",
|
|
55
|
+
timeout: int = 10,
|
|
56
|
+
encrypt_requests: bool = False,
|
|
53
57
|
) -> None:
|
|
54
|
-
self.base_url
|
|
55
|
-
self.api_key
|
|
56
|
-
self.hmac_secret
|
|
57
|
-
self.
|
|
58
|
-
self.
|
|
59
|
-
self.
|
|
58
|
+
self.base_url = (base_url or os.getenv("ENTITY_SERVER_URL", "http://localhost:47200")).rstrip("/")
|
|
59
|
+
self.api_key = api_key or os.getenv("ENTITY_SERVER_API_KEY", "")
|
|
60
|
+
self.hmac_secret = hmac_secret or os.getenv("ENTITY_SERVER_HMAC_SECRET", "")
|
|
61
|
+
self.token = token or os.getenv("ENTITY_SERVER_TOKEN", "")
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
self.encrypt_requests = encrypt_requests
|
|
64
|
+
self._packet_encryption: bool = False
|
|
65
|
+
self._session = requests.Session()
|
|
60
66
|
self._active_tx_id: str | None = None
|
|
61
67
|
|
|
68
|
+
def set_token(self, token: str) -> None:
|
|
69
|
+
"""JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용해야 합니다."""
|
|
70
|
+
self.token = token
|
|
71
|
+
|
|
62
72
|
# ─── 트랜잭션 ──────────────────────────────────────────────────────────────
|
|
63
73
|
|
|
64
74
|
def trans_start(self) -> str:
|
|
@@ -89,58 +99,108 @@ class EntityServerClient:
|
|
|
89
99
|
return self._request("POST", f"/v1/transaction/commit/{tx_id}")
|
|
90
100
|
|
|
91
101
|
# ─── CRUD ─────────────────────────────────────────────────────────────────
|
|
102
|
+
def check_health(self) -> dict:
|
|
103
|
+
"""서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
104
|
+
서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다."""
|
|
105
|
+
resp = self._session.get(self.base_url + "/v1/health", timeout=self.timeout)
|
|
106
|
+
data = resp.json()
|
|
107
|
+
if data.get("packet_encryption"):
|
|
108
|
+
self._packet_encryption = True
|
|
109
|
+
return data
|
|
110
|
+
def get(self, entity: str, seq: int, *, skip_hooks: bool = False) -> dict:
|
|
111
|
+
"""단건 조회. skip_hooks=True 이면 after_get 훅 미실행."""
|
|
112
|
+
q = "?skipHooks=true" if skip_hooks else ""
|
|
113
|
+
return self._request("GET", f"/v1/entity/{entity}/{seq}{q}")
|
|
114
|
+
|
|
115
|
+
def find(self, entity: str, conditions: dict, *, skip_hooks: bool = False) -> dict:
|
|
116
|
+
"""
|
|
117
|
+
조건으로 단건 조회 (POST + conditions body).
|
|
92
118
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
- conditions: index/hash/unique 필드에만 필터 조건 사용 가능
|
|
120
|
+
- skip_hooks=True 이면 after_find 훅 미실행
|
|
121
|
+
"""
|
|
122
|
+
q = "?skipHooks=true" if skip_hooks else ""
|
|
123
|
+
return self._request("POST", f"/v1/entity/{entity}/find{q}", body=conditions)
|
|
96
124
|
|
|
97
|
-
def list(
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
def list(
|
|
126
|
+
self,
|
|
127
|
+
entity: str,
|
|
128
|
+
page: int = 1,
|
|
129
|
+
limit: int = 20,
|
|
130
|
+
order_by: str | None = None,
|
|
131
|
+
order_dir: str | None = None,
|
|
132
|
+
fields: list[str] | None = None,
|
|
133
|
+
conditions: dict | None = None,
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""
|
|
136
|
+
목록 조회 (POST + conditions body)
|
|
137
|
+
|
|
138
|
+
- fields: 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름). ['*'] 지정 시 전체 필드 반환
|
|
139
|
+
- conditions: index/hash/unique 필드에만 필터 조건 사용 가능
|
|
140
|
+
"""
|
|
141
|
+
query_params: dict = {"page": page, "limit": limit}
|
|
100
142
|
if order_by:
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
query_params["order_by"] = f"-{order_by}" if order_dir == "DESC" else order_by
|
|
144
|
+
if fields:
|
|
145
|
+
query_params["fields"] = ",".join(fields)
|
|
146
|
+
return self._request("POST", f"/v1/entity/{entity}/list", body=conditions or {}, params=query_params)
|
|
103
147
|
|
|
104
|
-
def count(self, entity: str) -> dict:
|
|
105
|
-
"""건수
|
|
106
|
-
return self._request("
|
|
148
|
+
def count(self, entity: str, conditions: dict | None = None) -> dict:
|
|
149
|
+
"""건수 조회. conditions 는 list() 와 동일한 필터 규칙."""
|
|
150
|
+
return self._request("POST", f"/v1/entity/{entity}/count", body=conditions or {})
|
|
107
151
|
|
|
108
152
|
def query(
|
|
109
153
|
self,
|
|
110
|
-
entity:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
limit:
|
|
114
|
-
order_by: str | None = None,
|
|
154
|
+
entity: str,
|
|
155
|
+
sql: str,
|
|
156
|
+
params: list | None = None,
|
|
157
|
+
limit: int | None = None,
|
|
115
158
|
) -> dict:
|
|
116
159
|
"""
|
|
117
|
-
|
|
118
|
-
|
|
160
|
+
커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
161
|
+
|
|
162
|
+
- SELECT 쿼리만 허용. SELECT * 불가. 최대 1000건.
|
|
163
|
+
- 사용자 입력은 반드시 params 로 바인딩 (SQL Injection 방지)
|
|
164
|
+
|
|
165
|
+
예::
|
|
166
|
+
es.query(
|
|
167
|
+
'order',
|
|
168
|
+
'SELECT o.seq, u.name FROM order o JOIN account u ON u.data_seq = o.account_seq WHERE o.status = ?',
|
|
169
|
+
params=['pending'],
|
|
170
|
+
limit=100,
|
|
171
|
+
)
|
|
119
172
|
"""
|
|
120
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
return self._request("POST", f"/v1/entity/{entity}/query", body=
|
|
173
|
+
body: dict[str, Any] = {"sql": sql, "params": params or []}
|
|
174
|
+
if limit is not None:
|
|
175
|
+
body["limit"] = limit
|
|
176
|
+
return self._request("POST", f"/v1/entity/{entity}/query", body=body)
|
|
124
177
|
|
|
125
|
-
def submit(self, entity: str, data: dict, *, transaction_id: str | None = None) -> dict:
|
|
178
|
+
def submit(self, entity: str, data: dict, *, transaction_id: str | None = None, skip_hooks: bool = False) -> dict:
|
|
126
179
|
"""
|
|
127
180
|
생성 또는 수정
|
|
128
181
|
data에 'seq' 포함 시 수정, 없으면 생성
|
|
129
182
|
:param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
183
|
+
:param skip_hooks: True 이면 before/after_insert, before/after_update 훅 미실행
|
|
130
184
|
"""
|
|
131
185
|
tx_id = transaction_id or self._active_tx_id
|
|
132
186
|
extra = {"X-Transaction-ID": tx_id} if tx_id else {}
|
|
133
|
-
|
|
187
|
+
q = "?skipHooks=true" if skip_hooks else ""
|
|
188
|
+
return self._request("POST", f"/v1/entity/{entity}/submit{q}", body=data, extra_headers=extra)
|
|
134
189
|
|
|
135
|
-
def delete(self, entity: str, seq: int, *, transaction_id: str | None = None, hard: bool = False) -> dict:
|
|
136
|
-
"""
|
|
190
|
+
def delete(self, entity: str, seq: int, *, transaction_id: str | None = None, hard: bool = False, skip_hooks: bool = False) -> dict:
|
|
191
|
+
"""
|
|
192
|
+
삭제
|
|
137
193
|
:param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
138
|
-
:param hard: True 시 하드 삭제
|
|
194
|
+
:param hard: True 시 하드(물리) 삭제. False(기본) 이면 소프트 삭제 (rollback 으로 복원 가능)
|
|
195
|
+
:param skip_hooks: True 이면 before/after_delete 훅 미실행
|
|
139
196
|
"""
|
|
140
|
-
|
|
197
|
+
query_parts: list[str] = []
|
|
198
|
+
if hard: query_parts.append("hard=true")
|
|
199
|
+
if skip_hooks: query_parts.append("skipHooks=true")
|
|
200
|
+
q = "?" + "&".join(query_parts) if query_parts else ""
|
|
141
201
|
tx_id = transaction_id or self._active_tx_id
|
|
142
202
|
extra = {"X-Transaction-ID": tx_id} if tx_id else {}
|
|
143
|
-
return self._request("
|
|
203
|
+
return self._request("POST", f"/v1/entity/{entity}/delete/{seq}{q}", extra_headers=extra)
|
|
144
204
|
|
|
145
205
|
def history(self, entity: str, seq: int, page: int = 1, limit: int = 50) -> dict:
|
|
146
206
|
"""변경 이력 조회"""
|
|
@@ -273,18 +333,35 @@ class EntityServerClient:
|
|
|
273
333
|
else:
|
|
274
334
|
signed_path = path
|
|
275
335
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
336
|
+
# 요청 바디 결정: encrypt_requests 시 POST 바디를 암호화
|
|
337
|
+
body_data: bytes | None = None # 네트워크로 보낼 바이트
|
|
338
|
+
body_for_sign: bytes = b"" # HMAC 서명 대상
|
|
339
|
+
content_type_header = "application/json"
|
|
340
|
+
|
|
341
|
+
if body is not None:
|
|
342
|
+
json_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
343
|
+
if self.encrypt_requests or self._packet_encryption:
|
|
344
|
+
encrypted = self._encrypt_packet(json_bytes)
|
|
345
|
+
body_data = encrypted
|
|
346
|
+
body_for_sign = encrypted
|
|
347
|
+
content_type_header = "application/octet-stream"
|
|
348
|
+
else:
|
|
349
|
+
body_data = json_bytes
|
|
350
|
+
body_for_sign = json_bytes
|
|
351
|
+
|
|
352
|
+
is_hmac_mode = bool(self.api_key and self.hmac_secret)
|
|
353
|
+
|
|
354
|
+
headers: dict = {"Content-Type": content_type_header}
|
|
355
|
+
if is_hmac_mode:
|
|
356
|
+
timestamp = str(int(time.time()))
|
|
357
|
+
nonce = str(uuid.uuid4())
|
|
358
|
+
signature = self._sign(method, signed_path, timestamp, nonce, body_for_sign)
|
|
359
|
+
headers["X-API-Key"] = self.api_key
|
|
360
|
+
headers["X-Timestamp"] = timestamp
|
|
361
|
+
headers["X-Nonce"] = nonce
|
|
362
|
+
headers["X-Signature"] = signature
|
|
363
|
+
elif self.token:
|
|
364
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
288
365
|
if extra_headers:
|
|
289
366
|
headers.update(extra_headers)
|
|
290
367
|
|
|
@@ -293,14 +370,14 @@ class EntityServerClient:
|
|
|
293
370
|
method=method,
|
|
294
371
|
url=url,
|
|
295
372
|
headers=headers,
|
|
296
|
-
data=
|
|
373
|
+
data=body_data,
|
|
297
374
|
params=params,
|
|
298
375
|
timeout=self.timeout,
|
|
299
376
|
)
|
|
300
377
|
|
|
301
378
|
# 패킷 암호화 응답: application/octet-stream → 복호화
|
|
302
|
-
|
|
303
|
-
if "application/octet-stream" in
|
|
379
|
+
ct = resp.headers.get("Content-Type", "")
|
|
380
|
+
if "application/octet-stream" in ct:
|
|
304
381
|
data = json.loads(self._decrypt_packet(resp.content))
|
|
305
382
|
else:
|
|
306
383
|
data = resp.json()
|
|
@@ -310,22 +387,58 @@ class EntityServerClient:
|
|
|
310
387
|
|
|
311
388
|
return data
|
|
312
389
|
|
|
390
|
+
def _derive_packet_key(self) -> bytes:
|
|
391
|
+
"""
|
|
392
|
+
패킷 암호화 키를 유도합니다.
|
|
393
|
+
- HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
394
|
+
- JWT 모드: SHA256(token)
|
|
395
|
+
"""
|
|
396
|
+
if self.token and not self.hmac_secret:
|
|
397
|
+
return hashlib.sha256(self.token.encode("utf-8")).digest()
|
|
398
|
+
h = HKDF(
|
|
399
|
+
algorithm=hashes.SHA256(),
|
|
400
|
+
length=32,
|
|
401
|
+
salt=b"entity-server:hkdf:v1",
|
|
402
|
+
info=b"entity-server:packet-encryption",
|
|
403
|
+
)
|
|
404
|
+
return h.derive(self.hmac_secret.encode("utf-8"))
|
|
405
|
+
|
|
406
|
+
def _encrypt_packet(self, plaintext: bytes) -> bytes:
|
|
407
|
+
"""
|
|
408
|
+
XChaCha20-Poly1305 패킷 암호화
|
|
409
|
+
포맷: [magic:magic_len][nonce:24][ciphertext+tag]
|
|
410
|
+
magic_len: 2 + key[31] % 14 (패킷 키에서 자동 파생)
|
|
411
|
+
"""
|
|
412
|
+
key = self._derive_packet_key()
|
|
413
|
+
magic_len = 2 + key[31] % 14
|
|
414
|
+
magic = _secrets.token_bytes(magic_len)
|
|
415
|
+
nonce = _secrets.token_bytes(24)
|
|
416
|
+
ct = XChaCha20Poly1305(key).encrypt(nonce, plaintext, b"")
|
|
417
|
+
return magic + nonce + ct
|
|
418
|
+
|
|
313
419
|
def _decrypt_packet(self, data: bytes) -> bytes:
|
|
314
420
|
"""
|
|
315
421
|
XChaCha20-Poly1305 패킷 복호화
|
|
316
422
|
포맷: [magic:magic_len][nonce:24][ciphertext+tag]
|
|
317
|
-
키:
|
|
423
|
+
키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
318
424
|
"""
|
|
319
|
-
key =
|
|
320
|
-
|
|
321
|
-
|
|
425
|
+
key = self._derive_packet_key()
|
|
426
|
+
magic_len = 2 + key[31] % 14
|
|
427
|
+
nonce = data[magic_len : magic_len + 24]
|
|
428
|
+
ciphertext = data[magic_len + 24 :]
|
|
322
429
|
return XChaCha20Poly1305(key).decrypt(nonce, ciphertext, b"")
|
|
323
430
|
|
|
324
|
-
def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: str) -> str:
|
|
325
|
-
"""
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
431
|
+
def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: bytes | str) -> str:
|
|
432
|
+
"""
|
|
433
|
+
HMAC-SHA256 서명.
|
|
434
|
+
body 는 bytes(암호화된 바디 포함) 또는 str 모두 지원합니다.
|
|
435
|
+
"""
|
|
436
|
+
prefix = f"{method}|{path}|{timestamp}|{nonce}|".encode("utf-8")
|
|
437
|
+
h = hmac.new(key=self.hmac_secret.encode("utf-8"), digestmod=hashlib.sha256)
|
|
438
|
+
h.update(prefix)
|
|
439
|
+
if isinstance(body, bytes):
|
|
440
|
+
if body:
|
|
441
|
+
h.update(body)
|
|
442
|
+
elif body:
|
|
443
|
+
h.update(body.encode("utf-8"))
|
|
444
|
+
return h.hexdigest()
|