create-entity-server 0.0.9 → 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.
Files changed (56) hide show
  1. package/bin/create.js +26 -8
  2. package/package.json +1 -1
  3. package/template/.env.example +20 -3
  4. package/template/configs/database.json +173 -10
  5. package/template/configs/jwt.json +1 -0
  6. package/template/configs/oauth.json +37 -0
  7. package/template/configs/push.json +26 -0
  8. package/template/entities/Account/account_audit.json +4 -5
  9. package/template/entities/README.md +4 -4
  10. package/template/entities/{Auth → System/Auth}/account.json +0 -14
  11. package/template/entities/System/system_audit_log.json +14 -8
  12. package/template/samples/README.md +43 -21
  13. package/template/samples/browser/entity-server-client.js +453 -0
  14. package/template/samples/browser/example.html +498 -0
  15. package/template/samples/entities/01_basic_fields.json +39 -0
  16. package/template/samples/entities/02_types_and_defaults.json +67 -0
  17. package/template/samples/entities/03_hash_and_unique.json +33 -0
  18. package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
  19. package/template/samples/entities/05_cache.json +55 -0
  20. package/template/samples/entities/06_history_and_hard_delete.json +60 -0
  21. package/template/samples/entities/07_license_scope.json +52 -0
  22. package/template/samples/entities/08_hook_sql.json +52 -0
  23. package/template/samples/entities/09_hook_entity.json +65 -0
  24. package/template/samples/entities/10_hook_submit_delete.json +78 -0
  25. package/template/samples/entities/11_hook_webhook.json +84 -0
  26. package/template/samples/entities/12_hook_push.json +73 -0
  27. package/template/samples/entities/13_read_only.json +54 -0
  28. package/template/samples/entities/14_optimistic_lock.json +29 -0
  29. package/template/samples/entities/15_reset_defaults.json +94 -0
  30. package/template/samples/entities/16_isolated_license.json +62 -0
  31. package/template/samples/entities/README.md +91 -0
  32. package/template/samples/flutter/lib/entity_server_client.dart +261 -48
  33. package/template/samples/java/EntityServerClient.java +325 -61
  34. package/template/samples/java/EntityServerExample.java +4 -3
  35. package/template/samples/kotlin/EntityServerClient.kt +261 -45
  36. package/template/samples/node/src/EntityServerClient.js +348 -59
  37. package/template/samples/node/src/example.js +9 -9
  38. package/template/samples/php/ci4/Config/EntityServer.php +14 -0
  39. package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
  40. package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
  41. package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
  42. package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
  43. package/template/samples/python/entity_server.py +287 -68
  44. package/template/samples/python/example.py +7 -6
  45. package/template/samples/react/src/example.tsx +41 -25
  46. package/template/samples/swift/EntityServerClient.swift +248 -37
  47. package/template/scripts/normalize-entities.sh +10 -10
  48. package/template/scripts/run.ps1 +12 -3
  49. package/template/scripts/run.sh +120 -37
  50. package/template/scripts/update-server.ps1 +160 -4
  51. package/template/scripts/update-server.sh +132 -4
  52. package/template/samples/react/src/api/entityServerClient.ts +0 -290
  53. package/template/samples/react/src/hooks/useEntity.ts +0 -105
  54. /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
  55. /package/template/entities/{Auth → System/Auth}/license.json +0 -0
  56. /package/template/entities/{Auth → System/Auth}/rbac_roles.json +0 -0
@@ -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: str = "",
49
- api_key: str = "",
50
- hmac_secret: str = "",
51
- timeout: int = 10,
52
- magic_len: int = 4,
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 = (base_url or os.getenv("ENTITY_SERVER_URL", "http://localhost:47200")).rstrip("/")
55
- self.api_key = api_key or os.getenv("ENTITY_SERVER_API_KEY", "")
56
- self.hmac_secret = hmac_secret or os.getenv("ENTITY_SERVER_HMAC_SECRET", "")
57
- self.timeout = timeout
58
- self.magic_len = int(os.getenv("ENTITY_PACKET_MAGIC_LEN", magic_len))
59
- self._session = requests.Session()
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).
118
+
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)
92
124
 
93
- def get(self, entity: str, seq: int) -> dict:
94
- """단건 조회"""
95
- return self._request("GET", f"/v1/entity/{entity}/{seq}")
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)
96
137
 
97
- def list(self, entity: str, page: int = 1, limit: int = 20, order_by: str | None = None) -> dict:
98
- """목록 조회"""
99
- params: dict = {"page": page, "limit": limit}
138
+ - fields: 미지정 인덱스 필드만 반환 (기본, 가장 빠름). ['*'] 지정 전체 필드 반환
139
+ - conditions: index/hash/unique 필드에만 필터 조건 사용 가능
140
+ """
141
+ query_params: dict = {"page": page, "limit": limit}
100
142
  if order_by:
101
- params["order_by"] = order_by
102
- return self._request("GET", f"/v1/entity/{entity}/list", params=params)
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("GET", f"/v1/entity/{entity}/count")
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: str,
111
- filter: list[dict] | None = None,
112
- page: int = 1,
113
- limit: int = 20,
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
- filter 예: [{"field": "status", "op": "eq", "value": "active"}]
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
- params: dict = {"page": page, "limit": limit}
121
- if order_by:
122
- params["order_by"] = order_by
123
- return self._request("POST", f"/v1/entity/{entity}/query", body=filter or [], params=params)
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
- return self._request("POST", f"/v1/entity/{entity}/submit", body=data, extra_headers=extra)
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
- params = {"hard": "true"} if hard else {}
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("DELETE", f"/v1/entity/{entity}/delete/{seq}", params=params, extra_headers=extra)
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
  """변경 이력 조회"""
@@ -150,6 +210,112 @@ class EntityServerClient:
150
210
  """history seq 단위 롤백 (단건)"""
151
211
  return self._request("POST", f"/v1/entity/{entity}/rollback/{history_seq}")
152
212
 
213
+ def push(self, push_entity: str, payload: dict, *, transaction_id: str | None = None) -> dict:
214
+ """푸시 발송 트리거 엔티티에 submit합니다."""
215
+ return self.submit(push_entity, payload, transaction_id=transaction_id)
216
+
217
+ def push_log_list(self, page: int = 1, limit: int = 20, order_by: str | None = None) -> dict:
218
+ """push_log 목록 조회 헬퍼"""
219
+ return self.list("push_log", page=page, limit=limit, order_by=order_by)
220
+
221
+ def register_push_device(
222
+ self,
223
+ account_seq: int,
224
+ device_id: str,
225
+ push_token: str,
226
+ *,
227
+ platform: str | None = None,
228
+ device_type: str | None = None,
229
+ browser: str | None = None,
230
+ browser_version: str | None = None,
231
+ push_enabled: bool = True,
232
+ transaction_id: str | None = None,
233
+ ) -> dict:
234
+ """account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드)"""
235
+ payload: dict[str, Any] = {
236
+ "id": device_id,
237
+ "account_seq": account_seq,
238
+ "push_token": push_token,
239
+ "push_enabled": push_enabled,
240
+ }
241
+ if platform:
242
+ payload["platform"] = platform
243
+ if device_type:
244
+ payload["device_type"] = device_type
245
+ if browser:
246
+ payload["browser"] = browser
247
+ if browser_version:
248
+ payload["browser_version"] = browser_version
249
+ return self.submit("account_device", payload, transaction_id=transaction_id)
250
+
251
+ def update_push_device_token(
252
+ self,
253
+ device_seq: int,
254
+ push_token: str,
255
+ *,
256
+ push_enabled: bool = True,
257
+ transaction_id: str | None = None,
258
+ ) -> dict:
259
+ """account_device.seq 기준 push_token 갱신 헬퍼"""
260
+ return self.submit(
261
+ "account_device",
262
+ {
263
+ "seq": device_seq,
264
+ "push_token": push_token,
265
+ "push_enabled": push_enabled,
266
+ },
267
+ transaction_id=transaction_id,
268
+ )
269
+
270
+ def disable_push_device(
271
+ self,
272
+ device_seq: int,
273
+ *,
274
+ transaction_id: str | None = None,
275
+ ) -> dict:
276
+ """account_device.seq 기준 푸시 수신 비활성화 헬퍼"""
277
+ return self.submit(
278
+ "account_device",
279
+ {
280
+ "seq": device_seq,
281
+ "push_enabled": False,
282
+ },
283
+ transaction_id=transaction_id,
284
+ )
285
+
286
+ def read_request_body(
287
+ self,
288
+ raw_body: bytes | str | None,
289
+ content_type: str = "application/json",
290
+ *,
291
+ require_encrypted: bool = False,
292
+ ) -> dict:
293
+ """요청 본문을 읽어 JSON으로 반환합니다.
294
+ - application/octet-stream: 암호 패킷 복호화
295
+ - 그 외: 평문 JSON 파싱
296
+ """
297
+ lowered = (content_type or "").lower()
298
+ is_encrypted = "application/octet-stream" in lowered
299
+
300
+ if require_encrypted and not is_encrypted:
301
+ raise RuntimeError(
302
+ "Encrypted request required: Content-Type must be application/octet-stream"
303
+ )
304
+
305
+ if is_encrypted:
306
+ if raw_body in (None, b"", ""):
307
+ raise RuntimeError("Encrypted request body is empty")
308
+
309
+ packet = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
310
+ return json.loads(self._decrypt_packet(packet))
311
+
312
+ if raw_body in (None, b"", ""):
313
+ return {}
314
+
315
+ if isinstance(raw_body, bytes):
316
+ return json.loads(raw_body.decode("utf-8"))
317
+ return json.loads(raw_body)
318
+
153
319
  # ─── 내부 ─────────────────────────────────────────────────────────────────
154
320
 
155
321
  def _request(
@@ -167,18 +333,35 @@ class EntityServerClient:
167
333
  else:
168
334
  signed_path = path
169
335
 
170
- body_str = json.dumps(body, ensure_ascii=False) if body is not None else ""
171
- timestamp = str(int(time.time()))
172
- nonce = str(uuid.uuid4())
173
- signature = self._sign(method, signed_path, timestamp, nonce, body_str)
174
-
175
- headers: dict = {
176
- "Content-Type": "application/json",
177
- "X-API-Key": self.api_key,
178
- "X-Timestamp": timestamp,
179
- "X-Nonce": nonce,
180
- "X-Signature": signature,
181
- }
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}"
182
365
  if extra_headers:
183
366
  headers.update(extra_headers)
184
367
 
@@ -187,14 +370,14 @@ class EntityServerClient:
187
370
  method=method,
188
371
  url=url,
189
372
  headers=headers,
190
- data=body_str.encode("utf-8") if body_str else None,
373
+ data=body_data,
191
374
  params=params,
192
375
  timeout=self.timeout,
193
376
  )
194
377
 
195
378
  # 패킷 암호화 응답: application/octet-stream → 복호화
196
- content_type = resp.headers.get("Content-Type", "")
197
- if "application/octet-stream" in content_type:
379
+ ct = resp.headers.get("Content-Type", "")
380
+ if "application/octet-stream" in ct:
198
381
  data = json.loads(self._decrypt_packet(resp.content))
199
382
  else:
200
383
  data = resp.json()
@@ -204,22 +387,58 @@ class EntityServerClient:
204
387
 
205
388
  return data
206
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
+
207
419
  def _decrypt_packet(self, data: bytes) -> bytes:
208
420
  """
209
421
  XChaCha20-Poly1305 패킷 복호화
210
422
  포맷: [magic:magic_len][nonce:24][ciphertext+tag]
211
- 키: sha256(hmac_secret)
423
+ 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
212
424
  """
213
- key = hashlib.sha256(self.hmac_secret.encode("utf-8")).digest()
214
- nonce = data[self.magic_len : self.magic_len + 24]
215
- ciphertext = data[self.magic_len + 24 :]
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 :]
216
429
  return XChaCha20Poly1305(key).decrypt(nonce, ciphertext, b"")
217
430
 
218
- def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: str) -> str:
219
- """HMAC-SHA256 서명"""
220
- payload = "|".join([method, path, timestamp, nonce, body])
221
- return hmac.new(
222
- key=self.hmac_secret.encode("utf-8"),
223
- msg=payload.encode("utf-8"),
224
- digestmod=hashlib.sha256,
225
- ).hexdigest()
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()
@@ -15,7 +15,7 @@ es = EntityServerClient()
15
15
 
16
16
  # 목록 조회
17
17
  result = es.list("product", page=1, limit=10)
18
- print(f"List: {len(result.get('data', []))} items")
18
+ print(f"List: {result.get('data', {}).get('total', 0)} items")
19
19
 
20
20
  # 생성
21
21
  created = es.submit("product", {
@@ -34,16 +34,17 @@ print(f"Get: {item['data']['name']}")
34
34
  es.submit("product", {"seq": seq, "price": 39000})
35
35
  print("Updated")
36
36
 
37
- # 필터 검색
37
+ # 커스텀 SQL 검색
38
38
  results = es.query("product",
39
- filter=[{"field": "category", "op": "eq", "value": "peripherals"}],
40
- page=1, limit=5,
39
+ sql="SELECT seq, name, category FROM product WHERE category = ?",
40
+ params=["peripherals"],
41
+ limit=5,
41
42
  )
42
- print(f"Query: {len(results.get('data', []))} results")
43
+ print(f"Query: {len(results.get('data', {}).get('items', []))} results")
43
44
 
44
45
  # 이력 조회
45
46
  hist = es.history("product", seq)
46
- print(f"History: {len(hist.get('data', []))} entries")
47
+ print(f"History: {hist.get('data', {}).get('total', 0)} entries")
47
48
 
48
49
  # 삭제
49
50
  es.delete("product", seq)
@@ -4,16 +4,15 @@
4
4
  * 설정:
5
5
  * 1. .env 에 VITE_ENTITY_SERVER_URL=http://localhost:47200 추가
6
6
  * 2. 로그인 시 entityServer.login(email, password) 호출
7
- * 3. 이후 훅으로 데이터 조회/수정
7
+ * 3. 이후 client 메서드로 데이터 조회/수정
8
8
  */
9
9
 
10
- import { useState } from "react";
11
- import {
12
- useEntityDelete,
13
- useEntityGet,
14
- useEntityList,
15
- useEntitySubmit,
16
- } from "./hooks/useEntity";
10
+ // @ts-ignore
11
+ import { useEffect, useState } from "react";
12
+ // @ts-ignore
13
+ import { useEntityServer } from "entity-server-client/react";
14
+ // @ts-ignore
15
+ import type { EntityListResult } from "entity-server-client";
17
16
 
18
17
  interface Product {
19
18
  seq: number;
@@ -25,27 +24,35 @@ interface Product {
25
24
  // ─── 목록 컴포넌트 ─────────────────────────────────────────────────────────
26
25
 
27
26
  export function ProductList() {
27
+ const { client, isPending, error, del } = useEntityServer();
28
28
  const [page, setPage] = useState(1);
29
- const { data, isLoading, error } = useEntityList<Product>("product", {
30
- page,
31
- limit: 20,
32
- });
33
- const deleteMut = useEntityDelete("product");
29
+ const [result, setResult] = useState<EntityListResult<Product> | null>(
30
+ null,
31
+ );
32
+ const [loading, setLoading] = useState(false);
33
+
34
+ useEffect(() => {
35
+ setLoading(true);
36
+ client
37
+ .list<Product>("product", { page, limit: 20 })
38
+ .then((r) => setResult(r.data))
39
+ .finally(() => setLoading(false));
40
+ }, [client, page]);
34
41
 
35
- if (isLoading) return <p>로딩 중...</p>;
36
- if (error) return <p>에러: {(error as Error).message}</p>;
42
+ if (loading) return <p>로딩 중...</p>;
43
+ if (error) return <p>에러: {error.message}</p>;
37
44
 
38
45
  return (
39
46
  <div>
40
- <h2>상품 목록 ({data?.total ?? 0}건)</h2>
47
+ <h2>상품 목록 ({result?.total ?? 0}건)</h2>
41
48
  <ul>
42
- {data?.data.map((item) => (
49
+ {result?.items.map((item) => (
43
50
  <li key={item.seq}>
44
51
  [{item.seq}] {item.name} — {item.price.toLocaleString()}
45
52
 
46
53
  <button
47
- onClick={() => deleteMut.mutate(item.seq)}
48
- disabled={deleteMut.isPending}
54
+ onClick={() => del("product", item.seq)}
55
+ disabled={isPending}
49
56
  >
50
57
  삭제
51
58
  </button>
@@ -67,10 +74,19 @@ export function ProductList() {
67
74
  // ─── 단건 조회 컴포넌트 ─────────────────────────────────────────────────────
68
75
 
69
76
  export function ProductDetail({ seq }: { seq: number }) {
70
- const { data, isLoading } = useEntityGet<Product>("product", seq);
77
+ const { client } = useEntityServer();
78
+ const [item, setItem] = useState<Product | null>(null);
79
+ const [loading, setLoading] = useState(true);
80
+
81
+ useEffect(() => {
82
+ client
83
+ .get<Product>("product", seq)
84
+ .then((r) => setItem(r.data))
85
+ .catch(() => setItem(null))
86
+ .finally(() => setLoading(false));
87
+ }, [client, seq]);
71
88
 
72
- if (isLoading) return <p>로딩 중...</p>;
73
- const item = data?.data;
89
+ if (loading) return <p>로딩 중...</p>;
74
90
  if (!item) return <p>상품을 찾을 수 없습니다.</p>;
75
91
 
76
92
  return (
@@ -85,13 +101,13 @@ export function ProductDetail({ seq }: { seq: number }) {
85
101
  // ─── 생성/수정 폼 컴포넌트 ──────────────────────────────────────────────────
86
102
 
87
103
  export function ProductForm({ seq }: { seq?: number }) {
88
- const submitMut = useEntitySubmit("product");
104
+ const { submit, isPending } = useEntityServer();
89
105
  const [form, setForm] = useState({ name: "", price: 0, category: "" });
90
106
 
91
107
  const handleSubmit = async (e: React.FormEvent) => {
92
108
  e.preventDefault();
93
109
  // seq 있으면 수정, 없으면 생성
94
- await submitMut.mutateAsync(seq ? { ...form, seq } : form);
110
+ await submit("product", seq ? { ...form, seq } : form);
95
111
  alert(seq ? "수정 완료" : "등록 완료");
96
112
  };
97
113
 
@@ -119,7 +135,7 @@ export function ProductForm({ seq }: { seq?: number }) {
119
135
  setForm((f) => ({ ...f, category: e.target.value }))
120
136
  }
121
137
  />
122
- <button type="submit" disabled={submitMut.isPending}>
138
+ <button type="submit" disabled={isPending}>
123
139
  {seq ? "수정" : "등록"}
124
140
  </button>
125
141
  </form>